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}
+
{modelBreakdowns.length}
+
+
+ {copy.instanceUsage}
+
+
{instanceUsageRows.length}
+
+
+
+ {copy.accountUsage}
+
+
{accountUsageRows.length}
+
{#if detailError}
@@ -840,61 +1253,159 @@
-
+
-
{copy.modelBreakdown}
-
{copy.last30Days}
+
{copy.displayConfig}
+
{copy.displayConfigDescription}
-
+
+ {#each [
+ { key: 'dailyTrend', label: copy.dailyTrend },
+ { key: 'modelBreakdown', label: copy.modelBreakdown },
+ { key: 'instanceUsage', label: copy.instanceUsage },
+ { key: 'accountUsage', label: copy.accountUsage }
+ ] as option (option.key)}
+
+ {/each}
+
+
+
+ {#if !statsDisplayDraft.modelBreakdown && !statsDisplayDraft.dailyTrend && !statsDisplayDraft.instanceUsage && !statsDisplayDraft.accountUsage}
+
+
+
{copy.noChartsVisible}
+
+ {/if}
- {#if modelBreakdowns.length}
+ {#if statsDisplayDraft.modelBreakdown}
+
+
+
+
{copy.modelBreakdown}
+
{copy.last30Days}
+
+
+
+
+ {#if modelBreakdowns.length}
+
+ {:else}
+
+
+
{copy.tokenStatsNoData}
+
+ {/if}
+
+ {/if}
+
+ {#if statsDisplayDraft.instanceUsage}
+
+
+
+
{copy.instanceUsage}
+
{copy.instanceUsageDescription}
+
+
+
+
+ {#if instanceUsageRows.length}
+
+ {:else}
+
+
+
{copy.tokenStatsNoData}
+
+ {/if}
+
+ {/if}
+
+ {#if statsDisplayDraft.accountUsage}
+
+
+
+
{copy.accountUsage}
+
{copy.accountUsageDescription}
+
+
+
+
+ {#if accountUsageRows.length}
+
+ {:else}
+
+
+
{copy.noAccountUsageData}
+
+ {/if}
+
+ {/if}
+
+
+ {#if statsDisplayDraft.dailyTrend}
+
+
+
+
{copy.dailyTrend}
+
+ {formatTokens(selectedSummary?.last30DaysTokens ?? 0)}
+ {copy.tokens}
+ ·
+ {copy.costReference(formatCost(selectedSummary?.last30DaysCostUSD ?? null))}
+
+
+
+
+
+ {#if chartDaily.length}
-
{:else}
-
+
{copy.tokenStatsNoData}
{/if}
-
-
-
-
-
-
{copy.dailyTrend}
-
- {formatTokens(selectedSummary?.last30DaysTokens ?? 0)}
- {copy.tokens}
- ·
- {formatCost(selectedSummary?.last30DaysCostUSD ?? null)}
-
-
-
-
-
- {#if chartDaily.length}
-
- {:else}
-
-
-
{copy.tokenStatsNoData}
-
- {/if}
-
+ {/if}