mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import client from '../client'
|
||||
export * from './health-v2'
|
||||
import type {
|
||||
HealthStatus,
|
||||
HealthSummary,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user