/**
* 使用情况页面 — 对接 OpenClaw Gateway sessions.usage API
* 展示 Token 用量、费用、Top Models/Providers/Tools/Agents 等分析数据
*/
import { wsClient } from '../lib/ws-client.js'
import { toast } from '../components/toast.js'
import { icon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
let _page = null, _unsubReady = null
export async function render() {
const page = document.createElement('div')
page.className = 'page'
_page = page
page.innerHTML = `
`
page.querySelectorAll('[data-days]').forEach(btn => {
btn.onclick = () => {
_days = parseInt(btn.dataset.days)
page.querySelectorAll('[data-days]').forEach(b => { b.classList.remove('btn-primary'); b.classList.add('btn-secondary') })
btn.classList.remove('btn-secondary'); btn.classList.add('btn-primary')
loadUsage(page)
}
})
page.querySelector('#btn-usage-refresh')?.addEventListener('click', () => loadUsage(page))
loadUsage(page)
return page
}
export function cleanup() {
_page = null
if (_unsubReady) { _unsubReady(); _unsubReady = null }
}
let _days = 7
async function loadUsage(page) {
const el = page.querySelector('#usage-content')
el.innerHTML = `
`
if (!wsClient.connected) {
el.innerHTML = `
${t('usage.gwConnecting')}
${t('usage.gwWait')}
`
// 自动等待连接就绪后重试
if (_unsubReady) _unsubReady()
_unsubReady = wsClient.onReady(() => {
if (_unsubReady) { _unsubReady(); _unsubReady = null }
if (_page) loadUsage(_page)
})
return
}
try {
const now = new Date()
const end = now.toISOString().slice(0, 10)
const start = new Date(now.getTime() - (_days - 1) * 86400000).toISOString().slice(0, 10)
const data = await wsClient.request('sessions.usage', { startDate: start, endDate: end, limit: 20 })
renderUsage(el, data)
} catch (e) {
el.innerHTML = `
${t('usage.loadFailed')}: ${esc(e?.message || e)}
${t('usage.loadFailedHint')}
`
}
}
function renderUsage(el, data) {
if (!data) { el.innerHTML = `${t('usage.noData')}
`; return }
const totals = data.totals || {}
const a = data.aggregates || {}
const msgs = a.messages || {}
const tools = a.tools || {}
const fmtTokens = (n) => {
if (n == null || n === 0) return '0'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
return String(n)
}
const fmtCost = (n) => n != null && n > 0 ? '$' + n.toFixed(4) : '$0'
const fmtRate = (errors, total) => {
if (!total) return '—'
const pct = (errors / total * 100).toFixed(1)
return pct + '%'
}
// ── 概览卡片 ──
const overviewHtml = `
${msgs.total || 0}
${msgs.user || 0} ${t('usage.userMsgs')} · ${msgs.assistant || 0} ${t('usage.assistantMsgs')}
${tools.totalCalls || 0}
${t('usage.toolKinds', { count: tools.uniqueTools || 0 })}
${msgs.errors || 0}
${t('usage.errorRate')} ${fmtRate(msgs.errors, msgs.total)}
${fmtTokens(totals.totalTokens)}
${fmtTokens(totals.input)} ${t('usage.input')} · ${fmtTokens(totals.output)} ${t('usage.output')}
${fmtCost(totals.totalCost)}
${fmtCost(totals.inputCost)} ${t('usage.input')} · ${fmtCost(totals.outputCost)} ${t('usage.output')}
${(data.sessions || []).length}
${data.startDate || ''} ~ ${data.endDate || ''}
`
// ── Top 排行 ──
const renderTop = (title, items, keyFn, valueFn) => {
if (!items || !items.length) return ''
const rows = items.slice(0, 5).map(item => `
${esc(keyFn(item))}
${valueFn(item)}
`).join('')
return `
`
}
const topModels = renderTop(t('usage.topModels'),
a.byModel, m => m.model || t('usage.unknownModel'), m => fmtCost(m.totals?.totalCost) + ' · ' + fmtTokens(m.totals?.totalTokens))
const topProviders = renderTop(t('usage.topProviders'),
a.byProvider, p => p.provider || t('usage.unknownProvider'), p => fmtCost(p.totals?.totalCost) + ' · ' + t('usage.times', { count: p.count }))
const topTools = renderTop(t('usage.topTools'),
(tools.tools || []), item => item.name, item => t('usage.timesCall', { count: item.count }))
const topAgents = renderTop(t('usage.topAgents'),
a.byAgent, item => item.agentId || 'main', item => fmtCost(item.totals?.totalCost))
const topChannels = renderTop(t('usage.topChannels'),
a.byChannel, c => c.channel || 'webchat', c => fmtCost(c.totals?.totalCost))
const topsHtml = `${topModels}${topProviders}${topTools}${topAgents}${topChannels}
`
// ── Token 分类 ──
const tokenBreakdownHtml = `
${t('usage.tokenBreakdown')}
${t('usage.outputTokens')} ${fmtTokens(totals.output)}
${t('usage.inputTokens')} ${fmtTokens(totals.input)}
${t('usage.cacheRead')} ${fmtTokens(totals.cacheRead)}
${t('usage.cacheWrite')} ${fmtTokens(totals.cacheWrite)}
`
// ── 每日用量 ──
const daily = a.daily || []
let dailyHtml = ''
if (daily.length > 0) {
const maxTokens = Math.max(...daily.map(d => d.tokens || 0), 1)
const bars = daily.map(d => {
const pct = Math.max(1, Math.round((d.tokens || 0) / maxTokens * 100))
const date = (d.date || '').slice(5) // MM-DD
return ``
}).join('')
dailyHtml = `
${t('usage.dailyUsage')}
${bars}
`
}
// ── 会话列表 ──
const sessions = (data.sessions || []).slice(0, 10)
let sessionsHtml = ''
if (sessions.length > 0) {
const rows = sessions.map(s => {
const u = s.usage || {}
const key = esc(s.key || '').replace(/^agent:main:/, '')
const model = s.model || u.modelUsage?.[0]?.model || ''
const provider = u.modelUsage?.[0]?.provider || s.modelProvider || ''
return `
${fmtTokens(u.totalTokens)} tokens · ${fmtCost(u.totalCost)} · ${(u.messageCounts?.total || 0)} msgs${u.messageCounts?.errors ? ' · ' + u.messageCounts.errors + ' err' : ''}
`
}).join('')
sessionsHtml = `
${t('usage.sessionDetail')} ${t('usage.recentN', { count: sessions.length })}
${rows}
`
}
el.innerHTML = overviewHtml + topsHtml + tokenBreakdownHtml + dailyHtml + sessionsHtml
}
function esc(str) {
return (str || '').replace(/&/g, '&').replace(//g, '>')
}