Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,713 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
adminAnalyticsDefaultDays = 30
|
||||
adminAnalyticsMinDays = 7
|
||||
adminAnalyticsMaxDays = 90
|
||||
adminAnalyticsTopCompanies = 25
|
||||
adminAnalyticsRecentCycles = 40
|
||||
adminAnalyticsProviderDetailMax = 40
|
||||
adminAnalyticsStuckAfter = 2 * time.Hour
|
||||
)
|
||||
|
||||
func adminAnalyticsSummaryOnly(r *http.Request) bool {
|
||||
v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("summary")))
|
||||
if v == "" {
|
||||
v = strings.TrimSpace(strings.ToLower(r.URL.Query().Get("summary_only")))
|
||||
}
|
||||
return v == "1" || v == "true" || v == "yes"
|
||||
}
|
||||
|
||||
type adminDayPoint struct {
|
||||
Date string `json:"date"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Products int64 `json:"products,omitempty"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
Completed int64 `json:"completed,omitempty"`
|
||||
Failed int64 `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
type adminAnalyticsSummary struct {
|
||||
Users int64 `json:"users"`
|
||||
Companies int64 `json:"companies"`
|
||||
UsersPeriod int64 `json:"users_period"`
|
||||
CompaniesPeriod int64 `json:"companies_period"`
|
||||
CreditsAllocated int64 `json:"credits_allocated"`
|
||||
CreditsUsed int64 `json:"credits_used"`
|
||||
CreditsRemaining int64 `json:"credits_remaining"`
|
||||
TokensTotal int64 `json:"tokens_total"`
|
||||
TokensPeriod int64 `json:"tokens_period"`
|
||||
JobsTotal int64 `json:"jobs_total"`
|
||||
JobsByStatus map[string]int64 `json:"jobs_by_status"`
|
||||
JobsStuck int64 `json:"jobs_stuck"`
|
||||
JobsFailedPeriod int64 `json:"jobs_failed_period"`
|
||||
JobsCompletedPeriod int64 `json:"jobs_completed_period"`
|
||||
ProductsProcessed int64 `json:"products_processed"`
|
||||
ProductsRaw int64 `json:"products_raw"`
|
||||
FeedsInput int64 `json:"feeds_input"`
|
||||
FeedsExport int64 `json:"feeds_export"`
|
||||
ApiKeysActive int64 `json:"api_keys_active"`
|
||||
ApiKeysTotal int64 `json:"api_keys_total"`
|
||||
FeedSyncByStatus map[string]int64 `json:"feed_sync_by_status"`
|
||||
TicketsByStatus map[string]int64 `json:"tickets_by_status"`
|
||||
}
|
||||
|
||||
type adminSignupDayPoint struct {
|
||||
Date string `json:"date"`
|
||||
Users int64 `json:"users"`
|
||||
Companies int64 `json:"companies"`
|
||||
}
|
||||
|
||||
type adminCompanyUsage struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TotalCredits int64 `json:"total_credits"`
|
||||
UsedCredits int64 `json:"used_credits"`
|
||||
Remaining int64 `json:"credits_remaining"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Jobs int64 `json:"jobs"`
|
||||
Providers adminProviderBreakdown `json:"providers"`
|
||||
}
|
||||
|
||||
type adminProviderDayPoint struct {
|
||||
Date string `json:"date"`
|
||||
Internal int64 `json:"internal"`
|
||||
Popular int64 `json:"popular"`
|
||||
Custom int64 `json:"custom"`
|
||||
Unknown int64 `json:"unknown"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type adminProviderDetail struct {
|
||||
Provider string `json:"provider"`
|
||||
Class string `json:"class"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Products int64 `json:"products"`
|
||||
}
|
||||
|
||||
type adminBillingCycleRow struct {
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
CompanyName string `json:"company_name"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
CreditsUsed int64 `json:"credits_used"`
|
||||
ProductsProcessed int64 `json:"products_processed"`
|
||||
}
|
||||
|
||||
// adminProviderBucket is per-mode usage (internal / popular / custom / unknown).
|
||||
type adminProviderBucket struct {
|
||||
Tokens int64 `json:"tokens"`
|
||||
Jobs int64 `json:"jobs"`
|
||||
Products int64 `json:"products"`
|
||||
}
|
||||
|
||||
type adminProviderBreakdown struct {
|
||||
Internal adminProviderBucket `json:"internal"`
|
||||
Popular adminProviderBucket `json:"popular"`
|
||||
Custom adminProviderBucket `json:"custom"`
|
||||
Unknown adminProviderBucket `json:"unknown"`
|
||||
}
|
||||
|
||||
// handleAdminAnalytics returns platform-wide aggregates from live tables only.
|
||||
// GET /api/admin/analytics?days=30
|
||||
// Optional: summary=1|true — dashboard cards only (skips series/companies/cycles).
|
||||
func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
days := adminAnalyticsDefaultDays
|
||||
if raw := r.URL.Query().Get("days"); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
days = clampAdminAnalyticsDays(n)
|
||||
}
|
||||
}
|
||||
summaryOnly := adminAnalyticsSummaryOnly(r)
|
||||
ctx := r.Context()
|
||||
since := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -(days - 1))
|
||||
|
||||
summary, err := s.loadAdminAnalyticsSummary(ctx, since)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics summary failed")
|
||||
return
|
||||
}
|
||||
|
||||
providers, providerDetail := s.loadAdminProviderBreakdown(ctx)
|
||||
if summaryOnly {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"days": days,
|
||||
"summary": summary,
|
||||
"providers": providers,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenByDay := map[string]adminDayPoint{}
|
||||
tokRows, err := s.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
|
||||
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint,
|
||||
COUNT(*)::bigint
|
||||
FROM processed_products
|
||||
WHERE created_at >= $1
|
||||
GROUP BY 1
|
||||
ORDER BY 1`, since)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics token series failed")
|
||||
return
|
||||
}
|
||||
for tokRows.Next() {
|
||||
var d time.Time
|
||||
var tokens, products int64
|
||||
if err := tokRows.Scan(&d, &tokens, &products); err != nil {
|
||||
tokRows.Close()
|
||||
Error(w, http.StatusInternalServerError, "analytics token series scan failed")
|
||||
return
|
||||
}
|
||||
key := d.UTC().Format("2006-01-02")
|
||||
tokenByDay[key] = adminDayPoint{Date: key, Tokens: tokens, Products: products}
|
||||
}
|
||||
tokRows.Close()
|
||||
if err := tokRows.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics token series rows failed")
|
||||
return
|
||||
}
|
||||
|
||||
jobByDay := map[string]adminDayPoint{}
|
||||
jSeries, err := s.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
|
||||
COUNT(*)::bigint,
|
||||
COUNT(*) FILTER (WHERE status = 'completed')::bigint,
|
||||
COUNT(*) FILTER (WHERE status = 'failed')::bigint,
|
||||
COALESCE(SUM(estimated_tokens), 0)::bigint
|
||||
FROM processing_jobs
|
||||
WHERE created_at >= $1
|
||||
GROUP BY 1
|
||||
ORDER BY 1`, since)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics job series failed")
|
||||
return
|
||||
}
|
||||
for jSeries.Next() {
|
||||
var d time.Time
|
||||
var created, completed, failed, tokens int64
|
||||
if err := jSeries.Scan(&d, &created, &completed, &failed, &tokens); err != nil {
|
||||
jSeries.Close()
|
||||
Error(w, http.StatusInternalServerError, "analytics job series scan failed")
|
||||
return
|
||||
}
|
||||
key := d.UTC().Format("2006-01-02")
|
||||
jobByDay[key] = adminDayPoint{
|
||||
Date: key,
|
||||
Created: created,
|
||||
Completed: completed,
|
||||
Failed: failed,
|
||||
Tokens: tokens,
|
||||
}
|
||||
}
|
||||
jSeries.Close()
|
||||
if err := jSeries.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics job series rows failed")
|
||||
return
|
||||
}
|
||||
|
||||
tokenSeries := fillAdminDaySeries(since, days, tokenByDay, func(p adminDayPoint) adminDayPoint {
|
||||
return adminDayPoint{Date: p.Date, Tokens: p.Tokens, Products: p.Products}
|
||||
})
|
||||
jobSeries := fillAdminDaySeries(since, days, jobByDay, func(p adminDayPoint) adminDayPoint {
|
||||
return adminDayPoint{
|
||||
Date: p.Date,
|
||||
Created: p.Created,
|
||||
Completed: p.Completed,
|
||||
Failed: p.Failed,
|
||||
Tokens: p.Tokens,
|
||||
}
|
||||
})
|
||||
|
||||
companies := make([]adminCompanyUsage, 0, adminAnalyticsTopCompanies)
|
||||
companyIDs := make([]uuid.UUID, 0, adminAnalyticsTopCompanies)
|
||||
cRows, err := s.Pool.Query(ctx, `
|
||||
SELECT c.id, c.name,
|
||||
COALESCE(cb.total_credits, 0)::bigint,
|
||||
COALESCE(cb.used_credits, 0)::bigint,
|
||||
COALESCE(tok.tokens, 0)::bigint,
|
||||
COALESCE(jobs.cnt, 0)::bigint
|
||||
FROM companies c
|
||||
LEFT JOIN credit_balances cb ON cb.company_id = c.id
|
||||
LEFT JOIN (
|
||||
SELECT company_id, SUM(COALESCE(total_tokens, 0))::bigint AS tokens
|
||||
FROM processed_products
|
||||
GROUP BY company_id
|
||||
) tok ON tok.company_id = c.id
|
||||
LEFT JOIN (
|
||||
SELECT company_id, COUNT(*)::bigint AS cnt
|
||||
FROM processing_jobs
|
||||
GROUP BY company_id
|
||||
) jobs ON jobs.company_id = c.id
|
||||
ORDER BY COALESCE(tok.tokens, 0) DESC, COALESCE(cb.used_credits, 0) DESC, c.name ASC
|
||||
LIMIT $1`, adminAnalyticsTopCompanies)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics companies usage failed")
|
||||
return
|
||||
}
|
||||
for cRows.Next() {
|
||||
var row adminCompanyUsage
|
||||
if err := cRows.Scan(&row.ID, &row.Name, &row.TotalCredits, &row.UsedCredits, &row.Tokens, &row.Jobs); err != nil {
|
||||
cRows.Close()
|
||||
Error(w, http.StatusInternalServerError, "analytics companies scan failed")
|
||||
return
|
||||
}
|
||||
row.Remaining = row.TotalCredits - row.UsedCredits
|
||||
if row.Remaining < 0 {
|
||||
row.Remaining = 0
|
||||
}
|
||||
companies = append(companies, row)
|
||||
companyIDs = append(companyIDs, row.ID)
|
||||
}
|
||||
cRows.Close()
|
||||
if err := cRows.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics companies rows failed")
|
||||
return
|
||||
}
|
||||
|
||||
cycles := make([]adminBillingCycleRow, 0, adminAnalyticsRecentCycles)
|
||||
cyRows, err := s.Pool.Query(ctx, `
|
||||
SELECT bc.company_id, c.name, bc.start_date, bc.end_date,
|
||||
bc.credits_used::bigint, bc.products_processed::bigint
|
||||
FROM billing_cycles bc
|
||||
JOIN companies c ON c.id = bc.company_id
|
||||
ORDER BY bc.start_date DESC
|
||||
LIMIT $1`, adminAnalyticsRecentCycles)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics billing cycles failed")
|
||||
return
|
||||
}
|
||||
for cyRows.Next() {
|
||||
var row adminBillingCycleRow
|
||||
if err := cyRows.Scan(&row.CompanyID, &row.CompanyName, &row.StartDate, &row.EndDate, &row.CreditsUsed, &row.ProductsProcessed); err != nil {
|
||||
cyRows.Close()
|
||||
Error(w, http.StatusInternalServerError, "analytics billing cycles scan failed")
|
||||
return
|
||||
}
|
||||
cycles = append(cycles, row)
|
||||
}
|
||||
cyRows.Close()
|
||||
if err := cyRows.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics billing cycles rows failed")
|
||||
return
|
||||
}
|
||||
|
||||
providerDaySeries := s.loadAdminProviderDaySeries(ctx, since, days)
|
||||
signupSeries := s.loadAdminSignupDaySeries(ctx, since, days)
|
||||
companyProviders := s.loadAdminCompanyProviderBreakdown(ctx, companyIDs)
|
||||
for i := range companies {
|
||||
if split, ok := companyProviders[companies[i].ID]; ok {
|
||||
companies[i].Providers = split
|
||||
}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"days": days,
|
||||
"summary": summary,
|
||||
"series": map[string]any{
|
||||
"tokens_by_day": tokenSeries,
|
||||
"jobs_by_day": jobSeries,
|
||||
"tokens_by_provider_day": providerDaySeries,
|
||||
"signups_by_day": signupSeries,
|
||||
},
|
||||
"companies": companies,
|
||||
"billing_cycles": cycles,
|
||||
"providers": providers,
|
||||
"tokens_by_provider_detail": providerDetail,
|
||||
"notes": []string{
|
||||
"Tokens come from processed_products.total_tokens (LLM usage recorded per product).",
|
||||
"Provider classes use processed_products.ai_provider_mode: internal | popular:<name> | custom (blank/unknown rolled into the internal card).",
|
||||
"Job tokens use processing_jobs.estimated_tokens (running tally during jobs).",
|
||||
"Jobs stuck = status running with updated_at older than 2 hours (same threshold as diagnostics).",
|
||||
"Credits are live credit_balances snapshots - daily credit debits are not ledgered yet.",
|
||||
"Feeds / feed sync / support tickets / API keys are live table aggregates.",
|
||||
"Billing cycle rows are historical rollups when cycles have been run (may lag the active company_plans window).",
|
||||
"Use /admin/diagnostics for queue health checks and recent failure samples.",
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminAnalyticsSummary(ctx context.Context, since time.Time) (adminAnalyticsSummary, error) {
|
||||
summary := adminAnalyticsSummary{
|
||||
JobsByStatus: map[string]int64{},
|
||||
FeedSyncByStatus: map[string]int64{},
|
||||
TicketsByStatus: map[string]int64{},
|
||||
}
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*)::bigint FROM users),
|
||||
(SELECT COUNT(*)::bigint FROM companies),
|
||||
(SELECT COUNT(*)::bigint FROM users WHERE created_at >= $1),
|
||||
(SELECT COUNT(*)::bigint FROM companies WHERE created_at >= $1),
|
||||
(SELECT COALESCE(SUM(total_credits), 0)::bigint FROM credit_balances),
|
||||
(SELECT COALESCE(SUM(used_credits), 0)::bigint FROM credit_balances),
|
||||
(SELECT COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint FROM processed_products),
|
||||
(SELECT COUNT(*)::bigint FROM processed_products),
|
||||
(SELECT COUNT(*)::bigint FROM raw_products),
|
||||
(SELECT COUNT(*)::bigint FROM input_feeds),
|
||||
(SELECT COUNT(*)::bigint FROM export_feeds),
|
||||
(SELECT COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint FROM processed_products WHERE created_at >= $1),
|
||||
(SELECT COUNT(*)::bigint FROM processing_jobs WHERE created_at >= $1 AND status = 'failed'),
|
||||
(SELECT COUNT(*)::bigint FROM processing_jobs WHERE created_at >= $1 AND status = 'completed'),
|
||||
(SELECT COUNT(*)::bigint FROM processing_jobs
|
||||
WHERE status = 'running' AND updated_at < now() - make_interval(secs => $2)),
|
||||
(SELECT COUNT(*)::bigint FROM api_keys),
|
||||
(SELECT COUNT(*)::bigint FROM api_keys WHERE revoked_at IS NULL)
|
||||
`, since, adminAnalyticsStuckAfter.Seconds()).Scan(
|
||||
&summary.Users,
|
||||
&summary.Companies,
|
||||
&summary.UsersPeriod,
|
||||
&summary.CompaniesPeriod,
|
||||
&summary.CreditsAllocated,
|
||||
&summary.CreditsUsed,
|
||||
&summary.TokensTotal,
|
||||
&summary.ProductsProcessed,
|
||||
&summary.ProductsRaw,
|
||||
&summary.FeedsInput,
|
||||
&summary.FeedsExport,
|
||||
&summary.TokensPeriod,
|
||||
&summary.JobsFailedPeriod,
|
||||
&summary.JobsCompletedPeriod,
|
||||
&summary.JobsStuck,
|
||||
&summary.ApiKeysTotal,
|
||||
&summary.ApiKeysActive,
|
||||
)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
}
|
||||
summary.CreditsRemaining = summary.CreditsAllocated - summary.CreditsUsed
|
||||
if summary.CreditsRemaining < 0 {
|
||||
summary.CreditsRemaining = 0
|
||||
}
|
||||
|
||||
jobRows, err := s.Pool.Query(ctx, `
|
||||
SELECT status, COUNT(*)
|
||||
FROM processing_jobs
|
||||
GROUP BY status`)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
}
|
||||
defer jobRows.Close()
|
||||
for jobRows.Next() {
|
||||
var status string
|
||||
var n int64
|
||||
if err := jobRows.Scan(&status, &n); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
summary.JobsByStatus[status] = n
|
||||
summary.JobsTotal += n
|
||||
}
|
||||
if err := jobRows.Err(); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
|
||||
summary.FeedSyncByStatus = s.loadAdminStatusCounts(ctx,
|
||||
`SELECT status, COUNT(*)::bigint FROM feed_sync_jobs GROUP BY status`)
|
||||
summary.TicketsByStatus = s.loadAdminStatusCounts(ctx,
|
||||
`SELECT status, COUNT(*)::bigint FROM support_tickets GROUP BY status`)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminStatusCounts(ctx context.Context, query string) map[string]int64 {
|
||||
out := map[string]int64{}
|
||||
rows, err := s.Pool.Query(ctx, query)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var n int64
|
||||
if err := rows.Scan(&status, &n); err != nil {
|
||||
return out
|
||||
}
|
||||
out[status] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminSignupDaySeries(ctx context.Context, since time.Time, days int) []adminSignupDayPoint {
|
||||
byDay := map[string]adminSignupDayPoint{}
|
||||
uRows, err := s.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d, COUNT(*)::bigint
|
||||
FROM users WHERE created_at >= $1
|
||||
GROUP BY 1 ORDER BY 1`, since)
|
||||
if err == nil {
|
||||
for uRows.Next() {
|
||||
var d time.Time
|
||||
var n int64
|
||||
if err := uRows.Scan(&d, &n); err != nil {
|
||||
break
|
||||
}
|
||||
key := d.UTC().Format("2006-01-02")
|
||||
pt := byDay[key]
|
||||
pt.Date = key
|
||||
pt.Users = n
|
||||
byDay[key] = pt
|
||||
}
|
||||
uRows.Close()
|
||||
}
|
||||
cRows, err := s.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d, COUNT(*)::bigint
|
||||
FROM companies WHERE created_at >= $1
|
||||
GROUP BY 1 ORDER BY 1`, since)
|
||||
if err == nil {
|
||||
for cRows.Next() {
|
||||
var d time.Time
|
||||
var n int64
|
||||
if err := cRows.Scan(&d, &n); err != nil {
|
||||
break
|
||||
}
|
||||
key := d.UTC().Format("2006-01-02")
|
||||
pt := byDay[key]
|
||||
pt.Date = key
|
||||
pt.Companies = n
|
||||
byDay[key] = pt
|
||||
}
|
||||
cRows.Close()
|
||||
}
|
||||
out := make([]adminSignupDayPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
d := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
|
||||
if p, ok := byDay[d]; ok {
|
||||
p.Date = d
|
||||
out = append(out, p)
|
||||
continue
|
||||
}
|
||||
out = append(out, adminSignupDayPoint{Date: d})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminProviderBreakdown(ctx context.Context) (adminProviderBreakdown, []adminProviderDetail) {
|
||||
out := adminProviderBreakdown{}
|
||||
detail := make([]adminProviderDetail, 0)
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
|
||||
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint,
|
||||
COUNT(*)::bigint
|
||||
FROM processed_products
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC`)
|
||||
if err != nil {
|
||||
return out, detail
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var mode string
|
||||
var tokens, products int64
|
||||
if err := rows.Scan(&mode, &tokens, &products); err != nil {
|
||||
return out, detail
|
||||
}
|
||||
class := aiprovider.AnalyticsClass(mode)
|
||||
bucket := adminProviderBucket{Tokens: tokens, Products: products}
|
||||
switch class {
|
||||
case aiprovider.ModePopular:
|
||||
out.Popular.Tokens += bucket.Tokens
|
||||
out.Popular.Products += bucket.Products
|
||||
case aiprovider.ModeCustom:
|
||||
out.Custom.Tokens += bucket.Tokens
|
||||
out.Custom.Products += bucket.Products
|
||||
default:
|
||||
// internal + unknown → internal card (legacy/backfill)
|
||||
out.Internal.Tokens += bucket.Tokens
|
||||
out.Internal.Products += bucket.Products
|
||||
}
|
||||
detail = append(detail, adminProviderDetail{
|
||||
Provider: aiprovider.NormalizeAnalyticsMode(mode),
|
||||
Class: class,
|
||||
Tokens: tokens,
|
||||
Products: products,
|
||||
})
|
||||
}
|
||||
|
||||
jobRows, err := s.Pool.Query(ctx, `
|
||||
SELECT COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
|
||||
COUNT(*)::bigint
|
||||
FROM processing_jobs
|
||||
GROUP BY 1`)
|
||||
if err != nil {
|
||||
return out, detail
|
||||
}
|
||||
defer jobRows.Close()
|
||||
for jobRows.Next() {
|
||||
var mode string
|
||||
var jobs int64
|
||||
if err := jobRows.Scan(&mode, &jobs); err != nil {
|
||||
return out, detail
|
||||
}
|
||||
switch aiprovider.AnalyticsClass(mode) {
|
||||
case aiprovider.ModePopular:
|
||||
out.Popular.Jobs += jobs
|
||||
case aiprovider.ModeCustom:
|
||||
out.Custom.Jobs += jobs
|
||||
default:
|
||||
out.Internal.Jobs += jobs
|
||||
}
|
||||
}
|
||||
if len(detail) > adminAnalyticsProviderDetailMax {
|
||||
detail = detail[:adminAnalyticsProviderDetailMax]
|
||||
}
|
||||
return out, detail
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminProviderDaySeries(ctx context.Context, since time.Time, days int) []adminProviderDayPoint {
|
||||
byDay := map[string]adminProviderDayPoint{}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
|
||||
COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
|
||||
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint
|
||||
FROM processed_products
|
||||
WHERE created_at >= $1
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1`, since)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d time.Time
|
||||
var mode string
|
||||
var tokens int64
|
||||
if err := rows.Scan(&d, &mode, &tokens); err != nil {
|
||||
break
|
||||
}
|
||||
key := d.UTC().Format("2006-01-02")
|
||||
pt := byDay[key]
|
||||
pt.Date = key
|
||||
switch aiprovider.AnalyticsClass(mode) {
|
||||
case aiprovider.ModePopular:
|
||||
pt.Popular += tokens
|
||||
case aiprovider.ModeCustom:
|
||||
pt.Custom += tokens
|
||||
case "unknown":
|
||||
pt.Unknown += tokens
|
||||
default:
|
||||
pt.Internal += tokens
|
||||
}
|
||||
pt.Total += tokens
|
||||
byDay[key] = pt
|
||||
}
|
||||
}
|
||||
out := make([]adminProviderDayPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
d := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
|
||||
if p, ok := byDay[d]; ok {
|
||||
p.Date = d
|
||||
out = append(out, p)
|
||||
continue
|
||||
}
|
||||
out = append(out, adminProviderDayPoint{Date: d})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminCompanyProviderBreakdown(ctx context.Context, companyIDs []uuid.UUID) map[uuid.UUID]adminProviderBreakdown {
|
||||
out := map[uuid.UUID]adminProviderBreakdown{}
|
||||
if len(companyIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT company_id,
|
||||
COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
|
||||
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint,
|
||||
COUNT(*)::bigint
|
||||
FROM processed_products
|
||||
WHERE company_id = ANY($1)
|
||||
GROUP BY company_id, 2`, companyIDs)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var companyID uuid.UUID
|
||||
var mode string
|
||||
var tokens, products int64
|
||||
if err := rows.Scan(&companyID, &mode, &tokens, &products); err != nil {
|
||||
return out
|
||||
}
|
||||
b := out[companyID]
|
||||
switch aiprovider.AnalyticsClass(mode) {
|
||||
case aiprovider.ModePopular:
|
||||
b.Popular.Tokens += tokens
|
||||
b.Popular.Products += products
|
||||
case aiprovider.ModeCustom:
|
||||
b.Custom.Tokens += tokens
|
||||
b.Custom.Products += products
|
||||
default:
|
||||
b.Internal.Tokens += tokens
|
||||
b.Internal.Products += products
|
||||
}
|
||||
out[companyID] = b
|
||||
}
|
||||
|
||||
jobRows, err := s.Pool.Query(ctx, `
|
||||
SELECT company_id,
|
||||
COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
|
||||
COUNT(*)::bigint
|
||||
FROM processing_jobs
|
||||
WHERE company_id = ANY($1)
|
||||
GROUP BY company_id, 2`, companyIDs)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer jobRows.Close()
|
||||
for jobRows.Next() {
|
||||
var companyID uuid.UUID
|
||||
var mode string
|
||||
var jobs int64
|
||||
if err := jobRows.Scan(&companyID, &mode, &jobs); err != nil {
|
||||
return out
|
||||
}
|
||||
b := out[companyID]
|
||||
switch aiprovider.AnalyticsClass(mode) {
|
||||
case aiprovider.ModePopular:
|
||||
b.Popular.Jobs += jobs
|
||||
case aiprovider.ModeCustom:
|
||||
b.Custom.Jobs += jobs
|
||||
default:
|
||||
b.Internal.Jobs += jobs
|
||||
}
|
||||
out[companyID] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clampAdminAnalyticsDays(n int) int {
|
||||
if n < adminAnalyticsMinDays {
|
||||
return adminAnalyticsMinDays
|
||||
}
|
||||
if n > adminAnalyticsMaxDays {
|
||||
return adminAnalyticsMaxDays
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func fillAdminDaySeries(
|
||||
since time.Time,
|
||||
days int,
|
||||
src map[string]adminDayPoint,
|
||||
mapPoint func(adminDayPoint) adminDayPoint,
|
||||
) []adminDayPoint {
|
||||
out := make([]adminDayPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
d := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
|
||||
if p, ok := src[d]; ok {
|
||||
p.Date = d
|
||||
out = append(out, mapPoint(p))
|
||||
continue
|
||||
}
|
||||
out = append(out, mapPoint(adminDayPoint{Date: d}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user