claude-usage-dashboard 1.4.0 → 1.4.2
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 +114 -77
- package/bin/cli.cjs +20 -20
- package/bin/cli.js +16 -16
- package/bin/cli.sh +11 -11
- package/package.json +43 -43
- 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 +304 -304
- 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 +151 -151
- package/server/credentials.js +112 -112
- package/server/index.js +45 -45
- package/server/parser.js +129 -129
- package/server/pricing.js +52 -52
- package/server/routes/api.js +141 -130
- package/server/sync.js +69 -69
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
export function renderCacheEfficiency(container, data) {
|
|
2
|
-
container.innerHTML = '';
|
|
3
|
-
|
|
4
|
-
const items = [
|
|
5
|
-
{ label: 'Cache Read', value: data.cache_read_rate, color: '#4ade80', tokens: data.cache_read_tokens },
|
|
6
|
-
{ label: 'Cache Creation', value: data.cache_creation_rate, color: '#f59e0b', tokens: data.cache_creation_tokens },
|
|
7
|
-
{ label: 'No Cache', value: data.no_cache_rate, color: '#ef4444', tokens: data.non_cached_input_tokens },
|
|
8
|
-
];
|
|
9
|
-
|
|
10
|
-
for (const item of items) {
|
|
11
|
-
const row = document.createElement('div');
|
|
12
|
-
row.style.marginBottom = '12px';
|
|
13
|
-
|
|
14
|
-
const header = document.createElement('div');
|
|
15
|
-
header.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#94a3b8;margin-bottom:4px';
|
|
16
|
-
header.innerHTML = `<span>${item.label}</span><span>${(item.value * 100).toFixed(1)}%</span>`;
|
|
17
|
-
|
|
18
|
-
const barBg = document.createElement('div');
|
|
19
|
-
barBg.style.cssText = 'height:8px;background:#334155;border-radius:4px;overflow:hidden';
|
|
20
|
-
|
|
21
|
-
const barFill = document.createElement('div');
|
|
22
|
-
barFill.style.cssText = `width:${item.value * 100}%;height:100%;background:${item.color};border-radius:4px;transition:width 0.5s`;
|
|
23
|
-
|
|
24
|
-
barBg.appendChild(barFill);
|
|
25
|
-
row.appendChild(header);
|
|
26
|
-
row.appendChild(barBg);
|
|
27
|
-
container.appendChild(row);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
1
|
+
export function renderCacheEfficiency(container, data) {
|
|
2
|
+
container.innerHTML = '';
|
|
3
|
+
|
|
4
|
+
const items = [
|
|
5
|
+
{ label: 'Cache Read', value: data.cache_read_rate, color: '#4ade80', tokens: data.cache_read_tokens },
|
|
6
|
+
{ label: 'Cache Creation', value: data.cache_creation_rate, color: '#f59e0b', tokens: data.cache_creation_tokens },
|
|
7
|
+
{ label: 'No Cache', value: data.no_cache_rate, color: '#ef4444', tokens: data.non_cached_input_tokens },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
for (const item of items) {
|
|
11
|
+
const row = document.createElement('div');
|
|
12
|
+
row.style.marginBottom = '12px';
|
|
13
|
+
|
|
14
|
+
const header = document.createElement('div');
|
|
15
|
+
header.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#94a3b8;margin-bottom:4px';
|
|
16
|
+
header.innerHTML = `<span>${item.label}</span><span>${(item.value * 100).toFixed(1)}%</span>`;
|
|
17
|
+
|
|
18
|
+
const barBg = document.createElement('div');
|
|
19
|
+
barBg.style.cssText = 'height:8px;background:#334155;border-radius:4px;overflow:hidden';
|
|
20
|
+
|
|
21
|
+
const barFill = document.createElement('div');
|
|
22
|
+
barFill.style.cssText = `width:${item.value * 100}%;height:100%;background:${item.color};border-radius:4px;transition:width 0.5s`;
|
|
23
|
+
|
|
24
|
+
barBg.appendChild(barFill);
|
|
25
|
+
row.appendChild(header);
|
|
26
|
+
row.appendChild(barBg);
|
|
27
|
+
container.appendChild(row);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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
|
+
}
|