feat: revamp analytics dashboards and harden database migrations

Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend.

Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users.

Include the current documentation cleanup and regression coverage.
This commit is contained in:
elky
2026-10-01 11:48:17 +08:00
parent 60b89cc840
commit 066ea87d72
327 changed files with 31728 additions and 20645 deletions
+55
View File
@@ -192,4 +192,59 @@ describe('apiClient auth state change event', () => {
rawClient.defaults.adapter = previousAdapter
}
})
it('authenticates user and admin health while public status stays anonymous', async () => {
const rawClient = apiClient['client']
const previousAdapter = rawClient.defaults.adapter
const requests: InternalAxiosRequestConfig[] = []
rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
requests.push(config)
return { data: {}, status: 200, statusText: 'OK', headers: {}, config }
}) as AxiosAdapter
try {
apiClient.setToken('health-access-token')
await apiClient.get('/api/users/me/health/v2/summary')
await apiClient.get('/api/admin/endpoints/health/v2/objects')
await apiClient.get('/api/public/health/v2/summary')
await apiClient.get('/_gateway/health')
expect(requests.map(request => request.headers.Authorization)).toEqual([
'Bearer health-access-token',
'Bearer health-access-token',
undefined,
undefined,
])
expect(requests[0].headers['X-Client-Device-Id']).toBeTruthy()
} finally {
rawClient.defaults.adapter = previousAdapter
}
})
it('keeps publication and protected JSON routes authenticated despite public-looking names or query strings', async () => {
const rawClient = apiClient['client']
const previousAdapter = rawClient.defaults.adapter
const requests: InternalAxiosRequestConfig[] = []
rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
requests.push(config)
return { data: {}, status: 200, statusText: 'OK', headers: {}, config }
}) as AxiosAdapter
try {
apiClient.setToken('publication-access-token')
await apiClient.get('/api/admin/endpoints/health/v2/publication')
await apiClient.put('/api/admin/endpoints/health/v2/publication', { enabled: false, objects: [] })
await apiClient.get('/api/admin/usage/records?search=/public/health.json')
await apiClient.get('/api/admin/reports.json')
await apiClient.get('/api/publicity')
await apiClient.get('/api/public/health/v2/summary?window=1h')
expect(requests.map(request => request.headers.Authorization)).toEqual([
'Bearer publication-access-token', 'Bearer publication-access-token',
'Bearer publication-access-token', 'Bearer publication-access-token',
'Bearer publication-access-token', undefined,
])
} finally {
rawClient.defaults.adapter = previousAdapter
}
})
})
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }))
vi.mock('@/api/client', () => ({
default: { get: getMock },
}))
import { overviewApi, type OverviewQuery } from '@/api/overview'
describe('overview dashboard API contract', () => {
beforeEach(() => {
getMock.mockReset()
})
it('loads the compact flat dashboard snapshot with timezone and cancellation', async () => {
const signal = new AbortController().signal
const response = { stats_since: '2026-09-19T00:00:00Z', today: { request_count: 3 }, total: { request_count: 42 } }
getMock.mockResolvedValueOnce({ data: response })
expect(await overviewApi.dashboardSummary('Asia/Shanghai', signal)).toBe(response)
expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard/summary', { params: { timezone: 'Asia/Shanghai' }, signal })
})
it.each([true, false, null])('loads today and lifetime totals with history_complete=%s using only the timezone', async (historyComplete) => {
const signal = new AbortController().signal
const response = {
today: { data: { request_count: 2 } },
total: { data: { request_count: 42 } },
history_complete: historyComplete,
}
getMock.mockResolvedValueOnce({ data: response })
const result = await overviewApi.dashboard('America/New_York', signal)
expect(getMock).toHaveBeenCalledTimes(1)
expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard', {
params: { timezone: 'America/New_York' },
signal,
})
expect(result).toBe(response)
})
it.each([{ status: 'pending' }, { status: 'ready', total: { data: { request_count: 42 } }, history_complete: null, stale: true }])(
'loads independently computed lifetime totals: $status', async (response) => {
const signal = new AbortController().signal
getMock.mockResolvedValueOnce({ data: response })
expect(await overviewApi.dashboardTotal('Asia/Shanghai', signal)).toBe(response)
expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard/total', {
params: { timezone: 'Asia/Shanghai' }, signal,
})
},
)
it('loads full-site daily charts without leaking filters, pagination, or hourly granularity', async () => {
const signal = new AbortController().signal
const query: OverviewQuery = {
from: '2026-03-08T05:12:34.000Z',
to: '2026-03-09T04:00:00.000Z',
timezone: 'America/New_York',
user_id: 'employee-1',
api_key_id: 'key-1',
credential_owner_id: 'owner-1',
attribution_kind: 'employee',
provider_id: 'provider-1',
model: 'model-1',
request_type: 'chat',
api_format: 'openai',
endpoint_kind: 'chat',
is_stream: false,
has_format_conversion: false,
slow_threshold_ms: 1000,
status: 'success',
search: 'employee',
account_status: 'active',
usage_status: 'active',
amount_basis: 'rated',
group_by: 'provider',
sort: 'request_count',
order: 'asc',
granularity: 'hour',
limit: 1,
offset: 10,
}
const response = {
meta: { read_revision: 'dashboard-chart-revision' },
data: {
summary: { request_count: 5 },
series: [{ bucket_start: '2026-03-08T05:00:00.000Z' }],
models: [{ id: 'model-1', bucket_start: '2026-03-08T05:00:00.000Z' }],
providers: [{ id: 'provider-1' }, { id: 'provider-2' }, { id: null }],
},
}
getMock.mockResolvedValueOnce({ data: response })
const result = await overviewApi.dashboardCharts(query, signal)
expect(getMock).toHaveBeenCalledTimes(1)
expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard/charts', {
params: {
from: query.from,
to: query.to,
timezone: query.timezone,
granularity: 'day',
},
signal,
})
expect(result).toBe(response)
})
})
+6 -4
View File
@@ -94,12 +94,13 @@ export interface RefundCompleteRequest {
export const adminWalletApi = {
async listWallets(params?: {
user_id?: string
status?: string
owner_type?: 'user' | 'api_key'
limit?: number
offset?: number
}): Promise<AdminWalletListResponse> {
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', { params })
}, signal?: AbortSignal): Promise<AdminWalletListResponse> {
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', { params, signal })
return response.data
},
@@ -183,11 +184,12 @@ export const adminWalletApi = {
async getWalletTransactions(
walletId: string,
params?: { limit?: number; offset?: number }
params?: { limit?: number; offset?: number },
signal?: AbortSignal,
): Promise<AdminWalletTransactionsResponse> {
const response = await apiClient.get<AdminWalletTransactionsResponse>(
`/api/admin/wallets/${walletId}/transactions`,
{ params }
{ params, signal }
)
return response.data
},
+9
View File
@@ -50,6 +50,15 @@ export interface UpdateAnnouncementRequest {
}
export const announcementApi = {
async getUserAnnouncements(params?: {
unread_only?: boolean
limit?: number
offset?: number
}): Promise<AnnouncementListResponse> {
const response = await apiClient.get<AnnouncementListResponse>('/api/announcements/users/me', { params })
return response.data
},
// 获取公告列表
async getAnnouncements(params?: {
active_only?: boolean
+14 -11
View File
@@ -39,15 +39,19 @@ function loadMockRuntime(): Promise<MockRuntime> {
/**
* 判断请求是否为公共端点
*/
function requestPath(url?: string): string {
if (!url) return ''
try { return new URL(url, 'http://aether.local').pathname } catch { return '' }
}
function isPublicEndpoint(url?: string, method?: string): boolean {
if (!url) return false
const path = requestPath(url)
const isHealthCheck = ['/health', '/v1/health', '/_gateway/health'].includes(path) &&
method?.toLowerCase() === 'get'
const isHealthCheck = url.includes('/health') &&
method?.toLowerCase() === 'get' &&
!url.includes('/api/admin')
return url.includes('/public') ||
url.includes('.json') ||
return path === '/api/public' || path.startsWith('/api/public/') ||
path === '/public' || path.startsWith('/public/') ||
(!path.startsWith('/api/') && path.endsWith('.json')) ||
isHealthCheck
}
@@ -55,12 +59,11 @@ function isPublicEndpoint(url?: string, method?: string): boolean {
* 判断是否为认证相关请求
*/
function isAuthRequest(url?: string): boolean {
return url?.includes('/auth/login') || url?.includes('/auth/refresh') || url?.includes('/auth/logout') || false
return ['/api/auth/login', '/api/auth/refresh', '/api/auth/logout'].includes(requestPath(url))
}
function isProtectedOperationalEndpoint(url?: string): boolean {
if (!url) return false
const path = url.split('?', 1)[0]
const path = requestPath(url)
return path === '/_gateway/metrics' ||
path.startsWith('/_gateway/audit/') ||
path.startsWith('/_gateway/async-tasks/')
@@ -148,7 +151,7 @@ class ApiClient {
// 请求拦截器 - 仅处理认证
this.client.interceptors.request.use(
(config) => {
const carriesSessionCredentials = config.url?.includes('/api/') ||
const carriesSessionCredentials = requestPath(config.url).startsWith('/api/') ||
isProtectedOperationalEndpoint(config.url)
if (carriesSessionCredentials) {
+118
View File
@@ -0,0 +1,118 @@
import client from '../client'
export type HealthObjectKind = 'api_format' | 'model' | 'provider'
export type PublicHealthObjectKind = Exclude<HealthObjectKind, 'provider'>
export type ServiceHealthStatus = 'healthy' | 'degraded' | 'unavailable' | 'unknown'
export type HealthWindow = '1h' | '6h' | '24h' | '72h'
export interface HealthRatio {
numerator: number
denominator: number
value: number | null
}
export interface PublicHealthObject {
id: string
kind: HealthObjectKind
name: string
status: ServiceHealthStatus
request_count: number
request_success: HealthRatio
service_availability: HealthRatio
coverage: {
status: 'complete' | 'partial'
sample_status: 'empty' | 'insufficient' | 'sufficient'
classified_count: number
unknown_failure_count: number
excluded_count: number
exclusion_policy: string
}
average_latency_ms: number | null
latency_sample_count: number
last_request_at: string | null
timeline: Array<{
from: string
to: string
status: ServiceHealthStatus
service_availability: HealthRatio
unknown_failure_count: number
}>
}
export interface AdminHealthObject extends PublicHealthObject {
source_value: string
attempts: {
succeeded_count: number
failed_count: number
in_progress_count: number
cancelled_count: number
success: HealthRatio
}
}
export interface HealthMeta {
schema_version: 2
metric_version: string
scope: { kind: 'published' | 'authenticated' | 'installation'; object_kind: HealthObjectKind }
range: { from: string; to: string; timezone: 'UTC'; time_basis: string }
generated_at: string
data_through: string | null
freshness: 'current' | 'stale' | 'unknown'
policy: { version: string; minimum_samples: number; healthy_threshold: number; degraded_threshold: number }
}
export interface HealthEnvelope<T> { meta: HealthMeta; data: T }
export interface HealthSummaryV2 {
status: ServiceHealthStatus
object_count: number
healthy_count: number
degraded_count: number
unavailable_count: number
unknown_count: number
requests: PublicHealthObject
}
export interface HealthObjectsPage<T> { items: T[]; total: number; limit: number; offset: number }
export interface HealthQuery { kind: HealthObjectKind; window: HealthWindow; limit?: number; offset?: number }
export type PublicHealthQuery = Omit<HealthQuery, 'kind'> & { kind: PublicHealthObjectKind }
export interface HealthPublication {
enabled: boolean
objects: Array<{ public_id: string; kind: PublicHealthObjectKind; value: string; display_name: string }>
}
const adminRoot = '/api/admin/endpoints/health/v2'
const publicRoot = '/api/public/health/v2'
const userRoot = '/api/users/me/health/v2'
export async function getAdminHealthSummary(query: HealthQuery, signal?: AbortSignal) {
return (await client.get<HealthEnvelope<HealthSummaryV2>>(`${adminRoot}/summary`, { params: query, signal })).data
}
export async function getPublicHealthSummaryV2(query: PublicHealthQuery, signal?: AbortSignal) {
return (await client.get<HealthEnvelope<HealthSummaryV2>>(`${publicRoot}/summary`, { params: query, signal })).data
}
export async function getAdminHealthObjects(query: HealthQuery, signal?: AbortSignal) {
return (await client.get<HealthEnvelope<HealthObjectsPage<AdminHealthObject>>>(`${adminRoot}/objects`, { params: query, signal })).data
}
export async function getPublicHealthObjects(query: PublicHealthQuery, signal?: AbortSignal) {
return (await client.get<HealthEnvelope<HealthObjectsPage<PublicHealthObject>>>(`${publicRoot}/objects`, { params: query, signal })).data
}
export async function getAdminHealthObject(id: string, query: HealthQuery, signal?: AbortSignal) {
return (await client.get<HealthEnvelope<AdminHealthObject>>(`${adminRoot}/objects/${encodeURIComponent(id)}`, { params: query, signal })).data
}
export async function getPublicHealthObject(id: string, query: PublicHealthQuery, signal?: AbortSignal) {
return (await client.get<HealthEnvelope<PublicHealthObject>>(`${publicRoot}/objects/${encodeURIComponent(id)}`, { params: query, signal })).data
}
export async function getUserHealthSummary(query: PublicHealthQuery, signal?: AbortSignal) {
return (await client.get<HealthEnvelope<HealthSummaryV2>>(`${userRoot}/summary`, { params: query, signal })).data
}
export async function getUserHealthObjects(query: PublicHealthQuery, signal?: AbortSignal) {
return (await client.get<HealthEnvelope<HealthObjectsPage<PublicHealthObject>>>(`${userRoot}/objects`, { params: query, signal })).data
}
export async function getUserHealthObject(id: string, query: PublicHealthQuery, signal?: AbortSignal) {
return (await client.get<HealthEnvelope<PublicHealthObject>>(`${userRoot}/objects/${encodeURIComponent(id)}`, { params: query, signal })).data
}
export async function getHealthPublication() {
return (await client.get<HealthPublication>(`${adminRoot}/publication`)).data
}
export async function saveHealthPublication(publication: HealthPublication) {
return (await client.put<HealthPublication>(`${adminRoot}/publication`, publication)).data
}
+1
View File
@@ -1,4 +1,5 @@
import client from '../client'
export * from './health-v2'
import type {
HealthStatus,
HealthSummary,
+301
View File
@@ -0,0 +1,301 @@
import apiClient from './client'
import type { GatewayMetricsSummary, AdminMonitoringResilienceStatus } from './monitoring'
import type { ProviderPerformanceResponse } from './admin'
export interface OverviewRange {
from: string
to: string
timezone: string
}
export interface OverviewQuery extends OverviewRange {
user_id?: string
api_key_id?: string
request_type?: string
credential_owner_id?: string
attribution_kind?: string
model?: string
provider_id?: string
api_format?: string
endpoint_kind?: string
is_stream?: boolean
has_format_conversion?: boolean
slow_threshold_ms?: number
status?: string
search?: string
account_status?: string
usage_status?: string
sort?: string
order?: 'asc' | 'desc'
group_by?: string
amount_basis?: string
granularity?: 'hour' | 'day'
limit?: number
offset?: number
payment_limit?: number
payment_offset?: number
}
export interface OverviewAmount {
value: string | null
currency: string
basis: string
status: 'known' | 'known_subtotal' | 'unknown' | 'estimated' | string
}
export interface OverviewMetrics {
request_count: number
successful_request_count: number
failed_request_count: number
cancelled_request_count: number
in_flight_request_count: number
unclassified_failure_count: number
input_tokens: number | null
output_tokens: number | null
total_tokens: number | null
usage_source?: 'reported' | 'estimated' | 'mixed' | 'unknown'
usage_source_counts?: { reported: number; estimated: number; mixed: number; unknown: number }
requests_per_second?: number
requests_per_minute?: number
tokens_per_minute?: number | null
window_seconds?: number
usage_active_users?: number
enabled_users?: number
success_rate: { value: number | null; numerator: number; denominator: number }
latency_ms: { avg: number | null; p50: number | null; p95: number | null; p99: number | null; sample_count: number }
rated_amount: OverviewAmount
billable_amount: OverviewAmount
quota_covered_amount: OverviewAmount
wallet_consumed_amount: OverviewAmount
wallet_debit_amount: OverviewAmount
}
export interface OverviewMeta {
schema_version: number
metric_version: string
scope: { kind: string; user_id?: string }
range: OverviewRange & { time_basis: string }
generated_at: string
data_through: string | null
read_revision: string
available_metrics?: string[]
projection?: {
projection_from: string | null
projection_through: string | null
dirty_bucket_count: number
missing_bucket_count: number
read_enabled: boolean
}
coverage: {
status: string
request_count: number
usage_available_count: number
pricing_available_count: number
settled_count: number
attribution_available_count?: number
unrecoverable_bucket_count?: number
}
}
export interface OverviewResponse<T> { meta: OverviewMeta; data: T }
export type OverviewDashboardTotals = Pick<OverviewMetrics, 'request_count' | 'total_tokens' | 'billable_amount' | 'enabled_users'>
export interface OverviewDashboard {
today: OverviewResponse<OverviewMetrics>
total: OverviewResponse<OverviewDashboardTotals>
history_complete: boolean | null
}
export type OverviewDashboardTotal = { status: 'pending' } | {
status: 'ready'
total: OverviewResponse<OverviewDashboardTotals>
history_complete: boolean | null
stale: boolean
}
export interface OverviewDashboardSummary {
stats_since: string
generated_at: string
timezone: string
today_from: string
window_seconds: number
today: {
request_count: number
input_tokens: number | null
output_tokens: number | null
total_tokens: number | null
billable_amount: OverviewAmount
active_users: number
cache_read_tokens: number | null
cache_creation_tokens: number | null
cache_input_tokens: number | null
avg_first_byte_ms: number | null
avg_response_ms: number | null
stream_requests: number
standard_requests: number
}
total: {
request_count: number
total_tokens: number | null
billable_amount: OverviewAmount
cache_read_tokens: number | null
cache_input_tokens: number | null
}
users: { total: number; created_today: number; deleted_today: number }
consecutive_active_days: number
active_days: number
activity_days: { date: string; requests: number }[]
concurrency: {
avg: number | null
peak: number | null
observed_from: string | null
observed_through: string | null
scope: 'node'
coverage: 'partial' | 'complete' | 'unavailable'
}
}
export interface OverviewPage<T> { items: T[]; total: number; limit: number; offset: number }
export interface OverviewSeriesPoint extends OverviewMetrics { bucket_start: string }
export interface OverviewBreakdown extends OverviewMetrics { id: string | null; label: string | null }
export interface OverviewDashboardCharts {
summary: OverviewMetrics
series: OverviewSeriesPoint[]
models: (OverviewBreakdown & { bucket_start: string })[]
providers: OverviewBreakdown[]
}
export interface OverviewEmployee extends OverviewMetrics {
user_id: string
username: string
email: string | null
is_active: boolean
last_used_at: string | null
active_days: number
finance?: OverviewUserFinance | null
}
export interface OverviewUserFinance {
wallet_balance: OverviewAmount
recharge_balance: OverviewAmount
gift_balance: OverviewAmount
recharge_amount: OverviewAmount
recharge_count: number
plan_purchase_amount: OverviewAmount
plan_purchase_count: number
gift_credit_amount: OverviewAmount
gift_credit_count: number
balance_time_basis: 'current'
payment_time_basis: 'credited_at'
}
export interface OverviewUserPayment {
id: string
order_no: string
kind: 'wallet_recharge' | 'plan_purchase' | 'gift_credit'
amount: OverviewAmount
payment_method: string
credited_at: string
}
export interface OverviewUsers extends OverviewPage<OverviewEmployee> {
summary?: OverviewMetrics & { user_count: number; active_user_count: number }
finance_summary?: OverviewUserFinance | null
}
export interface OverviewEmployeeDetail {
user: { id: string; username: string; email: string | null; is_active: boolean }
summary: OverviewMetrics
finance?: OverviewUserFinance | null
payments?: OverviewPage<OverviewUserPayment> | null
}
export interface OverviewConsumption {
id: string
request_id: string
started_at: string
user_id: string | null
model: string | null
provider: string | null
status: string
settlement_status: string
attribution_kind: string
rated_amount: OverviewAmount
billable_amount: OverviewAmount
quota_covered_amount: OverviewAmount
wallet_consumed_amount: OverviewAmount
wallet_debit_amount: OverviewAmount
}
export interface OverviewCosts {
summary: OverviewMetrics
timeseries?: OverviewSeriesPoint[]
supplier_estimated_cost: OverviewAmount
supplier_verified_cost: OverviewAmount
cache: { read_tokens: number | null; creation_tokens: number | null; read_cost: OverviewAmount; creation_cost: OverviewAmount; estimated_full_cost: OverviewAmount; estimated_savings: OverviewAmount; pricing_available_count?: number; request_count?: number }
forecast: { amount: OverviewAmount; method: string; status: string; sample_days: number; period_end: string | null }
}
export interface OverviewExecutionActivity {
observed_at: string
observed_from: string
window_seconds: number
observed_window_seconds: number
scope: { kind: 'node' }
coverage: 'complete' | 'partial'
providers: { provider_id: string; provider: string | null; requests_per_minute: number; current_concurrency: number }[]
models: { model: string | null; requests_per_minute: number; current_concurrency: number }[]
}
export interface OverviewLive {
observed_at: string | null
window_seconds: number | null
node_id: string | null
scope: { kind: string; node_ids?: string[] }
metrics?: GatewayMetricsSummary | null
metrics_text?: string | null
resilience: AdminMonitoringResilienceStatus | null
unavailable_sections: string[]
recent_activity?: OverviewResponse<OverviewMetrics>
execution_activity?: OverviewExecutionActivity | null
}
export interface OverviewModelPerformance {
model: string | null
request_count: number
success_count: number
error_count: number
success_rate: number | null
avg_first_byte_time_ms: number | null
avg_output_tps: number | null
avg_response_time_ms: number | null
}
export interface OverviewPerformance {
summary: OverviewMetrics
timeseries: OverviewSeriesPoint[]
providers: ProviderPerformanceResponse | null
models?: OverviewModelPerformance[]
errors: { reason: string; count: number }[]
}
async function get<T>(path: string, params?: OverviewQuery, signal?: AbortSignal): Promise<OverviewResponse<T>> {
return (await apiClient.get<OverviewResponse<T>>(`/api/admin/overview/${path}`, { params, signal })).data
}
export const overviewApi = {
async dashboardSummary(timezone: string, signal?: AbortSignal): Promise<OverviewDashboardSummary> {
return (await apiClient.get<OverviewDashboardSummary>('/api/admin/overview/dashboard/summary', { params: { timezone }, signal })).data
},
async dashboard(timezone: string, signal?: AbortSignal): Promise<OverviewDashboard> {
return (await apiClient.get<OverviewDashboard>('/api/admin/overview/dashboard', { params: { timezone }, signal })).data
},
async dashboardTotal(timezone: string, signal?: AbortSignal): Promise<OverviewDashboardTotal> {
return (await apiClient.get<OverviewDashboardTotal>('/api/admin/overview/dashboard/total', { params: { timezone }, signal })).data
},
async dashboardCharts(range: OverviewRange, signal?: AbortSignal): Promise<OverviewResponse<OverviewDashboardCharts>> {
const { from, to, timezone } = range
return (await apiClient.get<OverviewResponse<OverviewDashboardCharts>>('/api/admin/overview/dashboard/charts', { params: { from, to, timezone, granularity: 'day' }, signal })).data
},
summary: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewMetrics>('summary', query, signal),
timeseries: (query: OverviewQuery, signal?: AbortSignal) => get<{ items: OverviewSeriesPoint[]; granularity: string }>('timeseries', query, signal),
breakdown: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewPage<OverviewBreakdown>>('breakdown', query, signal),
users: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewUsers>('users', query, signal),
user: (id: string, query: OverviewQuery, signal?: AbortSignal) => get<OverviewEmployeeDetail>(`users/${encodeURIComponent(id)}`, query, signal),
consumption: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewPage<OverviewConsumption>>('consumption', query, signal),
costs: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewCosts>('costs', query, signal),
performance: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewPerformance>('operations/performance', query, signal),
live: (signal?: AbortSignal) => get<OverviewLive>('operations/live', undefined, signal),
resources: (signal?: AbortSignal) => get<OverviewLive>('operations/resources', undefined, signal),
async exportCsv(path: 'users' | 'consumption' | 'breakdown', query: OverviewQuery, signal?: AbortSignal): Promise<Blob> {
const { limit: _limit, offset: _offset, ...filters } = query
return (await apiClient.get<Blob>(`/api/admin/overview/${path}`, {
params: { ...filters, format: 'csv' }, responseType: 'blob', signal,
})).data
},
}
+97
View File
@@ -0,0 +1,97 @@
import client from './client'
import type { OverviewRange } from './overview'
export type ProviderExpenseKind = 'recharge' | 'subscription' | 'other'
export interface ProviderExpense {
id: string
provider_id: string
provider_name: string
kind: ProviderExpenseKind
amount: string
currency: string
paid_at: string
period_start: string | null
period_end: string | null
note: string | null
external_reference: string | null
status: 'recorded' | 'void'
created_at: string
created_by: string | null
voided_at: string | null
}
export interface ProviderExpenseInput {
client_request_id: string
provider_id: string
kind: ProviderExpenseKind
amount: string
currency: string
paid_at: string
period_start?: string | null
period_end?: string | null
note?: string | null
external_reference?: string | null
}
export interface ProviderExpenseTotals {
currency: string
amount: string
entry_count: number
recharge_amount: string
subscription_amount: string
other_amount: string
}
export interface ProviderExpenses {
items: ProviderExpense[]
total: number
limit: number
offset: number
totals: ProviderExpenseTotals[]
providers: { provider_id: string; provider_name: string; currency: string; amount: string; entry_count: number }[]
}
export interface ProviderAccountSubscription {
group_name: string | null
status: string | null
daily_used_usd: number | null
daily_limit_usd: number | null
weekly_used_usd: number | null
weekly_limit_usd: number | null
monthly_used_usd: number | null
monthly_limit_usd: number | null
expires_at: string | null
}
export interface ProviderAccount {
provider_id: string
provider_name: string
is_active: boolean
billing_type: string | null
quota: { limit: number | string | null; used: number | string | null; remaining: number | string | null; currency: string; period_start: string | null; expires_at: string | null } | null
balance: {
status: string
observed_at: string | null
currency: string | null
available: number | string | null
used: number | string | null
granted: number | string | null
plan_name: string | null
subscriptions: ProviderAccountSubscription[]
} | null
}
export interface ProviderAccounts { observed_at: string; items: ProviderAccount[] }
export type ProviderExpensesQuery = OverviewRange & { limit?: number; offset?: number }
const base = '/api/admin/billing'
export const providerFinanceApi = {
async accounts(signal?: AbortSignal): Promise<ProviderAccounts> {
return (await client.get<ProviderAccounts>(`${base}/provider-accounts`, { signal })).data
},
async expenses(params: ProviderExpensesQuery, signal?: AbortSignal): Promise<ProviderExpenses> {
return (await client.get<ProviderExpenses>(`${base}/provider-expenses`, { params, signal })).data
},
async record(input: ProviderExpenseInput): Promise<{ item: ProviderExpense }> {
return (await client.post<{ item: ProviderExpense }>(`${base}/provider-expenses`, input)).data
},
async void(id: string): Promise<{ item: ProviderExpense }> {
return (await client.post<{ item: ProviderExpense }>(`${base}/provider-expenses/${encodeURIComponent(id)}/void`, {})).data
},
async exportExpenses(range: OverviewRange, signal?: AbortSignal): Promise<Blob> {
return (await client.get<Blob>(`${base}/provider-expenses`, { params: { ...range, format: 'csv' }, responseType: 'blob', signal })).data
},
}
+29 -1
View File
@@ -133,6 +133,8 @@ export interface UsageByApiFormat {
}
export interface UsageFilters {
from?: string
to?: string
user_id?: string // UUID
provider_id?: string // UUID
model?: string
@@ -499,6 +501,17 @@ export const usageApi = {
},
async getAllUsageRecords(params?: {
from?: string
to?: string
provider_id?: string
api_key_id?: string
request_id?: string
attribution_kind?: string
endpoint_kind?: string
request_type?: string
is_stream?: boolean
has_format_conversion?: boolean
slow_threshold_ms?: number
start_date?: string
end_date?: string
preset?: string
@@ -539,6 +552,17 @@ export const usageApi = {
},
async getAllUsageRecordTotal(params?: {
from?: string
to?: string
provider_id?: string
api_key_id?: string
request_id?: string
attribution_kind?: string
endpoint_kind?: string
request_type?: string
is_stream?: boolean
has_format_conversion?: boolean
slow_threshold_ms?: number
start_date?: string
end_date?: string
preset?: string
@@ -576,7 +600,7 @@ export const usageApi = {
*/
async getActiveRequests(
ids?: string[],
timeRange?: Pick<UsageFilters, 'start_date' | 'end_date' | 'preset' | 'timezone' | 'tz_offset_minutes'>
timeRange?: Pick<UsageFilters, 'from' | 'to' | 'start_date' | 'end_date' | 'preset' | 'timezone' | 'tz_offset_minutes'>
): Promise<{
requests: Array<{
id: string
@@ -629,6 +653,10 @@ export const usageApi = {
if (ids?.length) {
params.ids = ids.join(',')
}
if (timeRange?.from && timeRange.to) {
params.from = timeRange.from
params.to = timeRange.to
}
if (timeRange?.start_date) {
params.start_date = timeRange.start_date
}
+7 -2
View File
@@ -430,9 +430,14 @@ export const usersApi = {
return response.data
},
async listUserPlanEntitlements(userId: string): Promise<AdminUserPlanEntitlementsResponse> {
async listUserPlanEntitlements(
userId: string,
options: { include_inactive?: boolean } = {},
signal?: AbortSignal,
): Promise<AdminUserPlanEntitlementsResponse> {
const response = await apiClient.get<AdminUserPlanEntitlementsResponse>(
`/api/admin/users/${userId}/billing/entitlements`
`/api/admin/users/${userId}/billing/entitlements`,
{ params: options, signal },
)
return response.data
},