@roastcodes/ttdash 6.1.8 → 6.2.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 +14 -5
- package/dist/assets/AutoImportModal-C8gA0_mL.js +3 -0
- package/dist/assets/CustomTooltip-CdIOw3Ep.js +1 -0
- package/dist/assets/DrillDownModal-d6hcut-I.js +1 -0
- package/dist/assets/button-B26tLVFw.js +1 -0
- package/dist/assets/dialog-CA-ZSHjK.js +1 -0
- package/dist/assets/{icons-vendor-DFoaijFJ.js → icons-vendor-z59La6A4.js} +1 -1
- package/dist/assets/index-BkGSNAne.css +2 -0
- package/dist/assets/index-CMtAn7c8.js +4 -0
- package/dist/index.html +6 -6
- package/package.json +3 -1
- package/server/http-utils.js +165 -0
- package/server/report/chart-labels.js +12 -0
- package/server/report/charts.js +4 -8
- package/server/report/index.js +73 -16
- package/server/report/utils.js +76 -478
- package/server/runtime.js +78 -0
- package/server.js +280 -165
- package/shared/dashboard-domain.d.ts +19 -0
- package/shared/dashboard-domain.js +615 -0
- package/shared/dashboard-preferences.json +43 -0
- package/shared/dashboard-types.d.ts +62 -0
- package/shared/model-colors.d.ts +17 -0
- package/shared/model-colors.js +241 -0
- package/src/locales/de/common.json +198 -124
- package/src/locales/en/common.json +78 -4
- package/dist/assets/AutoImportModal-Dqbl8H04.js +0 -2
- package/dist/assets/CustomTooltip-BxopDd3O.js +0 -1
- package/dist/assets/DrillDownModal-B7ZU15xQ.js +0 -1
- package/dist/assets/button-D7Ib8H7t.js +0 -1
- package/dist/assets/dialog-Cn1m7WhC.js +0 -1
- package/dist/assets/index-DDw3UUhU.js +0 -4
- package/dist/assets/index-g2F-z39N.css +0 -2
package/dist/index.html
CHANGED
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
<link rel="icon" type="image/png" sizes="256x256" href="/favicon.png" />
|
|
9
9
|
<link rel="shortcut icon" href="/favicon.png" />
|
|
10
10
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-CMtAn7c8.js"></script>
|
|
12
12
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-COnpUsM8.js">
|
|
13
13
|
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-CiBqdKXh.js">
|
|
14
14
|
<link rel="modulepreload" crossorigin href="/assets/react-vendor-0R1rd57Z.js">
|
|
15
15
|
<link rel="modulepreload" crossorigin href="/assets/motion-vendor-BXI2L__C.js">
|
|
16
16
|
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BGjRFQGY.js">
|
|
17
|
-
<link rel="modulepreload" crossorigin href="/assets/icons-vendor-
|
|
18
|
-
<link rel="modulepreload" crossorigin href="/assets/dialog-
|
|
19
|
-
<link rel="modulepreload" crossorigin href="/assets/button-
|
|
20
|
-
<link rel="modulepreload" crossorigin href="/assets/CustomTooltip-
|
|
21
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
17
|
+
<link rel="modulepreload" crossorigin href="/assets/icons-vendor-z59La6A4.js">
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/assets/dialog-CA-ZSHjK.js">
|
|
19
|
+
<link rel="modulepreload" crossorigin href="/assets/button-B26tLVFw.js">
|
|
20
|
+
<link rel="modulepreload" crossorigin href="/assets/CustomTooltip-CdIOw3Ep.js">
|
|
21
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BkGSNAne.css">
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
|
24
24
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roastcodes/ttdash",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.2.0",
|
|
4
4
|
"description": "Local-first dashboard and CLI for toktrack usage data",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"repository": {
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"server.js",
|
|
45
45
|
"usage-normalizer.js",
|
|
46
46
|
"server/",
|
|
47
|
+
"shared/",
|
|
47
48
|
"src/locales/",
|
|
48
49
|
"dist/"
|
|
49
50
|
],
|
|
@@ -103,6 +104,7 @@
|
|
|
103
104
|
"vitest": "^4.1.3"
|
|
104
105
|
},
|
|
105
106
|
"dependencies": {
|
|
107
|
+
"cross-spawn": "^7.0.6",
|
|
106
108
|
"i18next": "^26.0.3",
|
|
107
109
|
"react-i18next": "^17.0.2",
|
|
108
110
|
"react-is": "^19.2.4"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
function createHttpUtils({ apiPrefix, maxBodySize, securityHeaders }) {
|
|
2
|
+
function readBody(req) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let totalSize = 0;
|
|
6
|
+
let settled = false;
|
|
7
|
+
|
|
8
|
+
const cleanup = () => {
|
|
9
|
+
req.off('data', onData);
|
|
10
|
+
req.off('end', onEnd);
|
|
11
|
+
req.off('error', onError);
|
|
12
|
+
req.off('close', onClose);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const rejectOnce = (error) => {
|
|
16
|
+
if (settled) return;
|
|
17
|
+
settled = true;
|
|
18
|
+
cleanup();
|
|
19
|
+
reject(error);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const resolveOnce = (value) => {
|
|
23
|
+
if (settled) return;
|
|
24
|
+
settled = true;
|
|
25
|
+
cleanup();
|
|
26
|
+
resolve(value);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const onData = (chunk) => {
|
|
30
|
+
totalSize += chunk.length;
|
|
31
|
+
if (totalSize > maxBodySize) {
|
|
32
|
+
const error = new Error('Payload too large');
|
|
33
|
+
error.code = 'PAYLOAD_TOO_LARGE';
|
|
34
|
+
rejectOnce(error);
|
|
35
|
+
req.resume();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
chunks.push(chunk);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const onEnd = () => {
|
|
42
|
+
try {
|
|
43
|
+
resolveOnce(JSON.parse(Buffer.concat(chunks).toString()));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
rejectOnce(error);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const onError = (error) => {
|
|
50
|
+
if (settled && error && error.code === 'ECONNRESET') {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
rejectOnce(error);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const onClose = () => {
|
|
57
|
+
if (!req.readableEnded) {
|
|
58
|
+
rejectOnce(new Error('Request body stream closed before the payload finished'));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
req.on('data', onData);
|
|
63
|
+
req.on('end', onEnd);
|
|
64
|
+
req.on('error', onError);
|
|
65
|
+
req.on('close', onClose);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function json(res, status, data) {
|
|
70
|
+
res.writeHead(status, {
|
|
71
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
72
|
+
...securityHeaders,
|
|
73
|
+
});
|
|
74
|
+
res.end(JSON.stringify(data));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sendBuffer(res, status, headers, buffer) {
|
|
78
|
+
res.writeHead(status, {
|
|
79
|
+
'Content-Length': buffer.length,
|
|
80
|
+
...headers,
|
|
81
|
+
...securityHeaders,
|
|
82
|
+
});
|
|
83
|
+
res.end(buffer);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveApiPath(pathname) {
|
|
87
|
+
if (pathname === apiPrefix) {
|
|
88
|
+
return '/';
|
|
89
|
+
}
|
|
90
|
+
if (pathname.startsWith(apiPrefix + '/')) {
|
|
91
|
+
return pathname.slice(apiPrefix.length);
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getHeaderValue(req, name) {
|
|
97
|
+
const value = req.headers[name];
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
return value[0] || '';
|
|
100
|
+
}
|
|
101
|
+
return typeof value === 'string' ? value : '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasJsonContentType(req) {
|
|
105
|
+
const contentType = getHeaderValue(req, 'content-type');
|
|
106
|
+
if (!contentType) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return contentType.split(';', 1)[0].trim().toLowerCase() === 'application/json';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasTrustedOrigin(req) {
|
|
114
|
+
const originHeader = getHeaderValue(req, 'origin').trim();
|
|
115
|
+
if (!originHeader) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const hostHeader = getHeaderValue(req, 'host').trim();
|
|
120
|
+
if (!hostHeader || originHeader === 'null') {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const origin = new URL(originHeader);
|
|
126
|
+
return origin.host === hostHeader;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isCrossSiteFetch(req) {
|
|
133
|
+
return getHeaderValue(req, 'sec-fetch-site').trim().toLowerCase() === 'cross-site';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateMutationRequest(req, { requiresJsonContentType = false } = {}) {
|
|
137
|
+
if (isCrossSiteFetch(req) || !hasTrustedOrigin(req)) {
|
|
138
|
+
return {
|
|
139
|
+
status: 403,
|
|
140
|
+
message: 'Cross-site requests are not allowed',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (requiresJsonContentType && !hasJsonContentType(req)) {
|
|
145
|
+
return {
|
|
146
|
+
status: 415,
|
|
147
|
+
message: 'Content-Type must be application/json',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
readBody,
|
|
156
|
+
json,
|
|
157
|
+
sendBuffer,
|
|
158
|
+
resolveApiPath,
|
|
159
|
+
validateMutationRequest,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
createHttpUtils,
|
|
165
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const TOP_MODEL_CHART_LABEL_MAX_LENGTH = 34;
|
|
2
|
+
|
|
3
|
+
function truncateTopModelChartLabel(value) {
|
|
4
|
+
const stringValue = String(value || '');
|
|
5
|
+
if (stringValue.length <= TOP_MODEL_CHART_LABEL_MAX_LENGTH) return stringValue;
|
|
6
|
+
return `${stringValue.slice(0, Math.max(1, TOP_MODEL_CHART_LABEL_MAX_LENGTH - 1)).trimEnd()}…`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
TOP_MODEL_CHART_LABEL_MAX_LENGTH,
|
|
11
|
+
truncateTopModelChartLabel,
|
|
12
|
+
};
|
package/server/report/charts.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { truncateTopModelChartLabel } = require('./chart-labels');
|
|
2
|
+
|
|
1
3
|
function escapeXml(value) {
|
|
2
4
|
return String(value)
|
|
3
5
|
.replace(/&/g, '&')
|
|
@@ -19,12 +21,6 @@ ${body}
|
|
|
19
21
|
|
|
20
22
|
const DEFAULT_FONT_FAMILY = 'Liberation Sans, DejaVu Sans, Arial, sans-serif';
|
|
21
23
|
|
|
22
|
-
function truncateSvgLabel(value, maxLength = 28) {
|
|
23
|
-
const stringValue = String(value || '');
|
|
24
|
-
if (stringValue.length <= maxLength) return stringValue;
|
|
25
|
-
return `${stringValue.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
24
|
function lineChart(
|
|
29
25
|
data,
|
|
30
26
|
{
|
|
@@ -136,7 +132,7 @@ function horizontalBarChart(
|
|
|
136
132
|
top: 46,
|
|
137
133
|
right: 100,
|
|
138
134
|
bottom: 24,
|
|
139
|
-
left: clamp(180 + longestLabelLength * 3.4, 220,
|
|
135
|
+
left: clamp(180 + longestLabelLength * 3.4, 220, 360),
|
|
140
136
|
};
|
|
141
137
|
const plotWidth = width - margin.left - margin.right;
|
|
142
138
|
const barGap = 18;
|
|
@@ -158,7 +154,7 @@ function horizontalBarChart(
|
|
|
158
154
|
const value = getValue(entry);
|
|
159
155
|
const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth);
|
|
160
156
|
return `
|
|
161
|
-
<text x="${margin.left - 18}" y="${y + barHeight / 2 + 4}" text-anchor="end" font-size="13" font-family="${fontFamily}" fill="#122033">${escapeXml(
|
|
157
|
+
<text x="${margin.left - 18}" y="${y + barHeight / 2 + 4}" text-anchor="end" font-size="13" font-family="${fontFamily}" fill="#122033">${escapeXml(truncateTopModelChartLabel(getLabel(entry)))}</text>
|
|
162
158
|
<rect x="${margin.left}" y="${y}" width="${plotWidth}" height="${barHeight}" rx="12" fill="#eef3f8"/>
|
|
163
159
|
<rect x="${margin.left}" y="${y}" width="${barWidth}" height="${barHeight}" rx="12" fill="${getColor(entry)}"/>
|
|
164
160
|
<text x="${margin.left + plotWidth + 12}" y="${y + barHeight / 2 + 4}" font-size="12" font-family="${fontFamily}" fill="#475569">${escapeXml(formatter(value))}</text>
|
package/server/report/index.js
CHANGED
|
@@ -3,7 +3,7 @@ const os = require('os');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { spawn } = require('child_process');
|
|
5
5
|
const { buildReportData, formatCompactAxis, formatDateAxis } = require('./utils');
|
|
6
|
-
const { translate } = require('./i18n');
|
|
6
|
+
const { getLocale, translate } = require('./i18n');
|
|
7
7
|
const { horizontalBarChart, lineChart, stackedBarChart } = require('./charts');
|
|
8
8
|
|
|
9
9
|
function ensureTypstInstalled() {
|
|
@@ -39,10 +39,41 @@ function compileTypst(workingDir, typPath, pdfPath) {
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function formatCostAxisValue(value, language = 'de') {
|
|
43
|
+
const numericValue = Number(value) || 0;
|
|
44
|
+
const absoluteValue = Math.abs(numericValue);
|
|
45
|
+
const locale = getLocale(language);
|
|
46
|
+
|
|
47
|
+
if (absoluteValue >= 100) {
|
|
48
|
+
return `$${Math.round(numericValue).toLocaleString(locale)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (absoluteValue >= 10) {
|
|
52
|
+
return `$${numericValue.toLocaleString(locale, {
|
|
53
|
+
minimumFractionDigits: 0,
|
|
54
|
+
maximumFractionDigits: 1,
|
|
55
|
+
})}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (absoluteValue >= 1) {
|
|
59
|
+
return `$${numericValue.toLocaleString(locale, {
|
|
60
|
+
minimumFractionDigits: 0,
|
|
61
|
+
maximumFractionDigits: 2,
|
|
62
|
+
})}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return `$${numericValue.toLocaleString(locale, {
|
|
66
|
+
minimumFractionDigits: 2,
|
|
67
|
+
maximumFractionDigits: 2,
|
|
68
|
+
})}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
function buildTemplate() {
|
|
43
72
|
return `
|
|
44
73
|
#let report = json("report.json")
|
|
45
74
|
|
|
75
|
+
#set document(title: report.meta.reportTitle)
|
|
76
|
+
|
|
46
77
|
#set page(
|
|
47
78
|
paper: "a4",
|
|
48
79
|
margin: (x: 14mm, y: 16mm),
|
|
@@ -56,10 +87,10 @@ function buildTemplate() {
|
|
|
56
87
|
#let muted = rgb("#5c6b7e")
|
|
57
88
|
#let panel = rgb("#ffffff")
|
|
58
89
|
#let line = rgb("#d9e2ec")
|
|
59
|
-
#let accent = rgb("#
|
|
90
|
+
#let accent = rgb("#175fc0")
|
|
60
91
|
#let accent-soft = rgb("#eaf2ff")
|
|
61
92
|
#let good = rgb("#16825d")
|
|
62
|
-
#let warn = rgb("#
|
|
93
|
+
#let warn = rgb("#9a5a00")
|
|
63
94
|
|
|
64
95
|
#let metric-card(label, value, note: none, tone: accent) = rect(
|
|
65
96
|
inset: 10pt,
|
|
@@ -89,6 +120,22 @@ function buildTemplate() {
|
|
|
89
120
|
],
|
|
90
121
|
)
|
|
91
122
|
|
|
123
|
+
#let chart-panel(file, alt, summary, note: none) = rect(
|
|
124
|
+
inset: 10pt,
|
|
125
|
+
radius: 14pt,
|
|
126
|
+
fill: panel,
|
|
127
|
+
stroke: (paint: line, thickness: 0.8pt),
|
|
128
|
+
[
|
|
129
|
+
#image(file, width: 100%, alt: alt)
|
|
130
|
+
#v(6pt)
|
|
131
|
+
#text(size: 8.7pt, fill: muted)[#summary]
|
|
132
|
+
#if note != none [
|
|
133
|
+
#v(4pt)
|
|
134
|
+
#text(size: 8.5pt, fill: muted)[#note]
|
|
135
|
+
]
|
|
136
|
+
],
|
|
137
|
+
)
|
|
138
|
+
|
|
92
139
|
#show heading.where(level: 1): it => block(above: 0pt, below: 10pt)[
|
|
93
140
|
#text(size: 24pt, fill: ink, weight: "bold")[#it.body]
|
|
94
141
|
]
|
|
@@ -150,21 +197,26 @@ function buildTemplate() {
|
|
|
150
197
|
#grid(
|
|
151
198
|
columns: (1fr, 1fr),
|
|
152
199
|
gutter: 10pt,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
200
|
+
chart-panel(
|
|
201
|
+
"cost-trend.svg",
|
|
202
|
+
report.chartDescriptions.costTrend.alt,
|
|
203
|
+
report.chartDescriptions.costTrend.summary,
|
|
204
|
+
),
|
|
205
|
+
chart-panel(
|
|
206
|
+
"top-models.svg",
|
|
207
|
+
report.chartDescriptions.topModels.alt,
|
|
208
|
+
report.chartDescriptions.topModels.summary,
|
|
209
|
+
note: report.chartDescriptions.topModels.fullNamesNote,
|
|
210
|
+
),
|
|
159
211
|
)
|
|
160
212
|
|
|
161
213
|
#v(10pt)
|
|
162
214
|
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
215
|
+
#chart-panel(
|
|
216
|
+
"token-trend.svg",
|
|
217
|
+
report.chartDescriptions.tokenTrend.alt,
|
|
218
|
+
report.chartDescriptions.tokenTrend.summary,
|
|
219
|
+
)
|
|
168
220
|
|
|
169
221
|
#v(12pt)
|
|
170
222
|
|
|
@@ -279,14 +331,14 @@ function createChartAssets(reportData) {
|
|
|
279
331
|
title: reportData.text.charts.costTrend,
|
|
280
332
|
valueKey: 'cost',
|
|
281
333
|
secondaryKey: reportData.meta.filterSummary.viewModeKey === 'daily' ? 'ma7' : null,
|
|
282
|
-
formatter: (value) =>
|
|
334
|
+
formatter: (value) => formatCostAxisValue(value, reportData.meta.language),
|
|
283
335
|
}),
|
|
284
336
|
'top-models.svg': horizontalBarChart(topModels, {
|
|
285
337
|
title: reportData.text.charts.topModels,
|
|
286
338
|
getValue: (entry) => entry.cost,
|
|
287
339
|
getLabel: (entry) => entry.name,
|
|
288
340
|
getColor: (entry) => entry.color,
|
|
289
|
-
formatter: (value) =>
|
|
341
|
+
formatter: (value) => formatCostAxisValue(value, reportData.meta.language),
|
|
290
342
|
}),
|
|
291
343
|
'token-trend.svg': stackedBarChart(tokenTrend, {
|
|
292
344
|
title: reportData.text.charts.tokenTrend,
|
|
@@ -363,4 +415,9 @@ async function generatePdfReport(allDailyData, options = {}) {
|
|
|
363
415
|
|
|
364
416
|
module.exports = {
|
|
365
417
|
generatePdfReport,
|
|
418
|
+
__test__: {
|
|
419
|
+
buildTemplate,
|
|
420
|
+
createChartAssets,
|
|
421
|
+
formatCostAxisValue,
|
|
422
|
+
},
|
|
366
423
|
};
|