diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 181b7235..042a7760 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -319,6 +319,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/admin/billing/summary" && request.method === "GET") { + await handleAdminBillingSummaryHttp(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); @@ -983,6 +988,62 @@ async function handleUsageSummaryHttp(request, response, url, options) { }); } +async function handleAdminBillingSummaryHttp(request, response, url, options) { + const auth = await options.accessController.authenticate(request, { required: true }); + if (!auth.ok) { + sendJson(response, auth.status, auth); + return; + } + if (auth.actor?.role !== "admin") { + sendRestError(request, response, 403, "admin_required", "Only admin users can read billing summaries", { + operation: "GET /v1/admin/billing/summary", + target: { type: "route", id: "/v1/admin/billing/summary" }, + result: "rejected" + }); + return; + } + const client = options.userBillingClient ?? options.accessController?.userBilling ?? null; + if (!client?.configured || typeof client.adminBillingSummary !== "function") { + sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin summary is not configured", { + operation: "GET /v1/admin/billing/summary", + target: { type: "service", id: "hwlab-user-billing" } + }); + return; + } + const limit = parsePositiveInteger(url.searchParams.get("limit"), 50); + const result = await client.adminBillingSummary({ limit: Math.min(limit, 100) }); + if (!result.ok) { + sendRestError(request, response, result.status ?? 502, result.error?.code ?? "user_billing_admin_summary_failed", result.error?.message ?? "user-billing admin summary request failed", { + operation: "GET /v1/admin/billing/summary", + target: { type: "service", id: "hwlab-user-billing" }, + reason: result.error?.code, + result: "failed" + }); + return; + } + sendJson(response, 200, { + ...(result.body ?? {}), + actor: adminActorSummary(auth.actor), + proxy: { + serviceId: CLOUD_API_SERVICE_ID, + route: "/v1/admin/billing/summary", + source: "hwlab-user-billing", + valuesRedacted: true + } + }); +} + +function adminActorSummary(actor) { + return actor ? { + id: String(actor.id ?? ""), + username: String(actor.username ?? ""), + displayName: String(actor.displayName ?? ""), + role: String(actor.role ?? ""), + status: String(actor.status ?? ""), + valuesRedacted: true + } : null; +} + function bearerTokenFromRequest(request) { const header = String(getHeader(request, "authorization") ?? "").trim(); return header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : ""; diff --git a/internal/cloud/user-billing-client.ts b/internal/cloud/user-billing-client.ts index 38650941..f1267189 100644 --- a/internal/cloud/user-billing-client.ts +++ b/internal/cloud/user-billing-client.ts @@ -72,6 +72,10 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch } async billingSummary(token, { limit = 20 } = {}) { const query = new URLSearchParams({ limit: String(limit) }); return requestJson(`/v1/billing/summary?${query.toString()}`, { method: "GET", bearerToken: token }); + }, + async adminBillingSummary({ limit = 50 } = {}) { + const query = new URLSearchParams({ limit: String(limit) }); + return requestJson(`/internal/admin/billing/summary?${query.toString()}`, { method: "GET" }); } }; } diff --git a/internal/cloud/user-billing-integration.test.ts b/internal/cloud/user-billing-integration.test.ts index 68b319c7..52dd6973 100644 --- a/internal/cloud/user-billing-integration.test.ts +++ b/internal/cloud/user-billing-integration.test.ts @@ -66,6 +66,31 @@ test("cloud api accepts user-billing API keys and records Code Agent billing usa valuesRedacted: true } }; + }, + async adminBillingSummary(options = {}) { + calls.push({ op: "adminSummary", options }); + assert.equal(options.limit, 50); + return { + ok: true, + status: 200, + body: { + contractVersion: "user-billing-admin-summary-v1", + status: "ok", + count: 1, + limit: 50, + totals: { users: 1, activeUsers: 1, balanceCredits: 99, usageCredits: 1, activeReservations: 0 }, + users: [{ + user: { id: "usr_user_billing_agent", email: "agent@hwlab.local", username: "billing-agent", displayName: "Billing Agent", role: "user", status: "active" }, + planId: "default", + credits: { balance: 99, reserved: 0, available: 99 }, + usage: { totalCredits: 1, totalQuantity: 1200, recordCount: 1, lastUsedAt: "2026-06-13T12:00:00.000Z" }, + apiKeys: { activeCount: 1, totalCount: 1, lastUsedAt: "2026-06-13T12:00:00.000Z" }, + reservations: { activeCount: 0, active: [] } + }], + state: { stateless: true, stateAuthority: "pk01-postgres", redis: { role: "cache-only" } }, + valuesRedacted: true + } + }; } }; @@ -157,7 +182,11 @@ test("cloud api accepts user-billing API keys and records Code Agent billing usa 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"]); + + const adminDenied = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/summary`, { headers: authHeader }); + assert.equal(adminDenied.status, 403); + + assert.deepEqual(calls.map((call) => call.op), ["introspect", "introspect", "introspect", "preflight", "record", "introspect", "introspect", "summary", "introspect"]); 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()))); @@ -179,3 +208,69 @@ async function pollUserBillingAgentResult(port, traceId, headers) { } throw new Error(`Code Agent result did not complete: ${JSON.stringify(last)}`); } + +test("cloud api proxies admin billing summary for web admins without exposing user-billing secrets", async () => { + const calls = []; + const userBillingClient = { + configured: true, + async adminBillingSummary(options = {}) { + calls.push({ op: "adminSummary", options }); + assert.equal(options.limit, 50); + return { + ok: true, + status: 200, + body: { + contractVersion: "user-billing-admin-summary-v1", + status: "ok", + count: 1, + limit: 50, + totals: { users: 1, activeUsers: 1, balanceCredits: 25, reservedCredits: 5, availableCredits: 20, usageCredits: 3, activeReservations: 1 }, + users: [{ + user: { id: "usr_billing_admin_subject", email: "subject@hwlab.local", username: "billing-subject", displayName: "Billing Subject", role: "user", status: "active" }, + planId: "default", + credits: { balance: 25, reserved: 5, available: 20 }, + usage: { totalCredits: 3, totalQuantity: 2048, recordCount: 2, lastUsedAt: "2026-06-13T13:00:00.000Z" }, + apiKeys: { activeCount: 1, totalCount: 2, lastUsedAt: "2026-06-13T12:30:00.000Z" }, + reservations: { activeCount: 1, active: [{ reservationId: "res_admin_billing", serviceId: "hwlab-hwpod", estimatedCredits: 5, estimatedTokens: 0, status: "reserved", expiresAt: "2026-06-13T13:30:00.000Z", createdAt: "2026-06-13T13:00:00.000Z" }] } + }], + state: { stateless: true, stateAuthority: "pk01-postgres", redis: { role: "cache-only" } }, + valuesRedacted: true + } + }; + } + }; + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-password-1127" + }, + userBillingClient + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const login = await fetch(`http://127.0.0.1:${port}/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "admin-password-1127" }) + }); + assert.equal(login.status, 200); + const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? ""; + assert.match(cookie, /^hwlab_session=/u); + const response = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/summary`, { headers: { cookie } }); + assert.equal(response.status, 200); + const summary = await response.json(); + assert.equal(summary.contractVersion, "user-billing-admin-summary-v1"); + assert.equal(summary.users[0].user.id, "usr_billing_admin_subject"); + assert.equal(summary.users[0].credits.available, 20); + assert.equal(summary.users[0].reservations.active[0].serviceId, "hwlab-hwpod"); + 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("admin-password-1127"), false); + assert.deepEqual(calls.map((call) => call.op), ["adminSummary"]); + } 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 6e13fe14..798b0912 100644 --- a/internal/userbilling/service.go +++ b/internal/userbilling/service.go @@ -105,6 +105,39 @@ type reservationSummaryRow struct { CreatedAt time.Time `json:"createdAt"` } +type adminBillingUserSummary struct { + User User `json:"user"` + PlanID string `json:"planId"` + Credits creditSummary `json:"credits"` + Usage userUsageAggregate `json:"usage"` + APIKeys apiKeyAggregate `json:"apiKeys"` + Reservations reservationAdminSummary `json:"reservations"` +} + +type creditSummary struct { + Balance int64 `json:"balance"` + Reserved int64 `json:"reserved"` + Available int64 `json:"available"` +} + +type userUsageAggregate struct { + TotalCredits int64 `json:"totalCredits"` + TotalQuantity int64 `json:"totalQuantity"` + RecordCount int64 `json:"recordCount"` + LastUsedAt *time.Time `json:"lastUsedAt"` +} + +type apiKeyAggregate struct { + ActiveCount int64 `json:"activeCount"` + TotalCount int64 `json:"totalCount"` + LastUsedAt *time.Time `json:"lastUsedAt"` +} + +type reservationAdminSummary struct { + ActiveCount int64 `json:"activeCount"` + Active []reservationSummaryRow `json:"active"` +} + type apiError struct { Code string `json:"code"` Message string `json:"message"` @@ -221,6 +254,7 @@ func (s *Server) routes() { s.route(http.MethodPost, "/internal/auth/introspect", s.handleIntrospect) s.route(http.MethodPost, "/internal/billing/preflight", s.handleBillingPreflight) s.route(http.MethodPost, "/internal/billing/record", s.handleBillingRecord) + s.route(http.MethodGet, "/internal/admin/billing/summary", s.handleAdminBillingSummary) s.route(http.MethodPost, "/internal/admin/credits/adjust", s.handleAdminCreditAdjust) s.route(http.MethodPost, "/internal/admin/users/status", s.handleAdminUserStatus) } @@ -717,6 +751,44 @@ func (s *Server) handleAdminUserStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"userId": req.UserID, "status": req.Status}) } +func (s *Server) handleAdminBillingSummary(w http.ResponseWriter, r *http.Request) { + if !s.internalAllowed(w, r) || !s.databaseAvailable(w) { + return + } + limit := queryLimit(r, "limit", 50, 100) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + users, err := s.adminBillingUsers(ctx, limit) + if err != nil { + s.logDatabaseError("admin_billing_summary_users", err) + writeAPIError(w, http.StatusInternalServerError, "admin_billing_summary_failed", "could not load admin billing summary") + return + } + totals, err := s.adminBillingTotals(ctx) + if err != nil { + s.logDatabaseError("admin_billing_summary_totals", err) + writeAPIError(w, http.StatusInternalServerError, "admin_billing_summary_failed", "could not load admin billing totals") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "contractVersion": "user-billing-admin-summary-v1", + "serviceId": serviceID, + "status": "ok", + "users": users, + "count": len(users), + "limit": limit, + "totals": totals, + "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, + }) +} + var errInsufficientCredits = errors.New("insufficient credits") func (s *Server) reserveCredits(ctx context.Context, userID, service string, credits, tokens int64, idempotencyKey string, metadata map[string]any) (map[string]any, error) { @@ -1100,6 +1172,128 @@ func (s *Server) activeReservations(ctx context.Context, userID string, limit in return items, rows.Err() } +func (s *Server) adminBillingUsers(ctx context.Context, limit int) ([]adminBillingUserSummary, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT + u.id, + u.email, + u.username, + u.display_name, + u.status, + u.role, + u.email_verified, + u.created_at, + COALESCE(a.plan_id, 'default'), + COALESCE(a.balance_credits, 0), + COALESCE(a.reserved_credits, 0), + COALESCE(usage.total_credits, 0), + COALESCE(usage.total_quantity, 0), + COALESCE(usage.record_count, 0), + usage.last_used_at, + COALESCE(keys.active_count, 0), + COALESCE(keys.total_count, 0), + keys.last_key_used_at, + COALESCE(res.active_count, 0) +FROM hwlab_users u +LEFT JOIN hwlab_credit_accounts a ON a.user_id = u.id +LEFT JOIN ( + SELECT user_id, SUM(credits) AS total_credits, SUM(quantity) AS total_quantity, COUNT(*) AS record_count, MAX(created_at) AS last_used_at + FROM hwlab_usage_records + GROUP BY user_id +) usage ON usage.user_id = u.id +LEFT JOIN ( + SELECT user_id, COUNT(*) FILTER (WHERE status = 'active' AND revoked_at IS NULL) AS active_count, COUNT(*) AS total_count, MAX(last_used_at) AS last_key_used_at + FROM hwlab_api_keys + GROUP BY user_id +) keys ON keys.user_id = u.id +LEFT JOIN ( + SELECT user_id, COUNT(*) AS active_count + FROM hwlab_billing_reservations + WHERE status = 'reserved' AND expires_at > now() + GROUP BY user_id +) res ON res.user_id = u.id +ORDER BY usage.last_used_at DESC NULLS LAST, u.created_at DESC, u.username ASC +LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + users := []adminBillingUserSummary{} + for rows.Next() { + var item adminBillingUserSummary + var lastUsed sql.NullTime + var lastKeyUsed sql.NullTime + if err := rows.Scan(&item.User.ID, &item.User.Email, &item.User.Username, &item.User.DisplayName, &item.User.Status, &item.User.Role, &item.User.EmailVerified, &item.User.CreatedAt, &item.PlanID, &item.Credits.Balance, &item.Credits.Reserved, &item.Usage.TotalCredits, &item.Usage.TotalQuantity, &item.Usage.RecordCount, &lastUsed, &item.APIKeys.ActiveCount, &item.APIKeys.TotalCount, &lastKeyUsed, &item.Reservations.ActiveCount); err != nil { + return nil, err + } + if lastUsed.Valid { + value := lastUsed.Time + item.Usage.LastUsedAt = &value + } + if lastKeyUsed.Valid { + value := lastKeyUsed.Time + item.APIKeys.LastUsedAt = &value + } + item.Credits.Available = item.Credits.Balance - item.Credits.Reserved + reservations, err := s.activeReservations(ctx, item.User.ID, 5) + if err != nil { + return nil, err + } + item.Reservations.Active = reservations + users = append(users, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return users, nil +} + +func (s *Server) adminBillingTotals(ctx context.Context) (map[string]any, error) { + var totals struct { + Users int64 + ActiveUsers int64 + DisabledUsers int64 + PendingUsers int64 + BalanceCredits int64 + ReservedCredits int64 + AvailableCredits int64 + UsageCredits int64 + UsageQuantity int64 + UsageRecords int64 + ActiveReservations int64 + } + err := s.db.QueryRowContext(ctx, ` +SELECT + (SELECT COUNT(*) FROM hwlab_users), + (SELECT COUNT(*) FROM hwlab_users WHERE status = 'active'), + (SELECT COUNT(*) FROM hwlab_users WHERE status = 'disabled'), + (SELECT COUNT(*) FROM hwlab_users WHERE status = 'pending'), + COALESCE((SELECT SUM(balance_credits) FROM hwlab_credit_accounts), 0), + COALESCE((SELECT SUM(reserved_credits) FROM hwlab_credit_accounts), 0), + COALESCE((SELECT SUM(balance_credits - reserved_credits) FROM hwlab_credit_accounts), 0), + COALESCE((SELECT SUM(credits) FROM hwlab_usage_records), 0), + COALESCE((SELECT SUM(quantity) FROM hwlab_usage_records), 0), + (SELECT COUNT(*) FROM hwlab_usage_records), + (SELECT COUNT(*) FROM hwlab_billing_reservations WHERE status = 'reserved' AND expires_at > now())`).Scan(&totals.Users, &totals.ActiveUsers, &totals.DisabledUsers, &totals.PendingUsers, &totals.BalanceCredits, &totals.ReservedCredits, &totals.AvailableCredits, &totals.UsageCredits, &totals.UsageQuantity, &totals.UsageRecords, &totals.ActiveReservations) + if err != nil { + return nil, err + } + return map[string]any{ + "users": totals.Users, + "activeUsers": totals.ActiveUsers, + "disabledUsers": totals.DisabledUsers, + "pendingUsers": totals.PendingUsers, + "balanceCredits": totals.BalanceCredits, + "reservedCredits": totals.ReservedCredits, + "availableCredits": totals.AvailableCredits, + "usageCredits": totals.UsageCredits, + "usageQuantity": totals.UsageQuantity, + "usageRecords": totals.UsageRecords, + "activeReservations": totals.ActiveReservations, + }, nil +} + func queryLimit(r *http.Request, name string, fallback, max int) int { value := strings.TrimSpace(r.URL.Query().Get(name)) if value == "" { diff --git a/web/hwlab-cloud-web/src/api/usage.ts b/web/hwlab-cloud-web/src/api/usage.ts index e71778df..45436fe2 100644 --- a/web/hwlab-cloud-web/src/api/usage.ts +++ b/web/hwlab-cloud-web/src/api/usage.ts @@ -1,7 +1,8 @@ import { fetchJson } from "./client"; -import type { ApiResult, UsageSummary } from "@/types"; +import type { AdminBillingSummary, ApiResult, UsageSummary } from "@/types"; export const usageAPI = { summary: (): Promise> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }), + adminBillingSummary: (): Promise> => fetchJson("/v1/admin/billing/summary", { timeoutMs: 12000, timeoutName: "admin billing summary" }), performance: (): Promise> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }) }; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 9a1c8a5b..1bb6088d 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -674,6 +674,49 @@ font-size: 12px; } +.admin-billing-panel { + overflow-x: auto; +} + +.admin-billing-table td { + vertical-align: top; +} + +.admin-billing-table td > strong, +.admin-billing-table td > small { + display: block; +} + +.admin-billing-table td > small { + margin-top: 3px; + color: #64748b; + font-size: 12px; +} + +.status-pill { + display: inline-flex; + align-items: center; + border: 1px solid #cbd5e1; + border-radius: 999px; + padding: 3px 8px; + background: #f8fafc; + color: #334155; + font-size: 12px; + font-weight: 700; +} + +.status-pill[data-status="active"] { + border-color: #99f6e4; + background: #ccfbf1; + color: #0f766e; +} + +.status-pill[data-status="disabled"] { + border-color: #fecaca; + background: #fee2e2; + color: #991b1b; +} + .toast-host { position: fixed; right: 16px; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index f6646e39..044335c7 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -259,6 +259,28 @@ export interface UsageSummary { [key: string]: unknown; } +export interface AdminBillingUserSummary { + user?: AuthUser & { email?: string; emailVerified?: boolean; createdAt?: string }; + planId?: string; + credits?: { balance?: number; reserved?: number; available?: number }; + usage?: { totalCredits?: number; totalQuantity?: number; recordCount?: number; lastUsedAt?: string | null }; + apiKeys?: { activeCount?: number; totalCount?: number; lastUsedAt?: string | null }; + reservations?: { activeCount?: number; active?: UsageReservationRow[] }; +} + +export interface AdminBillingSummary { + status?: string; + contractVersion?: string; + count?: number; + limit?: number; + totals?: Record; + users?: AdminBillingUserSummary[]; + state?: { stateless?: boolean; stateAuthority?: string; database?: Record; redis?: Record }; + proxy?: Record; + valuesRedacted?: boolean; + [key: string]: unknown; +} + export interface Toast { id: string; type: "success" | "error" | "info" | "warning"; diff --git a/web/hwlab-cloud-web/src/views/admin/UsersView.vue b/web/hwlab-cloud-web/src/views/admin/UsersView.vue index 86d75657..3290d57b 100644 --- a/web/hwlab-cloud-web/src/views/admin/UsersView.vue +++ b/web/hwlab-cloud-web/src/views/admin/UsersView.vue @@ -1,11 +1,118 @@