From 3a8385b6f426f89c51b1ef04c4804e50a53e1705 Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 13 Jun 2026 23:56:19 +0800 Subject: [PATCH] feat: expose user billing usage summary --- internal/cloud/server.ts | 52 ++++++ internal/cloud/user-billing-client.ts | 27 ++- .../cloud/user-billing-integration.test.ts | 31 +++- internal/userbilling/service.go | 175 ++++++++++++++++++ web/hwlab-cloud-web/src/styles/workbench.css | 33 +++- web/hwlab-cloud-web/src/types/index.ts | 17 +- .../src/views/user/UsageView.vue | 148 ++++++++++++++- 7 files changed, 468 insertions(+), 15 deletions(-) diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index f5396455..181b7235 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -314,6 +314,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/usage/summary" && request.method === "GET") { + await handleUsageSummaryHttp(request, response, url, options); + return; + } + if (request.method === "GET" && url.pathname === "/v1") { const dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe); const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options); @@ -936,6 +941,53 @@ async function codeAgentOptions(request, response, options, authOptions = {}) { }; } +async function handleUsageSummaryHttp(request, response, url, options) { + const nextOptions = await codeAgentOptions(request, response, options, { required: true }); + if (!nextOptions) return; + const client = nextOptions.userBillingClient; + const token = bearerTokenFromRequest(request); + if (nextOptions.userBillingAuth?.active !== true || !token) { + sendRestError(request, response, 403, "user_billing_auth_required", "usage summary requires a user-billing bearer token", { + operation: "GET /v1/usage/summary", + target: { type: "route", id: "/v1/usage/summary" }, + result: "rejected" + }); + return; + } + if (!client?.configured || typeof client.billingSummary !== "function") { + sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing summary is not configured", { + operation: "GET /v1/usage/summary", + target: { type: "service", id: "hwlab-user-billing" } + }); + return; + } + const limit = parsePositiveInteger(url.searchParams.get("limit"), 20); + const result = await client.billingSummary(token, { limit: Math.min(limit, 100) }); + if (!result.ok) { + sendRestError(request, response, result.status ?? 502, result.error?.code ?? "user_billing_summary_failed", result.error?.message ?? "user-billing summary request failed", { + operation: "GET /v1/usage/summary", + target: { type: "service", id: "hwlab-user-billing" }, + reason: result.error?.code, + result: "failed" + }); + return; + } + sendJson(response, 200, { + ...(result.body ?? {}), + proxy: { + serviceId: CLOUD_API_SERVICE_ID, + route: "/v1/usage/summary", + source: "hwlab-user-billing", + valuesRedacted: true + } + }); +} + +function bearerTokenFromRequest(request) { + const header = String(getHeader(request, "authorization") ?? "").trim(); + return header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : ""; +} + async function readJsonObject(request, limitBytes) { const body = await readBody(request, limitBytes); try { diff --git a/internal/cloud/user-billing-client.ts b/internal/cloud/user-billing-client.ts index a746be18..38650941 100644 --- a/internal/cloud/user-billing-client.ts +++ b/internal/cloud/user-billing-client.ts @@ -9,21 +9,26 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch } const internalToken = text(env.HWLAB_USER_BILLING_INTERNAL_TOKEN); const timeoutMs = positiveInteger(env.HWLAB_USER_BILLING_TIMEOUT_MS, DEFAULT_TIMEOUT_MS); - async function post(path, body = {}) { + async function requestJson(path, { method = "GET", body = null, bearerToken = "" } = {}) { if (!baseUrl) { return { ok: false, status: 503, error: { code: "user_billing_not_configured", message: "HWLAB user-billing URL is not configured" } }; } const headers = { - accept: "application/json", - "content-type": "application/json" + accept: "application/json" }; + if (body !== null) headers["content-type"] = "application/json"; + const token = text(bearerToken); + if (token) headers.authorization = `Bearer ${token}`; if (internalToken) headers["x-hwlab-internal-token"] = internalToken; try { - const response = await fetchImpl(`${baseUrl}${path}`, { - method: "POST", + const init = { + method, headers, - body: JSON.stringify(body), - signal: AbortSignal.timeout(timeoutMs) + signal: AbortSignal.timeout(timeoutMs), + ...(body !== null ? { body: JSON.stringify(body) } : {}) + }; + const response = await fetchImpl(`${baseUrl}${path}`, { + ...init }); const raw = await response.text(); const payload = parseJson(raw, null); @@ -48,6 +53,10 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch } } } + async function post(path, body = {}) { + return requestJson(path, { method: "POST", body }); + } + return { configured: Boolean(baseUrl), baseUrl, @@ -59,6 +68,10 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch } }, async billingRecord(body) { return post("/internal/billing/record", body); + }, + async billingSummary(token, { limit = 20 } = {}) { + const query = new URLSearchParams({ limit: String(limit) }); + return requestJson(`/v1/billing/summary?${query.toString()}`, { method: "GET", bearerToken: token }); } }; } diff --git a/internal/cloud/user-billing-integration.test.ts b/internal/cloud/user-billing-integration.test.ts index e794f3c4..68b319c7 100644 --- a/internal/cloud/user-billing-integration.test.ts +++ b/internal/cloud/user-billing-integration.test.ts @@ -47,6 +47,25 @@ test("cloud api accepts user-billing API keys and records Code Agent billing usa assert.equal(body.usedCredits, 1); assert.equal(body.idempotencyKey, "code-agent:trc_user_billing_agent:record"); return { ok: true, status: 200, body: { recordId: "use_user_billing_agent", credits: 1, balance: 99 } }; + }, + async billingSummary(token, options = {}) { + calls.push({ op: "summary", tokenPrefix: token.slice(0, 8), options }); + assert.equal(token, "hwl_user_billing_test_secret"); + assert.equal(options.limit, 20); + return { + ok: true, + status: 200, + body: { + contractVersion: "user-billing-summary-v1", + status: "ok", + credits: { balance: 99, reserved: 0, available: 99 }, + usage: { totalCredits: 1, totalQuantity: 1200, recordCount: 1, byService: [{ serviceId: "hwlab-code-agent", credits: 1, quantity: 1200, recordCount: 1, lastUsedAt: "2026-06-13T12:00:00.000Z" }] }, + ledger: { count: 1, limit: 20, rows: [{ id: "led_user_billing_agent", kind: "usage", reason: "hwlab-code-agent", deltaCredits: -1, balanceAfter: 99, createdAt: "2026-06-13T12:00:00.000Z" }] }, + reservations: { activeCount: 0, active: [] }, + state: { stateless: true, stateAuthority: "pk01-postgres", redis: { role: "cache-only" } }, + valuesRedacted: true + } + }; } }; @@ -128,7 +147,17 @@ test("cloud api accepts user-billing API keys and records Code Agent billing usa assert.equal(result.billing.recorded, true); assert.equal(result.billing.reservationId, "res_user_billing_agent"); assert.equal(result.billing.recordId, "use_user_billing_agent"); - assert.deepEqual(calls.map((call) => call.op), ["introspect", "introspect", "introspect", "preflight", "record", "introspect"]); + const summaryResponse = await fetch(`http://127.0.0.1:${port}/v1/usage/summary`, { headers: authHeader }); + assert.equal(summaryResponse.status, 200); + const summary = await summaryResponse.json(); + assert.equal(summary.contractVersion, "user-billing-summary-v1"); + assert.equal(summary.credits.available, 99); + assert.equal(summary.usage.byService[0].serviceId, "hwlab-code-agent"); + assert.equal(summary.state.stateAuthority, "pk01-postgres"); + assert.equal(summary.state.redis.role, "cache-only"); + assert.equal(summary.proxy.source, "hwlab-user-billing"); + assert.equal(JSON.stringify(summary).includes("hwl_user_billing_test_secret"), false); + assert.deepEqual(calls.map((call) => call.op), ["introspect", "introspect", "introspect", "preflight", "record", "introspect", "introspect", "summary"]); assert.equal(JSON.stringify(result).includes("hwl_user_billing_test_secret"), false); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); diff --git a/internal/userbilling/service.go b/internal/userbilling/service.go index 3ea556da..6e13fe14 100644 --- a/internal/userbilling/service.go +++ b/internal/userbilling/service.go @@ -78,6 +78,33 @@ type Principal struct { KeyID string `json:"keyId,omitempty"` } +type serviceUsageSummary struct { + ServiceID string `json:"serviceId"` + Credits int64 `json:"credits"` + Quantity int64 `json:"quantity"` + RecordCount int64 `json:"recordCount"` + LastUsedAt time.Time `json:"lastUsedAt"` +} + +type ledgerSummaryRow struct { + ID string `json:"id"` + Kind string `json:"kind"` + Reason string `json:"reason"` + DeltaCredits int64 `json:"deltaCredits"` + BalanceAfter int64 `json:"balanceAfter"` + CreatedAt time.Time `json:"createdAt"` +} + +type reservationSummaryRow struct { + ReservationID string `json:"reservationId"` + ServiceID string `json:"serviceId"` + EstimatedCredits int64 `json:"estimatedCredits"` + EstimatedTokens int64 `json:"estimatedTokens"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expiresAt"` + CreatedAt time.Time `json:"createdAt"` +} + type apiError struct { Code string `json:"code"` Message string `json:"message"` @@ -189,6 +216,7 @@ func (s *Server) routes() { s.route(http.MethodPost, "/v1/auth/logout", s.handleLogout) s.route(http.MethodPost, "/v1/auth/refresh", s.handleRefresh) s.route(http.MethodGet, "/v1/me", s.handleMe) + s.route(http.MethodGet, "/v1/billing/summary", s.handleBillingSummary) s.route(http.MethodPost, "/v1/api-keys", s.handleCreateAPIKey) s.route(http.MethodPost, "/internal/auth/introspect", s.handleIntrospect) s.route(http.MethodPost, "/internal/billing/preflight", s.handleBillingPreflight) @@ -435,6 +463,81 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"principal": principal, "credits": map[string]any{"balance": balance, "reserved": reserved, "available": balance - reserved}}) } +func (s *Server) handleBillingSummary(w http.ResponseWriter, r *http.Request) { + principal, ok := s.requirePrincipal(w, r) + if !ok { + return + } + limit := queryLimit(r, "limit", 20, 100) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + balance, reserved, err := s.accountBalance(ctx, principal.UserID) + if err != nil { + s.logDatabaseError("billing_summary_account", err) + writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load credit account") + return + } + totalCredits, totalQuantity, recordCount, err := s.usageTotals(ctx, principal.UserID) + if err != nil { + s.logDatabaseError("billing_summary_usage_totals", err) + writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load usage totals") + return + } + byService, err := s.usageByService(ctx, principal.UserID, 20) + if err != nil { + s.logDatabaseError("billing_summary_usage_by_service", err) + writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load service usage") + return + } + ledgerRows, err := s.ledgerRows(ctx, principal.UserID, limit) + if err != nil { + s.logDatabaseError("billing_summary_ledger", err) + writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load credit ledger") + return + } + reservations, err := s.activeReservations(ctx, principal.UserID, 20) + if err != nil { + s.logDatabaseError("billing_summary_reservations", err) + writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load active reservations") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "contractVersion": "user-billing-summary-v1", + "serviceId": serviceID, + "status": "ok", + "principal": principal, + "credits": map[string]any{ + "balance": balance, + "reserved": reserved, + "available": balance - reserved, + }, + "usage": map[string]any{ + "totalCredits": totalCredits, + "totalQuantity": totalQuantity, + "recordCount": recordCount, + "byService": byService, + }, + "ledger": map[string]any{ + "count": len(ledgerRows), + "limit": limit, + "rows": ledgerRows, + }, + "reservations": map[string]any{ + "activeCount": len(reservations), + "active": reservations, + }, + "state": map[string]any{ + "stateless": true, + "stateAuthority": s.config.StateAuthority, + "database": map[string]any{"authority": s.config.ExternalDatabaseLabel}, + "redis": map[string]any{"role": "cache-only"}, + }, + "valuesRedacted": true, + }) +} + func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { principal, ok := s.requirePrincipal(w, r) if !ok { @@ -940,6 +1043,78 @@ func (s *Server) accountBalance(ctx context.Context, userID string) (int64, int6 return balance, reserved, err } +func (s *Server) usageTotals(ctx context.Context, userID string) (int64, int64, int64, error) { + var credits, quantity, count int64 + err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(credits), 0), COALESCE(SUM(quantity), 0), COUNT(*) FROM hwlab_usage_records WHERE user_id = $1`, userID).Scan(&credits, &quantity, &count) + return credits, quantity, count, err +} + +func (s *Server) usageByService(ctx context.Context, userID string, limit int) ([]serviceUsageSummary, error) { + rows, err := s.db.QueryContext(ctx, `SELECT service_id, COALESCE(SUM(credits), 0), COALESCE(SUM(quantity), 0), COUNT(*), MAX(created_at) FROM hwlab_usage_records WHERE user_id = $1 GROUP BY service_id ORDER BY MAX(created_at) DESC NULLS LAST, service_id ASC LIMIT $2`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []serviceUsageSummary{} + for rows.Next() { + var item serviceUsageSummary + if err := rows.Scan(&item.ServiceID, &item.Credits, &item.Quantity, &item.RecordCount, &item.LastUsedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Server) ledgerRows(ctx context.Context, userID string, limit int) ([]ledgerSummaryRow, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, kind, reason, delta_credits, balance_after, created_at FROM hwlab_credit_ledger WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ledgerSummaryRow{} + for rows.Next() { + var item ledgerSummaryRow + if err := rows.Scan(&item.ID, &item.Kind, &item.Reason, &item.DeltaCredits, &item.BalanceAfter, &item.CreatedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Server) activeReservations(ctx context.Context, userID string, limit int) ([]reservationSummaryRow, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, service_id, estimated_credits, estimated_tokens, status, expires_at, created_at FROM hwlab_billing_reservations WHERE user_id = $1 AND status = 'reserved' AND expires_at > now() ORDER BY expires_at ASC, created_at DESC LIMIT $2`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []reservationSummaryRow{} + for rows.Next() { + var item reservationSummaryRow + if err := rows.Scan(&item.ReservationID, &item.ServiceID, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func queryLimit(r *http.Request, name string, fallback, max int) int { + value := strings.TrimSpace(r.URL.Query().Get(name)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return fallback + } + if parsed > max { + return max + } + return parsed +} + func (s *Server) estimateCredits(explicit, tokens int64) int64 { if explicit > 0 { return explicit diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index bbacc227..9a1c8a5b 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -628,6 +628,26 @@ font-size: 13px; } +.usage-metrics .metric-card strong { + color: #0f172a; + font-size: 28px; + line-height: 1.15; +} + +.usage-panels { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.usage-panel { + display: grid; + min-width: 0; + gap: 12px; + padding: 14px; + overflow: auto; +} + .data-table { width: 100%; border-collapse: collapse; @@ -644,6 +664,16 @@ text-align: left; } +.usage-table th, +.usage-table td { + white-space: nowrap; +} + +.usage-table code { + color: #0f766e; + font-size: 12px; +} + .toast-host { position: fixed; right: 16px; @@ -680,7 +710,8 @@ .workbench-grid, .overview-grid, - .settings-grid { + .settings-grid, + .usage-panels { grid-template-columns: 1fr; } } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index a2f6487d..f6646e39 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -242,7 +242,22 @@ export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: u export interface AdminAccessMatrixResponse { user?: AuthUser; tools?: Array>; [key: string]: unknown } export interface AdminAccessMutationResponse { ok?: boolean; user?: AuthUser; [key: string]: unknown } export interface ApiKeyRecord { id: string; name?: string; prefix?: string; createdAt?: string; revokedAt?: string | null } -export interface UsageSummary { status?: string; rows?: Array>; [key: string]: unknown } +export interface UsageServiceSummary { serviceId: string; credits?: number; quantity?: number; recordCount?: number; lastUsedAt?: string } +export interface UsageLedgerRow { id: string; kind?: string; reason?: string; deltaCredits?: number; balanceAfter?: number; createdAt?: string } +export interface UsageReservationRow { reservationId: string; serviceId?: string; estimatedCredits?: number; estimatedTokens?: number; status?: string; expiresAt?: string; createdAt?: string } +export interface UsageSummary { + status?: string; + contractVersion?: string; + rows?: Array>; + credits?: { balance?: number; reserved?: number; available?: number }; + usage?: { totalCredits?: number; totalQuantity?: number; recordCount?: number; byService?: UsageServiceSummary[] }; + ledger?: { count?: number; limit?: number; rows?: UsageLedgerRow[] }; + reservations?: { activeCount?: number; active?: UsageReservationRow[] }; + state?: { stateless?: boolean; stateAuthority?: string; database?: Record; redis?: Record }; + proxy?: Record; + valuesRedacted?: boolean; + [key: string]: unknown; +} export interface Toast { id: string; diff --git a/web/hwlab-cloud-web/src/views/user/UsageView.vue b/web/hwlab-cloud-web/src/views/user/UsageView.vue index c376caff..b3fcc6a2 100644 --- a/web/hwlab-cloud-web/src/views/user/UsageView.vue +++ b/web/hwlab-cloud-web/src/views/user/UsageView.vue @@ -1,21 +1,159 @@