From 0406764a654591b4915f9c51d3b8c7f947b11b5b Mon Sep 17 00:00:00 2001 From: Jarvis-bee Date: Fri, 24 Apr 2026 04:11:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=A1=B5=E6=98=BE=E7=A4=BA=E9=85=8D=E7=BD=AE=E4=B8=8E=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E5=9B=BE=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/__test__/run-cli.test.ts | 47 +- src/cli/cli-output.ts | 21 + src/cli/cli-parsing.ts | 46 +- src/cli/run-cli.ts | 4 +- src/main/codex-account-store.ts | 4 + src/main/codex-auth-shared.ts | 12 +- src/renderer/src/App.svelte | 23 +- .../src/components/AccountsPanel.svelte | 10 + .../src/components/CostStatsView.svelte | 605 ++++++++++++++++-- src/renderer/src/components/HeroPanel.svelte | 63 +- .../components/__test__/AccountsPanel.test.ts | 235 ++++++- .../src/components/__test__/HeroPanel.test.ts | 107 ++++ .../__test__/cost-stats-data.test.ts | 131 ++++ src/renderer/src/components/app-view.ts | 52 +- .../src/components/cost-stats-data.ts | 115 ++++ src/renderer/src/test/setup.ts | 9 +- src/shared/__test__/codex.test.ts | 37 ++ src/shared/codex.ts | 46 ++ vitest.config.ts | 3 +- 19 files changed, 1499 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/components/__test__/HeroPanel.test.ts create mode 100644 src/renderer/src/components/__test__/cost-stats-data.test.ts create mode 100644 src/renderer/src/components/cost-stats-data.ts diff --git a/src/cli/__test__/run-cli.test.ts b/src/cli/__test__/run-cli.test.ts index 7621117..5345f2d 100644 --- a/src/cli/__test__/run-cli.test.ts +++ b/src/cli/__test__/run-cli.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runCli } from '../run-cli' +import { defaultStatsDisplaySettings } from '../../shared/codex' import type { AccountRateLimits, AppSettings, @@ -218,7 +219,8 @@ function createRuntime(): { language: 'zh-CN', theme: 'light', checkForUpdatesOnStartup: true, - codexDesktopExecutablePath: '' + codexDesktopExecutablePath: '', + statsDisplay: defaultStatsDisplaySettings() } const currentSession: CurrentSessionSummary = { email: 'one@example.com', @@ -887,7 +889,8 @@ describe('runCli', () => { language: 'zh-CN', theme: 'light', checkForUpdatesOnStartup: true, - codexDesktopExecutablePath: '' + codexDesktopExecutablePath: '', + statsDisplay: defaultStatsDisplaySettings() }, error: null }) @@ -907,7 +910,8 @@ describe('runCli', () => { language: 'zh-CN', theme: 'light', checkForUpdatesOnStartup: false, - codexDesktopExecutablePath: '' + codexDesktopExecutablePath: '', + statsDisplay: defaultStatsDisplaySettings() }, error: null }) @@ -933,10 +937,32 @@ describe('runCli', () => { language: 'zh-CN', theme: 'light', checkForUpdatesOnStartup: true, - codexDesktopExecutablePath: 'C:\\\\Program Files\\\\Codex\\\\Codex.exe' + codexDesktopExecutablePath: 'C:\\\\Program Files\\\\Codex\\\\Codex.exe', + statsDisplay: defaultStatsDisplaySettings() }, error: null }) + + logSpy.mockClear() + await expect(runCli(runtime as never, ['settings', 'get', 'statsDisplay', '--json'])).resolves.toBe(0) + expect(parseJsonLog(logSpy)).toEqual({ + ok: true, + data: defaultStatsDisplaySettings(), + error: null + }) + + logSpy.mockClear() + await expect( + runCli(runtime as never, ['settings', 'set', 'statsDisplay', 'dailyTrend,accountUsage', '--json']) + ).resolves.toBe(0) + expect(runtime.services.settings.update).toHaveBeenCalledWith({ + statsDisplay: { + dailyTrend: true, + modelBreakdown: false, + instanceUsage: false, + accountUsage: true + } + }) }) it('covers doctor command', async () => { @@ -985,6 +1011,19 @@ describe('runCli', () => { } }) + logSpy.mockClear() + await expect( + runCli(runtime as never, ['settings', 'set', 'statsDisplay', 'wat', '--json']) + ).resolves.toBe(2) + expect(parseJsonLog(logSpy)).toEqual({ + ok: false, + data: null, + error: { + code: 2, + message: 'statsDisplay contains unknown chart key: wat' + } + }) + logSpy.mockClear() await expect( runCli(runtime as never, ['cost', 'read', '--instance', 'default', '--json']) diff --git a/src/cli/cli-output.ts b/src/cli/cli-output.ts index 14fd52f..9515f9c 100644 --- a/src/cli/cli-output.ts +++ b/src/cli/cli-output.ts @@ -13,6 +13,7 @@ import type { ProviderCheckReport, TokenCostDetail } from '../shared/codex' +import { serializeStatsDisplaySettings } from '../shared/codex' export function printHelp(): void { console.log(`ilc @@ -313,4 +314,24 @@ export function printSettings(settings: AppSettings, quiet: boolean): void { console.log(`theme=${settings.theme}`) console.log(`checkForUpdatesOnStartup=${settings.checkForUpdatesOnStartup}`) console.log(`codexDesktopExecutablePath=${settings.codexDesktopExecutablePath}`) + console.log(`showLocalMockData=${settings.showLocalMockData !== false}`) + console.log(`statsDisplay=${serializeStatsDisplaySettings(settings.statsDisplay)}`) +} + +export function formatSettingsValue(key: keyof AppSettings, settings: AppSettings): string { + const value = settings[key] + + if (key === 'statusBarAccountIds' && Array.isArray(value)) { + return value.join(',') + } + + if (key === 'showLocalMockData') { + return String(value !== false) + } + + if (key === 'statsDisplay') { + return serializeStatsDisplaySettings(settings.statsDisplay) + } + + return String(value ?? '') } diff --git a/src/cli/cli-parsing.ts b/src/cli/cli-parsing.ts index a8cdbc5..0f3f5cd 100644 --- a/src/cli/cli-parsing.ts +++ b/src/cli/cli-parsing.ts @@ -3,9 +3,11 @@ import type { CliSettingsKey, CreateCodexInstanceInput, CreateCustomProviderInput, + StatsDisplaySettings, UpdateCodexInstanceInput, UpdateCustomProviderInput } from '../shared/codex' +import { normalizeStatsDisplaySettings, statsDisplayKeys } from '../shared/codex' import { CliError, EXIT_USAGE } from './cli-errors' export interface CliFlags { @@ -27,9 +29,49 @@ const SETTING_KEYS: CliSettingsKey[] = [ 'theme', 'checkForUpdatesOnStartup', 'showLocalMockData', - 'codexDesktopExecutablePath' + 'codexDesktopExecutablePath', + 'statsDisplay' ] +function parseStatsDisplay(rawValue: string): StatsDisplaySettings { + const normalizedValue = rawValue.trim().toLowerCase() + if (normalizedValue === 'all') { + return normalizeStatsDisplaySettings() + } + + if (normalizedValue === 'none') { + return normalizeStatsDisplaySettings( + Object.fromEntries(statsDisplayKeys.map((key) => [key, false])) as Partial + ) + } + + const keys = rawValue + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + + if (!keys.length) { + throw new CliError( + `statsDisplay must be all, none, or a comma-separated subset of ${statsDisplayKeys.join(', ')}`, + EXIT_USAGE + ) + } + + const invalidKey = keys.find((key) => !statsDisplayKeys.includes(key as (typeof statsDisplayKeys)[number])) + if (invalidKey) { + throw new CliError( + `statsDisplay contains unknown chart key: ${invalidKey}`, + EXIT_USAGE + ) + } + + return normalizeStatsDisplaySettings( + Object.fromEntries( + statsDisplayKeys.map((key) => [key, keys.includes(key)]) + ) as Partial + ) +} + export function parseFlags(argv: string[]): { flags: CliFlags; positionals: string[] } { const flags: CliFlags = { json: false, @@ -147,6 +189,8 @@ export function parseSettingsValue( return rawValue === 'true' case 'codexDesktopExecutablePath': return rawValue.trim() + case 'statsDisplay': + return parseStatsDisplay(rawValue) } } diff --git a/src/cli/run-cli.ts b/src/cli/run-cli.ts index 5a64889..ece7e8c 100644 --- a/src/cli/run-cli.ts +++ b/src/cli/run-cli.ts @@ -19,6 +19,7 @@ import { } from './cli-parsing' import { accountLabel, + formatSettingsValue, instanceLabel, printAccountList, printDoctorReport, @@ -600,8 +601,7 @@ async function execute( const settingKey = ensureSettingsKey(key) if (!silent) { - const value = settings[settingKey] - console.log(Array.isArray(value) ? value.join(',') : value) + console.log(formatSettingsValue(settingKey, settings)) } return { code: EXIT_OK, payload: toCliResult(settings[settingKey]) } } diff --git a/src/main/codex-account-store.ts b/src/main/codex-account-store.ts index 7b8302d..678de02 100644 --- a/src/main/codex-account-store.ts +++ b/src/main/codex-account-store.ts @@ -13,6 +13,7 @@ import type { UpdateAccountWakeScheduleInput } from '../shared/codex' import type { CodexPlatformAdapter, ProtectedPayload } from '../shared/codex-platform' +import { normalizeStatsDisplaySettings } from '../shared/codex' import { type CodexAuthPayload, type LegacyPersistedState, @@ -86,6 +87,9 @@ export class CodexAccountStore { state.settings = { ...state.settings, ...nextSettings, + statsDisplay: normalizeStatsDisplaySettings( + nextSettings.statsDisplay ?? state.settings.statsDisplay + ), statusBarAccountIds: ( nextSettings.statusBarAccountIds ?? state.settings.statusBarAccountIds ).slice(0, 5) diff --git a/src/main/codex-auth-shared.ts b/src/main/codex-auth-shared.ts index 76fab65..4f75872 100644 --- a/src/main/codex-auth-shared.ts +++ b/src/main/codex-auth-shared.ts @@ -13,7 +13,11 @@ import type { PortOccupant } from '../shared/codex' import type { CodexPlatformAdapter, ProtectedPayload } from '../shared/codex-platform' -import { decodeJwtPayload, resolveChatGptAccountIdFromTokens } from '../shared/openai-auth' +import { + decodeJwtPayload, + resolveChatGptAccountIdFromTokens +} from '../shared/openai-auth' +import { defaultStatsDisplaySettings, normalizeStatsDisplaySettings } from '../shared/codex' export interface CodexAuthPayload { auth_mode?: string @@ -61,7 +65,8 @@ function defaultSettings(): AppSettings { theme: 'light', checkForUpdatesOnStartup: true, codexDesktopExecutablePath: '', - showLocalMockData: true + showLocalMockData: true, + statsDisplay: defaultStatsDisplaySettings() } } @@ -138,7 +143,8 @@ function normalizePersistedState(parsed: PersistedState | LegacyPersistedState): tags: parsed.tags ?? [], settings: { ...defaultSettings(), - ...('settings' in parsed ? parsed.settings : {}) + ...('settings' in parsed ? parsed.settings : {}), + statsDisplay: normalizeStatsDisplaySettings(parsed.settings?.statsDisplay) }, usageByAccountId: parsed.usageByAccountId ?? {}, usageErrorByAccountId: parsed.usageErrorByAccountId ?? {}, diff --git a/src/renderer/src/App.svelte b/src/renderer/src/App.svelte index dc584cf..7b7b380 100644 --- a/src/renderer/src/App.svelte +++ b/src/renderer/src/App.svelte @@ -33,6 +33,7 @@ LoginEvent, LoginMethod, PortOccupant, + StatsDisplaySettings, UpdateAccountWakeScheduleInput, WakeAccountRequestResult, WakeAccountRateLimitsInput, @@ -41,7 +42,9 @@ import { filterLocalMockAppSnapshot, accountTransferFormats, + defaultStatsDisplaySettings, formatRelativeReset, + normalizeStatsDisplaySettings, resolveBestAccount, shouldAutoPollUsage, supportsWeeklyQuota @@ -68,7 +71,8 @@ theme: 'light', checkForUpdatesOnStartup: true, codexDesktopExecutablePath: '', - showLocalMockData: true + showLocalMockData: true, + statsDisplay: defaultStatsDisplaySettings() }, usageByAccountId: {}, usageErrorByAccountId: {}, @@ -1013,6 +1017,19 @@ ) } + const updateStatsDisplay = async (statsDisplay: StatsDisplaySettings): Promise => { + const current = normalizeStatsDisplaySettings(snapshot.settings.statsDisplay) + const next = normalizeStatsDisplaySettings(statsDisplay) + + if (JSON.stringify(current) === JSON.stringify(next)) { + return + } + + await runAction('settings:stats-display', () => + window.codexApp.updateSettings({ statsDisplay: next }) + ) + } + const openMainPanel = async (): Promise => { applySnapshot(await window.codexApp.openMainWindow()) } @@ -1205,6 +1222,7 @@ language={snapshot.settings.language} showLocalMockData={snapshot.settings.showLocalMockData !== false} accounts={snapshot.accounts} + codexInstances={snapshot.codexInstances} providers={snapshot.providers} tags={snapshot.tags} activeAccountId={snapshot.activeAccountId} @@ -1215,6 +1233,7 @@ tokenCostErrorByInstanceId={snapshot.tokenCostErrorByInstanceId} runningTokenCostSummary={snapshot.runningTokenCostSummary} runningTokenCostInstanceIds={snapshot.runningTokenCostInstanceIds} + statsDisplay={normalizeStatsDisplaySettings(snapshot.settings.statsDisplay)} wakeSchedulesByAccountId={snapshot.wakeSchedulesByAccountId} loginActionBusy={loginActionBusy()} {loginStarting} @@ -1248,6 +1267,7 @@ {updateAccountTags} refreshAccountUsage={(account) => readRateLimits(account, { force: true })} {updateShowLocalMockData} + {updateStatsDisplay} {openWakeDialog} {removeAccount} {removeAccounts} @@ -1456,6 +1476,7 @@ {updatePollingInterval} {updateCheckForUpdatesOnStartup} {updateShowLocalMockData} + {updateStatsDisplay} {updateCodexDesktopExecutablePath} showCodexDesktopExecutablePath={shouldShowCodexDesktopExecutablePath()} showLocalMockToggle={appMeta.isPackaged === false} diff --git a/src/renderer/src/components/AccountsPanel.svelte b/src/renderer/src/components/AccountsPanel.svelte index 309ff64..11f4a9a 100644 --- a/src/renderer/src/components/AccountsPanel.svelte +++ b/src/renderer/src/components/AccountsPanel.svelte @@ -7,8 +7,10 @@ AccountTag, AccountWakeSchedule, AppLanguage, + CodexInstanceSummary, CustomProviderDetail, CustomProviderSummary, + StatsDisplaySettings, TokenCostDetail, TokenCostReadOptions, TokenCostSummary, @@ -45,6 +47,7 @@ export let language: AppLanguage export let showLocalMockData = true export let accounts: AccountSummary[] = [] + export let codexInstances: CodexInstanceSummary[] = [] export let providers: CustomProviderSummary[] = [] export let tags: AccountTag[] = [] export let activeAccountId: string | undefined @@ -55,6 +58,7 @@ export let tokenCostErrorByInstanceId: Record export let runningTokenCostSummary: TokenCostSummary | null export let runningTokenCostInstanceIds: string[] + export let statsDisplay: StatsDisplaySettings export let wakeSchedulesByAccountId: Record export let loginActionBusy: boolean export let loginStarting = false @@ -77,6 +81,7 @@ export let updateAccountTags: (account: AccountSummary, tagIds: string[]) => Promise export let refreshAccountUsage: (account: AccountSummary) => void export let updateShowLocalMockData: (enabled: boolean) => void + export let updateStatsDisplay: (statsDisplay: StatsDisplaySettings) => Promise export let removeAccount: (account: AccountSummary) => void export let removeAccounts: (accountIds: string[]) => Promise export let exportSelectedAccounts: (accountIds: string[]) => Promise @@ -379,12 +384,17 @@ {:else if currentView === 'tags'} = {} export let tokenCostErrorByInstanceId: Record = {} export let runningTokenCostSummary: TokenCostSummary | null = null export let runningTokenCostInstanceIds: string[] = [] export let compactGhostButton: string + export let usageByAccountId: Record = {} + export let statsDisplay: StatsDisplaySettings + export let updateStatsDisplay: (statsDisplay: StatsDisplaySettings) => Promise export let readTokenCost: (input?: TokenCostReadOptions) => Promise let lastLoadedKey = '' @@ -63,14 +74,25 @@ let snapshotError = '' let warningMessages: string[] = [] let modelBreakdowns: TokenCostModelBreakdown[] = [] + let instanceUsageRows: InstanceUsageRow[] = [] + let accountUsageRows: AccountUsageRow[] = [] let chartDaily: TokenCostDetail['daily'] = [] let trendCanvas: HTMLCanvasElement | null = null let modelCanvas: HTMLCanvasElement | null = null + let instanceCanvas: HTMLCanvasElement | null = null + let accountCanvas: HTMLCanvasElement | null = null let trendChart: Chart<'line', (number | null)[], string> | null = null let modelChart: Chart<'bar', number[], string> | null = null + let instanceChart: Chart<'bar', number[], string> | null = null + let accountChart: Chart<'bar', number[], string> | null = null let trendChartSyncKey = '' let modelChartSyncKey = '' + let instanceChartSyncKey = '' + let accountChartSyncKey = '' let modelChartHeight = 280 + let instanceChartHeight = 280 + let accountChartHeight = 280 + let statsDisplayDraft = normalizeStatsDisplaySettings(statsDisplay) const summaryHasData = (summary: TokenCostSummary | null): boolean => Boolean(summary && (summary.sessionTokens > 0 || summary.last30DaysTokens > 0)) @@ -109,17 +131,82 @@ }).format(date) } + const formatPercent = (value: number | null): string => { + if (value === null || Number.isNaN(value)) { + return '--' + } + + return `${Math.round(value)}%` + } + + const formatCredits = (usage: AccountRateLimits): string => { + if (!usage.credits?.hasCredits) { + return '--' + } + + if (usage.credits.unlimited) { + return copy.unlimited + } + + if (usage.credits.balance === null) { + return '--' + } + + return new Intl.NumberFormat(language === 'en' ? 'en-US' : 'zh-CN', { + maximumFractionDigits: 2 + }).format(usage.credits.balance) + } + + const formatInstanceName = (instanceId: string, instance?: CodexInstanceSummary): string => { + if (instance?.isDefault || instanceId === '__default__') { + return 'default' + } + + const name = instance?.name?.trim() + if (name) { + return name + } + + const segments = instance?.codexHome?.split('/').filter(Boolean) ?? [] + return segments.at(-1) || instanceId + } + const formatDayLabel = (value: string): string => { const [, month = '0', day = '0'] = value.split('-') return `${Number(month)}/${Number(day)}` } + const setStatsDisplay = ( + key: keyof StatsDisplaySettings, + enabled: boolean + ): void => { + const next = normalizeStatsDisplaySettings({ + ...statsDisplayDraft, + [key]: enabled + }) + statsDisplayDraft = next + void updateStatsDisplay(next) + } + interface CostRollup { total: number hasKnown: boolean hasUnknown: boolean } + interface InstanceUsageRow { + label: string + tokens: number + costUSD: number | null + } + + interface AccountUsageRow { + label: string + sessionUsedPercent: number | null + weeklyUsedPercent: number | null + credits: string + } + const createCostRollup = (): CostRollup => ({ total: 0, hasKnown: false, @@ -561,6 +648,253 @@ modelChart = new Chart(context, config) } + const syncInstanceChart = (): void => { + if (!instanceCanvas) { + return + } + + if (!instanceUsageRows.length) { + instanceChart?.destroy() + instanceChart = null + return + } + + const context = instanceCanvas.getContext('2d') + if (!context) { + return + } + + const accent = readCssVar('--ink', '#18181b') + const ink = readCssVar('--ink', '#18181b') + const muted = readCssVar('--muted-strong', '#6b7280') + const line = readCssVar('--line', 'rgba(24, 24, 27, 0.1)') + const surface = readCssVar('--panel-strong', '#ffffff') + + const config: ChartConfiguration<'bar', number[], string> = { + type: 'bar', + data: { + labels: instanceUsageRows.map((entry) => entry.label), + datasets: [ + { + label: copy.tokens, + data: instanceUsageRows.map((entry) => entry.tokens), + backgroundColor: withAlpha(accent, 0.72), + borderColor: accent, + borderWidth: 1, + borderRadius: 6, + borderSkipped: false, + barThickness: 16, + maxBarThickness: 18 + } + ] + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 240 + }, + plugins: { + legend: { + display: false + }, + tooltip: { + backgroundColor: surface, + borderColor: line, + borderWidth: 1, + titleColor: ink, + bodyColor: ink, + padding: 12, + displayColors: false, + callbacks: { + title: (items) => items[0]?.label ?? '', + label: (context) => { + const item = instanceUsageRows[context.dataIndex] + return `${copy.tokens}: ${formatTokens(item?.tokens ?? 0)}` + }, + afterLabel: (context) => { + const item = instanceUsageRows[context.dataIndex] + return copy.costReference(formatCost(item?.costUSD ?? null)) + } + } + } + }, + scales: { + x: { + beginAtZero: true, + grid: { + color: withAlpha(line, 0.55) + }, + ticks: { + color: muted, + maxTicksLimit: 5, + callback: (value) => formatTokens(Number(value)) + } + }, + y: { + grid: { + display: false + }, + ticks: { + color: ink, + font: { + size: 11, + weight: 600 + } + } + } + } + } + } + + if (instanceChart) { + instanceChart.data = config.data + instanceChart.options = config.options ?? {} + instanceChart.update() + return + } + + instanceChart = new Chart(context, config) + } + + const syncAccountChart = (): void => { + if (!accountCanvas) { + return + } + + if (!accountUsageRows.length) { + accountChart?.destroy() + accountChart = null + return + } + + const context = accountCanvas.getContext('2d') + if (!context) { + return + } + + const accentPrimary = readCssVar('--ink', '#18181b') + const accentSecondary = readCssVar('--success', '#0f766e') + const ink = readCssVar('--ink', '#18181b') + const muted = readCssVar('--muted-strong', '#6b7280') + const line = readCssVar('--line', 'rgba(24, 24, 27, 0.1)') + const surface = readCssVar('--panel-strong', '#ffffff') + + const config: ChartConfiguration<'bar', number[], string> = { + type: 'bar', + data: { + labels: accountUsageRows.map((entry) => entry.label), + datasets: [ + { + label: copy.sessionUsed, + data: accountUsageRows.map((entry) => entry.sessionUsedPercent ?? 0), + backgroundColor: withAlpha(accentPrimary, 0.72), + borderColor: accentPrimary, + borderWidth: 1, + borderRadius: 6, + borderSkipped: false, + barThickness: 14, + maxBarThickness: 16 + }, + { + label: copy.weeklyUsed, + data: accountUsageRows.map((entry) => entry.weeklyUsedPercent ?? 0), + backgroundColor: withAlpha(accentSecondary, 0.64), + borderColor: accentSecondary, + borderWidth: 1, + borderRadius: 6, + borderSkipped: false, + barThickness: 14, + maxBarThickness: 16 + } + ] + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 240 + }, + plugins: { + legend: { + position: 'top', + align: 'end', + labels: { + usePointStyle: true, + pointStyle: 'circle', + boxWidth: 8, + boxHeight: 8, + color: ink, + padding: 14, + font: { + size: 11, + weight: 600 + } + } + }, + tooltip: { + backgroundColor: surface, + borderColor: line, + borderWidth: 1, + titleColor: ink, + bodyColor: ink, + padding: 12, + callbacks: { + title: (items) => items[0]?.label ?? '', + afterBody: (items) => { + const row = accountUsageRows[items[0]?.dataIndex ?? 0] + return row ? ["", `${copy.credits}: ${row.credits}`] : [] + }, + label: (context) => { + const row = accountUsageRows[context.dataIndex] + return `${context.dataset.label}: ${formatPercent( + context.datasetIndex === 0 ? row?.sessionUsedPercent ?? null : row?.weeklyUsedPercent ?? null + )}` + } + } + } + }, + scales: { + x: { + beginAtZero: true, + max: 100, + grid: { + color: withAlpha(line, 0.55) + }, + ticks: { + color: muted, + maxTicksLimit: 5, + callback: (value) => `${Number(value)}%` + } + }, + y: { + grid: { + display: false + }, + ticks: { + color: ink, + font: { + size: 11, + weight: 600 + } + } + } + } + } + } + + if (accountChart) { + accountChart.data = config.data + accountChart.options = config.options ?? {} + accountChart.update() + return + } + + accountChart = new Chart(context, config) + } + async function loadDetail(refresh = false): Promise { const requestVersion = ++detailRequestVersion const requestTopologyKey = buildSnapshotTopologyKey() @@ -598,6 +932,8 @@ : new MutationObserver(() => { syncTrendChart() syncModelChart() + syncInstanceChart() + syncAccountChart() }) observer?.observe(document.documentElement, { @@ -607,6 +943,8 @@ syncTrendChart() syncModelChart() + syncInstanceChart() + syncAccountChart() return () => { observer?.disconnect() @@ -614,6 +952,10 @@ trendChart = null modelChart?.destroy() modelChart = null + instanceChart?.destroy() + instanceChart = null + accountChart?.destroy() + accountChart = null } }) @@ -650,8 +992,32 @@ ) ] $: modelBreakdowns = aggregateModelBreakdowns(detail) + $: statsDisplayDraft = normalizeStatsDisplaySettings(statsDisplay) + $: instanceUsageRows = buildInstanceConsumptionEntries({ + tokenCostByInstanceId, + instances: codexInstances, + runningInstanceIds: runningTokenCostInstanceIds, + resolveLabel: (instanceId, instance) => formatInstanceName(instanceId, instance) + }).map((entry) => ({ + label: entry.label, + tokens: entry.last30DaysTokens, + costUSD: entry.last30DaysCostUSD + })) + $: accountUsageRows = buildAccountUsageEntries({ + accounts, + usageByAccountId, + resolveLabel: (account) => accountLabel(account, copy) + }).map((entry) => ({ + label: entry.label, + sessionUsedPercent: entry.sessionUsedPercent, + weeklyUsedPercent: entry.weeklyUsedPercent, + credits: + usageByAccountId[entry.accountId] != null ? formatCredits(usageByAccountId[entry.accountId]) : '--' + })) $: chartDaily = detail ? [...detail.daily] : [] $: modelChartHeight = Math.max(280, modelBreakdowns.length * 38) + $: instanceChartHeight = Math.max(280, instanceUsageRows.length * 38) + $: accountChartHeight = Math.max(280, accountUsageRows.length * 44) $: trendChartSyncKey = [ language, copy.tokens, @@ -669,6 +1035,24 @@ (entry) => `${entry.modelName}:${entry.totalTokens}:${entry.costUSD ?? ''}` ) ].join('|') + $: instanceChartSyncKey = [ + language, + copy.instanceUsage, + copy.tokens, + copy.cost, + ...instanceUsageRows.map((entry) => `${entry.label}:${entry.tokens}:${entry.costUSD ?? ''}`) + ].join('|') + $: accountChartSyncKey = [ + language, + copy.accountUsage, + copy.sessionUsed, + copy.weeklyUsed, + copy.credits, + ...accountUsageRows.map( + (entry) => + `${entry.label}:${entry.sessionUsedPercent ?? ''}:${entry.weeklyUsedPercent ?? ''}:${entry.credits}` + ) + ].join('|') $: if (!trendCanvas && trendChart) { trendChart.destroy() trendChart = null @@ -677,12 +1061,26 @@ modelChart.destroy() modelChart = null } + $: if (!instanceCanvas && instanceChart) { + instanceChart.destroy() + instanceChart = null + } + $: if (!accountCanvas && accountChart) { + accountChart.destroy() + accountChart = null + } $: if (trendCanvas && trendChartSyncKey) { syncTrendChart() } $: if (modelCanvas && modelChartSyncKey) { syncModelChart() } + $: if (instanceCanvas && instanceChartSyncKey) { + syncInstanceChart() + } + $: if (accountCanvas && accountChartSyncKey) { + syncAccountChart() + }
{copy.tokenStatsTitle} +

+ {copy.tokenStatsDescription} +