mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(hub,web): add token usage dashboard
Track provider usage events with cache-aware token accounting and expose a settings dashboard.\n\nCo-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
+52
-2
@@ -9,6 +9,7 @@ import { FcmStore } from './fcmStore'
|
||||
import { ScratchlistStore } from './scratchlistStore'
|
||||
import { SessionStore } from './sessionStore'
|
||||
import { UserStore } from './userStore'
|
||||
import { UsageStore } from './usageStore'
|
||||
|
||||
export type {
|
||||
StoredMachine,
|
||||
@@ -28,8 +29,9 @@ export { FcmStore } from './fcmStore'
|
||||
export { ScratchlistStore } from './scratchlistStore'
|
||||
export { SessionStore } from './sessionStore'
|
||||
export { UserStore } from './userStore'
|
||||
export { UsageStore } from './usageStore'
|
||||
|
||||
const SCHEMA_VERSION: number = 16
|
||||
const SCHEMA_VERSION: number = 17
|
||||
const REQUIRED_TABLES = [
|
||||
'sessions',
|
||||
'machines',
|
||||
@@ -38,7 +40,8 @@ const REQUIRED_TABLES = [
|
||||
'users',
|
||||
'push_subscriptions',
|
||||
'fcm_devices',
|
||||
'session_scratchlist'
|
||||
'session_scratchlist',
|
||||
'usage_events'
|
||||
] as const
|
||||
|
||||
export class Store {
|
||||
@@ -53,6 +56,7 @@ export class Store {
|
||||
readonly push: PushStore
|
||||
readonly fcm: FcmStore
|
||||
readonly scratchlist: ScratchlistStore
|
||||
readonly usage: UsageStore
|
||||
|
||||
/**
|
||||
* Filesystem path of the underlying SQLite database, or ':memory:' for
|
||||
@@ -105,6 +109,7 @@ export class Store {
|
||||
this.push = new PushStore(this.db)
|
||||
this.fcm = new FcmStore(this.db)
|
||||
this.scratchlist = new ScratchlistStore(this.db)
|
||||
this.usage = new UsageStore(this.db)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +178,7 @@ export class Store {
|
||||
13: () => this.migrateFromV13ToV14(),
|
||||
14: () => this.migrateFromV14ToV15(),
|
||||
15: () => this.migrateFromV15ToV16(),
|
||||
16: () => this.migrateFromV16ToV17(),
|
||||
})
|
||||
|
||||
if (currentVersion === 0) {
|
||||
@@ -333,6 +339,26 @@ export class Store {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created
|
||||
ON session_scratchlist(session_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_events (
|
||||
session_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
source_seq INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
model TEXT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('delta', 'cumulative')),
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_id, source_key),
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_session_created
|
||||
ON usage_events(session_id, created_at, source_seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_created
|
||||
ON usage_events(created_at);
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -605,6 +631,30 @@ export class Store {
|
||||
*/
|
||||
private migrateFromV15ToV16(): void {}
|
||||
|
||||
private migrateFromV16ToV17(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS usage_events (
|
||||
session_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
source_seq INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
model TEXT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('delta', 'cumulative')),
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_id, source_key),
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_session_created
|
||||
ON usage_events(session_id, created_at, source_seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_created
|
||||
ON usage_events(created_at);
|
||||
`)
|
||||
}
|
||||
|
||||
private getSessionColumnNames(): Set<string> {
|
||||
const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
|
||||
return new Set(rows.map((row) => row.name))
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => {
|
||||
const store = new Store(':memory:')
|
||||
expect(tableExists(store, 'message_epochs')).toBe(true)
|
||||
expect(tableExists(store, 'session_scratchlist')).toBe(true)
|
||||
expect(getUserVersion(store)).toBe(16)
|
||||
expect(getUserVersion(store)).toBe(17)
|
||||
store.close()
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => {
|
||||
store = new Store(dbPath)
|
||||
expect(tableExists(store, 'message_epochs')).toBe(true)
|
||||
expect(tableExists(store, 'session_scratchlist')).toBe(true)
|
||||
expect(getUserVersion(store)).toBe(16)
|
||||
expect(getUserVersion(store)).toBe(17)
|
||||
expect(store.messages.getMessageEpoch('session-1')).toBe(0)
|
||||
expect(store.messages.getMessages('session-1')).toHaveLength(1)
|
||||
} finally {
|
||||
@@ -72,7 +72,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => {
|
||||
store = new Store(dbPath)
|
||||
expect(tableExists(store, 'message_epochs')).toBe(true)
|
||||
expect(tableExists(store, 'session_scratchlist')).toBe(true)
|
||||
expect(getUserVersion(store)).toBe(16)
|
||||
expect(getUserVersion(store)).toBe(17)
|
||||
expect(store.messages.getMessages('session-1')).toHaveLength(1)
|
||||
} finally {
|
||||
store?.close()
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => {
|
||||
const store = new Store(':memory:')
|
||||
const cols = getColumns(store, 'session_scratchlist')
|
||||
expect(cols).toContain('attachments')
|
||||
expect(getUserVersion(store)).toBe(16)
|
||||
expect(getUserVersion(store)).toBe(17)
|
||||
store.close()
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => {
|
||||
store = new Store(dbPath)
|
||||
const cols = getColumns(store, 'session_scratchlist')
|
||||
expect(cols).toContain('attachments')
|
||||
expect(getUserVersion(store)).toBe(16)
|
||||
expect(getUserVersion(store)).toBe(17)
|
||||
} finally {
|
||||
store?.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
@@ -56,7 +56,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => {
|
||||
store2 = new Store(dbPath)
|
||||
const cols2 = getColumns(store2, 'session_scratchlist')
|
||||
expect(cols2).toEqual(cols1)
|
||||
expect(getUserVersion(store2)).toBe(16)
|
||||
expect(getUserVersion(store2)).toBe(17)
|
||||
} finally {
|
||||
store2?.close()
|
||||
store1?.close()
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Database } from 'bun:sqlite'
|
||||
|
||||
export type UsageEventKind = 'delta' | 'cumulative'
|
||||
|
||||
export type UsageEvent = {
|
||||
sessionId: string
|
||||
sourceKey: string
|
||||
sourceSeq: number
|
||||
createdAt: number
|
||||
agent: string
|
||||
model: string | null
|
||||
kind: UsageEventKind
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheCreationTokens: number
|
||||
}
|
||||
|
||||
type UsageEventRow = {
|
||||
session_id: string
|
||||
source_key: string
|
||||
source_seq: number
|
||||
created_at: number
|
||||
agent: string
|
||||
model: string | null
|
||||
kind: UsageEventKind
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_creation_tokens: number
|
||||
}
|
||||
|
||||
function toUsageEvent(row: UsageEventRow): UsageEvent {
|
||||
return {
|
||||
sessionId: row.session_id,
|
||||
sourceKey: row.source_key,
|
||||
sourceSeq: row.source_seq,
|
||||
createdAt: row.created_at,
|
||||
agent: row.agent,
|
||||
model: row.model,
|
||||
kind: row.kind,
|
||||
inputTokens: row.input_tokens,
|
||||
outputTokens: row.output_tokens,
|
||||
cacheReadTokens: row.cache_read_tokens,
|
||||
cacheCreationTokens: row.cache_creation_tokens
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertUsageEvents(db: Database, events: UsageEvent[]): void {
|
||||
if (events.length === 0) return
|
||||
|
||||
db.transaction(() => {
|
||||
const statement = db.prepare(`
|
||||
INSERT INTO usage_events (
|
||||
session_id,
|
||||
source_key,
|
||||
source_seq,
|
||||
created_at,
|
||||
agent,
|
||||
model,
|
||||
kind,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_read_tokens,
|
||||
cache_creation_tokens
|
||||
) VALUES (
|
||||
@session_id,
|
||||
@source_key,
|
||||
@source_seq,
|
||||
@created_at,
|
||||
@agent,
|
||||
@model,
|
||||
@kind,
|
||||
@input_tokens,
|
||||
@output_tokens,
|
||||
@cache_read_tokens,
|
||||
@cache_creation_tokens
|
||||
)
|
||||
ON CONFLICT(session_id, source_key)
|
||||
DO UPDATE SET
|
||||
source_seq = excluded.source_seq,
|
||||
created_at = excluded.created_at,
|
||||
agent = excluded.agent,
|
||||
model = excluded.model,
|
||||
kind = excluded.kind,
|
||||
input_tokens = excluded.input_tokens,
|
||||
output_tokens = excluded.output_tokens,
|
||||
cache_read_tokens = excluded.cache_read_tokens,
|
||||
cache_creation_tokens = excluded.cache_creation_tokens
|
||||
`)
|
||||
|
||||
for (const event of events) {
|
||||
statement.run({
|
||||
session_id: event.sessionId,
|
||||
source_key: event.sourceKey,
|
||||
source_seq: event.sourceSeq,
|
||||
created_at: event.createdAt,
|
||||
agent: event.agent,
|
||||
model: event.model,
|
||||
kind: event.kind,
|
||||
input_tokens: event.inputTokens,
|
||||
output_tokens: event.outputTokens,
|
||||
cache_read_tokens: event.cacheReadTokens,
|
||||
cache_creation_tokens: event.cacheCreationTokens
|
||||
})
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
export function getUsageEvents(db: Database, sessionIds: string[]): UsageEvent[] {
|
||||
if (sessionIds.length === 0) return []
|
||||
|
||||
const placeholders = sessionIds.map(() => '?').join(', ')
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
session_id,
|
||||
source_key,
|
||||
source_seq,
|
||||
created_at,
|
||||
agent,
|
||||
model,
|
||||
kind,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_read_tokens,
|
||||
cache_creation_tokens
|
||||
FROM usage_events
|
||||
WHERE session_id IN (${placeholders})
|
||||
ORDER BY created_at ASC, source_seq ASC
|
||||
`).all(...sessionIds) as UsageEventRow[]
|
||||
|
||||
return rows.map(toUsageEvent)
|
||||
}
|
||||
|
||||
export function getMaxUsageSourceSeqBySession(db: Database, sessionIds: string[]): Map<string, number> {
|
||||
if (sessionIds.length === 0) return new Map()
|
||||
|
||||
const placeholders = sessionIds.map(() => '?').join(', ')
|
||||
const rows = db.prepare(`
|
||||
SELECT session_id, MAX(source_seq) AS max_source_seq
|
||||
FROM usage_events
|
||||
WHERE session_id IN (${placeholders})
|
||||
GROUP BY session_id
|
||||
`).all(...sessionIds) as Array<{ session_id: string; max_source_seq: number | null }>
|
||||
|
||||
return new Map(rows.map((row) => [row.session_id, row.max_source_seq ?? 0]))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Database } from 'bun:sqlite'
|
||||
|
||||
import {
|
||||
getMaxUsageSourceSeqBySession,
|
||||
getUsageEvents,
|
||||
upsertUsageEvents,
|
||||
type UsageEvent
|
||||
} from './usage'
|
||||
|
||||
export class UsageStore {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
upsertEvents(events: UsageEvent[]): void {
|
||||
upsertUsageEvents(this.db, events)
|
||||
}
|
||||
|
||||
getEvents(sessionIds: string[]): UsageEvent[] {
|
||||
return getUsageEvents(this.db, sessionIds)
|
||||
}
|
||||
|
||||
getMaxSourceSeqBySession(sessionIds: string[]): Map<string, number> {
|
||||
return getMaxUsageSourceSeqBySession(this.db, sessionIds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { Store } from '../store'
|
||||
import { getUsageSummary } from './usageService'
|
||||
|
||||
function addAgentMessage(store: Store, sessionId: string, content: unknown): void {
|
||||
store.messages.addMessage(sessionId, { role: 'agent', content })
|
||||
}
|
||||
|
||||
describe('usage service', () => {
|
||||
it('deduplicates Claude stream fragments and diffs Codex cumulative snapshots', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession(
|
||||
'usage-test',
|
||||
{ path: '/tmp', host: 'test', flavor: 'codex' },
|
||||
null,
|
||||
'default',
|
||||
'test-model'
|
||||
)
|
||||
|
||||
addAgentMessage(store, session.id, {
|
||||
type: 'output',
|
||||
data: {
|
||||
type: 'assistant',
|
||||
message: { id: 'claude-message', model: 'claude-test', usage: { input_tokens: 10, output_tokens: 2 } }
|
||||
}
|
||||
})
|
||||
addAgentMessage(store, session.id, {
|
||||
type: 'output',
|
||||
data: {
|
||||
type: 'assistant',
|
||||
message: { id: 'claude-message', model: 'claude-test', usage: { input_tokens: 12, output_tokens: 3 } }
|
||||
}
|
||||
})
|
||||
addAgentMessage(store, session.id, {
|
||||
type: 'codex',
|
||||
data: { type: 'token_count', thread_id: 'thread-1', scope_role: 'parent', info: { total: { inputTokens: 100, outputTokens: 10, cachedInputTokens: 80 } } }
|
||||
})
|
||||
addAgentMessage(store, session.id, {
|
||||
type: 'codex',
|
||||
data: { type: 'token_count', thread_id: 'thread-1', scope_role: 'parent', info: { total: { inputTokens: 140, outputTokens: 15, cachedInputTokens: 100 } } }
|
||||
})
|
||||
|
||||
const result = getUsageSummary(store, 'default', 'all')
|
||||
expect(result.totals.requests).toBe(3)
|
||||
expect(result.totals.inputTokens).toBe(152)
|
||||
expect(result.totals.outputTokens).toBe(18)
|
||||
expect(result.totals.cacheReadTokens).toBe(100)
|
||||
expect(result.totals.totalTokens).toBe(170)
|
||||
expect(result.byAgent.find((row) => row.key === 'claude')?.requests).toBe(1)
|
||||
expect(result.byModel.find((row) => row.key === 'claude-test')?.totalTokens).toBe(15)
|
||||
store.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { UsageSummaryBucket, UsageSummaryResponse } from '@hapi/protocol/apiTypes'
|
||||
import type { StoredMessage, StoredSession } from '../store'
|
||||
import type { UsageEvent } from '../store/usage'
|
||||
import type { Store } from '../store'
|
||||
|
||||
type RecordValue = Record<string, unknown>
|
||||
|
||||
function asRecord(value: unknown): RecordValue | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as RecordValue
|
||||
: null
|
||||
}
|
||||
|
||||
function asCount(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
? Math.floor(value)
|
||||
: null
|
||||
}
|
||||
|
||||
function firstCount(record: RecordValue, ...keys: string[]): number {
|
||||
for (const key of keys) {
|
||||
const value = asCount(record[key])
|
||||
if (value !== null) return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function sessionAgent(session: StoredSession): string {
|
||||
const metadata = asRecord(session.metadata)
|
||||
const flavor = metadata?.flavor
|
||||
return typeof flavor === 'string' && flavor.trim() ? flavor.trim() : 'unknown'
|
||||
}
|
||||
|
||||
function sessionModel(session: StoredSession): string | null {
|
||||
return typeof session.model === 'string' && session.model.trim() ? session.model.trim() : null
|
||||
}
|
||||
|
||||
function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageEvent | null {
|
||||
const envelope = asRecord(message.content)
|
||||
if (envelope?.role !== 'agent') return null
|
||||
|
||||
const payload = asRecord(envelope.content)
|
||||
if (!payload) return null
|
||||
const data = asRecord(payload.data)
|
||||
if (!data) return null
|
||||
|
||||
// Claude stream-json/SDK messages. A stream emits several updates for one
|
||||
// assistant message, so the provider's message id is the stable upsert key.
|
||||
if (payload.type === 'output' && data.type === 'assistant') {
|
||||
const assistant = asRecord(data.message)
|
||||
const usage = asRecord(assistant?.usage)
|
||||
if (!usage) return null
|
||||
const inputTokens = firstCount(usage, 'input_tokens', 'inputTokens')
|
||||
const outputTokens = firstCount(usage, 'output_tokens', 'outputTokens')
|
||||
const cacheReadTokens = firstCount(usage, 'cache_read_input_tokens', 'cacheReadTokens', 'cachedInputTokens')
|
||||
const cacheCreationTokens = firstCount(usage, 'cache_creation_input_tokens', 'cacheCreationTokens', 'cacheWriteInputTokens')
|
||||
if (inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens <= 0) return null
|
||||
const providerId = typeof assistant?.id === 'string' ? assistant.id : message.id
|
||||
const model = typeof assistant?.model === 'string' && assistant.model.trim()
|
||||
? assistant.model.trim()
|
||||
: sessionModel(session)
|
||||
return {
|
||||
sessionId: session.id,
|
||||
sourceKey: `claude|${providerId}`,
|
||||
sourceSeq: message.seq,
|
||||
createdAt: message.createdAt,
|
||||
agent: 'claude',
|
||||
model,
|
||||
kind: 'delta',
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens
|
||||
}
|
||||
}
|
||||
|
||||
// Codex and ACP-compatible backends forward token_count snapshots. The
|
||||
// `total` object is cumulative for a thread; aggregation below diffs it.
|
||||
if (data.type === 'token_count' || data.type === 'usage') {
|
||||
const info = asRecord(data.info) ?? data
|
||||
const total = asRecord(info.total) ?? info
|
||||
const inputTokens = firstCount(total, 'inputTokens', 'input_tokens')
|
||||
const outputTokens = firstCount(total, 'outputTokens', 'output_tokens')
|
||||
const cacheReadTokens = firstCount(total, 'cachedInputTokens', 'cached_input_tokens', 'cacheReadTokens', 'cache_read_input_tokens')
|
||||
const cacheCreationTokens = firstCount(total, 'cacheWriteInputTokens', 'cache_write_input_tokens', 'cacheCreationTokens', 'cache_creation_input_tokens')
|
||||
if (inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens <= 0) return null
|
||||
const threadId = typeof data.threadId === 'string'
|
||||
? data.threadId
|
||||
: typeof data.thread_id === 'string'
|
||||
? data.thread_id
|
||||
: session.id
|
||||
const scope = typeof data.scopeRole === 'string'
|
||||
? data.scopeRole
|
||||
: typeof data.scope_role === 'string'
|
||||
? data.scope_role
|
||||
: 'parent'
|
||||
const agent = sessionAgent(session)
|
||||
return {
|
||||
sessionId: session.id,
|
||||
sourceKey: `cumulative|${threadId}|${scope}|${message.id}`,
|
||||
sourceSeq: message.seq,
|
||||
createdAt: message.createdAt,
|
||||
agent,
|
||||
model: sessionModel(session),
|
||||
kind: 'cumulative',
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function collectUsageEvents(store: Store, sessions: StoredSession[]): void {
|
||||
const events: UsageEvent[] = []
|
||||
const maxSourceSeq = store.usage.getMaxSourceSeqBySession(sessions.map((session) => session.id))
|
||||
for (const session of sessions) {
|
||||
const afterSeq = maxSourceSeq.get(session.id) ?? 0
|
||||
const messages = store.messages.getAllMessages(session.id)
|
||||
for (const message of messages) {
|
||||
if (afterSeq > 0 && message.seq <= afterSeq) continue
|
||||
const event = parseUsageEvent(session, message)
|
||||
if (event) events.push(event)
|
||||
}
|
||||
}
|
||||
store.usage.upsertEvents(events)
|
||||
}
|
||||
|
||||
type Totals = Omit<UsageSummaryBucket, 'key'>
|
||||
|
||||
function emptyTotals(): Totals {
|
||||
return {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
totalTokens: 0,
|
||||
requests: 0
|
||||
}
|
||||
}
|
||||
|
||||
function addTotals(target: Totals, inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number): void {
|
||||
target.inputTokens += inputTokens
|
||||
target.outputTokens += outputTokens
|
||||
target.cacheReadTokens += cacheReadTokens
|
||||
target.cacheCreationTokens += cacheCreationTokens
|
||||
// Codex/Kimi inputTokens already includes cached input. Claude's raw
|
||||
// input_tokens excludes cache fields and is normalized before this call.
|
||||
target.totalTokens += inputTokens + outputTokens
|
||||
target.requests += 1
|
||||
}
|
||||
|
||||
function toBucket(key: string, totals: Totals): UsageSummaryBucket {
|
||||
return { key, ...totals }
|
||||
}
|
||||
|
||||
function dayKey(timestamp: number): string {
|
||||
return new Date(timestamp).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function getUsageSummary(store: Store, namespace: string, range: string | undefined): UsageSummaryResponse {
|
||||
const sessions = store.sessions.getSessionsByNamespace(namespace)
|
||||
// This is intentionally lazy. Existing HAPI databases have no usage table;
|
||||
// the first dashboard request backfills history, while later requests only
|
||||
// update the idempotent event rows.
|
||||
collectUsageEvents(store, sessions)
|
||||
|
||||
const now = Date.now()
|
||||
const days = range === '30d' ? 30 : range === 'all' ? null : 7
|
||||
const from = days === null ? null : now - days * 24 * 60 * 60 * 1000
|
||||
const sessionIds = new Set(sessions.map((session) => session.id))
|
||||
const events = store.usage.getEvents(Array.from(sessionIds))
|
||||
const isInRange = (event: UsageEvent) => (from === null || event.createdAt >= from) && event.createdAt <= now
|
||||
|
||||
const totals = emptyTotals()
|
||||
const daily = new Map<string, Totals>()
|
||||
const byAgent = new Map<string, Totals>()
|
||||
const byModel = new Map<string, Totals>()
|
||||
const sessionsWithUsage = new Set<string>()
|
||||
const cumulativePrevious = new Map<string, [number, number, number, number]>()
|
||||
|
||||
for (const event of events) {
|
||||
let inputTokens = event.inputTokens
|
||||
let outputTokens = event.outputTokens
|
||||
let cacheReadTokens = event.cacheReadTokens
|
||||
let cacheCreationTokens = event.cacheCreationTokens
|
||||
if (event.kind === 'cumulative') {
|
||||
const streamKey = event.sourceKey.split('|').slice(0, 3).join('|')
|
||||
const previous = cumulativePrevious.get(streamKey)
|
||||
if (previous) {
|
||||
inputTokens = inputTokens >= previous[0] ? inputTokens - previous[0] : inputTokens
|
||||
outputTokens = outputTokens >= previous[1] ? outputTokens - previous[1] : outputTokens
|
||||
cacheReadTokens = cacheReadTokens >= previous[2] ? cacheReadTokens - previous[2] : cacheReadTokens
|
||||
cacheCreationTokens = cacheCreationTokens >= previous[3] ? cacheCreationTokens - previous[3] : cacheCreationTokens
|
||||
}
|
||||
cumulativePrevious.set(streamKey, [event.inputTokens, event.outputTokens, event.cacheReadTokens, event.cacheCreationTokens])
|
||||
}
|
||||
if (!isInRange(event) || inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens <= 0) continue
|
||||
const normalizedInputTokens = event.agent === 'claude'
|
||||
? inputTokens + cacheReadTokens + cacheCreationTokens
|
||||
: inputTokens
|
||||
addTotals(totals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens)
|
||||
const dailyTotals = daily.get(dayKey(event.createdAt)) ?? emptyTotals()
|
||||
addTotals(dailyTotals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens)
|
||||
daily.set(dayKey(event.createdAt), dailyTotals)
|
||||
const agentTotals = byAgent.get(event.agent) ?? emptyTotals()
|
||||
addTotals(agentTotals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens)
|
||||
byAgent.set(event.agent, agentTotals)
|
||||
const modelKey = event.model ?? 'unknown'
|
||||
const modelTotals = byModel.get(modelKey) ?? emptyTotals()
|
||||
addTotals(modelTotals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens)
|
||||
byModel.set(modelKey, modelTotals)
|
||||
sessionsWithUsage.add(event.sessionId)
|
||||
}
|
||||
|
||||
const sortBuckets = (values: Map<string, Totals>): UsageSummaryBucket[] => Array.from(values.entries())
|
||||
.map(([key, value]) => toBucket(key, value))
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||
|
||||
return {
|
||||
range: { from, to: now },
|
||||
totals: { ...totals, sessions: sessionsWithUsage.size },
|
||||
daily: Array.from(daily.entries())
|
||||
.map(([key, value]) => toBucket(key, value))
|
||||
.sort((a, b) => a.key.localeCompare(b.key)),
|
||||
byAgent: sortBuckets(byAgent),
|
||||
byModel: sortBuckets(byModel),
|
||||
updatedAt: now
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Hono } from 'hono'
|
||||
import type { UsageSummaryResponse } from '@hapi/protocol/apiTypes'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
import type { Store } from '../../store'
|
||||
import { getUsageSummary } from '../../sync/usageService'
|
||||
|
||||
export function createUsageRoutes(store: Store): Hono<WebAppEnv> {
|
||||
const app = new Hono<WebAppEnv>()
|
||||
|
||||
app.get('/usage/summary', (c) => {
|
||||
if (c.get('namespace') !== 'default') {
|
||||
return c.json({ error: 'Usage summary is only available to the hub owner' }, 403)
|
||||
}
|
||||
const range = c.req.query('range')
|
||||
const response: UsageSummaryResponse = getUsageSummary(store, c.get('namespace'), range)
|
||||
c.header('Cache-Control', 'no-store')
|
||||
return c.json(response)
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { createMessagesRoutes } from './routes/messages'
|
||||
import { createPermissionsRoutes } from './routes/permissions'
|
||||
import { createMachinesRoutes } from './routes/machines'
|
||||
import { createStorageRoutes } from './routes/storage'
|
||||
import { createUsageRoutes } from './routes/usage'
|
||||
import { createGitRoutes } from './routes/git'
|
||||
import { createCliRoutes } from './routes/cli'
|
||||
import { createCodexDesktopRoutes } from './routes/codexDesktop'
|
||||
@@ -252,6 +253,7 @@ function createWebApp(options: {
|
||||
app.route('/api', createPermissionsRoutes(options.getSyncEngine))
|
||||
app.route('/api', createMachinesRoutes(options.getSyncEngine))
|
||||
app.route('/api', createStorageRoutes(configuration.dbPath))
|
||||
app.route('/api', createUsageRoutes(options.store))
|
||||
app.route('/api', createGitRoutes(options.getSyncEngine))
|
||||
// 中文注释:这里提供两类 Codex 辅助能力:扫描本地 transcript 以导入到 Hapi,以及按需重启 Codex Desktop 客户端。
|
||||
app.route('/api', createCodexDesktopRoutes({
|
||||
|
||||
@@ -691,3 +691,33 @@ export type SqliteStorageUsageResponse = {
|
||||
shmBytes: number
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
export type UsageSummaryBucket = {
|
||||
key: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheCreationTokens: number
|
||||
totalTokens: number
|
||||
requests: number
|
||||
}
|
||||
|
||||
export type UsageSummaryResponse = {
|
||||
range: {
|
||||
from: number | null
|
||||
to: number | null
|
||||
}
|
||||
totals: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheCreationTokens: number
|
||||
totalTokens: number
|
||||
requests: number
|
||||
sessions: number
|
||||
}
|
||||
daily: Array<UsageSummaryBucket & { key: string }>
|
||||
byAgent: UsageSummaryBucket[]
|
||||
byModel: UsageSummaryBucket[]
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
QueuedStateResponse,
|
||||
ReopenSessionResponse,
|
||||
SqliteStorageUsageResponse,
|
||||
UsageSummaryResponse,
|
||||
UploadFileResponse
|
||||
} from '@hapi/protocol/apiTypes'
|
||||
import type { AgentFlavor } from '@hapi/protocol'
|
||||
@@ -630,6 +631,10 @@ export class ApiClient {
|
||||
return await this.request<SqliteStorageUsageResponse>('/api/storage/sqlite')
|
||||
}
|
||||
|
||||
async getUsageSummary(range: '7d' | '30d' | 'all' = '7d'): Promise<UsageSummaryResponse> {
|
||||
return await this.request<UsageSummaryResponse>(`/api/usage/summary?range=${encodeURIComponent(range)}`)
|
||||
}
|
||||
|
||||
async listMachineDirectory(
|
||||
machineId: string,
|
||||
path: string
|
||||
|
||||
@@ -610,6 +610,33 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
)
|
||||
const agentFlavor = props.session.metadata?.flavor ?? null
|
||||
const controlledByUser = props.session.agentState?.controlledByUser === true
|
||||
const [claudeCustomModels, setClaudeCustomModels] = useState<string[]>([])
|
||||
useEffect(() => {
|
||||
if (agentFlavor !== 'claude') {
|
||||
setClaudeCustomModels([])
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
try {
|
||||
props.api.getClaudeCustomModels().then((result) => {
|
||||
if (!cancelled) {
|
||||
setClaudeCustomModels(Array.isArray(result.models) ? result.models : [])
|
||||
}
|
||||
}).catch(() => {
|
||||
// Custom models are optional; keep the built-in Claude presets.
|
||||
})
|
||||
} catch {
|
||||
// Partial API clients (tests, older hubs) may not expose this method.
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [agentFlavor, props.api])
|
||||
const claudeModelOptions = useMemo(
|
||||
() => claudeCustomModels.map((model) => ({ value: model, label: model })),
|
||||
[claudeCustomModels]
|
||||
)
|
||||
const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser
|
||||
const codexModelsState = useCodexModels({
|
||||
api: props.api,
|
||||
@@ -1440,7 +1467,9 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
effort={props.session.effort}
|
||||
agentFlavor={agentFlavor}
|
||||
availableModelOptions={
|
||||
agentFlavor === 'codex'
|
||||
agentFlavor === 'claude'
|
||||
? claudeModelOptions
|
||||
: agentFlavor === 'codex'
|
||||
? codexModelOptions
|
||||
: agentFlavor === 'cursor'
|
||||
? (
|
||||
|
||||
@@ -34,6 +34,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) {
|
||||
voice: t('settings.hub.voice.summary'),
|
||||
machines: t('settings.hub.machines.summary'),
|
||||
storage: t('settings.storage.summary'),
|
||||
usage: t('settings.usage.summary'),
|
||||
about: `v${__APP_VERSION__}`,
|
||||
}
|
||||
const visibleCategories = settingsCategories.filter((category) => category.id !== 'storage' || getNamespace(token) === 'default')
|
||||
|
||||
@@ -641,6 +641,26 @@ export default {
|
||||
'settings.storage.path': 'Path',
|
||||
'settings.storage.refresh': 'Refresh',
|
||||
'settings.storage.refreshing': 'Refreshing…',
|
||||
'settings.usage.title': 'Token usage',
|
||||
'settings.usage.summary': 'Token-only usage dashboard',
|
||||
'settings.usage.description': 'Token consumption recorded by HAPI sessions. Costs are not included.',
|
||||
'settings.usage.range.label': 'Usage range',
|
||||
'settings.usage.range.7d': '7 days',
|
||||
'settings.usage.range.30d': '30 days',
|
||||
'settings.usage.range.all': 'All time',
|
||||
'settings.usage.loading': 'Loading token usage…',
|
||||
'settings.usage.error': 'Unable to load token usage',
|
||||
'settings.usage.empty': 'No token usage recorded for this range.',
|
||||
'settings.usage.total': 'Total tokens (input + output)',
|
||||
'settings.usage.input': 'Input tokens (includes cache)',
|
||||
'settings.usage.output': 'Output tokens',
|
||||
'settings.usage.cacheRead': 'Cache read (included)',
|
||||
'settings.usage.cacheCreation': 'Cache creation (included)',
|
||||
'settings.usage.requests': 'Requests',
|
||||
'settings.usage.daily.title': 'Daily trend',
|
||||
'settings.usage.agent.title': 'By agent',
|
||||
'settings.usage.model.title': 'By model',
|
||||
'settings.usage.sessions': '{count} sessions with usage',
|
||||
'settings.general.description': 'Language, companion pairing, and general application preferences.',
|
||||
'settings.language.title': 'Language',
|
||||
'settings.language.label': 'Language',
|
||||
|
||||
@@ -645,6 +645,26 @@ export default {
|
||||
'settings.storage.path': '路径',
|
||||
'settings.storage.refresh': '刷新',
|
||||
'settings.storage.refreshing': '正在刷新…',
|
||||
'settings.usage.title': 'Token 用量',
|
||||
'settings.usage.summary': 'Token 用量看板',
|
||||
'settings.usage.description': '统计 HAPI 会话记录的 Token 消耗,不包含费用估算。',
|
||||
'settings.usage.range.label': '用量范围',
|
||||
'settings.usage.range.7d': '近 7 天',
|
||||
'settings.usage.range.30d': '近 30 天',
|
||||
'settings.usage.range.all': '全部时间',
|
||||
'settings.usage.loading': '正在加载 Token 用量…',
|
||||
'settings.usage.error': '无法加载 Token 用量',
|
||||
'settings.usage.empty': '此范围内没有记录到 Token 用量。',
|
||||
'settings.usage.total': '总 Token(输入 + 输出)',
|
||||
'settings.usage.input': '输入 Token(含缓存)',
|
||||
'settings.usage.output': '输出 Token',
|
||||
'settings.usage.cacheRead': '缓存命中(已计入)',
|
||||
'settings.usage.cacheCreation': '缓存创建(已计入)',
|
||||
'settings.usage.requests': '请求数',
|
||||
'settings.usage.daily.title': '每日趋势',
|
||||
'settings.usage.agent.title': '按 Agent',
|
||||
'settings.usage.model.title': '按模型',
|
||||
'settings.usage.sessions': '{count} 个会话有用量记录',
|
||||
'settings.general.description': '语言、伴侣应用配对和通用应用偏好。',
|
||||
'settings.language.title': '语言',
|
||||
'settings.language.label': '语言',
|
||||
|
||||
@@ -4,6 +4,7 @@ export const queryKeys = {
|
||||
messages: (sessionId: string) => ['messages', sessionId] as const,
|
||||
machines: ['machines'] as const,
|
||||
sqliteStorage: ['sqlite-storage'] as const,
|
||||
usageSummary: (range: string) => ['usage-summary', range] as const,
|
||||
machineCodexModels: (machineId: string) => ['machine-codex-models', machineId] as const,
|
||||
gitStatus: (sessionId: string) => ['git-status', sessionId] as const,
|
||||
sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const,
|
||||
|
||||
@@ -60,6 +60,7 @@ import SettingsVoiceAdvancedPage from '@/routes/settings/voice-advanced'
|
||||
import SettingsMachinesPage from '@/routes/settings/machines'
|
||||
import SettingsAboutPage from '@/routes/settings/about'
|
||||
import SettingsStoragePage from '@/routes/settings/storage'
|
||||
import SettingsUsagePage from '@/routes/settings/usage'
|
||||
import SharePage from '@/routes/share'
|
||||
import { setSharePendingTransfer } from '@/lib/sharePendingState'
|
||||
import { deleteShareTransfer } from '@/lib/shareTransfer'
|
||||
@@ -1146,6 +1147,12 @@ const settingsStorageRoute = createRoute({
|
||||
component: SettingsStoragePage,
|
||||
})
|
||||
|
||||
const settingsUsageRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: 'usage',
|
||||
component: SettingsUsagePage,
|
||||
})
|
||||
|
||||
// Web Share Target landing route. Service worker (`web/src/sw.ts`)
|
||||
// intercepts the manifest's `POST /share` and 303-redirects here with an
|
||||
// IDB transfer id. `error=ingest` is set when the SW failed to write IDB.
|
||||
@@ -1187,6 +1194,7 @@ export const routeTree = rootRoute.addChildren([
|
||||
settingsVoiceAdvancedRoute,
|
||||
settingsMachinesRoute,
|
||||
settingsStorageRoute,
|
||||
settingsUsageRoute,
|
||||
settingsAboutRoute,
|
||||
]),
|
||||
shareRoute,
|
||||
|
||||
@@ -5,6 +5,7 @@ export const settingsCategories = [
|
||||
{ id: 'voice', path: '/settings/voice', titleKey: 'settings.voice.title' },
|
||||
{ id: 'machines', path: '/settings/machines', titleKey: 'settings.machines.title' },
|
||||
{ id: 'storage', path: '/settings/storage', titleKey: 'settings.storage.title' },
|
||||
{ id: 'usage', path: '/settings/usage', titleKey: 'settings.usage.title' },
|
||||
{ id: 'about', path: '/settings/about', titleKey: 'settings.about.title' },
|
||||
] as const
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { UsageSummaryBucket } from '@hapi/protocol/apiTypes'
|
||||
import { SettingsPageContent, SettingsRow, SettingsSection } from '@/components/settings/SettingsPrimitives'
|
||||
import { useAppContext } from '@/lib/app-context'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
type UsageRange = '7d' | '30d' | 'all'
|
||||
|
||||
function formatTokens(value: number): string {
|
||||
if (value < 1000) return value.toLocaleString()
|
||||
if (value < 1_000_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}K`
|
||||
if (value < 1_000_000_000) return `${(value / 1_000_000).toFixed(value < 10_000_000 ? 1 : 0)}M`
|
||||
return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||
}
|
||||
|
||||
function UsageBarList(props: { rows: UsageSummaryBucket[]; empty: string }) {
|
||||
const max = props.rows[0]?.totalTokens ?? 0
|
||||
if (props.rows.length === 0) return <div className="px-3 py-4 text-sm text-[var(--app-hint)]">{props.empty}</div>
|
||||
return (
|
||||
<div className="divide-y divide-[var(--app-divider)]">
|
||||
{props.rows.slice(0, 8).map((row) => (
|
||||
<div key={row.key} className="px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate font-medium text-[var(--app-fg)]">{row.key}</span>
|
||||
<span className="shrink-0 text-[var(--app-hint)]">{formatTokens(row.totalTokens)}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--app-subtle-bg)]">
|
||||
<div className="h-full rounded-full bg-[var(--app-link)]" style={{ width: `${max > 0 ? Math.max(2, (row.totalTokens / max) * 100) : 0}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--app-hint)]">
|
||||
{row.requests.toLocaleString()} requests · {formatTokens(row.inputTokens)} in · {formatTokens(row.outputTokens)} out
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsUsagePage() {
|
||||
const { api } = useAppContext()
|
||||
const { t } = useTranslation()
|
||||
const [range, setRange] = useState<UsageRange>('7d')
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.usageSummary(range),
|
||||
queryFn: async () => {
|
||||
if (!api) throw new Error('API unavailable')
|
||||
return await api.getUsageSummary(range)
|
||||
},
|
||||
enabled: Boolean(api),
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false
|
||||
})
|
||||
const maxDaily = useMemo(() => Math.max(...(query.data?.daily.map((row) => row.totalTokens) ?? [0]), 1), [query.data?.daily])
|
||||
|
||||
return (
|
||||
<SettingsPageContent description={t('settings.usage.description')}>
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label={t('settings.usage.range.label')}>
|
||||
{(['7d', '30d', 'all'] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={range === option}
|
||||
onClick={() => setRange(option)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${range === option ? 'border-[var(--app-link)] bg-[var(--app-subtle-bg)] text-[var(--app-link)]' : 'border-[var(--app-border)] text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'}`}
|
||||
>
|
||||
{t(`settings.usage.range.${option}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{query.isLoading ? <SettingsSection><SettingsRow label={t('settings.usage.loading')} /></SettingsSection> : null}
|
||||
{query.error ? <SettingsSection><SettingsRow label={t('settings.usage.error')} description={query.error instanceof Error ? query.error.message : undefined} /></SettingsSection> : null}
|
||||
{query.data ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
['settings.usage.total', query.data.totals.totalTokens],
|
||||
['settings.usage.input', query.data.totals.inputTokens],
|
||||
['settings.usage.output', query.data.totals.outputTokens],
|
||||
['settings.usage.cacheRead', query.data.totals.cacheReadTokens],
|
||||
['settings.usage.cacheCreation', query.data.totals.cacheCreationTokens],
|
||||
['settings.usage.requests', query.data.totals.requests]
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-3 shadow-sm">
|
||||
<div className="text-xs text-[var(--app-hint)]">{t(label as string)}</div>
|
||||
<div className="mt-1 text-xl font-semibold text-[var(--app-fg)]">{typeof value === 'number' ? formatTokens(value) : value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SettingsSection title={t('settings.usage.daily.title')}>
|
||||
{query.data.daily.length === 0 ? <div className="px-3 py-4 text-sm text-[var(--app-hint)]">{t('settings.usage.empty')}</div> : (
|
||||
<div className="space-y-3 px-3 py-4">
|
||||
{query.data.daily.map((row) => (
|
||||
<div key={row.key} className="grid grid-cols-[5.5rem_1fr_auto] items-center gap-2 text-xs">
|
||||
<span className="text-[var(--app-hint)]">{row.key}</span>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[var(--app-subtle-bg)]"><div className="h-full rounded-full bg-[var(--app-link)]" style={{ width: `${Math.max(2, (row.totalTokens / maxDaily) * 100)}%` }} /></div>
|
||||
<span className="text-right font-medium text-[var(--app-fg)]">{formatTokens(row.totalTokens)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<SettingsSection title={t('settings.usage.agent.title')}>
|
||||
<UsageBarList rows={query.data.byAgent} empty={t('settings.usage.empty')} />
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t('settings.usage.model.title')}>
|
||||
<UsageBarList rows={query.data.byModel} empty={t('settings.usage.empty')} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--app-hint)]">
|
||||
{t('settings.usage.sessions', { count: query.data.totals.sessions })}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</SettingsPageContent>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user