@roastcodes/ttdash 6.2.0 → 6.2.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/dist/assets/AnimatedBarFill-Da354-1y.js +1 -0
- package/dist/assets/AnomalyDetection-D06kgR_V.js +1 -0
- package/dist/assets/{AutoImportModal-C8gA0_mL.js → AutoImportModal-CuzbrUVc.js} +3 -3
- package/dist/assets/CacheROI-fJ1paHaT.js +1 -0
- package/dist/assets/ChartCard-CS4Kbe6r.js +2 -0
- package/dist/assets/ChartLegend-Vzg_GmF-.js +1 -0
- package/dist/assets/CorrelationAnalysis-DX-ZQKVJ.js +1 -0
- package/dist/assets/CostByModelOverTime-BRo1PCRo.js +1 -0
- package/dist/assets/CostByWeekday-BJwEWkmF.js +1 -0
- package/dist/assets/CostForecast-BxICbpzM.js +1 -0
- package/dist/assets/CumulativeCost-BdlOTRDX.js +1 -0
- package/dist/assets/CustomTooltip-CEd8U-a3.js +1 -0
- package/dist/assets/DistributionAnalysis-Cw8bRtt9.js +1 -0
- package/dist/assets/DrillDownModal-BT2rFk_U.js +1 -0
- package/dist/assets/HelpPanel-Ct4CbfBg.js +1 -0
- package/dist/assets/InfoButton-CyA48Vlx.js +1 -0
- package/dist/assets/InfoHeading-CFbl45C9.js +1 -0
- package/dist/assets/MetricCard-CLFpH1l1.js +1 -0
- package/dist/assets/ModelEfficiency-CO7sQQnT.js +1 -0
- package/dist/assets/ModelMix-C4P2SpQZ.js +1 -0
- package/dist/assets/PeriodComparison-BogfA2IN.js +1 -0
- package/dist/assets/ProviderEfficiency-CY4HJ7Fy.js +1 -0
- package/dist/assets/ProviderLimitsSection-BcCrH2XG.js +1 -0
- package/dist/assets/RecentDays-K0Uzk7UU.js +1 -0
- package/dist/assets/RequestCacheHitRateByModel-8ip50S7V.js +1 -0
- package/dist/assets/RequestQuality-ByYXyzn8.js +1 -0
- package/dist/assets/RequestsOverTime-B4yB-x_5.js +1 -0
- package/dist/assets/SettingsModal-Badl8kHp.js +1 -0
- package/dist/assets/TokenEfficiency-Bhdj4v75.js +1 -0
- package/dist/assets/TokenTypes-CaVA8FXP.js +1 -0
- package/dist/assets/TokensOverTime-BA7W_OfG.js +1 -0
- package/dist/assets/app-settings-EFIYxqQU.js +1 -0
- package/dist/assets/button-olcuyddH.js +1 -0
- package/dist/assets/calculations-BJw_4KdI.js +1 -0
- package/dist/assets/card-CskYMXga.js +1 -0
- package/dist/assets/chart-theme-BdE8bEiN.js +1 -0
- package/dist/assets/constants-DOCA3QtR.js +1 -0
- package/dist/assets/dialog-D4LfH66U.js +1 -0
- package/dist/assets/formatted-value-DuGw8WPX.js +1 -0
- package/dist/assets/formatters-BzLLk0br.js +1 -0
- package/dist/assets/help-content-Cuf5l3_6.js +1 -0
- package/dist/assets/i18n-D1WJ4-W9.js +1 -0
- package/dist/assets/index-8kk0H-lQ.css +2 -0
- package/dist/assets/index-D6_2fCBR.js +3 -0
- package/dist/assets/model-utils-BIvGAFGz.js +1 -0
- package/dist/assets/motion-vendor-BNFhNARW.js +9 -0
- package/dist/assets/provider-limits-BgKrr7k7.js +1 -0
- package/dist/assets/section-header-KpKzq-Qa.js +1 -0
- package/dist/assets/ui-vendor-DOI7LsxH.js +45 -0
- package/dist/assets/useTranslation-JH30oWEP.js +1 -0
- package/dist/index.html +27 -8
- package/package.json +15 -5
- package/server/report/index.js +13 -11
- package/server.js +281 -56
- package/shared/dashboard-domain.d.ts +12 -0
- package/shared/dashboard-domain.js +79 -0
- package/shared/dashboard-types.d.ts +4 -0
- package/shared/model-colors.d.ts +7 -0
- package/shared/model-colors.js +27 -0
- package/src/locales/de/common.json +51 -5
- package/src/locales/en/common.json +51 -4
- package/dist/assets/CustomTooltip-CdIOw3Ep.js +0 -1
- package/dist/assets/DrillDownModal-d6hcut-I.js +0 -1
- package/dist/assets/button-B26tLVFw.js +0 -1
- package/dist/assets/dialog-CA-ZSHjK.js +0 -1
- package/dist/assets/index-BkGSNAne.css +0 -2
- package/dist/assets/index-CMtAn7c8.js +0 -4
- package/dist/assets/motion-vendor-BXI2L__C.js +0 -1
- package/dist/assets/ui-vendor-BGjRFQGY.js +0 -45
- /package/dist/assets/{icons-vendor-z59La6A4.js → icons-vendor-CIvT_-Pb.js} +0 -0
package/server.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const http = require('http');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const fsPromises = require('fs/promises');
|
|
5
6
|
const os = require('os');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
const readline = require('readline/promises');
|
|
@@ -271,6 +272,9 @@ const BACKGROUND_LOG_DIR = path.join(APP_PATHS.cacheDir, 'background');
|
|
|
271
272
|
const BACKGROUND_INSTANCES_LOCK_DIR = path.join(APP_PATHS.configDir, 'background-instances.lock');
|
|
272
273
|
const BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS = 5000;
|
|
273
274
|
const BACKGROUND_INSTANCES_LOCK_STALE_MS = 10000;
|
|
275
|
+
const FILE_MUTATION_LOCK_TIMEOUT_MS = 10000;
|
|
276
|
+
const FILE_MUTATION_LOCK_STALE_MS = 30000;
|
|
277
|
+
const fileMutationLocks = new Map();
|
|
274
278
|
|
|
275
279
|
const MIME_TYPES = {
|
|
276
280
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -314,6 +318,214 @@ function writeJsonAtomic(filePath, data) {
|
|
|
314
318
|
fs.renameSync(tempPath, filePath);
|
|
315
319
|
}
|
|
316
320
|
|
|
321
|
+
async function writeJsonAtomicAsync(filePath, data) {
|
|
322
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
323
|
+
let tempPathCreated = false;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
await fsPromises.mkdir(path.dirname(filePath), { recursive: true, mode: SECURE_DIR_MODE });
|
|
327
|
+
tempPathCreated = true;
|
|
328
|
+
await fsPromises.writeFile(tempPath, JSON.stringify(data, null, 2), {
|
|
329
|
+
mode: SECURE_FILE_MODE,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (!IS_WINDOWS) {
|
|
333
|
+
await fsPromises.chmod(tempPath, SECURE_FILE_MODE);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await fsPromises.rename(tempPath, filePath);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (tempPathCreated) {
|
|
339
|
+
try {
|
|
340
|
+
await fsPromises.unlink(tempPath);
|
|
341
|
+
} catch (unlinkError) {
|
|
342
|
+
if (unlinkError?.code !== 'ENOENT') {
|
|
343
|
+
// Ignore temp-file cleanup failures so the original error wins.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function unlinkIfExists(filePath) {
|
|
352
|
+
try {
|
|
353
|
+
await fsPromises.unlink(filePath);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error?.code !== 'ENOENT') {
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function getFileMutationLockDir(filePath) {
|
|
362
|
+
return `${filePath}.lock`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function getFileMutationLockOwnerPath(lockDir) {
|
|
366
|
+
return path.join(lockDir, 'owner.json');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function removeFileMutationLockDir(lockDir) {
|
|
370
|
+
try {
|
|
371
|
+
await fsPromises.rm(lockDir, { recursive: true, force: true });
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error?.code !== 'ENOENT') {
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function writeFileMutationLockOwner(lockDir) {
|
|
380
|
+
const ownerPath = getFileMutationLockOwnerPath(lockDir);
|
|
381
|
+
const owner = {
|
|
382
|
+
pid: process.pid,
|
|
383
|
+
createdAt: new Date().toISOString(),
|
|
384
|
+
instanceId: RUNTIME_INSTANCE.id,
|
|
385
|
+
};
|
|
386
|
+
await fsPromises.writeFile(ownerPath, JSON.stringify(owner, null, 2), {
|
|
387
|
+
mode: SECURE_FILE_MODE,
|
|
388
|
+
});
|
|
389
|
+
if (!IS_WINDOWS) {
|
|
390
|
+
await fsPromises.chmod(ownerPath, SECURE_FILE_MODE);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function shouldReapFileMutationLock(lockDir) {
|
|
395
|
+
const ownerPath = getFileMutationLockOwnerPath(lockDir);
|
|
396
|
+
let owner = null;
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
const rawOwner = await fsPromises.readFile(ownerPath, 'utf-8');
|
|
400
|
+
owner = JSON.parse(rawOwner);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (error?.code !== 'ENOENT') {
|
|
403
|
+
// Fall back to age-based cleanup if the owner metadata is missing or malformed.
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const ownerCreatedAt = owner?.createdAt ? Date.parse(owner.createdAt) : Number.NaN;
|
|
409
|
+
const stats = await fsPromises.stat(lockDir);
|
|
410
|
+
const lockAgeMs = Number.isFinite(ownerCreatedAt)
|
|
411
|
+
? Date.now() - ownerCreatedAt
|
|
412
|
+
: Date.now() - stats.mtimeMs;
|
|
413
|
+
|
|
414
|
+
if (lockAgeMs > FILE_MUTATION_LOCK_STALE_MS) {
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (Number.isInteger(owner?.pid)) {
|
|
419
|
+
return !isProcessRunning(owner.pid);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return false;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
if (error?.code === 'ENOENT') {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function withCrossProcessFileMutationLock(
|
|
432
|
+
filePath,
|
|
433
|
+
operation,
|
|
434
|
+
timeoutMs = FILE_MUTATION_LOCK_TIMEOUT_MS,
|
|
435
|
+
) {
|
|
436
|
+
const lockDir = getFileMutationLockDir(filePath);
|
|
437
|
+
const startedAt = Date.now();
|
|
438
|
+
|
|
439
|
+
while (true) {
|
|
440
|
+
try {
|
|
441
|
+
await fsPromises.mkdir(path.dirname(lockDir), {
|
|
442
|
+
recursive: true,
|
|
443
|
+
mode: SECURE_DIR_MODE,
|
|
444
|
+
});
|
|
445
|
+
await fsPromises.mkdir(lockDir, { mode: SECURE_DIR_MODE });
|
|
446
|
+
if (!IS_WINDOWS) {
|
|
447
|
+
await fsPromises.chmod(lockDir, SECURE_DIR_MODE);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
await writeFileMutationLockOwner(lockDir);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
await removeFileMutationLockDir(lockDir).catch(() => undefined);
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
break;
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (!error || error.code !== 'EEXIST') {
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (await shouldReapFileMutationLock(lockDir)) {
|
|
464
|
+
await removeFileMutationLockDir(lockDir).catch(() => undefined);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
469
|
+
throw new Error(`Could not acquire file mutation lock for ${path.basename(filePath)}.`, {
|
|
470
|
+
cause: error,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
await sleep(50);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
return await operation();
|
|
480
|
+
} finally {
|
|
481
|
+
try {
|
|
482
|
+
await removeFileMutationLockDir(lockDir);
|
|
483
|
+
} catch {
|
|
484
|
+
// Ignore cleanup races so the original operation result wins.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function withFileMutationLock(filePath, operation) {
|
|
490
|
+
const previous = fileMutationLocks.get(filePath) || Promise.resolve();
|
|
491
|
+
let releaseCurrent;
|
|
492
|
+
const current = new Promise((resolve) => {
|
|
493
|
+
releaseCurrent = resolve;
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
fileMutationLocks.set(filePath, current);
|
|
497
|
+
|
|
498
|
+
await previous.catch(() => undefined);
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
return await withCrossProcessFileMutationLock(filePath, operation);
|
|
502
|
+
} finally {
|
|
503
|
+
releaseCurrent();
|
|
504
|
+
if (fileMutationLocks.get(filePath) === current) {
|
|
505
|
+
fileMutationLocks.delete(filePath);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function withOrderedFileMutationLocks(filePaths, operation) {
|
|
511
|
+
const uniquePaths = Array.from(new Set(filePaths)).sort();
|
|
512
|
+
|
|
513
|
+
const runWithLock = async (index) => {
|
|
514
|
+
if (index >= uniquePaths.length) {
|
|
515
|
+
return operation();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const filePath = uniquePaths[index];
|
|
519
|
+
return withFileMutationLock(filePath, () => runWithLock(index + 1));
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
return runWithLock(0);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function withSettingsAndDataMutationLock(operation) {
|
|
526
|
+
return withOrderedFileMutationLocks([SETTINGS_FILE, DATA_FILE], operation);
|
|
527
|
+
}
|
|
528
|
+
|
|
317
529
|
function sleep(ms) {
|
|
318
530
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
319
531
|
}
|
|
@@ -1389,8 +1601,8 @@ function readData() {
|
|
|
1389
1601
|
}
|
|
1390
1602
|
}
|
|
1391
1603
|
|
|
1392
|
-
function writeData(data) {
|
|
1393
|
-
|
|
1604
|
+
async function writeData(data) {
|
|
1605
|
+
await writeJsonAtomicAsync(DATA_FILE, data);
|
|
1394
1606
|
}
|
|
1395
1607
|
|
|
1396
1608
|
function readSettings() {
|
|
@@ -1420,53 +1632,43 @@ function readSettingsForWrite() {
|
|
|
1420
1632
|
}
|
|
1421
1633
|
}
|
|
1422
1634
|
|
|
1423
|
-
function writeSettings(settings) {
|
|
1424
|
-
|
|
1635
|
+
async function writeSettings(settings) {
|
|
1636
|
+
await writeJsonAtomicAsync(SETTINGS_FILE, normalizeSettings(settings));
|
|
1425
1637
|
}
|
|
1426
1638
|
|
|
1427
|
-
function
|
|
1639
|
+
async function updateDataLoadState(patch) {
|
|
1428
1640
|
const current = readSettingsForWrite();
|
|
1429
1641
|
const next = {
|
|
1430
1642
|
...current,
|
|
1431
|
-
...
|
|
1643
|
+
...patch,
|
|
1432
1644
|
};
|
|
1433
1645
|
|
|
1434
|
-
|
|
1435
|
-
next.providerLimits = normalizeProviderLimits(patch.providerLimits);
|
|
1436
|
-
} else {
|
|
1437
|
-
next.providerLimits = current.providerLimits;
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
next.language = normalizeLanguage(next.language);
|
|
1441
|
-
next.theme = normalizeTheme(next.theme);
|
|
1442
|
-
|
|
1443
|
-
writeSettings(next);
|
|
1646
|
+
await writeSettings(next);
|
|
1444
1647
|
return toSettingsResponse(next);
|
|
1445
1648
|
}
|
|
1446
1649
|
|
|
1447
|
-
function
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1650
|
+
async function updateSettings(patch) {
|
|
1651
|
+
return withFileMutationLock(SETTINGS_FILE, async () => {
|
|
1652
|
+
const current = readSettingsForWrite();
|
|
1653
|
+
const next = {
|
|
1654
|
+
...current,
|
|
1655
|
+
...(patch && typeof patch === 'object' ? patch : {}),
|
|
1656
|
+
};
|
|
1454
1657
|
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
}
|
|
1658
|
+
if (patch && Object.prototype.hasOwnProperty.call(patch, 'providerLimits')) {
|
|
1659
|
+
next.providerLimits = normalizeProviderLimits(patch.providerLimits);
|
|
1660
|
+
} else {
|
|
1661
|
+
next.providerLimits = current.providerLimits;
|
|
1662
|
+
}
|
|
1458
1663
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
const next = {
|
|
1462
|
-
...current,
|
|
1463
|
-
lastLoadedAt: null,
|
|
1464
|
-
lastLoadSource: null,
|
|
1465
|
-
};
|
|
1664
|
+
next.language = normalizeLanguage(next.language);
|
|
1665
|
+
next.theme = normalizeTheme(next.theme);
|
|
1466
1666
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1667
|
+
await writeSettings(next);
|
|
1668
|
+
return toSettingsResponse(next);
|
|
1669
|
+
});
|
|
1469
1670
|
}
|
|
1671
|
+
|
|
1470
1672
|
const { json, readBody, resolveApiPath, sendBuffer, validateMutationRequest } = createHttpUtils({
|
|
1471
1673
|
apiPrefix: API_PREFIX,
|
|
1472
1674
|
maxBodySize: MAX_BODY_SIZE,
|
|
@@ -1647,8 +1849,13 @@ async function performAutoImport({
|
|
|
1647
1849
|
});
|
|
1648
1850
|
|
|
1649
1851
|
const normalized = normalizeIncomingData(JSON.parse(rawJson));
|
|
1650
|
-
|
|
1651
|
-
|
|
1852
|
+
await withSettingsAndDataMutationLock(async () => {
|
|
1853
|
+
await writeData(normalized);
|
|
1854
|
+
await updateDataLoadState({
|
|
1855
|
+
lastLoadedAt: new Date().toISOString(),
|
|
1856
|
+
lastLoadSource: source,
|
|
1857
|
+
});
|
|
1858
|
+
});
|
|
1652
1859
|
|
|
1653
1860
|
return {
|
|
1654
1861
|
days: normalized.daily.length,
|
|
@@ -1743,12 +1950,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1743
1950
|
if (validationError) {
|
|
1744
1951
|
return json(res, validationError.status, { message: validationError.message });
|
|
1745
1952
|
}
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1953
|
+
await withSettingsAndDataMutationLock(async () => {
|
|
1954
|
+
await unlinkIfExists(DATA_FILE);
|
|
1955
|
+
await updateDataLoadState({
|
|
1956
|
+
lastLoadedAt: null,
|
|
1957
|
+
lastLoadSource: null,
|
|
1958
|
+
});
|
|
1959
|
+
});
|
|
1752
1960
|
return json(res, 200, { success: true });
|
|
1753
1961
|
}
|
|
1754
1962
|
return json(res, 405, { message: 'Method Not Allowed' });
|
|
@@ -1786,11 +1994,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1786
1994
|
if (validationError) {
|
|
1787
1995
|
return json(res, validationError.status, { message: validationError.message });
|
|
1788
1996
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
}
|
|
1792
|
-
// Ignore missing settings files during reset.
|
|
1793
|
-
}
|
|
1997
|
+
await withFileMutationLock(SETTINGS_FILE, async () => {
|
|
1998
|
+
await unlinkIfExists(SETTINGS_FILE);
|
|
1999
|
+
});
|
|
1794
2000
|
return json(res, 200, { success: true, settings: readSettings() });
|
|
1795
2001
|
}
|
|
1796
2002
|
|
|
@@ -1801,7 +2007,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1801
2007
|
}
|
|
1802
2008
|
try {
|
|
1803
2009
|
const body = await readBody(req);
|
|
1804
|
-
return json(res, 200, updateSettings(body));
|
|
2010
|
+
return json(res, 200, await updateSettings(body));
|
|
1805
2011
|
} catch (e) {
|
|
1806
2012
|
if (isPayloadTooLargeError(e)) {
|
|
1807
2013
|
return json(res, 413, { message: 'Settings request too large' });
|
|
@@ -1826,7 +2032,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1826
2032
|
try {
|
|
1827
2033
|
const body = await readBody(req);
|
|
1828
2034
|
const importedSettings = normalizeSettings(extractSettingsImportPayload(body));
|
|
1829
|
-
|
|
2035
|
+
await withFileMutationLock(SETTINGS_FILE, async () => {
|
|
2036
|
+
await writeSettings(importedSettings);
|
|
2037
|
+
});
|
|
1830
2038
|
return json(res, 200, toSettingsResponse(importedSettings));
|
|
1831
2039
|
} catch (e) {
|
|
1832
2040
|
if (isPayloadTooLargeError(e)) {
|
|
@@ -1846,8 +2054,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1846
2054
|
try {
|
|
1847
2055
|
const body = await readBody(req);
|
|
1848
2056
|
const normalized = normalizeIncomingData(body);
|
|
1849
|
-
|
|
1850
|
-
|
|
2057
|
+
await withSettingsAndDataMutationLock(async () => {
|
|
2058
|
+
await writeData(normalized);
|
|
2059
|
+
await updateDataLoadState({
|
|
2060
|
+
lastLoadedAt: new Date().toISOString(),
|
|
2061
|
+
lastLoadSource: 'file',
|
|
2062
|
+
});
|
|
2063
|
+
});
|
|
1851
2064
|
const days = normalized.daily.length;
|
|
1852
2065
|
const totalCost = normalized.totals.totalCost;
|
|
1853
2066
|
return json(res, 200, { days, totalCost });
|
|
@@ -1875,10 +2088,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
1875
2088
|
try {
|
|
1876
2089
|
const body = await readBody(req);
|
|
1877
2090
|
const importedData = normalizeIncomingData(extractUsageImportPayload(body));
|
|
1878
|
-
const
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2091
|
+
const result = await withSettingsAndDataMutationLock(async () => {
|
|
2092
|
+
const currentData = readData();
|
|
2093
|
+
const merged = mergeUsageData(currentData, importedData);
|
|
2094
|
+
await writeData(merged.data);
|
|
2095
|
+
await updateDataLoadState({
|
|
2096
|
+
lastLoadedAt: new Date().toISOString(),
|
|
2097
|
+
lastLoadSource: 'file',
|
|
2098
|
+
});
|
|
2099
|
+
return merged;
|
|
2100
|
+
});
|
|
1882
2101
|
return json(res, 200, result.summary);
|
|
1883
2102
|
} catch (e) {
|
|
1884
2103
|
if (isPayloadTooLargeError(e)) {
|
|
@@ -2096,6 +2315,12 @@ module.exports = {
|
|
|
2096
2315
|
commandExists,
|
|
2097
2316
|
getExecutableName,
|
|
2098
2317
|
listenOnAvailablePort,
|
|
2318
|
+
getFileMutationLockDir,
|
|
2319
|
+
unlinkIfExists,
|
|
2320
|
+
writeJsonAtomicAsync,
|
|
2321
|
+
withFileMutationLock,
|
|
2322
|
+
withOrderedFileMutationLocks,
|
|
2323
|
+
getPendingFileMutationLockCount: () => fileMutationLocks.size,
|
|
2099
2324
|
},
|
|
2100
2325
|
};
|
|
2101
2326
|
|
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
import type { DailyUsage, DashboardMetrics, ViewMode } from './dashboard-types'
|
|
2
2
|
|
|
3
|
+
/** Aggregates usage rows to the requested dashboard view mode. */
|
|
3
4
|
export function aggregateToDailyFormat(data: DailyUsage[], viewMode: ViewMode): DailyUsage[]
|
|
5
|
+
/** Returns the busiest rolling seven-day window by cost. */
|
|
4
6
|
export function computeBusiestWeek(
|
|
5
7
|
data: DailyUsage[],
|
|
6
8
|
): { start: string; end: string; cost: number } | null
|
|
9
|
+
/** Computes the core dashboard metrics for a dataset. */
|
|
7
10
|
export function computeMetrics(data: DailyUsage[]): DashboardMetrics
|
|
11
|
+
/** Computes a simple moving average over numeric values. */
|
|
8
12
|
export function computeMovingAverage(
|
|
9
13
|
values: Array<number | undefined>,
|
|
10
14
|
window?: number,
|
|
11
15
|
): Array<number | undefined>
|
|
16
|
+
/** Computes the relative week-over-week cost change. */
|
|
12
17
|
export function computeWeekOverWeekChange(data: DailyUsage[]): number | null
|
|
18
|
+
/** Filters usage rows by an inclusive ISO date range. */
|
|
13
19
|
export function filterByDateRange(data: DailyUsage[], start?: string, end?: string): DailyUsage[]
|
|
20
|
+
/** Filters usage rows to entries that contain selected models. */
|
|
14
21
|
export function filterByModels(data: DailyUsage[], selectedModels: string[]): DailyUsage[]
|
|
22
|
+
/** Filters usage rows to a specific calendar month. */
|
|
15
23
|
export function filterByMonth(data: DailyUsage[], month: string | null): DailyUsage[]
|
|
24
|
+
/** Filters usage rows to entries that contain selected providers. */
|
|
16
25
|
export function filterByProviders(data: DailyUsage[], selectedProviders: string[]): DailyUsage[]
|
|
26
|
+
/** Resolves the provider name for a model identifier. */
|
|
17
27
|
export function getModelProvider(raw: string): string
|
|
28
|
+
/** Normalizes raw model names to their dashboard label. */
|
|
18
29
|
export function normalizeModelName(raw: string): string
|
|
30
|
+
/** Sorts usage rows in ascending date order. */
|
|
19
31
|
export function sortByDate(data: DailyUsage[]): DailyUsage[]
|
|
@@ -132,6 +132,12 @@ function parseOSeries(name) {
|
|
|
132
132
|
return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}`
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Normalizes raw model names to their dashboard label.
|
|
137
|
+
*
|
|
138
|
+
* @param raw - The raw model identifier.
|
|
139
|
+
* @returns The normalized display name.
|
|
140
|
+
*/
|
|
135
141
|
function normalizeModelName(raw) {
|
|
136
142
|
const canonical = canonicalizeModelName(raw)
|
|
137
143
|
|
|
@@ -178,6 +184,12 @@ function normalizeModelName(raw) {
|
|
|
178
184
|
return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || '')
|
|
179
185
|
}
|
|
180
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Resolves the provider name for a model identifier.
|
|
189
|
+
*
|
|
190
|
+
* @param raw - The raw model identifier.
|
|
191
|
+
* @returns The normalized provider name.
|
|
192
|
+
*/
|
|
181
193
|
function getModelProvider(raw) {
|
|
182
194
|
const canonical = canonicalizeModelName(raw)
|
|
183
195
|
for (const matcher of PROVIDER_MATCHERS) {
|
|
@@ -223,6 +235,14 @@ function recalculateDayFromBreakdowns(day, filteredBreakdowns) {
|
|
|
223
235
|
}
|
|
224
236
|
}
|
|
225
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Filters usage rows by an inclusive ISO date range.
|
|
240
|
+
*
|
|
241
|
+
* @param data - The source usage rows.
|
|
242
|
+
* @param start - The optional start date.
|
|
243
|
+
* @param end - The optional end date.
|
|
244
|
+
* @returns The filtered usage rows.
|
|
245
|
+
*/
|
|
226
246
|
function filterByDateRange(data, start, end) {
|
|
227
247
|
return data.filter((entry) => {
|
|
228
248
|
if (start && entry.date < start) return false
|
|
@@ -231,6 +251,13 @@ function filterByDateRange(data, start, end) {
|
|
|
231
251
|
})
|
|
232
252
|
}
|
|
233
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Filters usage rows to entries that contain selected models.
|
|
256
|
+
*
|
|
257
|
+
* @param data - The source usage rows.
|
|
258
|
+
* @param selectedModels - The normalized model names to keep.
|
|
259
|
+
* @returns The filtered usage rows.
|
|
260
|
+
*/
|
|
234
261
|
function filterByModels(data, selectedModels) {
|
|
235
262
|
if (!selectedModels || selectedModels.length === 0) return data
|
|
236
263
|
const selected = new Set(selectedModels)
|
|
@@ -247,6 +274,13 @@ function filterByModels(data, selectedModels) {
|
|
|
247
274
|
.filter(Boolean)
|
|
248
275
|
}
|
|
249
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Filters usage rows to entries that contain selected providers.
|
|
279
|
+
*
|
|
280
|
+
* @param data - The source usage rows.
|
|
281
|
+
* @param selectedProviders - The provider names to keep.
|
|
282
|
+
* @returns The filtered usage rows.
|
|
283
|
+
*/
|
|
250
284
|
function filterByProviders(data, selectedProviders) {
|
|
251
285
|
if (!selectedProviders || selectedProviders.length === 0) return data
|
|
252
286
|
const selected = new Set(selectedProviders)
|
|
@@ -263,15 +297,35 @@ function filterByProviders(data, selectedProviders) {
|
|
|
263
297
|
.filter(Boolean)
|
|
264
298
|
}
|
|
265
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Filters usage rows to a specific calendar month.
|
|
302
|
+
*
|
|
303
|
+
* @param data - The source usage rows.
|
|
304
|
+
* @param month - The month in YYYY-MM format.
|
|
305
|
+
* @returns The filtered usage rows.
|
|
306
|
+
*/
|
|
266
307
|
function filterByMonth(data, month) {
|
|
267
308
|
if (!month) return data
|
|
268
309
|
return data.filter((entry) => entry.date.startsWith(month))
|
|
269
310
|
}
|
|
270
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Sorts usage rows in ascending date order.
|
|
314
|
+
*
|
|
315
|
+
* @param data - The source usage rows.
|
|
316
|
+
* @returns A date-sorted copy of the input.
|
|
317
|
+
*/
|
|
271
318
|
function sortByDate(data) {
|
|
272
319
|
return [...data].sort((left, right) => left.date.localeCompare(right.date))
|
|
273
320
|
}
|
|
274
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Aggregates usage rows to the requested dashboard view mode.
|
|
324
|
+
*
|
|
325
|
+
* @param data - The source daily usage rows.
|
|
326
|
+
* @param viewMode - The target aggregation mode.
|
|
327
|
+
* @returns The aggregated usage rows.
|
|
328
|
+
*/
|
|
275
329
|
function aggregateToDailyFormat(data, viewMode) {
|
|
276
330
|
if (viewMode === 'daily') return data
|
|
277
331
|
|
|
@@ -309,6 +363,13 @@ function aggregateToDailyFormat(data, viewMode) {
|
|
|
309
363
|
return Array.from(groups.values()).sort((left, right) => left.date.localeCompare(right.date))
|
|
310
364
|
}
|
|
311
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Computes a simple moving average over numeric values.
|
|
368
|
+
*
|
|
369
|
+
* @param values - The source numeric values.
|
|
370
|
+
* @param window - The moving average window size.
|
|
371
|
+
* @returns The moving-average series.
|
|
372
|
+
*/
|
|
312
373
|
function computeMovingAverage(values, window = 7) {
|
|
313
374
|
const result = Array(values.length)
|
|
314
375
|
let sum = 0
|
|
@@ -343,6 +404,12 @@ function stdDev(values) {
|
|
|
343
404
|
return Math.sqrt(variance)
|
|
344
405
|
}
|
|
345
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Returns the busiest rolling seven-day window by cost.
|
|
409
|
+
*
|
|
410
|
+
* @param data - The source usage rows.
|
|
411
|
+
* @returns The top seven-day window or null when unavailable.
|
|
412
|
+
*/
|
|
346
413
|
function computeBusiestWeek(data) {
|
|
347
414
|
const sorted = data
|
|
348
415
|
.filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date))
|
|
@@ -383,6 +450,12 @@ function computeBusiestWeek(data) {
|
|
|
383
450
|
return bestWindow
|
|
384
451
|
}
|
|
385
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Computes the relative week-over-week cost change.
|
|
455
|
+
*
|
|
456
|
+
* @param data - The source usage rows.
|
|
457
|
+
* @returns The relative week-over-week delta.
|
|
458
|
+
*/
|
|
386
459
|
function computeWeekOverWeekChange(data) {
|
|
387
460
|
if (data.some((entry) => !/^\d{4}-\d{2}-\d{2}$/.test(entry.date))) return null
|
|
388
461
|
if (data.length < 14) return null
|
|
@@ -395,6 +468,12 @@ function computeWeekOverWeekChange(data) {
|
|
|
395
468
|
return ((lastSum - prevSum) / prevSum) * 100
|
|
396
469
|
}
|
|
397
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Computes the core dashboard metrics for a dataset.
|
|
473
|
+
*
|
|
474
|
+
* @param data - The source usage rows.
|
|
475
|
+
* @returns The derived dashboard metrics.
|
|
476
|
+
*/
|
|
398
477
|
function computeMetrics(data) {
|
|
399
478
|
if (data.length === 0) {
|
|
400
479
|
return {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** Describes per-model usage totals for one period. */
|
|
1
2
|
export interface ModelBreakdown {
|
|
2
3
|
modelName: string
|
|
3
4
|
inputTokens: number
|
|
@@ -9,6 +10,7 @@ export interface ModelBreakdown {
|
|
|
9
10
|
requestCount: number
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
/** Describes aggregated usage for one daily, monthly, or yearly period. */
|
|
12
14
|
export interface DailyUsage {
|
|
13
15
|
date: string
|
|
14
16
|
inputTokens: number
|
|
@@ -24,8 +26,10 @@ export interface DailyUsage {
|
|
|
24
26
|
_aggregatedDays?: number
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/** Lists the supported dashboard aggregation modes. */
|
|
27
30
|
export type ViewMode = 'daily' | 'monthly' | 'yearly'
|
|
28
31
|
|
|
32
|
+
/** Collects high-level metrics derived from the current dataset. */
|
|
29
33
|
export interface DashboardMetrics {
|
|
30
34
|
totalCost: number
|
|
31
35
|
totalTokens: number
|
package/shared/model-colors.d.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
|
+
/** Lists the supported shared model color themes. */
|
|
1
2
|
export type ModelColorTheme = 'dark' | 'light'
|
|
2
3
|
|
|
4
|
+
/** Describes the HSL values assigned to one model color. */
|
|
3
5
|
export interface ModelColorSpec {
|
|
4
6
|
h: number
|
|
5
7
|
s: number
|
|
6
8
|
l: number
|
|
7
9
|
}
|
|
8
10
|
|
|
11
|
+
/** Configures shared model color resolution. */
|
|
9
12
|
export interface ModelColorOptions {
|
|
10
13
|
theme?: ModelColorTheme
|
|
11
14
|
alpha?: number
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
/** Normalizes an unknown theme value to a supported color theme. */
|
|
14
18
|
export function normalizeTheme(theme?: string): ModelColorTheme
|
|
19
|
+
/** Returns the shared color spec for a normalized model name. */
|
|
15
20
|
export function getModelColorSpec(name: string, options?: ModelColorOptions): ModelColorSpec
|
|
21
|
+
/** Returns the shared model color as an HSL string. */
|
|
16
22
|
export function getModelColor(name: string, options?: ModelColorOptions): string
|
|
23
|
+
/** Returns the shared model color as an RGB string. */
|
|
17
24
|
export function getModelColorRgb(name: string, options?: ModelColorOptions): string
|