claude-usage-dashboard 1.3.4 → 1.3.6
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/bin/cli.js +2 -20
- package/package.json +40 -40
- package/public/css/style.css +265 -265
- package/public/index.html +108 -108
- package/public/js/api.js +16 -16
- package/public/js/app.js +273 -273
- package/public/js/charts/cache-efficiency.js +29 -29
- package/public/js/charts/cost-comparison.js +39 -39
- package/public/js/charts/model-distribution.js +56 -56
- package/public/js/charts/project-distribution.js +103 -103
- package/public/js/charts/session-stats.js +117 -117
- package/public/js/charts/token-trend.js +357 -357
- package/public/js/components/date-picker.js +35 -35
- package/public/js/components/plan-selector.js +57 -57
- package/server/aggregator.js +147 -147
- package/server/index.js +33 -33
- package/server/parser.js +109 -109
- package/server/pricing.js +52 -52
- package/server/routes/api.js +127 -127
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
export function initDatePicker(container, onChange) {
|
|
2
|
-
const today = new Date();
|
|
3
|
-
const thirtyDaysAgo = new Date(today);
|
|
4
|
-
thirtyDaysAgo.setDate(today.getDate() - 30);
|
|
5
|
-
const fmt = d => d.toISOString().slice(0, 10);
|
|
6
|
-
|
|
7
|
-
const savedFrom = localStorage.getItem('datePickerFrom') || fmt(thirtyDaysAgo);
|
|
8
|
-
const savedTo = localStorage.getItem('datePickerTo') || fmt(today);
|
|
9
|
-
|
|
10
|
-
container.innerHTML = `
|
|
11
|
-
<span>📅</span>
|
|
12
|
-
<input type="date" id="date-from" value="${savedFrom}">
|
|
13
|
-
<span>–</span>
|
|
14
|
-
<input type="date" id="date-to" value="${savedTo}">
|
|
15
|
-
`;
|
|
16
|
-
|
|
17
|
-
const fromInput = container.querySelector('#date-from');
|
|
18
|
-
const toInput = container.querySelector('#date-to');
|
|
19
|
-
const emitChange = () => {
|
|
20
|
-
localStorage.setItem('datePickerFrom', fromInput.value);
|
|
21
|
-
localStorage.setItem('datePickerTo', toInput.value);
|
|
22
|
-
onChange({ from: fromInput.value, to: toInput.value });
|
|
23
|
-
};
|
|
24
|
-
fromInput.addEventListener('change', emitChange);
|
|
25
|
-
toInput.addEventListener('change', emitChange);
|
|
26
|
-
|
|
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
|
-
};
|
|
35
|
-
}
|
|
1
|
+
export function initDatePicker(container, onChange) {
|
|
2
|
+
const today = new Date();
|
|
3
|
+
const thirtyDaysAgo = new Date(today);
|
|
4
|
+
thirtyDaysAgo.setDate(today.getDate() - 30);
|
|
5
|
+
const fmt = d => d.toISOString().slice(0, 10);
|
|
6
|
+
|
|
7
|
+
const savedFrom = localStorage.getItem('datePickerFrom') || fmt(thirtyDaysAgo);
|
|
8
|
+
const savedTo = localStorage.getItem('datePickerTo') || fmt(today);
|
|
9
|
+
|
|
10
|
+
container.innerHTML = `
|
|
11
|
+
<span>📅</span>
|
|
12
|
+
<input type="date" id="date-from" value="${savedFrom}">
|
|
13
|
+
<span>–</span>
|
|
14
|
+
<input type="date" id="date-to" value="${savedTo}">
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const fromInput = container.querySelector('#date-from');
|
|
18
|
+
const toInput = container.querySelector('#date-to');
|
|
19
|
+
const emitChange = () => {
|
|
20
|
+
localStorage.setItem('datePickerFrom', fromInput.value);
|
|
21
|
+
localStorage.setItem('datePickerTo', toInput.value);
|
|
22
|
+
onChange({ from: fromInput.value, to: toInput.value });
|
|
23
|
+
};
|
|
24
|
+
fromInput.addEventListener('change', emitChange);
|
|
25
|
+
toInput.addEventListener('change', emitChange);
|
|
26
|
+
|
|
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
|
+
};
|
|
35
|
+
}
|
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
const PLANS = {
|
|
2
|
-
pro: { label: 'Pro', price: 20 },
|
|
3
|
-
max5x: { label: 'Max 5x', price: 100 },
|
|
4
|
-
max20x: { label: 'Max 20x', price: 200 },
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
export function initPlanSelector(container, onChange) {
|
|
8
|
-
const saved = localStorage.getItem('selectedPlan') || 'max20x';
|
|
9
|
-
const savedPrice = localStorage.getItem('customPrice') || '';
|
|
10
|
-
let detectedPlan = null;
|
|
11
|
-
|
|
12
|
-
container.innerHTML = `
|
|
13
|
-
<select id="plan-select">
|
|
14
|
-
${Object.entries(PLANS).map(([key, p]) =>
|
|
15
|
-
`<option value="${key}" ${key === saved ? 'selected' : ''}>${p.label} ($${p.price}/mo)</option>`
|
|
16
|
-
).join('')}
|
|
17
|
-
</select>
|
|
18
|
-
<input type="number" id="custom-price" placeholder="Custom $" value="${savedPrice}" style="width:80px;display:${savedPrice ? 'inline-block' : 'none'};">
|
|
19
|
-
`;
|
|
20
|
-
|
|
21
|
-
const select = container.querySelector('#plan-select');
|
|
22
|
-
const customInput = container.querySelector('#custom-price');
|
|
23
|
-
const emitChange = () => {
|
|
24
|
-
const plan = select.value;
|
|
25
|
-
const customPrice = customInput.value ? parseFloat(customInput.value) : null;
|
|
26
|
-
localStorage.setItem('selectedPlan', plan);
|
|
27
|
-
if (customPrice) localStorage.setItem('customPrice', customInput.value);
|
|
28
|
-
else localStorage.removeItem('customPrice');
|
|
29
|
-
onChange({ plan, customPrice });
|
|
30
|
-
};
|
|
31
|
-
select.addEventListener('change', emitChange);
|
|
32
|
-
customInput.addEventListener('input', emitChange);
|
|
33
|
-
select.addEventListener('dblclick', () => {
|
|
34
|
-
customInput.style.display = customInput.style.display === 'none' ? 'inline-block' : 'none';
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
function setDetectedPlan(planKey) {
|
|
38
|
-
if (!planKey || !PLANS[planKey]) return;
|
|
39
|
-
detectedPlan = planKey;
|
|
40
|
-
// Only auto-select if user hasn't manually chosen a plan
|
|
41
|
-
if (!localStorage.getItem('selectedPlan')) {
|
|
42
|
-
select.value = planKey;
|
|
43
|
-
emitChange();
|
|
44
|
-
}
|
|
45
|
-
// Update option labels to show which is detected
|
|
46
|
-
for (const opt of select.options) {
|
|
47
|
-
const p = PLANS[opt.value];
|
|
48
|
-
const suffix = opt.value === planKey ? ' ✓' : '';
|
|
49
|
-
opt.textContent = `${p.label} ($${p.price}/mo)${suffix}`;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
getPlan: () => ({ plan: select.value, customPrice: customInput.value ? parseFloat(customInput.value) : null }),
|
|
55
|
-
setDetectedPlan,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
1
|
+
const PLANS = {
|
|
2
|
+
pro: { label: 'Pro', price: 20 },
|
|
3
|
+
max5x: { label: 'Max 5x', price: 100 },
|
|
4
|
+
max20x: { label: 'Max 20x', price: 200 },
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function initPlanSelector(container, onChange) {
|
|
8
|
+
const saved = localStorage.getItem('selectedPlan') || 'max20x';
|
|
9
|
+
const savedPrice = localStorage.getItem('customPrice') || '';
|
|
10
|
+
let detectedPlan = null;
|
|
11
|
+
|
|
12
|
+
container.innerHTML = `
|
|
13
|
+
<select id="plan-select">
|
|
14
|
+
${Object.entries(PLANS).map(([key, p]) =>
|
|
15
|
+
`<option value="${key}" ${key === saved ? 'selected' : ''}>${p.label} ($${p.price}/mo)</option>`
|
|
16
|
+
).join('')}
|
|
17
|
+
</select>
|
|
18
|
+
<input type="number" id="custom-price" placeholder="Custom $" value="${savedPrice}" style="width:80px;display:${savedPrice ? 'inline-block' : 'none'};">
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
const select = container.querySelector('#plan-select');
|
|
22
|
+
const customInput = container.querySelector('#custom-price');
|
|
23
|
+
const emitChange = () => {
|
|
24
|
+
const plan = select.value;
|
|
25
|
+
const customPrice = customInput.value ? parseFloat(customInput.value) : null;
|
|
26
|
+
localStorage.setItem('selectedPlan', plan);
|
|
27
|
+
if (customPrice) localStorage.setItem('customPrice', customInput.value);
|
|
28
|
+
else localStorage.removeItem('customPrice');
|
|
29
|
+
onChange({ plan, customPrice });
|
|
30
|
+
};
|
|
31
|
+
select.addEventListener('change', emitChange);
|
|
32
|
+
customInput.addEventListener('input', emitChange);
|
|
33
|
+
select.addEventListener('dblclick', () => {
|
|
34
|
+
customInput.style.display = customInput.style.display === 'none' ? 'inline-block' : 'none';
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function setDetectedPlan(planKey) {
|
|
38
|
+
if (!planKey || !PLANS[planKey]) return;
|
|
39
|
+
detectedPlan = planKey;
|
|
40
|
+
// Only auto-select if user hasn't manually chosen a plan
|
|
41
|
+
if (!localStorage.getItem('selectedPlan')) {
|
|
42
|
+
select.value = planKey;
|
|
43
|
+
emitChange();
|
|
44
|
+
}
|
|
45
|
+
// Update option labels to show which is detected
|
|
46
|
+
for (const opt of select.options) {
|
|
47
|
+
const p = PLANS[opt.value];
|
|
48
|
+
const suffix = opt.value === planKey ? ' ✓' : '';
|
|
49
|
+
opt.textContent = `${p.label} ($${p.price}/mo)${suffix}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
getPlan: () => ({ plan: select.value, customPrice: customInput.value ? parseFloat(customInput.value) : null }),
|
|
55
|
+
setDetectedPlan,
|
|
56
|
+
};
|
|
57
|
+
}
|
package/server/aggregator.js
CHANGED
|
@@ -1,147 +1,147 @@
|
|
|
1
|
-
import { calculateRecordCost, getModelPricing } from './pricing.js';
|
|
2
|
-
|
|
3
|
-
export function filterByDateRange(records, from, to) {
|
|
4
|
-
if (!from && !to) return records;
|
|
5
|
-
// Use local time boundaries (no Z suffix = local timezone)
|
|
6
|
-
const start = from ? new Date(from + 'T00:00:00.000').getTime() : -Infinity;
|
|
7
|
-
const end = to ? new Date(to + 'T23:59:59.999').getTime() : Infinity;
|
|
8
|
-
return records.filter(r => {
|
|
9
|
-
const t = new Date(r.timestamp).getTime();
|
|
10
|
-
return t >= start && t <= end;
|
|
11
|
-
});
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function autoGranularity(from, to) {
|
|
15
|
-
if (!from || !to) return 'daily';
|
|
16
|
-
const days = (new Date(to) - new Date(from)) / (1000 * 60 * 60 * 24);
|
|
17
|
-
if (days <= 2) return 'hourly';
|
|
18
|
-
if (days <= 14) return 'daily';
|
|
19
|
-
if (days <= 60) return 'weekly';
|
|
20
|
-
return 'monthly';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function pad(n) { return String(n).padStart(2, '0'); }
|
|
24
|
-
|
|
25
|
-
function localDateStr(d) {
|
|
26
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function bucketKey(timestamp, granularity) {
|
|
30
|
-
const d = new Date(timestamp);
|
|
31
|
-
switch (granularity) {
|
|
32
|
-
case 'hourly':
|
|
33
|
-
return `${localDateStr(d)}T${pad(d.getHours())}:00`;
|
|
34
|
-
case 'daily':
|
|
35
|
-
return localDateStr(d);
|
|
36
|
-
case 'weekly': {
|
|
37
|
-
const day = d.getDay();
|
|
38
|
-
const monday = new Date(d);
|
|
39
|
-
monday.setDate(d.getDate() - ((day + 6) % 7));
|
|
40
|
-
return localDateStr(monday);
|
|
41
|
-
}
|
|
42
|
-
case 'monthly':
|
|
43
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
|
44
|
-
default:
|
|
45
|
-
return localDateStr(d);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function aggregateByTime(records, granularity) {
|
|
50
|
-
const map = new Map();
|
|
51
|
-
for (const r of records) {
|
|
52
|
-
const key = bucketKey(r.timestamp, granularity);
|
|
53
|
-
if (!map.has(key)) {
|
|
54
|
-
map.set(key, { time: key, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_cost_usd: 0, models: {} });
|
|
55
|
-
}
|
|
56
|
-
const b = map.get(key);
|
|
57
|
-
b.input_tokens += r.input_tokens;
|
|
58
|
-
b.output_tokens += r.output_tokens;
|
|
59
|
-
b.cache_read_tokens += r.cache_read_tokens;
|
|
60
|
-
b.cache_creation_tokens += r.cache_creation_tokens;
|
|
61
|
-
b.estimated_cost_usd += calculateRecordCost(r);
|
|
62
|
-
if (!b.models[r.model]) b.models[r.model] = { input: 0, output: 0 };
|
|
63
|
-
b.models[r.model].input += r.input_tokens;
|
|
64
|
-
b.models[r.model].output += r.output_tokens;
|
|
65
|
-
}
|
|
66
|
-
return Array.from(map.values()).map(b => {
|
|
67
|
-
b.estimated_cost_usd = Math.round(b.estimated_cost_usd * 100) / 100;
|
|
68
|
-
return b;
|
|
69
|
-
}).sort((a, b) => a.time.localeCompare(b.time));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function aggregateBySession(records) {
|
|
73
|
-
const map = new Map();
|
|
74
|
-
for (const r of records) {
|
|
75
|
-
if (!map.has(r.sessionId)) {
|
|
76
|
-
map.set(r.sessionId, { sessionId: r.sessionId, project: r.project, models: new Set(), input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, startTime: r.timestamp, endTime: r.timestamp, cost: 0 });
|
|
77
|
-
}
|
|
78
|
-
const s = map.get(r.sessionId);
|
|
79
|
-
s.models.add(r.model);
|
|
80
|
-
s.input_tokens += r.input_tokens;
|
|
81
|
-
s.output_tokens += r.output_tokens;
|
|
82
|
-
s.cache_read_tokens += r.cache_read_tokens;
|
|
83
|
-
s.cache_creation_tokens += r.cache_creation_tokens;
|
|
84
|
-
s.cost += calculateRecordCost(r);
|
|
85
|
-
if (r.timestamp < s.startTime) s.startTime = r.timestamp;
|
|
86
|
-
if (r.timestamp > s.endTime) s.endTime = r.timestamp;
|
|
87
|
-
}
|
|
88
|
-
return Array.from(map.values()).map(s => ({
|
|
89
|
-
sessionId: s.sessionId, project: s.project, models: Array.from(s.models),
|
|
90
|
-
input_tokens: s.input_tokens, output_tokens: s.output_tokens,
|
|
91
|
-
cache_read_tokens: s.cache_read_tokens, cache_creation_tokens: s.cache_creation_tokens,
|
|
92
|
-
total_tokens: s.input_tokens + s.output_tokens + s.cache_read_tokens + s.cache_creation_tokens,
|
|
93
|
-
startTime: s.startTime, endTime: s.endTime,
|
|
94
|
-
duration_minutes: Math.round((new Date(s.endTime) - new Date(s.startTime)) / 60000),
|
|
95
|
-
estimated_cost_usd: Math.round(s.cost * 100) / 100,
|
|
96
|
-
}));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function aggregateByProject(records) {
|
|
100
|
-
const map = new Map();
|
|
101
|
-
for (const r of records) {
|
|
102
|
-
if (!map.has(r.project)) {
|
|
103
|
-
map.set(r.project, { name: r.project, projectDirName: r.projectDirName, total_input_tokens: 0, total_output_tokens: 0, total_cache_read: 0, total_cache_creation: 0, sessions: new Set(), cost: 0 });
|
|
104
|
-
}
|
|
105
|
-
const p = map.get(r.project);
|
|
106
|
-
p.total_input_tokens += r.input_tokens;
|
|
107
|
-
p.total_output_tokens += r.output_tokens;
|
|
108
|
-
p.total_cache_read += r.cache_read_tokens;
|
|
109
|
-
p.total_cache_creation += r.cache_creation_tokens;
|
|
110
|
-
p.sessions.add(r.sessionId);
|
|
111
|
-
p.cost += calculateRecordCost(r);
|
|
112
|
-
}
|
|
113
|
-
return Array.from(map.values()).map(p => {
|
|
114
|
-
const path = p.projectDirName ? '/' + p.projectDirName.replace(/^-/, '').replace(/-/g, '/') : '';
|
|
115
|
-
return { name: p.name, path, total_input_tokens: p.total_input_tokens, total_output_tokens: p.total_output_tokens, cache_read_tokens: p.total_cache_read, cache_creation_tokens: p.total_cache_creation, total_tokens: p.total_input_tokens + p.total_output_tokens + p.total_cache_read + p.total_cache_creation, estimated_cost_usd: Math.round(p.cost * 100) / 100, session_count: p.sessions.size };
|
|
116
|
-
}).sort((a, b) => b.total_tokens - a.total_tokens);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function aggregateByModel(records) {
|
|
120
|
-
const map = new Map();
|
|
121
|
-
for (const r of records) {
|
|
122
|
-
if (!map.has(r.model)) map.set(r.model, { id: r.model, total_tokens: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 });
|
|
123
|
-
const m = map.get(r.model);
|
|
124
|
-
m.input_tokens += r.input_tokens;
|
|
125
|
-
m.output_tokens += r.output_tokens;
|
|
126
|
-
m.cache_read_tokens += r.cache_read_tokens;
|
|
127
|
-
m.cache_creation_tokens += r.cache_creation_tokens;
|
|
128
|
-
m.total_tokens += r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_creation_tokens;
|
|
129
|
-
}
|
|
130
|
-
return Array.from(map.values()).map(m => {
|
|
131
|
-
const pricing = getModelPricing(m.id);
|
|
132
|
-
return { id: m.id, total_tokens: m.total_tokens, input_tokens: m.input_tokens, output_tokens: m.output_tokens, ...(pricing || {}) };
|
|
133
|
-
}).sort((a, b) => b.total_tokens - a.total_tokens);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function aggregateCache(records) {
|
|
137
|
-
let nonCached = 0, cacheRead = 0, cacheCreation = 0;
|
|
138
|
-
for (const r of records) { nonCached += r.input_tokens; cacheRead += r.cache_read_tokens; cacheCreation += r.cache_creation_tokens; }
|
|
139
|
-
const allInput = nonCached + cacheRead + cacheCreation;
|
|
140
|
-
return {
|
|
141
|
-
cache_read_tokens: cacheRead, cache_creation_tokens: cacheCreation,
|
|
142
|
-
non_cached_input_tokens: nonCached, total_input_tokens: allInput,
|
|
143
|
-
cache_read_rate: allInput > 0 ? Math.round((cacheRead / allInput) * 100) / 100 : 0,
|
|
144
|
-
cache_creation_rate: allInput > 0 ? Math.round((cacheCreation / allInput) * 100) / 100 : 0,
|
|
145
|
-
no_cache_rate: allInput > 0 ? Math.round((nonCached / allInput) * 100) / 100 : 0,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
1
|
+
import { calculateRecordCost, getModelPricing } from './pricing.js';
|
|
2
|
+
|
|
3
|
+
export function filterByDateRange(records, from, to) {
|
|
4
|
+
if (!from && !to) return records;
|
|
5
|
+
// Use local time boundaries (no Z suffix = local timezone)
|
|
6
|
+
const start = from ? new Date(from + 'T00:00:00.000').getTime() : -Infinity;
|
|
7
|
+
const end = to ? new Date(to + 'T23:59:59.999').getTime() : Infinity;
|
|
8
|
+
return records.filter(r => {
|
|
9
|
+
const t = new Date(r.timestamp).getTime();
|
|
10
|
+
return t >= start && t <= end;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function autoGranularity(from, to) {
|
|
15
|
+
if (!from || !to) return 'daily';
|
|
16
|
+
const days = (new Date(to) - new Date(from)) / (1000 * 60 * 60 * 24);
|
|
17
|
+
if (days <= 2) return 'hourly';
|
|
18
|
+
if (days <= 14) return 'daily';
|
|
19
|
+
if (days <= 60) return 'weekly';
|
|
20
|
+
return 'monthly';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function pad(n) { return String(n).padStart(2, '0'); }
|
|
24
|
+
|
|
25
|
+
function localDateStr(d) {
|
|
26
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bucketKey(timestamp, granularity) {
|
|
30
|
+
const d = new Date(timestamp);
|
|
31
|
+
switch (granularity) {
|
|
32
|
+
case 'hourly':
|
|
33
|
+
return `${localDateStr(d)}T${pad(d.getHours())}:00`;
|
|
34
|
+
case 'daily':
|
|
35
|
+
return localDateStr(d);
|
|
36
|
+
case 'weekly': {
|
|
37
|
+
const day = d.getDay();
|
|
38
|
+
const monday = new Date(d);
|
|
39
|
+
monday.setDate(d.getDate() - ((day + 6) % 7));
|
|
40
|
+
return localDateStr(monday);
|
|
41
|
+
}
|
|
42
|
+
case 'monthly':
|
|
43
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
|
44
|
+
default:
|
|
45
|
+
return localDateStr(d);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function aggregateByTime(records, granularity) {
|
|
50
|
+
const map = new Map();
|
|
51
|
+
for (const r of records) {
|
|
52
|
+
const key = bucketKey(r.timestamp, granularity);
|
|
53
|
+
if (!map.has(key)) {
|
|
54
|
+
map.set(key, { time: key, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_cost_usd: 0, models: {} });
|
|
55
|
+
}
|
|
56
|
+
const b = map.get(key);
|
|
57
|
+
b.input_tokens += r.input_tokens;
|
|
58
|
+
b.output_tokens += r.output_tokens;
|
|
59
|
+
b.cache_read_tokens += r.cache_read_tokens;
|
|
60
|
+
b.cache_creation_tokens += r.cache_creation_tokens;
|
|
61
|
+
b.estimated_cost_usd += calculateRecordCost(r);
|
|
62
|
+
if (!b.models[r.model]) b.models[r.model] = { input: 0, output: 0 };
|
|
63
|
+
b.models[r.model].input += r.input_tokens;
|
|
64
|
+
b.models[r.model].output += r.output_tokens;
|
|
65
|
+
}
|
|
66
|
+
return Array.from(map.values()).map(b => {
|
|
67
|
+
b.estimated_cost_usd = Math.round(b.estimated_cost_usd * 100) / 100;
|
|
68
|
+
return b;
|
|
69
|
+
}).sort((a, b) => a.time.localeCompare(b.time));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function aggregateBySession(records) {
|
|
73
|
+
const map = new Map();
|
|
74
|
+
for (const r of records) {
|
|
75
|
+
if (!map.has(r.sessionId)) {
|
|
76
|
+
map.set(r.sessionId, { sessionId: r.sessionId, project: r.project, models: new Set(), input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, startTime: r.timestamp, endTime: r.timestamp, cost: 0 });
|
|
77
|
+
}
|
|
78
|
+
const s = map.get(r.sessionId);
|
|
79
|
+
s.models.add(r.model);
|
|
80
|
+
s.input_tokens += r.input_tokens;
|
|
81
|
+
s.output_tokens += r.output_tokens;
|
|
82
|
+
s.cache_read_tokens += r.cache_read_tokens;
|
|
83
|
+
s.cache_creation_tokens += r.cache_creation_tokens;
|
|
84
|
+
s.cost += calculateRecordCost(r);
|
|
85
|
+
if (r.timestamp < s.startTime) s.startTime = r.timestamp;
|
|
86
|
+
if (r.timestamp > s.endTime) s.endTime = r.timestamp;
|
|
87
|
+
}
|
|
88
|
+
return Array.from(map.values()).map(s => ({
|
|
89
|
+
sessionId: s.sessionId, project: s.project, models: Array.from(s.models),
|
|
90
|
+
input_tokens: s.input_tokens, output_tokens: s.output_tokens,
|
|
91
|
+
cache_read_tokens: s.cache_read_tokens, cache_creation_tokens: s.cache_creation_tokens,
|
|
92
|
+
total_tokens: s.input_tokens + s.output_tokens + s.cache_read_tokens + s.cache_creation_tokens,
|
|
93
|
+
startTime: s.startTime, endTime: s.endTime,
|
|
94
|
+
duration_minutes: Math.round((new Date(s.endTime) - new Date(s.startTime)) / 60000),
|
|
95
|
+
estimated_cost_usd: Math.round(s.cost * 100) / 100,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function aggregateByProject(records) {
|
|
100
|
+
const map = new Map();
|
|
101
|
+
for (const r of records) {
|
|
102
|
+
if (!map.has(r.project)) {
|
|
103
|
+
map.set(r.project, { name: r.project, projectDirName: r.projectDirName, total_input_tokens: 0, total_output_tokens: 0, total_cache_read: 0, total_cache_creation: 0, sessions: new Set(), cost: 0 });
|
|
104
|
+
}
|
|
105
|
+
const p = map.get(r.project);
|
|
106
|
+
p.total_input_tokens += r.input_tokens;
|
|
107
|
+
p.total_output_tokens += r.output_tokens;
|
|
108
|
+
p.total_cache_read += r.cache_read_tokens;
|
|
109
|
+
p.total_cache_creation += r.cache_creation_tokens;
|
|
110
|
+
p.sessions.add(r.sessionId);
|
|
111
|
+
p.cost += calculateRecordCost(r);
|
|
112
|
+
}
|
|
113
|
+
return Array.from(map.values()).map(p => {
|
|
114
|
+
const path = p.projectDirName ? '/' + p.projectDirName.replace(/^-/, '').replace(/-/g, '/') : '';
|
|
115
|
+
return { name: p.name, path, total_input_tokens: p.total_input_tokens, total_output_tokens: p.total_output_tokens, cache_read_tokens: p.total_cache_read, cache_creation_tokens: p.total_cache_creation, total_tokens: p.total_input_tokens + p.total_output_tokens + p.total_cache_read + p.total_cache_creation, estimated_cost_usd: Math.round(p.cost * 100) / 100, session_count: p.sessions.size };
|
|
116
|
+
}).sort((a, b) => b.total_tokens - a.total_tokens);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function aggregateByModel(records) {
|
|
120
|
+
const map = new Map();
|
|
121
|
+
for (const r of records) {
|
|
122
|
+
if (!map.has(r.model)) map.set(r.model, { id: r.model, total_tokens: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 });
|
|
123
|
+
const m = map.get(r.model);
|
|
124
|
+
m.input_tokens += r.input_tokens;
|
|
125
|
+
m.output_tokens += r.output_tokens;
|
|
126
|
+
m.cache_read_tokens += r.cache_read_tokens;
|
|
127
|
+
m.cache_creation_tokens += r.cache_creation_tokens;
|
|
128
|
+
m.total_tokens += r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_creation_tokens;
|
|
129
|
+
}
|
|
130
|
+
return Array.from(map.values()).map(m => {
|
|
131
|
+
const pricing = getModelPricing(m.id);
|
|
132
|
+
return { id: m.id, total_tokens: m.total_tokens, input_tokens: m.input_tokens, output_tokens: m.output_tokens, ...(pricing || {}) };
|
|
133
|
+
}).sort((a, b) => b.total_tokens - a.total_tokens);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function aggregateCache(records) {
|
|
137
|
+
let nonCached = 0, cacheRead = 0, cacheCreation = 0;
|
|
138
|
+
for (const r of records) { nonCached += r.input_tokens; cacheRead += r.cache_read_tokens; cacheCreation += r.cache_creation_tokens; }
|
|
139
|
+
const allInput = nonCached + cacheRead + cacheCreation;
|
|
140
|
+
return {
|
|
141
|
+
cache_read_tokens: cacheRead, cache_creation_tokens: cacheCreation,
|
|
142
|
+
non_cached_input_tokens: nonCached, total_input_tokens: allInput,
|
|
143
|
+
cache_read_rate: allInput > 0 ? Math.round((cacheRead / allInput) * 100) / 100 : 0,
|
|
144
|
+
cache_creation_rate: allInput > 0 ? Math.round((cacheCreation / allInput) * 100) / 100 : 0,
|
|
145
|
+
no_cache_rate: allInput > 0 ? Math.round((nonCached / allInput) * 100) / 100 : 0,
|
|
146
|
+
};
|
|
147
|
+
}
|
package/server/index.js
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import { createRequire } from 'module';
|
|
5
|
-
import { fileURLToPath } from 'url';
|
|
6
|
-
import { createApiRouter } from './routes/api.js';
|
|
7
|
-
|
|
8
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
const require = createRequire(import.meta.url);
|
|
10
|
-
const PORT = process.env.PORT || 3000;
|
|
11
|
-
const LOG_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
12
|
-
|
|
13
|
-
// Resolve d3 via Node module resolution so it works when dependencies are hoisted (e.g. npx)
|
|
14
|
-
const d3Dir = path.join(path.dirname(require.resolve('d3')), '..', 'dist');
|
|
15
|
-
|
|
16
|
-
const app = express();
|
|
17
|
-
app.use('/lib/d3', express.static(d3Dir));
|
|
18
|
-
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
19
|
-
app.use('/api', createApiRouter(LOG_DIR));
|
|
20
|
-
|
|
21
|
-
const server = app.listen(PORT, () => {
|
|
22
|
-
console.log(`Claude Usage Dashboard running at http://localhost:${PORT}`);
|
|
23
|
-
console.log('Press Ctrl+C to stop.');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
process.on('SIGINT', () => {
|
|
27
|
-
console.log('\nShutting down...');
|
|
28
|
-
server.close(() => process.exit(0));
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
process.on('SIGTERM', () => {
|
|
32
|
-
server.close(() => process.exit(0));
|
|
33
|
-
});
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { createApiRouter } from './routes/api.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const PORT = process.env.PORT || 3000;
|
|
11
|
+
const LOG_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
12
|
+
|
|
13
|
+
// Resolve d3 via Node module resolution so it works when dependencies are hoisted (e.g. npx)
|
|
14
|
+
const d3Dir = path.join(path.dirname(require.resolve('d3')), '..', 'dist');
|
|
15
|
+
|
|
16
|
+
const app = express();
|
|
17
|
+
app.use('/lib/d3', express.static(d3Dir));
|
|
18
|
+
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
19
|
+
app.use('/api', createApiRouter(LOG_DIR));
|
|
20
|
+
|
|
21
|
+
const server = app.listen(PORT, () => {
|
|
22
|
+
console.log(`Claude Usage Dashboard running at http://localhost:${PORT}`);
|
|
23
|
+
console.log('Press Ctrl+C to stop.');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
process.on('SIGINT', () => {
|
|
27
|
+
console.log('\nShutting down...');
|
|
28
|
+
server.close(() => process.exit(0));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
process.on('SIGTERM', () => {
|
|
32
|
+
server.close(() => process.exit(0));
|
|
33
|
+
});
|