mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 21:00:52 +08:00
362 lines
10 KiB
Go
362 lines
10 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// usageTabModel displays usage statistics with charts and breakdowns.
|
|
type usageTabModel struct {
|
|
client *Client
|
|
viewport viewport.Model
|
|
usage map[string]any
|
|
err error
|
|
width int
|
|
height int
|
|
ready bool
|
|
}
|
|
|
|
type usageDataMsg struct {
|
|
usage map[string]any
|
|
err error
|
|
}
|
|
|
|
func newUsageTabModel(client *Client) usageTabModel {
|
|
return usageTabModel{
|
|
client: client,
|
|
}
|
|
}
|
|
|
|
func (m usageTabModel) Init() tea.Cmd {
|
|
return m.fetchData
|
|
}
|
|
|
|
func (m usageTabModel) fetchData() tea.Msg {
|
|
usage, err := m.client.GetUsage()
|
|
return usageDataMsg{usage: usage, err: err}
|
|
}
|
|
|
|
func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case usageDataMsg:
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
} else {
|
|
m.err = nil
|
|
m.usage = msg.usage
|
|
}
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
if msg.String() == "r" {
|
|
return m, m.fetchData
|
|
}
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *usageTabModel) SetSize(w, h int) {
|
|
m.width = w
|
|
m.height = h
|
|
if !m.ready {
|
|
m.viewport = viewport.New(w, h)
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.ready = true
|
|
} else {
|
|
m.viewport.Width = w
|
|
m.viewport.Height = h
|
|
}
|
|
}
|
|
|
|
func (m usageTabModel) View() string {
|
|
if !m.ready {
|
|
return "Loading..."
|
|
}
|
|
return m.viewport.View()
|
|
}
|
|
|
|
func (m usageTabModel) renderContent() string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(titleStyle.Render("📈 使用统计"))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll"))
|
|
sb.WriteString("\n\n")
|
|
|
|
if m.err != nil {
|
|
sb.WriteString(errorStyle.Render("⚠ Error: " + m.err.Error()))
|
|
sb.WriteString("\n")
|
|
return sb.String()
|
|
}
|
|
|
|
if m.usage == nil {
|
|
sb.WriteString(subtitleStyle.Render(" Usage data not available"))
|
|
sb.WriteString("\n")
|
|
return sb.String()
|
|
}
|
|
|
|
usageMap, _ := m.usage["usage"].(map[string]any)
|
|
if usageMap == nil {
|
|
sb.WriteString(subtitleStyle.Render(" No usage data"))
|
|
sb.WriteString("\n")
|
|
return sb.String()
|
|
}
|
|
|
|
totalReqs := int64(getFloat(usageMap, "total_requests"))
|
|
successCnt := int64(getFloat(usageMap, "success_count"))
|
|
failureCnt := int64(getFloat(usageMap, "failure_count"))
|
|
totalTokens := int64(getFloat(usageMap, "total_tokens"))
|
|
|
|
// ━━━ Overview Cards ━━━
|
|
cardWidth := 20
|
|
if m.width > 0 {
|
|
cardWidth = (m.width - 6) / 4
|
|
if cardWidth < 16 {
|
|
cardWidth = 16
|
|
}
|
|
}
|
|
cardStyle := lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("240")).
|
|
Padding(0, 1).
|
|
Width(cardWidth).
|
|
Height(3)
|
|
|
|
// Total Requests
|
|
card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf(
|
|
"%s\n%s\n%s",
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render("总请求数"),
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)),
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● 成功: %d ● 失败: %d", successCnt, failureCnt)),
|
|
))
|
|
|
|
// Total Tokens
|
|
card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf(
|
|
"%s\n%s\n%s",
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render("总 Token 数"),
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)),
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总Token: %s", formatLargeNumber(totalTokens))),
|
|
))
|
|
|
|
// RPM
|
|
rpm := float64(0)
|
|
if totalReqs > 0 {
|
|
if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 {
|
|
rpm = float64(totalReqs) / float64(len(rByH)) / 60.0
|
|
}
|
|
}
|
|
card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf(
|
|
"%s\n%s\n%s",
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render("RPM"),
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)),
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总请求数: %d", totalReqs)),
|
|
))
|
|
|
|
// TPM
|
|
tpm := float64(0)
|
|
if totalTokens > 0 {
|
|
if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 {
|
|
tpm = float64(totalTokens) / float64(len(tByH)) / 60.0
|
|
}
|
|
}
|
|
card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf(
|
|
"%s\n%s\n%s",
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render("TPM"),
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)),
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总Token数: %s", formatLargeNumber(totalTokens))),
|
|
))
|
|
|
|
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4))
|
|
sb.WriteString("\n\n")
|
|
|
|
// ━━━ Requests by Hour (ASCII bar chart) ━━━
|
|
if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 {
|
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("请求趋势 (按小时)"))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(renderBarChart(rByH, m.width-6, lipgloss.Color("111")))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// ━━━ Tokens by Hour ━━━
|
|
if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 {
|
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("Token 使用趋势 (按小时)"))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(renderBarChart(tByH, m.width-6, lipgloss.Color("214")))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// ━━━ Requests by Day ━━━
|
|
if rByD, ok := usageMap["requests_by_day"].(map[string]any); ok && len(rByD) > 0 {
|
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("请求趋势 (按天)"))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(renderBarChart(rByD, m.width-6, lipgloss.Color("76")))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// ━━━ API Detail Stats ━━━
|
|
if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 {
|
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("API 详细统计"))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 80)))
|
|
sb.WriteString("\n")
|
|
|
|
header := fmt.Sprintf(" %-30s %10s %12s", "API", "Requests", "Tokens")
|
|
sb.WriteString(tableHeaderStyle.Render(header))
|
|
sb.WriteString("\n")
|
|
|
|
for apiName, apiSnap := range apis {
|
|
if apiMap, ok := apiSnap.(map[string]any); ok {
|
|
apiReqs := int64(getFloat(apiMap, "total_requests"))
|
|
apiToks := int64(getFloat(apiMap, "total_tokens"))
|
|
|
|
row := fmt.Sprintf(" %-30s %10d %12s",
|
|
truncate(apiName, 30), apiReqs, formatLargeNumber(apiToks))
|
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row))
|
|
sb.WriteString("\n")
|
|
|
|
// Per-model breakdown
|
|
if models, ok := apiMap["models"].(map[string]any); ok {
|
|
for model, v := range models {
|
|
if stats, ok := v.(map[string]any); ok {
|
|
mReqs := int64(getFloat(stats, "total_requests"))
|
|
mToks := int64(getFloat(stats, "total_tokens"))
|
|
mRow := fmt.Sprintf(" ├─ %-28s %10d %12s",
|
|
truncate(model, 28), mReqs, formatLargeNumber(mToks))
|
|
sb.WriteString(tableCellStyle.Render(mRow))
|
|
sb.WriteString("\n")
|
|
|
|
// Token type breakdown from details
|
|
sb.WriteString(m.renderTokenBreakdown(stats))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
return sb.String()
|
|
}
|
|
|
|
// renderTokenBreakdown aggregates input/output/cached/reasoning tokens from model details.
|
|
func (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string {
|
|
details, ok := modelStats["details"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
detailList, ok := details.([]any)
|
|
if !ok || len(detailList) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var inputTotal, outputTotal, cachedTotal, reasoningTotal int64
|
|
for _, d := range detailList {
|
|
dm, ok := d.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
tokens, ok := dm["tokens"].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
inputTotal += int64(getFloat(tokens, "input_tokens"))
|
|
outputTotal += int64(getFloat(tokens, "output_tokens"))
|
|
cachedTotal += int64(getFloat(tokens, "cached_tokens"))
|
|
reasoningTotal += int64(getFloat(tokens, "reasoning_tokens"))
|
|
}
|
|
|
|
if inputTotal == 0 && outputTotal == 0 && cachedTotal == 0 && reasoningTotal == 0 {
|
|
return ""
|
|
}
|
|
|
|
parts := []string{}
|
|
if inputTotal > 0 {
|
|
parts = append(parts, fmt.Sprintf("输入:%s", formatLargeNumber(inputTotal)))
|
|
}
|
|
if outputTotal > 0 {
|
|
parts = append(parts, fmt.Sprintf("输出:%s", formatLargeNumber(outputTotal)))
|
|
}
|
|
if cachedTotal > 0 {
|
|
parts = append(parts, fmt.Sprintf("缓存:%s", formatLargeNumber(cachedTotal)))
|
|
}
|
|
if reasoningTotal > 0 {
|
|
parts = append(parts, fmt.Sprintf("思考:%s", formatLargeNumber(reasoningTotal)))
|
|
}
|
|
|
|
return fmt.Sprintf(" │ %s\n",
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(strings.Join(parts, " ")))
|
|
}
|
|
|
|
// renderBarChart renders a simple ASCII horizontal bar chart.
|
|
func renderBarChart(data map[string]any, maxBarWidth int, barColor lipgloss.Color) string {
|
|
if maxBarWidth < 10 {
|
|
maxBarWidth = 10
|
|
}
|
|
|
|
// Sort keys
|
|
keys := make([]string, 0, len(data))
|
|
for k := range data {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
// Find max value
|
|
maxVal := float64(0)
|
|
for _, k := range keys {
|
|
v := getFloat(data, k)
|
|
if v > maxVal {
|
|
maxVal = v
|
|
}
|
|
}
|
|
if maxVal == 0 {
|
|
return ""
|
|
}
|
|
|
|
barStyle := lipgloss.NewStyle().Foreground(barColor)
|
|
var sb strings.Builder
|
|
|
|
labelWidth := 12
|
|
barAvail := maxBarWidth - labelWidth - 12
|
|
if barAvail < 5 {
|
|
barAvail = 5
|
|
}
|
|
|
|
for _, k := range keys {
|
|
v := getFloat(data, k)
|
|
barLen := int(v / maxVal * float64(barAvail))
|
|
if barLen < 1 && v > 0 {
|
|
barLen = 1
|
|
}
|
|
bar := strings.Repeat("█", barLen)
|
|
label := k
|
|
if len(label) > labelWidth {
|
|
label = label[:labelWidth]
|
|
}
|
|
sb.WriteString(fmt.Sprintf(" %-*s %s %s\n",
|
|
labelWidth, label,
|
|
barStyle.Render(bar),
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%.0f", v)),
|
|
))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|