Skip to content

Commit 47c743f

Browse files
committed
fix: avoid Alpine SVG template rendering errors in cost charts
1 parent 0796377 commit 47c743f

File tree

2 files changed

+65
-49
lines changed

2 files changed

+65
-49
lines changed

crates/openfang-api/static/index_body.html

Lines changed: 2 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4045,20 +4045,7 @@ <h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
40454045
<div x-show="costByProvider().length > 0" class="donut-chart-wrap">
40464046
<div class="donut-chart">
40474047
<svg viewBox="0 0 160 160" width="160" height="160">
4048-
<template x-for="(seg, idx) in donutSegments()" :key="seg.provider">
4049-
<circle
4050-
cx="80" cy="80" r="60"
4051-
fill="none"
4052-
:stroke="seg.color"
4053-
stroke-width="24"
4054-
:stroke-dasharray="seg.dasharray"
4055-
:stroke-dashoffset="seg.dashoffset"
4056-
transform="rotate(-90 80 80)"
4057-
class="donut-segment"
4058-
>
4059-
<title x-text="seg.provider + ': ' + seg.percent + '% (' + formatCost(seg.cost) + ')'"></title>
4060-
</circle>
4061-
</template>
4048+
<g x-html="donutSegmentsSvg()"></g>
40624049
<!-- Center text -->
40634050
<text x="80" y="76" text-anchor="middle" fill="var(--text)" style="font-size:14px;font-weight:700;font-family:var(--font-mono)" x-text="formatCost(summary.total_cost_usd)"></text>
40644051
<text x="80" y="92" text-anchor="middle" fill="var(--text-muted)" style="font-size:9px;font-family:var(--font-mono)">TOTAL</text>
@@ -4085,41 +4072,7 @@ <h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
40854072
<svg :viewBox="'0 0 ' + (barChartData().length * 50 + 20) + ' 180'" :width="barChartData().length * 50 + 20" height="180">
40864073
<!-- Baseline -->
40874074
<line x1="10" :x2="barChartData().length * 50 + 10" y1="150" y2="150" stroke="var(--border)" stroke-width="1"/>
4088-
<template x-for="(bar, idx) in barChartData()" :key="bar.date">
4089-
<g>
4090-
<!-- Bar rect -->
4091-
<rect
4092-
:x="idx * 50 + 18"
4093-
:y="150 - bar.barHeight"
4094-
width="24"
4095-
:height="bar.barHeight"
4096-
rx="3"
4097-
fill="var(--accent)"
4098-
class="cost-bar"
4099-
style="opacity:0.85"
4100-
>
4101-
<title x-text="bar.date + ': ' + formatCost(bar.cost) + ' (' + bar.calls + ' calls)'"></title>
4102-
</rect>
4103-
<!-- Day label -->
4104-
<text
4105-
:x="idx * 50 + 30"
4106-
y="166"
4107-
text-anchor="middle"
4108-
fill="var(--text-muted)"
4109-
style="font-size:9px;font-family:var(--font-mono)"
4110-
x-text="bar.dayName"
4111-
></text>
4112-
<!-- Cost label on top -->
4113-
<text
4114-
:x="idx * 50 + 30"
4115-
:y="150 - bar.barHeight - 4"
4116-
text-anchor="middle"
4117-
fill="var(--text-dim)"
4118-
style="font-size:8px;font-family:var(--font-mono)"
4119-
x-text="formatCost(bar.cost)"
4120-
></text>
4121-
</g>
4122-
</template>
4075+
<g x-html="barChartSvg()"></g>
41234076
</svg>
41244077
</div>
41254078
</div>

crates/openfang-api/static/js/pages/usage.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,33 @@ function analyticsPage() {
191191
return segments;
192192
},
193193

194+
donutSegmentsSvg() {
195+
var segments = this.donutSegments();
196+
if (!segments.length) return '';
197+
198+
function escapeXml(value) {
199+
return String(value)
200+
.replace(/&/g, '&amp;')
201+
.replace(/</g, '&lt;')
202+
.replace(/>/g, '&gt;')
203+
.replace(/"/g, '&quot;')
204+
.replace(/'/g, '&apos;');
205+
}
206+
207+
var out = [];
208+
for (var i = 0; i < segments.length; i++) {
209+
var seg = segments[i];
210+
var title = seg.provider + ': ' + seg.percent + '% (' + this.formatCost(seg.cost) + ')';
211+
out.push(
212+
'<circle cx="80" cy="80" r="60" fill="none" stroke="' + escapeXml(seg.color) + '" stroke-width="24" stroke-dasharray="' + escapeXml(seg.dasharray) + '" stroke-dashoffset="' + escapeXml(seg.dashoffset) + '" transform="rotate(-90 80 80)" class="donut-segment">' +
213+
'<title>' + escapeXml(title) + '</title>' +
214+
'</circle>'
215+
);
216+
}
217+
218+
return out.join('');
219+
},
220+
194221
// ── Bar chart (last 7 days) ──
195222

196223
barChartData() {
@@ -218,6 +245,42 @@ function analyticsPage() {
218245
return result;
219246
},
220247

248+
barChartSvg() {
249+
var bars = this.barChartData();
250+
if (!bars.length) return '';
251+
252+
function escapeXml(value) {
253+
return String(value)
254+
.replace(/&/g, '&amp;')
255+
.replace(/</g, '&lt;')
256+
.replace(/>/g, '&gt;')
257+
.replace(/"/g, '&quot;')
258+
.replace(/'/g, '&apos;');
259+
}
260+
261+
var out = [];
262+
for (var i = 0; i < bars.length; i++) {
263+
var bar = bars[i];
264+
var x = i * 50 + 18;
265+
var labelX = i * 50 + 30;
266+
var y = 150 - bar.barHeight;
267+
var costLabelY = y - 4;
268+
var title = bar.date + ': ' + this.formatCost(bar.cost) + ' (' + bar.calls + ' calls)';
269+
270+
out.push(
271+
'<g>' +
272+
'<rect x="' + x + '" y="' + y + '" width="24" height="' + bar.barHeight + '" rx="3" fill="var(--accent)" class="cost-bar" style="opacity:0.85">' +
273+
'<title>' + escapeXml(title) + '</title>' +
274+
'</rect>' +
275+
'<text x="' + labelX + '" y="166" text-anchor="middle" fill="var(--text-muted)" style="font-size:9px;font-family:var(--font-mono)">' + escapeXml(bar.dayName) + '</text>' +
276+
'<text x="' + labelX + '" y="' + costLabelY + '" text-anchor="middle" fill="var(--text-dim)" style="font-size:8px;font-family:var(--font-mono)">' + escapeXml(this.formatCost(bar.cost)) + '</text>' +
277+
'</g>'
278+
);
279+
}
280+
281+
return out.join('');
282+
},
283+
221284
// ── Cost by model table (sorted by cost descending) ──
222285

223286
costByModelSorted() {

0 commit comments

Comments
 (0)