diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd77260..63b90d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,26 @@ jobs: coverage/ playwright-report/ test-results/ + + windows-smoke: + runs-on: windows-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Run Windows server helper smoke tests + run: npx vitest run tests/unit/server-helpers.test.ts + + - name: Build production bundle + run: npm run build:app diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a8f20..4fd93c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [6.1.9] - 2026-04-14 + +### Added + +- **Klare Recovery-Flows für beschädigte lokale Daten** — die App zeigt korrupte Settings- oder Usage-Dateien jetzt als expliziten Fehlerzustand mit direkten Reset- und Löschaktionen statt als irreführenden Leerzustand +- **Architekturdokumentation für die aktuelle Systemstruktur** — eine neue Architekturübersicht beschreibt die Grenzen zwischen lokalem Server, Frontend, Shared-Domainlogik und Packaging für die weitere Wartung + +### Improved + +- **Barrierefreiheit und Informationsqualität in zentralen Dashboard-Flächen** — Top-Level-Filter haben jetzt stabile zugängliche Namen, Info-Buttons sind semantisch sauber von Headings getrennt, und das Help-Panel zeigt vollständig benannte und fachlich besser gruppierte Inhalte +- **Lokalisierung und Terminologiekonsistenz in Analyse- und Tooltip-Flächen** — gemischte deutsche und englische UI-Begriffe wurden bereinigt, Tooltip-Texte lokalisiert und die verbleibenden Accessibility-/i18n-Regressionen durch zusätzliche Tests abgesichert +- **Robustere lokale API-Grenzen und Auto-Import-Sicherheit** — mutierende Endpunkte akzeptieren nur noch erlaubte Request-Formen, Cross-Site-Zugriffe werden abgewehrt, Auto-Import verwendet keine mutierende `GET`-Route mehr, und non-loopback Binding erfordert jetzt ein explizites Remote-Opt-in +- **Sicherere lokale Persistenz und Exportpfade** — Daten- und Settings-Dateien werden restriktiver geschrieben, CSV-Exporte escapen Sonderzeichen korrekt, und serverseitige Fatal-Load-Fehler werden bis in die UI transparent durchgereicht +- **Nachhaltigere Architektur für Dashboard, Report und Server-Runtime** — gemeinsame Dashboard-/Report-Domainlogik, ein entschlackter Dashboard-Controller und erste Server-Module reduzieren Drift, verbessern Testbarkeit und schaffen klarere Verantwortungsgrenzen + +### Fixed + +- **Windows-Kompatibilität beim Auto-Import und Child-Process-Start** — die Runner-Ausführung funktioniert auf Windows jetzt zuverlässig ohne die zuvor fehleranfällige Prozessinitialisierung + ## [6.1.8] ### Added diff --git a/README.md b/README.md index 66aedd5..a141527 100644 --- a/README.md +++ b/README.md @@ -177,11 +177,20 @@ Commands: Environment variables: -| Variable | Description | -| ------------------- | --------------------------------------------------------- | -| `PORT` | Override the start port | -| `NO_OPEN_BROWSER=1` | Disable browser auto-open | -| `HOST` | Override the bind host, for example `HOST=0.0.0.0 ttdash` | +| Variable | Description | +| ----------------------- | --------------------------------------------------------- | +| `PORT` | Override the start port | +| `NO_OPEN_BROWSER=1` | Disable browser auto-open | +| `HOST` | Override the bind host, for example `HOST=0.0.0.0 ttdash` | +| `TTDASH_ALLOW_REMOTE=1` | Explicitly allow binding to a non-loopback host | + +Binding to a non-loopback host such as `0.0.0.0` exposes the local dashboard API to your network, including destructive routes for local data and settings resets. TTDash now refuses that bind unless you also set `TTDASH_ALLOW_REMOTE=1`. Only use this on trusted networks. + +Example: + +```bash +TTDASH_ALLOW_REMOTE=1 HOST=0.0.0.0 ttdash +``` ## Features diff --git a/SECURITY.md b/SECURITY.md index 30c1a8f..92f86fe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,3 +26,15 @@ Please include: ## Response Expectations This project is maintained on a best-effort basis by a single maintainer. Reports will be reviewed as quickly as practical, but no fixed response SLA is promised. + +## Deployment Notes + +`TTDash` is intended to run as a local-first app on loopback by default. Binding it to a non-loopback host exposes local API routes for uploads, imports, resets, and report generation to your network. + +Non-loopback binding therefore requires an explicit opt-in: + +```bash +TTDASH_ALLOW_REMOTE=1 HOST=0.0.0.0 ttdash +``` + +Only use that mode on trusted networks. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ccec346 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,67 @@ +# Architecture Overview + +## System Shape + +TTDash is a local-first product with two runtime parts: + +- a Node-based CLI and HTTP server exposed through `server.js` +- a Vite/React frontend bundled into `dist/` + +The server owns local persistence, background instance management, auto-import execution and the HTTP API. The frontend owns interaction, filtering, visualization and user-driven import/export flows. + +## Architectural Boundaries + +### Shared Domain + +`shared/dashboard-domain.js` is the common source of truth for model normalization, provider resolution, filter application, aggregation and core dashboard metrics. It is used by: + +- frontend data transforms and calculations in `src/lib/*` +- server-side PDF report generation in `server/report/*` + +This boundary exists to keep dashboard and report output aligned for the same underlying data. + +### Frontend Page Composition + +The dashboard page is split into: + +- `src/hooks/use-dashboard-controller.ts` for query orchestration, local UI state and user actions +- `src/components/dashboard/DashboardSections.tsx` for section rendering and layout composition +- `src/components/Dashboard.tsx` as the thin page shell + +The shell is responsible for state branching only: + +- loading +- fatal local-state error +- empty state +- main dashboard + +### Settings Contract + +Dashboard preferences are driven by `shared/dashboard-preferences.json`, which is consumed by both: + +- `src/lib/dashboard-preferences.ts` +- `server.js` + +Frontend settings normalization lives in `src/lib/app-settings.ts`. Bootstrap loading is centralized in `src/lib/api.ts` through `loadBootstrapSettings()`. + +## Current Server Structure + +`server.js` is still the public entrypoint and still owns several runtime responsibilities. The current refactor reduces contract drift and shared-domain duplication first, while keeping the published CLI stable. Further modularization of the server runtime should continue from the current seams: + +- runtime/bootstrap +- persistence/settings +- background instance lifecycle +- HTTP helpers and route handlers +- auto-import execution + +## Release and Packaging + +The npm package ships: + +- `server.js` +- `server/` +- `shared/` +- `dist/` +- `src/locales/` + +`shared/` is published because the report layer depends on the same domain logic as the frontend bundle. diff --git a/package-lock.json b/package-lock.json index a13930d..1074d78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "6.1.8", "license": "MIT", "dependencies": { + "cross-spawn": "^7.0.6", "i18next": "^26.0.3", "react-i18next": "^17.0.2", "react-is": "^19.2.4" @@ -3337,7 +3338,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4263,7 +4263,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -4988,7 +4987,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5475,7 +5473,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5488,7 +5485,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6128,7 +6124,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index bebae0a..ca1a7f9 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "server.js", "usage-normalizer.js", "server/", + "shared/", "src/locales/", "dist/" ], @@ -103,6 +104,7 @@ "vitest": "^4.1.3" }, "dependencies": { + "cross-spawn": "^7.0.6", "i18next": "^26.0.3", "react-i18next": "^17.0.2", "react-is": "^19.2.4" diff --git a/server.js b/server.js index 8757179..4ac7078 100755 --- a/server.js +++ b/server.js @@ -6,10 +6,18 @@ const os = require('os'); const path = require('path'); const readline = require('readline/promises'); const { spawn } = require('child_process'); +const spawnCrossPlatform = require('cross-spawn'); const { parseArgs } = require('util'); const { normalizeIncomingData } = require('./usage-normalizer'); const { generatePdfReport } = require('./server/report'); const { version: APP_VERSION } = require('./package.json'); +const dashboardPreferences = require('./shared/dashboard-preferences.json'); +const { createHttpUtils } = require('./server/http-utils'); +const { + ensureBindHostAllowed, + isLoopbackHost, + listenOnAvailablePort, +} = require('./server/runtime'); const ROOT = __dirname; const STATIC_ROOT = path.join(ROOT, 'dist'); @@ -23,9 +31,12 @@ const ENV_START_PORT = parseInt(process.env.PORT, 10); const START_PORT = CLI_OPTIONS.port ?? (Number.isFinite(ENV_START_PORT) ? ENV_START_PORT : 3000); const MAX_PORT = Math.min(START_PORT + 100, 65535); const BIND_HOST = process.env.HOST || '127.0.0.1'; -const API_PREFIX = '/port/5000/api'; +const ALLOW_REMOTE_BIND = process.env.TTDASH_ALLOW_REMOTE === '1'; +const API_PREFIX = process.env.API_PREFIX || '/api'; const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB const IS_WINDOWS = process.platform === 'win32'; +const SECURE_DIR_MODE = 0o700; +const SECURE_FILE_MODE = 0o600; const TOKTRACK_LOCAL_BIN = path.join( ROOT, 'node_modules', @@ -46,22 +57,8 @@ const USAGE_BACKUP_KIND = 'ttdash-usage-backup'; const IS_BACKGROUND_CHILD = process.env.TTDASH_BACKGROUND_CHILD === '1'; const FORCE_OPEN_BROWSER = process.env.TTDASH_FORCE_OPEN_BROWSER === '1'; const BACKGROUND_START_TIMEOUT_MS = 15000; -const DASHBOARD_DATE_PRESETS = ['all', '7d', '30d', 'month', 'year']; -const DASHBOARD_SECTION_IDS = [ - 'insights', - 'metrics', - 'today', - 'currentMonth', - 'activity', - 'forecastCache', - 'limits', - 'costAnalysis', - 'tokenAnalysis', - 'requestAnalysis', - 'advancedAnalysis', - 'comparisons', - 'tables', -]; +const DASHBOARD_DATE_PRESETS = dashboardPreferences.datePresets; +const DASHBOARD_SECTION_IDS = dashboardPreferences.sectionDefinitions.map((section) => section.id); const DEFAULT_SETTINGS = { language: 'de', theme: 'dark', @@ -129,6 +126,7 @@ function printHelp() { console.log(' PORT=3010 ttdash'); console.log(' NO_OPEN_BROWSER=1 ttdash'); console.log(' HOST=127.0.0.1 ttdash'); + console.log(' TTDASH_ALLOW_REMOTE=1 HOST=0.0.0.0 ttdash'); } function parseCliArgs(rawArgs) { @@ -290,7 +288,10 @@ const MIME_TYPES = { }; function ensureDir(dirPath) { - fs.mkdirSync(dirPath, { recursive: true }); + fs.mkdirSync(dirPath, { recursive: true, mode: SECURE_DIR_MODE }); + if (!IS_WINDOWS) { + fs.chmodSync(dirPath, SECURE_DIR_MODE); + } } function ensureAppDirs() { @@ -304,7 +305,12 @@ function ensureAppDirs() { function writeJsonAtomic(filePath, data) { ensureDir(path.dirname(filePath)); const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), { + mode: SECURE_FILE_MODE, + }); + if (!IS_WINDOWS) { + fs.chmodSync(tempPath, SECURE_FILE_MODE); + } fs.renameSync(tempPath, filePath); } @@ -337,11 +343,12 @@ async function fetchRuntimeIdentity(url, timeoutMs = 1000) { return null; } + const runtimePath = `${API_PREFIX.replace(/\/+$/, '')}/runtime`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(new URL('/api/runtime', `${url}/`), { + const response = await fetch(new URL(runtimePath, `${url}/`), { signal: controller.signal, }); @@ -473,7 +480,7 @@ async function withBackgroundInstancesLock( while (true) { try { - fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR); + fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR, { mode: SECURE_DIR_MODE }); break; } catch (error) { if (!error || error.code !== 'EEXIST') { @@ -753,11 +760,15 @@ function shouldBackgroundChildOpenBrowser() { } async function startInBackground() { + ensureBindHostAllowed(BIND_HOST, ALLOW_REMOTE_BIND); ensureAppDirs(); const logFile = buildBackgroundLogFilePath(); const childArgs = NORMALIZED_CLI_ARGS.filter((arg) => arg !== '--background'); - const logFd = fs.openSync(logFile, 'a'); + const logFd = fs.openSync(logFile, 'a', SECURE_FILE_MODE); + if (!IS_WINDOWS) { + fs.fchmodSync(logFd, SECURE_FILE_MODE); + } let child; try { @@ -846,6 +857,46 @@ function normalizeIsoTimestamp(value) { return new Date(timestamp).toISOString(); } +function createPersistedStateError(kind, filePath, cause) { + const label = kind === 'settings' ? 'Settings file' : 'Usage data file'; + const error = new Error(`${label} is unreadable or corrupted.`); + error.code = 'PERSISTED_STATE_INVALID'; + error.kind = kind; + error.filePath = filePath; + error.cause = cause; + return error; +} + +function isPersistedStateError(error, kind) { + return ( + Boolean(error) && + error.code === 'PERSISTED_STATE_INVALID' && + (kind ? error.kind === kind : true) + ); +} + +function isPayloadTooLargeError(error) { + return Boolean(error) && error.code === 'PAYLOAD_TOO_LARGE'; +} + +function readJsonFile(filePath, kind) { + try { + return { + status: 'ok', + value: JSON.parse(fs.readFileSync(filePath, 'utf-8')), + }; + } catch (error) { + if (error && error.code === 'ENOENT') { + return { + status: 'missing', + value: null, + }; + } + + throw createPersistedStateError(kind, filePath, error); + } +} + function sanitizeCurrency(value) { if (typeof value !== 'number' || !Number.isFinite(value)) return 0; return Math.max(0, Number(value.toFixed(2))); @@ -855,6 +906,49 @@ function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } +function createAutoImportMessageEvent(key, vars = {}) { + return { + key, + vars, + }; +} + +function createAutoImportError(message, key, vars = {}) { + const error = new Error(message); + error.messageKey = key; + error.messageVars = vars; + return error; +} + +function toAutoImportErrorEvent(error) { + if (error && typeof error.messageKey === 'string') { + return createAutoImportMessageEvent(error.messageKey, error.messageVars || {}); + } + + return createAutoImportMessageEvent('errorPrefix', { + message: error && error.message ? error.message : 'Unknown error', + }); +} + +function formatAutoImportMessageEvent(event) { + switch (event?.key) { + case 'startingLocalImport': + return 'Starting local toktrack import...'; + case 'loadingUsageData': + return `Loading usage data via ${event.vars?.command || 'unknown command'}...`; + case 'processingUsageData': + return `Processing usage data... (${event.vars?.seconds || 0}s)`; + case 'autoImportRunning': + return 'An auto-import is already running. Please wait.'; + case 'noRunnerFound': + return 'No local toktrack, Bun, or npm exec installation found.'; + case 'errorPrefix': + return `Error: ${event.vars?.message || 'Unknown error'}`; + default: + return 'Auto-import update'; + } +} + function computeUsageTotals(daily) { return daily.reduce( (totals, day) => ({ @@ -1191,6 +1285,7 @@ function printStartupSummary(url, port) { const browserMode = shouldOpenBrowser() ? 'enabled' : 'disabled'; const autoLoadMode = CLI_OPTIONS.autoLoad ? 'enabled' : 'disabled'; const runtimeMode = IS_BACKGROUND_CHILD ? 'background' : 'foreground'; + const remoteBind = !isLoopbackHost(BIND_HOST); console.log(''); console.log(`${APP_LABEL} v${APP_VERSION} is ready`); @@ -1198,6 +1293,9 @@ function printStartupSummary(url, port) { console.log(` API: ${url}/api/usage`); console.log(` Port: ${port}`); console.log(` Host: ${BIND_HOST}`); + if (remoteBind) { + console.log(` Exposure: network-accessible via ${BIND_HOST}`); + } console.log(` Mode: ${runtimeMode}`); console.log(` Static Root: ${STATIC_ROOT}`); console.log(` Data File: ${DATA_FILE}`); @@ -1208,6 +1306,13 @@ function printStartupSummary(url, port) { console.log(` Data Status: ${describeDataFile()}`); console.log(` Browser Open: ${browserMode}`); console.log(` Auto-Load: ${autoLoadMode}`); + if (remoteBind) { + console.log(''); + console.log( + 'Security warning: this bind host can expose local data and destructive API routes.', + ); + console.log('Use non-loopback hosts only on trusted networks.'); + } console.log(''); console.log('Available ways to load data:'); console.log(' 1. Start auto-import from the app'); @@ -1219,6 +1324,7 @@ function printStartupSummary(url, port) { console.log(' ttdash --background'); console.log(' ttdash stop'); console.log(` NO_OPEN_BROWSER=1 PORT=${port} node server.js`); + console.log(` TTDASH_ALLOW_REMOTE=1 HOST=${BIND_HOST} PORT=${port} node server.js`); console.log(` curl ${url}/api/usage`); console.log(''); } @@ -1271,11 +1377,16 @@ function serveFile(res, reqPath) { // --- API helpers --- function readData() { - try { - return normalizeIncomingData(JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'))); - } catch { + const file = readJsonFile(DATA_FILE, 'usage'); + if (file.status === 'missing') { return null; } + + try { + return normalizeIncomingData(file.value); + } catch (error) { + throw createPersistedStateError('usage', DATA_FILE, error); + } } function writeData(data) { @@ -1283,14 +1394,30 @@ function writeData(data) { } function readSettings() { - try { - return toSettingsResponse(JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8'))); - } catch { + const file = readJsonFile(SETTINGS_FILE, 'settings'); + if (file.status === 'missing') { return toSettingsResponse({ ...DEFAULT_SETTINGS, providerLimits: {}, }); } + + return toSettingsResponse(file.value); +} + +function readSettingsForWrite() { + try { + return readSettings(); + } catch (error) { + if (isPersistedStateError(error, 'settings')) { + return toSettingsResponse({ + ...DEFAULT_SETTINGS, + providerLimits: {}, + }); + } + + throw error; + } } function writeSettings(settings) { @@ -1298,7 +1425,7 @@ function writeSettings(settings) { } function updateSettings(patch) { - const current = readSettings(); + const current = readSettingsForWrite(); const next = { ...current, ...(patch && typeof patch === 'object' ? patch : {}), @@ -1318,7 +1445,7 @@ function updateSettings(patch) { } function recordDataLoad(source) { - const current = readSettings(); + const current = readSettingsForWrite(); const next = { ...current, lastLoadedAt: new Date().toISOString(), @@ -1330,7 +1457,7 @@ function recordDataLoad(source) { } function clearDataLoadState() { - const current = readSettings(); + const current = readSettingsForWrite(); const next = { ...current, lastLoadedAt: null, @@ -1340,63 +1467,11 @@ function clearDataLoadState() { writeSettings(next); return toSettingsResponse(next); } - -function readBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - let totalSize = 0; - req.on('data', (c) => { - totalSize += c.length; - if (totalSize > MAX_BODY_SIZE) { - req.destroy(); - reject(new Error('Payload too large')); - return; - } - chunks.push(c); - }); - req.on('end', () => { - try { - resolve(JSON.parse(Buffer.concat(chunks).toString())); - } catch (e) { - reject(e); - } - }); - req.on('error', reject); - }); -} - -function json(res, status, data) { - res.writeHead(status, { - 'Content-Type': 'application/json; charset=utf-8', - ...SECURITY_HEADERS, - }); - res.end(JSON.stringify(data)); -} - -function sendBuffer(res, status, headers, buffer) { - res.writeHead(status, { - 'Content-Length': buffer.length, - ...headers, - ...SECURITY_HEADERS, - }); - res.end(buffer); -} - -function resolveApiPath(pathname) { - if (pathname.startsWith(API_PREFIX + '/')) { - return pathname.slice(API_PREFIX.length); - } - if (pathname === API_PREFIX) { - return '/'; - } - if (pathname.startsWith('/api/')) { - return pathname.slice(4); - } - if (pathname === '/api') { - return '/'; - } - return null; -} +const { json, readBody, resolveApiPath, sendBuffer, validateMutationRequest } = createHttpUtils({ + apiPrefix: API_PREFIX, + maxBodySize: MAX_BODY_SIZE, + securityHeaders: SECURITY_HEADERS, +}); // --- SSE helpers --- @@ -1406,10 +1481,6 @@ function sendSSE(res, event, data) { let autoImportRunning = false; -function shouldUseShell(command) { - return IS_WINDOWS && /\.(cmd|bat)$/i.test(command); -} - function getExecutableName(baseName, isWindows = IS_WINDOWS) { if (!isWindows) { return baseName; @@ -1427,9 +1498,10 @@ function getExecutableName(baseName, isWindows = IS_WINDOWS) { } function spawnCommand(command, args, options = {}) { - return spawn(command, args, { + // cross-spawn resolves Windows command shims without relying on shell=true, + // which avoids the DEP0190 warning from Node's child_process APIs. + return spawnCrossPlatform(command, args, { ...options, - shell: options.shell ?? shouldUseShell(command), windowsHide: options.windowsHide ?? true, }); } @@ -1527,24 +1599,30 @@ async function performAutoImport({ signalOnClose, } = {}) { if (autoImportRunning) { - throw new Error('An auto-import is already running. Please wait.'); + throw createAutoImportError( + 'An auto-import is already running. Please wait.', + 'autoImportRunning', + ); } autoImportRunning = true; let progressSeconds = 0; const progressInterval = setInterval(() => { progressSeconds += 5; - onOutput(`Processing usage data... (${progressSeconds}s)`); + onProgress(createAutoImportMessageEvent('processingUsageData', { seconds: progressSeconds })); }, 5000); try { onCheck({ tool: 'toktrack', status: 'checking' }); - onProgress({ message: 'Starting local toktrack import...' }); + onProgress(createAutoImportMessageEvent('startingLocalImport')); const runner = await resolveToktrackRunner(); if (!runner) { onCheck({ tool: 'toktrack', status: 'not_found' }); - throw new Error('No local toktrack, Bun, or npm exec installation found.'); + throw createAutoImportError( + 'No local toktrack, Bun, or npm exec installation found.', + 'noRunnerFound', + ); } const versionResult = await runToktrack(runner, ['--version']); @@ -1554,7 +1632,11 @@ async function performAutoImport({ method: runner.label, version: String(versionResult).replace(/^toktrack\s+/, ''), }); - onProgress({ message: `Loading usage data via ${runner.displayCommand}...` }); + onProgress( + createAutoImportMessageEvent('loadingUsageData', { + command: runner.displayCommand, + }), + ); const rawJson = await runToktrack(runner, ['daily', '--json'], { streamStderr: true, @@ -1590,7 +1672,7 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) { } }, onProgress: (event) => { - console.log(event.message); + console.log(formatAutoImportMessageEvent(event)); }, onOutput: (line) => { console.log(line); @@ -1610,15 +1692,34 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) { // --- Server --- const server = http.createServer(async (req, res) => { - const url = new URL(req.url, 'http://localhost'); - const pathname = decodeURIComponent(url.pathname); + let url; + let pathname; + + try { + url = new URL(req.url, 'http://localhost'); + pathname = decodeURIComponent(url.pathname); + } catch { + return json(res, 400, { message: 'Invalid request path' }); + } // API routing const apiPath = resolveApiPath(pathname); + if (apiPath === null && (pathname === '/api' || pathname.startsWith('/api/'))) { + return json(res, 404, { message: 'Not Found' }); + } + if (apiPath === '/usage') { if (req.method === 'GET') { - const data = readData(); + let data; + try { + data = readData(); + } catch (error) { + if (isPersistedStateError(error, 'usage')) { + return json(res, 500, { message: error.message }); + } + throw error; + } return json( res, 200, @@ -1638,6 +1739,10 @@ const server = http.createServer(async (req, res) => { ); } if (req.method === 'DELETE') { + const validationError = validateMutationRequest(req); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } try { fs.unlinkSync(DATA_FILE); } catch { @@ -1666,10 +1771,21 @@ const server = http.createServer(async (req, res) => { if (apiPath === '/settings') { if (req.method === 'GET') { - return json(res, 200, readSettings()); + try { + return json(res, 200, readSettings()); + } catch (error) { + if (isPersistedStateError(error, 'settings')) { + return json(res, 500, { message: error.message }); + } + throw error; + } } if (req.method === 'DELETE') { + const validationError = validateMutationRequest(req); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } try { fs.unlinkSync(SETTINGS_FILE); } catch { @@ -1679,10 +1795,17 @@ const server = http.createServer(async (req, res) => { } if (req.method === 'PATCH') { + const validationError = validateMutationRequest(req, { requiresJsonContentType: true }); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } try { const body = await readBody(req); return json(res, 200, updateSettings(body)); } catch (e) { + if (isPayloadTooLargeError(e)) { + return json(res, 413, { message: 'Settings request too large' }); + } return json(res, 400, { message: e.message || 'Invalid settings request' }); } } @@ -1695,18 +1818,31 @@ const server = http.createServer(async (req, res) => { return json(res, 405, { message: 'Method Not Allowed' }); } + const validationError = validateMutationRequest(req, { requiresJsonContentType: true }); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } + try { const body = await readBody(req); const importedSettings = normalizeSettings(extractSettingsImportPayload(body)); writeSettings(importedSettings); return json(res, 200, toSettingsResponse(importedSettings)); } catch (e) { + if (isPayloadTooLargeError(e)) { + return json(res, 413, { message: 'Settings file too large' }); + } return json(res, 400, { message: e.message || 'Invalid settings file' }); } } if (apiPath === '/upload') { if (req.method === 'POST') { + const validationError = validateMutationRequest(req, { requiresJsonContentType: true }); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } + try { const body = await readBody(req); const normalized = normalizeIncomingData(body); @@ -1716,11 +1852,10 @@ const server = http.createServer(async (req, res) => { const totalCost = normalized.totals.totalCost; return json(res, 200, { days, totalCost }); } catch (e) { - const status = e.message === 'Payload too large' ? 413 : 400; - const message = - e.message === 'Payload too large' - ? 'File too large (max. 10 MB)' - : e.message || 'Invalid JSON'; + const status = isPayloadTooLargeError(e) ? 413 : 400; + const message = isPayloadTooLargeError(e) + ? 'File too large (max. 10 MB)' + : e.message || 'Invalid JSON'; return json(res, status, { message }); } } @@ -1732,6 +1867,11 @@ const server = http.createServer(async (req, res) => { return json(res, 405, { message: 'Method Not Allowed' }); } + const validationError = validateMutationRequest(req, { requiresJsonContentType: true }); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } + try { const body = await readBody(req); const importedData = normalizeIncomingData(extractUsageImportPayload(body)); @@ -1741,15 +1881,26 @@ const server = http.createServer(async (req, res) => { recordDataLoad('file'); return json(res, 200, result.summary); } catch (e) { + if (isPayloadTooLargeError(e)) { + return json(res, 413, { message: 'Usage backup file too large' }); + } + if (isPersistedStateError(e, 'usage')) { + return json(res, 500, { message: e.message }); + } return json(res, 400, { message: e.message || 'Invalid usage backup file' }); } } if (apiPath === '/auto-import/stream') { - if (req.method !== 'GET') { + if (req.method !== 'POST') { return json(res, 405, { message: 'Method Not Allowed' }); } + const validationError = validateMutationRequest(req); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1797,7 +1948,7 @@ const server = http.createServer(async (req, res) => { if (aborted) { return; } - sendSSE(res, 'error', { message: `Error: ${err.message}` }); + sendSSE(res, 'error', toAutoImportErrorEvent(err)); sendSSE(res, 'done', {}); res.end(); } @@ -1809,7 +1960,20 @@ const server = http.createServer(async (req, res) => { return json(res, 405, { message: 'Method Not Allowed' }); } - const data = readData(); + const validationError = validateMutationRequest(req, { requiresJsonContentType: true }); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } + + let data; + try { + data = readData(); + } catch (error) { + if (isPersistedStateError(error, 'usage')) { + return json(res, 500, { message: error.message }); + } + throw error; + } if (!data || !Array.isArray(data.daily) || data.daily.length === 0) { return json(res, 400, { message: 'No data available for the report.' }); } @@ -1818,10 +1982,9 @@ const server = http.createServer(async (req, res) => { try { body = await readBody(req); } catch (e) { - const status = e.message === 'Payload too large' ? 413 : 400; + const status = isPayloadTooLargeError(e) ? 413 : 400; return json(res, status, { - message: - e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request', + message: isPayloadTooLargeError(e) ? 'Report request too large' : 'Invalid report request', }); } @@ -1861,61 +2024,12 @@ const server = http.createServer(async (req, res) => { serveFile(res, filePath); }); -function createNoFreePortError(rangeStartPort, maxPort) { - return new Error(`No free port found (${rangeStartPort}-${maxPort})`); -} - -async function listenOnAvailablePort( - serverInstance, - port, - maxPort, - bindHost, - log = console.log, - rangeStartPort = port, -) { - if (port > maxPort) { - throw createNoFreePortError(rangeStartPort, maxPort); - } - - for (let currentPort = port; currentPort <= maxPort; currentPort += 1) { - try { - await new Promise((resolve, reject) => { - const onError = (err) => { - serverInstance.off('listening', onListening); - reject(err); - }; - - const onListening = () => { - serverInstance.off('error', onError); - resolve(); - }; - - serverInstance.once('error', onError); - serverInstance.once('listening', onListening); - serverInstance.listen(currentPort, bindHost); - }); - - return currentPort; - } catch (err) { - if (err && err.code === 'EADDRINUSE') { - if (currentPort >= maxPort) { - throw createNoFreePortError(rangeStartPort, maxPort); - } - log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`); - continue; - } - throw err; - } - } - - throw createNoFreePortError(rangeStartPort, maxPort); -} - function tryListen(port) { return listenOnAvailablePort(server, port, MAX_PORT, BIND_HOST, console.log, START_PORT); } async function start() { + ensureBindHostAllowed(BIND_HOST, ALLOW_REMOTE_BIND); ensureAppDirs(); migrateLegacyDataFile(); @@ -1979,6 +2093,7 @@ module.exports = { bootstrapCli, runCli, __test__: { + commandExists, getExecutableName, listenOnAvailablePort, }, diff --git a/server/http-utils.js b/server/http-utils.js new file mode 100644 index 0000000..9879f52 --- /dev/null +++ b/server/http-utils.js @@ -0,0 +1,165 @@ +function createHttpUtils({ apiPrefix, maxBodySize, securityHeaders }) { + function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let totalSize = 0; + let settled = false; + + const cleanup = () => { + req.off('data', onData); + req.off('end', onEnd); + req.off('error', onError); + req.off('close', onClose); + }; + + const rejectOnce = (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + + const resolveOnce = (value) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + + const onData = (chunk) => { + totalSize += chunk.length; + if (totalSize > maxBodySize) { + const error = new Error('Payload too large'); + error.code = 'PAYLOAD_TOO_LARGE'; + rejectOnce(error); + req.resume(); + return; + } + chunks.push(chunk); + }; + + const onEnd = () => { + try { + resolveOnce(JSON.parse(Buffer.concat(chunks).toString())); + } catch (error) { + rejectOnce(error); + } + }; + + const onError = (error) => { + if (settled && error && error.code === 'ECONNRESET') { + return; + } + rejectOnce(error); + }; + + const onClose = () => { + if (!req.readableEnded) { + rejectOnce(new Error('Request body stream closed before the payload finished')); + } + }; + + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); + req.on('close', onClose); + }); + } + + function json(res, status, data) { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + ...securityHeaders, + }); + res.end(JSON.stringify(data)); + } + + function sendBuffer(res, status, headers, buffer) { + res.writeHead(status, { + 'Content-Length': buffer.length, + ...headers, + ...securityHeaders, + }); + res.end(buffer); + } + + function resolveApiPath(pathname) { + if (pathname === apiPrefix) { + return '/'; + } + if (pathname.startsWith(apiPrefix + '/')) { + return pathname.slice(apiPrefix.length); + } + return null; + } + + function getHeaderValue(req, name) { + const value = req.headers[name]; + if (Array.isArray(value)) { + return value[0] || ''; + } + return typeof value === 'string' ? value : ''; + } + + function hasJsonContentType(req) { + const contentType = getHeaderValue(req, 'content-type'); + if (!contentType) { + return false; + } + + return contentType.split(';', 1)[0].trim().toLowerCase() === 'application/json'; + } + + function hasTrustedOrigin(req) { + const originHeader = getHeaderValue(req, 'origin').trim(); + if (!originHeader) { + return true; + } + + const hostHeader = getHeaderValue(req, 'host').trim(); + if (!hostHeader || originHeader === 'null') { + return false; + } + + try { + const origin = new URL(originHeader); + return origin.host === hostHeader; + } catch { + return false; + } + } + + function isCrossSiteFetch(req) { + return getHeaderValue(req, 'sec-fetch-site').trim().toLowerCase() === 'cross-site'; + } + + function validateMutationRequest(req, { requiresJsonContentType = false } = {}) { + if (isCrossSiteFetch(req) || !hasTrustedOrigin(req)) { + return { + status: 403, + message: 'Cross-site requests are not allowed', + }; + } + + if (requiresJsonContentType && !hasJsonContentType(req)) { + return { + status: 415, + message: 'Content-Type must be application/json', + }; + } + + return null; + } + + return { + readBody, + json, + sendBuffer, + resolveApiPath, + validateMutationRequest, + }; +} + +module.exports = { + createHttpUtils, +}; diff --git a/server/report/utils.js b/server/report/utils.js index 4aaa303..14c545c 100644 --- a/server/report/utils.js +++ b/server/report/utils.js @@ -1,15 +1,17 @@ const { version: APP_VERSION } = require('../../package.json'); const { getLanguage, getLocale, translate } = require('./i18n'); -const modelNormalizationSpec = require('../model-normalization.json'); - -const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({ - ...alias, - matcher: new RegExp(alias.pattern, 'i'), -})); -const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({ - ...matcher, - matcher: new RegExp(matcher.pattern, 'i'), -})); +const { + aggregateToDailyFormat, + computeMetrics, + computeMovingAverage, + filterByDateRange, + filterByModels, + filterByMonth, + filterByProviders, + getModelProvider, + normalizeModelName, + sortByDate, +} = require('../../shared/dashboard-domain'); const MODEL_COLORS = { 'Opus 4.6': 'rgb(175, 92, 224)', @@ -26,300 +28,10 @@ const MODEL_COLORS = { const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; -function titleCaseSegment(segment) { - if (!segment) return segment; - if (/^\d+([.-]\d+)*$/.test(segment)) return segment.replace(/-/g, '.'); - if (/^[a-z]{1,4}\d+$/i.test(segment)) return segment.toUpperCase(); - return segment.charAt(0).toUpperCase() + segment.slice(1); -} - -function capitalize(segment) { - if (!segment) return ''; - return segment.charAt(0).toUpperCase() + segment.slice(1); -} - -function formatVersion(version) { - return version.replace(/-/g, '.'); -} - -function canonicalizeModelName(raw) { - const normalized = String(raw || '') - .trim() - .toLowerCase() - .replace(/^model[:/ -]*/i, '') - .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '') - .replace(/\./g, '-') - .replace(/[_/]+/g, '-') - .replace(/\s+/g, '-') - .replace(/-{2,}/g, '-') - .replace(/^-|-$/g, ''); - - const suffixStart = normalized.lastIndexOf('-'); - if (suffixStart > 0) { - const suffix = normalized.slice(suffixStart + 1); - if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) { - return normalized.slice(0, suffixStart); - } - } - - return normalized; -} - -function parseClaudeName(rest) { - const parts = rest.split('-', 2); - if (parts.length < 2) { - return `Claude ${capitalize(rest)}`; - } - - return `${capitalize(parts[0] || '')} ${formatVersion(parts[1] || '')}`.trim(); -} - -function parseGptName(rest) { - const parts = rest.split('-'); - const variant = parts[0] || ''; - const minor = parts[1] || ''; - - if (minor && minor.length <= 2 && /^\d+$/.test(minor)) { - const version = `${variant}.${minor}`; - if (parts.length > 2) { - const suffix = parts.slice(2).map(capitalize).join(' '); - return `GPT-${version}${suffix ? ` ${suffix}` : ''}`; - } - return `GPT-${version}`; - } - - if (parts.length > 1) { - const suffix = parts.slice(1).map(capitalize).join(' '); - return `GPT-${variant}${suffix ? ` ${suffix}` : ''}`; - } - - return `GPT-${rest}`; -} - -function parseGeminiName(rest) { - const parts = rest.split('-'); - if (parts.length < 2) { - return `Gemini ${rest}`; - } - - const versionParts = []; - const tierParts = []; - - for (const part of parts) { - if (/^\d+$/.test(part) && tierParts.length === 0) { - versionParts.push(part); - } else { - tierParts.push(capitalize(part)); - } - } - - const version = versionParts.join('.'); - const tier = tierParts.join(' '); - - return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}`; -} - -function parseCodexName(rest) { - const normalized = rest.replace(/-latest$/i, ''); - if (!normalized) { - return 'Codex'; - } - return `Codex ${normalized.split('-').map(capitalize).join(' ')}`; -} - -function parseOSeries(name) { - const separatorIndex = name.indexOf('-'); - if (separatorIndex === -1) { - return name; - } - return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}`; -} - -function normalizeModelName(raw) { - const canonical = canonicalizeModelName(raw); - - for (const alias of DISPLAY_ALIASES) { - if (alias.matcher.test(canonical)) return alias.name; - } - - if (canonical.startsWith('claude-')) { - return parseClaudeName(canonical.slice('claude-'.length)); - } - - if (canonical.startsWith('gpt-')) { - return parseGptName(canonical.slice('gpt-'.length)); - } - - if (canonical.startsWith('gemini-')) { - return parseGeminiName(canonical.slice('gemini-'.length)); - } - - if (canonical.startsWith('codex-')) { - return parseCodexName(canonical.slice('codex-'.length)); - } - - if (/^o\d/i.test(canonical)) { - return parseOSeries(canonical); - } - - const familyMatch = canonical.match( - /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i, - ); - if (familyMatch) { - const family = familyMatch[1]; - if (/^codex$/i.test(family)) { - return parseCodexName(familyMatch[2] || ''); - } - if (/^(o\d)$/i.test(family)) return parseOSeries(canonical); - - const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : ''; - if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}`; - return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim(); - } - - return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || ''); -} - -function getModelProvider(raw) { - const canonical = canonicalizeModelName(raw); - for (const matcher of PROVIDER_MATCHERS) { - if (matcher.matcher.test(canonical)) return matcher.provider; - } - return 'Other'; -} - function getModelColor(name) { return MODEL_COLORS[name] || 'rgb(113, 128, 150)'; } -function sortByDate(data) { - return [...data].sort((a, b) => a.date.localeCompare(b.date)); -} - -function filterByDateRange(data, start, end) { - return data.filter((day) => { - if (start && day.date < start) return false; - if (end && day.date > end) return false; - return true; - }); -} - -function filterByMonth(data, month) { - if (!month) return data; - return data.filter((day) => day.date.startsWith(month)); -} - -function recalculateDayFromBreakdowns(day, modelBreakdowns) { - let inputTokens = 0; - let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; - let thinkingTokens = 0; - let totalCost = 0; - let requestCount = 0; - - for (const breakdown of modelBreakdowns) { - inputTokens += breakdown.inputTokens; - outputTokens += breakdown.outputTokens; - cacheCreationTokens += breakdown.cacheCreationTokens; - cacheReadTokens += breakdown.cacheReadTokens; - thinkingTokens += breakdown.thinkingTokens; - totalCost += breakdown.cost; - requestCount += breakdown.requestCount; - } - - return { - ...day, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - thinkingTokens, - totalCost, - requestCount, - totalTokens: - inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, - modelsUsed: modelBreakdowns.map((item) => item.modelName), - modelBreakdowns, - }; -} - -function filterByProviders(data, selectedProviders) { - if (!selectedProviders || selectedProviders.length === 0) return data; - const selected = new Set(selectedProviders); - return data - .map((day) => { - const filteredBreakdowns = day.modelBreakdowns.filter((entry) => - selected.has(getModelProvider(entry.modelName)), - ); - return filteredBreakdowns.length > 0 - ? recalculateDayFromBreakdowns(day, filteredBreakdowns) - : null; - }) - .filter(Boolean); -} - -function filterByModels(data, selectedModels) { - if (!selectedModels || selectedModels.length === 0) return data; - const selected = new Set(selectedModels); - return data - .map((day) => { - const filteredBreakdowns = day.modelBreakdowns.filter((entry) => - selected.has(normalizeModelName(entry.modelName)), - ); - return filteredBreakdowns.length > 0 - ? recalculateDayFromBreakdowns(day, filteredBreakdowns) - : null; - }) - .filter(Boolean); -} - -function aggregateToDailyFormat(data, viewMode) { - if (viewMode === 'daily') return data; - const groupKey = viewMode === 'monthly' ? (date) => date.slice(0, 7) : (date) => date.slice(0, 4); - const groups = new Map(); - - for (const day of data) { - const key = groupKey(day.date); - const existing = groups.get(key); - const days = day._aggregatedDays || 1; - - if (!existing) { - groups.set(key, { - ...day, - date: key, - _aggregatedDays: days, - }); - continue; - } - - existing.totalCost += day.totalCost; - existing.totalTokens += day.totalTokens; - existing.inputTokens += day.inputTokens; - existing.outputTokens += day.outputTokens; - existing.cacheCreationTokens += day.cacheCreationTokens; - existing.cacheReadTokens += day.cacheReadTokens; - existing.thinkingTokens += day.thinkingTokens; - existing.requestCount += day.requestCount; - existing._aggregatedDays += days; - existing.modelBreakdowns = existing.modelBreakdowns.concat(day.modelBreakdowns); - existing.modelsUsed = Array.from(new Set(existing.modelsUsed.concat(day.modelsUsed))); - } - - return Array.from(groups.values()).sort((a, b) => a.date.localeCompare(b.date)); -} - -function computeMovingAverage(values, window = 7) { - const result = new Array(values.length); - let sum = 0; - for (let index = 0; index < values.length; index += 1) { - sum += values[index]; - if (index >= window) sum -= values[index - window]; - result[index] = index < window - 1 ? null : sum / window; - } - return result; -} - function toCostChartData(data) { const sorted = sortByDate(data); const ma7 = computeMovingAverage(sorted.map((day) => day.totalCost)); @@ -362,163 +74,6 @@ function toWeekdayData(data) { }); } -function stdDev(values) { - if (!values.length) return 0; - const mean = values.reduce((sum, value) => sum + value, 0) / values.length; - const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length; - return Math.sqrt(variance); -} - -function computeWeekOverWeekChange(data) { - if (data.some((entry) => !/^\d{4}-\d{2}-\d{2}$/.test(entry.date))) return null; - if (data.length < 14) return null; - const sorted = sortByDate(data); - const last7 = sorted.slice(-7); - const prev7 = sorted.slice(-14, -7); - const lastSum = last7.reduce((sum, day) => sum + day.totalCost, 0); - const prevSum = prev7.reduce((sum, day) => sum + day.totalCost, 0); - if (prevSum === 0) return null; - return ((lastSum - prevSum) / prevSum) * 100; -} - -function computeBusiestWeek(data) { - const sorted = sortByDate(data).filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)); - if (sorted.length < 3) return null; - let best = null; - for (let start = 0; start < sorted.length; start += 1) { - const startDate = new Date(`${sorted[start].date}T00:00:00`); - const endLimit = new Date(startDate); - endLimit.setDate(endLimit.getDate() + 6); - let cost = 0; - let end = start; - while (end < sorted.length && new Date(`${sorted[end].date}T00:00:00`) <= endLimit) { - cost += sorted[end].totalCost; - end += 1; - } - if (!best || cost > best.cost) { - best = { start: sorted[start].date, end: sorted[end - 1].date, cost }; - } - } - return best; -} - -function computeMetrics(data) { - if (data.length === 0) { - return { - totalCost: 0, - totalTokens: 0, - activeDays: 0, - totalRequests: 0, - hasRequestData: false, - avgDailyCost: 0, - avgRequestsPerDay: 0, - avgTokensPerRequest: 0, - avgCostPerRequest: 0, - cacheHitRate: 0, - costPerMillion: 0, - topModel: null, - topModelShare: 0, - topProvider: null, - topDay: null, - cheapestDay: null, - busiestWeek: null, - weekendCostShare: null, - weekOverWeekChange: null, - requestVolatility: 0, - providerCount: 0, - }; - } - - const modelCosts = new Map(); - const providerCosts = new Map(); - let totalCost = 0; - let totalTokens = 0; - let totalRequests = 0; - let totalInput = 0; - let totalOutput = 0; - let totalCacheRead = 0; - let totalCacheCreate = 0; - let totalThinking = 0; - let activeDays = 0; - let hasRequestData = false; - let weekendCost = 0; - let weekendEligible = 0; - let topDay = { date: data[0].date, cost: data[0].totalCost }; - let cheapestDay = { date: data[0].date, cost: data[0].totalCost }; - - for (const day of data) { - totalCost += day.totalCost; - totalTokens += day.totalTokens; - totalRequests += day.requestCount; - totalInput += day.inputTokens; - totalOutput += day.outputTokens; - totalCacheRead += day.cacheReadTokens; - totalCacheCreate += day.cacheCreationTokens; - totalThinking += day.thinkingTokens; - activeDays += day._aggregatedDays || 1; - if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0)) - hasRequestData = true; - if (day.totalCost > topDay.cost) topDay = { date: day.date, cost: day.totalCost }; - if (day.totalCost < cheapestDay.cost) cheapestDay = { date: day.date, cost: day.totalCost }; - - if (/^\d{4}-\d{2}-\d{2}$/.test(day.date)) { - const weekday = new Date(`${day.date}T00:00:00`).getDay(); - if (weekday === 0 || weekday === 6) weekendCost += day.totalCost; - weekendEligible += day.totalCost; - } - - for (const breakdown of day.modelBreakdowns) { - const model = normalizeModelName(breakdown.modelName); - const provider = getModelProvider(breakdown.modelName); - modelCosts.set(model, (modelCosts.get(model) || 0) + breakdown.cost); - providerCosts.set(provider, (providerCosts.get(provider) || 0) + breakdown.cost); - } - } - - let topModel = null; - for (const [name, cost] of modelCosts) { - if (!topModel || cost > topModel.cost) topModel = { name, cost }; - } - - let topProvider = null; - for (const [name, cost] of providerCosts) { - if (!topProvider || cost > topProvider.cost) { - topProvider = { name, cost, share: totalCost > 0 ? (cost / totalCost) * 100 : 0 }; - } - } - - const cacheBase = totalCacheRead + totalCacheCreate + totalInput + totalOutput + totalThinking; - - return { - totalCost, - totalTokens, - activeDays, - totalRequests, - totalInput, - totalOutput, - totalCacheRead, - totalCacheCreate, - totalThinking, - hasRequestData, - avgDailyCost: activeDays > 0 ? totalCost / activeDays : 0, - avgRequestsPerDay: hasRequestData && activeDays > 0 ? totalRequests / activeDays : 0, - avgTokensPerRequest: hasRequestData && totalRequests > 0 ? totalTokens / totalRequests : 0, - avgCostPerRequest: hasRequestData && totalRequests > 0 ? totalCost / totalRequests : 0, - cacheHitRate: cacheBase > 0 ? (totalCacheRead / cacheBase) * 100 : 0, - costPerMillion: totalTokens > 0 ? totalCost / (totalTokens / 1000000) : 0, - topModel, - topModelShare: topModel && totalCost > 0 ? (topModel.cost / totalCost) * 100 : 0, - topProvider, - topDay, - cheapestDay, - busiestWeek: computeBusiestWeek(data), - weekendCostShare: weekendEligible > 0 ? (weekendCost / weekendEligible) * 100 : null, - weekOverWeekChange: computeWeekOverWeekChange(data), - requestVolatility: stdDev(data.map((item) => item.requestCount)), - providerCount: providerCosts.size, - }; -} - function computeModelRows(data) { const rows = new Map(); for (const day of data) { diff --git a/server/runtime.js b/server/runtime.js new file mode 100644 index 0000000..d4d3445 --- /dev/null +++ b/server/runtime.js @@ -0,0 +1,78 @@ +function isLoopbackHost(host) { + const normalized = String(host || '') + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ''); + const ipv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(normalized); + const ipv4MappedLoopback = /^::ffff:127(?:\.\d{1,3}){3}$/.test(normalized); + return ipv4Loopback || normalized === 'localhost' || normalized === '::1' || ipv4MappedLoopback; +} + +function ensureBindHostAllowed(bindHost, allowRemoteBind) { + if (isLoopbackHost(bindHost) || allowRemoteBind) { + return; + } + + const error = new Error( + `Refusing to bind TTDash to non-loopback host "${bindHost}" without TTDASH_ALLOW_REMOTE=1.`, + ); + error.code = 'REMOTE_BIND_REQUIRES_OPT_IN'; + throw error; +} + +function createNoFreePortError(rangeStartPort, maxPort) { + return new Error(`No free port found (${rangeStartPort}-${maxPort})`); +} + +async function listenOnAvailablePort( + serverInstance, + port, + maxPort, + bindHost, + log = console.log, + rangeStartPort = port, +) { + if (port > maxPort) { + throw createNoFreePortError(rangeStartPort, maxPort); + } + + for (let currentPort = port; currentPort <= maxPort; currentPort += 1) { + try { + await new Promise((resolve, reject) => { + const onError = (error) => { + serverInstance.off('listening', onListening); + reject(error); + }; + + const onListening = () => { + serverInstance.off('error', onError); + resolve(); + }; + + serverInstance.once('error', onError); + serverInstance.once('listening', onListening); + serverInstance.listen(currentPort, bindHost); + }); + + return currentPort; + } catch (error) { + if (error && error.code === 'EADDRINUSE') { + if (currentPort >= maxPort) { + throw createNoFreePortError(rangeStartPort, maxPort); + } + log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`); + continue; + } + throw error; + } + } + + throw createNoFreePortError(rangeStartPort, maxPort); +} + +module.exports = { + createNoFreePortError, + ensureBindHostAllowed, + isLoopbackHost, + listenOnAvailablePort, +}; diff --git a/shared/dashboard-domain.d.ts b/shared/dashboard-domain.d.ts new file mode 100644 index 0000000..a2a17db --- /dev/null +++ b/shared/dashboard-domain.d.ts @@ -0,0 +1,19 @@ +import type { DailyUsage, DashboardMetrics, ViewMode } from './dashboard-types' + +export function aggregateToDailyFormat(data: DailyUsage[], viewMode: ViewMode): DailyUsage[] +export function computeBusiestWeek( + data: DailyUsage[], +): { start: string; end: string; cost: number } | null +export function computeMetrics(data: DailyUsage[]): DashboardMetrics +export function computeMovingAverage( + values: Array, + window?: number, +): Array +export function computeWeekOverWeekChange(data: DailyUsage[]): number | null +export function filterByDateRange(data: DailyUsage[], start?: string, end?: string): DailyUsage[] +export function filterByModels(data: DailyUsage[], selectedModels: string[]): DailyUsage[] +export function filterByMonth(data: DailyUsage[], month: string | null): DailyUsage[] +export function filterByProviders(data: DailyUsage[], selectedProviders: string[]): DailyUsage[] +export function getModelProvider(raw: string): string +export function normalizeModelName(raw: string): string +export function sortByDate(data: DailyUsage[]): DailyUsage[] diff --git a/shared/dashboard-domain.js b/shared/dashboard-domain.js new file mode 100644 index 0000000..b097da2 --- /dev/null +++ b/shared/dashboard-domain.js @@ -0,0 +1,615 @@ +const modelNormalizationSpec = require('../server/model-normalization.json') + +const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({ + ...alias, + matcher: new RegExp(alias.pattern, 'i'), +})) + +const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({ + ...matcher, + matcher: new RegExp(matcher.pattern, 'i'), +})) + +function titleCaseSegment(segment) { + if (!segment) return segment + if (/^\d+([.-]\d+)*$/.test(segment)) return segment.replace(/-/g, '.') + if (/^[a-z]{1,4}\d+$/i.test(segment)) return segment.toUpperCase() + return segment.charAt(0).toUpperCase() + segment.slice(1) +} + +function capitalize(segment) { + if (!segment) return '' + return segment.charAt(0).toUpperCase() + segment.slice(1) +} + +function formatVersion(version) { + return version.replace(/-/g, '.') +} + +function canonicalizeModelName(raw) { + const normalized = String(raw || '') + .trim() + .toLowerCase() + .replace(/^model[:/ -]*/i, '') + .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '') + .replace(/\./g, '-') + .replace(/[_/]+/g, '-') + .replace(/\s+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-|-$/g, '') + + const suffixStart = normalized.lastIndexOf('-') + if (suffixStart > 0) { + const suffix = normalized.slice(suffixStart + 1) + if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) { + return normalized.slice(0, suffixStart) + } + } + + return normalized +} + +function parseClaudeName(rest) { + const parts = rest.split('-') + if (parts.length < 2) { + return `Claude ${capitalize(rest)}` + } + + const family = capitalize(parts[0] || '') + const secondPart = parts[1] || '' + + if (/^\d+$/.test(secondPart)) { + const version = formatVersion(parts.slice(1).join('-')) + return ['Claude', family, version].filter(Boolean).join(' ').trim() + } + + const model = capitalize(secondPart) + const version = formatVersion(parts.slice(2).join('-')) + + return ['Claude', family, model, version].filter(Boolean).join(' ').trim() +} + +function parseGptName(rest) { + const parts = rest.split('-') + const variant = parts[0] || '' + const minor = parts[1] || '' + + if (minor && minor.length <= 2 && /^\d+$/.test(minor)) { + const version = `${variant}.${minor}` + if (parts.length > 2) { + const suffix = parts.slice(2).map(capitalize).join(' ') + return `GPT-${version}${suffix ? ` ${suffix}` : ''}` + } + return `GPT-${version}` + } + + if (parts.length > 1) { + const suffix = parts.slice(1).map(capitalize).join(' ') + return `GPT-${variant}${suffix ? ` ${suffix}` : ''}` + } + + return `GPT-${rest}` +} + +function parseGeminiName(rest) { + const parts = rest.split('-') + if (parts.length < 2) { + return `Gemini ${rest}` + } + + const versionParts = [] + const tierParts = [] + + for (const part of parts) { + if (/^\d+$/.test(part) && tierParts.length === 0) { + versionParts.push(part) + } else { + tierParts.push(capitalize(part)) + } + } + + const version = versionParts.join('.') + const tier = tierParts.join(' ') + + return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}` +} + +function parseCodexName(rest) { + const normalized = rest.replace(/-latest$/i, '') + if (!normalized) { + return 'Codex' + } + + return `Codex ${normalized.split('-').map(capitalize).join(' ')}` +} + +function parseOSeries(name) { + const separatorIndex = name.indexOf('-') + if (separatorIndex === -1) { + return name + } + + return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}` +} + +function normalizeModelName(raw) { + const canonical = canonicalizeModelName(raw) + + if (canonical.startsWith('claude-')) { + return parseClaudeName(canonical.slice('claude-'.length)) + } + + for (const alias of DISPLAY_ALIASES) { + if (alias.matcher.test(canonical)) return alias.name + } + + if (canonical.startsWith('gpt-')) { + return parseGptName(canonical.slice('gpt-'.length)) + } + + if (canonical.startsWith('gemini-')) { + return parseGeminiName(canonical.slice('gemini-'.length)) + } + + if (canonical.startsWith('codex-')) { + return parseCodexName(canonical.slice('codex-'.length)) + } + + if (/^o\d/i.test(canonical)) { + return parseOSeries(canonical) + } + + const familyMatch = canonical.match( + /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i, + ) + if (familyMatch) { + const family = familyMatch[1] + if (/^codex$/i.test(family)) { + return parseCodexName(familyMatch[2] || '') + } + + if (/^(o\d)$/i.test(family)) return parseOSeries(canonical) + + const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : '' + if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}` + return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim() + } + + return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || '') +} + +function getModelProvider(raw) { + const canonical = canonicalizeModelName(raw) + for (const matcher of PROVIDER_MATCHERS) { + if (matcher.matcher.test(canonical)) return matcher.provider + } + return 'Other' +} + +function recalculateDayFromBreakdowns(day, filteredBreakdowns) { + let totalCost = 0 + let inputTokens = 0 + let outputTokens = 0 + let cacheCreationTokens = 0 + let cacheReadTokens = 0 + let thinkingTokens = 0 + let requestCount = 0 + + for (const breakdown of filteredBreakdowns) { + totalCost += breakdown.cost + inputTokens += breakdown.inputTokens + outputTokens += breakdown.outputTokens + cacheCreationTokens += breakdown.cacheCreationTokens + cacheReadTokens += breakdown.cacheReadTokens + thinkingTokens += breakdown.thinkingTokens + requestCount += breakdown.requestCount + } + + return { + ...day, + totalCost, + totalTokens: + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + thinkingTokens, + requestCount, + modelBreakdowns: filteredBreakdowns, + modelsUsed: [ + ...new Set(filteredBreakdowns.map((breakdown) => normalizeModelName(breakdown.modelName))), + ], + } +} + +function filterByDateRange(data, start, end) { + return data.filter((entry) => { + if (start && entry.date < start) return false + if (end && entry.date > end) return false + return true + }) +} + +function filterByModels(data, selectedModels) { + if (!selectedModels || selectedModels.length === 0) return data + const selected = new Set(selectedModels) + + return data + .map((entry) => { + const filteredBreakdowns = entry.modelBreakdowns.filter((breakdown) => + selected.has(normalizeModelName(breakdown.modelName)), + ) + + if (filteredBreakdowns.length === 0) return null + return recalculateDayFromBreakdowns(entry, filteredBreakdowns) + }) + .filter(Boolean) +} + +function filterByProviders(data, selectedProviders) { + if (!selectedProviders || selectedProviders.length === 0) return data + const selected = new Set(selectedProviders) + + return data + .map((entry) => { + const filteredBreakdowns = entry.modelBreakdowns.filter((breakdown) => + selected.has(getModelProvider(breakdown.modelName)), + ) + + if (filteredBreakdowns.length === 0) return null + return recalculateDayFromBreakdowns(entry, filteredBreakdowns) + }) + .filter(Boolean) +} + +function filterByMonth(data, month) { + if (!month) return data + return data.filter((entry) => entry.date.startsWith(month)) +} + +function sortByDate(data) { + return [...data].sort((left, right) => left.date.localeCompare(right.date)) +} + +function aggregateToDailyFormat(data, viewMode) { + if (viewMode === 'daily') return data + + const getGroupKey = + viewMode === 'monthly' ? (date) => date.slice(0, 7) : (date) => date.slice(0, 4) + const groups = new Map() + + for (const day of data) { + const key = getGroupKey(day.date) + const existing = groups.get(key) + const aggregatedDays = day._aggregatedDays || 1 + + if (!existing) { + groups.set(key, { + ...day, + date: key, + _aggregatedDays: aggregatedDays, + }) + continue + } + + existing.totalCost += day.totalCost + existing.totalTokens += day.totalTokens + existing.inputTokens += day.inputTokens + existing.outputTokens += day.outputTokens + existing.cacheCreationTokens += day.cacheCreationTokens + existing.cacheReadTokens += day.cacheReadTokens + existing.thinkingTokens += day.thinkingTokens + existing.requestCount += day.requestCount + existing._aggregatedDays += aggregatedDays + existing.modelBreakdowns = existing.modelBreakdowns.concat(day.modelBreakdowns) + existing.modelsUsed = Array.from(new Set(existing.modelsUsed.concat(day.modelsUsed))) + } + + return Array.from(groups.values()).sort((left, right) => left.date.localeCompare(right.date)) +} + +function computeMovingAverage(values, window = 7) { + const result = Array(values.length) + let sum = 0 + let definedCount = 0 + + for (let index = 0; index < values.length; index += 1) { + const currentValue = values[index] + if (currentValue !== undefined) { + sum += currentValue + definedCount += 1 + } + + if (index >= window) { + const outgoingValue = values[index - window] + if (outgoingValue !== undefined) { + sum -= outgoingValue + definedCount -= 1 + } + } + + result[index] = + index < window - 1 ? undefined : definedCount > 0 ? sum / definedCount : undefined + } + + return result +} + +function stdDev(values) { + if (!values.length) return 0 + const mean = values.reduce((sum, value) => sum + value, 0) / values.length + const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length + return Math.sqrt(variance) +} + +function computeBusiestWeek(data) { + const sorted = data + .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) + .sort((left, right) => left.date.localeCompare(right.date)) + + if (sorted.length < 3) return null + + let bestWindow = null + + for (let start = 0; start < sorted.length; start += 1) { + const startEntry = sorted[start] + if (!startEntry) continue + + const startDate = new Date(`${startEntry.date}T00:00:00`) + const endLimit = new Date(startDate) + endLimit.setDate(endLimit.getDate() + 6) + let windowCost = 0 + let end = start + + while (end < sorted.length) { + const endEntry = sorted[end] + if (!endEntry) break + if (new Date(`${endEntry.date}T00:00:00`) > endLimit) break + windowCost += endEntry.totalCost + end += 1 + } + + const finalEntry = sorted[end - 1] + if (finalEntry && (!bestWindow || windowCost > bestWindow.cost)) { + bestWindow = { + start: startEntry.date, + end: finalEntry.date, + cost: windowCost, + } + } + } + + return bestWindow +} + +function computeWeekOverWeekChange(data) { + if (data.some((entry) => !/^\d{4}-\d{2}-\d{2}$/.test(entry.date))) return null + if (data.length < 14) return null + const sorted = sortByDate(data) + const last7 = sorted.slice(-7) + const prev7 = sorted.slice(-14, -7) + const lastSum = last7.reduce((sum, day) => sum + day.totalCost, 0) + const prevSum = prev7.reduce((sum, day) => sum + day.totalCost, 0) + if (prevSum === 0) return null + return ((lastSum - prevSum) / prevSum) * 100 +} + +function computeMetrics(data) { + if (data.length === 0) { + return { + totalCost: 0, + totalTokens: 0, + activeDays: 0, + topModel: null, + topRequestModel: null, + topTokenModel: null, + topModelShare: 0, + topThreeModelsShare: 0, + topProvider: null, + providerCount: 0, + hasRequestData: false, + cacheHitRate: 0, + costPerMillion: 0, + avgTokensPerRequest: 0, + avgCostPerRequest: 0, + avgModelsPerEntry: 0, + avgDailyCost: 0, + avgRequestsPerDay: 0, + topDay: null, + cheapestDay: null, + busiestWeek: null, + weekendCostShare: null, + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheCreate: 0, + totalThinking: 0, + totalRequests: 0, + weekOverWeekChange: null, + requestVolatility: 0, + modelConcentrationIndex: 0, + providerConcentrationIndex: 0, + } + } + + const firstDay = data[0] + let topDay = { date: firstDay.date, cost: firstDay.totalCost } + let cheapestDay = { date: firstDay.date, cost: firstDay.totalCost } + let totalCost = 0 + let totalTokens = 0 + let totalInput = 0 + let totalOutput = 0 + let totalCacheRead = 0 + let totalCacheCreate = 0 + let totalThinking = 0 + let totalRequests = 0 + let activeDays = 0 + let hasRequestData = false + let totalModelsUsed = 0 + let weekendCost = 0 + let weekendEligible = 0 + const modelCosts = new Map() + const modelTokens = new Map() + const modelRequests = new Map() + const providerCosts = new Map() + + for (const day of data) { + totalCost += day.totalCost + totalTokens += day.totalTokens + totalInput += day.inputTokens + totalOutput += day.outputTokens + totalCacheRead += day.cacheReadTokens + totalCacheCreate += day.cacheCreationTokens + totalThinking += day.thinkingTokens + totalRequests += day.requestCount + if ( + day.requestCount > 0 || + day.modelBreakdowns.some((breakdown) => breakdown.requestCount > 0) + ) { + hasRequestData = true + } + activeDays += day._aggregatedDays || 1 + totalModelsUsed += day.modelsUsed.length + + if (/^\d{4}-\d{2}-\d{2}$/.test(day.date)) { + const weekday = new Date(`${day.date}T00:00:00`).getDay() + if (weekday === 0 || weekday === 6) weekendCost += day.totalCost + weekendEligible += day.totalCost + } + + if (day.totalCost > topDay.cost) topDay = { date: day.date, cost: day.totalCost } + if (day.totalCost < cheapestDay.cost) cheapestDay = { date: day.date, cost: day.totalCost } + + for (const breakdown of day.modelBreakdowns) { + const normalizedName = normalizeModelName(breakdown.modelName) + const totalBreakdownTokens = + breakdown.inputTokens + + breakdown.outputTokens + + breakdown.cacheCreationTokens + + breakdown.cacheReadTokens + + breakdown.thinkingTokens + + modelCosts.set(normalizedName, (modelCosts.get(normalizedName) || 0) + breakdown.cost) + modelTokens.set(normalizedName, (modelTokens.get(normalizedName) || 0) + totalBreakdownTokens) + modelRequests.set( + normalizedName, + (modelRequests.get(normalizedName) || 0) + breakdown.requestCount, + ) + + const provider = getModelProvider(breakdown.modelName) + providerCosts.set(provider, (providerCosts.get(provider) || 0) + breakdown.cost) + } + } + + const avgDailyCost = totalCost / activeDays + const avgRequestsPerDay = hasRequestData && activeDays > 0 ? totalRequests / activeDays : 0 + const costPerMillion = totalTokens > 0 ? totalCost / (totalTokens / 1_000_000) : 0 + const avgTokensPerRequest = hasRequestData && totalRequests > 0 ? totalTokens / totalRequests : 0 + const avgCostPerRequest = hasRequestData && totalRequests > 0 ? totalCost / totalRequests : 0 + const avgModelsPerEntry = data.length > 0 ? totalModelsUsed / data.length : 0 + const cacheBase = totalCacheRead + totalCacheCreate + totalInput + totalOutput + totalThinking + const cacheHitRate = cacheBase > 0 ? (totalCacheRead / cacheBase) * 100 : 0 + + let topModel = null + for (const [name, cost] of modelCosts) { + if (!topModel || cost > topModel.cost) topModel = { name, cost } + } + + let topRequestModel = null + for (const [name, requests] of modelRequests) { + if (!topRequestModel || requests > topRequestModel.requests) { + topRequestModel = { name, requests } + } + } + + let topTokenModel = null + for (const [name, tokens] of modelTokens) { + if (!topTokenModel || tokens > topTokenModel.tokens) topTokenModel = { name, tokens } + } + + const topModelShare = topModel && totalCost > 0 ? (topModel.cost / totalCost) * 100 : 0 + const topThreeModelsShare = + totalCost > 0 + ? ([...modelCosts.values()] + .sort((left, right) => right - left) + .slice(0, 3) + .reduce((sum, value) => sum + value, 0) / + totalCost) * + 100 + : 0 + + let topProvider = null + for (const [name, cost] of providerCosts) { + if (!topProvider || cost > topProvider.cost) { + topProvider = { name, cost, share: totalCost > 0 ? (cost / totalCost) * 100 : 0 } + } + } + + const requestValues = data.map((entry) => entry.requestCount) + const requestVolatility = stdDev(requestValues) + const modelConcentrationIndex = + totalCost > 0 + ? [...modelCosts.values()].reduce((sum, cost) => { + const share = cost / totalCost + return sum + share * share + }, 0) + : 0 + const providerConcentrationIndex = + totalCost > 0 + ? [...providerCosts.values()].reduce((sum, cost) => { + const share = cost / totalCost + return sum + share * share + }, 0) + : 0 + + return { + totalCost, + totalTokens, + activeDays, + topModel, + topRequestModel, + topTokenModel, + topModelShare, + topThreeModelsShare, + topProvider, + providerCount: providerCosts.size, + hasRequestData, + cacheHitRate, + costPerMillion, + avgTokensPerRequest, + avgCostPerRequest, + avgModelsPerEntry, + avgDailyCost, + avgRequestsPerDay, + topDay, + cheapestDay, + busiestWeek: computeBusiestWeek(data), + weekendCostShare: weekendEligible > 0 ? (weekendCost / weekendEligible) * 100 : null, + totalInput, + totalOutput, + totalCacheRead, + totalCacheCreate, + totalThinking, + totalRequests, + weekOverWeekChange: computeWeekOverWeekChange(data), + requestVolatility, + modelConcentrationIndex, + providerConcentrationIndex, + } +} + +module.exports = { + aggregateToDailyFormat, + computeBusiestWeek, + computeMetrics, + computeMovingAverage, + computeWeekOverWeekChange, + filterByDateRange, + filterByModels, + filterByMonth, + filterByProviders, + getModelProvider, + normalizeModelName, + sortByDate, +} diff --git a/shared/dashboard-preferences.json b/shared/dashboard-preferences.json new file mode 100644 index 0000000..9361efb --- /dev/null +++ b/shared/dashboard-preferences.json @@ -0,0 +1,43 @@ +{ + "datePresets": ["all", "7d", "30d", "month", "year"], + "viewModes": ["daily", "monthly", "yearly"], + "sectionDefinitions": [ + { "id": "insights", "domId": "insights", "labelKey": "helpPanel.sectionLabels.insights" }, + { "id": "metrics", "domId": "metrics", "labelKey": "helpPanel.sectionLabels.metrics" }, + { "id": "today", "domId": "today", "labelKey": "helpPanel.sectionLabels.today" }, + { + "id": "currentMonth", + "domId": "current-month", + "labelKey": "helpPanel.sectionLabels.currentMonth" + }, + { "id": "activity", "domId": "activity", "labelKey": "helpPanel.sectionLabels.activity" }, + { + "id": "forecastCache", + "domId": "forecast-cache", + "labelKey": "helpPanel.sectionLabels.forecastCache" + }, + { "id": "limits", "domId": "limits", "labelKey": "helpPanel.sectionLabels.limits" }, + { "id": "costAnalysis", "domId": "charts", "labelKey": "helpPanel.sectionLabels.costAnalysis" }, + { + "id": "tokenAnalysis", + "domId": "token-analysis", + "labelKey": "helpPanel.sectionLabels.tokenAnalysis" + }, + { + "id": "requestAnalysis", + "domId": "request-analysis", + "labelKey": "helpPanel.sectionLabels.requestAnalysis" + }, + { + "id": "advancedAnalysis", + "domId": "advanced-analysis", + "labelKey": "helpPanel.sectionLabels.advancedAnalysis" + }, + { + "id": "comparisons", + "domId": "comparisons", + "labelKey": "helpPanel.sectionLabels.comparisons" + }, + { "id": "tables", "domId": "tables", "labelKey": "helpPanel.sectionLabels.tables" } + ] +} diff --git a/shared/dashboard-types.d.ts b/shared/dashboard-types.d.ts new file mode 100644 index 0000000..1b0c462 --- /dev/null +++ b/shared/dashboard-types.d.ts @@ -0,0 +1,62 @@ +export interface ModelBreakdown { + modelName: string + inputTokens: number + outputTokens: number + cacheCreationTokens: number + cacheReadTokens: number + thinkingTokens: number + cost: number + requestCount: number +} + +export interface DailyUsage { + date: string + inputTokens: number + outputTokens: number + cacheCreationTokens: number + cacheReadTokens: number + thinkingTokens: number + totalTokens: number + totalCost: number + requestCount: number + modelsUsed: string[] + modelBreakdowns: ModelBreakdown[] + _aggregatedDays?: number +} + +export type ViewMode = 'daily' | 'monthly' | 'yearly' + +export interface DashboardMetrics { + totalCost: number + totalTokens: number + activeDays: number + topModel: { name: string; cost: number } | null + topRequestModel: { name: string; requests: number } | null + topTokenModel: { name: string; tokens: number } | null + topModelShare: number + topThreeModelsShare: number + topProvider: { name: string; cost: number; share: number } | null + providerCount: number + hasRequestData: boolean + cacheHitRate: number + costPerMillion: number + avgTokensPerRequest: number + avgCostPerRequest: number + avgModelsPerEntry: number + avgDailyCost: number + avgRequestsPerDay: number + topDay: { date: string; cost: number } | null + cheapestDay: { date: string; cost: number } | null + busiestWeek: { start: string; end: string; cost: number } | null + weekendCostShare: number | null + totalInput: number + totalOutput: number + totalCacheRead: number + totalCacheCreate: number + totalThinking: number + totalRequests: number + weekOverWeekChange: number | null + requestVolatility: number + modelConcentrationIndex: number + providerConcentrationIndex: number +} diff --git a/src/App.tsx b/src/App.tsx index d0a8f9f..d184983 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,9 +7,10 @@ import type { AppSettings } from '@/types' interface AppProps { initialSettings: AppSettings + initialSettingsError?: string | null } -export function App({ initialSettings }: AppProps) { +export function App({ initialSettings, initialSettingsError = null }: AppProps) { const [queryClient] = useState(() => { const client = new QueryClient({ defaultOptions: { @@ -27,7 +28,7 @@ export function App({ initialSettings }: AppProps) { - + diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 23113cf..12b2a4b 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,82 +1,17 @@ -import { Fragment, lazy, Suspense, useEffect, useRef, useState, useCallback, useMemo } from 'react' +import { lazy, Suspense } from 'react' import { useTranslation } from 'react-i18next' -import { useQueryClient } from '@tanstack/react-query' import { SlidersHorizontal } from 'lucide-react' import { Header } from './layout/Header' import { FilterBar } from './layout/FilterBar' -import { PrimaryMetrics } from './cards/PrimaryMetrics' -import { SecondaryMetrics } from './cards/SecondaryMetrics' -import { TodayMetrics } from './cards/TodayMetrics' -import { MonthMetrics } from './cards/MonthMetrics' -import { CostOverTime } from './charts/CostOverTime' -import { CostByModel } from './charts/CostByModel' -import { CostByModelOverTime } from './charts/CostByModelOverTime' -import { CumulativeCost } from './charts/CumulativeCost' -import { TokensOverTime } from './charts/TokensOverTime' -import { RequestsOverTime } from './charts/RequestsOverTime' -import { RequestCacheHitRateByModel } from './charts/RequestCacheHitRateByModel' -import { TokenTypes } from './charts/TokenTypes' -import { CostByWeekday } from './charts/CostByWeekday' -import { TokenEfficiency } from './charts/TokenEfficiency' -import { ModelMix } from './charts/ModelMix' -import { DistributionAnalysis } from './charts/DistributionAnalysis' -import { CorrelationAnalysis } from './charts/CorrelationAnalysis' -import { ModelEfficiency } from './tables/ModelEfficiency' -import { ProviderEfficiency } from './tables/ProviderEfficiency' -import { RecentDays } from './tables/RecentDays' import { EmptyState } from './EmptyState' -import { HeatmapCalendar } from './features/heatmap/HeatmapCalendar' -import { CostForecast } from './features/forecast/CostForecast' -import { CacheROI } from './features/cache-roi/CacheROI' -import { PeriodComparison } from './features/comparison/PeriodComparison' -import { AnomalyDetection } from './features/anomaly/AnomalyDetection' -import { UsageInsights } from './features/insights/UsageInsights' -import { ConcentrationRisk } from './features/risk/ConcentrationRisk' -import { RequestQuality } from './features/request-quality/RequestQuality' -import { PDFReportButton } from './features/pdf-report/PDFReport' +import { LoadErrorState } from './LoadErrorState' import { CommandPalette } from './features/command-palette/CommandPalette' -import { FadeIn } from './features/animations/FadeIn' -import { SectionHeader } from './ui/section-header' -import { ExpandableCard } from './ui/expandable-card' +import { SettingsModal } from './features/settings/SettingsModal' +import { PDFReportButton } from './features/pdf-report/PDFReport' +import { DashboardSections } from './dashboard/DashboardSections' import { DashboardSkeleton } from './ui/skeleton' import { Button } from './ui/button' -import { useUsageData, useUploadData, useDeleteData } from '@/hooks/use-usage-data' -import { useAppSettings } from '@/hooks/use-app-settings' -import { useDashboardFilters } from '@/hooks/use-dashboard-filters' -import { useComputedMetrics } from '@/hooks/use-computed-metrics' -import { useToast } from '@/components/ui/toast' -import { applyTheme } from '@/lib/app-settings' -import { downloadCSV } from '@/lib/csv-export' -import { VERSION } from '@/lib/constants' -import { SECTION_HELP } from '@/lib/help-content' -import { - generatePdfReport, - importSettings, - importUsageData, - type PdfReportRequest, -} from '@/lib/api' -import { - formatCurrency, - formatDateTimeCompact, - formatDateTimeFull, - formatTokens, - formatPercent, - periodUnit, - localToday, - toLocalDateStr, -} from '@/lib/formatters' -import { getCurrentLocale } from '@/lib/i18n' -import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' -import { SettingsModal } from './features/settings/SettingsModal' -import { ProviderLimitsSection } from './features/limits/ProviderLimitsSection' -import type { - AppLanguage, - DashboardDefaultFilters, - DashboardSectionId, - DashboardSectionOrder, - DashboardSectionVisibility, - ProviderLimits, -} from '@/types' +import { useDashboardController } from '@/hooks/use-dashboard-controller' const DrillDownModal = lazy(() => import('./features/drill-down/DrillDownModal').then((module) => ({ @@ -88,143 +23,44 @@ const AutoImportModal = lazy(() => default: module.AutoImportModal, })), ) -const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup' -const USAGE_BACKUP_KIND = 'ttdash-usage-backup' -const BACKUP_FORMAT_VERSION = 1 -type JsonDownloadRecord = { - filename: string - mimeType: string - size: number - text: string -} - -type DashboardTestHooks = { - onJsonDownload?: (record: JsonDownloadRecord) => void - openSettings?: () => void -} - -function downloadJsonFile(filename: string, data: unknown) { - const text = JSON.stringify(data, null, 2) - const blob = new Blob([text], { type: 'application/json' }) - const globalWindow = window as Window & { - __TTDASH_TEST_HOOKS__?: DashboardTestHooks - } - globalWindow.__TTDASH_TEST_HOOKS__?.onJsonDownload?.({ - filename, - mimeType: blob.type, - size: blob.size, - text, - }) - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = filename - document.body.appendChild(anchor) - anchor.click() - anchor.remove() - window.setTimeout(() => URL.revokeObjectURL(url), 1000) +interface DashboardProps { + initialSettingsError?: string | null } -export function Dashboard() { - const { t, i18n } = useTranslation() - const { data: usageData, isLoading } = useUsageData() - const uploadMutation = useUploadData() - const deleteMutation = useDeleteData() - const queryClient = useQueryClient() - const { addToast } = useToast() - const fileInputRef = useRef(null) - const settingsImportInputRef = useRef(null) - const dataImportInputRef = useRef(null) - const [drillDownDate, setDrillDownDate] = useState(null) - const [helpOpen, setHelpOpen] = useState(false) - const [autoImportOpen, setAutoImportOpen] = useState(false) - const [settingsOpen, setSettingsOpen] = useState(false) - const [reportGenerating, setReportGenerating] = useState(false) - const [settingsTransferBusy, setSettingsTransferBusy] = useState(false) - const [dataTransferBusy, setDataTransferBusy] = useState(false) - const [dataSource, setDataSource] = useState<{ - type: 'stored' | 'auto-import' | 'file' - label?: string - time?: string - title?: string - } | null>(null) - const [animationSeed, setAnimationSeed] = useState(0) - - const daily = useMemo(() => usageData?.daily ?? [], [usageData]) - const hasData = daily.length > 0 - const allProviders = useMemo(() => getUniqueProviders(daily.map((d) => d.modelsUsed)), [daily]) - const allModelsFromData = useMemo(() => getUniqueModels(daily.map((d) => d.modelsUsed)), [daily]) - const { settings, providerLimits, setTheme, setLanguage, saveSettings, isSaving } = - useAppSettings(allProviders) - const isDark = settings.theme === 'dark' - - useEffect(() => { - applyTheme(settings.theme) - }, [settings.theme]) - - useEffect(() => { - if (i18n.resolvedLanguage !== settings.language) { - void i18n.changeLanguage(settings.language) - } - }, [i18n, settings.language]) - - useEffect(() => { - const globalWindow = window as Window & { - __TTDASH_TEST_HOOKS__?: DashboardTestHooks - } - - if (!globalWindow.__TTDASH_TEST_HOOKS__) { - return undefined - } - - globalWindow.__TTDASH_TEST_HOOKS__.openSettings = () => { - setSettingsOpen(true) - } - - return () => { - if (globalWindow.__TTDASH_TEST_HOOKS__?.openSettings) { - delete globalWindow.__TTDASH_TEST_HOOKS__.openSettings - } - } - }, []) - - const persistedLoadedTime = useMemo( - () => (settings.lastLoadedAt ? formatDateTimeCompact(settings.lastLoadedAt) : undefined), - [settings.lastLoadedAt], - ) - const persistedLoadedTitle = useMemo( - () => - settings.lastLoadedAt - ? t('header.loadedAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) - : undefined, - [settings.lastLoadedAt, t], - ) - const persistedDataSource = useMemo(() => { - if (!hasData) return null - - return { - type: 'stored' as const, - ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), - ...(persistedLoadedTitle ? { title: persistedLoadedTitle } : {}), - } - }, [hasData, persistedLoadedTime, persistedLoadedTitle]) - const headerDataSource = dataSource ?? persistedDataSource - const startupAutoLoadBadge = useMemo( - () => - settings.cliAutoLoadActive - ? { - active: true, - ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), - title: settings.lastLoadedAt - ? t('header.autoLoadAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) - : t('header.autoLoadActive'), - } - : null, - [settings.cliAutoLoadActive, settings.lastLoadedAt, persistedLoadedTime, t], - ) - +export function Dashboard({ initialSettingsError = null }: DashboardProps) { + const { t } = useTranslation() + const controller = useDashboardController(initialSettingsError) const { + fileInputRef, + settingsImportInputRef, + dataImportInputRef, + settings, + providerLimits, + isLoading, + settingsLoading, + isSaving, + isDark, + hasData, + helpOpen, + setHelpOpen, + autoImportOpen, + setAutoImportOpen, + settingsOpen, + setSettingsOpen, + drillDownDate, + setDrillDownDate, + drillDownDay, + reportGenerating, + settingsTransferBusy, + dataTransferBusy, + headerDataSource, + startupAutoLoadBadge, + animationSeed, + daily, + allProviders, + settingsProviderOptions, + settingsModelOptions, viewMode, setViewMode, selectedMonth, @@ -240,7 +76,6 @@ export function Dashboard() { endDate, setEndDate, resetAll, - applyDefaultFilters, applyPreset, filteredDailyData, filteredData, @@ -248,9 +83,6 @@ export function Dashboard() { availableProviders, availableModels, dateRange, - } = useDashboardFilters(daily, settings.defaultFilters) - - const { metrics, modelCosts, providerMetrics, @@ -262,679 +94,135 @@ export function Dashboard() { allModels, modelPieData, tokenPieData, - } = useComputedMetrics(filteredData) - - // Full dataset with only model filter applied (no date/month filter) for PeriodComparison - const comparisonData = filteredDailyData - - // Calculate total calendar days from the date range (only meaningful for daily view) - const totalCalendarDays = useMemo(() => { - if (!dateRange || viewMode !== 'daily') return 0 - const start = new Date(dateRange.start + 'T00:00:00') - const end = new Date(dateRange.end + 'T00:00:00') - return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1 - }, [dateRange, viewMode]) - - const todayStr = localToday() - const todayData = useMemo( - () => filteredDailyData.find((d) => d.date === todayStr) ?? null, - [filteredDailyData, todayStr], - ) - const hasCurrentMonthData = useMemo( - () => filteredDailyData.some((d) => d.date.startsWith(todayStr.slice(0, 7))), - [filteredDailyData, todayStr], - ) - const visibleLimitProviders = useMemo( - () => (selectedProviders.length > 0 ? selectedProviders : allProviders), - [selectedProviders, allProviders], - ) - const settingsProviderOptions = useMemo( - () => - [...new Set([...allProviders, ...settings.defaultFilters.providers])].sort((left, right) => - left.localeCompare(right), - ), - [allProviders, settings.defaultFilters.providers], - ) - const settingsModelOptions = useMemo( - () => - [...new Set([...allModelsFromData, ...settings.defaultFilters.models])].sort((left, right) => - left.localeCompare(right), - ), - [allModelsFromData, settings.defaultFilters.models], - ) - const sectionVisibility = settings.sectionVisibility - const sectionOrder = settings.sectionOrder - - // Compute active streak (consecutive days from today backwards) - const streak = useMemo(() => { - const dates = new Set(filteredDailyData.map((d) => d.date)) - let count = 0 - const d = new Date(todayStr + 'T00:00:00') - while (dates.has(toLocalDateStr(d))) { - count++ - d.setDate(d.getDate() - 1) - } - return count - }, [filteredDailyData, todayStr]) - - const drillDownDay = useMemo(() => { - if (!drillDownDate) return null - return filteredData.find((d) => d.date === drillDownDate) ?? null - }, [drillDownDate, filteredData]) - - const handleUpload = useCallback(() => { - fileInputRef.current?.click() - }, []) - - const handleOpenSettings = useCallback(() => { - setSettingsOpen(true) - }, []) - - const handleToggleTheme = useCallback(() => { - void setTheme(isDark ? 'light' : 'dark') - }, [isDark, setTheme]) - - const handleSaveSettings = useCallback( - async (nextSettings: { - providerLimits: ProviderLimits - defaultFilters: DashboardDefaultFilters - sectionVisibility: DashboardSectionVisibility - sectionOrder: DashboardSectionOrder - }) => { - const updatedSettings = await saveSettings(nextSettings) - applyDefaultFilters(updatedSettings.defaultFilters) - addToast(t('toasts.settingsSaved'), 'success') - }, - [saveSettings, applyDefaultFilters, addToast, t], + comparisonData, + totalCalendarDays, + todayData, + hasCurrentMonthData, + visibleLimitProviders, + sectionVisibility, + sectionOrder, + streak, + fatalLoadState, + handleUpload, + handleOpenSettings, + handleRetryLoad, + handleResetSettings, + handleToggleTheme, + handleSaveSettings, + handleLanguageChange, + handleFileChange, + handleDelete, + handleExportCSV, + handleGenerateReport, + handleAutoImport, + handleAutoImportSuccess, + handleExportSettings, + handleExportData, + handleImportSettings, + handleImportData, + handleSettingsImportChange, + handleDataImportChange, + handleScrollTo, + } = controller + + const fileInputs = ( + <> + + + + ) - const handleLanguageChange = useCallback( - (language: AppLanguage) => { - if (settings.language !== language) { - void setLanguage(language) - } - if (i18n.resolvedLanguage !== language) { - void i18n.changeLanguage(language) - } - }, - [i18n, setLanguage, settings.language], + const autoImportDialog = ( + + {autoImportOpen && ( + + )} + ) - const handleFileChange = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - try { - const text = await file.text() - const json: unknown = JSON.parse(text) - await uploadMutation.mutateAsync(json) - void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed((prev) => prev + 1) - const now = new Date() - const time = now.toLocaleTimeString(getCurrentLocale(), { - hour: '2-digit', - minute: '2-digit', - }) - setDataSource({ - type: 'file', - label: file.name, - time, - title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, - }) - addToast(t('toasts.fileLoaded', { name: file.name }), 'success') - } catch { - addToast(t('toasts.fileReadFailed'), 'error') - } - e.target.value = '' - }, - [uploadMutation, queryClient, addToast, t], + const settingsDialog = ( + ) - const handleDelete = useCallback(async () => { - await deleteMutation.mutateAsync() - void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed((prev) => prev + 1) - setDataSource(null) - addToast(t('toasts.dataDeleted'), 'info') - }, [deleteMutation, queryClient, addToast, t]) - - const handleExportCSV = useCallback(() => { - downloadCSV(filteredData) - addToast(t('toasts.csvExported'), 'success') - }, [filteredData, addToast, t]) - - const handleGenerateReport = useCallback(async () => { - if (reportGenerating) return - setReportGenerating(true) - - try { - const requestLanguage: PdfReportRequest['language'] = i18n.language === 'en' ? 'en' : 'de' - const request: PdfReportRequest = { - viewMode, - selectedMonth, - selectedProviders, - selectedModels, - language: requestLanguage, - ...(startDate ? { startDate } : {}), - ...(endDate ? { endDate } : {}), - } - const blob = await generatePdfReport(request) - const objectUrl = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = objectUrl - a.download = `ttdash-report-${new Date().toISOString().slice(0, 10)}.pdf` - document.body.appendChild(a) - a.click() - a.remove() - window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000) - addToast(t('commandPalette.commands.generateReport.label'), 'success') - } catch (error) { - console.error('PDF generation failed:', error) - addToast( - `${t('api.pdfFailed')}: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'error', - ) - } finally { - setReportGenerating(false) - } - }, [ - reportGenerating, - viewMode, - selectedMonth, - selectedProviders, - selectedModels, - startDate, - endDate, - addToast, - i18n.language, - t, - ]) - - const handleAutoImport = useCallback(() => { - setAutoImportOpen(true) - }, []) - - const handleAutoImportSuccess = useCallback(() => { - void queryClient.invalidateQueries({ queryKey: ['usage'] }) - void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed((prev) => prev + 1) - const now = new Date() - const time = now.toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit' }) - setDataSource({ - type: 'auto-import', - ...(time ? { time } : {}), - title: t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) }), - }) - addToast(t('toasts.dataImported'), 'success') - }, [queryClient, addToast, t]) + if (!fatalLoadState && (isLoading || settingsLoading)) { + return + } - const handleExportSettings = useCallback(() => { - downloadJsonFile(`ttdash-settings-backup-${localToday()}.json`, { - kind: SETTINGS_BACKUP_KIND, - version: BACKUP_FORMAT_VERSION, - exportedAt: new Date().toISOString(), - appVersion: VERSION, - settings: { - language: settings.language, - theme: settings.theme, - providerLimits: settings.providerLimits, - defaultFilters: settings.defaultFilters, - sectionVisibility: settings.sectionVisibility, - sectionOrder: settings.sectionOrder, - lastLoadedAt: settings.lastLoadedAt, - lastLoadSource: settings.lastLoadSource, + if (fatalLoadState) { + const actions = [ + { + label: t('loadError.retry'), + onClick: () => void handleRetryLoad(), + variant: 'default' as const, }, - }) - addToast(t('toasts.settingsExported'), 'success') - }, [settings, addToast, t]) - - const handleExportData = useCallback(() => { - if (!usageData || usageData.daily.length === 0) { - addToast(t('toasts.noDataToExport'), 'info') - return - } - - downloadJsonFile(`ttdash-data-backup-${localToday()}.json`, { - kind: USAGE_BACKUP_KIND, - version: BACKUP_FORMAT_VERSION, - exportedAt: new Date().toISOString(), - appVersion: VERSION, - data: usageData, - }) - addToast(t('toasts.dataExported'), 'success') - }, [usageData, addToast, t]) - - const handleImportSettings = useCallback(() => { - settingsImportInputRef.current?.click() - }, []) - - const handleImportData = useCallback(() => { - dataImportInputRef.current?.click() - }, []) - - const handleSettingsImportChange = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - - setSettingsTransferBusy(true) - try { - const parsed: unknown = JSON.parse(await file.text()) - const imported = await importSettings(parsed) - queryClient.setQueryData(['settings'], imported) - applyDefaultFilters(imported.defaultFilters) - addToast(t('toasts.settingsImported', { name: file.name }), 'success') - } catch (error) { - addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') - } finally { - setSettingsTransferBusy(false) - e.target.value = '' - } - }, - [queryClient, applyDefaultFilters, addToast, t], - ) + ...(fatalLoadState.canResetSettings + ? [{ label: t('loadError.resetSettings'), onClick: () => void handleResetSettings() }] + : []), + ...(fatalLoadState.canResetUsage + ? [{ label: t('loadError.deleteData'), onClick: () => void handleDelete() }] + : []), + ] - const handleDataImportChange = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - - setDataTransferBusy(true) - try { - const parsed: unknown = JSON.parse(await file.text()) - const summary = await importUsageData(parsed) - await queryClient.invalidateQueries({ queryKey: ['usage'] }) - await queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed((prev) => prev + 1) - const now = new Date() - const time = now.toLocaleTimeString(getCurrentLocale(), { - hour: '2-digit', - minute: '2-digit', - }) - setDataSource({ - type: 'file', - label: file.name, - ...(time ? { time } : {}), - title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, - }) - - const toastType: 'info' | 'success' = summary.conflictingDays > 0 ? 'info' : 'success' - const toastKey = - summary.conflictingDays > 0 - ? 'toasts.dataBackupImportedWithConflicts' - : 'toasts.dataBackupImported' - addToast( - t(toastKey, { - added: summary.addedDays, - unchanged: summary.unchangedDays, - conflicts: summary.conflictingDays, - }), - toastType, - ) - } catch (error) { - addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') - } finally { - setDataTransferBusy(false) - e.target.value = '' - } - }, - [queryClient, addToast, t], - ) - - const handleScrollTo = useCallback((section: string) => { - const el = document.getElementById(section) - el?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - }, []) - - const renderSection = useCallback( - (sectionId: DashboardSectionId) => { - switch (sectionId) { - case 'insights': - return sectionVisibility.insights ? ( -
- -
- ) : null - case 'metrics': - return sectionVisibility.metrics ? ( -
- - - - - -
- d.totalCost)} - viewMode={viewMode} - /> -
-
-
- ) : null - case 'today': - return sectionVisibility.today && todayData ? ( -
- -
- ) : null - case 'currentMonth': - return sectionVisibility.currentMonth && hasCurrentMonthData ? ( -
- -
- ) : null - case 'activity': - return sectionVisibility.activity ? ( -
- - -
- - - -
-
-
- ) : null - case 'forecastCache': - return sectionVisibility.forecastCache ? ( -
- - -
- - - - - - -
-
-
- ) : null - case 'limits': - return sectionVisibility.limits ? ( -
- - - -
- ) : null - case 'costAnalysis': - return sectionVisibility.costAnalysis ? ( -
- - -
-
- -
- -
-
- -
- -
-
- -
- - -
-
- -
- - -
-
-
- ) : null - case 'tokenAnalysis': - return sectionVisibility.tokenAnalysis ? ( -
- - -
- - -
-
-
- ) : null - case 'requestAnalysis': - return sectionVisibility.requestAnalysis && metrics.hasRequestData ? ( -
- - - - - -
- -
-
- -
- -
-
-
- ) : null - case 'advancedAnalysis': - return sectionVisibility.advancedAnalysis ? ( -
- - -
- - -
-
- -
- -
-
-
- ) : null - case 'comparisons': - return sectionVisibility.comparisons ? ( -
- - -
- - - - - - -
-
-
- ) : null - case 'tables': - return sectionVisibility.tables ? ( -
- - - - - -
- -
-
- -
- -
-
-
- ) : null - default: - return null - } - }, - [ - allModels, - comparisonData, - costChartData, - filteredDailyData, - filteredData, - hasCurrentMonthData, - metrics, - modelCostChartData, - modelCosts, - modelPieData, - providerLimits, - providerMetrics, - requestChartData, - sectionVisibility, - selectedMonth, - t, - todayData, - tokenChartData, - tokenPieData, - totalCalendarDays, - viewMode, - visibleLimitProviders, - weekdayData, - ], - ) - - if (isLoading) { - return + return ( + <> + + {fileInputs} + + ) } if (!hasData) { @@ -945,91 +233,16 @@ export function Dashboard() { onAutoImport={handleAutoImport} onOpenSettings={handleOpenSettings} /> - - - - - {autoImportOpen && ( - - )} - - + {fileInputs} + {autoImportDialog} + {settingsDialog} ) } return (
- - - + {fileInputs}
- {sectionOrder.map((sectionId) => ( - {renderSection(sectionId)} - ))} +
- {/* Drill-Down Modal */} {drillDownDate !== null && ( - {/* Command Palette */} - - {autoImportOpen && ( - - )} - - - + {autoImportDialog} + {settingsDialog} ) } diff --git a/src/components/LoadErrorState.tsx b/src/components/LoadErrorState.tsx new file mode 100644 index 0000000..e144127 --- /dev/null +++ b/src/components/LoadErrorState.tsx @@ -0,0 +1,71 @@ +import { AlertTriangle, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { FadeIn } from '@/components/features/animations/FadeIn' + +interface LoadErrorAction { + label: string + onClick: () => void + variant?: 'default' | 'outline' | 'ghost' +} + +interface LoadErrorStateProps { + title: string + description: string + details: string[] + detailLabel: string + actions: LoadErrorAction[] +} + +export function LoadErrorState({ + title, + description, + details, + detailLabel, + actions, +}: LoadErrorStateProps) { + return ( +
+ + +
+
+ +
+
+

{title}

+

{description}

+
+
+ + {details.length > 0 ? ( +
+
+ {detailLabel} +
+
    + {details.map((detail, index) => ( +
  • {detail}
  • + ))} +
+
+ ) : null} + +
+ {actions.map((action, index) => ( + + ))} +
+
+
+
+ ) +} diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index 7fd542c..441a8e4 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -16,6 +16,7 @@ import { SectionHeader } from '@/components/ui/section-header' import { FadeIn } from '@/components/features/animations/FadeIn' import { SECTION_HELP } from '@/lib/help-content' import { formatCurrency, formatMonthYear, localMonth } from '@/lib/formatters' +import { getCurrentLocale } from '@/lib/i18n' import { normalizeModelName } from '@/lib/model-utils' import type { DailyUsage, DashboardMetrics } from '@/types' @@ -26,7 +27,22 @@ interface MonthMetricsProps { export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const { t } = useTranslation() + const locale = getCurrentLocale() const currentMonth = localMonth() + const oneDecimalFormatter = new Intl.NumberFormat(locale, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + const oneDecimalPercentFormatter = new Intl.NumberFormat(locale, { + style: 'percent', + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + const wholePercentFormatter = new Intl.NumberFormat(locale, { + style: 'percent', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }) const monthData = useMemo( () => daily.filter((d) => d.date.startsWith(currentMonth)), @@ -101,7 +117,9 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const ioTotal = agg.inputTokens + agg.outputTokens const tokensSubtitle = agg.inputTokens > 0 && agg.outputTokens > 0 - ? t('metricCards.month.ioRatio', { value: (agg.inputTokens / agg.outputTokens).toFixed(1) }) + ? t('metricCards.month.ioRatio', { + value: oneDecimalFormatter.format(agg.inputTokens / agg.outputTokens), + }) : null const modelsSubtitle = agg.topModel ? t('metricCards.month.topModel', { value: agg.topModel.name }) @@ -113,7 +131,7 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const thinkingSubtitle = agg.totalTokens > 0 ? t('metricCards.month.thinkingSubtitle', { - value: `${((agg.thinkingTokens / agg.totalTokens) * 100).toFixed(1)}%`, + value: oneDecimalPercentFormatter.format(agg.thinkingTokens / agg.totalTokens), }) : null @@ -150,7 +168,7 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { label={t('metricCards.month.activeDays')} value={`${agg.activeDays} / ${agg.dayOfMonth}`} subtitle={t('metricCards.month.coverage', { - value: `${((agg.activeDays / agg.dayOfMonth) * 100).toFixed(0)}%`, + value: wholePercentFormatter.format(agg.activeDays / agg.dayOfMonth), })} icon={} /> @@ -170,8 +188,8 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { label={t('metricCards.month.cacheHitRate')} value={} subtitle={t('metricCards.month.cacheMix', { - input: `${ioTotal > 0 ? ((agg.inputTokens / ioTotal) * 100).toFixed(0) : 0}%`, - output: `${ioTotal > 0 ? ((agg.outputTokens / ioTotal) * 100).toFixed(0) : 0}%`, + input: wholePercentFormatter.format(ioTotal > 0 ? agg.inputTokens / ioTotal : 0), + output: wholePercentFormatter.format(ioTotal > 0 ? agg.outputTokens / ioTotal : 0), })} icon={} /> diff --git a/src/components/cards/PrimaryMetrics.tsx b/src/components/cards/PrimaryMetrics.tsx index 82cfd04..c022000 100644 --- a/src/components/cards/PrimaryMetrics.tsx +++ b/src/components/cards/PrimaryMetrics.tsx @@ -13,6 +13,7 @@ import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { formatCurrency, formatPercent, formatTokens, periodUnit } from '@/lib/formatters' import { METRIC_HELP } from '@/lib/help-content' +import { getCurrentLocale } from '@/lib/i18n' import type { DashboardMetrics, ViewMode } from '@/types' interface PrimaryMetricsProps { @@ -27,10 +28,14 @@ export function PrimaryMetrics({ viewMode = 'daily', }: PrimaryMetricsProps) { const { t } = useTranslation() + const locale = getCurrentLocale() // Calculate input/output ratio const ioRatio = metrics.totalInput > 0 && metrics.totalOutput > 0 - ? (metrics.totalInput / metrics.totalOutput).toFixed(1) + ? new Intl.NumberFormat(locale, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(metrics.totalInput / metrics.totalOutput) : null const coverageRate = @@ -75,7 +80,11 @@ export function PrimaryMetrics({ })} /> } - subtitle={`Ø ${formatCurrency(metrics.avgDailyCost)}/${periodUnit(viewMode)} · ${formatCurrency(metrics.avgCostPerRequest)}/Req`} + subtitle={t('metricCards.primary.totalCostSubtitle', { + average: formatCurrency(metrics.avgDailyCost), + unit: periodUnit(viewMode), + costPerRequest: formatCurrency(metrics.avgCostPerRequest), + })} icon={} trend={metrics.weekOverWeekChange !== null ? { value: metrics.weekOverWeekChange } : null} info={METRIC_HELP.totalCost} @@ -94,8 +103,13 @@ export function PrimaryMetrics({ } subtitle={ ioRatio - ? `I/O ${ioRatio}:1 · ${formatTokens(metrics.avgTokensPerRequest)} / Request` - : `${formatTokens(metrics.avgTokensPerRequest)} / Request` + ? t('metricCards.primary.totalTokensSubtitleWithRatio', { + ratio: ioRatio, + tokensPerRequest: formatTokens(metrics.avgTokensPerRequest), + }) + : t('metricCards.primary.totalTokensSubtitle', { + tokensPerRequest: formatTokens(metrics.avgTokensPerRequest), + }) } icon={} info={METRIC_HELP.totalTokens} diff --git a/src/components/cards/TodayMetrics.tsx b/src/components/cards/TodayMetrics.tsx index 7a831be..bb76207 100644 --- a/src/components/cards/TodayMetrics.tsx +++ b/src/components/cards/TodayMetrics.tsx @@ -102,7 +102,11 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { value={today.totalTokens} type="tokens" label={t('metricCards.today.tokensToday')} - insight={`${formatTokens(today.requestCount > 0 ? today.totalTokens / today.requestCount : 0)} / Request`} + insight={t('metricCards.today.tokensInsight', { + value: formatTokens( + today.requestCount > 0 ? today.totalTokens / today.requestCount : 0, + ), + })} /> } icon={} @@ -143,7 +147,9 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { value={today.requestCount} type="number" label={t('metricCards.today.requestsToday')} - insight={`${formatCurrency(today.totalCost / today.requestCount)} / Request`} + insight={t('metricCards.today.requestsInsight', { + value: formatCurrency(today.totalCost / today.requestCount), + })} /> ) : ( t('common.notAvailable') diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index 8a114f8..14fcc6b 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -14,8 +14,11 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/compone import { Maximize2 } from 'lucide-react' import { InfoButton } from '@/components/features/help/InfoButton' import { cn } from '@/lib/cn' +import { buildCsvLine } from '@/lib/csv' import { formatCurrency } from '@/lib/formatters' +export { stringifyCsvCell } from '@/lib/csv' + interface ChartCardProps { title: string subtitle?: string @@ -30,28 +33,6 @@ interface ChartCardProps { expandedExtra?: ReactNode } -export function stringifyCsvCell(value: unknown): string { - let stringValue = '' - - if (value == null) return '' - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' || - typeof value === 'bigint' - ) { - stringValue = String(value) - } else { - try { - stringValue = JSON.stringify(value) ?? '' - } catch { - stringValue = '' - } - } - - return `"${stringValue.replace(/"/g, '""')}"` -} - export function buildChartCsv(chartData: Record[]): string { if (chartData.length === 0) return '' @@ -60,8 +41,8 @@ export function buildChartCsv(chartData: Record[]): string { const keys = Object.keys(firstRow) return [ - keys.map((key) => stringifyCsvCell(key)).join(','), - ...chartData.map((row) => keys.map((key) => stringifyCsvCell(row[key])).join(',')), + buildCsvLine(keys), + ...chartData.map((row) => buildCsvLine(keys.map((key) => row[key]))), ].join('\n') } @@ -200,10 +181,11 @@ export function ChartCard({ {renderChildren(false)} {expandable && ( @@ -216,7 +198,7 @@ export function ChartCard({ {title} - Expanded chart view with metric summary and optional CSV export. + {t('chartCard.expandedDescription')}
@@ -228,10 +210,11 @@ export function ChartCard({
{chartData && chartData.length > 0 && ( )} diff --git a/src/components/charts/CorrelationAnalysis.tsx b/src/components/charts/CorrelationAnalysis.tsx index 997d7c5..dc0ee12 100644 --- a/src/components/charts/CorrelationAnalysis.tsx +++ b/src/components/charts/CorrelationAnalysis.tsx @@ -12,7 +12,7 @@ import { ZAxis, } from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { InfoButton } from '@/components/features/help/InfoButton' +import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' import { @@ -267,10 +267,11 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { return ( - - {t('charts.correlation.title')} - - + + + {t('charts.correlation.title')} + +
@@ -284,10 +285,11 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { return ( - - {t('charts.correlation.title')} - - + + + {t('charts.correlation.title')} + + @@ -93,9 +98,9 @@ export function CustomTooltip({
- Gesamt: + {totalLabel}: - {formatter ? formatter(total, 'Gesamt') : total} + {formatter ? formatter(total, totalLabel) : total} 100%
@@ -141,20 +146,20 @@ export function CustomTooltip({ {deltaVsPrevious !== null && (
- vs. vorher: + {t('customTooltip.vsPrevious')}: {deltaVsPrevious >= 0 ? '+' : ''} - {formatter ? formatter(deltaVsPrevious, 'Delta') : deltaVsPrevious} + {formatter ? formatter(deltaVsPrevious, deltaLabel) : deltaVsPrevious}
)} {deltaVsAverage !== null && (
- vs. Ø: + {t('customTooltip.vsAverage')}: {deltaVsAverage >= 0 ? '+' : ''} - {formatter ? formatter(deltaVsAverage, 'Delta') : deltaVsAverage} + {formatter ? formatter(deltaVsAverage, deltaLabel) : deltaVsAverage}
)} diff --git a/src/components/charts/DistributionAnalysis.tsx b/src/components/charts/DistributionAnalysis.tsx index 7f4f5ee..d3ef43b 100644 --- a/src/components/charts/DistributionAnalysis.tsx +++ b/src/components/charts/DistributionAnalysis.tsx @@ -12,7 +12,7 @@ import { Cell, } from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { InfoButton } from '@/components/features/help/InfoButton' +import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' import { formatCurrency, formatNumber, formatTokens, periodLabel } from '@/lib/formatters' @@ -123,10 +123,11 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA return ( - - {t('charts.distribution.title')} - - + + + {t('charts.distribution.title')} + +
@@ -140,10 +141,11 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA return ( - - {t('charts.distribution.title')} - - + + + {t('charts.distribution.title')} + + {distributions.map((distribution, index) => ( diff --git a/src/components/dashboard/DashboardSections.tsx b/src/components/dashboard/DashboardSections.tsx new file mode 100644 index 0000000..0ca5729 --- /dev/null +++ b/src/components/dashboard/DashboardSections.tsx @@ -0,0 +1,439 @@ +import { Fragment } from 'react' +import { useTranslation } from 'react-i18next' +import { PrimaryMetrics } from '../cards/PrimaryMetrics' +import { SecondaryMetrics } from '../cards/SecondaryMetrics' +import { TodayMetrics } from '../cards/TodayMetrics' +import { MonthMetrics } from '../cards/MonthMetrics' +import { CostOverTime } from '../charts/CostOverTime' +import { CostByModel } from '../charts/CostByModel' +import { CostByModelOverTime } from '../charts/CostByModelOverTime' +import { CumulativeCost } from '../charts/CumulativeCost' +import { TokensOverTime } from '../charts/TokensOverTime' +import { RequestsOverTime } from '../charts/RequestsOverTime' +import { RequestCacheHitRateByModel } from '../charts/RequestCacheHitRateByModel' +import { TokenTypes } from '../charts/TokenTypes' +import { CostByWeekday } from '../charts/CostByWeekday' +import { TokenEfficiency } from '../charts/TokenEfficiency' +import { ModelMix } from '../charts/ModelMix' +import { DistributionAnalysis } from '../charts/DistributionAnalysis' +import { CorrelationAnalysis } from '../charts/CorrelationAnalysis' +import { ModelEfficiency } from '../tables/ModelEfficiency' +import { ProviderEfficiency } from '../tables/ProviderEfficiency' +import { RecentDays } from '../tables/RecentDays' +import { HeatmapCalendar } from '../features/heatmap/HeatmapCalendar' +import { CostForecast } from '../features/forecast/CostForecast' +import { CacheROI } from '../features/cache-roi/CacheROI' +import { PeriodComparison } from '../features/comparison/PeriodComparison' +import { AnomalyDetection } from '../features/anomaly/AnomalyDetection' +import { UsageInsights } from '../features/insights/UsageInsights' +import { ConcentrationRisk } from '../features/risk/ConcentrationRisk' +import { RequestQuality } from '../features/request-quality/RequestQuality' +import { FadeIn } from '../features/animations/FadeIn' +import { ProviderLimitsSection } from '../features/limits/ProviderLimitsSection' +import { SectionHeader } from '../ui/section-header' +import { ExpandableCard } from '../ui/expandable-card' +import { SECTION_HELP } from '@/lib/help-content' +import { formatCurrency, formatPercent, formatTokens, periodUnit } from '@/lib/formatters' +import type { + AggregateMetrics, + ChartDataPoint, + DailyUsage, + DashboardMetrics, + DashboardSectionId, + ProviderLimits, + RequestChartDataPoint, + TokenChartDataPoint, + ViewMode, + WeekdayData, +} from '@/types' + +interface DashboardSectionsProps { + sectionOrder: DashboardSectionId[] + sectionVisibility: Record + metrics: DashboardMetrics + viewMode: ViewMode + totalCalendarDays: number + filteredData: DailyUsage[] + filteredDailyData: DailyUsage[] + todayData: DailyUsage | null + hasCurrentMonthData: boolean + visibleLimitProviders: string[] + providerLimits: ProviderLimits + selectedMonth: string | null + allModels: string[] + costChartData: ChartDataPoint[] + modelPieData: Array<{ name: string; value: number }> + modelCostChartData: Array> + weekdayData: WeekdayData[] + tokenChartData: TokenChartDataPoint[] + tokenPieData: Array<{ name: string; value: number }> + requestChartData: RequestChartDataPoint[] + comparisonData: DailyUsage[] + modelCosts: Map< + string, + { + cost: number + tokens: number + input: number + output: number + cacheRead: number + cacheCreate: number + thinking: number + requests: number + days: number + } + > + providerMetrics: Map + onDrillDownDateChange: (date: string | null) => void +} + +export function DashboardSections({ + sectionOrder, + sectionVisibility, + metrics, + viewMode, + totalCalendarDays, + filteredData, + filteredDailyData, + todayData, + hasCurrentMonthData, + visibleLimitProviders, + providerLimits, + selectedMonth, + allModels, + costChartData, + modelPieData, + modelCostChartData, + weekdayData, + tokenChartData, + tokenPieData, + requestChartData, + comparisonData, + modelCosts, + providerMetrics, + onDrillDownDateChange, +}: DashboardSectionsProps) { + const { t } = useTranslation() + + const renderSection = (sectionId: DashboardSectionId) => { + switch (sectionId) { + case 'insights': + return sectionVisibility.insights ? ( +
+ +
+ ) : null + case 'metrics': + return sectionVisibility.metrics ? ( +
+ + + + + +
+ entry.totalCost)} + viewMode={viewMode} + /> +
+
+
+ ) : null + case 'today': + return sectionVisibility.today && todayData ? ( +
+ +
+ ) : null + case 'currentMonth': + return sectionVisibility.currentMonth && hasCurrentMonthData ? ( +
+ +
+ ) : null + case 'activity': + return sectionVisibility.activity ? ( +
+ + +
+ + + +
+
+
+ ) : null + case 'forecastCache': + return sectionVisibility.forecastCache ? ( +
+ + +
+ + + + + + +
+
+
+ ) : null + case 'limits': + return sectionVisibility.limits ? ( +
+ + + +
+ ) : null + case 'costAnalysis': + return sectionVisibility.costAnalysis ? ( +
+ + +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+
+ +
+ + +
+
+
+ ) : null + case 'tokenAnalysis': + return sectionVisibility.tokenAnalysis ? ( +
+ + +
+ + +
+
+
+ ) : null + case 'requestAnalysis': + return sectionVisibility.requestAnalysis && metrics.hasRequestData ? ( +
+ + + + + +
+ +
+
+ +
+ +
+
+
+ ) : null + case 'advancedAnalysis': + return sectionVisibility.advancedAnalysis ? ( +
+ + +
+ + +
+
+ +
+ +
+
+
+ ) : null + case 'comparisons': + return sectionVisibility.comparisons ? ( +
+ + +
+ + + + + + +
+
+
+ ) : null + case 'tables': + return sectionVisibility.tables ? ( +
+ + + + + +
+ +
+
+ +
+ +
+
+
+ ) : null + default: + return null + } + } + + return ( + <> + {sectionOrder.map((sectionId) => ( + {renderSection(sectionId)} + ))} + + ) +} diff --git a/src/components/features/anomaly/AnomalyDetection.tsx b/src/components/features/anomaly/AnomalyDetection.tsx index 4d4a8dc..9b55d3b 100644 --- a/src/components/features/anomaly/AnomalyDetection.tsx +++ b/src/components/features/anomaly/AnomalyDetection.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' -import { InfoButton } from '@/components/features/help/InfoButton' +import { InfoHeading } from '@/components/features/help/InfoHeading' import { formatCurrency, formatDate } from '@/lib/formatters' import { computeAnomalies } from '@/lib/calculations' import { CHART_HELP } from '@/lib/help-content' @@ -29,10 +29,11 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma return ( - - {t('anomaly.title', { period: periodLabel(viewMode, true) })} - - + + + {t('anomaly.title', { period: periodLabel(viewMode, true) })} + +
@@ -47,11 +48,12 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma return ( - - - {t('anomaly.title', { period: periodLabel(viewMode, true) })} ({anomalies.length}) - - + + + + {t('anomaly.title', { period: periodLabel(viewMode, true) })} ({anomalies.length}) + +

diff --git a/src/components/features/cache-roi/CacheROI.tsx b/src/components/features/cache-roi/CacheROI.tsx index 2fee727..f57bfea 100644 --- a/src/components/features/cache-roi/CacheROI.tsx +++ b/src/components/features/cache-roi/CacheROI.tsx @@ -6,7 +6,7 @@ import { normalizeModelName } from '@/lib/model-utils' import { MODEL_PRICES } from '@/lib/constants' import { Zap } from 'lucide-react' import { FormattedValue } from '@/components/ui/formatted-value' -import { InfoButton } from '@/components/features/help/InfoButton' +import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_HELP } from '@/lib/help-content' import { periodUnit } from '@/lib/formatters' import type { DailyUsage, ViewMode } from '@/types' @@ -79,11 +79,12 @@ export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) { return ( - - - {t('cacheRoi.title')} - - + + + + {t('cacheRoi.title')} + + {heuristicModels.length > 0 && ( diff --git a/src/components/features/comparison/PeriodComparison.tsx b/src/components/features/comparison/PeriodComparison.tsx index 8c412e4..399d2c7 100644 --- a/src/components/features/comparison/PeriodComparison.tsx +++ b/src/components/features/comparison/PeriodComparison.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { InfoButton } from '@/components/features/help/InfoButton' +import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_HELP } from '@/lib/help-content' import { formatCurrency, formatTokens, formatPercent } from '@/lib/formatters' import { computeMetrics } from '@/lib/calculations' @@ -105,10 +105,11 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { return ( - - {t('comparison.title')} - - + + + {t('comparison.title')} + +

@@ -168,10 +169,11 @@ export function PeriodComparison({ data }: PeriodComparisonProps) {
- - {t('comparison.title')} - - + + + {t('comparison.title')} + +
+ ))} +
+
+
diff --git a/src/components/layout/FilterBar.tsx b/src/components/layout/FilterBar.tsx index c1396b4..6262a7a 100644 --- a/src/components/layout/FilterBar.tsx +++ b/src/components/layout/FilterBar.tsx @@ -204,28 +204,23 @@ function DatePickerField({ label, value, onChange }: DatePickerFieldProps) { ref={triggerRef} type="button" onClick={() => setOpen((prev) => !prev)} - className="flex h-10 w-full items-center justify-between gap-3 rounded-md border border-border bg-background px-3 text-sm text-left transition-colors hover:bg-accent/40" + className="flex h-10 w-full items-center justify-between gap-3 rounded-md border border-border bg-background px-3 pr-14 text-left text-sm transition-colors hover:bg-accent/40" > {value ? formatDate(value, 'long') : label} - - {value && ( - { - event.stopPropagation() - onChange(undefined) - }} - className="inline-flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" - > - - - )} - - + {value && ( + + )} + {open && typeof document !== 'undefined' && @@ -397,7 +392,7 @@ export function FilterBar({