diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/client/admin.go | 71 | ||||
| -rw-r--r-- | internal/formatter/usage.go | 202 | ||||
| -rw-r--r-- | internal/models/admin.go | 54 |
3 files changed, 327 insertions, 0 deletions
diff --git a/internal/client/admin.go b/internal/client/admin.go index c776b7c..707af7f 100644 --- a/internal/client/admin.go +++ b/internal/client/admin.go @@ -159,3 +159,74 @@ func (c *Client) GetAccountStats() (*models.AccountStatsResponse, error) { return &response, nil } + +// GetCreditUsageLogs fetches raw credit_usage_log rows. +// accountID=0 means all accounts. +func (c *Client) GetCreditUsageLogs(accountID int, limit int) (*models.CreditUsageLogsResponse, error) { + params := url.Values{} + if accountID > 0 { + params.Set("account_id", fmt.Sprintf("%d", accountID)) + } + if limit > 0 { + params.Set("limit", fmt.Sprintf("%d", limit)) + } + + data, err := c.Get("/admin/usage/logs", params) + if err != nil { + return nil, err + } + + var response models.CreditUsageLogsResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse usage logs response: %w", err) + } + return &response, nil +} + +// GetCreditUsageByEndpoint fetches per-endpoint aggregate stats. +func (c *Client) GetCreditUsageByEndpoint(from, to string) (*models.CreditUsageByEndpointResponse, error) { + params := url.Values{} + if from != "" { + params.Set("from", from) + } + if to != "" { + params.Set("to", to) + } + + data, err := c.Get("/admin/usage/by-endpoint", params) + if err != nil { + return nil, err + } + + var response models.CreditUsageByEndpointResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse usage by-endpoint response: %w", err) + } + return &response, nil +} + +// GetCreditUsageByAccount fetches per-account per-endpoint aggregate stats. +// accountID=0 means all accounts. +func (c *Client) GetCreditUsageByAccount(accountID int, from, to string) (*models.CreditUsageByAccountResponse, error) { + params := url.Values{} + if accountID > 0 { + params.Set("account_id", fmt.Sprintf("%d", accountID)) + } + if from != "" { + params.Set("from", from) + } + if to != "" { + params.Set("to", to) + } + + data, err := c.Get("/admin/usage/by-account", params) + if err != nil { + return nil, err + } + + var response models.CreditUsageByAccountResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse usage by-account response: %w", err) + } + return &response, nil +} diff --git a/internal/formatter/usage.go b/internal/formatter/usage.go new file mode 100644 index 0000000..6732a3b --- /dev/null +++ b/internal/formatter/usage.go @@ -0,0 +1,202 @@ +package formatter + +import ( + "fmt" + "strings" + "time" + + "git.db.org.ai/dborg/internal/models" +) + +// FormatCreditUsageLogs renders raw credit_usage_log rows. +func FormatCreditUsageLogs(resp *models.CreditUsageLogsResponse, asJSON bool) (string, error) { + if asJSON { + if err := PrintColorizedJSON(resp); err != nil { + return "", err + } + return "", nil + } + + if len(resp.Logs) == 0 { + return fmt.Sprintf("%s\n", Gray("No usage logs found")), nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("\n%s\n", Bold(Cyan("Credit Usage Logs")))) + sb.WriteString(fmt.Sprintf("%s\n\n", Gray(strings.Repeat("─", 90)))) + + table := NewTable([]string{"ID", "Account", "Endpoint", "Path", "Cost", "Charged", "Status", "Time"}) + + for _, row := range resp.Logs { + statusStr := Green(fmt.Sprintf("%d", row.StatusCode)) + if row.StatusCode >= 400 { + statusStr = Red(fmt.Sprintf("%d", row.StatusCode)) + } else if row.StatusCode >= 300 { + statusStr = Yellow(fmt.Sprintf("%d", row.StatusCode)) + } + + chargedStr := Gray("0") + if row.CreditsCharged > 0 { + chargedStr = Yellow(fmt.Sprintf("%d", row.CreditsCharged)) + } + + timeStr := row.CreatedAt + if t, err := time.Parse(time.RFC3339, row.CreatedAt); err == nil { + timeStr = t.Format("01-02 15:04:05") + } else if t, err := time.Parse("2006-01-02T15:04:05Z", row.CreatedAt); err == nil { + timeStr = t.Format("01-02 15:04:05") + } + + table.AddRow( + Gray(fmt.Sprintf("%d", row.ID)), + Bold(row.AccountName), + Cyan(row.Endpoint), + Dim(truncate(row.Path, 30)), + Gray(fmt.Sprintf("%d", row.EndpointCost)), + chargedStr, + statusStr, + Gray(timeStr), + ) + } + + sb.WriteString(table.Render()) + sb.WriteString(fmt.Sprintf("\n%s %d rows\n", Blue("Total:"), resp.Count)) + return sb.String(), nil +} + +// FormatCreditUsageByEndpoint renders per-endpoint aggregate stats. +func FormatCreditUsageByEndpoint(resp *models.CreditUsageByEndpointResponse, asJSON bool) (string, error) { + if asJSON { + if err := PrintColorizedJSON(resp); err != nil { + return "", err + } + return "", nil + } + + if len(resp.Endpoints) == 0 { + return fmt.Sprintf("%s\n", Gray("No endpoint usage data found")), nil + } + + fromStr, toStr := formatTimeRange(resp.From, resp.To) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("\n%s\n", Bold(Cyan("Usage by Endpoint")))) + sb.WriteString(fmt.Sprintf("%s %s → %s\n\n", Gray(strings.Repeat("─", 60)), Gray(fromStr), Gray(toStr))) + + table := NewTable([]string{"Endpoint", "Total", "Success", "Cost", "Charged"}) + + var totalCalls, totalCharged int64 + for _, row := range resp.Endpoints { + successRate := "" + if row.TotalCalls > 0 { + pct := float64(row.SuccessCalls) / float64(row.TotalCalls) * 100 + color := Green + if pct < 50 { + color = Red + } else if pct < 80 { + color = Yellow + } + successRate = color(fmt.Sprintf("%d (%.0f%%)", row.SuccessCalls, pct)) + } + + chargedStr := Gray("0") + if row.TotalCharged > 0 { + chargedStr = Yellow(fmt.Sprintf("%d", row.TotalCharged)) + } + + table.AddRow( + Cyan(row.Endpoint), + Bold(fmt.Sprintf("%d", row.TotalCalls)), + successRate, + Gray(fmt.Sprintf("%d ea", row.EndpointCost)), + chargedStr, + ) + totalCalls += row.TotalCalls + totalCharged += row.TotalCharged + } + + sb.WriteString(table.Render()) + sb.WriteString(fmt.Sprintf("\n%s %d calls %s %d credits charged\n", + Blue("Total calls:"), totalCalls, + Blue("Total charged:"), totalCharged, + )) + return sb.String(), nil +} + +// FormatCreditUsageByAccount renders per-account per-endpoint aggregate stats. +func FormatCreditUsageByAccount(resp *models.CreditUsageByAccountResponse, asJSON bool) (string, error) { + if asJSON { + if err := PrintColorizedJSON(resp); err != nil { + return "", err + } + return "", nil + } + + if len(resp.Accounts) == 0 { + return fmt.Sprintf("%s\n", Gray("No account usage data found")), nil + } + + fromStr, toStr := formatTimeRange(resp.From, resp.To) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("\n%s\n", Bold(Cyan("Usage by Account")))) + sb.WriteString(fmt.Sprintf("%s %s → %s\n\n", Gray(strings.Repeat("─", 60)), Gray(fromStr), Gray(toStr))) + + table := NewTable([]string{"Account", "Endpoint", "Calls", "Charged", "Last Call"}) + + // Group consecutive rows by account for visual separation + var prevAccount string + var totalCalls, totalCharged int64 + for _, row := range resp.Accounts { + nameStr := Bold(row.AccountName) + if row.AccountName == prevAccount { + nameStr = Dim(" ↳") + } + prevAccount = row.AccountName + + chargedStr := Gray("0") + if row.TotalCharged > 0 { + chargedStr = Yellow(fmt.Sprintf("%d", row.TotalCharged)) + } + + lastCall := row.LastCall + if t, err := time.Parse("2006-01-02 15:04:05", row.LastCall); err == nil { + lastCall = t.Format("2006-01-02 15:04") + } + + table.AddRow( + nameStr, + Cyan(row.Endpoint), + fmt.Sprintf("%d", row.TotalCalls), + chargedStr, + Gray(lastCall), + ) + totalCalls += row.TotalCalls + totalCharged += row.TotalCharged + } + + sb.WriteString(table.Render()) + sb.WriteString(fmt.Sprintf("\n%s %d %s %d credits charged\n", + Blue("Total calls:"), totalCalls, + Blue("Total charged:"), totalCharged, + )) + return sb.String(), nil +} + +func formatTimeRange(from, to string) (string, string) { + fromStr, toStr := from, to + if t, err := time.Parse(time.RFC3339, from); err == nil { + fromStr = t.Format("2006-01-02 15:04") + } + if t, err := time.Parse(time.RFC3339, to); err == nil { + toStr = t.Format("2006-01-02 15:04") + } + return fromStr, toStr +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} diff --git a/internal/models/admin.go b/internal/models/admin.go index 7d774a8..96403af 100644 --- a/internal/models/admin.go +++ b/internal/models/admin.go @@ -57,3 +57,57 @@ type AdminResponse struct { Account *Account `json:"account,omitempty"` Accounts []Account `json:"accounts,omitempty"` } + +// credit_usage_log models + +type CreditUsageLog struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + AccountName string `json:"account_name"` + Endpoint string `json:"endpoint"` + Path string `json:"path"` + Method string `json:"method"` + EndpointCost int64 `json:"endpoint_cost"` + CreditsCharged int64 `json:"credits_charged"` + StatusCode int64 `json:"status_code"` + CreatedAt string `json:"created_at"` +} + +type CreditUsageLogsResponse struct { + Logs []CreditUsageLog `json:"logs"` + Count int `json:"count"` + Error string `json:"error,omitempty"` +} + +type CreditUsageEndpointStat struct { + Endpoint string `json:"endpoint"` + TotalCalls int64 `json:"total_calls"` + SuccessCalls int64 `json:"success_calls"` + EndpointCost int64 `json:"endpoint_cost"` + TotalCharged int64 `json:"total_charged"` +} + +type CreditUsageByEndpointResponse struct { + From string `json:"from"` + To string `json:"to"` + Endpoints []CreditUsageEndpointStat `json:"endpoints"` + Count int `json:"count"` + Error string `json:"error,omitempty"` +} + +type CreditUsageAccountStat struct { + AccountID int64 `json:"account_id"` + AccountName string `json:"account_name"` + Endpoint string `json:"endpoint"` + TotalCalls int64 `json:"total_calls"` + TotalCharged int64 `json:"total_charged"` + LastCall string `json:"last_call"` +} + +type CreditUsageByAccountResponse struct { + From string `json:"from"` + To string `json:"to"` + Accounts []CreditUsageAccountStat `json:"accounts"` + Count int `json:"count"` + Error string `json:"error,omitempty"` +} |
