diff options
| author | sinner <[email protected]> | 2026-08-05 01:21:17 +0000 |
|---|---|---|
| committer | sinner <[email protected]> | 2026-08-05 01:21:17 +0000 |
| commit | 4c6c8e86565ac49d1bd1c64e17c1404f4517d200 (patch) | |
| tree | 9f079e106a3f01f5d227c407e3d984a38bb2a8e9 | |
| parent | 2875e578f5e22ae0971d057819f1ab4362b4d115 (diff) | |
| download | dborg-master.tar.gz dborg-master.zip | |
| -rw-r--r-- | cmd/admin.go | 105 | ||||
| -rw-r--r-- | internal/client/admin.go | 71 | ||||
| -rw-r--r-- | internal/formatter/usage.go | 202 | ||||
| -rw-r--r-- | internal/models/admin.go | 54 |
4 files changed, 432 insertions, 0 deletions
diff --git a/cmd/admin.go b/cmd/admin.go index e957860..f3af058 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -63,6 +63,24 @@ var adminStatsCmd = &cobra.Command{ RunE: runAdminStats, } +var adminUsageLogsCmd = &cobra.Command{ + Use: "usage-logs", + Short: "Raw credit usage log entries", + RunE: runAdminUsageLogs, +} + +var adminUsageByEndpointCmd = &cobra.Command{ + Use: "usage-by-endpoint", + Short: "Credit usage aggregated by endpoint", + RunE: runAdminUsageByEndpoint, +} + +var adminUsageByAccountCmd = &cobra.Command{ + Use: "usage-by-account", + Short: "Credit usage aggregated by account", + RunE: runAdminUsageByAccount, +} + var adminEditCmd = &cobra.Command{ Use: "edit [api_key]", Short: "Edit account properties", @@ -80,6 +98,17 @@ func init() { adminCmd.AddCommand(adminDisableCmd) adminCmd.AddCommand(adminStatsCmd) adminCmd.AddCommand(adminEditCmd) + adminCmd.AddCommand(adminUsageLogsCmd) + adminCmd.AddCommand(adminUsageByEndpointCmd) + adminCmd.AddCommand(adminUsageByAccountCmd) + + adminUsageLogsCmd.Flags().IntP("account-id", "a", 0, "Filter by account ID") + adminUsageLogsCmd.Flags().IntP("limit", "l", 100, "Max rows to return") + adminUsageByEndpointCmd.Flags().String("from", "", "Start time (RFC3339, e.g. 2026-01-01T00:00:00Z)") + adminUsageByEndpointCmd.Flags().String("to", "", "End time (RFC3339)") + adminUsageByAccountCmd.Flags().IntP("account-id", "a", 0, "Filter by account ID") + adminUsageByAccountCmd.Flags().String("from", "", "Start time (RFC3339)") + adminUsageByAccountCmd.Flags().String("to", "", "End time (RFC3339)") adminCreateCmd.Flags().IntP("credits", "c", 0, "Initial credits") adminCreateCmd.Flags().BoolP("unlimited", "u", false, "Unlimited credits") @@ -361,3 +390,79 @@ func runAdminEdit(cmd *cobra.Command, args []string) error { printOutput(output) return nil } + +func runAdminUsageLogs(cmd *cobra.Command, args []string) error { + c, err := getAdminClient(cmd) + if err != nil { + return err + } + + accountID, _ := cmd.Flags().GetInt("account-id") + limit, _ := cmd.Flags().GetInt("limit") + + response, err := c.GetCreditUsageLogs(accountID, limit) + if err != nil { + return err + } + if err := checkError(response.Error); err != nil { + return err + } + + output, err := formatter.FormatCreditUsageLogs(response, IsJSONOutput()) + if err != nil { + return err + } + printOutput(output) + return nil +} + +func runAdminUsageByEndpoint(cmd *cobra.Command, args []string) error { + c, err := getAdminClient(cmd) + if err != nil { + return err + } + + from, _ := cmd.Flags().GetString("from") + to, _ := cmd.Flags().GetString("to") + + response, err := c.GetCreditUsageByEndpoint(from, to) + if err != nil { + return err + } + if err := checkError(response.Error); err != nil { + return err + } + + output, err := formatter.FormatCreditUsageByEndpoint(response, IsJSONOutput()) + if err != nil { + return err + } + printOutput(output) + return nil +} + +func runAdminUsageByAccount(cmd *cobra.Command, args []string) error { + c, err := getAdminClient(cmd) + if err != nil { + return err + } + + accountID, _ := cmd.Flags().GetInt("account-id") + from, _ := cmd.Flags().GetString("from") + to, _ := cmd.Flags().GetString("to") + + response, err := c.GetCreditUsageByAccount(accountID, from, to) + if err != nil { + return err + } + if err := checkError(response.Error); err != nil { + return err + } + + output, err := formatter.FormatCreditUsageByAccount(response, IsJSONOutput()) + if err != nil { + return err + } + printOutput(output) + return nil +} 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"` +} |
