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,39 +1,39 @@
|
|
|
1
|
-
export function renderCostComparison(container, data) {
|
|
2
|
-
const el = d3.select(container);
|
|
3
|
-
el.selectAll('*').remove();
|
|
4
|
-
|
|
5
|
-
const margin = { top: 10, right: 20, bottom: 40, left: 50 };
|
|
6
|
-
const width = container.clientWidth - margin.left - margin.right;
|
|
7
|
-
const height = 180 - margin.top - margin.bottom;
|
|
8
|
-
|
|
9
|
-
const svg = el.append('svg')
|
|
10
|
-
.attr('width', width + margin.left + margin.right)
|
|
11
|
-
.attr('height', height + margin.top + margin.bottom)
|
|
12
|
-
.append('g')
|
|
13
|
-
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
14
|
-
|
|
15
|
-
const bars = [
|
|
16
|
-
{ label: 'Subscription', value: data.subscription_cost_usd, color: '#3b82f6' },
|
|
17
|
-
{ label: 'API Cost', value: data.api_equivalent_cost_usd, color: '#f59e0b' },
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
const x = d3.scaleBand().domain(bars.map(d => d.label)).range([0, width]).padding(0.4);
|
|
21
|
-
const y = d3.scaleLinear().domain([0, d3.max(bars, d => d.value) * 1.2]).range([height, 0]);
|
|
22
|
-
|
|
23
|
-
svg.append('g').attr('transform', `translate(0,${height})`)
|
|
24
|
-
.call(d3.axisBottom(x))
|
|
25
|
-
.selectAll('text').style('fill', '#94a3b8').style('font-size', '11px');
|
|
26
|
-
svg.append('g').call(d3.axisLeft(y).ticks(4).tickFormat(d => `$${d}`))
|
|
27
|
-
.selectAll('text').style('fill', '#64748b').style('font-size', '10px');
|
|
28
|
-
|
|
29
|
-
svg.selectAll('.bar').data(bars).enter().append('rect')
|
|
30
|
-
.attr('x', d => x(d.label)).attr('y', d => y(d.value))
|
|
31
|
-
.attr('width', x.bandwidth()).attr('height', d => height - y(d.value))
|
|
32
|
-
.attr('fill', d => d.color).attr('rx', 4);
|
|
33
|
-
|
|
34
|
-
svg.selectAll('.label').data(bars).enter().append('text')
|
|
35
|
-
.attr('x', d => x(d.label) + x.bandwidth() / 2).attr('y', d => y(d.value) - 5)
|
|
36
|
-
.attr('text-anchor', 'middle')
|
|
37
|
-
.style('fill', '#f8fafc').style('font-size', '12px').style('font-weight', '600')
|
|
38
|
-
.text(d => `$${d.value.toFixed(2)}`);
|
|
39
|
-
}
|
|
1
|
+
export function renderCostComparison(container, data) {
|
|
2
|
+
const el = d3.select(container);
|
|
3
|
+
el.selectAll('*').remove();
|
|
4
|
+
|
|
5
|
+
const margin = { top: 10, right: 20, bottom: 40, left: 50 };
|
|
6
|
+
const width = container.clientWidth - margin.left - margin.right;
|
|
7
|
+
const height = 180 - margin.top - margin.bottom;
|
|
8
|
+
|
|
9
|
+
const svg = el.append('svg')
|
|
10
|
+
.attr('width', width + margin.left + margin.right)
|
|
11
|
+
.attr('height', height + margin.top + margin.bottom)
|
|
12
|
+
.append('g')
|
|
13
|
+
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
14
|
+
|
|
15
|
+
const bars = [
|
|
16
|
+
{ label: 'Subscription', value: data.subscription_cost_usd, color: '#3b82f6' },
|
|
17
|
+
{ label: 'API Cost', value: data.api_equivalent_cost_usd, color: '#f59e0b' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const x = d3.scaleBand().domain(bars.map(d => d.label)).range([0, width]).padding(0.4);
|
|
21
|
+
const y = d3.scaleLinear().domain([0, d3.max(bars, d => d.value) * 1.2]).range([height, 0]);
|
|
22
|
+
|
|
23
|
+
svg.append('g').attr('transform', `translate(0,${height})`)
|
|
24
|
+
.call(d3.axisBottom(x))
|
|
25
|
+
.selectAll('text').style('fill', '#94a3b8').style('font-size', '11px');
|
|
26
|
+
svg.append('g').call(d3.axisLeft(y).ticks(4).tickFormat(d => `$${d}`))
|
|
27
|
+
.selectAll('text').style('fill', '#64748b').style('font-size', '10px');
|
|
28
|
+
|
|
29
|
+
svg.selectAll('.bar').data(bars).enter().append('rect')
|
|
30
|
+
.attr('x', d => x(d.label)).attr('y', d => y(d.value))
|
|
31
|
+
.attr('width', x.bandwidth()).attr('height', d => height - y(d.value))
|
|
32
|
+
.attr('fill', d => d.color).attr('rx', 4);
|
|
33
|
+
|
|
34
|
+
svg.selectAll('.label').data(bars).enter().append('text')
|
|
35
|
+
.attr('x', d => x(d.label) + x.bandwidth() / 2).attr('y', d => y(d.value) - 5)
|
|
36
|
+
.attr('text-anchor', 'middle')
|
|
37
|
+
.style('fill', '#f8fafc').style('font-size', '12px').style('font-weight', '600')
|
|
38
|
+
.text(d => `$${d.value.toFixed(2)}`);
|
|
39
|
+
}
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
const MODEL_COLORS = {
|
|
2
|
-
'claude-sonnet-4-6': '#3b82f6',
|
|
3
|
-
'claude-opus-4-6': '#8b5cf6',
|
|
4
|
-
'claude-haiku-4-5': '#f59e0b',
|
|
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
|
-
export function renderModelDistribution(container, data) {
|
|
15
|
-
const el = d3.select(container);
|
|
16
|
-
el.selectAll('*').remove();
|
|
17
|
-
|
|
18
|
-
if (!data.models || data.models.length === 0) {
|
|
19
|
-
el.append('p').style('color', '#64748b').text('No data');
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const containerWidth = container.clientWidth;
|
|
24
|
-
const size = Math.min(containerWidth * 0.45, 200);
|
|
25
|
-
const radius = size / 2;
|
|
26
|
-
const innerRadius = radius * 0.55;
|
|
27
|
-
|
|
28
|
-
const isNarrow = containerWidth < 280;
|
|
29
|
-
const wrapper = el.append('div')
|
|
30
|
-
.style('display', 'flex')
|
|
31
|
-
.style('flex-direction', isNarrow ? 'column' : 'row')
|
|
32
|
-
.style('align-items', 'center')
|
|
33
|
-
.style('gap', isNarrow ? '8px' : '20px');
|
|
34
|
-
|
|
35
|
-
const svg = wrapper.append('svg')
|
|
36
|
-
.attr('width', size).attr('height', size)
|
|
37
|
-
.style('flex-shrink', '0')
|
|
38
|
-
.append('g').attr('transform', `translate(${size / 2},${size / 2})`);
|
|
39
|
-
|
|
40
|
-
const total = d3.sum(data.models, d => d.total_tokens);
|
|
41
|
-
const pie = d3.pie().value(d => d.total_tokens).sort(null);
|
|
42
|
-
const arc = d3.arc().innerRadius(innerRadius).outerRadius(radius);
|
|
43
|
-
|
|
44
|
-
svg.selectAll('path').data(pie(data.models)).enter().append('path')
|
|
45
|
-
.attr('d', arc).attr('fill', d => MODEL_COLORS[d.data.id] || '#64748b')
|
|
46
|
-
.attr('stroke', '#1e293b').attr('stroke-width', 2);
|
|
47
|
-
|
|
48
|
-
const legend = wrapper.append('div');
|
|
49
|
-
data.models.forEach(m => {
|
|
50
|
-
const pct = ((m.total_tokens / total) * 100).toFixed(1);
|
|
51
|
-
const color = MODEL_COLORS[m.id] || '#64748b';
|
|
52
|
-
const shortName = MODEL_DISPLAY[m.id] || m.id.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2');
|
|
53
|
-
legend.append('div').style('font-size', '11px').style('color', '#94a3b8').style('margin-bottom', '4px')
|
|
54
|
-
.html(`<span style="color:${color}">●</span> ${shortName} — ${pct}%`);
|
|
55
|
-
});
|
|
56
|
-
}
|
|
1
|
+
const MODEL_COLORS = {
|
|
2
|
+
'claude-sonnet-4-6': '#3b82f6',
|
|
3
|
+
'claude-opus-4-6': '#8b5cf6',
|
|
4
|
+
'claude-haiku-4-5': '#f59e0b',
|
|
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
|
+
export function renderModelDistribution(container, data) {
|
|
15
|
+
const el = d3.select(container);
|
|
16
|
+
el.selectAll('*').remove();
|
|
17
|
+
|
|
18
|
+
if (!data.models || data.models.length === 0) {
|
|
19
|
+
el.append('p').style('color', '#64748b').text('No data');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const containerWidth = container.clientWidth;
|
|
24
|
+
const size = Math.min(containerWidth * 0.45, 200);
|
|
25
|
+
const radius = size / 2;
|
|
26
|
+
const innerRadius = radius * 0.55;
|
|
27
|
+
|
|
28
|
+
const isNarrow = containerWidth < 280;
|
|
29
|
+
const wrapper = el.append('div')
|
|
30
|
+
.style('display', 'flex')
|
|
31
|
+
.style('flex-direction', isNarrow ? 'column' : 'row')
|
|
32
|
+
.style('align-items', 'center')
|
|
33
|
+
.style('gap', isNarrow ? '8px' : '20px');
|
|
34
|
+
|
|
35
|
+
const svg = wrapper.append('svg')
|
|
36
|
+
.attr('width', size).attr('height', size)
|
|
37
|
+
.style('flex-shrink', '0')
|
|
38
|
+
.append('g').attr('transform', `translate(${size / 2},${size / 2})`);
|
|
39
|
+
|
|
40
|
+
const total = d3.sum(data.models, d => d.total_tokens);
|
|
41
|
+
const pie = d3.pie().value(d => d.total_tokens).sort(null);
|
|
42
|
+
const arc = d3.arc().innerRadius(innerRadius).outerRadius(radius);
|
|
43
|
+
|
|
44
|
+
svg.selectAll('path').data(pie(data.models)).enter().append('path')
|
|
45
|
+
.attr('d', arc).attr('fill', d => MODEL_COLORS[d.data.id] || '#64748b')
|
|
46
|
+
.attr('stroke', '#1e293b').attr('stroke-width', 2);
|
|
47
|
+
|
|
48
|
+
const legend = wrapper.append('div');
|
|
49
|
+
data.models.forEach(m => {
|
|
50
|
+
const pct = ((m.total_tokens / total) * 100).toFixed(1);
|
|
51
|
+
const color = MODEL_COLORS[m.id] || '#64748b';
|
|
52
|
+
const shortName = MODEL_DISPLAY[m.id] || m.id.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2');
|
|
53
|
+
legend.append('div').style('font-size', '11px').style('color', '#94a3b8').style('margin-bottom', '4px')
|
|
54
|
+
.html(`<span style="color:${color}">●</span> ${shortName} — ${pct}%`);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -1,103 +1,103 @@
|
|
|
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
|
-
// Measure longest project name to set left margin dynamically
|
|
19
|
-
const tempSvg = el.append('svg').style('position', 'absolute').style('visibility', 'hidden');
|
|
20
|
-
const tempText = tempSvg.append('text').style('font-size', '12px');
|
|
21
|
-
let maxLabelWidth = 120;
|
|
22
|
-
for (const p of data.projects) {
|
|
23
|
-
tempText.text(p.name);
|
|
24
|
-
maxLabelWidth = Math.max(maxLabelWidth, tempText.node().getComputedTextLength());
|
|
25
|
-
}
|
|
26
|
-
tempSvg.remove();
|
|
27
|
-
const leftMargin = Math.ceil(maxLabelWidth) + 16;
|
|
28
|
-
|
|
29
|
-
const margin = { top: 10, right: 360, bottom: 10, left: leftMargin };
|
|
30
|
-
const barHeight = 24;
|
|
31
|
-
const gap = 8;
|
|
32
|
-
const height = data.projects.length * (barHeight + gap) + margin.top + margin.bottom;
|
|
33
|
-
const width = container.clientWidth - margin.left - margin.right;
|
|
34
|
-
|
|
35
|
-
const svg = el.append('svg')
|
|
36
|
-
.attr('width', width + margin.left + margin.right)
|
|
37
|
-
.attr('height', height)
|
|
38
|
-
.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
|
|
39
|
-
|
|
40
|
-
const x = d3.scaleLinear().domain([0, d3.max(data.projects, d => d.total_tokens)]).range([0, width]);
|
|
41
|
-
const y = d3.scaleBand().domain(data.projects.map(d => d.name)).range([0, height - margin.top - margin.bottom]).padding(0.25);
|
|
42
|
-
|
|
43
|
-
// Project name labels
|
|
44
|
-
svg.selectAll('.project-label').data(data.projects).enter().append('text')
|
|
45
|
-
.attr('x', -8).attr('y', d => y(d.name) + y.bandwidth() / 2)
|
|
46
|
-
.attr('text-anchor', 'end').attr('dominant-baseline', 'middle')
|
|
47
|
-
.style('fill', '#e2e8f0').style('font-size', '12px').text(d => d.name);
|
|
48
|
-
|
|
49
|
-
// Stacked bars: cache_read + cache_creation + input + output
|
|
50
|
-
const cr = d => d.cache_read_tokens || 0;
|
|
51
|
-
const cc = d => d.cache_creation_tokens || 0;
|
|
52
|
-
|
|
53
|
-
svg.selectAll('.bar-cache-read').data(data.projects).enter().append('rect')
|
|
54
|
-
.attr('x', 0).attr('y', d => y(d.name))
|
|
55
|
-
.attr('width', d => x(cr(d))).attr('height', y.bandwidth())
|
|
56
|
-
.attr('fill', '#4ade80').attr('opacity', 0.6).attr('rx', 3);
|
|
57
|
-
|
|
58
|
-
svg.selectAll('.bar-cache-creation').data(data.projects).enter().append('rect')
|
|
59
|
-
.attr('x', d => x(cr(d))).attr('y', d => y(d.name))
|
|
60
|
-
.attr('width', d => x(cr(d) + cc(d)) - x(cr(d))).attr('height', y.bandwidth())
|
|
61
|
-
.attr('fill', '#f59e0b').attr('opacity', 0.6);
|
|
62
|
-
|
|
63
|
-
svg.selectAll('.bar-input').data(data.projects).enter().append('rect')
|
|
64
|
-
.attr('x', d => x(cr(d) + cc(d))).attr('y', d => y(d.name))
|
|
65
|
-
.attr('width', d => x(cr(d) + cc(d) + d.total_input_tokens) - x(cr(d) + cc(d))).attr('height', y.bandwidth())
|
|
66
|
-
.attr('fill', '#3b82f6').attr('opacity', 0.7);
|
|
67
|
-
|
|
68
|
-
svg.selectAll('.bar-output').data(data.projects).enter().append('rect')
|
|
69
|
-
.attr('x', d => x(cr(d) + cc(d) + d.total_input_tokens)).attr('y', d => y(d.name))
|
|
70
|
-
.attr('width', d => x(d.total_tokens) - x(cr(d) + cc(d) + d.total_input_tokens)).attr('height', y.bandwidth())
|
|
71
|
-
.attr('fill', '#f97316').attr('opacity', 0.7);
|
|
72
|
-
|
|
73
|
-
// Right-side label: tokens + cost
|
|
74
|
-
svg.selectAll('.detail-label').data(data.projects).enter().append('text')
|
|
75
|
-
.attr('x', d => x(d.total_tokens) + 8).attr('y', d => y(d.name) + y.bandwidth() / 2)
|
|
76
|
-
.attr('dominant-baseline', 'middle')
|
|
77
|
-
.style('font-size', '11px')
|
|
78
|
-
.html(d => {
|
|
79
|
-
// Use tspans for colored segments
|
|
80
|
-
return '';
|
|
81
|
-
})
|
|
82
|
-
.each(function(d) {
|
|
83
|
-
const text = d3.select(this);
|
|
84
|
-
text.append('tspan').style('fill', '#f8fafc').style('font-weight', '600').text(fmt(d.total_tokens));
|
|
85
|
-
text.append('tspan').style('fill', '#64748b').text(' (');
|
|
86
|
-
text.append('tspan').style('fill', '#4ade80').text(`cr:${fmt(d.cache_read_tokens || 0)}`);
|
|
87
|
-
text.append('tspan').style('fill', '#64748b').text('/');
|
|
88
|
-
text.append('tspan').style('fill', '#f59e0b').text(`cw:${fmt(d.cache_creation_tokens || 0)}`);
|
|
89
|
-
text.append('tspan').style('fill', '#64748b').text('/');
|
|
90
|
-
text.append('tspan').style('fill', '#60a5fa').text(`in:${fmt(d.total_input_tokens)}`);
|
|
91
|
-
text.append('tspan').style('fill', '#64748b').text('/');
|
|
92
|
-
text.append('tspan').style('fill', '#f97316').text(`out:${fmt(d.total_output_tokens)}`);
|
|
93
|
-
text.append('tspan').style('fill', '#64748b').text(') ');
|
|
94
|
-
text.append('tspan').style('fill', '#f59e0b').style('font-weight', '600').text(`$${d.estimated_cost_usd.toFixed(2)}`);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// Legend
|
|
98
|
-
const legend = el.append('div').style('display', 'flex').style('gap', '16px').style('margin-top', '8px');
|
|
99
|
-
legend.append('span').style('font-size', '11px').style('color', '#4ade80').html('● Cache Read');
|
|
100
|
-
legend.append('span').style('font-size', '11px').style('color', '#f59e0b').html('● Cache Write');
|
|
101
|
-
legend.append('span').style('font-size', '11px').style('color', '#60a5fa').html('● Input');
|
|
102
|
-
legend.append('span').style('font-size', '11px').style('color', '#f97316').html('● Output');
|
|
103
|
-
}
|
|
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
|
+
// Measure longest project name to set left margin dynamically
|
|
19
|
+
const tempSvg = el.append('svg').style('position', 'absolute').style('visibility', 'hidden');
|
|
20
|
+
const tempText = tempSvg.append('text').style('font-size', '12px');
|
|
21
|
+
let maxLabelWidth = 120;
|
|
22
|
+
for (const p of data.projects) {
|
|
23
|
+
tempText.text(p.name);
|
|
24
|
+
maxLabelWidth = Math.max(maxLabelWidth, tempText.node().getComputedTextLength());
|
|
25
|
+
}
|
|
26
|
+
tempSvg.remove();
|
|
27
|
+
const leftMargin = Math.ceil(maxLabelWidth) + 16;
|
|
28
|
+
|
|
29
|
+
const margin = { top: 10, right: 360, bottom: 10, left: leftMargin };
|
|
30
|
+
const barHeight = 24;
|
|
31
|
+
const gap = 8;
|
|
32
|
+
const height = data.projects.length * (barHeight + gap) + margin.top + margin.bottom;
|
|
33
|
+
const width = container.clientWidth - margin.left - margin.right;
|
|
34
|
+
|
|
35
|
+
const svg = el.append('svg')
|
|
36
|
+
.attr('width', width + margin.left + margin.right)
|
|
37
|
+
.attr('height', height)
|
|
38
|
+
.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
|
|
39
|
+
|
|
40
|
+
const x = d3.scaleLinear().domain([0, d3.max(data.projects, d => d.total_tokens)]).range([0, width]);
|
|
41
|
+
const y = d3.scaleBand().domain(data.projects.map(d => d.name)).range([0, height - margin.top - margin.bottom]).padding(0.25);
|
|
42
|
+
|
|
43
|
+
// Project name labels
|
|
44
|
+
svg.selectAll('.project-label').data(data.projects).enter().append('text')
|
|
45
|
+
.attr('x', -8).attr('y', d => y(d.name) + y.bandwidth() / 2)
|
|
46
|
+
.attr('text-anchor', 'end').attr('dominant-baseline', 'middle')
|
|
47
|
+
.style('fill', '#e2e8f0').style('font-size', '12px').text(d => d.name);
|
|
48
|
+
|
|
49
|
+
// Stacked bars: cache_read + cache_creation + input + output
|
|
50
|
+
const cr = d => d.cache_read_tokens || 0;
|
|
51
|
+
const cc = d => d.cache_creation_tokens || 0;
|
|
52
|
+
|
|
53
|
+
svg.selectAll('.bar-cache-read').data(data.projects).enter().append('rect')
|
|
54
|
+
.attr('x', 0).attr('y', d => y(d.name))
|
|
55
|
+
.attr('width', d => x(cr(d))).attr('height', y.bandwidth())
|
|
56
|
+
.attr('fill', '#4ade80').attr('opacity', 0.6).attr('rx', 3);
|
|
57
|
+
|
|
58
|
+
svg.selectAll('.bar-cache-creation').data(data.projects).enter().append('rect')
|
|
59
|
+
.attr('x', d => x(cr(d))).attr('y', d => y(d.name))
|
|
60
|
+
.attr('width', d => x(cr(d) + cc(d)) - x(cr(d))).attr('height', y.bandwidth())
|
|
61
|
+
.attr('fill', '#f59e0b').attr('opacity', 0.6);
|
|
62
|
+
|
|
63
|
+
svg.selectAll('.bar-input').data(data.projects).enter().append('rect')
|
|
64
|
+
.attr('x', d => x(cr(d) + cc(d))).attr('y', d => y(d.name))
|
|
65
|
+
.attr('width', d => x(cr(d) + cc(d) + d.total_input_tokens) - x(cr(d) + cc(d))).attr('height', y.bandwidth())
|
|
66
|
+
.attr('fill', '#3b82f6').attr('opacity', 0.7);
|
|
67
|
+
|
|
68
|
+
svg.selectAll('.bar-output').data(data.projects).enter().append('rect')
|
|
69
|
+
.attr('x', d => x(cr(d) + cc(d) + d.total_input_tokens)).attr('y', d => y(d.name))
|
|
70
|
+
.attr('width', d => x(d.total_tokens) - x(cr(d) + cc(d) + d.total_input_tokens)).attr('height', y.bandwidth())
|
|
71
|
+
.attr('fill', '#f97316').attr('opacity', 0.7);
|
|
72
|
+
|
|
73
|
+
// Right-side label: tokens + cost
|
|
74
|
+
svg.selectAll('.detail-label').data(data.projects).enter().append('text')
|
|
75
|
+
.attr('x', d => x(d.total_tokens) + 8).attr('y', d => y(d.name) + y.bandwidth() / 2)
|
|
76
|
+
.attr('dominant-baseline', 'middle')
|
|
77
|
+
.style('font-size', '11px')
|
|
78
|
+
.html(d => {
|
|
79
|
+
// Use tspans for colored segments
|
|
80
|
+
return '';
|
|
81
|
+
})
|
|
82
|
+
.each(function(d) {
|
|
83
|
+
const text = d3.select(this);
|
|
84
|
+
text.append('tspan').style('fill', '#f8fafc').style('font-weight', '600').text(fmt(d.total_tokens));
|
|
85
|
+
text.append('tspan').style('fill', '#64748b').text(' (');
|
|
86
|
+
text.append('tspan').style('fill', '#4ade80').text(`cr:${fmt(d.cache_read_tokens || 0)}`);
|
|
87
|
+
text.append('tspan').style('fill', '#64748b').text('/');
|
|
88
|
+
text.append('tspan').style('fill', '#f59e0b').text(`cw:${fmt(d.cache_creation_tokens || 0)}`);
|
|
89
|
+
text.append('tspan').style('fill', '#64748b').text('/');
|
|
90
|
+
text.append('tspan').style('fill', '#60a5fa').text(`in:${fmt(d.total_input_tokens)}`);
|
|
91
|
+
text.append('tspan').style('fill', '#64748b').text('/');
|
|
92
|
+
text.append('tspan').style('fill', '#f97316').text(`out:${fmt(d.total_output_tokens)}`);
|
|
93
|
+
text.append('tspan').style('fill', '#64748b').text(') ');
|
|
94
|
+
text.append('tspan').style('fill', '#f59e0b').style('font-weight', '600').text(`$${d.estimated_cost_usd.toFixed(2)}`);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Legend
|
|
98
|
+
const legend = el.append('div').style('display', 'flex').style('gap', '16px').style('margin-top', '8px');
|
|
99
|
+
legend.append('span').style('font-size', '11px').style('color', '#4ade80').html('● Cache Read');
|
|
100
|
+
legend.append('span').style('font-size', '11px').style('color', '#f59e0b').html('● Cache Write');
|
|
101
|
+
legend.append('span').style('font-size', '11px').style('color', '#60a5fa').html('● Input');
|
|
102
|
+
legend.append('span').style('font-size', '11px').style('color', '#f97316').html('● Output');
|
|
103
|
+
}
|
|
@@ -1,117 +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
|
-
}
|
|
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
|
+
}
|