mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-20 05:10:52 +08:00
365 lines
10 KiB
Go
365 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 localeChangedMsg:
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
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 T("loading")
|
|
}
|
|
return m.viewport.View()
|
|
}
|
|
|
|
func (m usageTabModel) renderContent() string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(titleStyle.Render(T("usage_title")))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(helpStyle.Render(T("usage_help")))
|
|
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(T("usage_no_data")))
|
|
sb.WriteString("\n")
|
|
return sb.String()
|
|
}
|
|
|
|
usageMap, _ := m.usage["usage"].(map[string]any)
|
|
if usageMap == nil {
|
|
sb.WriteString(subtitleStyle.Render(T("usage_no_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(T("usage_total_reqs")),
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)),
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● %s: %d ● %s: %d", T("usage_success"), successCnt, T("usage_failure"), failureCnt)),
|
|
))
|
|
|
|
// Total Tokens
|
|
card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf(
|
|
"%s\n%s\n%s",
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_tokens")),
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)),
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_token_l"), 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(T("usage_rpm")),
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)),
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %d", T("usage_total_reqs"), 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(T("usage_tpm")),
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)),
|
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_tokens"), 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(T("usage_req_by_hour")))
|
|
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(T("usage_tok_by_hour")))
|
|
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(T("usage_req_by_day")))
|
|
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(T("usage_api_detail")))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 80)))
|
|
sb.WriteString("\n")
|
|
|
|
header := fmt.Sprintf(" %-30s %10s %12s", "API", T("requests"), T("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:%s", T("usage_input"), formatLargeNumber(inputTotal)))
|
|
}
|
|
if outputTotal > 0 {
|
|
parts = append(parts, fmt.Sprintf("%s:%s", T("usage_output"), formatLargeNumber(outputTotal)))
|
|
}
|
|
if cachedTotal > 0 {
|
|
parts = append(parts, fmt.Sprintf("%s:%s", T("usage_cached"), formatLargeNumber(cachedTotal)))
|
|
}
|
|
if reasoningTotal > 0 {
|
|
parts = append(parts, fmt.Sprintf("%s:%s", T("usage_reasoning"), 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()
|
|
}
|