@roastcodes/ttdash 6.1.5 → 6.1.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/README.md +14 -4
- package/dist/assets/AutoImportModal-Dqbl8H04.js +2 -0
- package/dist/assets/CustomTooltip-DBPq6A_5.js +1 -0
- package/dist/assets/DrillDownModal-BKuxN4GY.js +1 -0
- package/dist/assets/index-D8uaHhGW.js +4 -0
- package/dist/index.html +3 -3
- package/package.json +18 -5
- package/server/model-normalization.json +28 -0
- package/server/report/charts.js +127 -54
- package/server/report/index.js +25 -5
- package/server/report/utils.js +292 -86
- package/server.js +302 -188
- package/src/locales/de/common.json +14 -0
- package/src/locales/en/common.json +14 -0
- package/usage-normalizer.js +44 -36
- package/dist/assets/AutoImportModal-Dig6ASar.js +0 -2
- package/dist/assets/CustomTooltip-Be-rHcDB.js +0 -1
- package/dist/assets/DrillDownModal-DXP44-00.js +0 -1
- package/dist/assets/index-_318nw_j.js +0 -4
package/server.js
CHANGED
|
@@ -26,13 +26,19 @@ const BIND_HOST = process.env.HOST || '127.0.0.1';
|
|
|
26
26
|
const API_PREFIX = '/port/5000/api';
|
|
27
27
|
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
28
28
|
const IS_WINDOWS = process.platform === 'win32';
|
|
29
|
-
const TOKTRACK_LOCAL_BIN = path.join(
|
|
29
|
+
const TOKTRACK_LOCAL_BIN = path.join(
|
|
30
|
+
ROOT,
|
|
31
|
+
'node_modules',
|
|
32
|
+
'.bin',
|
|
33
|
+
IS_WINDOWS ? 'toktrack.cmd' : 'toktrack',
|
|
34
|
+
);
|
|
30
35
|
const SECURITY_HEADERS = {
|
|
31
36
|
'X-Content-Type-Options': 'nosniff',
|
|
32
37
|
'Referrer-Policy': 'no-referrer',
|
|
33
38
|
'X-Frame-Options': 'DENY',
|
|
34
39
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
35
|
-
'Content-Security-Policy':
|
|
40
|
+
'Content-Security-Policy':
|
|
41
|
+
"default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
|
|
36
42
|
};
|
|
37
43
|
const APP_LABEL = 'TTDash';
|
|
38
44
|
const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup';
|
|
@@ -66,7 +72,9 @@ const DEFAULT_SETTINGS = {
|
|
|
66
72
|
providers: [],
|
|
67
73
|
models: [],
|
|
68
74
|
},
|
|
69
|
-
sectionVisibility: Object.fromEntries(
|
|
75
|
+
sectionVisibility: Object.fromEntries(
|
|
76
|
+
DASHBOARD_SECTION_IDS.map((sectionId) => [sectionId, true]),
|
|
77
|
+
),
|
|
70
78
|
sectionOrder: DASHBOARD_SECTION_IDS,
|
|
71
79
|
lastLoadedAt: null,
|
|
72
80
|
lastLoadSource: null,
|
|
@@ -223,14 +231,27 @@ function resolveAppPaths() {
|
|
|
223
231
|
};
|
|
224
232
|
} else if (IS_WINDOWS) {
|
|
225
233
|
platformPaths = {
|
|
226
|
-
dataDir: path.join(
|
|
227
|
-
|
|
228
|
-
|
|
234
|
+
dataDir: path.join(
|
|
235
|
+
process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
|
|
236
|
+
APP_DIR_NAME,
|
|
237
|
+
),
|
|
238
|
+
configDir: path.join(
|
|
239
|
+
process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
|
|
240
|
+
APP_DIR_NAME,
|
|
241
|
+
),
|
|
242
|
+
cacheDir: path.join(
|
|
243
|
+
process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
|
|
244
|
+
APP_DIR_NAME,
|
|
245
|
+
'Cache',
|
|
246
|
+
),
|
|
229
247
|
};
|
|
230
248
|
} else {
|
|
231
249
|
const appName = APP_DIR_NAME_LINUX;
|
|
232
250
|
platformPaths = {
|
|
233
|
-
dataDir: path.join(
|
|
251
|
+
dataDir: path.join(
|
|
252
|
+
process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'),
|
|
253
|
+
appName,
|
|
254
|
+
),
|
|
234
255
|
configDir: path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), appName),
|
|
235
256
|
cacheDir: path.join(process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache'), appName),
|
|
236
257
|
};
|
|
@@ -355,9 +376,9 @@ async function isBackgroundInstanceOwned(instance) {
|
|
|
355
376
|
return false;
|
|
356
377
|
}
|
|
357
378
|
|
|
358
|
-
return
|
|
359
|
-
&& runtime.pid === instance.pid
|
|
360
|
-
|
|
379
|
+
return (
|
|
380
|
+
runtime.id === instance.id && runtime.pid === instance.pid && runtime.port === instance.port
|
|
381
|
+
);
|
|
361
382
|
}
|
|
362
383
|
|
|
363
384
|
function normalizeBackgroundInstance(value) {
|
|
@@ -368,17 +389,19 @@ function normalizeBackgroundInstance(value) {
|
|
|
368
389
|
const pid = Number.parseInt(value.pid, 10);
|
|
369
390
|
const port = Number.parseInt(value.port, 10);
|
|
370
391
|
const startedAt = normalizeIsoTimestamp(value.startedAt);
|
|
371
|
-
const id = typeof value.id === 'string' && value.id.trim()
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
392
|
+
const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : null;
|
|
393
|
+
const url = typeof value.url === 'string' && value.url.trim() ? value.url.trim() : null;
|
|
394
|
+
const host = typeof value.host === 'string' && value.host.trim() ? value.host.trim() : BIND_HOST;
|
|
395
|
+
|
|
396
|
+
if (
|
|
397
|
+
!id ||
|
|
398
|
+
!url ||
|
|
399
|
+
!startedAt ||
|
|
400
|
+
!Number.isInteger(pid) ||
|
|
401
|
+
pid <= 0 ||
|
|
402
|
+
!Number.isInteger(port) ||
|
|
403
|
+
port <= 0
|
|
404
|
+
) {
|
|
382
405
|
return null;
|
|
383
406
|
}
|
|
384
407
|
|
|
@@ -389,9 +412,8 @@ function normalizeBackgroundInstance(value) {
|
|
|
389
412
|
url,
|
|
390
413
|
host,
|
|
391
414
|
startedAt,
|
|
392
|
-
logFile:
|
|
393
|
-
? value.logFile.trim()
|
|
394
|
-
: null,
|
|
415
|
+
logFile:
|
|
416
|
+
typeof value.logFile === 'string' && value.logFile.trim() ? value.logFile.trim() : null,
|
|
395
417
|
};
|
|
396
418
|
}
|
|
397
419
|
|
|
@@ -401,7 +423,9 @@ function readBackgroundInstancesRaw() {
|
|
|
401
423
|
if (Array.isArray(parsed)) {
|
|
402
424
|
return parsed;
|
|
403
425
|
}
|
|
404
|
-
} catch {
|
|
426
|
+
} catch {
|
|
427
|
+
// Ignore missing or invalid background registry state.
|
|
428
|
+
}
|
|
405
429
|
|
|
406
430
|
return [];
|
|
407
431
|
}
|
|
@@ -411,9 +435,7 @@ function writeBackgroundInstances(instances) {
|
|
|
411
435
|
}
|
|
412
436
|
|
|
413
437
|
async function readBackgroundInstancesSnapshot() {
|
|
414
|
-
const normalized = readBackgroundInstancesRaw()
|
|
415
|
-
.map(normalizeBackgroundInstance)
|
|
416
|
-
.filter(Boolean);
|
|
438
|
+
const normalized = readBackgroundInstancesRaw().map(normalizeBackgroundInstance).filter(Boolean);
|
|
417
439
|
const alive = [];
|
|
418
440
|
|
|
419
441
|
for (const instance of normalized) {
|
|
@@ -443,7 +465,10 @@ async function getBackgroundInstances() {
|
|
|
443
465
|
return (await readBackgroundInstancesSnapshot()).alive;
|
|
444
466
|
}
|
|
445
467
|
|
|
446
|
-
async function withBackgroundInstancesLock(
|
|
468
|
+
async function withBackgroundInstancesLock(
|
|
469
|
+
callback,
|
|
470
|
+
timeoutMs = BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS,
|
|
471
|
+
) {
|
|
447
472
|
const startedAt = Date.now();
|
|
448
473
|
|
|
449
474
|
while (true) {
|
|
@@ -458,18 +483,22 @@ async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INST
|
|
|
458
483
|
let lockIsStale = false;
|
|
459
484
|
try {
|
|
460
485
|
const stats = fs.statSync(BACKGROUND_INSTANCES_LOCK_DIR);
|
|
461
|
-
lockIsStale =
|
|
462
|
-
} catch {
|
|
486
|
+
lockIsStale = Date.now() - stats.mtimeMs > BACKGROUND_INSTANCES_LOCK_STALE_MS;
|
|
487
|
+
} catch {
|
|
488
|
+
// Ignore stat races while the lock directory is changing.
|
|
489
|
+
}
|
|
463
490
|
|
|
464
491
|
if (lockIsStale) {
|
|
465
492
|
try {
|
|
466
493
|
fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true });
|
|
467
494
|
continue;
|
|
468
|
-
} catch {
|
|
495
|
+
} catch {
|
|
496
|
+
// Ignore lock cleanup races and retry until timeout.
|
|
497
|
+
}
|
|
469
498
|
}
|
|
470
499
|
|
|
471
500
|
if (Date.now() - startedAt >= timeoutMs) {
|
|
472
|
-
throw new Error('Could not acquire background registry lock.');
|
|
501
|
+
throw new Error('Could not acquire background registry lock.', { cause: error });
|
|
473
502
|
}
|
|
474
503
|
|
|
475
504
|
await sleep(50);
|
|
@@ -481,7 +510,9 @@ async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INST
|
|
|
481
510
|
} finally {
|
|
482
511
|
try {
|
|
483
512
|
fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true });
|
|
484
|
-
} catch {
|
|
513
|
+
} catch {
|
|
514
|
+
// Ignore cleanup races after the lock holder exits.
|
|
515
|
+
}
|
|
485
516
|
}
|
|
486
517
|
}
|
|
487
518
|
|
|
@@ -604,7 +635,11 @@ async function promptForBackgroundInstance(instances) {
|
|
|
604
635
|
|
|
605
636
|
try {
|
|
606
637
|
while (true) {
|
|
607
|
-
const answer = (
|
|
638
|
+
const answer = (
|
|
639
|
+
await rl.question(
|
|
640
|
+
`Which instance should be stopped? [1-${instances.length}, Enter=cancel] `,
|
|
641
|
+
)
|
|
642
|
+
).trim();
|
|
608
643
|
|
|
609
644
|
if (!answer) {
|
|
610
645
|
return null;
|
|
@@ -683,22 +718,30 @@ async function runStopCommand() {
|
|
|
683
718
|
|
|
684
719
|
const result = await stopBackgroundInstance(selectedInstance);
|
|
685
720
|
if (result.status === 'stopped') {
|
|
686
|
-
console.log(
|
|
721
|
+
console.log(
|
|
722
|
+
`Stopped TTDash background server: ${selectedInstance.url} (PID ${selectedInstance.pid})`,
|
|
723
|
+
);
|
|
687
724
|
return;
|
|
688
725
|
}
|
|
689
726
|
|
|
690
727
|
if (result.status === 'already-stopped') {
|
|
691
|
-
console.log(
|
|
728
|
+
console.log(
|
|
729
|
+
`Instance was already stopped and was removed from the registry: ${selectedInstance.url} (PID ${selectedInstance.pid})`,
|
|
730
|
+
);
|
|
692
731
|
return;
|
|
693
732
|
}
|
|
694
733
|
|
|
695
734
|
if (result.status === 'forbidden') {
|
|
696
|
-
console.error(
|
|
735
|
+
console.error(
|
|
736
|
+
`Could not stop TTDash background server (permission denied): ${selectedInstance.url} (PID ${selectedInstance.pid})`,
|
|
737
|
+
);
|
|
697
738
|
process.exitCode = 1;
|
|
698
739
|
return;
|
|
699
740
|
}
|
|
700
741
|
|
|
701
|
-
console.error(
|
|
742
|
+
console.error(
|
|
743
|
+
`TTDash background server did not respond to SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`,
|
|
744
|
+
);
|
|
702
745
|
if (selectedInstance.logFile) {
|
|
703
746
|
console.error(`Log file: ${selectedInstance.logFile}`);
|
|
704
747
|
}
|
|
@@ -736,9 +779,7 @@ async function startInBackground() {
|
|
|
736
779
|
|
|
737
780
|
const instance = await waitForBackgroundInstance(child.pid);
|
|
738
781
|
if (!instance) {
|
|
739
|
-
const logOutput = fs.existsSync(logFile)
|
|
740
|
-
? fs.readFileSync(logFile, 'utf-8').trim()
|
|
741
|
-
: '';
|
|
782
|
+
const logOutput = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf-8').trim() : '';
|
|
742
783
|
throw new Error(logOutput || `Could not start TTDash as a background process. Log: ${logFile}`);
|
|
743
784
|
}
|
|
744
785
|
|
|
@@ -765,7 +806,9 @@ function migrateLegacyDataFile() {
|
|
|
765
806
|
fs.copyFileSync(LEGACY_DATA_FILE, DATA_FILE);
|
|
766
807
|
try {
|
|
767
808
|
fs.unlinkSync(LEGACY_DATA_FILE);
|
|
768
|
-
} catch {
|
|
809
|
+
} catch {
|
|
810
|
+
// Ignore best-effort cleanup failures after copying legacy data.
|
|
811
|
+
}
|
|
769
812
|
console.log(`Copying existing data to ${DATA_FILE}`);
|
|
770
813
|
}
|
|
771
814
|
}
|
|
@@ -787,9 +830,7 @@ function normalizeDashboardDatePreset(value) {
|
|
|
787
830
|
}
|
|
788
831
|
|
|
789
832
|
function normalizeLastLoadSource(value) {
|
|
790
|
-
return value === 'file' || value === 'auto-import' || value === 'cli-auto-load'
|
|
791
|
-
? value
|
|
792
|
-
: null;
|
|
833
|
+
return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' ? value : null;
|
|
793
834
|
}
|
|
794
835
|
|
|
795
836
|
function normalizeIsoTimestamp(value) {
|
|
@@ -815,30 +856,38 @@ function isPlainObject(value) {
|
|
|
815
856
|
}
|
|
816
857
|
|
|
817
858
|
function computeUsageTotals(daily) {
|
|
818
|
-
return daily.reduce(
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
859
|
+
return daily.reduce(
|
|
860
|
+
(totals, day) => ({
|
|
861
|
+
inputTokens: totals.inputTokens + (day.inputTokens || 0),
|
|
862
|
+
outputTokens: totals.outputTokens + (day.outputTokens || 0),
|
|
863
|
+
cacheCreationTokens: totals.cacheCreationTokens + (day.cacheCreationTokens || 0),
|
|
864
|
+
cacheReadTokens: totals.cacheReadTokens + (day.cacheReadTokens || 0),
|
|
865
|
+
thinkingTokens: totals.thinkingTokens + (day.thinkingTokens || 0),
|
|
866
|
+
totalCost: totals.totalCost + (day.totalCost || 0),
|
|
867
|
+
totalTokens: totals.totalTokens + (day.totalTokens || 0),
|
|
868
|
+
requestCount: totals.requestCount + (day.requestCount || 0),
|
|
869
|
+
}),
|
|
870
|
+
{
|
|
871
|
+
inputTokens: 0,
|
|
872
|
+
outputTokens: 0,
|
|
873
|
+
cacheCreationTokens: 0,
|
|
874
|
+
cacheReadTokens: 0,
|
|
875
|
+
thinkingTokens: 0,
|
|
876
|
+
totalCost: 0,
|
|
877
|
+
totalTokens: 0,
|
|
878
|
+
requestCount: 0,
|
|
879
|
+
},
|
|
880
|
+
);
|
|
837
881
|
}
|
|
838
882
|
|
|
839
883
|
function sortStrings(values) {
|
|
840
|
-
return [
|
|
841
|
-
|
|
884
|
+
return [
|
|
885
|
+
...new Set(
|
|
886
|
+
(Array.isArray(values) ? values : []).filter(
|
|
887
|
+
(value) => typeof value === 'string' && value.trim(),
|
|
888
|
+
),
|
|
889
|
+
),
|
|
890
|
+
].sort((left, right) => left.localeCompare(right));
|
|
842
891
|
}
|
|
843
892
|
|
|
844
893
|
function canonicalizeModelBreakdown(entry) {
|
|
@@ -918,9 +967,10 @@ function extractUsageImportPayload(payload) {
|
|
|
918
967
|
}
|
|
919
968
|
|
|
920
969
|
function mergeUsageData(currentData, importedData) {
|
|
921
|
-
const current =
|
|
922
|
-
|
|
923
|
-
|
|
970
|
+
const current =
|
|
971
|
+
currentData && Array.isArray(currentData.daily) && currentData.daily.length > 0
|
|
972
|
+
? normalizeIncomingData(currentData)
|
|
973
|
+
: null;
|
|
924
974
|
|
|
925
975
|
if (!current) {
|
|
926
976
|
return {
|
|
@@ -956,7 +1006,9 @@ function mergeUsageData(currentData, importedData) {
|
|
|
956
1006
|
conflictingDays += 1;
|
|
957
1007
|
}
|
|
958
1008
|
|
|
959
|
-
const mergedDaily = [...currentByDate.values()].sort((left, right) =>
|
|
1009
|
+
const mergedDaily = [...currentByDate.values()].sort((left, right) =>
|
|
1010
|
+
left.date.localeCompare(right.date),
|
|
1011
|
+
);
|
|
960
1012
|
|
|
961
1013
|
return {
|
|
962
1014
|
data: {
|
|
@@ -1006,10 +1058,14 @@ function normalizeStringList(value) {
|
|
|
1006
1058
|
return [];
|
|
1007
1059
|
}
|
|
1008
1060
|
|
|
1009
|
-
return [
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1061
|
+
return [
|
|
1062
|
+
...new Set(
|
|
1063
|
+
value
|
|
1064
|
+
.filter((entry) => typeof entry === 'string')
|
|
1065
|
+
.map((entry) => entry.trim())
|
|
1066
|
+
.filter(Boolean),
|
|
1067
|
+
),
|
|
1068
|
+
];
|
|
1013
1069
|
}
|
|
1014
1070
|
|
|
1015
1071
|
function normalizeDefaultFilters(value) {
|
|
@@ -1028,9 +1084,7 @@ function normalizeSectionVisibility(value) {
|
|
|
1028
1084
|
const next = {};
|
|
1029
1085
|
|
|
1030
1086
|
for (const sectionId of DASHBOARD_SECTION_IDS) {
|
|
1031
|
-
next[sectionId] = typeof source[sectionId] === 'boolean'
|
|
1032
|
-
? source[sectionId]
|
|
1033
|
-
: true;
|
|
1087
|
+
next[sectionId] = typeof source[sectionId] === 'boolean' ? source[sectionId] : true;
|
|
1034
1088
|
}
|
|
1035
1089
|
|
|
1036
1090
|
return next;
|
|
@@ -1041,9 +1095,9 @@ function normalizeSectionOrder(value) {
|
|
|
1041
1095
|
return [...DASHBOARD_SECTION_IDS];
|
|
1042
1096
|
}
|
|
1043
1097
|
|
|
1044
|
-
const incoming = value.filter(
|
|
1045
|
-
typeof sectionId === 'string' && DASHBOARD_SECTION_IDS.includes(sectionId)
|
|
1046
|
-
)
|
|
1098
|
+
const incoming = value.filter(
|
|
1099
|
+
(sectionId) => typeof sectionId === 'string' && DASHBOARD_SECTION_IDS.includes(sectionId),
|
|
1100
|
+
);
|
|
1047
1101
|
const uniqueIncoming = [...new Set(incoming)];
|
|
1048
1102
|
const missing = DASHBOARD_SECTION_IDS.filter((sectionId) => !uniqueIncoming.includes(sectionId));
|
|
1049
1103
|
|
|
@@ -1077,14 +1131,8 @@ function openBrowser(url) {
|
|
|
1077
1131
|
}
|
|
1078
1132
|
|
|
1079
1133
|
const platform = process.platform;
|
|
1080
|
-
const command = platform === 'darwin'
|
|
1081
|
-
|
|
1082
|
-
: platform === 'win32'
|
|
1083
|
-
? 'cmd'
|
|
1084
|
-
: 'xdg-open';
|
|
1085
|
-
const args = platform === 'win32'
|
|
1086
|
-
? ['/c', 'start', '', url]
|
|
1087
|
-
: [url];
|
|
1134
|
+
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
1135
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
1088
1136
|
|
|
1089
1137
|
const child = spawn(command, args, {
|
|
1090
1138
|
detached: true,
|
|
@@ -1140,15 +1188,9 @@ function describeDataFile() {
|
|
|
1140
1188
|
}
|
|
1141
1189
|
|
|
1142
1190
|
function printStartupSummary(url, port) {
|
|
1143
|
-
const browserMode = shouldOpenBrowser()
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
const autoLoadMode = CLI_OPTIONS.autoLoad
|
|
1147
|
-
? 'enabled'
|
|
1148
|
-
: 'disabled';
|
|
1149
|
-
const runtimeMode = IS_BACKGROUND_CHILD
|
|
1150
|
-
? 'background'
|
|
1151
|
-
: 'foreground';
|
|
1191
|
+
const browserMode = shouldOpenBrowser() ? 'enabled' : 'disabled';
|
|
1192
|
+
const autoLoadMode = CLI_OPTIONS.autoLoad ? 'enabled' : 'disabled';
|
|
1193
|
+
const runtimeMode = IS_BACKGROUND_CHILD ? 'background' : 'foreground';
|
|
1152
1194
|
|
|
1153
1195
|
console.log('');
|
|
1154
1196
|
console.log(`${APP_LABEL} v${APP_VERSION} is ready`);
|
|
@@ -1331,23 +1373,6 @@ function json(res, status, data) {
|
|
|
1331
1373
|
res.end(JSON.stringify(data));
|
|
1332
1374
|
}
|
|
1333
1375
|
|
|
1334
|
-
function sendFile(res, status, headers, filePath) {
|
|
1335
|
-
const stream = fs.createReadStream(filePath);
|
|
1336
|
-
res.writeHead(status, {
|
|
1337
|
-
...headers,
|
|
1338
|
-
...SECURITY_HEADERS,
|
|
1339
|
-
});
|
|
1340
|
-
stream.on('error', () => {
|
|
1341
|
-
if (!res.headersSent) {
|
|
1342
|
-
res.writeHead(500, SECURITY_HEADERS);
|
|
1343
|
-
res.end('Internal Server Error');
|
|
1344
|
-
return;
|
|
1345
|
-
}
|
|
1346
|
-
res.destroy();
|
|
1347
|
-
});
|
|
1348
|
-
stream.pipe(res);
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
1376
|
function sendBuffer(res, status, headers, buffer) {
|
|
1352
1377
|
res.writeHead(status, {
|
|
1353
1378
|
'Content-Length': buffer.length,
|
|
@@ -1385,6 +1410,22 @@ function shouldUseShell(command) {
|
|
|
1385
1410
|
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command);
|
|
1386
1411
|
}
|
|
1387
1412
|
|
|
1413
|
+
function getExecutableName(baseName, isWindows = IS_WINDOWS) {
|
|
1414
|
+
if (!isWindows) {
|
|
1415
|
+
return baseName;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
switch (baseName) {
|
|
1419
|
+
case 'bun':
|
|
1420
|
+
case 'bunx':
|
|
1421
|
+
return 'bun.exe';
|
|
1422
|
+
case 'npx':
|
|
1423
|
+
return 'npx.cmd';
|
|
1424
|
+
default:
|
|
1425
|
+
return baseName;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1388
1429
|
function spawnCommand(command, args, options = {}) {
|
|
1389
1430
|
return spawn(command, args, {
|
|
1390
1431
|
...options,
|
|
@@ -1413,9 +1454,9 @@ async function resolveToktrackRunner() {
|
|
|
1413
1454
|
};
|
|
1414
1455
|
}
|
|
1415
1456
|
|
|
1416
|
-
if (await commandExists(
|
|
1457
|
+
if (await commandExists(getExecutableName('bun'))) {
|
|
1417
1458
|
return {
|
|
1418
|
-
command:
|
|
1459
|
+
command: getExecutableName('bunx'),
|
|
1419
1460
|
prefixArgs: IS_WINDOWS ? ['x', 'toktrack'] : ['toktrack'],
|
|
1420
1461
|
env: process.env,
|
|
1421
1462
|
method: 'bunx',
|
|
@@ -1424,9 +1465,9 @@ async function resolveToktrackRunner() {
|
|
|
1424
1465
|
};
|
|
1425
1466
|
}
|
|
1426
1467
|
|
|
1427
|
-
if (await commandExists(
|
|
1468
|
+
if (await commandExists(getExecutableName('npx'))) {
|
|
1428
1469
|
return {
|
|
1429
|
-
command:
|
|
1470
|
+
command: getExecutableName('npx'),
|
|
1430
1471
|
prefixArgs: ['--yes', 'toktrack'],
|
|
1431
1472
|
env: {
|
|
1432
1473
|
...process.env,
|
|
@@ -1557,7 +1598,9 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) {
|
|
|
1557
1598
|
});
|
|
1558
1599
|
|
|
1559
1600
|
startupAutoLoadCompleted = true;
|
|
1560
|
-
console.log(
|
|
1601
|
+
console.log(
|
|
1602
|
+
`Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`,
|
|
1603
|
+
);
|
|
1561
1604
|
} catch (error) {
|
|
1562
1605
|
console.error(`Auto-load failed: ${error.message}`);
|
|
1563
1606
|
console.error('Dashboard will start without newly imported data.');
|
|
@@ -1576,22 +1619,30 @@ const server = http.createServer(async (req, res) => {
|
|
|
1576
1619
|
if (apiPath === '/usage') {
|
|
1577
1620
|
if (req.method === 'GET') {
|
|
1578
1621
|
const data = readData();
|
|
1579
|
-
return json(
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1622
|
+
return json(
|
|
1623
|
+
res,
|
|
1624
|
+
200,
|
|
1625
|
+
data || {
|
|
1626
|
+
daily: [],
|
|
1627
|
+
totals: {
|
|
1628
|
+
inputTokens: 0,
|
|
1629
|
+
outputTokens: 0,
|
|
1630
|
+
cacheCreationTokens: 0,
|
|
1631
|
+
cacheReadTokens: 0,
|
|
1632
|
+
thinkingTokens: 0,
|
|
1633
|
+
totalCost: 0,
|
|
1634
|
+
totalTokens: 0,
|
|
1635
|
+
requestCount: 0,
|
|
1636
|
+
},
|
|
1590
1637
|
},
|
|
1591
|
-
|
|
1638
|
+
);
|
|
1592
1639
|
}
|
|
1593
1640
|
if (req.method === 'DELETE') {
|
|
1594
|
-
try {
|
|
1641
|
+
try {
|
|
1642
|
+
fs.unlinkSync(DATA_FILE);
|
|
1643
|
+
} catch {
|
|
1644
|
+
// Ignore missing data files during reset.
|
|
1645
|
+
}
|
|
1595
1646
|
clearDataLoadState();
|
|
1596
1647
|
return json(res, 200, { success: true });
|
|
1597
1648
|
}
|
|
@@ -1619,7 +1670,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1619
1670
|
}
|
|
1620
1671
|
|
|
1621
1672
|
if (req.method === 'DELETE') {
|
|
1622
|
-
try {
|
|
1673
|
+
try {
|
|
1674
|
+
fs.unlinkSync(SETTINGS_FILE);
|
|
1675
|
+
} catch {
|
|
1676
|
+
// Ignore missing settings files during reset.
|
|
1677
|
+
}
|
|
1623
1678
|
return json(res, 200, { success: true, settings: readSettings() });
|
|
1624
1679
|
}
|
|
1625
1680
|
|
|
@@ -1662,9 +1717,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1662
1717
|
return json(res, 200, { days, totalCost });
|
|
1663
1718
|
} catch (e) {
|
|
1664
1719
|
const status = e.message === 'Payload too large' ? 413 : 400;
|
|
1665
|
-
const message =
|
|
1666
|
-
|
|
1667
|
-
|
|
1720
|
+
const message =
|
|
1721
|
+
e.message === 'Payload too large'
|
|
1722
|
+
? 'File too large (max. 10 MB)'
|
|
1723
|
+
: e.message || 'Invalid JSON';
|
|
1668
1724
|
return json(res, status, { message });
|
|
1669
1725
|
}
|
|
1670
1726
|
}
|
|
@@ -1697,13 +1753,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1697
1753
|
res.writeHead(200, {
|
|
1698
1754
|
'Content-Type': 'text/event-stream',
|
|
1699
1755
|
'Cache-Control': 'no-cache',
|
|
1700
|
-
|
|
1756
|
+
Connection: 'keep-alive',
|
|
1701
1757
|
'X-Accel-Buffering': 'no',
|
|
1702
1758
|
...SECURITY_HEADERS,
|
|
1703
1759
|
});
|
|
1704
1760
|
|
|
1705
1761
|
let aborted = false;
|
|
1706
|
-
req.on('close', () => {
|
|
1762
|
+
req.on('close', () => {
|
|
1763
|
+
aborted = true;
|
|
1764
|
+
});
|
|
1707
1765
|
|
|
1708
1766
|
try {
|
|
1709
1767
|
const result = await performAutoImport({
|
|
@@ -1728,13 +1786,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
1728
1786
|
},
|
|
1729
1787
|
});
|
|
1730
1788
|
|
|
1731
|
-
if (aborted) {
|
|
1789
|
+
if (aborted) {
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1732
1792
|
|
|
1733
1793
|
sendSSE(res, 'success', result);
|
|
1734
1794
|
sendSSE(res, 'done', {});
|
|
1735
1795
|
res.end();
|
|
1736
1796
|
} catch (err) {
|
|
1737
|
-
if (aborted) {
|
|
1797
|
+
if (aborted) {
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1738
1800
|
sendSSE(res, 'error', { message: `Error: ${err.message}` });
|
|
1739
1801
|
sendSSE(res, 'done', {});
|
|
1740
1802
|
res.end();
|
|
@@ -1752,20 +1814,28 @@ const server = http.createServer(async (req, res) => {
|
|
|
1752
1814
|
return json(res, 400, { message: 'No data available for the report.' });
|
|
1753
1815
|
}
|
|
1754
1816
|
|
|
1755
|
-
let body
|
|
1817
|
+
let body;
|
|
1756
1818
|
try {
|
|
1757
1819
|
body = await readBody(req);
|
|
1758
1820
|
} catch (e) {
|
|
1759
1821
|
const status = e.message === 'Payload too large' ? 413 : 400;
|
|
1760
|
-
return json(res, status, {
|
|
1822
|
+
return json(res, status, {
|
|
1823
|
+
message:
|
|
1824
|
+
e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request',
|
|
1825
|
+
});
|
|
1761
1826
|
}
|
|
1762
1827
|
|
|
1763
1828
|
try {
|
|
1764
1829
|
const result = await generatePdfReport(data.daily, body || {});
|
|
1765
|
-
return sendBuffer(
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1830
|
+
return sendBuffer(
|
|
1831
|
+
res,
|
|
1832
|
+
200,
|
|
1833
|
+
{
|
|
1834
|
+
'Content-Type': 'application/pdf',
|
|
1835
|
+
'Content-Disposition': `attachment; filename="${result.filename}"`,
|
|
1836
|
+
},
|
|
1837
|
+
result.buffer,
|
|
1838
|
+
);
|
|
1769
1839
|
} catch (error) {
|
|
1770
1840
|
const message = error && error.message ? error.message : 'PDF generation failed';
|
|
1771
1841
|
const status = error && error.code === 'TYPST_MISSING' ? 503 : 500;
|
|
@@ -1781,43 +1851,68 @@ const server = http.createServer(async (req, res) => {
|
|
|
1781
1851
|
const safePath = pathname === '/' ? '/index.html' : pathname;
|
|
1782
1852
|
const filePath = path.resolve(STATIC_ROOT, `.${safePath}`);
|
|
1783
1853
|
|
|
1784
|
-
if (
|
|
1854
|
+
if (
|
|
1855
|
+
!filePath.startsWith(path.resolve(STATIC_ROOT) + path.sep) &&
|
|
1856
|
+
filePath !== path.resolve(STATIC_ROOT, 'index.html')
|
|
1857
|
+
) {
|
|
1785
1858
|
return json(res, 403, { message: 'Access denied' });
|
|
1786
1859
|
}
|
|
1787
1860
|
|
|
1788
1861
|
serveFile(res, filePath);
|
|
1789
1862
|
});
|
|
1790
1863
|
|
|
1791
|
-
function
|
|
1792
|
-
return new
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1864
|
+
function createNoFreePortError(rangeStartPort, maxPort) {
|
|
1865
|
+
return new Error(`No free port found (${rangeStartPort}-${maxPort})`);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
async function listenOnAvailablePort(
|
|
1869
|
+
serverInstance,
|
|
1870
|
+
port,
|
|
1871
|
+
maxPort,
|
|
1872
|
+
bindHost,
|
|
1873
|
+
log = console.log,
|
|
1874
|
+
rangeStartPort = port,
|
|
1875
|
+
) {
|
|
1876
|
+
if (port > maxPort) {
|
|
1877
|
+
throw createNoFreePortError(rangeStartPort, maxPort);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
for (let currentPort = port; currentPort <= maxPort; currentPort += 1) {
|
|
1881
|
+
try {
|
|
1882
|
+
await new Promise((resolve, reject) => {
|
|
1883
|
+
const onError = (err) => {
|
|
1884
|
+
serverInstance.off('listening', onListening);
|
|
1885
|
+
reject(err);
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1888
|
+
const onListening = () => {
|
|
1889
|
+
serverInstance.off('error', onError);
|
|
1890
|
+
resolve();
|
|
1891
|
+
};
|
|
1892
|
+
|
|
1893
|
+
serverInstance.once('error', onError);
|
|
1894
|
+
serverInstance.once('listening', onListening);
|
|
1895
|
+
serverInstance.listen(currentPort, bindHost);
|
|
1896
|
+
});
|
|
1797
1897
|
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
if (err.code === 'EADDRINUSE') {
|
|
1801
|
-
if (
|
|
1802
|
-
|
|
1803
|
-
return;
|
|
1898
|
+
return currentPort;
|
|
1899
|
+
} catch (err) {
|
|
1900
|
+
if (err && err.code === 'EADDRINUSE') {
|
|
1901
|
+
if (currentPort >= maxPort) {
|
|
1902
|
+
throw createNoFreePortError(rangeStartPort, maxPort);
|
|
1804
1903
|
}
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
} else {
|
|
1808
|
-
reject(err);
|
|
1904
|
+
log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`);
|
|
1905
|
+
continue;
|
|
1809
1906
|
}
|
|
1810
|
-
|
|
1907
|
+
throw err;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1811
1910
|
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
resolve(port);
|
|
1815
|
-
};
|
|
1911
|
+
throw createNoFreePortError(rangeStartPort, maxPort);
|
|
1912
|
+
}
|
|
1816
1913
|
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
server.listen(port, BIND_HOST);
|
|
1820
|
-
});
|
|
1914
|
+
function tryListen(port) {
|
|
1915
|
+
return listenOnAvailablePort(server, port, MAX_PORT, BIND_HOST, console.log, START_PORT);
|
|
1821
1916
|
}
|
|
1822
1917
|
|
|
1823
1918
|
async function start() {
|
|
@@ -1858,18 +1953,40 @@ async function runCli() {
|
|
|
1858
1953
|
await start();
|
|
1859
1954
|
}
|
|
1860
1955
|
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
.
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
}
|
|
1956
|
+
function registerShutdownHandlers() {
|
|
1957
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1958
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function bootstrapCli() {
|
|
1962
|
+
runCli().catch((error) => {
|
|
1963
|
+
Promise.resolve()
|
|
1964
|
+
.then(async () => {
|
|
1965
|
+
if (IS_BACKGROUND_CHILD) {
|
|
1966
|
+
await unregisterBackgroundInstance(process.pid);
|
|
1967
|
+
}
|
|
1968
|
+
})
|
|
1969
|
+
.finally(() => {
|
|
1970
|
+
console.error(error);
|
|
1971
|
+
process.exit(1);
|
|
1972
|
+
});
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
registerShutdownHandlers();
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
module.exports = {
|
|
1979
|
+
bootstrapCli,
|
|
1980
|
+
runCli,
|
|
1981
|
+
__test__: {
|
|
1982
|
+
getExecutableName,
|
|
1983
|
+
listenOnAvailablePort,
|
|
1984
|
+
},
|
|
1985
|
+
};
|
|
1986
|
+
|
|
1987
|
+
if (require.main === module) {
|
|
1988
|
+
bootstrapCli();
|
|
1989
|
+
}
|
|
1873
1990
|
|
|
1874
1991
|
// Graceful shutdown on Ctrl+C / kill
|
|
1875
1992
|
function shutdown(signal) {
|
|
@@ -1890,6 +2007,3 @@ function shutdown(signal) {
|
|
|
1890
2007
|
process.exit(0);
|
|
1891
2008
|
}, 3000);
|
|
1892
2009
|
}
|
|
1893
|
-
|
|
1894
|
-
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1895
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'));
|