summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsinner <[email protected]>2026-08-05 01:21:17 +0000
committersinner <[email protected]>2026-08-05 01:21:17 +0000
commit4c6c8e86565ac49d1bd1c64e17c1404f4517d200 (patch)
tree9f079e106a3f01f5d227c407e3d984a38bb2a8e9
parent2875e578f5e22ae0971d057819f1ab4362b4d115 (diff)
downloaddborg-master.tar.gz
dborg-master.zip
feat: add admin usage logging and reporting commandsHEADmaster
-rw-r--r--cmd/admin.go105
-rw-r--r--internal/client/admin.go71
-rw-r--r--internal/formatter/usage.go202
-rw-r--r--internal/models/admin.go54
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"`
+}