@roastcodes/ttdash 6.1.4 → 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/assets/index-TppJ6Iqj.css +2 -0
- package/dist/index.html +4 -4
- package/package.json +18 -5
- package/server/model-normalization.json +28 -0
- package/server/report/charts.js +151 -64
- package/server/report/index.js +149 -109
- package/server/report/utils.js +482 -85
- package/server.js +305 -191
- package/src/locales/de/common.json +78 -1
- package/src/locales/en/common.json +78 -1
- package/usage-normalizer.js +44 -36
- package/dist/assets/AutoImportModal-Dig6ASar.js +0 -2
- package/dist/assets/CustomTooltip-YeXs5zcp.js +0 -1
- package/dist/assets/DrillDownModal-Ct7hxDzy.js +0 -1
- package/dist/assets/index-9cPAel40.js +0 -4
- package/dist/assets/index-DWoj-vpZ.css +0 -2
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,21 +1373,13 @@ function json(res, status, data) {
|
|
|
1331
1373
|
res.end(JSON.stringify(data));
|
|
1332
1374
|
}
|
|
1333
1375
|
|
|
1334
|
-
function
|
|
1335
|
-
const stream = fs.createReadStream(filePath);
|
|
1376
|
+
function sendBuffer(res, status, headers, buffer) {
|
|
1336
1377
|
res.writeHead(status, {
|
|
1378
|
+
'Content-Length': buffer.length,
|
|
1337
1379
|
...headers,
|
|
1338
1380
|
...SECURITY_HEADERS,
|
|
1339
1381
|
});
|
|
1340
|
-
|
|
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);
|
|
1382
|
+
res.end(buffer);
|
|
1349
1383
|
}
|
|
1350
1384
|
|
|
1351
1385
|
function resolveApiPath(pathname) {
|
|
@@ -1376,6 +1410,22 @@ function shouldUseShell(command) {
|
|
|
1376
1410
|
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command);
|
|
1377
1411
|
}
|
|
1378
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
|
+
|
|
1379
1429
|
function spawnCommand(command, args, options = {}) {
|
|
1380
1430
|
return spawn(command, args, {
|
|
1381
1431
|
...options,
|
|
@@ -1404,9 +1454,9 @@ async function resolveToktrackRunner() {
|
|
|
1404
1454
|
};
|
|
1405
1455
|
}
|
|
1406
1456
|
|
|
1407
|
-
if (await commandExists(
|
|
1457
|
+
if (await commandExists(getExecutableName('bun'))) {
|
|
1408
1458
|
return {
|
|
1409
|
-
command:
|
|
1459
|
+
command: getExecutableName('bunx'),
|
|
1410
1460
|
prefixArgs: IS_WINDOWS ? ['x', 'toktrack'] : ['toktrack'],
|
|
1411
1461
|
env: process.env,
|
|
1412
1462
|
method: 'bunx',
|
|
@@ -1415,9 +1465,9 @@ async function resolveToktrackRunner() {
|
|
|
1415
1465
|
};
|
|
1416
1466
|
}
|
|
1417
1467
|
|
|
1418
|
-
if (await commandExists(
|
|
1468
|
+
if (await commandExists(getExecutableName('npx'))) {
|
|
1419
1469
|
return {
|
|
1420
|
-
command:
|
|
1470
|
+
command: getExecutableName('npx'),
|
|
1421
1471
|
prefixArgs: ['--yes', 'toktrack'],
|
|
1422
1472
|
env: {
|
|
1423
1473
|
...process.env,
|
|
@@ -1548,7 +1598,9 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) {
|
|
|
1548
1598
|
});
|
|
1549
1599
|
|
|
1550
1600
|
startupAutoLoadCompleted = true;
|
|
1551
|
-
console.log(
|
|
1601
|
+
console.log(
|
|
1602
|
+
`Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`,
|
|
1603
|
+
);
|
|
1552
1604
|
} catch (error) {
|
|
1553
1605
|
console.error(`Auto-load failed: ${error.message}`);
|
|
1554
1606
|
console.error('Dashboard will start without newly imported data.');
|
|
@@ -1567,22 +1619,30 @@ const server = http.createServer(async (req, res) => {
|
|
|
1567
1619
|
if (apiPath === '/usage') {
|
|
1568
1620
|
if (req.method === 'GET') {
|
|
1569
1621
|
const data = readData();
|
|
1570
|
-
return json(
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
+
},
|
|
1581
1637
|
},
|
|
1582
|
-
|
|
1638
|
+
);
|
|
1583
1639
|
}
|
|
1584
1640
|
if (req.method === 'DELETE') {
|
|
1585
|
-
try {
|
|
1641
|
+
try {
|
|
1642
|
+
fs.unlinkSync(DATA_FILE);
|
|
1643
|
+
} catch {
|
|
1644
|
+
// Ignore missing data files during reset.
|
|
1645
|
+
}
|
|
1586
1646
|
clearDataLoadState();
|
|
1587
1647
|
return json(res, 200, { success: true });
|
|
1588
1648
|
}
|
|
@@ -1610,7 +1670,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1610
1670
|
}
|
|
1611
1671
|
|
|
1612
1672
|
if (req.method === 'DELETE') {
|
|
1613
|
-
try {
|
|
1673
|
+
try {
|
|
1674
|
+
fs.unlinkSync(SETTINGS_FILE);
|
|
1675
|
+
} catch {
|
|
1676
|
+
// Ignore missing settings files during reset.
|
|
1677
|
+
}
|
|
1614
1678
|
return json(res, 200, { success: true, settings: readSettings() });
|
|
1615
1679
|
}
|
|
1616
1680
|
|
|
@@ -1653,9 +1717,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1653
1717
|
return json(res, 200, { days, totalCost });
|
|
1654
1718
|
} catch (e) {
|
|
1655
1719
|
const status = e.message === 'Payload too large' ? 413 : 400;
|
|
1656
|
-
const message =
|
|
1657
|
-
|
|
1658
|
-
|
|
1720
|
+
const message =
|
|
1721
|
+
e.message === 'Payload too large'
|
|
1722
|
+
? 'File too large (max. 10 MB)'
|
|
1723
|
+
: e.message || 'Invalid JSON';
|
|
1659
1724
|
return json(res, status, { message });
|
|
1660
1725
|
}
|
|
1661
1726
|
}
|
|
@@ -1688,13 +1753,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1688
1753
|
res.writeHead(200, {
|
|
1689
1754
|
'Content-Type': 'text/event-stream',
|
|
1690
1755
|
'Cache-Control': 'no-cache',
|
|
1691
|
-
|
|
1756
|
+
Connection: 'keep-alive',
|
|
1692
1757
|
'X-Accel-Buffering': 'no',
|
|
1693
1758
|
...SECURITY_HEADERS,
|
|
1694
1759
|
});
|
|
1695
1760
|
|
|
1696
1761
|
let aborted = false;
|
|
1697
|
-
req.on('close', () => {
|
|
1762
|
+
req.on('close', () => {
|
|
1763
|
+
aborted = true;
|
|
1764
|
+
});
|
|
1698
1765
|
|
|
1699
1766
|
try {
|
|
1700
1767
|
const result = await performAutoImport({
|
|
@@ -1719,13 +1786,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
1719
1786
|
},
|
|
1720
1787
|
});
|
|
1721
1788
|
|
|
1722
|
-
if (aborted) {
|
|
1789
|
+
if (aborted) {
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1723
1792
|
|
|
1724
1793
|
sendSSE(res, 'success', result);
|
|
1725
1794
|
sendSSE(res, 'done', {});
|
|
1726
1795
|
res.end();
|
|
1727
1796
|
} catch (err) {
|
|
1728
|
-
if (aborted) {
|
|
1797
|
+
if (aborted) {
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1729
1800
|
sendSSE(res, 'error', { message: `Error: ${err.message}` });
|
|
1730
1801
|
sendSSE(res, 'done', {});
|
|
1731
1802
|
res.end();
|
|
@@ -1743,29 +1814,28 @@ const server = http.createServer(async (req, res) => {
|
|
|
1743
1814
|
return json(res, 400, { message: 'No data available for the report.' });
|
|
1744
1815
|
}
|
|
1745
1816
|
|
|
1746
|
-
let body
|
|
1817
|
+
let body;
|
|
1747
1818
|
try {
|
|
1748
1819
|
body = await readBody(req);
|
|
1749
1820
|
} catch (e) {
|
|
1750
1821
|
const status = e.message === 'Payload too large' ? 413 : 400;
|
|
1751
|
-
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
|
+
});
|
|
1752
1826
|
}
|
|
1753
1827
|
|
|
1754
1828
|
try {
|
|
1755
1829
|
const result = await generatePdfReport(data.daily, body || {});
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
return sendFile(res, 200, {
|
|
1766
|
-
'Content-Type': 'application/pdf',
|
|
1767
|
-
'Content-Disposition': `attachment; filename="${result.filename}"`,
|
|
1768
|
-
}, result.pdfPath);
|
|
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'));
|