Merge pull request #1159 from pikasTech/feat/1127-usage-summary
feat: expose user billing usage summary
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,22 @@ export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: u
|
||||
export interface AdminAccessMatrixResponse { user?: AuthUser; tools?: Array<Record<string, unknown>>; [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<Record<string, unknown>>; [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<Record<string, unknown>>;
|
||||
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<string, unknown>; redis?: Record<string, unknown> };
|
||||
proxy?: Record<string, unknown>;
|
||||
valuesRedacted?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
|
||||
@@ -1,21 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { usageAPI } from "@/api";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import type { UsageSummary } from "@/types";
|
||||
|
||||
const summary = ref<UsageSummary | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const credits = computed(() => summary.value?.credits ?? {});
|
||||
const usage = computed(() => summary.value?.usage ?? {});
|
||||
const services = computed(() => usage.value.byService ?? []);
|
||||
const ledgerRows = computed(() => summary.value?.ledger?.rows ?? []);
|
||||
const reservations = computed(() => summary.value?.reservations?.active ?? []);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
const response = await usageAPI.summary();
|
||||
summary.value = response.data;
|
||||
if (!response.ok) {
|
||||
error.value = response.error ?? `HTTP ${response.status}`;
|
||||
summary.value = null;
|
||||
} else {
|
||||
summary.value = response.data;
|
||||
error.value = null;
|
||||
}
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
function formatNumber(value: unknown): string {
|
||||
const number = Number(value ?? 0);
|
||||
if (!Number.isFinite(number)) return "0";
|
||||
return new Intl.NumberFormat("zh-CN").format(number);
|
||||
}
|
||||
|
||||
function formatSignedCredits(value: unknown): string {
|
||||
const number = Number(value ?? 0);
|
||||
const sign = number > 0 ? "+" : "";
|
||||
return `${sign}${formatNumber(number)}`;
|
||||
}
|
||||
|
||||
function formatDate(value: unknown): string {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return "-";
|
||||
const date = new Date(text);
|
||||
if (Number.isNaN(date.getTime())) return text;
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="route-stack">
|
||||
<PageHeader eyebrow="User" title="用量与配额" description="为后续订阅式配额、账单和资源消费留下 Sub2API 风格页面。" />
|
||||
<EmptyState v-if="!summary" title="用量接口待接入" description="当前只保留同源 API 扩展位,不在前端制造 fixture。" />
|
||||
<pre v-else class="json-panel">{{ JSON.stringify(summary, null, 2) }}</pre>
|
||||
<PageHeader eyebrow="User" title="用量与配额" description="PK01 PostgreSQL ledger 派生的余额、消费和预留。" />
|
||||
<EmptyState v-if="loading" title="正在加载用量" description="正在读取用户账务 summary。" />
|
||||
<EmptyState v-else-if="error" title="用量接口不可用" :description="error" />
|
||||
<EmptyState v-else-if="!summary" title="暂无用量数据" description="当前账号还没有账务 summary。" />
|
||||
<template v-else>
|
||||
<div class="overview-grid usage-metrics">
|
||||
<article class="metric-card">
|
||||
<span>可用 credit</span>
|
||||
<strong>{{ formatNumber(credits.available) }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>余额</span>
|
||||
<strong>{{ formatNumber(credits.balance) }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>已预留</span>
|
||||
<strong>{{ formatNumber(credits.reserved) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="usage-panels">
|
||||
<section class="data-panel usage-panel">
|
||||
<header class="panel-header">
|
||||
<h2>服务用量</h2>
|
||||
<small>{{ formatNumber(usage.recordCount) }} records / {{ formatNumber(usage.totalCredits) }} credits</small>
|
||||
</header>
|
||||
<table v-if="services.length" class="data-table usage-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Credits</th>
|
||||
<th>Quantity</th>
|
||||
<th>Records</th>
|
||||
<th>Last used</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in services" :key="item.serviceId">
|
||||
<td><code>{{ item.serviceId }}</code></td>
|
||||
<td>{{ formatNumber(item.credits) }}</td>
|
||||
<td>{{ formatNumber(item.quantity) }}</td>
|
||||
<td>{{ formatNumber(item.recordCount) }}</td>
|
||||
<td>{{ formatDate(item.lastUsedAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted-box">暂无服务消费记录。</p>
|
||||
</section>
|
||||
|
||||
<section class="data-panel usage-panel">
|
||||
<header class="panel-header">
|
||||
<h2>最近账务</h2>
|
||||
<small>{{ formatNumber(summary.ledger?.count) }} rows</small>
|
||||
</header>
|
||||
<table v-if="ledgerRows.length" class="data-table usage-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Kind</th>
|
||||
<th>Reason</th>
|
||||
<th>Delta</th>
|
||||
<th>Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in ledgerRows" :key="row.id">
|
||||
<td>{{ formatDate(row.createdAt) }}</td>
|
||||
<td>{{ row.kind }}</td>
|
||||
<td><code>{{ row.reason }}</code></td>
|
||||
<td>{{ formatSignedCredits(row.deltaCredits) }}</td>
|
||||
<td>{{ formatNumber(row.balanceAfter) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted-box">暂无 ledger 记录。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="data-panel usage-panel">
|
||||
<header class="panel-header">
|
||||
<h2>Active reservations</h2>
|
||||
<small>{{ formatNumber(summary.reservations?.activeCount) }} reserved</small>
|
||||
</header>
|
||||
<table v-if="reservations.length" class="data-table usage-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reservation</th>
|
||||
<th>Service</th>
|
||||
<th>Credits</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reservations" :key="item.reservationId">
|
||||
<td><code>{{ item.reservationId }}</code></td>
|
||||
<td><code>{{ item.serviceId }}</code></td>
|
||||
<td>{{ formatNumber(item.estimatedCredits) }}</td>
|
||||
<td>{{ formatDate(item.expiresAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted-box">当前没有 active reservation。</p>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user