claude-usage-dashboard 1.0.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 +52 -0
- package/bin/cli.js +2 -0
- package/package.json +34 -0
- package/public/css/style.css +221 -0
- package/public/index.html +93 -0
- package/public/js/api.js +13 -0
- package/public/js/app.js +140 -0
- package/public/js/charts/cache-efficiency.js +29 -0
- package/public/js/charts/cost-comparison.js +39 -0
- package/public/js/charts/model-distribution.js +48 -0
- package/public/js/charts/project-distribution.js +92 -0
- package/public/js/charts/session-stats.js +117 -0
- package/public/js/charts/token-trend.js +113 -0
- package/public/js/components/date-picker.js +21 -0
- package/public/js/components/plan-selector.js +37 -0
- package/server/aggregator.js +147 -0
- package/server/index.js +18 -0
- package/server/parser.js +92 -0
- package/server/pricing.js +52 -0
- package/server/routes/api.js +87 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const COLORS = ['#3b82f6', '#8b5cf6', '#f59e0b', '#4ade80', '#ef4444', '#ec4899', '#06b6d4'];
|
|
2
|
+
|
|
3
|
+
function fmt(n) {
|
|
4
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
5
|
+
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
|
|
6
|
+
return n.toString();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderProjectDistribution(container, data) {
|
|
10
|
+
const el = d3.select(container);
|
|
11
|
+
el.selectAll('*').remove();
|
|
12
|
+
|
|
13
|
+
if (!data.projects || data.projects.length === 0) {
|
|
14
|
+
el.append('p').style('color', '#64748b').text('No data');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const margin = { top: 10, right: 360, bottom: 10, left: 120 };
|
|
19
|
+
const barHeight = 24;
|
|
20
|
+
const gap = 8;
|
|
21
|
+
const height = data.projects.length * (barHeight + gap) + margin.top + margin.bottom;
|
|
22
|
+
const width = container.clientWidth - margin.left - margin.right;
|
|
23
|
+
|
|
24
|
+
const svg = el.append('svg')
|
|
25
|
+
.attr('width', width + margin.left + margin.right)
|
|
26
|
+
.attr('height', height)
|
|
27
|
+
.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
|
|
28
|
+
|
|
29
|
+
const x = d3.scaleLinear().domain([0, d3.max(data.projects, d => d.total_tokens)]).range([0, width]);
|
|
30
|
+
const y = d3.scaleBand().domain(data.projects.map(d => d.name)).range([0, height - margin.top - margin.bottom]).padding(0.25);
|
|
31
|
+
|
|
32
|
+
// Project name labels
|
|
33
|
+
svg.selectAll('.project-label').data(data.projects).enter().append('text')
|
|
34
|
+
.attr('x', -8).attr('y', d => y(d.name) + y.bandwidth() / 2)
|
|
35
|
+
.attr('text-anchor', 'end').attr('dominant-baseline', 'middle')
|
|
36
|
+
.style('fill', '#e2e8f0').style('font-size', '12px').text(d => d.name);
|
|
37
|
+
|
|
38
|
+
// Stacked bars: cache_read + cache_creation + input + output
|
|
39
|
+
const cr = d => d.cache_read_tokens || 0;
|
|
40
|
+
const cc = d => d.cache_creation_tokens || 0;
|
|
41
|
+
|
|
42
|
+
svg.selectAll('.bar-cache-read').data(data.projects).enter().append('rect')
|
|
43
|
+
.attr('x', 0).attr('y', d => y(d.name))
|
|
44
|
+
.attr('width', d => x(cr(d))).attr('height', y.bandwidth())
|
|
45
|
+
.attr('fill', '#4ade80').attr('opacity', 0.6).attr('rx', 3);
|
|
46
|
+
|
|
47
|
+
svg.selectAll('.bar-cache-creation').data(data.projects).enter().append('rect')
|
|
48
|
+
.attr('x', d => x(cr(d))).attr('y', d => y(d.name))
|
|
49
|
+
.attr('width', d => x(cr(d) + cc(d)) - x(cr(d))).attr('height', y.bandwidth())
|
|
50
|
+
.attr('fill', '#f59e0b').attr('opacity', 0.6);
|
|
51
|
+
|
|
52
|
+
svg.selectAll('.bar-input').data(data.projects).enter().append('rect')
|
|
53
|
+
.attr('x', d => x(cr(d) + cc(d))).attr('y', d => y(d.name))
|
|
54
|
+
.attr('width', d => x(cr(d) + cc(d) + d.total_input_tokens) - x(cr(d) + cc(d))).attr('height', y.bandwidth())
|
|
55
|
+
.attr('fill', '#3b82f6').attr('opacity', 0.7);
|
|
56
|
+
|
|
57
|
+
svg.selectAll('.bar-output').data(data.projects).enter().append('rect')
|
|
58
|
+
.attr('x', d => x(cr(d) + cc(d) + d.total_input_tokens)).attr('y', d => y(d.name))
|
|
59
|
+
.attr('width', d => x(d.total_tokens) - x(cr(d) + cc(d) + d.total_input_tokens)).attr('height', y.bandwidth())
|
|
60
|
+
.attr('fill', '#f97316').attr('opacity', 0.7);
|
|
61
|
+
|
|
62
|
+
// Right-side label: tokens + cost
|
|
63
|
+
svg.selectAll('.detail-label').data(data.projects).enter().append('text')
|
|
64
|
+
.attr('x', d => x(d.total_tokens) + 8).attr('y', d => y(d.name) + y.bandwidth() / 2)
|
|
65
|
+
.attr('dominant-baseline', 'middle')
|
|
66
|
+
.style('font-size', '11px')
|
|
67
|
+
.html(d => {
|
|
68
|
+
// Use tspans for colored segments
|
|
69
|
+
return '';
|
|
70
|
+
})
|
|
71
|
+
.each(function(d) {
|
|
72
|
+
const text = d3.select(this);
|
|
73
|
+
text.append('tspan').style('fill', '#f8fafc').style('font-weight', '600').text(fmt(d.total_tokens));
|
|
74
|
+
text.append('tspan').style('fill', '#64748b').text(' (');
|
|
75
|
+
text.append('tspan').style('fill', '#4ade80').text(`cr:${fmt(d.cache_read_tokens || 0)}`);
|
|
76
|
+
text.append('tspan').style('fill', '#64748b').text('/');
|
|
77
|
+
text.append('tspan').style('fill', '#f59e0b').text(`cw:${fmt(d.cache_creation_tokens || 0)}`);
|
|
78
|
+
text.append('tspan').style('fill', '#64748b').text('/');
|
|
79
|
+
text.append('tspan').style('fill', '#60a5fa').text(`in:${fmt(d.total_input_tokens)}`);
|
|
80
|
+
text.append('tspan').style('fill', '#64748b').text('/');
|
|
81
|
+
text.append('tspan').style('fill', '#f97316').text(`out:${fmt(d.total_output_tokens)}`);
|
|
82
|
+
text.append('tspan').style('fill', '#64748b').text(') ');
|
|
83
|
+
text.append('tspan').style('fill', '#f59e0b').style('font-weight', '600').text(`$${d.estimated_cost_usd.toFixed(2)}`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Legend
|
|
87
|
+
const legend = el.append('div').style('display', 'flex').style('gap', '16px').style('margin-top', '8px');
|
|
88
|
+
legend.append('span').style('font-size', '11px').style('color', '#4ade80').html('● Cache Read');
|
|
89
|
+
legend.append('span').style('font-size', '11px').style('color', '#f59e0b').html('● Cache Write');
|
|
90
|
+
legend.append('span').style('font-size', '11px').style('color', '#60a5fa').html('● Input');
|
|
91
|
+
legend.append('span').style('font-size', '11px').style('color', '#f97316').html('● Output');
|
|
92
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
function formatTokens(n) {
|
|
2
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
3
|
+
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
|
|
4
|
+
return n.toString();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const MODEL_DISPLAY = {
|
|
8
|
+
'claude-opus-4-6': 'opus 4.6',
|
|
9
|
+
'claude-sonnet-4-6': 'sonnet 4.6',
|
|
10
|
+
'claude-haiku-4-5': 'haiku 4.5',
|
|
11
|
+
'claude-haiku-4-5-20251001': 'haiku 4.5',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function modelTag(model) {
|
|
15
|
+
const shortName = MODEL_DISPLAY[model] || model.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2');
|
|
16
|
+
let cls = 'tag-model-sonnet';
|
|
17
|
+
if (model.includes('opus')) cls = 'tag-model-opus';
|
|
18
|
+
else if (model.includes('haiku')) cls = 'tag-model-haiku';
|
|
19
|
+
return `<span class="tag ${cls}">${shortName}</span>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatDate(iso) {
|
|
23
|
+
const d = new Date(iso);
|
|
24
|
+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
|
|
25
|
+
', ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatDuration(minutes) {
|
|
29
|
+
if (minutes < 60) return `${minutes}m`;
|
|
30
|
+
const h = Math.floor(minutes / 60);
|
|
31
|
+
const m = minutes % 60;
|
|
32
|
+
return `${h}h ${m}m`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderSessionTable(container, data, { onSort, onPageChange }) {
|
|
36
|
+
container.innerHTML = '';
|
|
37
|
+
|
|
38
|
+
const table = document.createElement('table');
|
|
39
|
+
const thead = document.createElement('thead');
|
|
40
|
+
const headerRow = document.createElement('tr');
|
|
41
|
+
|
|
42
|
+
const columns = [
|
|
43
|
+
{ key: 'date', label: 'Date & Time' },
|
|
44
|
+
{ key: 'project', label: 'Project' },
|
|
45
|
+
{ key: 'models', label: 'Model(s)' },
|
|
46
|
+
{ key: 'input', label: 'Input', align: 'right' },
|
|
47
|
+
{ key: 'output', label: 'Output', align: 'right' },
|
|
48
|
+
{ key: 'cache_read', label: 'Cache Read', align: 'right' },
|
|
49
|
+
{ key: 'cache_creation', label: 'Cache Write', align: 'right' },
|
|
50
|
+
{ key: 'total', label: 'Total', align: 'right' },
|
|
51
|
+
{ key: 'cost', label: 'API Cost', align: 'right' },
|
|
52
|
+
{ key: 'duration', label: 'Duration', align: 'right' },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
for (const col of columns) {
|
|
56
|
+
const th = document.createElement('th');
|
|
57
|
+
th.textContent = col.label;
|
|
58
|
+
if (col.align) th.className = 'align-right';
|
|
59
|
+
if (['date', 'cost', 'total'].includes(col.key)) {
|
|
60
|
+
th.style.cursor = 'pointer';
|
|
61
|
+
th.addEventListener('click', () => {
|
|
62
|
+
const sortKey = col.key === 'total' ? 'tokens' : col.key;
|
|
63
|
+
onSort(sortKey);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
headerRow.appendChild(th);
|
|
67
|
+
}
|
|
68
|
+
thead.appendChild(headerRow);
|
|
69
|
+
table.appendChild(thead);
|
|
70
|
+
|
|
71
|
+
const tbody = document.createElement('tbody');
|
|
72
|
+
for (const s of data.sessions) {
|
|
73
|
+
const tr = document.createElement('tr');
|
|
74
|
+
tr.innerHTML = `
|
|
75
|
+
<td>${formatDate(s.startTime)}</td>
|
|
76
|
+
<td><span class="tag tag-project">${s.project}</span></td>
|
|
77
|
+
<td>${s.models.map(modelTag).join(' ')}</td>
|
|
78
|
+
<td class="align-right" style="color:#60a5fa">${formatTokens(s.input_tokens)}</td>
|
|
79
|
+
<td class="align-right" style="color:#f97316">${formatTokens(s.output_tokens)}</td>
|
|
80
|
+
<td class="align-right" style="color:#4ade80">${formatTokens(s.cache_read_tokens)}</td>
|
|
81
|
+
<td class="align-right" style="color:#f59e0b">${formatTokens(s.cache_creation_tokens)}</td>
|
|
82
|
+
<td class="align-right" style="font-weight:600">${formatTokens(s.total_tokens)}</td>
|
|
83
|
+
<td class="align-right" style="color:#f59e0b;font-weight:600">$${s.estimated_cost_usd.toFixed(2)}</td>
|
|
84
|
+
<td class="align-right">${formatDuration(s.duration_minutes)}</td>
|
|
85
|
+
`;
|
|
86
|
+
tbody.appendChild(tr);
|
|
87
|
+
}
|
|
88
|
+
table.appendChild(tbody);
|
|
89
|
+
|
|
90
|
+
if (data.totals) {
|
|
91
|
+
const tfoot = document.createElement('tfoot');
|
|
92
|
+
const tr = document.createElement('tr');
|
|
93
|
+
tr.innerHTML = `
|
|
94
|
+
<td colspan="3">Showing ${data.sessions.length} of ${data.pagination.total_sessions} sessions</td>
|
|
95
|
+
<td class="align-right" colspan="4"></td>
|
|
96
|
+
<td class="align-right">${formatTokens(data.totals.total_tokens)}</td>
|
|
97
|
+
<td class="align-right" style="color:#f59e0b">$${data.totals.estimated_cost_usd.toFixed(2)}</td>
|
|
98
|
+
<td></td>
|
|
99
|
+
`;
|
|
100
|
+
tfoot.appendChild(tr);
|
|
101
|
+
table.appendChild(tfoot);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
container.appendChild(table);
|
|
105
|
+
|
|
106
|
+
const pagEl = document.getElementById('session-pagination');
|
|
107
|
+
if (pagEl && data.pagination && data.pagination.total_pages > 1) {
|
|
108
|
+
pagEl.innerHTML = '';
|
|
109
|
+
for (let i = 1; i <= data.pagination.total_pages; i++) {
|
|
110
|
+
const btn = document.createElement('button');
|
|
111
|
+
btn.textContent = i;
|
|
112
|
+
if (i === data.pagination.page) btn.className = 'active';
|
|
113
|
+
btn.addEventListener('click', () => onPageChange(i));
|
|
114
|
+
pagEl.appendChild(btn);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// d3 is loaded as a global via <script> tag in index.html
|
|
2
|
+
|
|
3
|
+
export function renderTokenTrend(container, data) {
|
|
4
|
+
const el = d3.select(container);
|
|
5
|
+
el.selectAll('*').remove();
|
|
6
|
+
|
|
7
|
+
if (!data.buckets || data.buckets.length === 0) {
|
|
8
|
+
el.append('p').style('color', '#64748b').text('No data for selected range');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const margin = { top: 20, right: 30, bottom: 40, left: 60 };
|
|
13
|
+
const width = container.clientWidth - margin.left - margin.right;
|
|
14
|
+
const height = 250 - margin.top - margin.bottom;
|
|
15
|
+
|
|
16
|
+
const svg = el.append('svg')
|
|
17
|
+
.attr('width', width + margin.left + margin.right)
|
|
18
|
+
.attr('height', height + margin.top + margin.bottom)
|
|
19
|
+
.append('g')
|
|
20
|
+
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
21
|
+
|
|
22
|
+
const buckets = data.buckets;
|
|
23
|
+
const x = d3.scaleBand()
|
|
24
|
+
.domain(buckets.map(d => d.time))
|
|
25
|
+
.range([0, width])
|
|
26
|
+
.padding(0.1);
|
|
27
|
+
|
|
28
|
+
// Helper to get total height for each bucket
|
|
29
|
+
const totalOf = d => d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
|
|
30
|
+
|
|
31
|
+
const maxVal = d3.max(buckets, totalOf);
|
|
32
|
+
const y = d3.scaleLinear().domain([0, maxVal * 1.1]).range([height, 0]);
|
|
33
|
+
|
|
34
|
+
const xAxis = svg.append('g')
|
|
35
|
+
.attr('transform', `translate(0,${height})`)
|
|
36
|
+
.call(d3.axisBottom(x).tickValues(x.domain().filter((_, i) => i % Math.ceil(buckets.length / 10) === 0)));
|
|
37
|
+
xAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px')
|
|
38
|
+
.attr('transform', 'rotate(-45)').attr('text-anchor', 'end');
|
|
39
|
+
xAxis.selectAll('line, path').style('stroke', '#334155');
|
|
40
|
+
|
|
41
|
+
const yAxis = svg.append('g').call(d3.axisLeft(y).ticks(5).tickFormat(d3.format('.2s')));
|
|
42
|
+
yAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px');
|
|
43
|
+
yAxis.selectAll('line, path').style('stroke', '#334155');
|
|
44
|
+
|
|
45
|
+
// Stack order (bottom to top): cache_read, cache_creation, input, output
|
|
46
|
+
// Cache read (bottom)
|
|
47
|
+
svg.selectAll('.bar-cache-read')
|
|
48
|
+
.data(buckets)
|
|
49
|
+
.enter().append('rect')
|
|
50
|
+
.attr('x', d => x(d.time))
|
|
51
|
+
.attr('y', d => y(d.cache_read_tokens || 0))
|
|
52
|
+
.attr('width', x.bandwidth())
|
|
53
|
+
.attr('height', d => height - y(d.cache_read_tokens || 0))
|
|
54
|
+
.attr('fill', '#4ade80')
|
|
55
|
+
.attr('opacity', 0.6);
|
|
56
|
+
|
|
57
|
+
// Cache creation (on top of cache read)
|
|
58
|
+
const cacheBase = d => (d.cache_read_tokens || 0);
|
|
59
|
+
svg.selectAll('.bar-cache-creation')
|
|
60
|
+
.data(buckets)
|
|
61
|
+
.enter().append('rect')
|
|
62
|
+
.attr('x', d => x(d.time))
|
|
63
|
+
.attr('y', d => y(cacheBase(d) + (d.cache_creation_tokens || 0)))
|
|
64
|
+
.attr('width', x.bandwidth())
|
|
65
|
+
.attr('height', d => y(cacheBase(d)) - y(cacheBase(d) + (d.cache_creation_tokens || 0)))
|
|
66
|
+
.attr('fill', '#f59e0b')
|
|
67
|
+
.attr('opacity', 0.6);
|
|
68
|
+
|
|
69
|
+
// Input (on top of cache)
|
|
70
|
+
const inputBase = d => cacheBase(d) + (d.cache_creation_tokens || 0);
|
|
71
|
+
svg.selectAll('.bar-input')
|
|
72
|
+
.data(buckets)
|
|
73
|
+
.enter().append('rect')
|
|
74
|
+
.attr('x', d => x(d.time))
|
|
75
|
+
.attr('y', d => y(inputBase(d) + d.input_tokens))
|
|
76
|
+
.attr('width', x.bandwidth())
|
|
77
|
+
.attr('height', d => y(inputBase(d)) - y(inputBase(d) + d.input_tokens))
|
|
78
|
+
.attr('fill', '#3b82f6')
|
|
79
|
+
.attr('opacity', 0.7);
|
|
80
|
+
|
|
81
|
+
// Output (top)
|
|
82
|
+
const outputBase = d => inputBase(d) + d.input_tokens;
|
|
83
|
+
svg.selectAll('.bar-output')
|
|
84
|
+
.data(buckets)
|
|
85
|
+
.enter().append('rect')
|
|
86
|
+
.attr('x', d => x(d.time))
|
|
87
|
+
.attr('y', d => y(outputBase(d) + d.output_tokens))
|
|
88
|
+
.attr('width', x.bandwidth())
|
|
89
|
+
.attr('height', d => y(outputBase(d)) - y(outputBase(d) + d.output_tokens))
|
|
90
|
+
.attr('fill', '#f97316')
|
|
91
|
+
.attr('opacity', 0.7);
|
|
92
|
+
|
|
93
|
+
// Tooltip
|
|
94
|
+
const tooltip = d3.select('body').append('div').attr('class', 'd3-tooltip').style('display', 'none');
|
|
95
|
+
|
|
96
|
+
svg.selectAll('rect')
|
|
97
|
+
.on('mouseover', (event, d) => {
|
|
98
|
+
const total = d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
|
|
99
|
+
const cost = d.estimated_cost_usd || 0;
|
|
100
|
+
tooltip.style('display', 'block')
|
|
101
|
+
.html(`<strong>${d.time}</strong><br>Total: ${d3.format(',')(total)} tokens <span style="color:#f59e0b;font-weight:600">$${cost.toFixed(2)}</span><br><span style="color:#4ade80">Cache Read: ${d3.format(',')(d.cache_read_tokens || 0)}</span><br><span style="color:#f59e0b">Cache Write: ${d3.format(',')(d.cache_creation_tokens || 0)}</span><br><span style="color:#60a5fa">Input: ${d3.format(',')(d.input_tokens)}</span><br><span style="color:#f97316">Output: ${d3.format(',')(d.output_tokens)}</span>`);
|
|
102
|
+
})
|
|
103
|
+
.on('mousemove', (event) => {
|
|
104
|
+
tooltip.style('left', (event.pageX + 10) + 'px').style('top', (event.pageY - 10) + 'px');
|
|
105
|
+
})
|
|
106
|
+
.on('mouseout', () => tooltip.style('display', 'none'));
|
|
107
|
+
|
|
108
|
+
const legend = el.append('div').style('display', 'flex').style('gap', '16px').style('margin-top', '8px');
|
|
109
|
+
legend.append('span').style('font-size', '11px').style('color', '#4ade80').html('● Cache Read');
|
|
110
|
+
legend.append('span').style('font-size', '11px').style('color', '#f59e0b').html('● Cache Write');
|
|
111
|
+
legend.append('span').style('font-size', '11px').style('color', '#60a5fa').html('● Input');
|
|
112
|
+
legend.append('span').style('font-size', '11px').style('color', '#f97316').html('● Output');
|
|
113
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
container.innerHTML = `
|
|
8
|
+
<span>📅</span>
|
|
9
|
+
<input type="date" id="date-from" value="${fmt(thirtyDaysAgo)}">
|
|
10
|
+
<span>–</span>
|
|
11
|
+
<input type="date" id="date-to" value="${fmt(today)}">
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
const fromInput = container.querySelector('#date-from');
|
|
15
|
+
const toInput = container.querySelector('#date-to');
|
|
16
|
+
const emitChange = () => onChange({ from: fromInput.value, to: toInput.value });
|
|
17
|
+
fromInput.addEventListener('change', emitChange);
|
|
18
|
+
toInput.addEventListener('change', emitChange);
|
|
19
|
+
|
|
20
|
+
return { getRange: () => ({ from: fromInput.value, to: toInput.value }) };
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
|
|
11
|
+
container.innerHTML = `
|
|
12
|
+
<select id="plan-select">
|
|
13
|
+
${Object.entries(PLANS).map(([key, p]) =>
|
|
14
|
+
`<option value="${key}" ${key === saved ? 'selected' : ''}>${p.label} ($${p.price}/mo)</option>`
|
|
15
|
+
).join('')}
|
|
16
|
+
</select>
|
|
17
|
+
<input type="number" id="custom-price" placeholder="Custom $" value="${savedPrice}" style="width:80px;display:${savedPrice ? 'inline-block' : 'none'};">
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
const select = container.querySelector('#plan-select');
|
|
21
|
+
const customInput = container.querySelector('#custom-price');
|
|
22
|
+
const emitChange = () => {
|
|
23
|
+
const plan = select.value;
|
|
24
|
+
const customPrice = customInput.value ? parseFloat(customInput.value) : null;
|
|
25
|
+
localStorage.setItem('selectedPlan', plan);
|
|
26
|
+
if (customPrice) localStorage.setItem('customPrice', customInput.value);
|
|
27
|
+
else localStorage.removeItem('customPrice');
|
|
28
|
+
onChange({ plan, customPrice });
|
|
29
|
+
};
|
|
30
|
+
select.addEventListener('change', emitChange);
|
|
31
|
+
customInput.addEventListener('input', emitChange);
|
|
32
|
+
select.addEventListener('dblclick', () => {
|
|
33
|
+
customInput.style.display = customInput.style.display === 'none' ? 'inline-block' : 'none';
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return { getPlan: () => ({ plan: select.value, customPrice: customInput.value ? parseFloat(customInput.value) : null }) };
|
|
37
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { createApiRouter } from './routes/api.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const PORT = process.env.PORT || 3000;
|
|
9
|
+
const LOG_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
10
|
+
|
|
11
|
+
const app = express();
|
|
12
|
+
app.use('/lib/d3', express.static(path.join(__dirname, '..', 'node_modules', 'd3', 'dist')));
|
|
13
|
+
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
14
|
+
app.use('/api', createApiRouter(LOG_DIR));
|
|
15
|
+
|
|
16
|
+
app.listen(PORT, () => {
|
|
17
|
+
console.log(`Claude Usage Dashboard running at http://localhost:${PORT}`);
|
|
18
|
+
});
|
package/server/parser.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export function deriveProjectName(dirName) {
|
|
5
|
+
const clean = dirName.startsWith('-') ? dirName.slice(1) : dirName;
|
|
6
|
+
|
|
7
|
+
const workspaceIdx = clean.indexOf('-Workspace-');
|
|
8
|
+
if (workspaceIdx !== -1) {
|
|
9
|
+
return clean.slice(workspaceIdx + '-Workspace-'.length);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const homeIdx = clean.indexOf('-Home-');
|
|
13
|
+
if (homeIdx !== -1) {
|
|
14
|
+
const rest = clean.slice(homeIdx + '-Home-'.length);
|
|
15
|
+
return rest;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const parts = clean.split('-');
|
|
19
|
+
return parts[parts.length - 1];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseLogFile(filePath) {
|
|
23
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
24
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
25
|
+
const records = [];
|
|
26
|
+
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
let entry;
|
|
29
|
+
try {
|
|
30
|
+
entry = JSON.parse(line);
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (entry.type !== 'assistant') continue;
|
|
36
|
+
|
|
37
|
+
const model = entry.message?.model;
|
|
38
|
+
if (!model || model === '<synthetic>') continue;
|
|
39
|
+
|
|
40
|
+
const usage = entry.message?.usage;
|
|
41
|
+
if (!usage) continue;
|
|
42
|
+
|
|
43
|
+
records.push({
|
|
44
|
+
sessionId: entry.sessionId,
|
|
45
|
+
timestamp: entry.timestamp,
|
|
46
|
+
model,
|
|
47
|
+
input_tokens: usage.input_tokens || 0,
|
|
48
|
+
output_tokens: usage.output_tokens || 0,
|
|
49
|
+
cache_creation_tokens: usage.cache_creation_input_tokens || 0,
|
|
50
|
+
cache_read_tokens: usage.cache_read_input_tokens || 0,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return records;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseLogDirectory(baseDir) {
|
|
58
|
+
const allRecords = [];
|
|
59
|
+
|
|
60
|
+
let projectDirs;
|
|
61
|
+
try {
|
|
62
|
+
projectDirs = fs.readdirSync(baseDir, { withFileTypes: true })
|
|
63
|
+
.filter(d => d.isDirectory());
|
|
64
|
+
} catch {
|
|
65
|
+
return allRecords;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const dir of projectDirs) {
|
|
69
|
+
const projectName = deriveProjectName(dir.name);
|
|
70
|
+
const projectPath = path.join(baseDir, dir.name);
|
|
71
|
+
|
|
72
|
+
let files;
|
|
73
|
+
try {
|
|
74
|
+
files = fs.readdirSync(projectPath)
|
|
75
|
+
.filter(f => f.endsWith('.jsonl'));
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
const filePath = path.join(projectPath, file);
|
|
82
|
+
const records = parseLogFile(filePath);
|
|
83
|
+
for (const record of records) {
|
|
84
|
+
record.project = projectName;
|
|
85
|
+
record.projectDirName = dir.name;
|
|
86
|
+
}
|
|
87
|
+
allRecords.push(...records);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return allRecords;
|
|
92
|
+
}
|