mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 18:59:50 +08:00
feat(analytics): 重构统计分析模块,统一 API 与前端视图
close #260 - 新增 `src/services/analytics/query_service.py`,集中实现排行榜、性能、时间序列等查询逻辑 - 新增 `src/api/analytics/routes.py`,替代原 `stats/` 和 `dashboard/` 的分散路由 - 删除旧 `src/api/admin/stats/`、`src/api/dashboard/` 模块 - 重构 `src/api/user_me/routes.py` 与 `src/api/admin/usage/routes.py`,精简用量查询接口 - 新增 Alembic 迁移,修正 token 语义字段 - 前端新增 Analytics.vue、LeaderboardTab、PerformanceTab、ReportsTab 及 Reports 用户视图 - 新增 composables(useAnalyticsFilters、useReportsData、useLeaderboardData、usePerformanceData) - 新增工具函数:analyticsGranularity、analyticsTimeseries、chartTheme、csvExport、usageBreakdown - 删除旧 CostAnalysis、PerformanceAnalysis、UserStats 页面及相关组件 - 前端 API 层重组:新增 analytics.ts、request-details.ts,删除 dashboard.ts 和 usage.ts Co-authored-by: NyaDoo <[email protected]>
This commit is contained in:
@@ -0,0 +1,191 @@
|
|||||||
|
"""usage token semantics v2
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6a7b8
|
||||||
|
Revises: c9d8e7f6a5b4
|
||||||
|
Create Date: 2026-03-24 14:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c3d4e5f6a7b8"
|
||||||
|
down_revision: str | None = "c9d8e7f6a5b4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if column_exists("usage", "total_tokens") and not column_exists("usage", "input_output_total_tokens"):
|
||||||
|
with op.batch_alter_table("usage") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"total_tokens",
|
||||||
|
new_column_name="input_output_total_tokens",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table("usage") as batch_op:
|
||||||
|
if not column_exists("usage", "input_context_tokens"):
|
||||||
|
batch_op.add_column(sa.Column("input_context_tokens", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "total_tokens"):
|
||||||
|
batch_op.add_column(sa.Column("total_tokens", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "cache_creation_cost_usd_5m"):
|
||||||
|
batch_op.add_column(sa.Column("cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "cache_creation_cost_usd_1h"):
|
||||||
|
batch_op.add_column(sa.Column("cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "actual_cache_creation_cost_usd_5m"):
|
||||||
|
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "actual_cache_creation_cost_usd_1h"):
|
||||||
|
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "actual_cache_cost_usd"):
|
||||||
|
batch_op.add_column(sa.Column("actual_cache_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "cache_creation_price_per_1m_5m"):
|
||||||
|
batch_op.add_column(sa.Column("cache_creation_price_per_1m_5m", sa.Numeric(20, 8), nullable=True))
|
||||||
|
if not column_exists("usage", "cache_creation_price_per_1m_1h"):
|
||||||
|
batch_op.add_column(sa.Column("cache_creation_price_per_1m_1h", sa.Numeric(20, 8), nullable=True))
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
batch_size = 5000
|
||||||
|
while True:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE usage
|
||||||
|
SET
|
||||||
|
input_output_total_tokens = COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)),
|
||||||
|
input_context_tokens = COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0),
|
||||||
|
total_tokens = COALESCE(input_output_total_tokens, 0)
|
||||||
|
+ COALESCE(cache_creation_input_tokens, 0)
|
||||||
|
+ COALESCE(cache_read_input_tokens, 0),
|
||||||
|
cache_creation_cost_usd_5m = CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
|
cache_creation_cost_usd_1h = CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
|
actual_cache_creation_cost_usd_5m = CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
|
actual_cache_creation_cost_usd_1h = CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
|
actual_cache_cost_usd = COALESCE(actual_cache_creation_cost_usd, 0) + COALESCE(actual_cache_read_cost_usd, 0),
|
||||||
|
cache_creation_price_per_1m_5m = CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||||
|
THEN cache_creation_price_per_1m
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
cache_creation_price_per_1m_1h = CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||||
|
THEN cache_creation_price_per_1m
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
cache_cost_usd = COALESCE(cache_creation_cost_usd, 0) + COALESCE(cache_read_cost_usd, 0)
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM usage
|
||||||
|
WHERE input_context_tokens = 0 AND total_tokens = 0
|
||||||
|
LIMIT :batch_size
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"batch_size": batch_size},
|
||||||
|
)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
batch_size = 5000
|
||||||
|
while True:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE usage
|
||||||
|
SET total_tokens = COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM usage
|
||||||
|
WHERE total_tokens != COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||||
|
LIMIT :batch_size
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"batch_size": batch_size},
|
||||||
|
)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
with op.batch_alter_table("usage") as batch_op:
|
||||||
|
if column_exists("usage", "cache_creation_price_per_1m_1h"):
|
||||||
|
batch_op.drop_column("cache_creation_price_per_1m_1h")
|
||||||
|
if column_exists("usage", "cache_creation_price_per_1m_5m"):
|
||||||
|
batch_op.drop_column("cache_creation_price_per_1m_5m")
|
||||||
|
if column_exists("usage", "actual_cache_cost_usd"):
|
||||||
|
batch_op.drop_column("actual_cache_cost_usd")
|
||||||
|
if column_exists("usage", "actual_cache_creation_cost_usd_1h"):
|
||||||
|
batch_op.drop_column("actual_cache_creation_cost_usd_1h")
|
||||||
|
if column_exists("usage", "actual_cache_creation_cost_usd_5m"):
|
||||||
|
batch_op.drop_column("actual_cache_creation_cost_usd_5m")
|
||||||
|
if column_exists("usage", "cache_creation_cost_usd_1h"):
|
||||||
|
batch_op.drop_column("cache_creation_cost_usd_1h")
|
||||||
|
if column_exists("usage", "cache_creation_cost_usd_5m"):
|
||||||
|
batch_op.drop_column("cache_creation_cost_usd_5m")
|
||||||
|
if column_exists("usage", "input_context_tokens"):
|
||||||
|
batch_op.drop_column("input_context_tokens")
|
||||||
|
if column_exists("usage", "total_tokens"):
|
||||||
|
batch_op.drop_column("total_tokens")
|
||||||
|
if column_exists("usage", "input_output_total_tokens"):
|
||||||
|
batch_op.alter_column(
|
||||||
|
"input_output_total_tokens",
|
||||||
|
new_column_name="total_tokens",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
@@ -380,102 +380,6 @@ export interface AdminApiKeysResponse {
|
|||||||
skip: number
|
skip: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaderboardItem {
|
|
||||||
rank: number
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
value: number
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeaderboardResponse {
|
|
||||||
items: LeaderboardItem[]
|
|
||||||
total: number
|
|
||||||
metric: string
|
|
||||||
start_date?: string | null
|
|
||||||
end_date?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CostForecastResponse {
|
|
||||||
history: Array<{ date: string; total_cost: number }>
|
|
||||||
forecast: Array<{ date: string; total_cost: number }>
|
|
||||||
slope: number
|
|
||||||
intercept: number
|
|
||||||
start_date: string
|
|
||||||
end_date: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CostSavingsResponse {
|
|
||||||
cache_read_tokens: number
|
|
||||||
cache_read_cost: number
|
|
||||||
cache_creation_cost: number
|
|
||||||
estimated_full_cost: number
|
|
||||||
cache_savings: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuotaUsageProvider {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
quota_usd: number
|
|
||||||
used_usd: number
|
|
||||||
remaining_usd: number
|
|
||||||
usage_percent: number
|
|
||||||
quota_expires_at?: string | null
|
|
||||||
estimated_exhaust_at?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuotaUsageResponse {
|
|
||||||
providers: QuotaUsageProvider[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PercentileItem {
|
|
||||||
date: string
|
|
||||||
p50_response_time_ms?: number | null
|
|
||||||
p90_response_time_ms?: number | null
|
|
||||||
p99_response_time_ms?: number | null
|
|
||||||
p50_first_byte_time_ms?: number | null
|
|
||||||
p90_first_byte_time_ms?: number | null
|
|
||||||
p99_first_byte_time_ms?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorDistributionItem {
|
|
||||||
category: string
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorTrendItem {
|
|
||||||
date: string
|
|
||||||
total: number
|
|
||||||
categories: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorDistributionResponse {
|
|
||||||
distribution: ErrorDistributionItem[]
|
|
||||||
trend: ErrorTrendItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComparisonMetric {
|
|
||||||
total_requests: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
actual_total_cost: number
|
|
||||||
avg_response_time_ms: number
|
|
||||||
error_requests: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComparisonResponse {
|
|
||||||
current: ComparisonMetric
|
|
||||||
comparison: ComparisonMetric
|
|
||||||
change_percent: Record<string, number | null>
|
|
||||||
current_start: string
|
|
||||||
current_end: string
|
|
||||||
comparison_start: string
|
|
||||||
comparison_end: string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface ApiKeyToggleResponse {
|
export interface ApiKeyToggleResponse {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
@@ -753,197 +657,6 @@ export const adminApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stats / Leaderboards
|
|
||||||
async getLeaderboardUsers(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
metric?: 'requests' | 'tokens' | 'cost'
|
|
||||||
order?: 'asc' | 'desc'
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
provider_name?: string
|
|
||||||
model?: string
|
|
||||||
include_inactive?: boolean
|
|
||||||
exclude_admin?: boolean
|
|
||||||
}): Promise<LeaderboardResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getLeaderboardApiKeys(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
metric?: 'requests' | 'tokens' | 'cost'
|
|
||||||
order?: 'asc' | 'desc'
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
provider_name?: string
|
|
||||||
model?: string
|
|
||||||
include_inactive?: boolean
|
|
||||||
exclude_admin?: boolean
|
|
||||||
}): Promise<LeaderboardResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:api-keys', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getLeaderboardModels(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
metric?: 'requests' | 'tokens' | 'cost'
|
|
||||||
order?: 'asc' | 'desc'
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
provider_name?: string
|
|
||||||
model?: string
|
|
||||||
}): Promise<LeaderboardResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:models', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCostForecast(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
days?: number
|
|
||||||
forecast_days?: number
|
|
||||||
}): Promise<CostForecastResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:cost:forecast', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCostSavings(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
provider_name?: string
|
|
||||||
model?: string
|
|
||||||
}): Promise<CostSavingsResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:cost:savings', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getQuotaUsage(): Promise<QuotaUsageResponse> {
|
|
||||||
return cachedRequest(
|
|
||||||
'admin:stats:providers:quota-usage',
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getPercentiles(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
}): Promise<PercentileItem[]> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:performance:percentiles', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getErrorDistribution(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
}): Promise<ErrorDistributionResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:errors:distribution', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getComparison(params: {
|
|
||||||
current_start: string
|
|
||||||
current_end: string
|
|
||||||
comparison_type?: 'period' | 'year'
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
}): Promise<ComparisonResponse> {
|
|
||||||
const response = await apiClient.get<ComparisonResponse>('/api/admin/stats/comparison', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 数据清空
|
// 数据清空
|
||||||
purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'),
|
purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'),
|
||||||
purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'),
|
purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'),
|
||||||
@@ -952,26 +665,4 @@ export const adminApi = {
|
|||||||
purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'),
|
purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'),
|
||||||
purgeStats: () => purge<{ message: string }>('stats'),
|
purgeStats: () => purge<{ message: string }>('stats'),
|
||||||
|
|
||||||
async getTimeSeries(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
user_id?: string
|
|
||||||
model?: string
|
|
||||||
provider_name?: string
|
|
||||||
}): Promise<Array<Record<string, unknown>>> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:time-series', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||||
|
import type { ActivityHeatmap } from '@/types/activity'
|
||||||
|
|
||||||
|
export type AnalyticsScopeKind = 'global' | 'me' | 'user' | 'api_key'
|
||||||
|
export type AnalyticsGranularity = 'hour' | 'day' | 'week' | 'month'
|
||||||
|
export type AnalyticsBreakdownDimension = 'model' | 'provider' | 'api_format' | 'api_key' | 'user'
|
||||||
|
export type AnalyticsBreakdownMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd' | 'actual_total_cost_usd'
|
||||||
|
export type AnalyticsLeaderboardEntity = 'user' | 'api_key'
|
||||||
|
export type AnalyticsLeaderboardMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd'
|
||||||
|
|
||||||
|
export interface AnalyticsScope {
|
||||||
|
kind: AnalyticsScopeKind
|
||||||
|
user_id?: string | null
|
||||||
|
api_key_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTimeRange {
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
preset?: string
|
||||||
|
granularity?: AnalyticsGranularity
|
||||||
|
timezone?: string | null
|
||||||
|
tz_offset_minutes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFilters {
|
||||||
|
user_ids?: string[]
|
||||||
|
provider_names?: string[]
|
||||||
|
models?: string[]
|
||||||
|
target_models?: string[]
|
||||||
|
api_key_ids?: string[]
|
||||||
|
api_formats?: string[]
|
||||||
|
request_types?: string[]
|
||||||
|
statuses?: string[]
|
||||||
|
error_categories?: string[]
|
||||||
|
is_stream?: boolean | null
|
||||||
|
has_format_conversion?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsBaseRequest {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
time_range: AnalyticsTimeRange
|
||||||
|
filters?: AnalyticsFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
requests_total: number
|
||||||
|
requests_success: number
|
||||||
|
requests_error: number
|
||||||
|
requests_stream: number
|
||||||
|
success_rate: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
input_output_total_tokens: number
|
||||||
|
cache_creation_input_tokens: number
|
||||||
|
cache_creation_input_tokens_5m: number
|
||||||
|
cache_creation_input_tokens_1h: number
|
||||||
|
cache_read_input_tokens: number
|
||||||
|
input_context_tokens: number
|
||||||
|
total_tokens: number
|
||||||
|
cache_hit_rate: number
|
||||||
|
input_cost_usd: number
|
||||||
|
output_cost_usd: number
|
||||||
|
cache_creation_cost_usd: number
|
||||||
|
cache_creation_cost_usd_5m: number
|
||||||
|
cache_creation_cost_usd_1h: number
|
||||||
|
cache_read_cost_usd: number
|
||||||
|
cache_cost_usd: number
|
||||||
|
request_cost_usd: number
|
||||||
|
total_cost_usd: number
|
||||||
|
actual_total_cost_usd: number
|
||||||
|
actual_cache_cost_usd: number
|
||||||
|
avg_response_time_ms: number
|
||||||
|
avg_first_byte_time_ms: number
|
||||||
|
format_conversion_count: number
|
||||||
|
models_used_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsCompositionSegment {
|
||||||
|
key: string
|
||||||
|
value: number
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsOverviewResponse {
|
||||||
|
query_context: {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
time_range: AnalyticsTimeRange
|
||||||
|
}
|
||||||
|
summary: AnalyticsSummary
|
||||||
|
composition: {
|
||||||
|
token_segments: AnalyticsCompositionSegment[]
|
||||||
|
cost_segments: AnalyticsCompositionSegment[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTimeseriesBucket extends AnalyticsSummary {
|
||||||
|
bucket_start: string
|
||||||
|
bucket_end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTimeseriesResponse {
|
||||||
|
buckets: AnalyticsTimeseriesBucket[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsBreakdownRequest extends AnalyticsBaseRequest {
|
||||||
|
dimension: AnalyticsBreakdownDimension
|
||||||
|
metric?: AnalyticsBreakdownMetric
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsBreakdownRow extends AnalyticsSummary {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
share_of_total_cost: number
|
||||||
|
share_of_total_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsBreakdownResponse {
|
||||||
|
dimension: AnalyticsBreakdownDimension
|
||||||
|
metric: AnalyticsBreakdownMetric
|
||||||
|
rows: AnalyticsBreakdownRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsRecordsRequest extends AnalyticsBaseRequest {
|
||||||
|
search?: {
|
||||||
|
text?: string | null
|
||||||
|
request_id?: string | null
|
||||||
|
}
|
||||||
|
pagination?: {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsRecord {
|
||||||
|
id: string
|
||||||
|
request_id: string
|
||||||
|
created_at: string | null
|
||||||
|
user_id: string | null
|
||||||
|
username: string | null
|
||||||
|
api_key_id: string | null
|
||||||
|
api_key_name: string | null
|
||||||
|
provider_api_key_name: string | null
|
||||||
|
provider_name: string | null
|
||||||
|
model: string
|
||||||
|
target_model: string | null
|
||||||
|
api_format: string | null
|
||||||
|
request_type: string | null
|
||||||
|
status: string
|
||||||
|
billing_status: string
|
||||||
|
is_stream: boolean
|
||||||
|
has_format_conversion: boolean | null
|
||||||
|
has_fallback?: boolean
|
||||||
|
has_retry?: boolean
|
||||||
|
status_code: number | null
|
||||||
|
error_message: string | null
|
||||||
|
error_category: string | null
|
||||||
|
response_time_ms: number | null
|
||||||
|
first_byte_time_ms: number | null
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
input_output_total_tokens: number
|
||||||
|
cache_creation_input_tokens: number
|
||||||
|
cache_creation_input_tokens_5m: number
|
||||||
|
cache_creation_input_tokens_1h: number
|
||||||
|
cache_read_input_tokens: number
|
||||||
|
input_context_tokens: number
|
||||||
|
total_tokens: number
|
||||||
|
input_cost_usd: number
|
||||||
|
output_cost_usd: number
|
||||||
|
cache_creation_cost_usd: number
|
||||||
|
cache_creation_cost_usd_5m: number
|
||||||
|
cache_creation_cost_usd_1h: number
|
||||||
|
cache_read_cost_usd: number
|
||||||
|
cache_cost_usd: number
|
||||||
|
request_cost_usd: number
|
||||||
|
total_cost_usd: number
|
||||||
|
actual_total_cost_usd: number
|
||||||
|
actual_cache_cost_usd: number
|
||||||
|
rate_multiplier: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsRecordsResponse {
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
records: AnalyticsRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFilterOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFilterOptionsResponse {
|
||||||
|
providers: AnalyticsFilterOption[]
|
||||||
|
models: AnalyticsFilterOption[]
|
||||||
|
target_models: AnalyticsFilterOption[]
|
||||||
|
api_formats: AnalyticsFilterOption[]
|
||||||
|
request_types: AnalyticsFilterOption[]
|
||||||
|
error_categories: AnalyticsFilterOption[]
|
||||||
|
statuses: AnalyticsFilterOption[]
|
||||||
|
users?: AnalyticsFilterOption[]
|
||||||
|
api_keys?: AnalyticsFilterOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsLeaderboardRequest extends AnalyticsBaseRequest {
|
||||||
|
entity: AnalyticsLeaderboardEntity
|
||||||
|
metric: AnalyticsLeaderboardMetric
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsLeaderboardItem {
|
||||||
|
rank: number
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
requests_total: number
|
||||||
|
total_tokens: number
|
||||||
|
total_cost_usd: number
|
||||||
|
actual_total_cost_usd: number
|
||||||
|
metric_value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsLeaderboardResponse {
|
||||||
|
entity: AnalyticsLeaderboardEntity
|
||||||
|
metric: AnalyticsLeaderboardMetric
|
||||||
|
items: AnalyticsLeaderboardItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsPercentilePoint {
|
||||||
|
date: string
|
||||||
|
p50_response_time_ms: number | null
|
||||||
|
p90_response_time_ms: number | null
|
||||||
|
p99_response_time_ms: number | null
|
||||||
|
p50_first_byte_time_ms: number | null
|
||||||
|
p90_first_byte_time_ms: number | null
|
||||||
|
p99_first_byte_time_ms: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsErrorCategory {
|
||||||
|
category: string
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsErrorTrendItem {
|
||||||
|
date: string
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsProviderHealthItem {
|
||||||
|
provider_name: string
|
||||||
|
requests_total: number
|
||||||
|
success_rate: number
|
||||||
|
error_rate: number
|
||||||
|
avg_response_time_ms: number
|
||||||
|
avg_first_byte_time_ms: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsPerformanceResponse {
|
||||||
|
latency: {
|
||||||
|
response_time_ms: {
|
||||||
|
avg: number
|
||||||
|
p50: number | null
|
||||||
|
p90: number | null
|
||||||
|
p99: number | null
|
||||||
|
}
|
||||||
|
first_byte_time_ms: {
|
||||||
|
avg: number
|
||||||
|
p50: number | null
|
||||||
|
p90: number | null
|
||||||
|
p99: number | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
percentiles: AnalyticsPercentilePoint[]
|
||||||
|
errors: {
|
||||||
|
total: number
|
||||||
|
rate: number
|
||||||
|
categories: AnalyticsErrorCategory[]
|
||||||
|
trend: AnalyticsErrorTrendItem[]
|
||||||
|
}
|
||||||
|
provider_health: AnalyticsProviderHealthItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsActiveRequest {
|
||||||
|
id: string
|
||||||
|
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
cache_creation_input_tokens?: number | null
|
||||||
|
cache_read_input_tokens?: number | null
|
||||||
|
cost?: number
|
||||||
|
total_cost_usd?: number
|
||||||
|
actual_cost?: number | null
|
||||||
|
actual_total_cost_usd?: number | null
|
||||||
|
rate_multiplier?: number | null
|
||||||
|
response_time_ms: number | null
|
||||||
|
first_byte_time_ms: number | null
|
||||||
|
provider?: string | null
|
||||||
|
provider_name?: string | null
|
||||||
|
api_key_name?: string | null
|
||||||
|
api_format?: string | null
|
||||||
|
endpoint_api_format?: string | null
|
||||||
|
has_format_conversion?: boolean | null
|
||||||
|
target_model?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsActiveRequestsResponse {
|
||||||
|
requests: AnalyticsActiveRequest[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analyticsApi = {
|
||||||
|
async getOverview(payload: AnalyticsBaseRequest): Promise<AnalyticsOverviewResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsOverviewResponse>('/api/analytics/overview', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTimeseries(payload: AnalyticsBaseRequest): Promise<AnalyticsTimeseriesResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsTimeseriesResponse>('/api/analytics/timeseries', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBreakdown(payload: AnalyticsBreakdownRequest): Promise<AnalyticsBreakdownResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsBreakdownResponse>('/api/analytics/breakdown', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecords(payload: AnalyticsRecordsRequest): Promise<AnalyticsRecordsResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsRecordsResponse>('/api/analytics/records', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFilterOptions(payload: AnalyticsBaseRequest): Promise<AnalyticsFilterOptionsResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsFilterOptionsResponse>('/api/analytics/filter-options', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLeaderboard(payload: AnalyticsLeaderboardRequest): Promise<AnalyticsLeaderboardResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsLeaderboardResponse>('/api/analytics/leaderboard', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPerformance(payload: AnalyticsBaseRequest): Promise<AnalyticsPerformanceResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsPerformanceResponse>('/api/analytics/performance', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHeatmap(payload: {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
user_id?: string | null
|
||||||
|
api_key_id?: string | null
|
||||||
|
}): Promise<ActivityHeatmap> {
|
||||||
|
const cacheKey = buildCacheKey('analytics:heatmap', payload as Record<string, unknown>)
|
||||||
|
return cachedRequest(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const response = await apiClient.post<ActivityHeatmap>('/api/analytics/heatmap', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
60000,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActiveRequests(payload: {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
ids?: string[]
|
||||||
|
}): Promise<AnalyticsActiveRequestsResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsActiveRequestsResponse>('/api/analytics/active-requests', {
|
||||||
|
...payload,
|
||||||
|
ids: payload.ids ?? [],
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async analyzeCacheAffinityTTL(payload: {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
user_id?: string | null
|
||||||
|
api_key_id?: string | null
|
||||||
|
hours?: number
|
||||||
|
}): Promise<Record<string, unknown>> {
|
||||||
|
const response = await apiClient.post('/api/analytics/cache-affinity/ttl-analysis', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async analyzeCacheAffinityHit(payload: {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
user_id?: string | null
|
||||||
|
api_key_id?: string | null
|
||||||
|
hours?: number
|
||||||
|
}): Promise<Record<string, unknown>> {
|
||||||
|
const response = await apiClient.post('/api/analytics/cache-affinity/hit-analysis', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -33,6 +33,203 @@ export interface AuditFilters {
|
|||||||
offset?: number
|
offset?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MonitoringMetricStatus = 'ok' | 'warning' | 'danger' | 'degraded' | 'error' | 'unknown'
|
||||||
|
|
||||||
|
export interface MonitoringCpuMetric {
|
||||||
|
status: MonitoringMetricStatus
|
||||||
|
label: string
|
||||||
|
usage_percent: number | null
|
||||||
|
load_percent: number | null
|
||||||
|
core_count: number
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringMemoryMetric {
|
||||||
|
status: MonitoringMetricStatus
|
||||||
|
label: string
|
||||||
|
used_percent: number | null
|
||||||
|
used_bytes: number | null
|
||||||
|
available_bytes: number | null
|
||||||
|
total_bytes: number | null
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringServiceMetric {
|
||||||
|
status: MonitoringMetricStatus
|
||||||
|
label: string
|
||||||
|
latency_ms: number | null
|
||||||
|
memory_status?: MonitoringMetricStatus
|
||||||
|
memory_label?: string | null
|
||||||
|
used_memory_bytes?: number | null
|
||||||
|
peak_memory_bytes?: number | null
|
||||||
|
maxmemory_bytes?: number | null
|
||||||
|
memory_ceiling_bytes?: number | null
|
||||||
|
memory_source?: 'configured' | 'maxmemory' | 'system' | 'unknown'
|
||||||
|
available_memory_bytes?: number | null
|
||||||
|
memory_percent?: number | null
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringPostgresMetric {
|
||||||
|
status: MonitoringMetricStatus
|
||||||
|
label: string
|
||||||
|
usage_percent: number | null
|
||||||
|
pool_usage_percent?: number | null
|
||||||
|
checked_out: number
|
||||||
|
pool_size: number
|
||||||
|
overflow: number
|
||||||
|
max_capacity: number
|
||||||
|
pool_timeout: number
|
||||||
|
server_connections?: number | null
|
||||||
|
server_max_connections?: number | null
|
||||||
|
server_usage_percent?: number | null
|
||||||
|
storage_status?: MonitoringMetricStatus
|
||||||
|
storage_label?: string | null
|
||||||
|
storage_total_bytes?: number | null
|
||||||
|
storage_free_bytes?: number | null
|
||||||
|
storage_free_percent?: number | null
|
||||||
|
database_size_bytes?: number | null
|
||||||
|
storage_message?: string | null
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringSystemStatus {
|
||||||
|
timestamp: string
|
||||||
|
users: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
providers: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
api_keys: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
today_stats: {
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost_usd: number
|
||||||
|
}
|
||||||
|
recent_errors: number
|
||||||
|
system_metrics?: {
|
||||||
|
cpu: MonitoringCpuMetric
|
||||||
|
memory: MonitoringMemoryMetric
|
||||||
|
redis: MonitoringServiceMetric
|
||||||
|
postgres: MonitoringPostgresMetric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumericLike(value: unknown): number {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().replace(/[$,\s]/g, '')
|
||||||
|
if (!normalized) return 0
|
||||||
|
const parsed = Number(normalized)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNullableNumericLike(value: unknown): number | null {
|
||||||
|
if (value == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().replace(/[$,\s]/g, '')
|
||||||
|
if (!normalized) return null
|
||||||
|
const parsed = Number(normalized)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonitoringSystemStatus(data: MonitoringSystemStatus): MonitoringSystemStatus {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
users: {
|
||||||
|
total: parseNumericLike(data.users?.total),
|
||||||
|
active: parseNumericLike(data.users?.active),
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
total: parseNumericLike(data.providers?.total),
|
||||||
|
active: parseNumericLike(data.providers?.active),
|
||||||
|
},
|
||||||
|
api_keys: {
|
||||||
|
total: parseNumericLike(data.api_keys?.total),
|
||||||
|
active: parseNumericLike(data.api_keys?.active),
|
||||||
|
},
|
||||||
|
today_stats: {
|
||||||
|
requests: parseNumericLike(data.today_stats?.requests),
|
||||||
|
tokens: parseNumericLike(data.today_stats?.tokens),
|
||||||
|
cost_usd: parseNumericLike(data.today_stats?.cost_usd),
|
||||||
|
},
|
||||||
|
recent_errors: parseNumericLike(data.recent_errors),
|
||||||
|
system_metrics: data.system_metrics ? {
|
||||||
|
cpu: {
|
||||||
|
status: data.system_metrics.cpu?.status ?? 'unknown',
|
||||||
|
label: data.system_metrics.cpu?.label ?? '未知',
|
||||||
|
usage_percent: parseNullableNumericLike(data.system_metrics.cpu?.usage_percent),
|
||||||
|
load_percent: parseNullableNumericLike(data.system_metrics.cpu?.load_percent),
|
||||||
|
core_count: parseNumericLike(data.system_metrics.cpu?.core_count),
|
||||||
|
message: data.system_metrics.cpu?.message ?? null,
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
status: data.system_metrics.memory?.status ?? 'unknown',
|
||||||
|
label: data.system_metrics.memory?.label ?? '未知',
|
||||||
|
used_percent: parseNullableNumericLike(data.system_metrics.memory?.used_percent),
|
||||||
|
used_bytes: parseNullableNumericLike(data.system_metrics.memory?.used_bytes),
|
||||||
|
available_bytes: parseNullableNumericLike(data.system_metrics.memory?.available_bytes),
|
||||||
|
total_bytes: parseNullableNumericLike(data.system_metrics.memory?.total_bytes),
|
||||||
|
message: data.system_metrics.memory?.message ?? null,
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
status: data.system_metrics.redis?.status ?? 'unknown',
|
||||||
|
label: data.system_metrics.redis?.label ?? '未知',
|
||||||
|
latency_ms: parseNullableNumericLike(data.system_metrics.redis?.latency_ms),
|
||||||
|
memory_status: data.system_metrics.redis?.memory_status ?? 'unknown',
|
||||||
|
memory_label: data.system_metrics.redis?.memory_label ?? '未知',
|
||||||
|
used_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.used_memory_bytes),
|
||||||
|
peak_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.peak_memory_bytes),
|
||||||
|
maxmemory_bytes: parseNullableNumericLike(data.system_metrics.redis?.maxmemory_bytes),
|
||||||
|
memory_ceiling_bytes: parseNullableNumericLike(data.system_metrics.redis?.memory_ceiling_bytes),
|
||||||
|
memory_source: (data.system_metrics.redis?.memory_source as MonitoringServiceMetric['memory_source']) ?? 'unknown',
|
||||||
|
available_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.available_memory_bytes),
|
||||||
|
memory_percent: parseNullableNumericLike(data.system_metrics.redis?.memory_percent),
|
||||||
|
message: data.system_metrics.redis?.message ?? null,
|
||||||
|
},
|
||||||
|
postgres: {
|
||||||
|
status: data.system_metrics.postgres?.status ?? 'unknown',
|
||||||
|
label: data.system_metrics.postgres?.label ?? '未知',
|
||||||
|
usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.usage_percent),
|
||||||
|
pool_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.pool_usage_percent),
|
||||||
|
checked_out: parseNumericLike(data.system_metrics.postgres?.checked_out),
|
||||||
|
pool_size: parseNumericLike(data.system_metrics.postgres?.pool_size),
|
||||||
|
overflow: parseNumericLike(data.system_metrics.postgres?.overflow),
|
||||||
|
max_capacity: parseNumericLike(data.system_metrics.postgres?.max_capacity),
|
||||||
|
pool_timeout: parseNumericLike(data.system_metrics.postgres?.pool_timeout),
|
||||||
|
server_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_connections),
|
||||||
|
server_max_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_max_connections),
|
||||||
|
server_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.server_usage_percent),
|
||||||
|
storage_status: data.system_metrics.postgres?.storage_status ?? 'unknown',
|
||||||
|
storage_label: data.system_metrics.postgres?.storage_label ?? '未知',
|
||||||
|
storage_total_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_total_bytes),
|
||||||
|
storage_free_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_bytes),
|
||||||
|
storage_free_percent: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_percent),
|
||||||
|
database_size_bytes: parseNullableNumericLike(data.system_metrics.postgres?.database_size_bytes),
|
||||||
|
storage_message: data.system_metrics.postgres?.storage_message ?? null,
|
||||||
|
message: data.system_metrics.postgres?.message ?? null,
|
||||||
|
},
|
||||||
|
} : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAuditResponse(data: Record<string, unknown>): AuditLogsResponse {
|
function normalizeAuditResponse(data: Record<string, unknown>): AuditLogsResponse {
|
||||||
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
|
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
|
||||||
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
|
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
|
||||||
@@ -78,6 +275,11 @@ export const auditApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getSystemStatus(): Promise<MonitoringSystemStatus> {
|
||||||
|
const response = await apiClient.get<MonitoringSystemStatus>('/api/admin/monitoring/system-status')
|
||||||
|
return normalizeMonitoringSystemStatus(response.data)
|
||||||
|
},
|
||||||
|
|
||||||
// 分析用户行为 (管理员)
|
// 分析用户行为 (管理员)
|
||||||
async analyzeUserBehavior(userId: number, days: number = 7): Promise<{
|
async analyzeUserBehavior(userId: number, days: number = 7): Promise<{
|
||||||
analysis: Record<string, unknown>
|
analysis: Record<string, unknown>
|
||||||
|
|||||||
@@ -24,13 +24,6 @@ export interface UserPreferences {
|
|||||||
[key: string]: unknown // 允许扩展其他偏好设置
|
[key: string]: unknown // 允许扩展其他偏好设置
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserStats {
|
|
||||||
total_requests?: number
|
|
||||||
total_cost?: number
|
|
||||||
last_request_at?: string
|
|
||||||
[key: string]: unknown // 允许扩展其他统计数据
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendVerificationCodeRequest {
|
export interface SendVerificationCodeRequest {
|
||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
@@ -113,13 +106,9 @@ export interface User {
|
|||||||
role: string // 'admin' or 'user'
|
role: string // 'admin' or 'user'
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
billing?: BillingSummary
|
billing?: BillingSummary
|
||||||
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
|
|
||||||
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
|
|
||||||
allowed_models?: string[] | null // 允许使用的模型名称列表
|
|
||||||
created_at: string
|
created_at: string
|
||||||
last_login_at?: string
|
last_login_at?: string
|
||||||
preferences?: UserPreferences
|
preferences?: UserPreferences
|
||||||
stats?: UserStats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
|
|||||||
@@ -288,7 +288,12 @@ export const cacheAnalysisApi = {
|
|||||||
api_key_id?: string
|
api_key_id?: string
|
||||||
hours?: number
|
hours?: number
|
||||||
}): Promise<TTLAnalysisResponse> {
|
}): Promise<TTLAnalysisResponse> {
|
||||||
const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params })
|
const response = await api.post('/api/analytics/cache-affinity/ttl-analysis', {
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
user_id: params?.user_id ?? null,
|
||||||
|
api_key_id: params?.api_key_id ?? null,
|
||||||
|
hours: params?.hours,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -300,7 +305,12 @@ export const cacheAnalysisApi = {
|
|||||||
api_key_id?: string
|
api_key_id?: string
|
||||||
hours?: number
|
hours?: number
|
||||||
}): Promise<CacheHitAnalysisResponse> {
|
}): Promise<CacheHitAnalysisResponse> {
|
||||||
const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params })
|
const response = await api.post('/api/analytics/cache-affinity/hit-analysis', {
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
user_id: params?.user_id ?? null,
|
||||||
|
api_key_id: params?.api_key_id ?? null,
|
||||||
|
hours: params?.hours,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -319,7 +329,13 @@ export const cacheAnalysisApi = {
|
|||||||
return cachedRequest(
|
return cachedRequest(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
|
const response = await api.post('/api/analytics/interval-timeline', {
|
||||||
|
scope: params?.user_id ? { kind: 'user', user_id: params.user_id } : { kind: 'global' },
|
||||||
|
user_id: params?.user_id ?? null,
|
||||||
|
hours: params?.hours,
|
||||||
|
limit: params?.limit,
|
||||||
|
include_user_info: params?.include_user_info ?? false,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
30000
|
30000
|
||||||
|
|||||||
@@ -1,382 +0,0 @@
|
|||||||
import apiClient from './client'
|
|
||||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
|
||||||
|
|
||||||
export interface DashboardStat {
|
|
||||||
name: string
|
|
||||||
value: string
|
|
||||||
subValue?: string
|
|
||||||
change?: string
|
|
||||||
changeType?: 'increase' | 'decrease' | 'neutral'
|
|
||||||
extraBadge?: string
|
|
||||||
icon: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecentRequest {
|
|
||||||
id: string // UUID
|
|
||||||
user: string
|
|
||||||
model: string
|
|
||||||
tokens: number
|
|
||||||
time: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderStatus {
|
|
||||||
name: string
|
|
||||||
status: 'active' | 'inactive'
|
|
||||||
requests: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 系统健康指标(管理员专用)
|
|
||||||
export interface SystemHealth {
|
|
||||||
avg_response_time: number
|
|
||||||
error_rate: number
|
|
||||||
error_requests: number
|
|
||||||
fallback_count: number
|
|
||||||
total_requests: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 成本统计(管理员专用)
|
|
||||||
export interface CostStats {
|
|
||||||
total_cost: number
|
|
||||||
total_actual_cost: number
|
|
||||||
cost_savings: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 缓存统计
|
|
||||||
export interface CacheStats {
|
|
||||||
cache_creation_tokens: number
|
|
||||||
cache_read_tokens: number
|
|
||||||
cache_creation_cost?: number
|
|
||||||
cache_read_cost?: number
|
|
||||||
cache_hit_rate?: number
|
|
||||||
total_cache_tokens: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户统计(管理员专用)
|
|
||||||
export interface UserStats {
|
|
||||||
total: number
|
|
||||||
active: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token 详细分类
|
|
||||||
export interface TokenBreakdown {
|
|
||||||
input: number
|
|
||||||
output: number
|
|
||||||
cache_creation: number
|
|
||||||
cache_read: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardStatsResponse {
|
|
||||||
stats: DashboardStat[]
|
|
||||||
today?: {
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
actual_cost?: number
|
|
||||||
cache_creation_tokens?: number
|
|
||||||
cache_read_tokens?: number
|
|
||||||
}
|
|
||||||
api_keys?: {
|
|
||||||
total: number
|
|
||||||
active: number
|
|
||||||
}
|
|
||||||
tokens?: {
|
|
||||||
month: number
|
|
||||||
}
|
|
||||||
// 管理员专用字段
|
|
||||||
system_health?: SystemHealth
|
|
||||||
cost_stats?: CostStats
|
|
||||||
cache_stats?: CacheStats
|
|
||||||
users?: UserStats
|
|
||||||
token_breakdown?: TokenBreakdown
|
|
||||||
// 普通用户专用字段
|
|
||||||
monthly_cost?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecentRequestsResponse {
|
|
||||||
requests: RecentRequest[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderStatusResponse {
|
|
||||||
providers: ProviderStatus[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 视频/图像/音频计费信息
|
|
||||||
export interface VideoBilling {
|
|
||||||
task_type: 'video' | 'image' | 'audio'
|
|
||||||
duration_seconds?: number // 视频时长(秒)
|
|
||||||
resolution?: string // 分辨率
|
|
||||||
video_price_per_second?: number // 每秒单价
|
|
||||||
video_cost?: number // 视频费用
|
|
||||||
cost?: number // 总费用
|
|
||||||
rule_name?: string // 计费规则名称
|
|
||||||
expression?: string // 计费公式
|
|
||||||
status?: string // 计费状态
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestDetail {
|
|
||||||
id: string // UUID
|
|
||||||
request_id: string
|
|
||||||
user: {
|
|
||||||
id: string // UUID
|
|
||||||
username: string
|
|
||||||
email: string
|
|
||||||
}
|
|
||||||
api_key: {
|
|
||||||
id: string // UUID
|
|
||||||
name: string
|
|
||||||
display: string
|
|
||||||
}
|
|
||||||
provider: string
|
|
||||||
api_format?: string
|
|
||||||
model: string
|
|
||||||
target_model?: string | null // 映射后的目标模型名
|
|
||||||
tokens: {
|
|
||||||
input: number
|
|
||||||
output: number
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
cost: {
|
|
||||||
input: number
|
|
||||||
output: number
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
// Additional token fields
|
|
||||||
input_tokens?: number
|
|
||||||
output_tokens?: number
|
|
||||||
total_tokens?: number
|
|
||||||
cache_creation_input_tokens?: number
|
|
||||||
cache_creation_input_tokens_5m?: number
|
|
||||||
cache_creation_input_tokens_1h?: number
|
|
||||||
cache_read_input_tokens?: number
|
|
||||||
// Additional cost fields
|
|
||||||
input_cost?: number
|
|
||||||
output_cost?: number
|
|
||||||
total_cost?: number
|
|
||||||
cache_creation_cost?: number
|
|
||||||
cache_read_cost?: number
|
|
||||||
request_cost?: number // 按次计费费用
|
|
||||||
// Historical pricing fields (per 1M tokens)
|
|
||||||
input_price_per_1m?: number
|
|
||||||
output_price_per_1m?: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
price_per_request?: number // 按次计费价格
|
|
||||||
request_type: string
|
|
||||||
is_stream: boolean
|
|
||||||
status_code: number
|
|
||||||
status?: string // pending, streaming, completed, failed, cancelled
|
|
||||||
error_message?: string
|
|
||||||
response_time_ms: number
|
|
||||||
created_at: string
|
|
||||||
request_headers?: Record<string, unknown>
|
|
||||||
request_body?: Record<string, unknown>
|
|
||||||
provider_request_headers?: Record<string, unknown>
|
|
||||||
provider_request_body?: Record<string, unknown>
|
|
||||||
response_headers?: Record<string, unknown>
|
|
||||||
client_response_headers?: Record<string, unknown>
|
|
||||||
response_body?: Record<string, unknown>
|
|
||||||
client_response_body?: Record<string, unknown>
|
|
||||||
has_request_body?: boolean
|
|
||||||
has_provider_request_body?: boolean
|
|
||||||
has_response_body?: boolean
|
|
||||||
has_client_response_body?: boolean
|
|
||||||
metadata?: Record<string, unknown>
|
|
||||||
// 阶梯计费信息
|
|
||||||
tiered_pricing?: {
|
|
||||||
total_input_context: number // 总输入上下文 (input + cache_read)
|
|
||||||
tier_index: number // 命中的阶梯索引 (0-based)
|
|
||||||
tier_count: number // 阶梯总数
|
|
||||||
source?: 'provider' | 'global' // 定价来源: 提供商或全局
|
|
||||||
current_tier: { // 当前命中的阶梯配置
|
|
||||||
up_to?: number | null
|
|
||||||
input_price_per_1m: number
|
|
||||||
output_price_per_1m: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
cache_ttl_pricing?: Array<{
|
|
||||||
ttl_minutes: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
tiers: Array<{ // 完整阶梯配置列表
|
|
||||||
up_to?: number | null
|
|
||||||
input_price_per_1m: number
|
|
||||||
output_price_per_1m: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
cache_ttl_pricing?: Array<{
|
|
||||||
ttl_minutes: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
}>
|
|
||||||
}>
|
|
||||||
} | null
|
|
||||||
// 视频/图像/音频计费信息
|
|
||||||
video_billing?: VideoBilling | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurlData {
|
|
||||||
url: string
|
|
||||||
method: string
|
|
||||||
headers: Record<string, string>
|
|
||||||
body: Record<string, unknown>
|
|
||||||
curl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplayRequest {
|
|
||||||
provider_id?: string
|
|
||||||
endpoint_id?: string
|
|
||||||
api_key_id?: string
|
|
||||||
body_override?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplayResponse {
|
|
||||||
url: string
|
|
||||||
provider: string
|
|
||||||
status_code: number
|
|
||||||
response_headers: Record<string, string>
|
|
||||||
response_body: Record<string, unknown>
|
|
||||||
response_time_ms: number
|
|
||||||
mapping?: {
|
|
||||||
source_model: string
|
|
||||||
original_target_model?: string | null
|
|
||||||
resolved_model: string
|
|
||||||
target_provider_id: string
|
|
||||||
target_provider: string
|
|
||||||
target_endpoint_id: string
|
|
||||||
target_api_format: string
|
|
||||||
replay_mode: string
|
|
||||||
mapping_applied: boolean
|
|
||||||
mapping_source: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelBreakdown {
|
|
||||||
model: string
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelSummary {
|
|
||||||
model: string
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
avg_response_time: number
|
|
||||||
cost_per_request: number
|
|
||||||
tokens_per_request: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderSummary {
|
|
||||||
provider: string
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DailyStat {
|
|
||||||
date: string // ISO date string
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
avg_response_time: number // in seconds
|
|
||||||
unique_models: number
|
|
||||||
unique_providers?: number // 仅管理员返回
|
|
||||||
model_breakdown: ModelBreakdown[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DailyStatsResponse {
|
|
||||||
daily_stats: DailyStat[]
|
|
||||||
model_summary: ModelSummary[]
|
|
||||||
provider_summary?: ProviderSummary[] // 仅管理员返回
|
|
||||||
period: {
|
|
||||||
start_date: string
|
|
||||||
end_date: string
|
|
||||||
days: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimeRangeParams {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dashboardApi = {
|
|
||||||
// 获取仪表盘统计数据
|
|
||||||
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
|
||||||
const cacheKey = buildCacheKey('dashboard:stats', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
10 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取最近的请求记录
|
|
||||||
async getRecentRequests(limit: number = 10): Promise<RecentRequest[]> {
|
|
||||||
const response = await apiClient.get<RecentRequestsResponse>('/api/dashboard/recent-requests', {
|
|
||||||
params: { limit }
|
|
||||||
})
|
|
||||||
return response.data.requests
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取提供商状态
|
|
||||||
async getProviderStatus(): Promise<ProviderStatus[]> {
|
|
||||||
return cachedRequest(
|
|
||||||
'dashboard:provider-status',
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
|
|
||||||
return response.data.providers
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取请求详情
|
|
||||||
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
|
|
||||||
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
|
|
||||||
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
|
|
||||||
params: { include_bodies: options.includeBodies ?? true },
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取每日统计数据
|
|
||||||
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
|
|
||||||
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取 cURL 命令数据(含明文 API Key)
|
|
||||||
async getCurlData(requestId: string): Promise<CurlData> {
|
|
||||||
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 回放请求到提供商
|
|
||||||
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
|
|
||||||
const response = await apiClient.post<ReplayResponse>(
|
|
||||||
`/api/admin/usage/${requestId}/replay`,
|
|
||||||
params || {}
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -594,7 +594,7 @@ export interface HealthSummary {
|
|||||||
total: number
|
total: number
|
||||||
active: number
|
active: number
|
||||||
unhealthy: number
|
unhealthy: number
|
||||||
circuit_open: number
|
circuit_open?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import type { ActivityHeatmap } from '@/types/activity'
|
|
||||||
import type { TieredPricingConfig } from './endpoints/types'
|
import type { TieredPricingConfig } from './endpoints/types'
|
||||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
|
||||||
import type { BillingSummary } from './auth'
|
import type { BillingSummary } from './auth'
|
||||||
import type { UserSession } from '@/types/session'
|
import type { UserSession } from '@/types/session'
|
||||||
|
|
||||||
@@ -25,8 +23,6 @@ export interface Profile {
|
|||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
avatar_url?: string
|
avatar_url?: string
|
||||||
bio?: string
|
bio?: string
|
||||||
default_provider_id?: string // UUID
|
|
||||||
default_provider?: Record<string, unknown>
|
|
||||||
theme: string
|
theme: string
|
||||||
language: string
|
language: string
|
||||||
timezone?: string
|
timezone?: string
|
||||||
@@ -37,103 +33,6 @@ export interface UserPreferences {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提供商配置接口
|
|
||||||
export interface ProviderConfig {
|
|
||||||
provider_id: string
|
|
||||||
priority: number // 优先级(越高越优先)
|
|
||||||
weight: number // 负载均衡权重
|
|
||||||
enabled: boolean // 是否启用
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用记录接口
|
|
||||||
export interface UsageRecordDetail {
|
|
||||||
id: string
|
|
||||||
provider: string
|
|
||||||
model: string
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
total_tokens: number
|
|
||||||
cost: number // 官方费率
|
|
||||||
actual_cost?: number // 倍率消耗(仅管理员可见)
|
|
||||||
rate_multiplier?: number // 成本倍率(仅管理员可见)
|
|
||||||
response_time_ms?: number
|
|
||||||
is_stream: boolean
|
|
||||||
created_at: string
|
|
||||||
cache_creation_input_tokens?: number
|
|
||||||
cache_read_input_tokens?: number
|
|
||||||
status_code: number
|
|
||||||
error_message?: string
|
|
||||||
input_price_per_1m: number
|
|
||||||
output_price_per_1m: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
price_per_request?: number // 按次计费价格
|
|
||||||
api_key?: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
display: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模型统计接口
|
|
||||||
export interface ModelSummary {
|
|
||||||
model: string
|
|
||||||
requests: number
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
total_tokens: number
|
|
||||||
cache_read_tokens?: number
|
|
||||||
cache_hit_rate?: number
|
|
||||||
total_cost_usd: number
|
|
||||||
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提供商统计接口
|
|
||||||
export interface ProviderSummary {
|
|
||||||
provider: string
|
|
||||||
requests: number
|
|
||||||
total_tokens: number
|
|
||||||
cache_read_tokens?: number
|
|
||||||
cache_hit_rate?: number
|
|
||||||
total_cost_usd: number
|
|
||||||
success_rate: number | null
|
|
||||||
avg_response_time_ms: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 格式统计接口
|
|
||||||
export interface ApiFormatSummary {
|
|
||||||
api_format: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
cache_read_tokens: number
|
|
||||||
cache_hit_rate: number
|
|
||||||
total_cost_usd: number
|
|
||||||
avg_response_time_ms: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用统计响应接口
|
|
||||||
export interface UsageResponse {
|
|
||||||
total_requests: number
|
|
||||||
total_input_tokens: number
|
|
||||||
total_output_tokens: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number // 官方费率
|
|
||||||
total_actual_cost?: number // 倍率消耗(仅管理员可见)
|
|
||||||
avg_response_time: number
|
|
||||||
billing: BillingSummary
|
|
||||||
summary_by_model: ModelSummary[]
|
|
||||||
summary_by_provider?: ProviderSummary[]
|
|
||||||
summary_by_api_format?: ApiFormatSummary[]
|
|
||||||
pagination?: {
|
|
||||||
total: number
|
|
||||||
limit: number
|
|
||||||
offset: number
|
|
||||||
has_more: boolean
|
|
||||||
}
|
|
||||||
records: UsageRecordDetail[]
|
|
||||||
activity_heatmap?: ActivityHeatmap | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiKey {
|
export interface ApiKey {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
name: string
|
name: string
|
||||||
@@ -146,7 +45,6 @@ export interface ApiKey {
|
|||||||
total_requests?: number
|
total_requests?: number
|
||||||
total_cost_usd?: number
|
total_cost_usd?: number
|
||||||
rate_limit?: number | null
|
rate_limit?: number | null
|
||||||
allowed_providers?: ProviderConfig[]
|
|
||||||
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,51 +147,6 @@ export const meApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// 使用统计
|
|
||||||
async getUsage(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
search?: string // 通用搜索:密钥名、模型名
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}): Promise<UsageResponse> {
|
|
||||||
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取活跃请求状态(用于轮询更新)
|
|
||||||
async getActiveRequests(ids?: string): Promise<{
|
|
||||||
requests: Array<{
|
|
||||||
id: string
|
|
||||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
cache_creation_input_tokens?: number | null
|
|
||||||
cache_read_input_tokens?: number | null
|
|
||||||
cost: number
|
|
||||||
actual_cost?: number | null
|
|
||||||
rate_multiplier?: number | null
|
|
||||||
response_time_ms: number | null
|
|
||||||
first_byte_time_ms: number | null
|
|
||||||
api_format?: string | null
|
|
||||||
endpoint_api_format?: string | null
|
|
||||||
has_format_conversion?: boolean | null
|
|
||||||
}>
|
|
||||||
}> {
|
|
||||||
const params = ids ? { ids } : {}
|
|
||||||
const response = await apiClient.get('/api/users/me/usage/active', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取可用的提供商
|
|
||||||
async getAvailableProviders(): Promise<Array<Record<string, unknown>>> {
|
|
||||||
const response = await apiClient.get('/api/users/me/providers')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取用户可用的模型列表
|
// 获取用户可用的模型列表
|
||||||
async getAvailableModels(params?: {
|
async getAvailableModels(params?: {
|
||||||
skip?: number
|
skip?: number
|
||||||
@@ -334,16 +187,6 @@ export const meApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// 提供商绑定管理相关方法已移除,改为直接从可用提供商中选择
|
|
||||||
|
|
||||||
// API密钥提供商关联
|
|
||||||
async updateApiKeyProviders(keyId: string, data: {
|
|
||||||
allowed_providers?: ProviderConfig[]
|
|
||||||
}): Promise<{ message: string }> {
|
|
||||||
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/providers`, data)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// API密钥能力配置
|
// API密钥能力配置
|
||||||
async updateApiKeyCapabilities(keyId: string, data: {
|
async updateApiKeyCapabilities(keyId: string, data: {
|
||||||
force_capabilities?: Record<string, boolean> | null
|
force_capabilities?: Record<string, boolean> | null
|
||||||
@@ -370,39 +213,4 @@ export const meApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取请求间隔时间线(用于散点图)
|
|
||||||
async getIntervalTimeline(params?: {
|
|
||||||
hours?: number
|
|
||||||
limit?: number
|
|
||||||
}): Promise<{
|
|
||||||
analysis_period_hours: number
|
|
||||||
total_points: number
|
|
||||||
points: Array<{ x: string; y: number; model?: string }>
|
|
||||||
models?: string[]
|
|
||||||
}> {
|
|
||||||
const cacheKey = buildCacheKey('me:interval-timeline', params as Record<string, unknown> | undefined)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取活跃度热力图数据(用户)
|
|
||||||
* 后端已缓存5分钟
|
|
||||||
*/
|
|
||||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
|
||||||
return cachedRequest(
|
|
||||||
'me-activity-heatmap',
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
60000
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
export interface VideoBilling {
|
||||||
|
task_type: 'video' | 'image' | 'audio'
|
||||||
|
duration_seconds?: number
|
||||||
|
resolution?: string
|
||||||
|
video_price_per_second?: number
|
||||||
|
video_cost?: number
|
||||||
|
cost?: number
|
||||||
|
rule_name?: string
|
||||||
|
expression?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestDetail {
|
||||||
|
id: string
|
||||||
|
request_id: string
|
||||||
|
user: {
|
||||||
|
id: string | null
|
||||||
|
username: string | null
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
api_key: {
|
||||||
|
id: string | null
|
||||||
|
name: string | null
|
||||||
|
display: string | null
|
||||||
|
}
|
||||||
|
provider_api_key: {
|
||||||
|
id: string | null
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
|
provider: string
|
||||||
|
api_format?: string
|
||||||
|
model: string
|
||||||
|
target_model?: string | null
|
||||||
|
tokens: {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
cost: {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
input_tokens?: number
|
||||||
|
output_tokens?: number
|
||||||
|
total_tokens?: number
|
||||||
|
cache_creation_input_tokens?: number
|
||||||
|
cache_creation_input_tokens_5m?: number
|
||||||
|
cache_creation_input_tokens_1h?: number
|
||||||
|
cache_read_input_tokens?: number
|
||||||
|
input_cost?: number
|
||||||
|
output_cost?: number
|
||||||
|
total_cost?: number
|
||||||
|
cache_creation_cost?: number
|
||||||
|
cache_read_cost?: number
|
||||||
|
request_cost?: number
|
||||||
|
input_price_per_1m?: number
|
||||||
|
output_price_per_1m?: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
price_per_request?: number
|
||||||
|
request_type: string
|
||||||
|
is_stream: boolean
|
||||||
|
status_code: number
|
||||||
|
status?: string
|
||||||
|
error_message?: string
|
||||||
|
response_time_ms: number
|
||||||
|
created_at: string
|
||||||
|
request_headers?: Record<string, unknown>
|
||||||
|
request_body?: Record<string, unknown>
|
||||||
|
provider_request_headers?: Record<string, unknown>
|
||||||
|
provider_request_body?: Record<string, unknown>
|
||||||
|
response_headers?: Record<string, unknown>
|
||||||
|
client_response_headers?: Record<string, unknown>
|
||||||
|
response_body?: Record<string, unknown>
|
||||||
|
client_response_body?: Record<string, unknown>
|
||||||
|
has_request_body?: boolean
|
||||||
|
has_provider_request_body?: boolean
|
||||||
|
has_response_body?: boolean
|
||||||
|
has_client_response_body?: boolean
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
tiered_pricing?: {
|
||||||
|
total_input_context: number
|
||||||
|
tier_index: number
|
||||||
|
tier_count: number
|
||||||
|
source?: 'provider' | 'global'
|
||||||
|
current_tier: {
|
||||||
|
up_to?: number | null
|
||||||
|
input_price_per_1m: number
|
||||||
|
output_price_per_1m: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
cache_ttl_pricing?: Array<{
|
||||||
|
ttl_minutes: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
tiers: Array<{
|
||||||
|
up_to?: number | null
|
||||||
|
input_price_per_1m: number
|
||||||
|
output_price_per_1m: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
cache_ttl_pricing?: Array<{
|
||||||
|
ttl_minutes: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
} | null
|
||||||
|
video_billing?: VideoBilling | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurlData {
|
||||||
|
url: string
|
||||||
|
method: string
|
||||||
|
headers: Record<string, string>
|
||||||
|
body: Record<string, unknown>
|
||||||
|
curl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayRequest {
|
||||||
|
provider_id?: string
|
||||||
|
endpoint_id?: string
|
||||||
|
api_key_id?: string
|
||||||
|
body_override?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayResponse {
|
||||||
|
url: string
|
||||||
|
provider: string
|
||||||
|
status_code: number
|
||||||
|
response_headers: Record<string, string>
|
||||||
|
response_body: Record<string, unknown>
|
||||||
|
response_time_ms: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestDetailsApi = {
|
||||||
|
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
|
||||||
|
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
|
||||||
|
params: { include_bodies: options.includeBodies ?? true },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCurlData(requestId: string): Promise<CurlData> {
|
||||||
|
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
|
||||||
|
const response = await apiClient.post<ReplayResponse>(
|
||||||
|
`/api/admin/usage/${requestId}/replay`,
|
||||||
|
params || {},
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
import apiClient from './client'
|
|
||||||
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
|
|
||||||
import type { ActivityHeatmap } from '@/types/activity'
|
|
||||||
|
|
||||||
export interface UsageRecord {
|
|
||||||
id: string // UUID
|
|
||||||
user_id: string // UUID
|
|
||||||
username?: string
|
|
||||||
provider_id?: string // UUID
|
|
||||||
provider_name?: string
|
|
||||||
model: string
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
cache_creation_input_tokens?: number
|
|
||||||
cache_read_input_tokens?: number
|
|
||||||
total_tokens: number
|
|
||||||
cost?: number
|
|
||||||
response_time?: number
|
|
||||||
created_at: string
|
|
||||||
has_fallback?: boolean // 🆕 是否发生了 fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageStats {
|
|
||||||
total_requests: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
total_actual_cost?: number
|
|
||||||
avg_response_time: number
|
|
||||||
today?: {
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
}
|
|
||||||
activity_heatmap?: ActivityHeatmap | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageByModel {
|
|
||||||
model: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
avg_response_time?: number
|
|
||||||
cache_read_tokens?: number
|
|
||||||
cache_hit_rate?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageByUser {
|
|
||||||
user_id: string // UUID
|
|
||||||
email: string
|
|
||||||
username: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageByProvider {
|
|
||||||
provider_id: string
|
|
||||||
provider: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
actual_cost: number
|
|
||||||
avg_response_time_ms: number
|
|
||||||
success_rate: number
|
|
||||||
error_count: number
|
|
||||||
cache_read_tokens?: number
|
|
||||||
cache_hit_rate?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageByApiFormat {
|
|
||||||
api_format: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
actual_cost: number
|
|
||||||
avg_response_time_ms: number
|
|
||||||
cache_read_tokens?: number
|
|
||||||
cache_hit_rate?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageFilters {
|
|
||||||
user_id?: string // UUID
|
|
||||||
provider_id?: string // UUID
|
|
||||||
model?: string
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usageApi = {
|
|
||||||
async getUsageRecords(filters?: UsageFilters): Promise<{
|
|
||||||
records: UsageRecord[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
page_size: number
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.get('/api/usage', { params: filters })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
|
|
||||||
// 为统计数据添加30秒缓存
|
|
||||||
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}`
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<UsageStats>('/api/admin/usage/stats', { params: filters })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30000 // 30秒缓存
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get usage aggregation by dimension (RESTful API)
|
|
||||||
* @param groupBy Aggregation dimension: 'model', 'user', 'provider', or 'api_format'
|
|
||||||
* @param filters Optional filters
|
|
||||||
*/
|
|
||||||
async getUsageAggregation<T = UsageByModel[] | UsageByUser[] | UsageByProvider[] | UsageByApiFormat[]>(
|
|
||||||
groupBy: 'model' | 'user' | 'provider' | 'api_format',
|
|
||||||
filters?: UsageFilters & { limit?: number }
|
|
||||||
): Promise<T> {
|
|
||||||
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}`
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<T>('/api/admin/usage/aggregation/stats', {
|
|
||||||
params: { group_by: groupBy, ...filters }
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30000 // 30秒缓存
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Shorthand methods using getUsageAggregation
|
|
||||||
async getUsageByModel(filters?: UsageFilters & { limit?: number }): Promise<UsageByModel[]> {
|
|
||||||
return this.getUsageAggregation<UsageByModel[]>('model', filters)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUsageByUser(filters?: UsageFilters & { limit?: number }): Promise<UsageByUser[]> {
|
|
||||||
return this.getUsageAggregation<UsageByUser[]>('user', filters)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUsageByProvider(filters?: UsageFilters & { limit?: number }): Promise<UsageByProvider[]> {
|
|
||||||
return this.getUsageAggregation<UsageByProvider[]>('provider', filters)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUsageByApiFormat(filters?: UsageFilters & { limit?: number }): Promise<UsageByApiFormat[]> {
|
|
||||||
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
|
|
||||||
records: UsageRecord[]
|
|
||||||
stats: UsageStats
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.get(`/api/users/${userId}/usage`, { params: filters })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async exportUsage(format: 'csv' | 'json', filters?: UsageFilters): Promise<Blob> {
|
|
||||||
const response = await apiClient.get('/api/usage/export', {
|
|
||||||
params: { ...filters, format },
|
|
||||||
responseType: 'blob'
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getAllUsageRecords(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
|
||||||
user_id?: string // UUID
|
|
||||||
username?: string
|
|
||||||
model?: string
|
|
||||||
provider?: string
|
|
||||||
api_format?: string // API 格式筛选(如 openai:chat, claude:chat)
|
|
||||||
status?: string // 'stream' | 'standard' | 'error'
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}): Promise<{
|
|
||||||
records: Array<Record<string, unknown>>
|
|
||||||
total: number
|
|
||||||
limit: number
|
|
||||||
offset: number
|
|
||||||
}> {
|
|
||||||
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
|
|
||||||
return dedupedRequest(key, async () => {
|
|
||||||
const response = await apiClient.get('/api/admin/usage/records', { params })
|
|
||||||
return response.data
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取活跃请求的状态(轻量级接口,用于轮询更新)
|
|
||||||
* @param ids 可选,逗号分隔的请求 ID 列表
|
|
||||||
*/
|
|
||||||
async getActiveRequests(ids?: string[]): Promise<{
|
|
||||||
requests: Array<{
|
|
||||||
id: string
|
|
||||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
cache_creation_input_tokens?: number | null
|
|
||||||
cache_read_input_tokens?: number | null
|
|
||||||
cost: number
|
|
||||||
actual_cost?: number | null
|
|
||||||
rate_multiplier?: number | null
|
|
||||||
response_time_ms: number | null
|
|
||||||
first_byte_time_ms: number | null
|
|
||||||
provider?: string | null
|
|
||||||
api_key_name?: string | null
|
|
||||||
api_format?: string | null
|
|
||||||
endpoint_api_format?: string | null
|
|
||||||
has_format_conversion?: boolean | null
|
|
||||||
target_model?: string | null
|
|
||||||
}>
|
|
||||||
}> {
|
|
||||||
const params = ids?.length ? { ids: ids.join(',') } : {}
|
|
||||||
const response = await apiClient.get('/api/admin/usage/active', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取活跃度热力图数据(管理员)
|
|
||||||
* 后端已缓存5分钟
|
|
||||||
*/
|
|
||||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
|
||||||
return cachedRequest(
|
|
||||||
'admin-usage-activity-heatmap',
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
60000
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -141,10 +141,5 @@ export const usersApi = {
|
|||||||
`/api/admin/users/${userId}/api-keys/${keyId}/full-key`
|
`/api/admin/users/${userId}/api-keys/${keyId}/full-key`
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
|
||||||
// 管理员统计
|
|
||||||
async getUsageStats(): Promise<Record<string, unknown>> {
|
|
||||||
const response = await apiClient.get('/api/admin/usage/stats')
|
|
||||||
return response.data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div
|
||||||
|
class="w-full"
|
||||||
|
:style="{ height: `${props.height}px` }"
|
||||||
|
>
|
||||||
<canvas ref="chartRef" />
|
<canvas ref="chartRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -18,6 +21,7 @@ import {
|
|||||||
type ChartData,
|
type ChartData,
|
||||||
type ChartOptions
|
type ChartOptions
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
|
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: 300,
|
height: 300,
|
||||||
@@ -44,6 +48,7 @@ interface Props {
|
|||||||
|
|
||||||
const chartRef = ref<HTMLCanvasElement>()
|
const chartRef = ref<HTMLCanvasElement>()
|
||||||
let chart: ChartJS<'bar'> | null = null
|
let chart: ChartJS<'bar'> | null = null
|
||||||
|
let stopThemeObserver: (() => void) | null = null
|
||||||
|
|
||||||
const defaultOptions: ChartOptions<'bar'> = {
|
const defaultOptions: ChartOptions<'bar'> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -91,9 +96,7 @@ const defaultOptions: ChartOptions<'bar'> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChart() {
|
function buildChartOptions(): ChartOptions<'bar'> {
|
||||||
if (!chartRef.value) return
|
|
||||||
|
|
||||||
const stackedOptions = props.stacked ? {
|
const stackedOptions = props.stacked ? {
|
||||||
scales: {
|
scales: {
|
||||||
x: { ...defaultOptions.scales?.x, stacked: true },
|
x: { ...defaultOptions.scales?.x, stacked: true },
|
||||||
@@ -106,20 +109,26 @@ function createChart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return resolveChartTheme({
|
||||||
|
...defaultOptions,
|
||||||
|
...stackedOptions,
|
||||||
|
...props.options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
|
||||||
chart = new ChartJS(chartRef.value, {
|
chart = new ChartJS(chartRef.value, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: props.data,
|
data: resolveChartTheme(props.data),
|
||||||
options: {
|
options: buildChartOptions()
|
||||||
...defaultOptions,
|
|
||||||
...stackedOptions,
|
|
||||||
...props.options
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.data = props.data
|
chart.data = resolveChartTheme(props.data)
|
||||||
chart.update('none')
|
chart.update('none')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,9 +136,17 @@ function updateChart() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
createChart()
|
createChart()
|
||||||
|
stopThemeObserver = observeChartThemeChanges(() => {
|
||||||
|
if (!chart) return
|
||||||
|
chart.data = resolveChartTheme(props.data)
|
||||||
|
chart.options = buildChartOptions()
|
||||||
|
chart.update('none')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopThemeObserver?.()
|
||||||
|
stopThemeObserver = null
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.destroy()
|
chart.destroy()
|
||||||
chart = null
|
chart = null
|
||||||
@@ -139,11 +156,8 @@ onUnmounted(() => {
|
|||||||
watch(() => props.data, updateChart, { deep: true })
|
watch(() => props.data, updateChart, { deep: true })
|
||||||
watch(() => props.options, () => {
|
watch(() => props.options, () => {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.options = {
|
chart.options = buildChartOptions()
|
||||||
...defaultOptions,
|
chart.update('none')
|
||||||
...props.options
|
|
||||||
}
|
|
||||||
chart.update()
|
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div
|
||||||
|
class="w-full"
|
||||||
|
:style="{ height: `${props.height}px` }"
|
||||||
|
>
|
||||||
<canvas ref="chartRef" />
|
<canvas ref="chartRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -16,10 +19,12 @@ import {
|
|||||||
type ChartData,
|
type ChartData,
|
||||||
type ChartOptions
|
type ChartOptions
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
|
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: 300,
|
height: 300,
|
||||||
options: undefined
|
options: undefined,
|
||||||
|
showLegend: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
@@ -34,15 +39,22 @@ interface Props {
|
|||||||
data: ChartData<'doughnut'>
|
data: ChartData<'doughnut'>
|
||||||
options?: ChartOptions<'doughnut'>
|
options?: ChartOptions<'doughnut'>
|
||||||
height?: number
|
height?: number
|
||||||
|
showLegend?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartRef = ref<HTMLCanvasElement>()
|
const chartRef = ref<HTMLCanvasElement>()
|
||||||
let chart: ChartJS<'doughnut'> | null = null
|
let chart: ChartJS<'doughnut'> | null = null
|
||||||
|
let stopThemeObserver: (() => void) | null = null
|
||||||
|
|
||||||
const defaultOptions: ChartOptions<'doughnut'> = {
|
const defaultOptions: ChartOptions<'doughnut'> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
cutout: '60%',
|
cutout: '60%',
|
||||||
|
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest',
|
||||||
|
intersect: true,
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'right',
|
position: 'right',
|
||||||
@@ -71,22 +83,59 @@ const defaultOptions: ChartOptions<'doughnut'> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvedOptions(): ChartOptions<'doughnut'> {
|
||||||
|
const incomingOptions = props.options ?? {}
|
||||||
|
const incomingPlugins = (incomingOptions.plugins ?? {}) as Record<string, unknown>
|
||||||
|
const incomingLegend = (incomingPlugins.legend ?? {}) as Record<string, unknown>
|
||||||
|
const incomingTooltip = (incomingPlugins.tooltip ?? {}) as Record<string, unknown>
|
||||||
|
const defaultPlugins = (defaultOptions.plugins ?? {}) as Record<string, unknown>
|
||||||
|
const defaultLegend = (defaultPlugins.legend ?? {}) as Record<string, unknown>
|
||||||
|
const defaultTooltip = (defaultPlugins.tooltip ?? {}) as Record<string, unknown>
|
||||||
|
|
||||||
|
return resolveChartTheme({
|
||||||
|
...defaultOptions,
|
||||||
|
...incomingOptions,
|
||||||
|
interaction: {
|
||||||
|
...defaultOptions.interaction,
|
||||||
|
...incomingOptions.interaction,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
...defaultPlugins,
|
||||||
|
...incomingPlugins,
|
||||||
|
legend: {
|
||||||
|
...defaultLegend,
|
||||||
|
...incomingLegend,
|
||||||
|
display: incomingLegend.display ?? props.showLegend,
|
||||||
|
labels: {
|
||||||
|
...(defaultLegend.labels as Record<string, unknown> | undefined),
|
||||||
|
...(incomingLegend.labels as Record<string, unknown> | undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...defaultTooltip,
|
||||||
|
...incomingTooltip,
|
||||||
|
callbacks: {
|
||||||
|
...(defaultTooltip.callbacks as Record<string, unknown> | undefined),
|
||||||
|
...(incomingTooltip.callbacks as Record<string, unknown> | undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createChart() {
|
function createChart() {
|
||||||
if (!chartRef.value) return
|
if (!chartRef.value) return
|
||||||
|
|
||||||
chart = new ChartJS(chartRef.value, {
|
chart = new ChartJS(chartRef.value, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: props.data,
|
data: resolveChartTheme(props.data),
|
||||||
options: {
|
options: resolvedOptions(),
|
||||||
...defaultOptions,
|
|
||||||
...props.options
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.data = props.data
|
chart.data = resolveChartTheme(props.data)
|
||||||
chart.update('none')
|
chart.update('none')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,9 +143,17 @@ function updateChart() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
createChart()
|
createChart()
|
||||||
|
stopThemeObserver = observeChartThemeChanges(() => {
|
||||||
|
if (!chart) return
|
||||||
|
chart.data = resolveChartTheme(props.data)
|
||||||
|
chart.options = resolvedOptions()
|
||||||
|
chart.update('none')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopThemeObserver?.()
|
||||||
|
stopThemeObserver = null
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.destroy()
|
chart.destroy()
|
||||||
chart = null
|
chart = null
|
||||||
@@ -106,8 +163,15 @@ onUnmounted(() => {
|
|||||||
watch(() => props.data, updateChart, { deep: true })
|
watch(() => props.data, updateChart, { deep: true })
|
||||||
watch(() => props.options, () => {
|
watch(() => props.options, () => {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.options = { ...defaultOptions, ...props.options }
|
chart.options = resolvedOptions()
|
||||||
chart.update()
|
chart.update('none')
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(() => props.showLegend, () => {
|
||||||
|
if (chart) {
|
||||||
|
chart.options = resolvedOptions()
|
||||||
|
chart.update('none')
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div
|
||||||
|
class="w-full"
|
||||||
|
:style="{ height: `${props.height}px` }"
|
||||||
|
>
|
||||||
<canvas ref="chartRef" />
|
<canvas ref="chartRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -16,9 +19,11 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
|
Filler,
|
||||||
type ChartData,
|
type ChartData,
|
||||||
type ChartOptions
|
type ChartOptions
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
|
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: 300,
|
height: 300,
|
||||||
@@ -34,7 +39,8 @@ ChartJS.register(
|
|||||||
LineController,
|
LineController,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend
|
Legend,
|
||||||
|
Filler
|
||||||
)
|
)
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -45,12 +51,17 @@ interface Props {
|
|||||||
|
|
||||||
const chartRef = ref<HTMLCanvasElement>()
|
const chartRef = ref<HTMLCanvasElement>()
|
||||||
let chart: ChartJS<'line'> | null = null
|
let chart: ChartJS<'line'> | null = null
|
||||||
|
let stopThemeObserver: (() => void) | null = null
|
||||||
|
|
||||||
function buildChartOptions(): ChartOptions<'line'> {
|
function buildChartOptions(): ChartOptions<'line'> {
|
||||||
return {
|
return resolveChartTheme({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
...props.options
|
...props.options
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartData(): ChartData<'line'> {
|
||||||
|
return resolveChartTheme(props.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions: ChartOptions<'line'> = {
|
const defaultOptions: ChartOptions<'line'> = {
|
||||||
@@ -95,14 +106,14 @@ function createChart() {
|
|||||||
|
|
||||||
chart = new ChartJS(chartRef.value, {
|
chart = new ChartJS(chartRef.value, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: props.data,
|
data: buildChartData(),
|
||||||
options: buildChartOptions()
|
options: buildChartOptions()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.data = props.data
|
chart.data = buildChartData()
|
||||||
chart.update('none') // 禁用动画以提高性能
|
chart.update('none') // 禁用动画以提高性能
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,9 +121,17 @@ function updateChart() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
createChart()
|
createChart()
|
||||||
|
stopThemeObserver = observeChartThemeChanges(() => {
|
||||||
|
if (!chart) return
|
||||||
|
chart.data = buildChartData()
|
||||||
|
chart.options = buildChartOptions()
|
||||||
|
chart.update('none')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopThemeObserver?.()
|
||||||
|
stopThemeObserver = null
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.destroy()
|
chart.destroy()
|
||||||
chart = null
|
chart = null
|
||||||
|
|||||||
@@ -3,39 +3,24 @@
|
|||||||
<Select
|
<Select
|
||||||
v-model="selectedPreset"
|
v-model="selectedPreset"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
|
<SelectTrigger :class="presetTriggerClass">
|
||||||
<SelectValue placeholder="选择时间段" />
|
<SelectValue placeholder="选择时间段" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="today">
|
<SelectItem value="today">
|
||||||
今天
|
今天
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="yesterday">
|
|
||||||
昨天
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="last7days">
|
<SelectItem value="last7days">
|
||||||
最近7天
|
最近7天
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="last30days">
|
<SelectItem value="last30days">
|
||||||
最近30天
|
最近30天
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="last90days">
|
<SelectItem value="last180days">
|
||||||
最近90天
|
最近180天
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="this_week">
|
<SelectItem value="last1year">
|
||||||
本周
|
最近一年
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="last_week">
|
|
||||||
上周
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="this_month">
|
|
||||||
本月
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="last_month">
|
|
||||||
上月
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="this_year">
|
|
||||||
今年
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="custom">
|
<SelectItem value="custom">
|
||||||
自定义
|
自定义
|
||||||
@@ -50,13 +35,13 @@
|
|||||||
<Input
|
<Input
|
||||||
v-model="startDate"
|
v-model="startDate"
|
||||||
type="date"
|
type="date"
|
||||||
class="h-8 w-36 text-xs border-border/60"
|
:class="dateInputClass"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-muted-foreground">至</span>
|
<span class="text-xs text-muted-foreground">至</span>
|
||||||
<Input
|
<Input
|
||||||
v-model="endDate"
|
v-model="endDate"
|
||||||
type="date"
|
type="date"
|
||||||
class="h-8 w-36 text-xs border-border/60"
|
:class="dateInputClass"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,10 +49,16 @@
|
|||||||
v-if="showGranularity"
|
v-if="showGranularity"
|
||||||
v-model="selectedGranularity"
|
v-model="selectedGranularity"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
|
<SelectTrigger :class="granularityTriggerClass">
|
||||||
<SelectValue placeholder="粒度" />
|
<SelectValue placeholder="粒度" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-if="includeAutoGranularity"
|
||||||
|
value="auto"
|
||||||
|
>
|
||||||
|
自动
|
||||||
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
v-if="allowHourly && canUseHourly"
|
v-if="allowHourly && canUseHourly"
|
||||||
value="hour"
|
value="hour"
|
||||||
@@ -100,26 +91,51 @@ import {
|
|||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import type { DateRangeParams } from '@/features/usage/types'
|
import type { DateRangeParams } from '@/features/usage/types'
|
||||||
|
|
||||||
|
type PickerGranularity = NonNullable<DateRangeParams['granularity']>
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: DateRangeParams
|
modelValue: DateRangeParams
|
||||||
showGranularity?: boolean
|
showGranularity?: boolean
|
||||||
allowHourly?: boolean
|
allowHourly?: boolean
|
||||||
|
includeAutoGranularity?: boolean
|
||||||
|
compact?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: DateRangeParams]
|
'update:modelValue': [value: DateRangeParams]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const selectedPreset = ref(props.modelValue.preset || 'last7days')
|
function getDefaultGranularity(): PickerGranularity {
|
||||||
|
return props.includeAutoGranularity ? 'auto' : 'day'
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPreset = ref(props.modelValue.preset || 'today')
|
||||||
const startDate = ref(props.modelValue.start_date || '')
|
const startDate = ref(props.modelValue.start_date || '')
|
||||||
const endDate = ref(props.modelValue.end_date || '')
|
const endDate = ref(props.modelValue.end_date || '')
|
||||||
const selectedGranularity = ref(props.modelValue.granularity || 'day')
|
const selectedGranularity = ref<PickerGranularity>(props.modelValue.granularity || getDefaultGranularity())
|
||||||
|
|
||||||
const showGranularity = computed(() => props.showGranularity !== false)
|
const showGranularity = computed(() => props.showGranularity !== false)
|
||||||
const allowHourly = computed(() => props.allowHourly === true)
|
const allowHourly = computed(() => props.allowHourly === true)
|
||||||
|
const includeAutoGranularity = computed(() => props.includeAutoGranularity === true)
|
||||||
|
const compact = computed(() => props.compact === true)
|
||||||
|
const presetTriggerClass = computed(() =>
|
||||||
|
compact.value
|
||||||
|
? 'h-8 w-28 text-xs border-border/60'
|
||||||
|
: 'h-8 w-32 text-xs border-border/60',
|
||||||
|
)
|
||||||
|
const dateInputClass = computed(() =>
|
||||||
|
compact.value
|
||||||
|
? 'h-8 w-32 text-xs border-border/60'
|
||||||
|
: 'h-8 w-36 text-xs border-border/60',
|
||||||
|
)
|
||||||
|
const granularityTriggerClass = computed(() =>
|
||||||
|
compact.value
|
||||||
|
? 'h-8 w-20 text-xs border-border/60'
|
||||||
|
: 'h-8 w-24 text-xs border-border/60',
|
||||||
|
)
|
||||||
|
|
||||||
const canUseHourly = computed(() => {
|
const canUseHourly = computed(() => {
|
||||||
if (selectedPreset.value === 'today' || selectedPreset.value === 'yesterday') return true
|
if (selectedPreset.value === 'today') return true
|
||||||
if (selectedPreset.value === 'custom' && startDate.value && endDate.value) {
|
if (selectedPreset.value === 'custom' && startDate.value && endDate.value) {
|
||||||
return startDate.value === endDate.value
|
return startDate.value === endDate.value
|
||||||
}
|
}
|
||||||
@@ -165,7 +181,7 @@ watch(() => props.modelValue, (value) => {
|
|||||||
if (value.preset) selectedPreset.value = value.preset
|
if (value.preset) selectedPreset.value = value.preset
|
||||||
if (value.start_date !== undefined) startDate.value = value.start_date || ''
|
if (value.start_date !== undefined) startDate.value = value.start_date || ''
|
||||||
if (value.end_date !== undefined) endDate.value = value.end_date || ''
|
if (value.end_date !== undefined) endDate.value = value.end_date || ''
|
||||||
if (value.granularity) selectedGranularity.value = value.granularity
|
selectedGranularity.value = value.granularity || getDefaultGranularity()
|
||||||
// 同步更新 lastEmittedValue,避免外部设置值后触发重复 emit
|
// 同步更新 lastEmittedValue,避免外部设置值后触发重复 emit
|
||||||
lastEmittedValue = getValueKey(value)
|
lastEmittedValue = getValueKey(value)
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
@@ -173,7 +189,7 @@ watch(() => props.modelValue, (value) => {
|
|||||||
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
|
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
|
||||||
if (!allowHourly.value || !canUseHourly.value) {
|
if (!allowHourly.value || !canUseHourly.value) {
|
||||||
if (selectedGranularity.value === 'hour') {
|
if (selectedGranularity.value === 'hour') {
|
||||||
selectedGranularity.value = 'day'
|
selectedGranularity.value = includeAutoGranularity.value ? 'auto' : 'day'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,11 @@
|
|||||||
<p class="mt-0.5">
|
<p class="mt-0.5">
|
||||||
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
|
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[11px] text-muted-foreground">
|
<p class="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||||
成本 {{ formatCurrency(tooltip.day.total_cost) }}
|
{{ valueLabel }} {{ formatCurrency(tooltip.day.total_cost) }}
|
||||||
|
<template v-if="actualValueLabel && tooltip.day.actual_total_cost !== undefined">
|
||||||
|
· {{ actualValueLabel }} {{ formatCurrency(tooltip.day.actual_total_cost) }}
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -90,7 +93,7 @@
|
|||||||
class="flex items-center invisible"
|
class="flex items-center invisible"
|
||||||
>周六</span>
|
>周六</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
ref="heatmapWrapper"
|
ref="heatmapWrapper"
|
||||||
class="relative block w-full"
|
class="relative block w-full"
|
||||||
@@ -100,12 +103,12 @@
|
|||||||
:style="horizontalGapStyle"
|
:style="horizontalGapStyle"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(week, weekIndex) in weekColumns"
|
v-for="(week, weekIndex) in visibleWeekColumns"
|
||||||
:key="`month-${weekIndex}`"
|
:key="`month-${weekIndex}`"
|
||||||
:style="monthCellStyle"
|
:style="monthCellStyle"
|
||||||
class="text-center"
|
class="text-center"
|
||||||
>
|
>
|
||||||
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
|
<span v-if="visibleMonthMarkers[weekIndex]">{{ visibleMonthMarkers[weekIndex] }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -113,7 +116,7 @@
|
|||||||
:style="horizontalGapStyle"
|
:style="horizontalGapStyle"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(week, weekIndex) in weekColumns"
|
v-for="(week, weekIndex) in visibleWeekColumns"
|
||||||
:key="weekIndex"
|
:key="weekIndex"
|
||||||
class="flex flex-col"
|
class="flex flex-col"
|
||||||
:style="verticalGapStyle"
|
:style="verticalGapStyle"
|
||||||
@@ -161,11 +164,15 @@ const props = withDefaults(defineProps<{
|
|||||||
title?: string
|
title?: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
showHeader?: boolean
|
showHeader?: boolean
|
||||||
|
valueLabel?: string
|
||||||
|
actualValueLabel?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
data: undefined,
|
data: undefined,
|
||||||
title: undefined,
|
title: undefined,
|
||||||
subtitle: undefined,
|
subtitle: undefined,
|
||||||
showHeader: true
|
showHeader: true,
|
||||||
|
valueLabel: '费用',
|
||||||
|
actualValueLabel: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||||
@@ -270,7 +277,47 @@ const monthMarkers = computed(() => {
|
|||||||
if (month === lastMonth) {
|
if (month === lastMonth) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
markers[index] = `${month + 1}月`
|
markers[index] = String(month + 1)
|
||||||
|
lastMonth = month
|
||||||
|
})
|
||||||
|
|
||||||
|
return markers
|
||||||
|
})
|
||||||
|
|
||||||
|
const MIN_CELL_SIZE = 6
|
||||||
|
|
||||||
|
// 根据容器宽度计算最多能放多少周,然后从后面截取(保留最新数据)
|
||||||
|
const maxVisibleWeeks = computed(() => {
|
||||||
|
const total = weekColumns.value.length
|
||||||
|
if (!heatmapWidth.value || !total) return total
|
||||||
|
// 先尝试用理想尺寸放全部
|
||||||
|
const totalGapAll = Math.max(total - 1, 0) * cellGap.value
|
||||||
|
const rawSizeAll = (heatmapWidth.value - totalGapAll) / total
|
||||||
|
if (rawSizeAll >= MIN_CELL_SIZE) return total
|
||||||
|
// 放不下,算容器最多能容纳几周(用最小尺寸)
|
||||||
|
// n * MIN_CELL_SIZE + (n-1) * gap <= width
|
||||||
|
const maxN = Math.floor((heatmapWidth.value + cellGap.value) / (MIN_CELL_SIZE + cellGap.value))
|
||||||
|
return Math.max(1, Math.min(maxN, total))
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleWeekColumns = computed(() => {
|
||||||
|
const all = weekColumns.value
|
||||||
|
const n = maxVisibleWeeks.value
|
||||||
|
if (n >= all.length) return all
|
||||||
|
return all.slice(all.length - n)
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleMonthMarkers = computed(() => {
|
||||||
|
const markers: Record<number, string> = {}
|
||||||
|
const columns = visibleWeekColumns.value
|
||||||
|
let lastMonth: number | null = null
|
||||||
|
|
||||||
|
columns.forEach((week, index) => {
|
||||||
|
const firstValid = week.find((day): day is DayWithMeta => day !== null)
|
||||||
|
if (!firstValid) return
|
||||||
|
const month = firstValid.dateObj.getUTCMonth()
|
||||||
|
if (month === lastMonth) return
|
||||||
|
markers[index] = String(month + 1)
|
||||||
lastMonth = month
|
lastMonth = month
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,7 +329,7 @@ let mediaQuery: MediaQueryList | null = null
|
|||||||
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
|
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
|
||||||
|
|
||||||
const recalcCellSize = () => {
|
const recalcCellSize = () => {
|
||||||
const columnCount = weekColumns.value.length
|
const columnCount = visibleWeekColumns.value.length
|
||||||
if (!columnCount || !heatmapWidth.value) {
|
if (!columnCount || !heatmapWidth.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -290,12 +337,12 @@ const recalcCellSize = () => {
|
|||||||
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
|
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
|
||||||
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
|
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
|
||||||
const rawSize = availableSpace / columnCount
|
const rawSize = availableSpace / columnCount
|
||||||
// 自适应尺寸,最小 6px
|
// 当所有列都能放下时,让格子继续随容器放大,避免宽屏下出现大块留白。
|
||||||
cellSize.value = Math.max(6, rawSize)
|
cellSize.value = Math.max(MIN_CELL_SIZE, rawSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
|
[() => heatmapWidth.value, () => visibleWeekColumns.value.length, () => cellGap.value],
|
||||||
() => {
|
() => {
|
||||||
recalcCellSize()
|
recalcCellSize()
|
||||||
},
|
},
|
||||||
@@ -345,7 +392,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
|
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
|
||||||
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
const tooltipWidth = 200
|
const tooltipWidth = 280
|
||||||
const tooltipHeight = 72
|
const tooltipHeight = 72
|
||||||
|
|
||||||
// Calculate horizontal position (centered on cell)
|
// Calculate horizontal position (centered on cell)
|
||||||
@@ -402,10 +449,10 @@ function getCellStyle(requests: number) {
|
|||||||
|
|
||||||
function buildTooltip(day: ActivityHeatmapDay): string {
|
function buildTooltip(day: ActivityHeatmapDay): string {
|
||||||
const dateLabel = day.date
|
const dateLabel = day.date
|
||||||
const costLabel = formatCurrency(day.total_cost || 0)
|
const costLabel = `${props.valueLabel} ${formatCurrency(day.total_cost || 0)}`
|
||||||
const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel]
|
const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel]
|
||||||
if (day.actual_total_cost !== undefined) {
|
if (props.actualValueLabel && day.actual_total_cost !== undefined) {
|
||||||
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`)
|
parts.push(`${props.actualValueLabel} ${formatCurrency(day.actual_total_cost)}`)
|
||||||
}
|
}
|
||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Card class="p-4 space-y-2">
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
{{ label }}
|
|
||||||
</div>
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{ value }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-xs"
|
|
||||||
:class="changeClass"
|
|
||||||
>
|
|
||||||
<span v-if="changePercent !== null">{{ changePercent }}%</span>
|
|
||||||
<span v-else>--</span>
|
|
||||||
<span class="ml-1 text-muted-foreground">vs 对比期</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { Card } from '@/components/ui'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
changePercent: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const changeClass = computed(() => {
|
|
||||||
if (props.changePercent === null) return 'text-muted-foreground'
|
|
||||||
if (props.changePercent > 0) return 'text-emerald-500'
|
|
||||||
if (props.changePercent < 0) return 'text-rose-500'
|
|
||||||
return 'text-muted-foreground'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-sm font-semibold">
|
|
||||||
{{ title }}
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
v-if="subtitle"
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>{{ subtitle }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="p-6"
|
|
||||||
>
|
|
||||||
<LoadingState />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="h-[280px]"
|
|
||||||
>
|
|
||||||
<LineChart
|
|
||||||
:data="chartData"
|
|
||||||
:options="chartOptions"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import LineChart from '@/components/charts/LineChart.vue'
|
|
||||||
import { LoadingState } from '@/components/common'
|
|
||||||
import { formatCurrency } from '@/utils/format'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
history: Array<{ date: string; total_cost: number }>
|
|
||||||
forecast: Array<{ date: string; total_cost: number }>
|
|
||||||
loading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
subtitle: undefined,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const labels = computed(() => [
|
|
||||||
...props.history.map(item => item.date),
|
|
||||||
...props.forecast.map(item => item.date)
|
|
||||||
])
|
|
||||||
|
|
||||||
const chartData = computed(() => {
|
|
||||||
const historyValues = props.history.map(item => item.total_cost)
|
|
||||||
const forecastValues = props.forecast.map(item => item.total_cost)
|
|
||||||
return {
|
|
||||||
labels: labels.value,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: '实际成本',
|
|
||||||
data: historyValues.concat(new Array(forecastValues.length).fill(null)),
|
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '预测成本',
|
|
||||||
data: new Array(historyValues.length).fill(null).concat(forecastValues),
|
|
||||||
borderColor: 'rgb(234, 179, 8)',
|
|
||||||
backgroundColor: 'rgba(234, 179, 8, 0.15)',
|
|
||||||
borderDash: [6, 4],
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
|
||||||
plugins: {
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: (context: { parsed?: { y?: number }; dataset: { label?: string } }) => {
|
|
||||||
const value = context.parsed?.y ?? 0
|
|
||||||
return `${context.dataset.label}: ${formatCurrency(value)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
@@ -1,14 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<h3 class="text-sm font-semibold">
|
<div>
|
||||||
{{ title }}
|
<h3 class="text-sm font-semibold">
|
||||||
</h3>
|
{{ title }}
|
||||||
<span
|
</h3>
|
||||||
v-if="subtitle"
|
<p
|
||||||
class="text-xs text-muted-foreground"
|
v-if="subtitle"
|
||||||
>{{ subtitle }}</span>
|
class="mt-1 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ subtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!unavailable"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<div class="rounded-full border border-rose-500/20 bg-rose-500/8 px-2.5 py-1 text-[10px] font-medium text-rose-700 dark:text-rose-300">
|
||||||
|
总错误 {{ totalErrors }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="topCategory"
|
||||||
|
class="rounded-full border border-border/70 bg-muted/20 px-2.5 py-1 text-[10px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
TOP {{ topCategory.label }} · {{ topCategory.count }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="loading"
|
v-if="loading"
|
||||||
class="p-6"
|
class="p-6"
|
||||||
@@ -16,57 +36,116 @@
|
|||||||
<LoadingState />
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else-if="unavailable"
|
||||||
class="h-[260px]"
|
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<DoughnutChart
|
{{ unavailableText }}
|
||||||
:data="chartData"
|
</div>
|
||||||
:options="chartOptions"
|
<div
|
||||||
/>
|
v-else-if="!hasData"
|
||||||
|
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无错误分布数据
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-2xl border border-border/60 bg-background p-3"
|
||||||
|
>
|
||||||
|
<div class="mx-auto w-full max-w-[320px]">
|
||||||
|
<DoughnutChart
|
||||||
|
:data="chartData"
|
||||||
|
:options="chartOptions"
|
||||||
|
:show-legend="true"
|
||||||
|
:height="248"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js'
|
||||||
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
||||||
import { LoadingState } from '@/components/common'
|
import { LoadingState } from '@/components/common'
|
||||||
import type { ErrorDistributionItem } from '@/api/admin'
|
|
||||||
|
interface ErrorDistributionItem {
|
||||||
|
category: string
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
distribution: ErrorDistributionItem[]
|
distribution: ErrorDistributionItem[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
unavailable?: boolean
|
||||||
|
unavailableText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
subtitle: undefined,
|
subtitle: undefined,
|
||||||
loading: false
|
loading: false,
|
||||||
|
unavailable: false,
|
||||||
|
unavailableText: '数据暂不可用',
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartData = computed(() => ({
|
const chartColors = [
|
||||||
labels: props.distribution.map(item => item.category),
|
'rgba(226, 85, 118, 0.88)',
|
||||||
|
'rgba(84, 135, 237, 0.86)',
|
||||||
|
'rgba(225, 165, 52, 0.84)',
|
||||||
|
'rgba(68, 187, 132, 0.82)',
|
||||||
|
'rgba(148, 163, 184, 0.8)',
|
||||||
|
'rgba(145, 118, 214, 0.82)',
|
||||||
|
]
|
||||||
|
|
||||||
|
const totalErrors = computed(() => props.distribution.reduce((sum, item) => sum + item.count, 0))
|
||||||
|
const hasData = computed(() => totalErrors.value > 0)
|
||||||
|
const distributionWithShare = computed(() => props.distribution.map(item => ({
|
||||||
|
...item,
|
||||||
|
label: item.label?.trim() || item.category,
|
||||||
|
share: totalErrors.value > 0 ? `${((item.count / totalErrors.value) * 100).toFixed(1)}%` : '0.0%',
|
||||||
|
})))
|
||||||
|
const topCategory = computed(() => (
|
||||||
|
distributionWithShare.value.slice().sort((left, right) => right.count - left.count)[0] ?? null
|
||||||
|
))
|
||||||
|
|
||||||
|
const chartData = computed<ChartData<'doughnut'>>(() => ({
|
||||||
|
labels: distributionWithShare.value.map(item => item.label),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
data: props.distribution.map(item => item.count),
|
data: distributionWithShare.value.map(item => item.count),
|
||||||
backgroundColor: [
|
backgroundColor: distributionWithShare.value.map((_, index) => chartColors[index % chartColors.length]),
|
||||||
'rgba(239, 68, 68, 0.7)',
|
borderColor: 'rgba(255,255,255,0.92)',
|
||||||
'rgba(59, 130, 246, 0.7)',
|
borderWidth: 2,
|
||||||
'rgba(234, 179, 8, 0.7)',
|
hoverOffset: 6,
|
||||||
'rgba(34, 197, 94, 0.7)',
|
},
|
||||||
'rgba(148, 163, 184, 0.7)'
|
],
|
||||||
],
|
|
||||||
borderWidth: 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
|
||||||
|
cutout: '68%',
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom' as const
|
position: 'bottom',
|
||||||
}
|
align: 'center',
|
||||||
}
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle',
|
||||||
|
boxWidth: 10,
|
||||||
|
padding: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const value = Number(context.raw ?? 0)
|
||||||
|
const percentage = totalErrors.value > 0 ? ((value / totalErrors.value) * 100).toFixed(1) : '0.0'
|
||||||
|
return `${context.label ?? '错误'}: ${value} (${percentage}%)`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,34 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<TableCard :title="title">
|
<TableCard :title="title">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Select
|
<div
|
||||||
v-if="showMetricSelect"
|
v-if="isRefreshing"
|
||||||
:model-value="metric"
|
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
|
||||||
@update:model-value="emitMetric"
|
|
||||||
>
|
>
|
||||||
<SelectTrigger class="h-8 text-xs w-28">
|
更新中
|
||||||
<SelectValue placeholder="指标" />
|
</div>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="requests">
|
|
||||||
请求数
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="tokens">
|
|
||||||
Tokens
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="cost">
|
|
||||||
成本
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="loading"
|
v-if="showLoadingState"
|
||||||
class="p-6"
|
class="p-6"
|
||||||
>
|
>
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="showUnavailableState"
|
||||||
|
class="p-6"
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
type="error"
|
||||||
|
:title="unavailableTitle"
|
||||||
|
:description="unavailableDescription"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="items.length === 0"
|
v-else-if="items.length === 0"
|
||||||
class="p-6"
|
class="p-6"
|
||||||
@@ -38,20 +34,29 @@
|
|||||||
description="当前时间范围内没有统计结果"
|
description="当前时间范围内没有统计结果"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Table v-else>
|
<Table
|
||||||
|
v-else
|
||||||
|
class="table-fixed"
|
||||||
|
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="w-16">
|
<TableHead class="w-14 px-2.5">
|
||||||
排名
|
排名
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>名称</TableHead>
|
<TableHead class="w-[34%] px-2.5">
|
||||||
<TableHead class="text-right">
|
名称
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-[15%] px-2.5 text-right">
|
||||||
请求数
|
请求数
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="text-right">
|
<TableHead class="w-[18%] px-2.5 text-right">
|
||||||
Tokens
|
Tokens
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="text-right">
|
<TableHead class="w-[16%] px-2.5 text-right">
|
||||||
|
费用
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-[17%] px-2.5 text-right">
|
||||||
成本
|
成本
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -60,68 +65,118 @@
|
|||||||
<TableRow
|
<TableRow
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
|
class="cursor-pointer"
|
||||||
|
:class="item.id === selectedId
|
||||||
|
? 'bg-[linear-gradient(90deg,rgba(232,145,89,0.16),rgba(232,145,89,0.05),transparent)] shadow-[inset_4px_0_0_0_rgba(232,145,89,0.9)] hover:bg-[linear-gradient(90deg,rgba(232,145,89,0.2),rgba(232,145,89,0.06),transparent)]'
|
||||||
|
: 'hover:bg-muted/25'"
|
||||||
|
@click="emit('select', item.id)"
|
||||||
>
|
>
|
||||||
<TableCell class="font-medium">
|
<TableCell class="px-2.5 py-2.5">
|
||||||
{{ item.rank }}
|
<div
|
||||||
|
class="inline-flex h-7 min-w-7 items-center justify-center rounded-full border px-2 text-[11px] font-semibold tabular-nums transition-colors"
|
||||||
|
:class="item.id === selectedId
|
||||||
|
? 'border-amber-500/70 bg-amber-500/15 text-amber-700 dark:text-amber-300'
|
||||||
|
: 'border-border/70 bg-muted/35 text-muted-foreground'"
|
||||||
|
>
|
||||||
|
{{ item.rank }}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{{ item.name }}</TableCell>
|
<TableCell class="px-2.5 py-2.5">
|
||||||
<TableCell class="text-right">
|
<div class="min-w-0">
|
||||||
{{ item.requests }}
|
<div
|
||||||
|
class="truncate text-sm font-medium"
|
||||||
|
:title="item.name"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||||
|
费用优先
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right">
|
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums">
|
||||||
|
{{ item.requests.toLocaleString() }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums">
|
||||||
{{ formatTokens(item.tokens) }}
|
{{ formatTokens(item.tokens) }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right">
|
<TableCell class="px-2.5 py-2.5 text-right text-[11px] font-medium tabular-nums text-amber-700 dark:text-amber-300">
|
||||||
{{ formatCurrency(item.cost) }}
|
{{ formatCurrency(item.cost) }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{{ formatCurrency(item.actualCost) }}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
<template v-if="showPagination" #pagination>
|
||||||
|
<Pagination
|
||||||
|
:current="currentPage"
|
||||||
|
:total="totalItems"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:show-page-size-selector="false"
|
||||||
|
@update:current="emit('update:currentPage', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</TableCard>
|
</TableCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { EmptyState, LoadingState } from '@/components/common'
|
import { EmptyState, LoadingState } from '@/components/common'
|
||||||
import { TableCard } from '@/components/ui'
|
import { Pagination, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableCard } from '@/components/ui'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from '@/components/ui'
|
|
||||||
import { formatCurrency, formatTokens } from '@/utils/format'
|
import { formatCurrency, formatTokens } from '@/utils/format'
|
||||||
import type { LeaderboardItem } from '@/api/admin'
|
|
||||||
|
interface LeaderboardItem {
|
||||||
|
rank: number
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost: number
|
||||||
|
actualCost: number
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
items: LeaderboardItem[]
|
items: LeaderboardItem[]
|
||||||
metric: 'requests' | 'tokens' | 'cost'
|
selectedId?: string
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
showMetricSelect?: boolean
|
hasLoaded?: boolean
|
||||||
|
currentPage?: number
|
||||||
|
totalItems?: number
|
||||||
|
pageSize?: number
|
||||||
|
showPagination?: boolean
|
||||||
|
unavailable?: boolean
|
||||||
|
unavailableTitle?: string
|
||||||
|
unavailableDescription?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
selectedId: undefined,
|
||||||
loading: false,
|
loading: false,
|
||||||
showMetricSelect: true
|
hasLoaded: false,
|
||||||
|
currentPage: 1,
|
||||||
|
totalItems: 0,
|
||||||
|
pageSize: 5,
|
||||||
|
showPagination: false,
|
||||||
|
unavailable: false,
|
||||||
|
unavailableTitle: '排行榜暂不可用',
|
||||||
|
unavailableDescription: '接口未返回结果,请稍后重试',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void
|
(e: 'select', value: string): void
|
||||||
|
(e: 'update:currentPage', value: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const metric = computed(() => props.metric)
|
const selectedId = computed(() => props.selectedId)
|
||||||
|
const showLoadingState = computed(() => props.loading && (!props.hasLoaded || props.items.length === 0))
|
||||||
function emitMetric(value: string) {
|
const showUnavailableState = computed(() => props.unavailable && !props.loading && props.items.length === 0)
|
||||||
if (value === 'requests' || value === 'tokens' || value === 'cost') {
|
const isRefreshing = computed(() => props.loading && props.hasLoaded && props.items.length > 0)
|
||||||
emit('update:metric', value)
|
const currentPage = computed(() => props.currentPage)
|
||||||
}
|
const totalItems = computed(() => props.totalItems)
|
||||||
}
|
const pageSize = computed(() => props.pageSize)
|
||||||
|
const showPagination = computed(() => props.showPagination && props.totalItems > props.pageSize)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,27 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<h3 class="text-sm font-semibold">
|
<div>
|
||||||
{{ title }}
|
<h3 class="text-sm font-semibold">
|
||||||
</h3>
|
{{ title }}
|
||||||
<span
|
</h3>
|
||||||
v-if="subtitle"
|
<p
|
||||||
class="text-xs text-muted-foreground"
|
v-if="subtitle"
|
||||||
>{{ subtitle }}</span>
|
class="mt-1 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ subtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="summaryMetrics.length > 0 && !unavailable"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="metric in summaryMetrics"
|
||||||
|
:key="metric.label"
|
||||||
|
class="rounded-full border px-2.5 py-1 text-[10px] font-medium"
|
||||||
|
:class="metric.className"
|
||||||
|
>
|
||||||
|
{{ metric.label }} {{ metric.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="loading"
|
v-if="loading"
|
||||||
class="p-6"
|
class="p-6"
|
||||||
>
|
>
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="unavailable"
|
||||||
|
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ unavailableText }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!hasData"
|
||||||
|
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无延迟数据
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="h-[260px]"
|
class="rounded-2xl border border-border/60 bg-background p-3"
|
||||||
>
|
>
|
||||||
<LineChart
|
<LineChart
|
||||||
:data="chartData"
|
:data="chartData"
|
||||||
:options="chartOptions"
|
:options="chartOptions"
|
||||||
|
:height="248"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,9 +61,19 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js'
|
||||||
import LineChart from '@/components/charts/LineChart.vue'
|
import LineChart from '@/components/charts/LineChart.vue'
|
||||||
import { LoadingState } from '@/components/common'
|
import { LoadingState } from '@/components/common'
|
||||||
import type { PercentileItem } from '@/api/admin'
|
|
||||||
|
interface PercentileItem {
|
||||||
|
date: string
|
||||||
|
p50_response_time_ms?: number | null
|
||||||
|
p90_response_time_ms?: number | null
|
||||||
|
p99_response_time_ms?: number | null
|
||||||
|
p50_first_byte_time_ms?: number | null
|
||||||
|
p90_first_byte_time_ms?: number | null
|
||||||
|
p99_first_byte_time_ms?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
@@ -39,61 +81,159 @@ interface Props {
|
|||||||
series: PercentileItem[]
|
series: PercentileItem[]
|
||||||
mode: 'response' | 'ttfb'
|
mode: 'response' | 'ttfb'
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
unavailable?: boolean
|
||||||
|
unavailableText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
subtitle: undefined,
|
subtitle: undefined,
|
||||||
loading: false
|
loading: false,
|
||||||
|
unavailable: false,
|
||||||
|
unavailableText: '数据暂不可用',
|
||||||
})
|
})
|
||||||
|
|
||||||
const labels = computed(() => props.series.map(item => item.date))
|
|
||||||
|
|
||||||
// 毫秒转秒
|
|
||||||
function msToSeconds(ms: number | null | undefined): number | null {
|
function msToSeconds(ms: number | null | undefined): number | null {
|
||||||
if (ms == null) return null
|
if (ms == null) return null
|
||||||
return ms / 1000
|
return Number((ms / 1000).toFixed(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = computed(() => {
|
function getPercentileValue(item: PercentileItem, percentile: '50' | '90' | '99') {
|
||||||
const p50Key = props.mode === 'response' ? 'p50_response_time_ms' : 'p50_first_byte_time_ms'
|
if (props.mode === 'response') {
|
||||||
const p90Key = props.mode === 'response' ? 'p90_response_time_ms' : 'p90_first_byte_time_ms'
|
if (percentile === '50') return msToSeconds(item.p50_response_time_ms)
|
||||||
const p99Key = props.mode === 'response' ? 'p99_response_time_ms' : 'p99_first_byte_time_ms'
|
if (percentile === '90') return msToSeconds(item.p90_response_time_ms)
|
||||||
|
return msToSeconds(item.p99_response_time_ms)
|
||||||
return {
|
|
||||||
labels: labels.value,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'P50',
|
|
||||||
data: props.series.map(item => msToSeconds(item[p50Key])),
|
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'P90',
|
|
||||||
data: props.series.map(item => msToSeconds(item[p90Key])),
|
|
||||||
borderColor: 'rgb(234, 179, 8)',
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'P99',
|
|
||||||
data: props.series.map(item => msToSeconds(item[p99Key])),
|
|
||||||
borderColor: 'rgb(239, 68, 68)',
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
if (percentile === '50') return msToSeconds(item.p50_first_byte_time_ms)
|
||||||
|
if (percentile === '90') return msToSeconds(item.p90_first_byte_time_ms)
|
||||||
|
return msToSeconds(item.p99_first_byte_time_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestMetricValue(percentile: '50' | '90' | '99') {
|
||||||
|
for (let index = props.series.length - 1; index >= 0; index -= 1) {
|
||||||
|
const value = getPercentileValue(props.series[index], percentile)
|
||||||
|
if (value != null) return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(value: number | null) {
|
||||||
|
if (value == null) return '--'
|
||||||
|
if (value < 1) return `${Math.round(value * 1000)}ms`
|
||||||
|
return `${value.toFixed(2)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = computed(() => props.series.map(item => item.date?.slice(5) ?? ''))
|
||||||
|
const hasData = computed(() => props.series.some(item => (
|
||||||
|
getPercentileValue(item, '50') != null
|
||||||
|
|| getPercentileValue(item, '90') != null
|
||||||
|
|| getPercentileValue(item, '99') != null
|
||||||
|
)))
|
||||||
|
|
||||||
|
const summaryMetrics = computed(() => ([
|
||||||
|
{
|
||||||
|
label: 'P50',
|
||||||
|
value: formatSeconds(getLatestMetricValue('50')),
|
||||||
|
className: 'border-sky-500/20 bg-sky-500/8 text-sky-700 dark:text-sky-300',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'P90',
|
||||||
|
value: formatSeconds(getLatestMetricValue('90')),
|
||||||
|
className: 'border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-300',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'P99',
|
||||||
|
value: formatSeconds(getLatestMetricValue('99')),
|
||||||
|
className: 'border-rose-500/20 bg-rose-500/8 text-rose-700 dark:text-rose-300',
|
||||||
|
},
|
||||||
|
]))
|
||||||
|
|
||||||
|
const chartData = computed<ChartData<'line'>>(() => ({
|
||||||
|
labels: labels.value,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'P50',
|
||||||
|
data: props.series.map(item => getPercentileValue(item, '50')),
|
||||||
|
borderColor: 'rgb(70, 136, 240)',
|
||||||
|
backgroundColor: 'rgba(70, 136, 240, 0.10)',
|
||||||
|
borderWidth: 2.4,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.32,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'P90',
|
||||||
|
data: props.series.map(item => getPercentileValue(item, '90')),
|
||||||
|
borderColor: 'rgb(223, 166, 55)',
|
||||||
|
backgroundColor: 'rgba(223, 166, 55, 0.08)',
|
||||||
|
borderWidth: 2.1,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'P99',
|
||||||
|
data: props.series.map(item => getPercentileValue(item, '99')),
|
||||||
|
borderColor: 'rgb(214, 92, 111)',
|
||||||
|
backgroundColor: 'rgba(214, 92, 111, 0.08)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [6, 4],
|
||||||
|
fill: false,
|
||||||
|
tension: 0.28,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const chartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
boxWidth: 10,
|
||||||
|
padding: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: items => props.series[items[0]?.dataIndex ?? -1]?.date ?? '',
|
||||||
|
label: context => `${context.dataset.label}: ${formatSeconds(Number(context.raw ?? 0))}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
x: {
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.06)',
|
||||||
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: (value: number) => `${value}s`
|
color: 'rgb(100, 116, 139)',
|
||||||
}
|
maxRotation: 0,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.07)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(100, 116, 139)',
|
||||||
|
callback: value => formatSeconds(Number(value)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Card class="p-4 space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-sm font-semibold">
|
|
||||||
{{ title }}
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
v-if="subtitle"
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>{{ subtitle }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="p-4"
|
|
||||||
>
|
|
||||||
<LoadingState />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="providers.length === 0"
|
|
||||||
class="p-4"
|
|
||||||
>
|
|
||||||
<EmptyState
|
|
||||||
title="暂无数据"
|
|
||||||
description="暂无月卡配额数据"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="provider in providers"
|
|
||||||
:key="provider.id"
|
|
||||||
class="space-y-2"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between text-xs">
|
|
||||||
<span class="font-medium">{{ provider.name }}</span>
|
|
||||||
<span class="text-muted-foreground">
|
|
||||||
{{ formatCurrency(provider.used_usd) }} / {{ formatCurrency(provider.quota_usd) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-2 rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-2 rounded-full bg-primary"
|
|
||||||
:style="{ width: `${Math.min(provider.usage_percent, 100)}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
|
|
||||||
<span>剩余 {{ formatCurrency(provider.remaining_usd) }}</span>
|
|
||||||
<span v-if="provider.estimated_exhaust_at">
|
|
||||||
预计耗尽 {{ formatDate(provider.estimated_exhaust_at) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Card } from '@/components/ui'
|
|
||||||
import { EmptyState, LoadingState } from '@/components/common'
|
|
||||||
import { formatCurrency } from '@/utils/format'
|
|
||||||
import type { QuotaUsageProvider } from '@/api/admin'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
providers: QuotaUsageProvider[]
|
|
||||||
loading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
subtitle: undefined,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatDate(value: string) {
|
|
||||||
return new Date(value).toLocaleDateString()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
|
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
|
||||||
export { default as LeaderboardTable } from './LeaderboardTable.vue'
|
export { default as LeaderboardTable } from './LeaderboardTable.vue'
|
||||||
export { default as CostForecastChart } from './CostForecastChart.vue'
|
|
||||||
export { default as QuotaProgressCard } from './QuotaProgressCard.vue'
|
|
||||||
export { default as PercentileChart } from './PercentileChart.vue'
|
export { default as PercentileChart } from './PercentileChart.vue'
|
||||||
export { default as ErrorDistributionChart } from './ErrorDistributionChart.vue'
|
export { default as ErrorDistributionChart } from './ErrorDistributionChart.vue'
|
||||||
export { default as ComparisonCard } from './ComparisonCard.vue'
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const isHidden = computed(
|
|||||||
)
|
)
|
||||||
const itemClass = computed(() =>
|
const itemClass = computed(() =>
|
||||||
cn(
|
cn(
|
||||||
'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none',
|
'relative flex min-w-0 w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||||
'data-[highlighted]:bg-accent focus:bg-accent text-foreground',
|
'data-[highlighted]:bg-accent focus:bg-accent text-foreground',
|
||||||
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
isHidden.value && 'hidden',
|
isHidden.value && 'hidden',
|
||||||
@@ -101,8 +101,16 @@ onBeforeUnmount(() => {
|
|||||||
<Check class="h-4 w-4" />
|
<Check class="h-4 w-4" />
|
||||||
</SelectItemIndicator>
|
</SelectItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectItemText>
|
<SelectItemText
|
||||||
<slot />
|
as="div"
|
||||||
|
class="min-w-0 flex-1 overflow-hidden"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block w-full min-w-0 truncate"
|
||||||
|
:title="normalizedText"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
</SelectItemText>
|
</SelectItemText>
|
||||||
</SelectItemPrimitive>
|
</SelectItemPrimitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const triggerClass = computed(() =>
|
|||||||
:class="triggerClass"
|
:class="triggerClass"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
>
|
>
|
||||||
<span class="truncate">
|
<span class="min-w-0 flex-1 truncate">
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" />
|
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" />
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { useLeaderboardData } from './useLeaderboardData'
|
||||||
|
export type { LeaderboardDimension } from './useLeaderboardData'
|
||||||
|
export { usePerformanceData } from './usePerformanceData'
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { AnalyticsLeaderboardItem } from '@/api/analytics'
|
||||||
|
|
||||||
|
export interface LeaderboardTableRow {
|
||||||
|
rank: number
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost: number
|
||||||
|
actualCost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAnalyticsLeaderboardItem(item: AnalyticsLeaderboardItem): LeaderboardTableRow {
|
||||||
|
return {
|
||||||
|
rank: item.rank,
|
||||||
|
id: item.id,
|
||||||
|
name: item.label,
|
||||||
|
requests: item.requests_total,
|
||||||
|
tokens: item.total_tokens,
|
||||||
|
cost: item.total_cost_usd,
|
||||||
|
actualCost: item.actual_total_cost_usd,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
|
||||||
|
import { analyticsApi, type AnalyticsFilterOption, type AnalyticsLeaderboardItem, type AnalyticsTimeseriesBucket } from '@/api/analytics'
|
||||||
|
import { buildTimeRangeParams, createLoader, createRequestGuard, useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
|
||||||
|
import { fillMissingTimeseriesBuckets } from '@/utils/analyticsTimeseries'
|
||||||
|
import { isAnalyticsSingleDayRange, resolveAnalyticsAutoGranularity, type AnalyticsGranularityOption } from '@/utils/analyticsGranularity'
|
||||||
|
import { mapAnalyticsLeaderboardItem, type LeaderboardTableRow } from './mappers'
|
||||||
|
|
||||||
|
export type LeaderboardDimension = 'users' | 'api_keys'
|
||||||
|
|
||||||
|
export interface LeaderboardSummary {
|
||||||
|
total_requests: number
|
||||||
|
total_tokens: number
|
||||||
|
total_cost: number
|
||||||
|
total_actual_cost: number
|
||||||
|
avg_response_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimensionToEntity(dimension: LeaderboardDimension): 'user' | 'api_key' {
|
||||||
|
return dimension === 'users' ? 'user' : 'api_key'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLeaderboardData(dimension: Ref<LeaderboardDimension>) {
|
||||||
|
const filters = useInjectedAnalyticsFilters()
|
||||||
|
|
||||||
|
const leaderboardItems = ref<AnalyticsLeaderboardItem[]>([])
|
||||||
|
const latestUserOptions = ref<AnalyticsFilterOption[]>([])
|
||||||
|
const latestApiKeyOptions = ref<AnalyticsFilterOption[]>([])
|
||||||
|
const leaderboardLoading = ref(false)
|
||||||
|
const leaderboardError = ref(false)
|
||||||
|
const leaderboardHasLoaded = ref(false)
|
||||||
|
const selectedId = ref<string>('')
|
||||||
|
|
||||||
|
const selectedSummary = ref<LeaderboardSummary | null>(null)
|
||||||
|
const selectedTrendBuckets = ref<AnalyticsTimeseriesBucket[]>([])
|
||||||
|
const panelLoading = ref(false)
|
||||||
|
const panelError = ref(false)
|
||||||
|
const panelHasLoaded = ref(false)
|
||||||
|
|
||||||
|
const leaderboardGuard = createRequestGuard()
|
||||||
|
const filterOptionsGuard = createRequestGuard()
|
||||||
|
const panelGuard = createRequestGuard()
|
||||||
|
|
||||||
|
const entityOptions = computed(() => (
|
||||||
|
dimension.value === 'users' ? latestUserOptions.value : latestApiKeyOptions.value
|
||||||
|
))
|
||||||
|
const entityLabelMap = computed(() => (
|
||||||
|
new Map(entityOptions.value.map(option => [option.value, option.label]))
|
||||||
|
))
|
||||||
|
|
||||||
|
const leaderboard = computed<LeaderboardTableRow[]>(() => (
|
||||||
|
leaderboardItems.value.map(item => ({
|
||||||
|
...mapAnalyticsLeaderboardItem(item),
|
||||||
|
name: entityLabelMap.value.get(item.id) ?? item.label,
|
||||||
|
}))
|
||||||
|
))
|
||||||
|
|
||||||
|
const selectedItem = computed(() => (
|
||||||
|
leaderboard.value.find(item => item.id === selectedId.value) ?? null
|
||||||
|
))
|
||||||
|
|
||||||
|
const selectedLabel = computed(() => (
|
||||||
|
selectedItem.value?.name ?? entityLabelMap.value.get(selectedId.value) ?? null
|
||||||
|
))
|
||||||
|
|
||||||
|
const resolvedTrendGranularity = computed(() => resolveAnalyticsAutoGranularity(filters.timeRange.value))
|
||||||
|
const baseTimeRangePayload = computed(() => ({
|
||||||
|
start_date: filters.timeRange.value.start_date,
|
||||||
|
end_date: filters.timeRange.value.end_date,
|
||||||
|
preset: filters.timeRange.value.preset,
|
||||||
|
timezone: filters.timeRange.value.timezone,
|
||||||
|
tz_offset_minutes: filters.timeRange.value.tz_offset_minutes,
|
||||||
|
}))
|
||||||
|
const trendTimeRangePayload = computed(() => ({
|
||||||
|
...baseTimeRangePayload.value,
|
||||||
|
granularity: resolvedTrendGranularity.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const currentTrendGranularitySelection = computed<AnalyticsGranularityOption>({
|
||||||
|
get: () => filters.timeRange.value.granularity || 'auto',
|
||||||
|
set: (value) => {
|
||||||
|
if (value === 'hour' && !isAnalyticsSingleDayRange(filters.timeRange.value)) {
|
||||||
|
value = 'auto'
|
||||||
|
}
|
||||||
|
filters.timeRange.value = {
|
||||||
|
...filters.timeRange.value,
|
||||||
|
granularity: value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetPanelData(invalidate = false) {
|
||||||
|
if (invalidate) panelGuard.invalidate()
|
||||||
|
panelLoading.value = false
|
||||||
|
panelError.value = false
|
||||||
|
selectedSummary.value = null
|
||||||
|
selectedTrendBuckets.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFilterOptions() {
|
||||||
|
const requestId = filterOptionsGuard.next()
|
||||||
|
const response = await analyticsApi.getFilterOptions({
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
time_range: baseTimeRangePayload.value,
|
||||||
|
filters: {},
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
if (filterOptionsGuard.isStale(requestId) || !response) return
|
||||||
|
latestUserOptions.value = response.users ?? []
|
||||||
|
latestApiKeyOptions.value = response.api_keys ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLeaderboard() {
|
||||||
|
const requestId = leaderboardGuard.next()
|
||||||
|
leaderboardLoading.value = true
|
||||||
|
leaderboardError.value = false
|
||||||
|
try {
|
||||||
|
const response = await analyticsApi.getLeaderboard({
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
time_range: baseTimeRangePayload.value,
|
||||||
|
filters: {
|
||||||
|
statuses: ['completed', 'failed', 'cancelled'],
|
||||||
|
},
|
||||||
|
entity: dimensionToEntity(dimension.value),
|
||||||
|
metric: 'total_cost_usd',
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (leaderboardGuard.isStale(requestId)) return
|
||||||
|
leaderboardItems.value = response.items
|
||||||
|
|
||||||
|
const nextSelectedId = response.items.some(item => item.id === selectedId.value)
|
||||||
|
? selectedId.value
|
||||||
|
: (response.items[0]?.id ?? '')
|
||||||
|
|
||||||
|
if (selectedId.value !== nextSelectedId) {
|
||||||
|
selectedId.value = nextSelectedId
|
||||||
|
}
|
||||||
|
leaderboardHasLoaded.value = true
|
||||||
|
} catch {
|
||||||
|
if (leaderboardGuard.isStale(requestId)) return
|
||||||
|
leaderboardError.value = true
|
||||||
|
} finally {
|
||||||
|
if (leaderboardGuard.isCurrent(requestId)) {
|
||||||
|
leaderboardLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPanel() {
|
||||||
|
if (!selectedId.value) {
|
||||||
|
resetPanelData(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = panelGuard.next()
|
||||||
|
panelLoading.value = true
|
||||||
|
panelError.value = false
|
||||||
|
try {
|
||||||
|
const scope = dimension.value === 'users'
|
||||||
|
? { kind: 'user' as const, user_id: selectedId.value }
|
||||||
|
: { kind: 'api_key' as const, api_key_id: selectedId.value }
|
||||||
|
|
||||||
|
const basePayload = {
|
||||||
|
scope,
|
||||||
|
time_range: baseTimeRangePayload.value,
|
||||||
|
filters: {
|
||||||
|
statuses: ['completed', 'failed', 'cancelled'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const [overview, timeseries] = await Promise.all([
|
||||||
|
analyticsApi.getOverview(basePayload),
|
||||||
|
analyticsApi.getTimeseries({
|
||||||
|
...basePayload,
|
||||||
|
time_range: trendTimeRangePayload.value,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (panelGuard.isStale(requestId)) return
|
||||||
|
selectedSummary.value = {
|
||||||
|
total_requests: overview.summary.requests_total,
|
||||||
|
total_tokens: overview.summary.total_tokens,
|
||||||
|
total_cost: overview.summary.total_cost_usd,
|
||||||
|
total_actual_cost: overview.summary.actual_total_cost_usd,
|
||||||
|
avg_response_time: overview.summary.avg_response_time_ms / 1000,
|
||||||
|
}
|
||||||
|
selectedTrendBuckets.value = fillMissingTimeseriesBuckets(
|
||||||
|
timeseries.buckets,
|
||||||
|
trendTimeRangePayload.value,
|
||||||
|
)
|
||||||
|
panelHasLoaded.value = true
|
||||||
|
} catch {
|
||||||
|
if (panelGuard.isStale(requestId)) return
|
||||||
|
panelError.value = true
|
||||||
|
} finally {
|
||||||
|
if (panelGuard.isCurrent(requestId)) {
|
||||||
|
panelLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterOptionsLoader = createLoader(loadFilterOptions)
|
||||||
|
const leaderboardLoader = createLoader(loadLeaderboard)
|
||||||
|
const panelLoader = createLoader(loadPanel)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [filters.timeRange.value.preset, filters.timeRange.value.start_date, filters.timeRange.value.end_date],
|
||||||
|
() => {
|
||||||
|
if (filters.timeRange.value.granularity === 'hour' && !isAnalyticsSingleDayRange(filters.timeRange.value)) {
|
||||||
|
filters.timeRange.value = {
|
||||||
|
...filters.timeRange.value,
|
||||||
|
granularity: 'auto',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [baseTimeRangePayload.value, dimension.value],
|
||||||
|
() => {
|
||||||
|
filterOptionsLoader.schedule()
|
||||||
|
leaderboardLoader.schedule()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
selectedId.value,
|
||||||
|
dimension.value,
|
||||||
|
baseTimeRangePayload.value,
|
||||||
|
trendTimeRangePayload.value,
|
||||||
|
],
|
||||||
|
() => panelLoader.schedule(),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void (async () => {
|
||||||
|
await filterOptionsLoader.execute()
|
||||||
|
await leaderboardLoader.execute()
|
||||||
|
await panelLoader.execute()
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
filterOptionsLoader.cleanup()
|
||||||
|
leaderboardLoader.cleanup()
|
||||||
|
panelLoader.cleanup()
|
||||||
|
filterOptionsGuard.invalidate()
|
||||||
|
leaderboardGuard.invalidate()
|
||||||
|
panelGuard.invalidate()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
leaderboard,
|
||||||
|
leaderboardLoading,
|
||||||
|
leaderboardError,
|
||||||
|
leaderboardHasLoaded,
|
||||||
|
selectedId,
|
||||||
|
selectedItem,
|
||||||
|
selectedLabel,
|
||||||
|
selectedSummary,
|
||||||
|
selectedTrendBuckets,
|
||||||
|
panelLoading,
|
||||||
|
panelError,
|
||||||
|
panelHasLoaded,
|
||||||
|
resolvedTrendGranularity,
|
||||||
|
currentTrendGranularitySelection,
|
||||||
|
reloadLeaderboard: leaderboardLoader.schedule,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { analyticsApi, type AnalyticsErrorCategory, type AnalyticsErrorTrendItem, type AnalyticsPercentilePoint, type AnalyticsPerformanceResponse, type AnalyticsProviderHealthItem } from '@/api/analytics'
|
||||||
|
import { auditApi, type MonitoringSystemStatus } from '@/api/audit'
|
||||||
|
import { getHealthSummary, type HealthSummary } from '@/api/endpoints'
|
||||||
|
import { createLoader, createRequestGuard, useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
|
||||||
|
|
||||||
|
const EMPTY_LATENCY: AnalyticsPerformanceResponse['latency'] = {
|
||||||
|
response_time_ms: { avg: 0, p50: null, p90: null, p99: null },
|
||||||
|
first_byte_time_ms: { avg: 0, p50: null, p90: null, p99: null },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePerformanceData() {
|
||||||
|
const filters = useInjectedAnalyticsFilters()
|
||||||
|
|
||||||
|
const latency = ref<AnalyticsPerformanceResponse['latency']>(EMPTY_LATENCY)
|
||||||
|
const percentiles = ref<AnalyticsPercentilePoint[]>([])
|
||||||
|
const errorDistribution = ref<AnalyticsErrorCategory[]>([])
|
||||||
|
const errorTrend = ref<AnalyticsErrorTrendItem[]>([])
|
||||||
|
const errorTotal = ref(0)
|
||||||
|
const errorRate = ref(0)
|
||||||
|
const providerStatus = ref<AnalyticsProviderHealthItem[]>([])
|
||||||
|
const healthSummary = ref<HealthSummary | null>(null)
|
||||||
|
const systemStatus = ref<MonitoringSystemStatus | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref(false)
|
||||||
|
const hasLoaded = ref(false)
|
||||||
|
const guard = createRequestGuard()
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const requestId = guard.next()
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = false
|
||||||
|
try {
|
||||||
|
const [response, summaryResponse, systemResponse] = await Promise.all([
|
||||||
|
analyticsApi.getPerformance({
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
time_range: filters.getTimeRangeParams(),
|
||||||
|
filters: {
|
||||||
|
statuses: ['completed', 'failed', 'cancelled'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getHealthSummary().catch(() => null),
|
||||||
|
auditApi.getSystemStatus().catch(() => null),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (guard.isStale(requestId)) return
|
||||||
|
latency.value = response.latency
|
||||||
|
percentiles.value = response.percentiles
|
||||||
|
errorTotal.value = response.errors.total
|
||||||
|
errorRate.value = response.errors.rate
|
||||||
|
errorDistribution.value = response.errors.categories
|
||||||
|
errorTrend.value = response.errors.trend
|
||||||
|
providerStatus.value = response.provider_health
|
||||||
|
if (summaryResponse) {
|
||||||
|
healthSummary.value = summaryResponse
|
||||||
|
}
|
||||||
|
if (systemResponse) {
|
||||||
|
systemStatus.value = systemResponse
|
||||||
|
}
|
||||||
|
hasLoaded.value = true
|
||||||
|
} catch {
|
||||||
|
if (guard.isStale(requestId)) return
|
||||||
|
loadError.value = true
|
||||||
|
} finally {
|
||||||
|
if (guard.isCurrent(requestId)) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = createLoader(load)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
filters.getTimeRangeParams(),
|
||||||
|
],
|
||||||
|
() => loader.schedule(),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => { void loader.execute() })
|
||||||
|
onUnmounted(() => { loader.cleanup(); guard.invalidate() })
|
||||||
|
|
||||||
|
return {
|
||||||
|
latency,
|
||||||
|
percentiles,
|
||||||
|
errorDistribution,
|
||||||
|
errorTrend,
|
||||||
|
errorTotal,
|
||||||
|
errorRate,
|
||||||
|
providerStatus,
|
||||||
|
healthSummary,
|
||||||
|
systemStatus,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
hasLoaded,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { inject, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import type { DateRangeParams, PeriodValue } from '@/features/usage/types'
|
||||||
|
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建时间范围 API 参数(从各页面提取的公共逻辑)
|
||||||
|
*/
|
||||||
|
export function buildTimeRangeParams(timeRange: DateRangeParams) {
|
||||||
|
return {
|
||||||
|
start_date: timeRange.start_date,
|
||||||
|
end_date: timeRange.end_date,
|
||||||
|
preset: timeRange.preset,
|
||||||
|
timezone: timeRange.timezone,
|
||||||
|
tz_offset_minutes: timeRange.tz_offset_minutes,
|
||||||
|
granularity: timeRange.granularity && timeRange.granularity !== 'auto'
|
||||||
|
? timeRange.granularity
|
||||||
|
: 'day',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用防抖加载器工厂
|
||||||
|
* 封装 120ms debounce + in-flight promise 去重 + requestId 过期检查
|
||||||
|
*/
|
||||||
|
export function createLoader(loadFn: () => Promise<void>) {
|
||||||
|
let loadPromise: Promise<void> | null = null
|
||||||
|
let hasPending = false
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debounceTimer = null
|
||||||
|
void execute()
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute() {
|
||||||
|
if (loadPromise) {
|
||||||
|
hasPending = true
|
||||||
|
return loadPromise
|
||||||
|
}
|
||||||
|
loadPromise = loadFn()
|
||||||
|
.finally(() => {
|
||||||
|
loadPromise = null
|
||||||
|
if (hasPending) {
|
||||||
|
hasPending = false
|
||||||
|
void execute()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return loadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = null
|
||||||
|
}
|
||||||
|
hasPending = false
|
||||||
|
loadPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { schedule, execute, cleanup }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求 ID 守卫:用于确保异步回调中的数据不被过期请求覆盖
|
||||||
|
*/
|
||||||
|
export function createRequestGuard() {
|
||||||
|
let id = 0
|
||||||
|
function next() { return ++id }
|
||||||
|
function isStale(requestId: number) { return requestId !== id }
|
||||||
|
function isCurrent(requestId: number) { return requestId === id }
|
||||||
|
function invalidate() { id++ }
|
||||||
|
return { next, isStale, isCurrent, invalidate }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFiltersOptions {
|
||||||
|
defaultPreset?: PeriodValue
|
||||||
|
syncToUrl?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的分析页面筛选状态
|
||||||
|
* - timeRange: 时间范围
|
||||||
|
* - modelFilter / providerFilter / apiKeyFilter / userFilter: 多维度筛选
|
||||||
|
* - activeTab: 当前 Tab
|
||||||
|
* - URL query 双向同步
|
||||||
|
*/
|
||||||
|
export function useAnalyticsFilters(options: AnalyticsFiltersOptions = {}) {
|
||||||
|
const { defaultPreset = 'last30days', syncToUrl = true } = options
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 从 URL query 初始化,无则用默认值
|
||||||
|
const initialPreset = (syncToUrl && route.query.preset as PeriodValue) || defaultPreset
|
||||||
|
const initialTab = (syncToUrl && route.query.tab as string) || 'detail'
|
||||||
|
|
||||||
|
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod(initialPreset))
|
||||||
|
const activeTab = ref(initialTab)
|
||||||
|
const modelFilter = ref<string[]>([])
|
||||||
|
const providerFilter = ref<string[]>([])
|
||||||
|
const apiKeyFilter = ref<string[]>([])
|
||||||
|
const userFilter = ref<string[]>([])
|
||||||
|
|
||||||
|
// URL 同步:仅同步 preset 和 tab(各类多选筛选太冗长不入 URL)
|
||||||
|
if (syncToUrl) {
|
||||||
|
watch([() => timeRange.value.preset, activeTab], ([preset, tab]) => {
|
||||||
|
const query: Record<string, string> = {}
|
||||||
|
if (preset && preset !== defaultPreset) query.preset = preset
|
||||||
|
if (tab && tab !== 'detail') query.tab = tab
|
||||||
|
// 保留其他 query 参数
|
||||||
|
const currentQuery = { ...route.query }
|
||||||
|
delete currentQuery.preset
|
||||||
|
delete currentQuery.tab
|
||||||
|
router.replace({ query: { ...currentQuery, ...query } })
|
||||||
|
}, { flush: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeRangeParams() {
|
||||||
|
return buildTimeRangeParams(timeRange.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeRange,
|
||||||
|
activeTab,
|
||||||
|
modelFilter,
|
||||||
|
providerFilter,
|
||||||
|
apiKeyFilter,
|
||||||
|
userFilter,
|
||||||
|
getTimeRangeParams,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnalyticsFilters = ReturnType<typeof useAnalyticsFilters>
|
||||||
|
|
||||||
|
export function useInjectedAnalyticsFilters(): AnalyticsFilters {
|
||||||
|
const filters = inject<AnalyticsFilters>('analyticsFilters', null)
|
||||||
|
if (!filters) {
|
||||||
|
throw new Error('analyticsFilters provider is missing')
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { computed, ref, watch, onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
import {
|
||||||
|
analyticsApi,
|
||||||
|
type AnalyticsBreakdownRow,
|
||||||
|
type AnalyticsFilterOption,
|
||||||
|
type AnalyticsScope,
|
||||||
|
type AnalyticsSummary,
|
||||||
|
type AnalyticsTimeseriesBucket,
|
||||||
|
} from '@/api/analytics'
|
||||||
|
import type { DateRangeParams, PeriodValue } from '@/features/usage/types'
|
||||||
|
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||||
|
import { createLoader, createRequestGuard, buildTimeRangeParams } from '@/composables/useAnalyticsFilters'
|
||||||
|
import { fillMissingTimeseriesBuckets } from '@/utils/analyticsTimeseries'
|
||||||
|
import { getAnalyticsRangeDaysInclusive, resolveAnalyticsAutoGranularity, type ResolvedAnalyticsGranularity } from '@/utils/analyticsGranularity'
|
||||||
|
import { type DailyUsageBreakdown } from '@/utils/usageBreakdown'
|
||||||
|
|
||||||
|
export interface ModelSummary {
|
||||||
|
model: string
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost: number
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
cacheCreationTokens: number
|
||||||
|
cacheReadTokens: number
|
||||||
|
cacheHitRate: number
|
||||||
|
avg_response_time?: number
|
||||||
|
avg_first_byte_time?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportBucketStat {
|
||||||
|
bucket_start: string
|
||||||
|
bucket_end: string
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost: number
|
||||||
|
avg_response_time: number
|
||||||
|
avg_first_byte_time: number
|
||||||
|
models_used_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBucketToReportStat(bucket: AnalyticsTimeseriesBucket): ReportBucketStat {
|
||||||
|
return {
|
||||||
|
bucket_start: bucket.bucket_start,
|
||||||
|
bucket_end: bucket.bucket_end,
|
||||||
|
requests: bucket.requests_total,
|
||||||
|
tokens: bucket.total_tokens,
|
||||||
|
cost: bucket.total_cost_usd,
|
||||||
|
avg_response_time: bucket.avg_response_time_ms / 1000,
|
||||||
|
avg_first_byte_time: bucket.avg_first_byte_time_ms / 1000,
|
||||||
|
models_used_count: bucket.models_used_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBucketToBreakdown(bucket: AnalyticsTimeseriesBucket): DailyUsageBreakdown {
|
||||||
|
return {
|
||||||
|
date: bucket.bucket_start,
|
||||||
|
inputTokens: bucket.input_tokens,
|
||||||
|
outputTokens: bucket.output_tokens,
|
||||||
|
cacheCreationTokens: bucket.cache_creation_input_tokens,
|
||||||
|
cacheReadTokens: bucket.cache_read_input_tokens,
|
||||||
|
cacheCreationCost: bucket.cache_creation_cost_usd,
|
||||||
|
cacheReadCost: bucket.cache_read_cost_usd,
|
||||||
|
cacheHitRate: bucket.cache_hit_rate,
|
||||||
|
totalCacheCost: bucket.cache_cost_usd,
|
||||||
|
totalCacheTokens: bucket.cache_creation_input_tokens + bucket.cache_read_input_tokens,
|
||||||
|
totalTrackedTokens: bucket.total_tokens,
|
||||||
|
baseTokens: bucket.input_output_total_tokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBreakdownRowToModelSummary(row: AnalyticsBreakdownRow): ModelSummary {
|
||||||
|
const requests = row.requests_total
|
||||||
|
const tokens = row.total_tokens
|
||||||
|
const cost = row.total_cost_usd
|
||||||
|
return {
|
||||||
|
model: row.label,
|
||||||
|
requests,
|
||||||
|
tokens,
|
||||||
|
cost,
|
||||||
|
inputTokens: row.input_tokens,
|
||||||
|
outputTokens: row.output_tokens,
|
||||||
|
cacheCreationTokens: row.cache_creation_input_tokens,
|
||||||
|
cacheReadTokens: row.cache_read_input_tokens,
|
||||||
|
cacheHitRate: row.cache_hit_rate,
|
||||||
|
avg_response_time: row.avg_response_time_ms / 1000,
|
||||||
|
avg_first_byte_time: row.avg_first_byte_time_ms / 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportGranularity = ResolvedAnalyticsGranularity
|
||||||
|
|
||||||
|
function resolveReportGranularity(range: DateRangeParams): ReportGranularity {
|
||||||
|
return resolveAnalyticsAutoGranularity(range)
|
||||||
|
}
|
||||||
|
|
||||||
|
function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||||
|
if (left.length !== right.length) return false
|
||||||
|
return left.every((value, index) => value === right[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseReportsDataOptions {
|
||||||
|
scope?: AnalyticsScope
|
||||||
|
defaultPreset?: PeriodValue
|
||||||
|
timeRange?: Ref<DateRangeParams>
|
||||||
|
userFilter?: Ref<string[]>
|
||||||
|
apiKeyFilter?: Ref<string[]>
|
||||||
|
loadApiKeyOptions?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportsData(options: UseReportsDataOptions = {}) {
|
||||||
|
const {
|
||||||
|
scope = { kind: 'me' as const },
|
||||||
|
defaultPreset = 'today',
|
||||||
|
timeRange: externalTimeRange,
|
||||||
|
userFilter: externalUserFilter,
|
||||||
|
apiKeyFilter: externalApiKeyFilter,
|
||||||
|
loadApiKeyOptions = scope.kind === 'me',
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const timeRange = externalTimeRange ?? ref<DateRangeParams>({
|
||||||
|
...getDateRangeFromPeriod(defaultPreset),
|
||||||
|
granularity: 'auto',
|
||||||
|
})
|
||||||
|
const userFilter = externalUserFilter ?? ref<string[]>([])
|
||||||
|
const apiKeyFilter = externalApiKeyFilter ?? ref<string[]>([])
|
||||||
|
const apiKeyOptions = ref<AnalyticsFilterOption[]>([])
|
||||||
|
|
||||||
|
const summary = ref<AnalyticsSummary | null>(null)
|
||||||
|
const bucketStats = ref<ReportBucketStat[]>([])
|
||||||
|
const bucketBreakdowns = ref<Record<string, DailyUsageBreakdown>>({})
|
||||||
|
const modelSummary = ref<ModelSummary[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref(false)
|
||||||
|
const hasLoaded = ref(false)
|
||||||
|
const guard = createRequestGuard()
|
||||||
|
const apiKeysGuard = createRequestGuard()
|
||||||
|
let suppressNextApiKeyFilterLoad = false
|
||||||
|
const resolvedGranularity = computed(() => resolveReportGranularity(timeRange.value))
|
||||||
|
const timeseriesTimeRange = computed(() => ({
|
||||||
|
...buildTimeRangeParams(timeRange.value),
|
||||||
|
granularity: resolvedGranularity.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [timeRange.value.preset, timeRange.value.start_date, timeRange.value.end_date],
|
||||||
|
() => {
|
||||||
|
if (timeRange.value.granularity === 'hour' && getAnalyticsRangeDaysInclusive(timeRange.value) !== 1) {
|
||||||
|
timeRange.value = {
|
||||||
|
...timeRange.value,
|
||||||
|
granularity: 'auto',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const requestId = guard.next()
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = false
|
||||||
|
try {
|
||||||
|
const params = buildTimeRangeParams(timeRange.value)
|
||||||
|
const timeseriesParams = timeseriesTimeRange.value
|
||||||
|
const basePayload = {
|
||||||
|
scope,
|
||||||
|
time_range: params,
|
||||||
|
filters: {
|
||||||
|
user_ids: userFilter.value,
|
||||||
|
api_key_ids: apiKeyFilter.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const [overviewData, timeseriesData, modelBreakdownData] = await Promise.all([
|
||||||
|
analyticsApi.getOverview(basePayload),
|
||||||
|
analyticsApi.getTimeseries({
|
||||||
|
...basePayload,
|
||||||
|
time_range: timeseriesParams,
|
||||||
|
}),
|
||||||
|
analyticsApi.getBreakdown({
|
||||||
|
...basePayload,
|
||||||
|
dimension: 'model',
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (guard.isStale(requestId)) return
|
||||||
|
|
||||||
|
const filledBuckets = fillMissingTimeseriesBuckets(timeseriesData.buckets, timeseriesParams)
|
||||||
|
|
||||||
|
summary.value = overviewData.summary
|
||||||
|
bucketStats.value = filledBuckets.map(mapBucketToReportStat)
|
||||||
|
bucketBreakdowns.value = Object.fromEntries(
|
||||||
|
filledBuckets.map(bucket => {
|
||||||
|
const breakdown = mapBucketToBreakdown(bucket)
|
||||||
|
return [breakdown.date, breakdown]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
modelSummary.value = modelBreakdownData.rows.map(mapBreakdownRowToModelSummary)
|
||||||
|
hasLoaded.value = true
|
||||||
|
} catch {
|
||||||
|
if (guard.isStale(requestId)) return
|
||||||
|
loadError.value = true
|
||||||
|
} finally {
|
||||||
|
if (guard.isCurrent(requestId)) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApiKeys() {
|
||||||
|
if (!loadApiKeyOptions) return
|
||||||
|
const requestId = apiKeysGuard.next()
|
||||||
|
const filterOptions = await analyticsApi.getFilterOptions({
|
||||||
|
scope,
|
||||||
|
time_range: buildTimeRangeParams(timeRange.value),
|
||||||
|
filters: {},
|
||||||
|
}).catch(() => null)
|
||||||
|
if (apiKeysGuard.isStale(requestId)) return
|
||||||
|
|
||||||
|
const nextOptions = filterOptions?.api_keys ?? []
|
||||||
|
|
||||||
|
apiKeyOptions.value = nextOptions
|
||||||
|
const validOptionValues = new Set(nextOptions.map(option => option.value))
|
||||||
|
const nextFilter = apiKeyFilter.value.filter(value => validOptionValues.has(value))
|
||||||
|
if (!areStringArraysEqual(nextFilter, apiKeyFilter.value)) {
|
||||||
|
suppressNextApiKeyFilterLoad = true
|
||||||
|
apiKeyFilter.value = nextFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = createLoader(load)
|
||||||
|
const apiKeysLoader = createLoader(loadApiKeys)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
timeRange,
|
||||||
|
() => {
|
||||||
|
void (async () => {
|
||||||
|
if (loadApiKeyOptions) {
|
||||||
|
await apiKeysLoader.execute()
|
||||||
|
}
|
||||||
|
loader.schedule()
|
||||||
|
})()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[userFilter, apiKeyFilter],
|
||||||
|
() => {
|
||||||
|
if (suppressNextApiKeyFilterLoad) {
|
||||||
|
suppressNextApiKeyFilterLoad = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loader.schedule()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (loadApiKeyOptions) {
|
||||||
|
await apiKeysLoader.execute()
|
||||||
|
}
|
||||||
|
await loader.execute()
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
loader.cleanup()
|
||||||
|
apiKeysLoader.cleanup()
|
||||||
|
guard.invalidate()
|
||||||
|
apiKeysGuard.invalidate()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeRange,
|
||||||
|
apiKeyFilter,
|
||||||
|
apiKeyOptions,
|
||||||
|
summary,
|
||||||
|
bucketStats,
|
||||||
|
bucketBreakdowns,
|
||||||
|
modelSummary,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
hasLoaded,
|
||||||
|
resolvedGranularity,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -282,8 +282,6 @@ export interface AliasGroup {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
providerId: string
|
providerId: string
|
||||||
/** @deprecated */
|
|
||||||
providerApiFormats?: string[]
|
|
||||||
models: Model[]
|
models: Model[]
|
||||||
editingGroup?: AliasGroup | null
|
editingGroup?: AliasGroup | null
|
||||||
preselectedModelId?: string | null
|
preselectedModelId?: string | null
|
||||||
|
|||||||
@@ -544,12 +544,12 @@
|
|||||||
v-if="refreshingQuota"
|
v-if="refreshingQuota"
|
||||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="key.upstream_metadata.codex?.updated_at"
|
v-if="key.upstream_metadata.codex?.updated_at"
|
||||||
class="text-[9px] text-muted-foreground/70"
|
class="text-[9px] text-muted-foreground/70"
|
||||||
>
|
>
|
||||||
{{ formatCodexUpdatedAt(key.upstream_metadata.codex.updated_at) }}
|
{{ formatUpdatedAt(key.upstream_metadata.codex.updated_at) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
|
<!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
|
||||||
@@ -674,7 +674,7 @@
|
|||||||
v-if="key.upstream_metadata.antigravity?.updated_at"
|
v-if="key.upstream_metadata.antigravity?.updated_at"
|
||||||
class="text-[9px] text-muted-foreground/70"
|
class="text-[9px] text-muted-foreground/70"
|
||||||
>
|
>
|
||||||
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
|
{{ formatUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2471,10 +2471,6 @@ function formatUpdatedAt(updatedAt: number): string {
|
|||||||
return `${days}天前更新`
|
return `${days}天前更新`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容旧函数名
|
|
||||||
const formatCodexUpdatedAt = formatUpdatedAt
|
|
||||||
const formatAntigravityUpdatedAt = formatUpdatedAt
|
|
||||||
|
|
||||||
function secondsUntilReset(resetTime: string): number | null {
|
function secondsUntilReset(resetTime: string): number | null {
|
||||||
if (!resetTime) return null
|
if (!resetTime) return null
|
||||||
const ts = Date.parse(resetTime)
|
const ts = Date.parse(resetTime)
|
||||||
|
|||||||
@@ -165,7 +165,6 @@
|
|||||||
<ModelMappingDialog
|
<ModelMappingDialog
|
||||||
v-model:open="dialogOpen"
|
v-model:open="dialogOpen"
|
||||||
:provider-id="provider.id"
|
:provider-id="provider.id"
|
||||||
:provider-api-formats="providerApiFormats"
|
|
||||||
:models="models"
|
:models="models"
|
||||||
:editing-group="editingGroup"
|
:editing-group="editingGroup"
|
||||||
:preselected-model-id="preselectedModelId"
|
:preselected-model-id="preselectedModelId"
|
||||||
@@ -195,7 +194,6 @@ import { useToast } from '@/composables/useToast'
|
|||||||
import {
|
import {
|
||||||
getProviderModels,
|
getProviderModels,
|
||||||
testModel,
|
testModel,
|
||||||
API_FORMAT_LABELS,
|
|
||||||
type Model,
|
type Model,
|
||||||
type ProviderModelAlias
|
type ProviderModelAlias
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
@@ -227,16 +225,6 @@ const preselectedModelId = ref<string | null>(null)
|
|||||||
// 列表展开状态
|
// 列表展开状态
|
||||||
const expandedAliasGroups = ref<Set<string>>(new Set())
|
const expandedAliasGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
// 获取 Provider 支持的 API 格式
|
|
||||||
const providerApiFormats = computed(() => {
|
|
||||||
const formats = props.provider?.api_formats
|
|
||||||
if (Array.isArray(formats) && formats.length > 0) {
|
|
||||||
const order = Object.keys(API_FORMAT_LABELS)
|
|
||||||
return [...formats].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
|
|
||||||
// 生成作用域唯一键
|
// 生成作用域唯一键
|
||||||
function getApiFormatsKey(formats: string[] | undefined): string {
|
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||||
if (!formats || formats.length === 0) return ''
|
if (!formats || formats.length === 0) return ''
|
||||||
|
|||||||
@@ -20,26 +20,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
class="flex items-center justify-center text-sm text-muted-foreground"
|
||||||
|
:style="{ minHeight: `${minHeight}px` }"
|
||||||
>
|
>
|
||||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||||
加载中...
|
加载中...
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="hasError"
|
v-else-if="hasError"
|
||||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-destructive"
|
class="flex items-center justify-center text-sm text-destructive"
|
||||||
|
:style="{ minHeight: `${minHeight}px` }"
|
||||||
>
|
>
|
||||||
<AlertCircle class="h-4 w-4 mr-1.5" />
|
<AlertCircle class="h-4 w-4 mr-1.5" />
|
||||||
加载失败
|
数据暂不可用
|
||||||
</div>
|
</div>
|
||||||
<ActivityHeatmap
|
<ActivityHeatmap
|
||||||
v-else-if="hasData"
|
v-else-if="hasData"
|
||||||
:data="data"
|
:data="data"
|
||||||
:show-header="false"
|
:show-header="false"
|
||||||
|
:value-label="valueLabel"
|
||||||
|
:actual-value-label="actualValueLabel"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
class="flex items-center justify-center text-sm text-muted-foreground"
|
||||||
|
:style="{ minHeight: `${minHeight}px` }"
|
||||||
>
|
>
|
||||||
暂无活跃数据
|
暂无活跃数据
|
||||||
</div>
|
</div>
|
||||||
@@ -58,6 +63,9 @@ const props = defineProps<{
|
|||||||
title: string
|
title: string
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
hasError?: boolean
|
hasError?: boolean
|
||||||
|
minHeight?: number
|
||||||
|
valueLabel?: string
|
||||||
|
actualValueLabel?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||||
@@ -65,4 +73,6 @@ const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
|||||||
const hasData = computed(() =>
|
const hasData = computed(() =>
|
||||||
props.data && props.data.days && props.data.days.length > 0
|
props.data && props.data.days && props.data.days.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const minHeight = computed(() => props.minHeight ?? 160)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -780,11 +780,11 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
merged.status = normalizeTimelineStatus(raw.status ?? merged.status)
|
merged.status = normalizeTimelineStatus(raw.status ?? merged.status)
|
||||||
if (typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
|
if (!merged.provider_id && typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
|
||||||
if (typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
|
if (!merged.provider_name && typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
|
||||||
if (typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
|
if (!merged.endpoint_id && typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
|
||||||
if (typeof raw.key_id === 'string') merged.key_id = raw.key_id
|
if (!merged.key_id && typeof raw.key_id === 'string') merged.key_id = raw.key_id
|
||||||
if (typeof raw.key_name === 'string') merged.key_name = raw.key_name
|
if (!merged.key_name && typeof raw.key_name === 'string') merged.key_name = raw.key_name
|
||||||
if (typeof raw.status_code === 'number') merged.status_code = raw.status_code
|
if (typeof raw.status_code === 'number') merged.status_code = raw.status_code
|
||||||
if (typeof raw.error_type === 'string') merged.error_type = raw.error_type
|
if (typeof raw.error_type === 'string') merged.error_type = raw.error_type
|
||||||
const rawPoolGroupId = typeof raw.pool_group_id === 'string' ? raw.pool_group_id.trim() : ''
|
const rawPoolGroupId = typeof raw.pool_group_id === 'string' ? raw.pool_group_id.trim() : ''
|
||||||
|
|||||||
@@ -1,325 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Card class="p-4">
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<p class="text-sm font-semibold">
|
|
||||||
{{ title }}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-if="displayLegendItems.length > 0"
|
|
||||||
class="flex items-center gap-2 flex-wrap justify-end text-[11px]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="item in displayLegendItems"
|
|
||||||
:key="item.id"
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-2.5 h-2.5 rounded-full"
|
|
||||||
:style="{ backgroundColor: item.color }"
|
|
||||||
/>
|
|
||||||
<span class="text-muted-foreground">{{ item.name }}</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
v-if="hiddenLegendCount > 0"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
>
|
|
||||||
+{{ hiddenLegendCount }} 更多
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="h-[160px] flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="hasData"
|
|
||||||
class="h-[160px]"
|
|
||||||
>
|
|
||||||
<ScatterChart
|
|
||||||
:data="chartData"
|
|
||||||
:options="chartOptions"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
暂无请求间隔数据
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
||||||
import Card from '@/components/ui/card.vue'
|
|
||||||
import ScatterChart from '@/components/charts/ScatterChart.vue'
|
|
||||||
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
|
|
||||||
import { meApi } from '@/api/me'
|
|
||||||
import type { ChartData, ChartOptions } from 'chart.js'
|
|
||||||
import { log } from '@/utils/logger'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
title: string
|
|
||||||
isAdmin: boolean
|
|
||||||
hours?: number
|
|
||||||
}>(), {
|
|
||||||
hours: 24 // 默认当天
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const timelineData = ref<IntervalTimelineResponse | null>(null)
|
|
||||||
const primaryColor = ref('201, 100, 66') // 默认主题色
|
|
||||||
let loadRequestId = 0
|
|
||||||
|
|
||||||
const ADMIN_TIMELINE_LIMIT = 1500
|
|
||||||
const USER_TIMELINE_LIMIT = 1200
|
|
||||||
|
|
||||||
// 获取主题色
|
|
||||||
function getPrimaryColor(): string {
|
|
||||||
if (typeof window === 'undefined') return '201, 100, 66'
|
|
||||||
// CSS 变量定义在 body 上,不是 documentElement
|
|
||||||
const body = document.body
|
|
||||||
const style = getComputedStyle(body)
|
|
||||||
const rgb = style.getPropertyValue('--color-primary-rgb').trim()
|
|
||||||
return rgb || '201, 100, 66'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
primaryColor.value = getPrimaryColor()
|
|
||||||
void loadData()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 预定义的颜色列表(用于区分不同用户/模型)
|
|
||||||
const COLORS = [
|
|
||||||
'rgba(59, 130, 246, 0.7)', // blue
|
|
||||||
'rgba(236, 72, 153, 0.7)', // pink
|
|
||||||
'rgba(34, 197, 94, 0.7)', // green
|
|
||||||
'rgba(249, 115, 22, 0.7)', // orange
|
|
||||||
'rgba(168, 85, 247, 0.7)', // purple
|
|
||||||
'rgba(234, 179, 8, 0.7)', // yellow
|
|
||||||
'rgba(14, 165, 233, 0.7)', // sky
|
|
||||||
'rgba(239, 68, 68, 0.7)', // red
|
|
||||||
'rgba(20, 184, 166, 0.7)', // teal
|
|
||||||
'rgba(99, 102, 241, 0.7)', // indigo
|
|
||||||
]
|
|
||||||
|
|
||||||
const hasData = computed(() =>
|
|
||||||
timelineData.value && timelineData.value.points && timelineData.value.points.length > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
// 判断是否有多个分组(管理员按用户分组,普通用户按模型分组)
|
|
||||||
const hasMultipleGroups = computed(() => {
|
|
||||||
if (props.isAdmin) {
|
|
||||||
// 管理员视图:按用户分组
|
|
||||||
return timelineData.value?.users && Object.keys(timelineData.value.users).length > 1
|
|
||||||
} else {
|
|
||||||
// 用户视图:按模型分组
|
|
||||||
return timelineData.value?.models && timelineData.value.models.length > 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 图例最多显示数量
|
|
||||||
const MAX_LEGEND_ITEMS = 6
|
|
||||||
|
|
||||||
// 图例项(管理员显示用户,普通用户显示模型)
|
|
||||||
const legendItems = computed(() => {
|
|
||||||
if (props.isAdmin && timelineData.value?.users) {
|
|
||||||
// 管理员视图:显示用户图例
|
|
||||||
const users = Object.entries(timelineData.value.users)
|
|
||||||
if (users.length <= 1) return []
|
|
||||||
return users.map(([userId, username], index) => ({
|
|
||||||
id: userId,
|
|
||||||
name: username || userId.slice(0, 8),
|
|
||||||
color: COLORS[index % COLORS.length]
|
|
||||||
}))
|
|
||||||
} else if (timelineData.value?.models && timelineData.value.models.length > 1) {
|
|
||||||
// 用户视图:显示模型图例
|
|
||||||
return timelineData.value.models.map((model, index) => ({
|
|
||||||
id: model,
|
|
||||||
name: formatModelName(model),
|
|
||||||
color: COLORS[index % COLORS.length]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
|
|
||||||
// 显示的图例项(限制数量)
|
|
||||||
const displayLegendItems = computed(() => {
|
|
||||||
return legendItems.value.slice(0, MAX_LEGEND_ITEMS)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 隐藏的图例数量
|
|
||||||
const hiddenLegendCount = computed(() => {
|
|
||||||
return Math.max(0, legendItems.value.length - MAX_LEGEND_ITEMS)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 格式化模型名称,使其更简洁
|
|
||||||
function formatModelName(model: string): string {
|
|
||||||
// 常见的简化规则
|
|
||||||
if (model.includes('claude')) {
|
|
||||||
// claude-3-5-sonnet-20241022 -> Claude 3.5 Sonnet
|
|
||||||
const match = model.match(/claude-(\d+)-(\d+)?-?(\w+)?/i)
|
|
||||||
if (match) {
|
|
||||||
const major = match[1]
|
|
||||||
const minor = match[2]
|
|
||||||
const variant = match[3]
|
|
||||||
let name = `Claude ${major}`
|
|
||||||
if (minor) name += `.${minor}`
|
|
||||||
if (variant) name += ` ${variant.charAt(0).toUpperCase() + variant.slice(1)}`
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 其他模型保持原样但截断
|
|
||||||
return model.length > 20 ? `${model.slice(0, 17) }...` : model
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建图表数据
|
|
||||||
const chartData = computed<ChartData<'scatter'>>(() => {
|
|
||||||
if (!timelineData.value?.points) {
|
|
||||||
return { datasets: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const points = timelineData.value.points
|
|
||||||
|
|
||||||
// 管理员视图且有多个用户:按用户分组
|
|
||||||
if (props.isAdmin && timelineData.value.users && Object.keys(timelineData.value.users).length > 1) {
|
|
||||||
const userIds = Object.keys(timelineData.value.users)
|
|
||||||
const userColorMap: Record<string, string> = {}
|
|
||||||
userIds.forEach((userId, index) => {
|
|
||||||
userColorMap[userId] = COLORS[index % COLORS.length]
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按用户分组数据
|
|
||||||
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
|
|
||||||
for (const point of points) {
|
|
||||||
const userId = point.user_id || 'unknown'
|
|
||||||
if (!groupedData[userId]) {
|
|
||||||
groupedData[userId] = []
|
|
||||||
}
|
|
||||||
groupedData[userId].push({ x: point.x, y: point.y })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建每个用户的 dataset
|
|
||||||
const datasets = Object.entries(groupedData).map(([userId, data]) => ({
|
|
||||||
label: timelineData.value?.users?.[userId] || userId.slice(0, 8),
|
|
||||||
data,
|
|
||||||
backgroundColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.6)',
|
|
||||||
borderColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.8)',
|
|
||||||
pointRadius: 3,
|
|
||||||
pointHoverRadius: 5,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { datasets }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户视图且有多个模型:按模型分组
|
|
||||||
if (!props.isAdmin && timelineData.value.models && timelineData.value.models.length > 1) {
|
|
||||||
const models = timelineData.value.models
|
|
||||||
const modelColorMap: Record<string, string> = {}
|
|
||||||
models.forEach((model, index) => {
|
|
||||||
modelColorMap[model] = COLORS[index % COLORS.length]
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按模型分组数据
|
|
||||||
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
|
|
||||||
for (const point of points) {
|
|
||||||
const model = point.model || 'unknown'
|
|
||||||
if (!groupedData[model]) {
|
|
||||||
groupedData[model] = []
|
|
||||||
}
|
|
||||||
groupedData[model].push({ x: point.x, y: point.y })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建每个模型的 dataset
|
|
||||||
const datasets = Object.entries(groupedData).map(([model, data]) => ({
|
|
||||||
label: formatModelName(model),
|
|
||||||
data,
|
|
||||||
backgroundColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.6)',
|
|
||||||
borderColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.8)',
|
|
||||||
pointRadius: 3,
|
|
||||||
pointHoverRadius: 5,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { datasets }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 单用户或单模型:使用主题色
|
|
||||||
return {
|
|
||||||
datasets: [{
|
|
||||||
label: '请求间隔',
|
|
||||||
data: points.map(p => ({ x: p.x, y: p.y })),
|
|
||||||
backgroundColor: `rgba(${primaryColor.value}, 0.6)`,
|
|
||||||
borderColor: `rgba(${primaryColor.value}, 0.8)`,
|
|
||||||
pointRadius: 3,
|
|
||||||
pointHoverRadius: 5,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false // 使用自定义图例
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: (context) => {
|
|
||||||
const point = context.raw as { x: string; y: number; _originalY?: number }
|
|
||||||
const realY = point._originalY ?? point.y
|
|
||||||
const datasetLabel = context.dataset.label || ''
|
|
||||||
if (hasMultipleGroups.value) {
|
|
||||||
return `${datasetLabel}: ${realY.toFixed(1)} 分钟`
|
|
||||||
}
|
|
||||||
return `间隔: ${realY.toFixed(1)} 分钟`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
const requestId = ++loadRequestId
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const limit = props.isAdmin ? ADMIN_TIMELINE_LIMIT : USER_TIMELINE_LIMIT
|
|
||||||
if (props.isAdmin) {
|
|
||||||
// 管理员:获取所有用户数据(按比例采样)
|
|
||||||
const data = await cacheAnalysisApi.getIntervalTimeline({
|
|
||||||
hours: props.hours,
|
|
||||||
include_user_info: true,
|
|
||||||
limit,
|
|
||||||
})
|
|
||||||
if (requestId !== loadRequestId) return
|
|
||||||
timelineData.value = data
|
|
||||||
} else {
|
|
||||||
// 普通用户:获取自己的数据
|
|
||||||
const data = await meApi.getIntervalTimeline({
|
|
||||||
hours: props.hours,
|
|
||||||
limit,
|
|
||||||
})
|
|
||||||
if (requestId !== loadRequestId) return
|
|
||||||
timelineData.value = data
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (requestId !== loadRequestId) return
|
|
||||||
log.error('加载请求间隔时间线失败:', error)
|
|
||||||
timelineData.value = null
|
|
||||||
} finally {
|
|
||||||
if (requestId === loadRequestId) {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([() => props.hours, () => props.isAdmin], () => {
|
|
||||||
void loadData()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
loadRequestId++
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { dashboardApi, type ReplayResponse, type RequestDetail } from '@/api/dashboard'
|
import { requestDetailsApi, type ReplayResponse, type RequestDetail } from '@/api/request-details'
|
||||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||||
import { getProviderKeys } from '@/api/endpoints/keys'
|
import { getProviderKeys } from '@/api/endpoints/keys'
|
||||||
import type { EndpointAPIKey } from '@/api/endpoints/types'
|
import type { EndpointAPIKey } from '@/api/endpoints/types'
|
||||||
@@ -482,7 +482,7 @@ async function doReplay() {
|
|||||||
if (selectedProviderId.value) params.provider_id = selectedProviderId.value
|
if (selectedProviderId.value) params.provider_id = selectedProviderId.value
|
||||||
if (selectedKeyId.value) params.api_key_id = selectedKeyId.value
|
if (selectedKeyId.value) params.api_key_id = selectedKeyId.value
|
||||||
|
|
||||||
replayResult.value = await dashboardApi.replayRequest(
|
replayResult.value = await requestDetailsApi.replayRequest(
|
||||||
props.requestId,
|
props.requestId,
|
||||||
Object.keys(params).length > 0 ? params : undefined,
|
Object.keys(params).length > 0 ? params : undefined,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -696,7 +696,7 @@ import Skeleton from '@/components/ui/skeleton.vue'
|
|||||||
import Tabs from '@/components/ui/tabs.vue'
|
import Tabs from '@/components/ui/tabs.vue'
|
||||||
import TabsContent from '@/components/ui/tabs-content.vue'
|
import TabsContent from '@/components/ui/tabs-content.vue'
|
||||||
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||||
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
import { requestDetailsApi, type RequestDetail } from '@/api/request-details'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
@@ -1319,7 +1319,7 @@ async function ensureBodyContentLoaded() {
|
|||||||
const requestId = ++bodyLoadRequestId
|
const requestId = ++bodyLoadRequestId
|
||||||
bodyLoading.value = true
|
bodyLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await dashboardApi.getRequestDetail(props.requestId, { includeBodies: true })
|
const response = await requestDetailsApi.getRequestDetail(props.requestId, { includeBodies: true })
|
||||||
if (requestId !== bodyLoadRequestId || !detail.value) return
|
if (requestId !== bodyLoadRequestId || !detail.value) return
|
||||||
detail.value = {
|
detail.value = {
|
||||||
...detail.value,
|
...detail.value,
|
||||||
@@ -1359,7 +1359,7 @@ async function loadDetail(id: string, silent = false) {
|
|||||||
}
|
}
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await dashboardApi.getRequestDetail(id, { includeBodies: false })
|
const response = await requestDetailsApi.getRequestDetail(id, { includeBodies: false })
|
||||||
if (requestId !== loadDetailRequestId) return
|
if (requestId !== loadDetailRequestId) return
|
||||||
|
|
||||||
const previousDetail = detail.value
|
const previousDetail = detail.value
|
||||||
@@ -1737,7 +1737,7 @@ async function copyCurlCommand() {
|
|||||||
if (!props.requestId || curlCopying.value) return
|
if (!props.requestId || curlCopying.value) return
|
||||||
curlCopying.value = true
|
curlCopying.value = true
|
||||||
try {
|
try {
|
||||||
const data = await dashboardApi.getCurlData(props.requestId)
|
const data = await requestDetailsApi.getCurlData(props.requestId)
|
||||||
if (data.curl) {
|
if (data.curl) {
|
||||||
copyToClipboard(data.curl, false)
|
copyToClipboard(data.curl, false)
|
||||||
curlCopied.value = true
|
curlCopied.value = true
|
||||||
|
|||||||
+1
-1
@@ -159,7 +159,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import JsonContent from './JsonContent.vue'
|
import JsonContent from './JsonContent.vue'
|
||||||
import type { RequestDetail } from '@/api/dashboard'
|
import type { RequestDetail } from '@/api/request-details'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
detail: RequestDetail
|
detail: RequestDetail
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Card class="overflow-hidden flex flex-col">
|
|
||||||
<div class="px-3 py-2 border-b flex-shrink-0">
|
|
||||||
<h3 class="text-sm font-medium">
|
|
||||||
按API格式分析
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-auto max-h-[320px]">
|
|
||||||
<Table class="text-sm">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead class="h-8 px-2">
|
|
||||||
API格式
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
请求数
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
<div class="flex flex-col text-xs gap-0.5 whitespace-nowrap">
|
|
||||||
<span>输入/输出</span>
|
|
||||||
<span class="text-muted-foreground font-normal">缓存</span>
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
费用
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
缓存命中率
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
平均响应
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow v-if="data.length === 0">
|
|
||||||
<TableCell
|
|
||||||
:colspan="6"
|
|
||||||
class="text-center py-6 text-muted-foreground px-2"
|
|
||||||
>
|
|
||||||
暂无API格式统计数据
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow
|
|
||||||
v-for="item in data"
|
|
||||||
:key="item.api_format"
|
|
||||||
>
|
|
||||||
<TableCell class="font-medium py-2 px-2">
|
|
||||||
{{ formatApiFormat(item.api_format) }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
{{ item.request_count }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
|
||||||
<span>{{ formatTokens(item.total_input_context || 0) }} / {{ formatTokens(item.output_tokens || 0) }}</span>
|
|
||||||
<span class="text-muted-foreground">{{ formatTokens((item.cache_read_tokens || 0) + (item.cache_creation_tokens || 0)) }}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
|
||||||
<span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span>
|
|
||||||
<span
|
|
||||||
v-if="isAdmin && item.actual_cost !== undefined"
|
|
||||||
class="text-muted-foreground text-[10px]"
|
|
||||||
>
|
|
||||||
{{ formatCurrency(item.actual_cost) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
<span>{{ formatHitRate(item.cache_hit_rate) }}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
|
||||||
{{ item.avgResponseTime }}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Card from '@/components/ui/card.vue'
|
|
||||||
import Table from '@/components/ui/table.vue'
|
|
||||||
import TableHeader from '@/components/ui/table-header.vue'
|
|
||||||
import TableBody from '@/components/ui/table-body.vue'
|
|
||||||
import TableRow from '@/components/ui/table-row.vue'
|
|
||||||
import TableHead from '@/components/ui/table-head.vue'
|
|
||||||
import TableCell from '@/components/ui/table-cell.vue'
|
|
||||||
import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
|
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
|
||||||
import type { ApiFormatStatsItem } from '../types'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
data: ApiFormatStatsItem[]
|
|
||||||
isAdmin: boolean
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Card class="overflow-hidden flex flex-col">
|
|
||||||
<div class="px-3 py-2 border-b flex-shrink-0">
|
|
||||||
<h3 class="text-sm font-medium">
|
|
||||||
按模型分析
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-auto max-h-[320px]">
|
|
||||||
<Table class="text-sm">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead class="h-8 px-2">
|
|
||||||
模型
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
请求数
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
<div class="flex flex-col text-xs gap-0.5 whitespace-nowrap">
|
|
||||||
<span>输入/输出</span>
|
|
||||||
<span class="text-muted-foreground font-normal">缓存</span>
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
费用
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
缓存命中率
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
效率
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow v-if="data.length === 0">
|
|
||||||
<TableCell
|
|
||||||
:colspan="6"
|
|
||||||
class="text-center py-6 text-muted-foreground px-2"
|
|
||||||
>
|
|
||||||
暂无模型统计数据
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow
|
|
||||||
v-for="model in data"
|
|
||||||
:key="model.model"
|
|
||||||
>
|
|
||||||
<TableCell class="font-medium py-2 px-2">
|
|
||||||
{{ model.model.replace('claude-', '') }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
{{ model.request_count }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
|
||||||
<span>{{ formatTokens(model.total_input_context || 0) }} / {{ formatTokens(model.output_tokens || 0) }}</span>
|
|
||||||
<span class="text-muted-foreground">{{ formatTokens(model.cache_read_tokens || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
|
||||||
<span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span>
|
|
||||||
<span
|
|
||||||
v-if="isAdmin && model.actual_cost !== undefined"
|
|
||||||
class="text-muted-foreground text-[10px]"
|
|
||||||
>
|
|
||||||
{{ formatCurrency(model.actual_cost) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
<span>{{ formatHitRate(model.cache_hit_rate) }}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
|
||||||
{{ model.costPerToken }}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Card from '@/components/ui/card.vue'
|
|
||||||
import Table from '@/components/ui/table.vue'
|
|
||||||
import TableHeader from '@/components/ui/table-header.vue'
|
|
||||||
import TableBody from '@/components/ui/table-body.vue'
|
|
||||||
import TableRow from '@/components/ui/table-row.vue'
|
|
||||||
import TableHead from '@/components/ui/table-head.vue'
|
|
||||||
import TableCell from '@/components/ui/table-cell.vue'
|
|
||||||
import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
|
|
||||||
import type { EnhancedModelStatsItem } from '../types'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
data: EnhancedModelStatsItem[]
|
|
||||||
isAdmin: boolean
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Card class="overflow-hidden flex flex-col">
|
|
||||||
<div class="px-3 py-2 border-b flex-shrink-0">
|
|
||||||
<h3 class="text-sm font-medium">
|
|
||||||
按提供商分析
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-auto max-h-[320px]">
|
|
||||||
<Table class="text-sm">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead class="h-8 px-2">
|
|
||||||
提供商
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
请求数
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
<div class="flex flex-col text-xs gap-0.5 whitespace-nowrap">
|
|
||||||
<span>输入/输出</span>
|
|
||||||
<span class="text-muted-foreground font-normal">缓存</span>
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
费用
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
缓存命中率
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
成功率
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="h-8 px-2 text-right">
|
|
||||||
平均响应
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow v-if="data.length === 0">
|
|
||||||
<TableCell
|
|
||||||
:colspan="7"
|
|
||||||
class="text-center py-6 text-muted-foreground px-2"
|
|
||||||
>
|
|
||||||
暂无提供商统计数据
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow
|
|
||||||
v-for="provider in data"
|
|
||||||
:key="provider.provider"
|
|
||||||
>
|
|
||||||
<TableCell class="font-medium py-2 px-2">
|
|
||||||
{{ provider.provider }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
{{ provider.requests }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
|
||||||
<span>{{ formatTokens(provider.totalInputContext || 0) }} / {{ formatTokens(provider.outputTokens || 0) }}</span>
|
|
||||||
<span class="text-muted-foreground">{{ formatTokens((provider.cacheReadTokens || 0) + (provider.cacheCreationTokens || 0)) }}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
|
||||||
<span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span>
|
|
||||||
<span
|
|
||||||
v-if="isAdmin && provider.actualCost !== undefined"
|
|
||||||
class="text-muted-foreground text-[10px]"
|
|
||||||
>
|
|
||||||
{{ formatCurrency(provider.actualCost) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
<span>{{ formatHitRate(provider.cacheHitRate) }}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right py-2 px-2">
|
|
||||||
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
|
||||||
{{ provider.avgResponseTime }}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Card from '@/components/ui/card.vue'
|
|
||||||
import Table from '@/components/ui/table.vue'
|
|
||||||
import TableHeader from '@/components/ui/table-header.vue'
|
|
||||||
import TableBody from '@/components/ui/table-body.vue'
|
|
||||||
import TableRow from '@/components/ui/table-row.vue'
|
|
||||||
import TableHead from '@/components/ui/table-head.vue'
|
|
||||||
import TableCell from '@/components/ui/table-cell.vue'
|
|
||||||
import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
|
|
||||||
import type { ProviderStatsItem } from '../types'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
data: ProviderStatsItem[]
|
|
||||||
isAdmin: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// 成功率样式 - 简化为两种状态
|
|
||||||
function getSuccessRateClass(rate: number): string {
|
|
||||||
if (rate < 90) return 'text-destructive'
|
|
||||||
return '' // 默认颜色
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -36,10 +36,33 @@
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
v-for="user in availableUsers"
|
v-for="user in availableUsers"
|
||||||
:key="user.id"
|
:key="user.value"
|
||||||
:value="user.id"
|
:value="user.value"
|
||||||
>
|
>
|
||||||
{{ user.username || user.email }}
|
{{ user.label }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<!-- Key 筛选 -->
|
||||||
|
<Select
|
||||||
|
v-if="availableApiKeys.length > 0"
|
||||||
|
:model-value="filterApiKey"
|
||||||
|
@update:model-value="$emit('update:filterApiKey', $event)"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-36 h-8 text-xs border-border/60">
|
||||||
|
<SelectValue placeholder="Key" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">
|
||||||
|
全部 Key
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="apiKey in availableApiKeys"
|
||||||
|
:key="apiKey.value"
|
||||||
|
:value="apiKey.value"
|
||||||
|
>
|
||||||
|
{{ apiKey.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -58,10 +81,10 @@
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
v-for="model in availableModels"
|
v-for="model in availableModels"
|
||||||
:key="model"
|
:key="model.value"
|
||||||
:value="model"
|
:value="model.value"
|
||||||
>
|
>
|
||||||
{{ model.replace('claude-', '') }}
|
{{ model.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -81,10 +104,10 @@
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
v-for="provider in availableProviders"
|
v-for="provider in availableProviders"
|
||||||
:key="provider"
|
:key="provider.value"
|
||||||
:value="provider"
|
:value="provider.value"
|
||||||
>
|
>
|
||||||
{{ provider }}
|
{{ provider.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -106,7 +129,7 @@
|
|||||||
:key="format.value"
|
:key="format.value"
|
||||||
:value="format.value"
|
:value="format.value"
|
||||||
>
|
>
|
||||||
{{ format.label }}
|
{{ getApiFormatOptionLabel(format) }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -123,26 +146,12 @@
|
|||||||
<SelectItem value="__all__">
|
<SelectItem value="__all__">
|
||||||
全部状态
|
全部状态
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="stream">
|
<SelectItem
|
||||||
流式
|
v-for="status in availableStatuses"
|
||||||
</SelectItem>
|
:key="status.value"
|
||||||
<SelectItem value="standard">
|
:value="status.value"
|
||||||
标准
|
>
|
||||||
</SelectItem>
|
{{ getStatusOptionLabel(status) }}
|
||||||
<SelectItem value="active">
|
|
||||||
活跃
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="failed">
|
|
||||||
失败
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="cancelled">
|
|
||||||
已取消
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="has_retry">
|
|
||||||
发生重试
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="has_fallback">
|
|
||||||
发生转移
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -350,18 +359,18 @@
|
|||||||
<TableCell
|
<TableCell
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
class="py-4 w-[100px] truncate"
|
class="py-4 w-[100px] truncate"
|
||||||
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
:title="getUserDisplayName(record)"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col text-xs gap-0.5">
|
<div class="flex flex-col text-xs gap-0.5">
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
|
{{ getUserDisplayName(record) }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="record.api_key?.name"
|
v-if="getUserApiKeyLabel(record)"
|
||||||
class="text-muted-foreground truncate"
|
class="text-muted-foreground truncate"
|
||||||
:title="record.api_key.name"
|
:title="getUserApiKeyLabel(record) || undefined"
|
||||||
>
|
>
|
||||||
{{ record.api_key.name }}
|
{{ getUserApiKeyLabel(record) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -369,10 +378,10 @@
|
|||||||
<TableCell
|
<TableCell
|
||||||
v-if="!isAdmin"
|
v-if="!isAdmin"
|
||||||
class="py-4 w-[100px]"
|
class="py-4 w-[100px]"
|
||||||
:title="record.api_key?.name || '-'"
|
:title="getUserApiKeyLabel(record) || '-'"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col text-xs gap-0.5">
|
<div class="flex flex-col text-xs gap-0.5">
|
||||||
<span class="truncate">{{ record.api_key?.name || '-' }}</span>
|
<span class="truncate">{{ getUserApiKeyLabel(record) || '-' }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="record.api_key?.display"
|
v-if="record.api_key?.display"
|
||||||
class="text-muted-foreground truncate"
|
class="text-muted-foreground truncate"
|
||||||
@@ -417,7 +426,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="flex flex-col text-xs gap-0.5">
|
<div class="flex flex-col text-xs gap-0.5">
|
||||||
<span>{{ record.provider }}</span>
|
<span>{{ formatProviderLabel(record.provider) }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="record.api_key_name"
|
v-if="record.api_key_name"
|
||||||
class="text-muted-foreground truncate"
|
class="text-muted-foreground truncate"
|
||||||
@@ -672,16 +681,11 @@ import { formatTokens, formatCurrency } from '@/utils/format'
|
|||||||
import { formatDateTime } from '../composables'
|
import { formatDateTime } from '../composables'
|
||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
|
import type { AnalyticsFilterOption } from '@/api/analytics'
|
||||||
import type { DateRangeParams, UsageRecord } from '../types'
|
import type { DateRangeParams, UsageRecord } from '../types'
|
||||||
import { TimeRangePicker } from '@/components/common'
|
import { TimeRangePicker } from '@/components/common'
|
||||||
import ElapsedTimeText from './ElapsedTimeText.vue'
|
import ElapsedTimeText from './ElapsedTimeText.vue'
|
||||||
|
|
||||||
export interface UserOption {
|
|
||||||
id: string
|
|
||||||
username: string
|
|
||||||
email: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
records: UsageRecord[]
|
records: UsageRecord[]
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
@@ -692,13 +696,17 @@ const props = defineProps<{
|
|||||||
// 筛选
|
// 筛选
|
||||||
filterSearch: string
|
filterSearch: string
|
||||||
filterUser: string
|
filterUser: string
|
||||||
|
filterApiKey: string
|
||||||
filterModel: string
|
filterModel: string
|
||||||
filterProvider: string
|
filterProvider: string
|
||||||
filterApiFormat: string
|
filterApiFormat: string
|
||||||
filterStatus: string
|
filterStatus: string
|
||||||
availableUsers: UserOption[]
|
availableUsers: AnalyticsFilterOption[]
|
||||||
availableModels: string[]
|
availableApiKeys: AnalyticsFilterOption[]
|
||||||
availableProviders: string[]
|
availableModels: AnalyticsFilterOption[]
|
||||||
|
availableApiFormats: AnalyticsFilterOption[]
|
||||||
|
availableProviders: AnalyticsFilterOption[]
|
||||||
|
availableStatuses: AnalyticsFilterOption[]
|
||||||
// 分页
|
// 分页
|
||||||
currentPage: number
|
currentPage: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@@ -712,6 +720,7 @@ const emit = defineEmits<{
|
|||||||
'update:timeRange': [value: DateRangeParams]
|
'update:timeRange': [value: DateRangeParams]
|
||||||
'update:filterSearch': [value: string]
|
'update:filterSearch': [value: string]
|
||||||
'update:filterUser': [value: string]
|
'update:filterUser': [value: string]
|
||||||
|
'update:filterApiKey': [value: string]
|
||||||
'update:filterModel': [value: string]
|
'update:filterModel': [value: string]
|
||||||
'update:filterProvider': [value: string]
|
'update:filterProvider': [value: string]
|
||||||
'update:filterApiFormat': [value: string]
|
'update:filterApiFormat': [value: string]
|
||||||
@@ -723,22 +732,6 @@ const emit = defineEmits<{
|
|||||||
'showDetail': [id: string]
|
'showDetail': [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 静态常量(放在 defineProps/defineEmits 之后)
|
|
||||||
const AVAILABLE_API_FORMATS = [
|
|
||||||
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
|
||||||
{ value: 'openai:cli', label: 'OpenAI CLI' },
|
|
||||||
{ value: 'openai:compact', label: 'OpenAI Compact' },
|
|
||||||
{ value: 'openai:video', label: 'OpenAI Video' },
|
|
||||||
{ value: 'claude:chat', label: 'Claude Chat' },
|
|
||||||
{ value: 'claude:cli', label: 'Claude CLI' },
|
|
||||||
{ value: 'gemini:chat', label: 'Gemini Chat' },
|
|
||||||
{ value: 'gemini:cli', label: 'Gemini CLI' },
|
|
||||||
{ value: 'gemini:video', label: 'Gemini Video' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
// 使用模块级常量
|
|
||||||
const availableApiFormats = AVAILABLE_API_FORMATS
|
|
||||||
|
|
||||||
const timeRangeModel = computed({
|
const timeRangeModel = computed({
|
||||||
get: () => props.timeRange,
|
get: () => props.timeRange,
|
||||||
set: (value: DateRangeParams) => emit('update:timeRange', value)
|
set: (value: DateRangeParams) => emit('update:timeRange', value)
|
||||||
@@ -760,6 +753,44 @@ watch(localSearch, (value) => {
|
|||||||
emitSearchDebounced(value)
|
emitSearchDebounced(value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function getApiFormatOptionLabel(option: AnalyticsFilterOption): string {
|
||||||
|
return formatApiFormat(option.label || option.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusOptionLabel(option: AnalyticsFilterOption): string {
|
||||||
|
const statusLabelMap: Record<string, string> = {
|
||||||
|
pending: '等待',
|
||||||
|
streaming: '传输中',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
cancelled: '已取消',
|
||||||
|
active: '活跃',
|
||||||
|
stream: '流式',
|
||||||
|
standard: '标准',
|
||||||
|
has_retry: '发生重试',
|
||||||
|
has_fallback: '发生转移',
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusLabelMap[option.value] || option.label || option.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProviderLabel(value: string | null | undefined): string {
|
||||||
|
if (!value) return '-'
|
||||||
|
if (value === 'pending') return '待分配提供商'
|
||||||
|
if (value === 'unknown') return '未识别提供商'
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserDisplayName(record: UsageRecord): string {
|
||||||
|
return record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserApiKeyLabel(record: UsageRecord): string | null {
|
||||||
|
const name = record.api_key?.name?.trim()
|
||||||
|
if (name) return name
|
||||||
|
return record.api_key?.id ? null : '已删除Key'
|
||||||
|
}
|
||||||
|
|
||||||
// 使用复用的行点击逻辑
|
// 使用复用的行点击逻辑
|
||||||
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
export { default as UsageModelTable } from './UsageModelTable.vue'
|
|
||||||
export { default as UsageProviderTable } from './UsageProviderTable.vue'
|
|
||||||
export { default as UsageApiFormatTable } from './UsageApiFormatTable.vue'
|
|
||||||
export { default as UsageRecordsTable } from './UsageRecordsTable.vue'
|
export { default as UsageRecordsTable } from './UsageRecordsTable.vue'
|
||||||
export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue'
|
export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue'
|
||||||
export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue'
|
export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue'
|
||||||
export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue'
|
|
||||||
export { default as IntervalTimelineCard } from './IntervalTimelineCard.vue'
|
|
||||||
|
|||||||
@@ -28,18 +28,17 @@ export function getDateRangeFromPeriod(period: PeriodValue): DateRangeParams {
|
|||||||
case 'today':
|
case 'today':
|
||||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
break
|
break
|
||||||
case 'yesterday':
|
|
||||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
|
||||||
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
||||||
break
|
|
||||||
case 'last7days':
|
case 'last7days':
|
||||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
break
|
break
|
||||||
case 'last30days':
|
case 'last30days':
|
||||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||||
break
|
break
|
||||||
case 'last90days':
|
case 'last180days':
|
||||||
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
|
startDate = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000)
|
||||||
|
break
|
||||||
|
case 'last1year':
|
||||||
|
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
return {} // 返回空对象表示不过滤时间
|
return {} // 返回空对象表示不过滤时间
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { ref, computed, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { usageApi } from '@/api/usage'
|
import { analyticsApi, type AnalyticsBaseRequest, type AnalyticsFilterOption, type AnalyticsRecord, type AnalyticsScope } from '@/api/analytics'
|
||||||
import { meApi } from '@/api/me'
|
import { buildTimeRangeParams } from '@/composables/useAnalyticsFilters'
|
||||||
import type {
|
import type {
|
||||||
UsageStatsState,
|
|
||||||
ModelStatsItem,
|
|
||||||
ProviderStatsItem,
|
|
||||||
ApiFormatStatsItem,
|
|
||||||
UsageRecord,
|
UsageRecord,
|
||||||
DateRangeParams,
|
DateRangeParams,
|
||||||
EnhancedModelStatsItem
|
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { createDefaultStats } from '../types'
|
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import { getErrorStatus } from '@/types/api-error'
|
import { getErrorStatus } from '@/types/api-error'
|
||||||
|
|
||||||
@@ -26,27 +20,21 @@ export interface PaginationParams {
|
|||||||
export interface FilterParams {
|
export interface FilterParams {
|
||||||
search?: string
|
search?: string
|
||||||
user_id?: string
|
user_id?: string
|
||||||
|
api_key_id?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
api_format?: string
|
api_format?: string
|
||||||
status?: string
|
status?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FilterOptionDimension = 'user' | 'api_key' | 'model' | 'provider' | 'api_format' | 'status'
|
||||||
|
|
||||||
export function useUsageData(options: UseUsageDataOptions) {
|
export function useUsageData(options: UseUsageDataOptions) {
|
||||||
const { isAdminPage } = options
|
const { isAdminPage } = options
|
||||||
|
|
||||||
// 加载状态
|
|
||||||
const isLoadingStats = ref(true)
|
|
||||||
const isLoadingRecords = ref(false)
|
const isLoadingRecords = ref(false)
|
||||||
const loading = computed(() => isLoadingStats.value || isLoadingRecords.value)
|
|
||||||
|
|
||||||
// 统计数据
|
// 记录数据:前后端统一按当前页维护
|
||||||
const stats = ref<UsageStatsState>(createDefaultStats())
|
|
||||||
const modelStats = ref<ModelStatsItem[]>([])
|
|
||||||
const providerStats = ref<ProviderStatsItem[]>([])
|
|
||||||
const apiFormatStats = ref<ApiFormatStatsItem[]>([])
|
|
||||||
|
|
||||||
// 记录数据 - 只存储当前页
|
|
||||||
const currentRecords = ref<UsageRecord[]>([])
|
const currentRecords = ref<UsageRecord[]>([])
|
||||||
const totalRecords = ref(0)
|
const totalRecords = ref(0)
|
||||||
|
|
||||||
@@ -56,195 +44,93 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
let loadRecordsRequestId = 0
|
let loadRecordsRequestId = 0
|
||||||
|
|
||||||
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
||||||
const availableModels = ref<string[]>([])
|
const availableUsers = ref<AnalyticsFilterOption[]>([])
|
||||||
const availableProviders = ref<string[]>([])
|
const availableModels = ref<AnalyticsFilterOption[]>([])
|
||||||
|
const availableProviders = ref<AnalyticsFilterOption[]>([])
|
||||||
|
const availableApiKeys = ref<AnalyticsFilterOption[]>([])
|
||||||
|
const availableApiFormats = ref<AnalyticsFilterOption[]>([])
|
||||||
|
const availableStatuses = ref<AnalyticsFilterOption[]>([])
|
||||||
|
|
||||||
// 增强的模型统计(包含效率分析)
|
function mapAnalyticsRecord(record: AnalyticsRecord): UsageRecord {
|
||||||
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
|
return {
|
||||||
return modelStats.value.map(model => ({
|
id: record.id,
|
||||||
...model,
|
user_id: record.user_id || undefined,
|
||||||
costPerToken: model.total_tokens > 0
|
username: record.username || undefined,
|
||||||
? `$${(model.total_cost / model.total_tokens * 1000000).toFixed(2)}/M`
|
api_key: {
|
||||||
: '-'
|
id: record.api_key_id,
|
||||||
}))
|
name: record.api_key_name,
|
||||||
})
|
display: null,
|
||||||
|
},
|
||||||
|
provider: record.provider_name,
|
||||||
|
api_key_name: record.provider_api_key_name || undefined,
|
||||||
|
rate_multiplier: record.rate_multiplier,
|
||||||
|
model: record.model,
|
||||||
|
target_model: record.target_model,
|
||||||
|
api_format: record.api_format || undefined,
|
||||||
|
endpoint_api_format: undefined,
|
||||||
|
has_format_conversion: record.has_format_conversion ?? undefined,
|
||||||
|
input_tokens: record.input_tokens,
|
||||||
|
output_tokens: record.output_tokens,
|
||||||
|
cache_creation_input_tokens: record.cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens: record.cache_read_input_tokens,
|
||||||
|
total_tokens: record.total_tokens,
|
||||||
|
cost: record.total_cost_usd,
|
||||||
|
actual_cost: record.actual_total_cost_usd,
|
||||||
|
response_time_ms: record.response_time_ms ?? undefined,
|
||||||
|
first_byte_time_ms: record.first_byte_time_ms ?? undefined,
|
||||||
|
is_stream: record.is_stream,
|
||||||
|
status_code: record.status_code ?? undefined,
|
||||||
|
error_message: record.error_message ?? undefined,
|
||||||
|
status: record.status as UsageRecord['status'],
|
||||||
|
created_at: record.created_at || '',
|
||||||
|
has_fallback: record.has_fallback ?? undefined,
|
||||||
|
has_retry: record.has_retry ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScope(): AnalyticsScope {
|
||||||
|
if (isAdminPage.value) {
|
||||||
|
return { kind: 'global' }
|
||||||
|
}
|
||||||
|
return { kind: 'me' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnalyticsFilters(
|
||||||
|
filters?: FilterParams,
|
||||||
|
excludeDimension?: FilterOptionDimension,
|
||||||
|
): NonNullable<AnalyticsBaseRequest['filters']> {
|
||||||
|
return {
|
||||||
|
user_ids: filters?.user_id && excludeDimension !== 'user' ? [filters.user_id] : [],
|
||||||
|
models: filters?.model && excludeDimension !== 'model' ? [filters.model] : [],
|
||||||
|
provider_names: filters?.provider && excludeDimension !== 'provider' ? [filters.provider] : [],
|
||||||
|
api_key_ids: filters?.api_key_id && excludeDimension !== 'api_key' ? [filters.api_key_id] : [],
|
||||||
|
api_formats: filters?.api_format && excludeDimension !== 'api_format' ? [filters.api_format] : [],
|
||||||
|
statuses: filters?.status && excludeDimension !== 'status' ? [filters.status] : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载统计数据(不加载记录)
|
// 加载统计数据(不加载记录)
|
||||||
async function loadStats(dateRange?: DateRangeParams) {
|
async function loadStats(dateRange?: DateRangeParams, filters?: FilterParams) {
|
||||||
const requestId = ++loadStatsRequestId
|
const requestId = ++loadStatsRequestId
|
||||||
isLoadingStats.value = true
|
|
||||||
currentDateRange.value = dateRange
|
currentDateRange.value = dateRange
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isAdminPage.value) {
|
const response = await analyticsApi.getFilterOptions({
|
||||||
// 管理员页面顺序加载统计数据,避免刷新使用记录时瞬时打满后端 worker。
|
scope: buildScope(),
|
||||||
const statsData = await usageApi.getUsageStats(dateRange)
|
time_range: buildTimeRangeParams(dateRange || currentDateRange.value || {}),
|
||||||
|
filters: buildAnalyticsFilters(filters),
|
||||||
|
})
|
||||||
|
|
||||||
if (requestId !== loadStatsRequestId) {
|
if (requestId !== loadStatsRequestId) {
|
||||||
return
|
return
|
||||||
}
|
|
||||||
|
|
||||||
// statsData may contain additional fields not declared in UsageStats
|
|
||||||
const statsRaw = statsData as Record<string, unknown>
|
|
||||||
stats.value = {
|
|
||||||
total_requests: statsData.total_requests || 0,
|
|
||||||
total_tokens: statsData.total_tokens || 0,
|
|
||||||
total_cost: statsData.total_cost || 0,
|
|
||||||
total_actual_cost: statsData.total_actual_cost,
|
|
||||||
avg_response_time: statsData.avg_response_time || 0,
|
|
||||||
error_count: typeof statsRaw.error_count === 'number' ? statsRaw.error_count : undefined,
|
|
||||||
error_rate: typeof statsRaw.error_rate === 'number' ? statsRaw.error_rate : undefined,
|
|
||||||
cache_stats: statsRaw.cache_stats as UsageStatsState['cache_stats'],
|
|
||||||
period_start: '',
|
|
||||||
period_end: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelData = await usageApi.getUsageByModel(dateRange)
|
|
||||||
if (requestId !== loadStatsRequestId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
modelStats.value = modelData.map(item => {
|
|
||||||
const raw = item as Record<string, unknown>
|
|
||||||
return {
|
|
||||||
model: item.model,
|
|
||||||
request_count: item.request_count || 0,
|
|
||||||
total_tokens: item.total_tokens || 0,
|
|
||||||
total_input_context: typeof raw.total_input_context === 'number' ? raw.total_input_context : 0,
|
|
||||||
output_tokens: typeof raw.output_tokens === 'number' ? raw.output_tokens : 0,
|
|
||||||
cache_read_tokens: typeof raw.cache_read_tokens === 'number' ? raw.cache_read_tokens : 0,
|
|
||||||
cache_creation_tokens: typeof raw.cache_creation_tokens === 'number' ? raw.cache_creation_tokens : 0,
|
|
||||||
cache_hit_rate: typeof raw.cache_hit_rate === 'number' ? raw.cache_hit_rate : 0,
|
|
||||||
total_cost: item.total_cost || 0,
|
|
||||||
actual_cost: typeof raw.actual_cost === 'number' ? raw.actual_cost : undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const providerData = await usageApi.getUsageByProvider(dateRange)
|
|
||||||
if (requestId !== loadStatsRequestId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
providerStats.value = providerData.map(item => ({
|
|
||||||
provider: item.provider,
|
|
||||||
requests: item.request_count,
|
|
||||||
totalTokens: item.total_tokens || 0,
|
|
||||||
totalInputContext: item.total_input_context || 0,
|
|
||||||
outputTokens: item.output_tokens || 0,
|
|
||||||
cacheReadTokens: item.cache_read_tokens || 0,
|
|
||||||
cacheCreationTokens: item.cache_creation_tokens || 0,
|
|
||||||
cacheHitRate: item.cache_hit_rate || 0,
|
|
||||||
totalCost: item.total_cost,
|
|
||||||
actualCost: item.actual_cost,
|
|
||||||
successRate: item.success_rate,
|
|
||||||
avgResponseTime: item.avg_response_time_ms > 0
|
|
||||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
|
||||||
: '-'
|
|
||||||
}))
|
|
||||||
|
|
||||||
const apiFormatData = await usageApi.getUsageByApiFormat(dateRange)
|
|
||||||
if (requestId !== loadStatsRequestId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
apiFormatStats.value = apiFormatData.map(item => ({
|
|
||||||
api_format: item.api_format,
|
|
||||||
request_count: item.request_count || 0,
|
|
||||||
total_tokens: item.total_tokens || 0,
|
|
||||||
total_input_context: item.total_input_context || 0,
|
|
||||||
output_tokens: item.output_tokens || 0,
|
|
||||||
cache_read_tokens: item.cache_read_tokens || 0,
|
|
||||||
cache_creation_tokens: item.cache_creation_tokens || 0,
|
|
||||||
cache_hit_rate: item.cache_hit_rate || 0,
|
|
||||||
total_cost: item.total_cost || 0,
|
|
||||||
actual_cost: item.actual_cost,
|
|
||||||
avgResponseTime: item.avg_response_time_ms > 0
|
|
||||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
|
||||||
: '-'
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 从统计数据中提取可用的筛选选项
|
|
||||||
availableModels.value = modelData.map(item => item.model).filter(Boolean).sort()
|
|
||||||
availableProviders.value = providerData.map(item => item.provider).filter(Boolean).sort()
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// 用户页面
|
|
||||||
const userData = await meApi.getUsage(dateRange)
|
|
||||||
if (requestId !== loadStatsRequestId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.value = {
|
|
||||||
total_requests: userData.total_requests || 0,
|
|
||||||
total_tokens: userData.total_tokens || 0,
|
|
||||||
total_cost: userData.total_cost || 0,
|
|
||||||
total_actual_cost: userData.total_actual_cost,
|
|
||||||
avg_response_time: userData.avg_response_time || 0,
|
|
||||||
period_start: '',
|
|
||||||
period_end: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
modelStats.value = (userData.summary_by_model || []).map((item) => ({
|
|
||||||
model: item.model,
|
|
||||||
request_count: item.requests || 0,
|
|
||||||
total_tokens: item.total_tokens || 0,
|
|
||||||
total_input_context: item.total_input_context || 0,
|
|
||||||
output_tokens: item.output_tokens || 0,
|
|
||||||
cache_read_tokens: item.cache_read_tokens || 0,
|
|
||||||
cache_creation_tokens: item.cache_creation_tokens || 0,
|
|
||||||
cache_hit_rate: item.cache_hit_rate || 0,
|
|
||||||
total_cost: item.total_cost_usd || 0,
|
|
||||||
actual_cost: item.actual_total_cost_usd
|
|
||||||
}))
|
|
||||||
|
|
||||||
providerStats.value = (userData.summary_by_provider || []).map((item) => ({
|
|
||||||
provider: item.provider,
|
|
||||||
requests: item.requests || 0,
|
|
||||||
totalTokens: item.total_tokens || 0,
|
|
||||||
totalInputContext: item.total_input_context || 0,
|
|
||||||
outputTokens: item.output_tokens || 0,
|
|
||||||
cacheReadTokens: item.cache_read_tokens || 0,
|
|
||||||
cacheCreationTokens: item.cache_creation_tokens || 0,
|
|
||||||
cacheHitRate: item.cache_hit_rate || 0,
|
|
||||||
totalCost: item.total_cost_usd || 0,
|
|
||||||
successRate: item.success_rate || 0,
|
|
||||||
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
|
|
||||||
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
|
|
||||||
: '-'
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 用户页面:记录直接从 userData 获取(数量较少)
|
|
||||||
// 使用 mergeRecordStatus 保护已有的活跃状态,避免轮询更新被覆盖
|
|
||||||
const nextRecords = (userData.records || []) as UsageRecord[]
|
|
||||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
|
||||||
totalRecords.value = userData.pagination?.total ?? currentRecords.value.length
|
|
||||||
|
|
||||||
// 从记录中提取筛选选项
|
|
||||||
const models = new Set<string>()
|
|
||||||
const providers = new Set<string>()
|
|
||||||
currentRecords.value.forEach(record => {
|
|
||||||
if (record.model) models.add(record.model)
|
|
||||||
if (record.provider) providers.add(record.provider)
|
|
||||||
})
|
|
||||||
availableModels.value = Array.from(models).sort()
|
|
||||||
availableProviders.value = Array.from(providers).sort()
|
|
||||||
|
|
||||||
// API 格式统计直接使用后端聚合数据
|
|
||||||
apiFormatStats.value = (userData.summary_by_api_format || []).map(item => ({
|
|
||||||
api_format: item.api_format,
|
|
||||||
request_count: item.request_count || 0,
|
|
||||||
total_tokens: item.total_tokens || 0,
|
|
||||||
total_input_context: item.total_input_context || 0,
|
|
||||||
output_tokens: item.output_tokens || 0,
|
|
||||||
cache_read_tokens: item.cache_read_tokens || 0,
|
|
||||||
cache_creation_tokens: item.cache_creation_tokens || 0,
|
|
||||||
cache_hit_rate: item.cache_hit_rate || 0,
|
|
||||||
total_cost: item.total_cost_usd || 0,
|
|
||||||
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
|
|
||||||
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
|
|
||||||
: '-'
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
availableUsers.value = response.users ?? []
|
||||||
|
availableModels.value = response.models ?? []
|
||||||
|
availableProviders.value = response.providers ?? []
|
||||||
|
availableApiKeys.value = response.api_keys ?? []
|
||||||
|
availableApiFormats.value = response.api_formats ?? []
|
||||||
|
availableStatuses.value = response.statuses ?? []
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (requestId !== loadStatsRequestId) {
|
if (requestId !== loadStatsRequestId) {
|
||||||
return
|
return
|
||||||
@@ -252,17 +138,17 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
if (getErrorStatus(error) !== 403) {
|
if (getErrorStatus(error) !== 403) {
|
||||||
log.error('加载统计数据失败:', error)
|
log.error('加载统计数据失败:', error)
|
||||||
}
|
}
|
||||||
stats.value = createDefaultStats()
|
availableUsers.value = []
|
||||||
modelStats.value = []
|
availableModels.value = []
|
||||||
|
availableProviders.value = []
|
||||||
currentRecords.value = []
|
currentRecords.value = []
|
||||||
} finally {
|
availableApiKeys.value = []
|
||||||
if (requestId === loadStatsRequestId) {
|
availableApiFormats.value = []
|
||||||
isLoadingStats.value = false
|
availableStatuses.value = []
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载记录(真正的后端分页)
|
// 加载记录:前后端统一由后端分页和筛选
|
||||||
async function loadRecords(
|
async function loadRecords(
|
||||||
pagination: PaginationParams,
|
pagination: PaginationParams,
|
||||||
filters?: FilterParams,
|
filters?: FilterParams,
|
||||||
@@ -278,53 +164,24 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
currentDateRange.value = dateRange
|
currentDateRange.value = dateRange
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建请求参数
|
const response = await analyticsApi.getRecords({
|
||||||
const params: Record<string, unknown> = {
|
scope: buildScope(),
|
||||||
limit: pagination.pageSize,
|
time_range: buildTimeRangeParams(currentDateRange.value || {}),
|
||||||
offset,
|
filters: buildAnalyticsFilters(filters),
|
||||||
...effectiveDateRange
|
search: {
|
||||||
}
|
text: filters?.search || null,
|
||||||
|
},
|
||||||
// 添加筛选条件
|
pagination: {
|
||||||
if (filters?.search?.trim()) {
|
limit: pagination.pageSize,
|
||||||
params.search = filters.search.trim()
|
offset,
|
||||||
}
|
},
|
||||||
|
})
|
||||||
if (isAdminPage.value) {
|
if (requestId !== loadRecordsRequestId) {
|
||||||
// 管理员页面:使用管理员 API
|
return
|
||||||
if (filters?.user_id) {
|
|
||||||
params.user_id = filters.user_id
|
|
||||||
}
|
|
||||||
if (filters?.model) {
|
|
||||||
params.model = filters.model
|
|
||||||
}
|
|
||||||
if (filters?.provider) {
|
|
||||||
params.provider = filters.provider
|
|
||||||
}
|
|
||||||
if (filters?.api_format) {
|
|
||||||
params.api_format = filters.api_format
|
|
||||||
}
|
|
||||||
if (filters?.status) {
|
|
||||||
params.status = filters.status
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await usageApi.getAllUsageRecords(params)
|
|
||||||
if (requestId !== loadRecordsRequestId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const nextRecords = (response.records || []) as UsageRecord[]
|
|
||||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
|
||||||
totalRecords.value = response.total || 0
|
|
||||||
} else {
|
|
||||||
// 用户页面:使用用户 API
|
|
||||||
const userData = await meApi.getUsage(params)
|
|
||||||
if (requestId !== loadRecordsRequestId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const nextRecords = (userData.records || []) as UsageRecord[]
|
|
||||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
|
||||||
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
|
||||||
}
|
}
|
||||||
|
const nextRecords = (response.records || []).map(mapAnalyticsRecord)
|
||||||
|
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||||
|
totalRecords.value = response.total || 0
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== loadRecordsRequestId) {
|
if (requestId !== loadRecordsRequestId) {
|
||||||
return
|
return
|
||||||
@@ -412,33 +269,19 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新所有数据
|
|
||||||
async function refreshData(dateRange?: DateRangeParams) {
|
|
||||||
await loadStats(dateRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
|
||||||
loading,
|
|
||||||
isLoadingStats,
|
|
||||||
isLoadingRecords,
|
isLoadingRecords,
|
||||||
stats,
|
|
||||||
modelStats,
|
|
||||||
providerStats,
|
|
||||||
apiFormatStats,
|
|
||||||
currentRecords,
|
currentRecords,
|
||||||
totalRecords,
|
totalRecords,
|
||||||
|
|
||||||
// 筛选选项
|
availableUsers,
|
||||||
availableModels,
|
availableModels,
|
||||||
availableProviders,
|
availableProviders,
|
||||||
|
availableApiKeys,
|
||||||
|
availableApiFormats,
|
||||||
|
availableStatuses,
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
enhancedModelStats,
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
loadStats,
|
loadStats,
|
||||||
loadRecords,
|
loadRecords,
|
||||||
refreshData
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,3 @@
|
|||||||
// 统计数据状态
|
|
||||||
export interface UsageStatsState {
|
|
||||||
total_requests: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
total_actual_cost?: number // 倍率消耗(仅管理员可见)
|
|
||||||
avg_response_time: number
|
|
||||||
error_count?: number
|
|
||||||
error_rate?: number
|
|
||||||
cache_stats?: {
|
|
||||||
cache_creation_tokens: number
|
|
||||||
cache_read_tokens: number
|
|
||||||
cache_creation_cost: number
|
|
||||||
cache_read_cost: number
|
|
||||||
}
|
|
||||||
period_start: string
|
|
||||||
period_end: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模型统计
|
|
||||||
export interface ModelStatsItem {
|
|
||||||
model: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
total_input_context?: number
|
|
||||||
output_tokens?: number
|
|
||||||
cache_read_tokens?: number
|
|
||||||
cache_creation_tokens?: number
|
|
||||||
cache_hit_rate?: number
|
|
||||||
total_cost: number
|
|
||||||
actual_cost?: number // 倍率消耗
|
|
||||||
}
|
|
||||||
|
|
||||||
// 增强的模型统计(包含效率分析)
|
|
||||||
export interface EnhancedModelStatsItem extends ModelStatsItem {
|
|
||||||
costPerToken: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提供商统计
|
|
||||||
export interface ProviderStatsItem {
|
|
||||||
provider: string
|
|
||||||
requests: number
|
|
||||||
totalTokens: number
|
|
||||||
totalInputContext?: number
|
|
||||||
outputTokens?: number
|
|
||||||
cacheReadTokens?: number
|
|
||||||
cacheCreationTokens?: number
|
|
||||||
cacheHitRate?: number
|
|
||||||
totalCost: number
|
|
||||||
actualCost?: number
|
|
||||||
successRate: number
|
|
||||||
avgResponseTime: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// API格式统计
|
|
||||||
export interface ApiFormatStatsItem {
|
|
||||||
api_format: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
total_input_context?: number
|
|
||||||
output_tokens?: number
|
|
||||||
cache_read_tokens?: number
|
|
||||||
cache_creation_tokens?: number
|
|
||||||
cache_hit_rate?: number
|
|
||||||
total_cost: number
|
|
||||||
actual_cost?: number
|
|
||||||
avgResponseTime: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 请求记录
|
|
||||||
// 请求状态类型
|
|
||||||
export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||||
|
|
||||||
export interface UsageRecord {
|
export interface UsageRecord {
|
||||||
@@ -82,7 +11,7 @@ export interface UsageRecord {
|
|||||||
display: string | null
|
display: string | null
|
||||||
} | null
|
} | null
|
||||||
provider?: string // 仅管理员可见
|
provider?: string // 仅管理员可见
|
||||||
api_key_name?: string
|
api_key_name?: string // 提供商 Key 名称(管理员列使用)
|
||||||
rate_multiplier?: number
|
rate_multiplier?: number
|
||||||
model: string
|
model: string
|
||||||
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
||||||
@@ -113,29 +42,13 @@ export interface DateRangeParams {
|
|||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
preset?: string
|
preset?: string
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
granularity?: 'auto' | 'hour' | 'day' | 'week' | 'month'
|
||||||
timezone?: string
|
timezone?: string
|
||||||
tz_offset_minutes?: number
|
tz_offset_minutes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 时间段选项
|
// 时间段选项
|
||||||
export type PeriodValue = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days'
|
export type PeriodValue = 'today' | 'last7days' | 'last30days' | 'last180days' | 'last1year'
|
||||||
|
|
||||||
// 筛选状态(简化为常用维度)
|
// 筛选状态由后端 filter-options 决定
|
||||||
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'active' | 'failed' | 'cancelled'
|
export type FilterStatusValue = string
|
||||||
|
|
||||||
// 默认统计状态
|
|
||||||
export function createDefaultStats(): UsageStatsState {
|
|
||||||
return {
|
|
||||||
total_requests: 0,
|
|
||||||
total_tokens: 0,
|
|
||||||
total_cost: 0,
|
|
||||||
total_actual_cost: undefined,
|
|
||||||
avg_response_time: 0,
|
|
||||||
error_count: undefined,
|
|
||||||
error_rate: undefined,
|
|
||||||
cache_stats: undefined,
|
|
||||||
period_start: '',
|
|
||||||
period_end: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ const navigation = computed(() => {
|
|||||||
items: [
|
items: [
|
||||||
{ name: '仪表盘', href: '/dashboard', icon: Home },
|
{ name: '仪表盘', href: '/dashboard', icon: Home },
|
||||||
{ name: '健康监控', href: '/dashboard/endpoint-status', icon: Activity },
|
{ name: '健康监控', href: '/dashboard/endpoint-status', icon: Activity },
|
||||||
|
{ name: '综合报表', href: '/dashboard/reports', icon: BarChart3 },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -529,7 +530,7 @@ const navigation = computed(() => {
|
|||||||
title: '账户',
|
title: '账户',
|
||||||
items: [
|
items: [
|
||||||
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
|
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
|
||||||
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
|
{ name: '使用记录', href: '/dashboard/usage', icon: Activity },
|
||||||
{ name: '异步任务', href: '/dashboard/async-tasks', icon: Zap },
|
{ name: '异步任务', href: '/dashboard/async-tasks', icon: Zap },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -573,10 +574,8 @@ const navigation = computed(() => {
|
|||||||
title: '概览',
|
title: '概览',
|
||||||
items: [
|
items: [
|
||||||
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
|
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
|
||||||
|
{ name: '数据分析', href: '/admin/analytics', icon: BarChart3 },
|
||||||
{ name: '健康监控', href: '/admin/health-monitor', icon: Activity },
|
{ name: '健康监控', href: '/admin/health-monitor', icon: Activity },
|
||||||
{ name: '用户统计', href: '/admin/user-stats', icon: BarChart3 },
|
|
||||||
{ name: '成本分析', href: '/admin/cost-analysis', icon: Gauge },
|
|
||||||
{ name: '性能分析', href: '/admin/performance-analysis', icon: Activity },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-210
@@ -4,10 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { User, LoginResponse } from '@/api/auth'
|
import type { User, LoginResponse } from '@/api/auth'
|
||||||
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
|
|
||||||
import type { User as AdminUser } from '@/api/users'
|
import type { User as AdminUser } from '@/api/users'
|
||||||
import type { AdminApiKeysResponse } from '@/api/admin'
|
import type { AdminApiKeysResponse } from '@/api/admin'
|
||||||
import type { Profile, UsageResponse } from '@/api/me'
|
import type { Profile } from '@/api/me'
|
||||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||||
|
|
||||||
// ========== 用户数据 ==========
|
// ========== 用户数据 ==========
|
||||||
@@ -128,163 +127,6 @@ export const MOCK_USER_PROFILE: Profile = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Dashboard 数据 ==========
|
|
||||||
|
|
||||||
export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
|
|
||||||
stats: [
|
|
||||||
{
|
|
||||||
name: '今日请求',
|
|
||||||
value: '1,234',
|
|
||||||
subValue: '成功率 99.2%',
|
|
||||||
change: '+12.5%',
|
|
||||||
changeType: 'increase',
|
|
||||||
icon: 'Activity'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '今日 Token',
|
|
||||||
value: '2.5M',
|
|
||||||
subValue: '输入 1.8M / 输出 0.7M',
|
|
||||||
change: '+8.3%',
|
|
||||||
changeType: 'increase',
|
|
||||||
icon: 'Zap'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '今日费用',
|
|
||||||
value: '$45.67',
|
|
||||||
subValue: '节省 $12.34 (21%)',
|
|
||||||
change: '-5.2%',
|
|
||||||
changeType: 'decrease',
|
|
||||||
icon: 'DollarSign'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '活跃用户',
|
|
||||||
value: '28',
|
|
||||||
subValue: '总用户 156',
|
|
||||||
change: '+3',
|
|
||||||
changeType: 'increase',
|
|
||||||
icon: 'Users'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
today: {
|
|
||||||
requests: 1234,
|
|
||||||
tokens: 2500000,
|
|
||||||
cost: 45.67,
|
|
||||||
actual_cost: 33.33,
|
|
||||||
cache_creation_tokens: 50000,
|
|
||||||
cache_read_tokens: 200000
|
|
||||||
},
|
|
||||||
api_keys: {
|
|
||||||
total: 45,
|
|
||||||
active: 38
|
|
||||||
},
|
|
||||||
tokens: {
|
|
||||||
month: 75000000
|
|
||||||
},
|
|
||||||
system_health: {
|
|
||||||
avg_response_time: 1.23,
|
|
||||||
error_rate: 0.8,
|
|
||||||
error_requests: 10,
|
|
||||||
fallback_count: 5,
|
|
||||||
total_requests: 1234
|
|
||||||
},
|
|
||||||
cost_stats: {
|
|
||||||
total_cost: 45.67,
|
|
||||||
total_actual_cost: 33.33,
|
|
||||||
cost_savings: 12.34
|
|
||||||
},
|
|
||||||
cache_stats: {
|
|
||||||
cache_creation_tokens: 50000,
|
|
||||||
cache_read_tokens: 200000,
|
|
||||||
cache_creation_cost: 0.25,
|
|
||||||
cache_read_cost: 0.10,
|
|
||||||
cache_hit_rate: 0.35,
|
|
||||||
total_cache_tokens: 250000
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
total: 156,
|
|
||||||
active: 28
|
|
||||||
},
|
|
||||||
token_breakdown: {
|
|
||||||
input: 1800000,
|
|
||||||
output: 700000,
|
|
||||||
cache_creation: 50000,
|
|
||||||
cache_read: 200000
|
|
||||||
},
|
|
||||||
// 普通用户专用字段
|
|
||||||
monthly_cost: 45.67
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
|
|
||||||
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
|
|
||||||
{ id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
|
|
||||||
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
|
|
||||||
{ id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
|
|
||||||
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
|
|
||||||
{ id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
|
|
||||||
{ id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
|
|
||||||
{ id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
|
|
||||||
]
|
|
||||||
|
|
||||||
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
|
|
||||||
{ name: 'Anthropic Official', status: 'active', requests: 456 },
|
|
||||||
{ name: 'OpenAI Official', status: 'active', requests: 389 },
|
|
||||||
{ name: 'Google AI', status: 'active', requests: 234 },
|
|
||||||
{ name: 'AWS Bedrock', status: 'active', requests: 89 },
|
|
||||||
{ name: 'Azure OpenAI', status: 'inactive', requests: 0 },
|
|
||||||
{ name: 'Vertex AI', status: 'active', requests: 66 }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 生成过去7天的每日统计数据
|
|
||||||
function generateDailyStats(): DailyStatsResponse {
|
|
||||||
const dailyStats = []
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
for (let i = 6; i >= 0; i--) {
|
|
||||||
const date = new Date(now)
|
|
||||||
date.setDate(date.getDate() - i)
|
|
||||||
const dateStr = date.toISOString().split('T')[0]
|
|
||||||
|
|
||||||
const baseRequests = 800 + Math.floor(Math.random() * 600)
|
|
||||||
const baseTokens = 1500000 + Math.floor(Math.random() * 1500000)
|
|
||||||
const baseCost = 30 + Math.random() * 30
|
|
||||||
|
|
||||||
dailyStats.push({
|
|
||||||
date: dateStr,
|
|
||||||
requests: baseRequests,
|
|
||||||
tokens: baseTokens,
|
|
||||||
cost: Number(baseCost.toFixed(2)),
|
|
||||||
avg_response_time: 0.8 + Math.random() * 0.8,
|
|
||||||
unique_models: 8 + Math.floor(Math.random() * 5),
|
|
||||||
unique_providers: 4 + Math.floor(Math.random() * 3),
|
|
||||||
model_breakdown: [
|
|
||||||
{ model: 'claude-sonnet-4-5-20250929', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
|
|
||||||
{ model: 'gpt-5.1', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
|
|
||||||
{ model: 'claude-opus-4-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
|
|
||||||
{ model: 'gemini-3-pro-preview', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
|
|
||||||
{ model: 'claude-haiku-4-5-20251001', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
daily_stats: dailyStats,
|
|
||||||
model_summary: [
|
|
||||||
{ model: 'claude-sonnet-4-5-20250929', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
|
|
||||||
{ model: 'gpt-5.1', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
|
|
||||||
{ model: 'claude-opus-4-5-20251101', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
|
|
||||||
{ model: 'gemini-3-pro-preview', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
|
|
||||||
{ model: 'claude-haiku-4-5-20251001', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
|
|
||||||
],
|
|
||||||
period: {
|
|
||||||
start_date: dailyStats[0].date,
|
|
||||||
end_date: dailyStats[dailyStats.length - 1].date,
|
|
||||||
days: 7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MOCK_DAILY_STATS = generateDailyStats()
|
|
||||||
|
|
||||||
// ========== 用户管理数据 ==========
|
// ========== 用户管理数据 ==========
|
||||||
|
|
||||||
export const MOCK_ALL_USERS: AdminUser[] = [
|
export const MOCK_ALL_USERS: AdminUser[] = [
|
||||||
@@ -808,57 +650,6 @@ export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
// ========== Usage 数据 ==========
|
|
||||||
|
|
||||||
export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
|
||||||
total_requests: 1234,
|
|
||||||
total_input_tokens: 1800000,
|
|
||||||
total_output_tokens: 700000,
|
|
||||||
total_tokens: 2500000,
|
|
||||||
total_cost: 45.67,
|
|
||||||
total_actual_cost: 33.33,
|
|
||||||
avg_response_time: 1.23,
|
|
||||||
billing: MOCK_USER_BILLING,
|
|
||||||
summary_by_model: [
|
|
||||||
{ model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
|
|
||||||
{ model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
|
|
||||||
{ model: 'claude-haiku-4-5-20251001', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
|
|
||||||
{ model: 'gemini-3-pro-preview', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
|
|
||||||
],
|
|
||||||
records: [
|
|
||||||
{
|
|
||||||
id: 'usage-001',
|
|
||||||
provider: 'anthropic',
|
|
||||||
model: 'claude-sonnet-4-5-20250929',
|
|
||||||
input_tokens: 1500,
|
|
||||||
output_tokens: 800,
|
|
||||||
total_tokens: 2300,
|
|
||||||
cost: 0.0165,
|
|
||||||
response_time_ms: 1234,
|
|
||||||
is_stream: true,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
status_code: 200,
|
|
||||||
input_price_per_1m: 3,
|
|
||||||
output_price_per_1m: 15
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'usage-002',
|
|
||||||
provider: 'openai',
|
|
||||||
model: 'gpt-5.1',
|
|
||||||
input_tokens: 2000,
|
|
||||||
output_tokens: 500,
|
|
||||||
total_tokens: 2500,
|
|
||||||
cost: 0.01,
|
|
||||||
response_time_ms: 890,
|
|
||||||
is_stream: false,
|
|
||||||
created_at: new Date(Date.now() - 300000).toISOString(),
|
|
||||||
status_code: 200,
|
|
||||||
input_price_per_1m: 2.5,
|
|
||||||
output_price_per_1m: 10
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 系统配置 ==========
|
// ========== 系统配置 ==========
|
||||||
|
|
||||||
export const MOCK_SYSTEM_CONFIGS = [
|
export const MOCK_SYSTEM_CONFIGS = [
|
||||||
|
|||||||
+1228
-499
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'MyUsage',
|
name: 'MyUsage',
|
||||||
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
|
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'reports',
|
||||||
|
name: 'Reports',
|
||||||
|
component: () => importWithRetry(() => import('@/views/user/Reports.vue'))
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'endpoint-status',
|
path: 'endpoint-status',
|
||||||
name: 'EndpointStatus',
|
name: 'EndpointStatus',
|
||||||
@@ -196,19 +201,9 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
|
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'user-stats',
|
path: 'analytics',
|
||||||
name: 'UserStats',
|
name: 'Analytics',
|
||||||
component: () => importWithRetry(() => import('@/views/admin/UserStats.vue'))
|
component: () => importWithRetry(() => import('@/views/admin/Analytics.vue'))
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'cost-analysis',
|
|
||||||
name: 'CostAnalysis',
|
|
||||||
component: () => importWithRetry(() => import('@/views/admin/CostAnalysis.vue'))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'performance-analysis',
|
|
||||||
name: 'PerformanceAnalysis',
|
|
||||||
component: () => importWithRetry(() => import('@/views/admin/PerformanceAnalysis.vue'))
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'system',
|
path: 'system',
|
||||||
@@ -272,11 +267,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: 'gemini-files',
|
path: 'gemini-files',
|
||||||
name: 'GeminiFilesManagement',
|
name: 'GeminiFilesManagement',
|
||||||
component: () => importWithRetry(() => import('@/views/admin/GeminiFilesManagement.vue'))
|
component: () => importWithRetry(() => import('@/views/admin/GeminiFilesManagement.vue'))
|
||||||
},
|
|
||||||
// 保留旧路由兼容性
|
|
||||||
{
|
|
||||||
path: 'video-tasks',
|
|
||||||
redirect: '/admin/async-tasks'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { DateRangeParams } from '@/features/usage/types'
|
||||||
|
|
||||||
|
export type AnalyticsGranularityOption = NonNullable<DateRangeParams['granularity']>
|
||||||
|
export type ResolvedAnalyticsGranularity = Exclude<AnalyticsGranularityOption, 'auto'>
|
||||||
|
|
||||||
|
export const analyticsGranularityTabs: Array<{ value: AnalyticsGranularityOption; label: string }> = [
|
||||||
|
{ value: 'auto', label: '自动' },
|
||||||
|
{ value: 'hour', label: '小时' },
|
||||||
|
{ value: 'day', label: '天' },
|
||||||
|
{ value: 'week', label: '周' },
|
||||||
|
{ value: 'month', label: '月' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function parseAnalyticsDateKey(dateKey?: string): Date | null {
|
||||||
|
if (!dateKey) return null
|
||||||
|
return new Date(`${dateKey}T00:00:00`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAnalyticsRangeBounds(range: DateRangeParams): { startDate: Date | null; endDate: Date | null } {
|
||||||
|
const today = new Date()
|
||||||
|
|
||||||
|
switch (range.preset) {
|
||||||
|
case 'today':
|
||||||
|
return {
|
||||||
|
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||||
|
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||||
|
}
|
||||||
|
case 'last7days':
|
||||||
|
return {
|
||||||
|
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6),
|
||||||
|
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||||
|
}
|
||||||
|
case 'last30days':
|
||||||
|
return {
|
||||||
|
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29),
|
||||||
|
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||||
|
}
|
||||||
|
case 'last180days':
|
||||||
|
return {
|
||||||
|
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 179),
|
||||||
|
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||||
|
}
|
||||||
|
case 'last1year':
|
||||||
|
return {
|
||||||
|
startDate: new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()),
|
||||||
|
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
startDate: parseAnalyticsDateKey(range.start_date),
|
||||||
|
endDate: parseAnalyticsDateKey(range.end_date),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAnalyticsRangeDaysInclusive(range: DateRangeParams): number | null {
|
||||||
|
const { startDate, endDate } = resolveAnalyticsRangeBounds(range)
|
||||||
|
if (!startDate || !endDate) return null
|
||||||
|
const diff = endDate.getTime() - startDate.getTime()
|
||||||
|
if (diff < 0) return null
|
||||||
|
return Math.floor(diff / (24 * 60 * 60 * 1000)) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAnalyticsSingleDayRange(range: DateRangeParams): boolean {
|
||||||
|
return getAnalyticsRangeDaysInclusive(range) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAnalyticsAutoGranularity(range: DateRangeParams): ResolvedAnalyticsGranularity {
|
||||||
|
if (range.granularity && range.granularity !== 'auto') {
|
||||||
|
if (range.granularity === 'hour') {
|
||||||
|
return isAnalyticsSingleDayRange(range) ? 'hour' : 'day'
|
||||||
|
}
|
||||||
|
return range.granularity
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeDays = getAnalyticsRangeDaysInclusive(range)
|
||||||
|
if (rangeDays == null) return 'day'
|
||||||
|
if (rangeDays === 1) return 'hour'
|
||||||
|
if (rangeDays > 180) return 'month'
|
||||||
|
if (rangeDays > 31) return 'week'
|
||||||
|
return 'day'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAnalyticsTrendDescription(granularity: ResolvedAnalyticsGranularity, metricsLabel: string): string {
|
||||||
|
if (granularity === 'hour') {
|
||||||
|
return `按小时观察今天截至当前时刻的${metricsLabel}`
|
||||||
|
}
|
||||||
|
if (granularity === 'week') {
|
||||||
|
return `按周观察${metricsLabel}`
|
||||||
|
}
|
||||||
|
if (granularity === 'month') {
|
||||||
|
return `按月观察${metricsLabel}`
|
||||||
|
}
|
||||||
|
return `按日观察${metricsLabel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAnalyticsTrendBucketLabel(
|
||||||
|
bucketStart: string,
|
||||||
|
granularity: ResolvedAnalyticsGranularity,
|
||||||
|
): string {
|
||||||
|
if (granularity === 'hour') {
|
||||||
|
return `${bucketStart.slice(11, 13)}:00`
|
||||||
|
}
|
||||||
|
if (granularity === 'month') {
|
||||||
|
return bucketStart.slice(0, 7)
|
||||||
|
}
|
||||||
|
return bucketStart.slice(5, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAnalyticsDateRangeLabel(start: Date, end: Date): string {
|
||||||
|
const startLabel = `${String(start.getMonth() + 1).padStart(2, '0')}/${String(start.getDate()).padStart(2, '0')}`
|
||||||
|
const endLabel = `${String(end.getMonth() + 1).padStart(2, '0')}/${String(end.getDate()).padStart(2, '0')}`
|
||||||
|
return `${startLabel} - ${endLabel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAnalyticsTrendTooltipTitle(
|
||||||
|
bucketStart: string,
|
||||||
|
granularity: ResolvedAnalyticsGranularity,
|
||||||
|
): string {
|
||||||
|
if (granularity === 'hour') {
|
||||||
|
return `${bucketStart.slice(0, 10)} ${bucketStart.slice(11, 13)}:00`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (granularity === 'month') {
|
||||||
|
return bucketStart.slice(0, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (granularity === 'week') {
|
||||||
|
const start = parseAnalyticsDateKey(bucketStart.slice(0, 10))
|
||||||
|
if (!start) return bucketStart.slice(0, 10)
|
||||||
|
const end = new Date(start)
|
||||||
|
end.setDate(end.getDate() + 6)
|
||||||
|
return formatAnalyticsDateRangeLabel(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucketStart.slice(0, 10)
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import type { AnalyticsGranularity, AnalyticsSummary, AnalyticsTimeRange, AnalyticsTimeseriesBucket } from '@/api/analytics'
|
||||||
|
|
||||||
|
type DailyPreset = 'today' | 'last7days' | 'last30days' | 'last180days' | 'last1year'
|
||||||
|
|
||||||
|
const ZERO_ANALYTICS_SUMMARY: AnalyticsSummary = {
|
||||||
|
requests_total: 0,
|
||||||
|
requests_success: 0,
|
||||||
|
requests_error: 0,
|
||||||
|
requests_stream: 0,
|
||||||
|
success_rate: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
input_output_total_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_creation_input_tokens_5m: 0,
|
||||||
|
cache_creation_input_tokens_1h: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
input_context_tokens: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
cache_hit_rate: 0,
|
||||||
|
input_cost_usd: 0,
|
||||||
|
output_cost_usd: 0,
|
||||||
|
cache_creation_cost_usd: 0,
|
||||||
|
cache_creation_cost_usd_5m: 0,
|
||||||
|
cache_creation_cost_usd_1h: 0,
|
||||||
|
cache_read_cost_usd: 0,
|
||||||
|
cache_cost_usd: 0,
|
||||||
|
request_cost_usd: 0,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
actual_total_cost_usd: 0,
|
||||||
|
actual_cache_cost_usd: 0,
|
||||||
|
avg_response_time_ms: 0,
|
||||||
|
avg_first_byte_time_ms: 0,
|
||||||
|
format_conversion_count: 0,
|
||||||
|
models_used_count: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateKey(dateKey: string | undefined): Date | null {
|
||||||
|
if (!dateKey) return null
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey.trim())
|
||||||
|
if (!match) return null
|
||||||
|
const [, year, month, day] = match
|
||||||
|
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateKey(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocalDateKey(date: Date): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHourKey(dateKey: string, hour: number): string {
|
||||||
|
return `${dateKey}T${String(hour).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStartOfWeekDateKey(date: Date): string {
|
||||||
|
const weekday = date.getUTCDay()
|
||||||
|
const diff = weekday === 0 ? -6 : 1 - weekday
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setUTCDate(result.getUTCDate() + diff)
|
||||||
|
return formatDateKey(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStartOfMonthDateKey(date: Date): string {
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setUTCDate(1)
|
||||||
|
return formatDateKey(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextDateKey(dateKey: string): string {
|
||||||
|
const date = parseDateKey(dateKey)
|
||||||
|
if (!date) return dateKey
|
||||||
|
date.setUTCDate(date.getUTCDate() + 1)
|
||||||
|
return formatDateKey(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextWeekKey(dateKey: string): string {
|
||||||
|
const date = parseDateKey(dateKey)
|
||||||
|
if (!date) return dateKey
|
||||||
|
date.setUTCDate(date.getUTCDate() + 7)
|
||||||
|
return formatDateKey(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextMonthKey(dateKey: string): string {
|
||||||
|
const date = parseDateKey(dateKey)
|
||||||
|
if (!date) return dateKey
|
||||||
|
date.setUTCMonth(date.getUTCMonth() + 1, 1)
|
||||||
|
return formatDateKey(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresetDateRange(preset?: string): { start_date: string; end_date: string } | null {
|
||||||
|
const now = new Date()
|
||||||
|
let startDate: Date
|
||||||
|
const endDate = new Date(now)
|
||||||
|
|
||||||
|
switch (preset as DailyPreset | undefined) {
|
||||||
|
case 'today':
|
||||||
|
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
break
|
||||||
|
case 'last7days':
|
||||||
|
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6)
|
||||||
|
break
|
||||||
|
case 'last30days':
|
||||||
|
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29)
|
||||||
|
break
|
||||||
|
case 'last180days':
|
||||||
|
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 179)
|
||||||
|
break
|
||||||
|
case 'last1year':
|
||||||
|
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start_date: formatLocalDateKey(startDate),
|
||||||
|
end_date: formatLocalDateKey(endDate),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDateBounds(
|
||||||
|
timeRange: AnalyticsTimeRange,
|
||||||
|
fallbackBuckets: AnalyticsTimeseriesBucket[],
|
||||||
|
): { startDate: Date | null; endDate: Date | null } {
|
||||||
|
const sortedBuckets = sortBuckets(fallbackBuckets)
|
||||||
|
const fallbackStart = sortedBuckets[0]?.bucket_start.slice(0, 10)
|
||||||
|
const fallbackEnd = sortedBuckets[sortedBuckets.length - 1]?.bucket_start.slice(0, 10)
|
||||||
|
const presetRange = resolvePresetDateRange(timeRange.preset)
|
||||||
|
const startDate = parseDateKey(timeRange.start_date ?? presetRange?.start_date ?? fallbackStart)
|
||||||
|
const endDate = parseDateKey(timeRange.end_date ?? presetRange?.end_date ?? fallbackEnd)
|
||||||
|
return { startDate, endDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBuckets(buckets: AnalyticsTimeseriesBucket[]): AnalyticsTimeseriesBucket[] {
|
||||||
|
return buckets.slice().sort((left, right) => left.bucket_start.localeCompare(right.bucket_start))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGranularity(granularity?: string): AnalyticsGranularity {
|
||||||
|
if (granularity === 'hour' || granularity === 'week' || granularity === 'month') {
|
||||||
|
return granularity
|
||||||
|
}
|
||||||
|
return 'day'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTodayDateKey(): string {
|
||||||
|
return formatLocalDateKey(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDateKeysInRange(
|
||||||
|
timeRange: AnalyticsTimeRange,
|
||||||
|
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
|
||||||
|
): string[] {
|
||||||
|
const sortedBuckets = sortBuckets(fallbackBuckets)
|
||||||
|
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
|
||||||
|
|
||||||
|
if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) {
|
||||||
|
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates: string[] = []
|
||||||
|
const cursor = new Date(startDate)
|
||||||
|
|
||||||
|
while (cursor.getTime() <= endDate.getTime()) {
|
||||||
|
dates.push(formatDateKey(cursor))
|
||||||
|
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHourKeysInRange(
|
||||||
|
timeRange: AnalyticsTimeRange,
|
||||||
|
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
|
||||||
|
): string[] {
|
||||||
|
const sortedBuckets = sortBuckets(fallbackBuckets)
|
||||||
|
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
|
||||||
|
|
||||||
|
if (!startDate || !endDate || startDate.getTime() !== endDate.getTime()) {
|
||||||
|
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 13))
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateKey = formatDateKey(startDate)
|
||||||
|
const isToday = dateKey === resolveTodayDateKey()
|
||||||
|
const maxHour = isToday ? new Date().getHours() : 23
|
||||||
|
|
||||||
|
return Array.from({ length: maxHour + 1 }, (_, hour) => formatHourKey(dateKey, hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeekKeysInRange(
|
||||||
|
timeRange: AnalyticsTimeRange,
|
||||||
|
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
|
||||||
|
): string[] {
|
||||||
|
const sortedBuckets = sortBuckets(fallbackBuckets)
|
||||||
|
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
|
||||||
|
|
||||||
|
if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) {
|
||||||
|
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys: string[] = []
|
||||||
|
let cursorKey = getStartOfWeekDateKey(startDate)
|
||||||
|
const endKey = getStartOfWeekDateKey(endDate)
|
||||||
|
|
||||||
|
while (cursorKey <= endKey) {
|
||||||
|
keys.push(cursorKey)
|
||||||
|
cursorKey = getNextWeekKey(cursorKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonthKeysInRange(
|
||||||
|
timeRange: AnalyticsTimeRange,
|
||||||
|
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
|
||||||
|
): string[] {
|
||||||
|
const sortedBuckets = sortBuckets(fallbackBuckets)
|
||||||
|
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
|
||||||
|
|
||||||
|
if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) {
|
||||||
|
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys: string[] = []
|
||||||
|
let cursorKey = getStartOfMonthDateKey(startDate)
|
||||||
|
const endKey = getStartOfMonthDateKey(endDate)
|
||||||
|
|
||||||
|
while (cursorKey <= endKey) {
|
||||||
|
keys.push(cursorKey)
|
||||||
|
cursorKey = getNextMonthKey(cursorKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createZeroDailyBucket(dateKey: string): AnalyticsTimeseriesBucket {
|
||||||
|
return {
|
||||||
|
...ZERO_ANALYTICS_SUMMARY,
|
||||||
|
bucket_start: `${dateKey}T00:00:00`,
|
||||||
|
bucket_end: `${getNextDateKey(dateKey)}T00:00:00`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createZeroHourlyBucket(hourKey: string): AnalyticsTimeseriesBucket {
|
||||||
|
const dateKey = hourKey.slice(0, 10)
|
||||||
|
const hour = Number(hourKey.slice(11, 13))
|
||||||
|
const nextDate = new Date(Date.UTC(
|
||||||
|
Number(dateKey.slice(0, 4)),
|
||||||
|
Number(dateKey.slice(5, 7)) - 1,
|
||||||
|
Number(dateKey.slice(8, 10)),
|
||||||
|
hour,
|
||||||
|
))
|
||||||
|
nextDate.setUTCHours(nextDate.getUTCHours() + 1)
|
||||||
|
const nextDateKey = formatDateKey(nextDate)
|
||||||
|
const nextHour = String(nextDate.getUTCHours()).padStart(2, '0')
|
||||||
|
|
||||||
|
return {
|
||||||
|
...ZERO_ANALYTICS_SUMMARY,
|
||||||
|
bucket_start: `${hourKey}:00:00`,
|
||||||
|
bucket_end: `${nextDateKey}T${nextHour}:00:00`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createZeroWeeklyBucket(dateKey: string): AnalyticsTimeseriesBucket {
|
||||||
|
return {
|
||||||
|
...ZERO_ANALYTICS_SUMMARY,
|
||||||
|
bucket_start: `${dateKey}T00:00:00`,
|
||||||
|
bucket_end: `${getNextWeekKey(dateKey)}T00:00:00`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createZeroMonthlyBucket(dateKey: string): AnalyticsTimeseriesBucket {
|
||||||
|
return {
|
||||||
|
...ZERO_ANALYTICS_SUMMARY,
|
||||||
|
bucket_start: `${dateKey}T00:00:00`,
|
||||||
|
bucket_end: `${getNextMonthKey(dateKey)}T00:00:00`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fillMissingDailyTimeseriesBuckets(
|
||||||
|
buckets: AnalyticsTimeseriesBucket[],
|
||||||
|
timeRange: AnalyticsTimeRange,
|
||||||
|
): AnalyticsTimeseriesBucket[] {
|
||||||
|
const sortedBuckets = sortBuckets(buckets)
|
||||||
|
const dateKeys = getDateKeysInRange(timeRange, sortedBuckets)
|
||||||
|
|
||||||
|
if (!dateKeys.length) {
|
||||||
|
return sortedBuckets
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketByDate = new Map(
|
||||||
|
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 10), bucket]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return dateKeys.map(dateKey => bucketByDate.get(dateKey) ?? createZeroDailyBucket(dateKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fillMissingWeeklyTimeseriesBuckets(
|
||||||
|
buckets: AnalyticsTimeseriesBucket[],
|
||||||
|
timeRange: AnalyticsTimeRange,
|
||||||
|
): AnalyticsTimeseriesBucket[] {
|
||||||
|
const sortedBuckets = sortBuckets(buckets)
|
||||||
|
const weekKeys = getWeekKeysInRange(timeRange, sortedBuckets)
|
||||||
|
|
||||||
|
if (!weekKeys.length) {
|
||||||
|
return sortedBuckets
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketByWeek = new Map(
|
||||||
|
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 10), bucket]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return weekKeys.map(weekKey => bucketByWeek.get(weekKey) ?? createZeroWeeklyBucket(weekKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fillMissingMonthlyTimeseriesBuckets(
|
||||||
|
buckets: AnalyticsTimeseriesBucket[],
|
||||||
|
timeRange: AnalyticsTimeRange,
|
||||||
|
): AnalyticsTimeseriesBucket[] {
|
||||||
|
const sortedBuckets = sortBuckets(buckets)
|
||||||
|
const monthKeys = getMonthKeysInRange(timeRange, sortedBuckets)
|
||||||
|
|
||||||
|
if (!monthKeys.length) {
|
||||||
|
return sortedBuckets
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketByMonth = new Map(
|
||||||
|
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 10), bucket]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return monthKeys.map(monthKey => bucketByMonth.get(monthKey) ?? createZeroMonthlyBucket(monthKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fillMissingTimeseriesBuckets(
|
||||||
|
buckets: AnalyticsTimeseriesBucket[],
|
||||||
|
timeRange: AnalyticsTimeRange,
|
||||||
|
): AnalyticsTimeseriesBucket[] {
|
||||||
|
const granularity = normalizeGranularity(timeRange.granularity)
|
||||||
|
const sortedBuckets = sortBuckets(buckets)
|
||||||
|
|
||||||
|
if (granularity === 'week') {
|
||||||
|
return fillMissingWeeklyTimeseriesBuckets(sortedBuckets, timeRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (granularity === 'month') {
|
||||||
|
return fillMissingMonthlyTimeseriesBuckets(sortedBuckets, timeRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (granularity !== 'hour') {
|
||||||
|
return fillMissingDailyTimeseriesBuckets(sortedBuckets, timeRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourKeys = getHourKeysInRange(timeRange, sortedBuckets)
|
||||||
|
|
||||||
|
if (!hourKeys.length) {
|
||||||
|
return sortedBuckets
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketByHour = new Map(
|
||||||
|
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 13), bucket]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return hourKeys.map(hourKey => bucketByHour.get(hourKey) ?? createZeroHourlyBucket(hourKey))
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
const COLOR_KEYS = new Set([
|
||||||
|
'backgroundColor',
|
||||||
|
'borderColor',
|
||||||
|
'color',
|
||||||
|
'pointBackgroundColor',
|
||||||
|
'pointBorderColor',
|
||||||
|
'pointHoverBackgroundColor',
|
||||||
|
'pointHoverBorderColor',
|
||||||
|
'hoverBackgroundColor',
|
||||||
|
'hoverBorderColor',
|
||||||
|
'tickColor',
|
||||||
|
])
|
||||||
|
|
||||||
|
const CSS_COLOR_PATTERN = /(var\(|oklch\(|hsl\(|hsla\(|rgb\(|rgba\()/i
|
||||||
|
const HSL_VAR_PATTERN = /^hsl\(var\((--[^)]+)\)(?:\s*\/\s*([^)]+))?\)$/i
|
||||||
|
|
||||||
|
let resolverElement: HTMLSpanElement | null = null
|
||||||
|
|
||||||
|
function getResolverElement(): HTMLSpanElement | null {
|
||||||
|
if (typeof document === 'undefined') return null
|
||||||
|
if (resolverElement && resolverElement.isConnected) return resolverElement
|
||||||
|
|
||||||
|
const element = document.createElement('span')
|
||||||
|
element.setAttribute('aria-hidden', 'true')
|
||||||
|
element.style.position = 'fixed'
|
||||||
|
element.style.pointerEvents = 'none'
|
||||||
|
element.style.opacity = '0'
|
||||||
|
element.style.visibility = 'hidden'
|
||||||
|
element.style.inset = '-9999px'
|
||||||
|
document.body.appendChild(element)
|
||||||
|
resolverElement = element
|
||||||
|
return resolverElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCssColor(value: string): string {
|
||||||
|
if (!CSS_COLOR_PATTERN.test(value)) return value
|
||||||
|
|
||||||
|
const hslVarMatch = value.trim().match(HSL_VAR_PATTERN)
|
||||||
|
if (hslVarMatch) {
|
||||||
|
const [, variableName, alphaValue] = hslVarMatch
|
||||||
|
const baseColor = resolveCssColor(`var(${variableName})`)
|
||||||
|
if (!alphaValue) return baseColor
|
||||||
|
return applyAlpha(baseColor, alphaValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = getResolverElement()
|
||||||
|
if (!element) return value
|
||||||
|
|
||||||
|
element.style.color = ''
|
||||||
|
element.style.color = value
|
||||||
|
if (!element.style.color) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const resolved = window.getComputedStyle(element).color.trim()
|
||||||
|
|
||||||
|
return resolved || value
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAlpha(color: string, alphaValue: string): string {
|
||||||
|
const alpha = parseAlpha(alphaValue)
|
||||||
|
if (alpha == null) return color
|
||||||
|
|
||||||
|
const rgbMatch = color.match(/^rgba?\(([^)]+)\)$/i)
|
||||||
|
if (!rgbMatch) return color
|
||||||
|
|
||||||
|
const parts = rgbMatch[1].split(',').map(part => part.trim())
|
||||||
|
if (parts.length < 3) return color
|
||||||
|
|
||||||
|
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAlpha(value: string): number | null {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed.endsWith('%')) {
|
||||||
|
const percentage = Number.parseFloat(trimmed.slice(0, -1))
|
||||||
|
return Number.isFinite(percentage) ? percentage / 100 : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number.parseFloat(trimmed)
|
||||||
|
return Number.isFinite(numeric) ? numeric : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isColorKey(key: string | undefined): boolean {
|
||||||
|
if (!key) return false
|
||||||
|
return COLOR_KEYS.has(key) || /color$/i.test(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveValue(value: unknown, key?: string): unknown {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return isColorKey(key) ? resolveCssColor(value) : value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(item => resolveValue(item, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isObject(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: Record<string, unknown> = {}
|
||||||
|
for (const [childKey, childValue] of Object.entries(value)) {
|
||||||
|
output[childKey] = resolveValue(childValue, childKey)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChartTheme<T>(value: T): T {
|
||||||
|
return resolveValue(value) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observeChartThemeChanges(callback: () => void): () => void {
|
||||||
|
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => callback())
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'style'],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (document.body) {
|
||||||
|
observer.observe(document.body, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'style', 'theme-mode'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* 客户端 CSV 导出工具
|
||||||
|
* 将数据转为 CSV 并触发浏览器下载
|
||||||
|
*/
|
||||||
|
|
||||||
|
function escapeCsvField(value: string | number | null | undefined): string {
|
||||||
|
if (value == null) return ''
|
||||||
|
const str = String(value)
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 CSV 内容并触发下载
|
||||||
|
* @param filename 文件名(不含扩展名)
|
||||||
|
* @param headers 列标题
|
||||||
|
* @param rows 数据行
|
||||||
|
*/
|
||||||
|
export function downloadCsv(
|
||||||
|
filename: string,
|
||||||
|
headers: string[],
|
||||||
|
rows: (string | number | null | undefined)[][],
|
||||||
|
): void {
|
||||||
|
const bom = '\uFEFF' // UTF-8 BOM,确保 Excel 正确识别中文
|
||||||
|
const headerLine = headers.map(escapeCsvField).join(',')
|
||||||
|
const dataLines = rows.map(row => row.map(escapeCsvField).join(','))
|
||||||
|
const csv = bom + [headerLine, ...dataLines].join('\n')
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${filename}.csv`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从对象数组生成 CSV 下载
|
||||||
|
* @param filename 文件名
|
||||||
|
* @param data 对象数组
|
||||||
|
* @param columns 列定义 [{ key, label }]
|
||||||
|
*/
|
||||||
|
export function downloadCsvFromObjects<T extends Record<string, unknown>>(
|
||||||
|
filename: string,
|
||||||
|
data: T[],
|
||||||
|
columns: { key: string; label: string }[],
|
||||||
|
): void {
|
||||||
|
const headers = columns.map(c => c.label)
|
||||||
|
const rows = data.map(item =>
|
||||||
|
columns.map(c => item[c.key] as string | number | null),
|
||||||
|
)
|
||||||
|
downloadCsv(filename, headers, rows)
|
||||||
|
}
|
||||||
@@ -1,17 +1,34 @@
|
|||||||
|
function toFiniteNumber(value: number | string | undefined | null): number | null {
|
||||||
|
if (value === undefined || value === null) return null
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value
|
||||||
|
.trim()
|
||||||
|
.replace(/[$,\s]/g, '')
|
||||||
|
|
||||||
|
if (!normalized) return null
|
||||||
|
|
||||||
|
const parsed = Number(normalized)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
// Token formatting - intelligent display based on value size
|
// Token formatting - intelligent display based on value size
|
||||||
export function formatTokens(num: number | undefined | null): string {
|
export function formatTokens(num: number | string | undefined | null): string {
|
||||||
if (num === undefined || num === null || num === 0) {
|
const normalized = toFiniteNumber(num)
|
||||||
|
if (normalized === null || normalized === 0) {
|
||||||
return '0'
|
return '0'
|
||||||
}
|
}
|
||||||
|
|
||||||
// For very small values (< 1000), show as is without unit
|
// For very small values (< 1000), show as is without unit
|
||||||
if (num < 1000) {
|
if (normalized < 1000) {
|
||||||
return num.toString()
|
return normalized.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// For values 1K-999K, show in thousands
|
// For values 1K-999K, show in thousands
|
||||||
if (num < 1000000) {
|
if (normalized < 1000000) {
|
||||||
const thousands = num / 1000
|
const thousands = normalized / 1000
|
||||||
if (thousands >= 100) {
|
if (thousands >= 100) {
|
||||||
return `${Math.round(thousands) }K`
|
return `${Math.round(thousands) }K`
|
||||||
} else if (thousands >= 10) {
|
} else if (thousands >= 10) {
|
||||||
@@ -22,7 +39,7 @@ export function formatTokens(num: number | undefined | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For values >= 1M, show in millions
|
// For values >= 1M, show in millions
|
||||||
const millions = num / 1000000
|
const millions = normalized / 1000000
|
||||||
if (millions >= 100) {
|
if (millions >= 100) {
|
||||||
return `${Math.round(millions) }M`
|
return `${Math.round(millions) }M`
|
||||||
} else if (millions >= 10) {
|
} else if (millions >= 10) {
|
||||||
@@ -33,53 +50,54 @@ export function formatTokens(num: number | undefined | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Currency formatting with high precision for small values
|
// Currency formatting with high precision for small values
|
||||||
export function formatCurrency(amount: number | undefined | null): string {
|
export function formatCurrency(amount: number | string | undefined | null): string {
|
||||||
if (amount === undefined || amount === null || amount === 0) {
|
const normalized = toFiniteNumber(amount)
|
||||||
|
if (normalized === null || normalized === 0) {
|
||||||
return '$0.00'
|
return '$0.00'
|
||||||
}
|
}
|
||||||
|
|
||||||
// For very small amounts (< $0.00001), show up to 8 decimal places
|
// For very small amounts (< $0.00001), show up to 8 decimal places
|
||||||
if (amount > 0 && amount < 0.00001) {
|
if (normalized > 0 && normalized < 0.00001) {
|
||||||
const formatted = amount.toFixed(8)
|
const formatted = normalized.toFixed(8)
|
||||||
// Remove trailing zeros but keep at least 2 decimal places
|
// Remove trailing zeros but keep at least 2 decimal places
|
||||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||||
return `$${ trimmed}`
|
return `$${ trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// For small amounts (< $0.0001), show up to 6 decimal places
|
// For small amounts (< $0.0001), show up to 6 decimal places
|
||||||
if (amount < 0.0001) {
|
if (normalized < 0.0001) {
|
||||||
const formatted = amount.toFixed(6)
|
const formatted = normalized.toFixed(6)
|
||||||
// Remove trailing zeros but keep at least 2 decimal places
|
// Remove trailing zeros but keep at least 2 decimal places
|
||||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||||
return `$${ trimmed}`
|
return `$${ trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// For small amounts (< $0.01), show up to 5 decimal places
|
// For small amounts (< $0.01), show up to 5 decimal places
|
||||||
if (amount < 0.01) {
|
if (normalized < 0.01) {
|
||||||
const formatted = amount.toFixed(5)
|
const formatted = normalized.toFixed(5)
|
||||||
// Remove trailing zeros but keep at least 2 decimal places
|
// Remove trailing zeros but keep at least 2 decimal places
|
||||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||||
return `$${ trimmed}`
|
return `$${ trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// For amounts less than $1, show 4 decimal places
|
// For amounts less than $1, show 4 decimal places
|
||||||
if (amount < 1) {
|
if (normalized < 1) {
|
||||||
const formatted = amount.toFixed(4)
|
const formatted = normalized.toFixed(4)
|
||||||
// Remove trailing zeros but keep at least 2 decimal places
|
// Remove trailing zeros but keep at least 2 decimal places
|
||||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||||
return `$${ trimmed}`
|
return `$${ trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// For amounts $1-$100, show 2-3 decimal places
|
// For amounts $1-$100, show 2-3 decimal places
|
||||||
if (amount < 100) {
|
if (normalized < 100) {
|
||||||
const formatted = amount.toFixed(3)
|
const formatted = normalized.toFixed(3)
|
||||||
// Remove trailing zeros but keep at least 2 decimal places
|
// Remove trailing zeros but keep at least 2 decimal places
|
||||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||||
return `$${ trimmed}`
|
return `$${ trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// For larger amounts, show 2 decimal places
|
// For larger amounts, show 2 decimal places
|
||||||
return `$${ amount.toFixed(2)}`
|
return `$${ normalized.toFixed(2)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Number formatting with locale support
|
// Number formatting with locale support
|
||||||
@@ -157,7 +175,8 @@ export function formatRemainingTime(expireAt: number | undefined, currentTime: n
|
|||||||
// Cache hit rate formatting
|
// Cache hit rate formatting
|
||||||
export function formatHitRate(rate: number | undefined): string {
|
export function formatHitRate(rate: number | undefined): string {
|
||||||
if (typeof rate !== 'number' || Number.isNaN(rate)) return '-'
|
if (typeof rate !== 'number' || Number.isNaN(rate)) return '-'
|
||||||
return `${rate.toFixed(2)}%`
|
const normalized = rate <= 1 ? rate * 100 : rate
|
||||||
|
return `${normalized.toFixed(2)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit formatting (supports "inherit" semantics: null = inherit system default)
|
// Rate limit formatting (supports "inherit" semantics: null = inherit system default)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface DailyUsageBreakdown {
|
||||||
|
date: string
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
cacheCreationTokens: number
|
||||||
|
cacheReadTokens: number
|
||||||
|
cacheCreationCost: number
|
||||||
|
cacheReadCost: number
|
||||||
|
cacheHitRate: number | null
|
||||||
|
totalCacheCost: number
|
||||||
|
totalCacheTokens: number
|
||||||
|
totalTrackedTokens: number
|
||||||
|
baseTokens: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="text-lg font-semibold">
|
||||||
|
数据分析
|
||||||
|
</h1>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
用量报表、模型报表与全局性能洞察
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<Tabs v-model="filters.activeTab.value">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<TabsList class="tabs-button-list">
|
||||||
|
<TabsTrigger value="detail">
|
||||||
|
用量报表
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="models">
|
||||||
|
模型报表
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="leaderboard">
|
||||||
|
排行榜
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="performance">
|
||||||
|
性能
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||||
|
<TimeRangePicker
|
||||||
|
v-model="filters.timeRange.value"
|
||||||
|
:show-granularity="false"
|
||||||
|
:include-auto-granularity="true"
|
||||||
|
:compact="true"
|
||||||
|
/>
|
||||||
|
<template v-if="filters.activeTab.value === 'leaderboard'">
|
||||||
|
<Select v-model="leaderboardDimension">
|
||||||
|
<SelectTrigger class="h-8 w-full text-xs sm:w-32">
|
||||||
|
<SelectValue placeholder="维度" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="users">
|
||||||
|
用户
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="api_keys">
|
||||||
|
API Key
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="showEntityFilters">
|
||||||
|
<Select v-model="selectedUserValue">
|
||||||
|
<SelectTrigger class="h-8 w-full text-xs sm:w-36">
|
||||||
|
<SelectValue placeholder="全部用户">
|
||||||
|
<span
|
||||||
|
class="block min-w-0 truncate"
|
||||||
|
:title="selectedUserLabel"
|
||||||
|
>
|
||||||
|
{{ selectedUserLabel }}
|
||||||
|
</span>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="w-[min(18rem,var(--radix-select-trigger-width))]">
|
||||||
|
<SelectItem value="__all__">
|
||||||
|
全部用户
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="option in userOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
:text-value="option.label"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select v-model="selectedApiKeyValue">
|
||||||
|
<SelectTrigger class="h-8 w-full text-xs sm:w-36">
|
||||||
|
<SelectValue placeholder="全部 Key">
|
||||||
|
<span
|
||||||
|
class="block min-w-0 truncate"
|
||||||
|
:title="selectedApiKeyLabel"
|
||||||
|
>
|
||||||
|
{{ selectedApiKeyLabel }}
|
||||||
|
</span>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="w-[min(18rem,var(--radix-select-trigger-width))]">
|
||||||
|
<SelectItem value="__all__">
|
||||||
|
全部 Key
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="option in apiKeyOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
:text-value="option.label"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<ReportsTab
|
||||||
|
v-if="isReportsTab"
|
||||||
|
:mode="reportMode"
|
||||||
|
:selected-user-label="selectedUserExportLabel"
|
||||||
|
:selected-api-key-label="selectedApiKeyExportLabel"
|
||||||
|
/>
|
||||||
|
<LeaderboardTab
|
||||||
|
v-else-if="filters.activeTab.value === 'leaderboard'"
|
||||||
|
:dimension="leaderboardDimension"
|
||||||
|
/>
|
||||||
|
<PerformanceTab v-else-if="filters.activeTab.value === 'performance'" />
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, provide, ref, watch, onMounted } from 'vue'
|
||||||
|
import { Tabs, TabsList, TabsTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||||
|
import { TimeRangePicker } from '@/components/common'
|
||||||
|
import { analyticsApi, type AnalyticsFilterOption } from '@/api/analytics'
|
||||||
|
import { useAnalyticsFilters } from '@/composables/useAnalyticsFilters'
|
||||||
|
import type { LeaderboardDimension } from '@/composables/analytics/useLeaderboardData'
|
||||||
|
import ReportsTab from './components/analytics/ReportsTab.vue'
|
||||||
|
import LeaderboardTab from './components/analytics/LeaderboardTab.vue'
|
||||||
|
import PerformanceTab from './components/analytics/PerformanceTab.vue'
|
||||||
|
|
||||||
|
const filters = useAnalyticsFilters()
|
||||||
|
provide('analyticsFilters', filters)
|
||||||
|
|
||||||
|
const userOptions = ref<AnalyticsFilterOption[]>([])
|
||||||
|
const apiKeyOptions = ref<AnalyticsFilterOption[]>([])
|
||||||
|
const leaderboardDimension = ref<LeaderboardDimension>('users')
|
||||||
|
const supportedTabs = new Set(['detail', 'models', 'leaderboard', 'performance'])
|
||||||
|
const isReportsTab = computed(() => filters.activeTab.value === 'detail' || filters.activeTab.value === 'models')
|
||||||
|
const showEntityFilters = computed(() => isReportsTab.value)
|
||||||
|
const reportMode = computed<'detail' | 'models'>(() => (
|
||||||
|
filters.activeTab.value === 'models' ? 'models' : 'detail'
|
||||||
|
))
|
||||||
|
let suppressNextUserFilterLoad = false
|
||||||
|
let suppressNextApiKeyFilterLoad = false
|
||||||
|
const selectedUserValue = computed({
|
||||||
|
get: () => filters.userFilter.value[0] ?? '__all__',
|
||||||
|
set: (value: string) => {
|
||||||
|
filters.userFilter.value = value && value !== '__all__' ? [value] : []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const selectedUserLabel = computed(() => (
|
||||||
|
selectedUserValue.value === '__all__'
|
||||||
|
? '全部用户'
|
||||||
|
: userOptions.value.find(option => option.value === selectedUserValue.value)?.label || '全部用户'
|
||||||
|
))
|
||||||
|
const selectedApiKeyValue = computed({
|
||||||
|
get: () => filters.apiKeyFilter.value[0] ?? '__all__',
|
||||||
|
set: (value: string) => {
|
||||||
|
filters.apiKeyFilter.value = value && value !== '__all__' ? [value] : []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const selectedApiKeyLabel = computed(() => (
|
||||||
|
selectedApiKeyValue.value === '__all__'
|
||||||
|
? '全部 Key'
|
||||||
|
: apiKeyOptions.value.find(option => option.value === selectedApiKeyValue.value)?.label || '全部 Key'
|
||||||
|
))
|
||||||
|
const selectedUserExportLabel = computed(() => (
|
||||||
|
selectedUserValue.value === '__all__' ? '' : selectedUserLabel.value
|
||||||
|
))
|
||||||
|
const selectedApiKeyExportLabel = computed(() => (
|
||||||
|
selectedApiKeyValue.value === '__all__' ? '' : selectedApiKeyLabel.value
|
||||||
|
))
|
||||||
|
|
||||||
|
function hasOption(options: AnalyticsFilterOption[], value: string): boolean {
|
||||||
|
return options.some(option => option.value === value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filters.activeTab.value,
|
||||||
|
(tab) => {
|
||||||
|
if (!supportedTabs.has(tab)) {
|
||||||
|
filters.activeTab.value = 'detail'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadFilterOptions() {
|
||||||
|
if (!showEntityFilters.value) return
|
||||||
|
const response = await analyticsApi.getFilterOptions({
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
time_range: filters.getTimeRangeParams(),
|
||||||
|
filters: {
|
||||||
|
user_ids: filters.userFilter.value,
|
||||||
|
api_key_ids: filters.apiKeyFilter.value,
|
||||||
|
},
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
if (!response) return
|
||||||
|
userOptions.value = response.users ?? []
|
||||||
|
apiKeyOptions.value = response.api_keys ?? []
|
||||||
|
|
||||||
|
if (filters.userFilter.value.length > 0 && !hasOption(userOptions.value, filters.userFilter.value[0])) {
|
||||||
|
suppressNextUserFilterLoad = true
|
||||||
|
filters.userFilter.value = []
|
||||||
|
}
|
||||||
|
if (filters.apiKeyFilter.value.length > 0 && !hasOption(apiKeyOptions.value, filters.apiKeyFilter.value[0])) {
|
||||||
|
suppressNextApiKeyFilterLoad = true
|
||||||
|
filters.apiKeyFilter.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
showEntityFilters.value,
|
||||||
|
filters.getTimeRangeParams(),
|
||||||
|
filters.userFilter.value.slice(),
|
||||||
|
filters.apiKeyFilter.value.slice(),
|
||||||
|
],
|
||||||
|
([shouldShow]) => {
|
||||||
|
if (!shouldShow) return
|
||||||
|
if (suppressNextUserFilterLoad) {
|
||||||
|
suppressNextUserFilterLoad = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (suppressNextApiKeyFilterLoad) {
|
||||||
|
suppressNextApiKeyFilterLoad = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadFilterOptions()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (showEntityFilters.value) {
|
||||||
|
void loadFilterOptions()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-lg font-semibold">
|
|
||||||
成本分析
|
|
||||||
</h1>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
成本趋势、预测与节省统计
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<TimeRangePicker v-model="timeRange" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
||||||
<Card class="p-4 space-y-2">
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
缓存节省
|
|
||||||
</div>
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{ formatCurrency(costSavings?.cache_savings ?? 0) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
读取成本 {{ formatCurrency(costSavings?.cache_read_cost ?? 0) }}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card class="p-4 space-y-2">
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
缓存读取 Tokens
|
|
||||||
</div>
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{ formatTokens(costSavings?.cache_read_tokens ?? 0) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
预计全额成本 {{ formatCurrency(costSavings?.estimated_full_cost ?? 0) }}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card class="p-4 space-y-2">
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
缓存创建成本
|
|
||||||
</div>
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{ formatCurrency(costSavings?.cache_creation_cost ?? 0) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
基于当前时间范围
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<Card class="p-4">
|
|
||||||
<CostForecastChart
|
|
||||||
title="成本趋势预测"
|
|
||||||
:history="forecastHistory"
|
|
||||||
:forecast="forecastFuture"
|
|
||||||
:loading="forecastLoading"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
<QuotaProgressCard
|
|
||||||
title="月卡消耗进度"
|
|
||||||
:providers="quotaProviders"
|
|
||||||
:loading="quotaLoading"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UsageProviderTable
|
|
||||||
:data="providerStats"
|
|
||||||
:is-admin="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
||||||
import Card from '@/components/ui/card.vue'
|
|
||||||
import { TimeRangePicker } from '@/components/common'
|
|
||||||
import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
|
|
||||||
import { UsageProviderTable } from '@/features/usage/components'
|
|
||||||
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type QuotaUsageProvider } from '@/api/admin'
|
|
||||||
import { usageApi } from '@/api/usage'
|
|
||||||
import { formatCurrency, formatTokens } from '@/utils/format'
|
|
||||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
|
||||||
import type { DateRangeParams } from '@/features/usage/types'
|
|
||||||
import type { ProviderStatsItem } from '@/features/usage/types'
|
|
||||||
|
|
||||||
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
|
|
||||||
|
|
||||||
const forecast = ref<CostForecastResponse | null>(null)
|
|
||||||
const costSavings = ref<CostSavingsResponse | null>(null)
|
|
||||||
const quotaProviders = ref<QuotaUsageProvider[]>([])
|
|
||||||
const providerStats = ref<ProviderStatsItem[]>([])
|
|
||||||
|
|
||||||
const forecastLoading = ref(false)
|
|
||||||
const quotaLoading = ref(false)
|
|
||||||
let forecastRequestId = 0
|
|
||||||
let savingsRequestId = 0
|
|
||||||
let quotaRequestId = 0
|
|
||||||
let providerStatsRequestId = 0
|
|
||||||
let loadAllPromise: Promise<void> | null = null
|
|
||||||
let hasPendingLoadAll = false
|
|
||||||
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
const forecastHistory = computed(() => forecast.value?.history || [])
|
|
||||||
const forecastFuture = computed(() => forecast.value?.forecast || [])
|
|
||||||
|
|
||||||
function buildTimeRangeParams() {
|
|
||||||
return {
|
|
||||||
start_date: timeRange.value.start_date,
|
|
||||||
end_date: timeRange.value.end_date,
|
|
||||||
preset: timeRange.value.preset,
|
|
||||||
timezone: timeRange.value.timezone,
|
|
||||||
tz_offset_minutes: timeRange.value.tz_offset_minutes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadForecast() {
|
|
||||||
const requestId = ++forecastRequestId
|
|
||||||
forecastLoading.value = true
|
|
||||||
try {
|
|
||||||
const data = await adminApi.getCostForecast(buildTimeRangeParams())
|
|
||||||
if (requestId !== forecastRequestId) return
|
|
||||||
forecast.value = data
|
|
||||||
} finally {
|
|
||||||
if (requestId === forecastRequestId) {
|
|
||||||
forecastLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSavings() {
|
|
||||||
const requestId = ++savingsRequestId
|
|
||||||
const data = await adminApi.getCostSavings(buildTimeRangeParams())
|
|
||||||
if (requestId !== savingsRequestId) return
|
|
||||||
costSavings.value = data
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadQuotaUsage() {
|
|
||||||
const requestId = ++quotaRequestId
|
|
||||||
quotaLoading.value = true
|
|
||||||
try {
|
|
||||||
const response = await adminApi.getQuotaUsage()
|
|
||||||
if (requestId !== quotaRequestId) return
|
|
||||||
quotaProviders.value = response.providers
|
|
||||||
} finally {
|
|
||||||
if (requestId === quotaRequestId) {
|
|
||||||
quotaLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProviderStats() {
|
|
||||||
const requestId = ++providerStatsRequestId
|
|
||||||
const stats = await usageApi.getUsageByProvider({
|
|
||||||
...buildTimeRangeParams(),
|
|
||||||
limit: 8
|
|
||||||
})
|
|
||||||
if (requestId !== providerStatsRequestId) return
|
|
||||||
providerStats.value = stats
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAll() {
|
|
||||||
if (loadAllPromise) {
|
|
||||||
hasPendingLoadAll = true
|
|
||||||
return loadAllPromise
|
|
||||||
}
|
|
||||||
loadAllPromise = Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
|
|
||||||
.then(() => undefined)
|
|
||||||
.finally(() => {
|
|
||||||
loadAllPromise = null
|
|
||||||
if (hasPendingLoadAll) {
|
|
||||||
hasPendingLoadAll = false
|
|
||||||
void loadAll()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return loadAllPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleLoadAll() {
|
|
||||||
if (loadAllDebounceTimer) {
|
|
||||||
clearTimeout(loadAllDebounceTimer)
|
|
||||||
}
|
|
||||||
loadAllDebounceTimer = setTimeout(() => {
|
|
||||||
loadAllDebounceTimer = null
|
|
||||||
void loadAll()
|
|
||||||
}, 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(timeRange, scheduleLoadAll, { deep: true })
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadAll()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (loadAllDebounceTimer) {
|
|
||||||
clearTimeout(loadAllDebounceTimer)
|
|
||||||
loadAllDebounceTimer = null
|
|
||||||
}
|
|
||||||
hasPendingLoadAll = false
|
|
||||||
loadAllPromise = null
|
|
||||||
forecastRequestId += 1
|
|
||||||
savingsRequestId += 1
|
|
||||||
quotaRequestId += 1
|
|
||||||
providerStatsRequestId += 1
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-lg font-semibold">
|
|
||||||
性能分析
|
|
||||||
</h1>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
延迟分布与错误统计
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<TimeRangePicker v-model="timeRange" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<Card class="p-4">
|
|
||||||
<PercentileChart
|
|
||||||
title="响应延迟百分位"
|
|
||||||
:series="percentiles"
|
|
||||||
mode="response"
|
|
||||||
:loading="percentileLoading"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
<Card class="p-4">
|
|
||||||
<PercentileChart
|
|
||||||
title="首字节延迟百分位"
|
|
||||||
:series="percentiles"
|
|
||||||
mode="ttfb"
|
|
||||||
:loading="percentileLoading"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<Card class="p-4">
|
|
||||||
<ErrorDistributionChart
|
|
||||||
title="错误分布"
|
|
||||||
:distribution="errorDistribution"
|
|
||||||
:loading="errorLoading"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
<Card class="p-4 space-y-3">
|
|
||||||
<h3 class="text-sm font-semibold">
|
|
||||||
错误趋势
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
v-if="errorLoading"
|
|
||||||
class="p-6"
|
|
||||||
>
|
|
||||||
<LoadingState />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="h-[260px]"
|
|
||||||
>
|
|
||||||
<LineChart :data="errorTrendChartData" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card class="p-4 space-y-3">
|
|
||||||
<h3 class="text-sm font-semibold">
|
|
||||||
提供商健康度
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
v-if="providerLoading"
|
|
||||||
class="p-4"
|
|
||||||
>
|
|
||||||
<LoadingState />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-sm"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="provider in providerStatus"
|
|
||||||
:key="provider.name"
|
|
||||||
class="p-3 border rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-medium">{{ provider.name }}</span>
|
|
||||||
<span class="text-xs text-muted-foreground">{{ provider.requests }} 请求</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-muted-foreground mt-1">
|
|
||||||
状态: {{ provider.status }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
||||||
import Card from '@/components/ui/card.vue'
|
|
||||||
import { LoadingState, TimeRangePicker } from '@/components/common'
|
|
||||||
import { ErrorDistributionChart, PercentileChart } from '@/components/stats'
|
|
||||||
import LineChart from '@/components/charts/LineChart.vue'
|
|
||||||
import { adminApi, type ErrorDistributionResponse, type PercentileItem } from '@/api/admin'
|
|
||||||
import { dashboardApi, type ProviderStatus } from '@/api/dashboard'
|
|
||||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
|
||||||
import type { DateRangeParams } from '@/features/usage/types'
|
|
||||||
|
|
||||||
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
|
|
||||||
|
|
||||||
const percentiles = ref<PercentileItem[]>([])
|
|
||||||
const percentileLoading = ref(false)
|
|
||||||
|
|
||||||
const errorDistribution = ref<ErrorDistributionResponse['distribution']>([])
|
|
||||||
const errorTrend = ref<ErrorDistributionResponse['trend']>([])
|
|
||||||
const errorLoading = ref(false)
|
|
||||||
|
|
||||||
const providerStatus = ref<ProviderStatus[]>([])
|
|
||||||
const providerLoading = ref(false)
|
|
||||||
let percentilesRequestId = 0
|
|
||||||
let errorsRequestId = 0
|
|
||||||
let providersRequestId = 0
|
|
||||||
let loadAllPromise: Promise<void> | null = null
|
|
||||||
let hasPendingLoadAll = false
|
|
||||||
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
function buildTimeRangeParams() {
|
|
||||||
return {
|
|
||||||
start_date: timeRange.value.start_date,
|
|
||||||
end_date: timeRange.value.end_date,
|
|
||||||
preset: timeRange.value.preset,
|
|
||||||
timezone: timeRange.value.timezone,
|
|
||||||
tz_offset_minutes: timeRange.value.tz_offset_minutes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPercentiles() {
|
|
||||||
const requestId = ++percentilesRequestId
|
|
||||||
percentileLoading.value = true
|
|
||||||
try {
|
|
||||||
const data = await adminApi.getPercentiles(buildTimeRangeParams())
|
|
||||||
if (requestId !== percentilesRequestId) return
|
|
||||||
percentiles.value = data
|
|
||||||
} finally {
|
|
||||||
if (requestId === percentilesRequestId) {
|
|
||||||
percentileLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadErrors() {
|
|
||||||
const requestId = ++errorsRequestId
|
|
||||||
errorLoading.value = true
|
|
||||||
try {
|
|
||||||
const response = await adminApi.getErrorDistribution(buildTimeRangeParams())
|
|
||||||
if (requestId !== errorsRequestId) return
|
|
||||||
errorDistribution.value = response.distribution
|
|
||||||
errorTrend.value = response.trend
|
|
||||||
} finally {
|
|
||||||
if (requestId === errorsRequestId) {
|
|
||||||
errorLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProviders() {
|
|
||||||
const requestId = ++providersRequestId
|
|
||||||
providerLoading.value = true
|
|
||||||
try {
|
|
||||||
const data = await dashboardApi.getProviderStatus()
|
|
||||||
if (requestId !== providersRequestId) return
|
|
||||||
providerStatus.value = data
|
|
||||||
} finally {
|
|
||||||
if (requestId === providersRequestId) {
|
|
||||||
providerLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorTrendChartData = computed(() => ({
|
|
||||||
labels: errorTrend.value.map(item => item.date),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: '错误数',
|
|
||||||
data: errorTrend.value.map(item => item.total),
|
|
||||||
borderColor: 'rgb(239, 68, 68)',
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
|
|
||||||
async function loadAll() {
|
|
||||||
if (loadAllPromise) {
|
|
||||||
hasPendingLoadAll = true
|
|
||||||
return loadAllPromise
|
|
||||||
}
|
|
||||||
loadAllPromise = Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
|
|
||||||
.then(() => undefined)
|
|
||||||
.finally(() => {
|
|
||||||
loadAllPromise = null
|
|
||||||
if (hasPendingLoadAll) {
|
|
||||||
hasPendingLoadAll = false
|
|
||||||
void loadAll()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return loadAllPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleLoadAll() {
|
|
||||||
if (loadAllDebounceTimer) {
|
|
||||||
clearTimeout(loadAllDebounceTimer)
|
|
||||||
}
|
|
||||||
loadAllDebounceTimer = setTimeout(() => {
|
|
||||||
loadAllDebounceTimer = null
|
|
||||||
void loadAll()
|
|
||||||
}, 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(timeRange, scheduleLoadAll, { deep: true })
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadAll()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (loadAllDebounceTimer) {
|
|
||||||
clearTimeout(loadAllDebounceTimer)
|
|
||||||
loadAllDebounceTimer = null
|
|
||||||
}
|
|
||||||
hasPendingLoadAll = false
|
|
||||||
loadAllPromise = null
|
|
||||||
percentilesRequestId += 1
|
|
||||||
errorsRequestId += 1
|
|
||||||
providersRequestId += 1
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -73,6 +73,17 @@
|
|||||||
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
|
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MonitoringCapacitySection
|
||||||
|
id="section-monitoring-capacity"
|
||||||
|
:redis-memory-total-g-b="redisMemoryTotalGB"
|
||||||
|
:postgres-storage-total-g-b="postgresStorageTotalGB"
|
||||||
|
:loading="monitoringConfigLoading"
|
||||||
|
:has-changes="hasMonitoringConfigChanges"
|
||||||
|
@save="saveMonitoringConfig"
|
||||||
|
@update:redis-memory-total-g-b="redisMemoryTotalGB = $event"
|
||||||
|
@update:postgres-storage-total-g-b="postgresStorageTotalGB = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 请求记录配置 -->
|
<!-- 请求记录配置 -->
|
||||||
<RequestLogSection
|
<RequestLogSection
|
||||||
id="section-request-log"
|
id="section-request-log"
|
||||||
@@ -211,6 +222,7 @@ import UserDataSection from './system-settings/UserDataSection.vue'
|
|||||||
import DataManagementSection from './system-settings/DataManagementSection.vue'
|
import DataManagementSection from './system-settings/DataManagementSection.vue'
|
||||||
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
|
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
|
||||||
import BasicConfigSection from './system-settings/BasicConfigSection.vue'
|
import BasicConfigSection from './system-settings/BasicConfigSection.vue'
|
||||||
|
import MonitoringCapacitySection from './system-settings/MonitoringCapacitySection.vue'
|
||||||
import RequestLogSection from './system-settings/RequestLogSection.vue'
|
import RequestLogSection from './system-settings/RequestLogSection.vue'
|
||||||
import CleanupPolicySection from './system-settings/CleanupPolicySection.vue'
|
import CleanupPolicySection from './system-settings/CleanupPolicySection.vue'
|
||||||
import ScheduledTasksSection from './system-settings/ScheduledTasksSection.vue'
|
import ScheduledTasksSection from './system-settings/ScheduledTasksSection.vue'
|
||||||
@@ -230,6 +242,7 @@ const tocItems = [
|
|||||||
{ id: 'section-data-mgmt', label: '数据管理' },
|
{ id: 'section-data-mgmt', label: '数据管理' },
|
||||||
{ id: 'section-proxy', label: '网络代理' },
|
{ id: 'section-proxy', label: '网络代理' },
|
||||||
{ id: 'section-basic', label: '基础配置' },
|
{ id: 'section-basic', label: '基础配置' },
|
||||||
|
{ id: 'section-monitoring-capacity', label: '监控容量' },
|
||||||
{ id: 'section-request-log', label: '请求记录' },
|
{ id: 'section-request-log', label: '请求记录' },
|
||||||
{ id: 'section-cleanup', label: '记录清理策略' },
|
{ id: 'section-cleanup', label: '记录清理策略' },
|
||||||
{ id: 'section-scheduled', label: '定时任务' },
|
{ id: 'section-scheduled', label: '定时任务' },
|
||||||
@@ -290,21 +303,26 @@ const {
|
|||||||
siteInfoLoading,
|
siteInfoLoading,
|
||||||
proxyConfigLoading,
|
proxyConfigLoading,
|
||||||
basicConfigLoading,
|
basicConfigLoading,
|
||||||
|
monitoringConfigLoading,
|
||||||
logConfigLoading,
|
logConfigLoading,
|
||||||
cleanupConfigLoading,
|
cleanupConfigLoading,
|
||||||
hasSiteInfoChanges,
|
hasSiteInfoChanges,
|
||||||
hasProxyConfigChanges,
|
hasProxyConfigChanges,
|
||||||
hasBasicConfigChanges,
|
hasBasicConfigChanges,
|
||||||
|
hasMonitoringConfigChanges,
|
||||||
hasLogConfigChanges,
|
hasLogConfigChanges,
|
||||||
hasCleanupConfigChanges,
|
hasCleanupConfigChanges,
|
||||||
maxRequestBodySizeKB,
|
maxRequestBodySizeKB,
|
||||||
maxResponseBodySizeKB,
|
maxResponseBodySizeKB,
|
||||||
|
redisMemoryTotalGB,
|
||||||
|
postgresStorageTotalGB,
|
||||||
sensitiveHeadersStr,
|
sensitiveHeadersStr,
|
||||||
loadSystemConfig,
|
loadSystemConfig,
|
||||||
loadSystemVersion,
|
loadSystemVersion,
|
||||||
saveSiteInfo,
|
saveSiteInfo,
|
||||||
saveProxyConfig,
|
saveProxyConfig,
|
||||||
saveBasicConfig,
|
saveBasicConfig,
|
||||||
|
saveMonitoringConfig,
|
||||||
saveLogConfig,
|
saveLogConfig,
|
||||||
saveCleanupConfig,
|
saveCleanupConfig,
|
||||||
handleAutoCleanupToggle,
|
handleAutoCleanupToggle,
|
||||||
|
|||||||
@@ -1,394 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-lg font-semibold">
|
|
||||||
用户统计
|
|
||||||
</h1>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
查看用户排行榜与使用趋势
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<TimeRangePicker
|
|
||||||
v-model="timeRange"
|
|
||||||
:allow-hourly="true"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
v-model="selectedUserId"
|
|
||||||
>
|
|
||||||
<SelectTrigger class="h-8 text-xs w-52">
|
|
||||||
<SelectValue placeholder="选择用户" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
:value="user.id"
|
|
||||||
>
|
|
||||||
{{ user.username || user.email }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
v-model="compareUserId"
|
|
||||||
>
|
|
||||||
<SelectTrigger class="h-8 text-xs w-52">
|
|
||||||
<SelectValue placeholder="对比用户(可选)" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__">
|
|
||||||
不对比
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
v-for="user in users"
|
|
||||||
:key="`compare-${user.id}`"
|
|
||||||
:value="user.id"
|
|
||||||
>
|
|
||||||
{{ user.username || user.email }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<LeaderboardTable
|
|
||||||
title="用户排行榜"
|
|
||||||
:items="leaderboard"
|
|
||||||
:metric="metric"
|
|
||||||
:loading="leaderboardLoading"
|
|
||||||
@update:metric="metric = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card class="p-4 space-y-3">
|
|
||||||
<h3 class="text-sm font-semibold">
|
|
||||||
用户摘要
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
v-if="summaryLoading"
|
|
||||||
class="p-6"
|
|
||||||
>
|
|
||||||
<LoadingState />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="grid grid-cols-2 gap-3 text-sm"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
请求数
|
|
||||||
</div>
|
|
||||||
<div class="font-semibold">
|
|
||||||
{{ userSummary?.total_requests ?? 0 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
Tokens
|
|
||||||
</div>
|
|
||||||
<div class="font-semibold">
|
|
||||||
{{ formatTokens(userSummary?.total_tokens ?? 0) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
成本
|
|
||||||
</div>
|
|
||||||
<div class="font-semibold">
|
|
||||||
{{ formatCurrency(userSummary?.total_cost ?? 0) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
错误率
|
|
||||||
</div>
|
|
||||||
<div class="font-semibold">
|
|
||||||
{{ userSummary?.error_rate ?? 0 }}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card class="p-4 space-y-4">
|
|
||||||
<h3 class="text-sm font-semibold">
|
|
||||||
用户使用趋势
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
v-if="seriesLoading"
|
|
||||||
class="p-6"
|
|
||||||
>
|
|
||||||
<LoadingState />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="h-[280px]"
|
|
||||||
>
|
|
||||||
<LineChart :data="seriesChartData" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
v-if="comparisonSeries.length > 0"
|
|
||||||
class="p-4 space-y-4"
|
|
||||||
>
|
|
||||||
<h3 class="text-sm font-semibold">
|
|
||||||
用户对比趋势
|
|
||||||
</h3>
|
|
||||||
<div class="h-[280px]">
|
|
||||||
<LineChart :data="comparisonChartData" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
||||||
import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
|
||||||
import LineChart from '@/components/charts/LineChart.vue'
|
|
||||||
import { LoadingState, TimeRangePicker } from '@/components/common'
|
|
||||||
import { LeaderboardTable } from '@/components/stats'
|
|
||||||
import { adminApi, type LeaderboardItem } from '@/api/admin'
|
|
||||||
import { usersApi, type User } from '@/api/users'
|
|
||||||
import { usageApi } from '@/api/usage'
|
|
||||||
import { formatCurrency, formatTokens } from '@/utils/format'
|
|
||||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
|
||||||
import type { DateRangeParams } from '@/features/usage/types'
|
|
||||||
|
|
||||||
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
|
|
||||||
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
|
|
||||||
|
|
||||||
const users = ref<User[]>([])
|
|
||||||
const selectedUserId = ref<string | null>(null)
|
|
||||||
const compareUserId = ref<string>('__none__')
|
|
||||||
|
|
||||||
const leaderboard = ref<LeaderboardItem[]>([])
|
|
||||||
const leaderboardLoading = ref(false)
|
|
||||||
|
|
||||||
interface UsageSummary {
|
|
||||||
total_requests: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
error_rate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TimeSeriesItem {
|
|
||||||
date: string
|
|
||||||
total_cost: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const userSummary = ref<UsageSummary | null>(null)
|
|
||||||
const summaryLoading = ref(false)
|
|
||||||
|
|
||||||
const series = ref<TimeSeriesItem[]>([])
|
|
||||||
const comparisonSeries = ref<TimeSeriesItem[]>([])
|
|
||||||
const seriesLoading = ref(false)
|
|
||||||
let leaderboardRequestId = 0
|
|
||||||
let summaryRequestId = 0
|
|
||||||
let seriesRequestId = 0
|
|
||||||
let leaderboardLoadPromise: Promise<void> | null = null
|
|
||||||
let hasPendingLeaderboardLoad = false
|
|
||||||
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let userPanelsLoadPromise: Promise<void> | null = null
|
|
||||||
let hasPendingUserPanelsLoad = false
|
|
||||||
let userPanelsDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
function buildTimeRangeParams() {
|
|
||||||
return {
|
|
||||||
start_date: timeRange.value.start_date,
|
|
||||||
end_date: timeRange.value.end_date,
|
|
||||||
preset: timeRange.value.preset,
|
|
||||||
timezone: timeRange.value.timezone,
|
|
||||||
tz_offset_minutes: timeRange.value.tz_offset_minutes,
|
|
||||||
granularity: timeRange.value.granularity || 'day'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers() {
|
|
||||||
users.value = await usersApi.getAllUsers()
|
|
||||||
if (!selectedUserId.value && users.value.length > 0) {
|
|
||||||
selectedUserId.value = users.value[0].id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLeaderboard() {
|
|
||||||
if (leaderboardLoadPromise) {
|
|
||||||
hasPendingLeaderboardLoad = true
|
|
||||||
return leaderboardLoadPromise
|
|
||||||
}
|
|
||||||
leaderboardLoadPromise = (async () => {
|
|
||||||
const requestId = ++leaderboardRequestId
|
|
||||||
leaderboardLoading.value = true
|
|
||||||
try {
|
|
||||||
const response = await adminApi.getLeaderboardUsers({
|
|
||||||
...buildTimeRangeParams(),
|
|
||||||
metric: metric.value,
|
|
||||||
limit: 10
|
|
||||||
})
|
|
||||||
if (requestId !== leaderboardRequestId) return
|
|
||||||
leaderboard.value = response.items
|
|
||||||
} finally {
|
|
||||||
if (requestId === leaderboardRequestId) {
|
|
||||||
leaderboardLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})().finally(() => {
|
|
||||||
leaderboardLoadPromise = null
|
|
||||||
if (hasPendingLeaderboardLoad) {
|
|
||||||
hasPendingLeaderboardLoad = false
|
|
||||||
void loadLeaderboard()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return leaderboardLoadPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSummary() {
|
|
||||||
if (!selectedUserId.value) return
|
|
||||||
const requestId = ++summaryRequestId
|
|
||||||
summaryLoading.value = true
|
|
||||||
try {
|
|
||||||
const summary = await usageApi.getUsageStats({
|
|
||||||
...buildTimeRangeParams(),
|
|
||||||
user_id: selectedUserId.value
|
|
||||||
})
|
|
||||||
if (requestId !== summaryRequestId) return
|
|
||||||
userSummary.value = summary
|
|
||||||
} finally {
|
|
||||||
if (requestId === summaryRequestId) {
|
|
||||||
summaryLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSeries() {
|
|
||||||
if (!selectedUserId.value) return
|
|
||||||
const requestId = ++seriesRequestId
|
|
||||||
seriesLoading.value = true
|
|
||||||
try {
|
|
||||||
const baseParams = {
|
|
||||||
...buildTimeRangeParams(),
|
|
||||||
user_id: selectedUserId.value
|
|
||||||
}
|
|
||||||
const shouldCompare = Boolean(compareUserId.value && compareUserId.value !== '__none__')
|
|
||||||
const comparePromise: Promise<TimeSeriesItem[]> = shouldCompare
|
|
||||||
? adminApi.getTimeSeries({
|
|
||||||
...buildTimeRangeParams(),
|
|
||||||
user_id: compareUserId.value
|
|
||||||
})
|
|
||||||
: Promise.resolve([])
|
|
||||||
|
|
||||||
const [primarySeries, compareSeries] = await Promise.all([
|
|
||||||
adminApi.getTimeSeries(baseParams),
|
|
||||||
comparePromise
|
|
||||||
])
|
|
||||||
|
|
||||||
if (requestId !== seriesRequestId) return
|
|
||||||
series.value = primarySeries
|
|
||||||
comparisonSeries.value = compareSeries
|
|
||||||
} finally {
|
|
||||||
if (requestId === seriesRequestId) {
|
|
||||||
seriesLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUserPanels() {
|
|
||||||
if (userPanelsLoadPromise) {
|
|
||||||
hasPendingUserPanelsLoad = true
|
|
||||||
return userPanelsLoadPromise
|
|
||||||
}
|
|
||||||
userPanelsLoadPromise = Promise.all([loadSummary(), loadSeries()])
|
|
||||||
.then(() => undefined)
|
|
||||||
.finally(() => {
|
|
||||||
userPanelsLoadPromise = null
|
|
||||||
if (hasPendingUserPanelsLoad) {
|
|
||||||
hasPendingUserPanelsLoad = false
|
|
||||||
void loadUserPanels()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return userPanelsLoadPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
const seriesChartData = computed(() => ({
|
|
||||||
labels: series.value.map(item => item.date),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: '成本',
|
|
||||||
data: series.value.map(item => item.total_cost),
|
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
|
|
||||||
const comparisonChartData = computed(() => ({
|
|
||||||
labels: series.value.map(item => item.date),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: '当前用户',
|
|
||||||
data: series.value.map(item => item.total_cost),
|
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '对比用户',
|
|
||||||
data: comparisonSeries.value.map(item => item.total_cost),
|
|
||||||
borderColor: 'rgb(234, 179, 8)',
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
|
|
||||||
function scheduleLeaderboardLoad() {
|
|
||||||
if (leaderboardDebounceTimer) {
|
|
||||||
clearTimeout(leaderboardDebounceTimer)
|
|
||||||
}
|
|
||||||
leaderboardDebounceTimer = setTimeout(() => {
|
|
||||||
leaderboardDebounceTimer = null
|
|
||||||
void loadLeaderboard()
|
|
||||||
}, 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleUserPanelsLoad() {
|
|
||||||
if (userPanelsDebounceTimer) {
|
|
||||||
clearTimeout(userPanelsDebounceTimer)
|
|
||||||
}
|
|
||||||
userPanelsDebounceTimer = setTimeout(() => {
|
|
||||||
userPanelsDebounceTimer = null
|
|
||||||
void loadUserPanels()
|
|
||||||
}, 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([timeRange, metric], scheduleLeaderboardLoad, { deep: true })
|
|
||||||
watch([timeRange, selectedUserId, compareUserId], scheduleUserPanelsLoad, { deep: true })
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadUsers()
|
|
||||||
await Promise.all([
|
|
||||||
loadLeaderboard(),
|
|
||||||
loadUserPanels()
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (leaderboardDebounceTimer) {
|
|
||||||
clearTimeout(leaderboardDebounceTimer)
|
|
||||||
leaderboardDebounceTimer = null
|
|
||||||
}
|
|
||||||
if (userPanelsDebounceTimer) {
|
|
||||||
clearTimeout(userPanelsDebounceTimer)
|
|
||||||
userPanelsDebounceTimer = null
|
|
||||||
}
|
|
||||||
hasPendingLeaderboardLoad = false
|
|
||||||
hasPendingUserPanelsLoad = false
|
|
||||||
leaderboardLoadPromise = null
|
|
||||||
userPanelsLoadPromise = null
|
|
||||||
leaderboardRequestId += 1
|
|
||||||
summaryRequestId += 1
|
|
||||||
seriesRequestId += 1
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1019,7 +1019,7 @@ import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
|||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { usageApi, type UsageByUser } from '@/api/usage'
|
import { analyticsApi } from '@/api/analytics'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
|
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
|
||||||
|
|
||||||
@@ -1102,7 +1102,14 @@ const userApiKeyForm = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 用户统计
|
// 用户统计
|
||||||
const userStats = ref<Record<string, UsageByUser>>({})
|
interface UserUsageSummary {
|
||||||
|
user_id: string
|
||||||
|
request_count: number
|
||||||
|
total_tokens: number
|
||||||
|
total_cost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const userStats = ref<Record<string, UserUsageSummary>>({})
|
||||||
const loadingStats = ref(false)
|
const loadingStats = ref(false)
|
||||||
let userStatsRequestId = 0
|
let userStatsRequestId = 0
|
||||||
const userWalletMap = ref<Record<string, AdminWallet>>({})
|
const userWalletMap = ref<Record<string, AdminWallet>>({})
|
||||||
@@ -1181,10 +1188,26 @@ async function loadUserStats() {
|
|||||||
const requestId = ++userStatsRequestId
|
const requestId = ++userStatsRequestId
|
||||||
loadingStats.value = true
|
loadingStats.value = true
|
||||||
try {
|
try {
|
||||||
const data = await usageApi.getUsageByUser()
|
const response = await analyticsApi.getBreakdown({
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
time_range: {
|
||||||
|
preset: 'last30days',
|
||||||
|
granularity: 'day',
|
||||||
|
timezone: 'UTC',
|
||||||
|
tz_offset_minutes: 0,
|
||||||
|
},
|
||||||
|
dimension: 'user',
|
||||||
|
metric: 'requests_total',
|
||||||
|
limit: 200,
|
||||||
|
})
|
||||||
if (requestId !== userStatsRequestId) return
|
if (requestId !== userStatsRequestId) return
|
||||||
userStats.value = data.reduce((acc: Record<string, UsageByUser>, stat: UsageByUser) => {
|
userStats.value = response.rows.reduce((acc: Record<string, UserUsageSummary>, row) => {
|
||||||
acc[stat.user_id] = stat
|
acc[row.key] = {
|
||||||
|
user_id: row.key,
|
||||||
|
request_count: row.requests_total,
|
||||||
|
total_tokens: row.total_tokens,
|
||||||
|
total_cost: row.total_cost_usd,
|
||||||
|
}
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
固定按费用排序,点击{{ dimensionLabel }}名称即可在右侧查看同时间范围下的趋势。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="inline-flex h-8 items-center rounded-lg border border-border/70 bg-background px-3 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
|
||||||
|
@click="exportLeaderboard"
|
||||||
|
>
|
||||||
|
导出 CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 xl:grid-cols-[minmax(0,390px)_minmax(0,1fr)] 2xl:grid-cols-[minmax(0,420px)_minmax(0,1.18fr)]">
|
||||||
|
<LeaderboardTable
|
||||||
|
:title="leaderboardTitle"
|
||||||
|
:items="paginatedLeaderboard"
|
||||||
|
:selected-id="selectedId"
|
||||||
|
:loading="leaderboardLoading"
|
||||||
|
:unavailable="leaderboardUnavailable"
|
||||||
|
:has-loaded="leaderboardHasLoaded"
|
||||||
|
:current-page="leaderboardCurrentPage"
|
||||||
|
:total-items="leaderboard.length"
|
||||||
|
:page-size="LEADERBOARD_PAGE_SIZE"
|
||||||
|
:show-pagination="true"
|
||||||
|
@select="selectedId = $event"
|
||||||
|
@update:current-page="leaderboardCurrentPage = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-border/60 px-4 py-4">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="border-amber-500/35 bg-amber-500/8 text-[10px] text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
{{ selectedItem ? `#${selectedItem.rank}` : '未选择' }}
|
||||||
|
</Badge>
|
||||||
|
<h3 class="truncate text-sm font-semibold">
|
||||||
|
{{ selectedLabel || `${dimensionLabel}趋势` }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
{{ trendLeadText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
v-model="currentTrendGranularitySelection"
|
||||||
|
class="self-start"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-start gap-2">
|
||||||
|
<div
|
||||||
|
v-if="isPanelRefreshing"
|
||||||
|
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
更新中
|
||||||
|
</div>
|
||||||
|
<TabsList class="tabs-button-list flex-wrap justify-start gap-1">
|
||||||
|
<TabsTrigger
|
||||||
|
v-for="option in trendGranularityTabStates"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
:disabled="option.disabled"
|
||||||
|
class="min-w-[38px] px-2 py-0.5 text-[10px]"
|
||||||
|
:class="option.disabled ? 'cursor-not-allowed opacity-40' : ''"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedSummary"
|
||||||
|
class="mt-4 grid grid-cols-2 gap-2 transition-opacity sm:grid-cols-4"
|
||||||
|
:class="{ 'opacity-60': isPanelRefreshing }"
|
||||||
|
>
|
||||||
|
<div class="rounded-xl border border-border/60 bg-muted/15 px-3 py-2.5">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
请求数
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold tabular-nums">
|
||||||
|
{{ selectedSummary.total_requests.toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-border/60 bg-muted/15 px-3 py-2.5">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
总计 Tokens
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold tabular-nums">
|
||||||
|
{{ formatTokens(selectedSummary.total_tokens) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-border/60 bg-muted/15 px-3 py-2.5">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
费用
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold tabular-nums">
|
||||||
|
{{ formatCurrency(selectedSummary.total_cost) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-border/60 bg-muted/15 px-3 py-2.5">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
成本
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold tabular-nums">
|
||||||
|
{{ formatCurrency(selectedSummary.total_actual_cost) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LineChart
|
||||||
|
v-if="selectedItem && trendChartData"
|
||||||
|
:class="{ 'opacity-60 transition-opacity': isPanelRefreshing }"
|
||||||
|
:data="trendChartData"
|
||||||
|
:options="trendChartOptions"
|
||||||
|
:height="320"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-[320px] items-center justify-center px-6 text-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ trendEmptyText }}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, toRef, watch } from 'vue'
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js'
|
||||||
|
import { Badge, Card, Tabs, TabsList, TabsTrigger } from '@/components/ui'
|
||||||
|
import { LeaderboardTable } from '@/components/stats'
|
||||||
|
import LineChart from '@/components/charts/LineChart.vue'
|
||||||
|
import { useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
|
||||||
|
import { useLeaderboardData, type LeaderboardDimension } from '@/composables/analytics'
|
||||||
|
import {
|
||||||
|
analyticsGranularityTabs,
|
||||||
|
formatAnalyticsTrendBucketLabel,
|
||||||
|
formatAnalyticsTrendDescription,
|
||||||
|
formatAnalyticsTrendTooltipTitle,
|
||||||
|
isAnalyticsSingleDayRange,
|
||||||
|
} from '@/utils/analyticsGranularity'
|
||||||
|
import { downloadCsvFromObjects } from '@/utils/csvExport'
|
||||||
|
import { formatCurrency, formatTokens } from '@/utils/format'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
dimension: LeaderboardDimension
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dimension = toRef(props, 'dimension')
|
||||||
|
const filters = useInjectedAnalyticsFilters()
|
||||||
|
const {
|
||||||
|
leaderboard,
|
||||||
|
leaderboardLoading,
|
||||||
|
leaderboardError,
|
||||||
|
leaderboardHasLoaded,
|
||||||
|
selectedId,
|
||||||
|
selectedItem,
|
||||||
|
selectedLabel,
|
||||||
|
selectedSummary,
|
||||||
|
selectedTrendBuckets,
|
||||||
|
panelLoading,
|
||||||
|
panelError,
|
||||||
|
panelHasLoaded,
|
||||||
|
resolvedTrendGranularity,
|
||||||
|
currentTrendGranularitySelection,
|
||||||
|
} = useLeaderboardData(dimension)
|
||||||
|
|
||||||
|
const trendAxisTickCount = 6
|
||||||
|
const LEADERBOARD_PAGE_SIZE = 5
|
||||||
|
const leaderboardCurrentPage = ref(1)
|
||||||
|
|
||||||
|
const dimensionLabel = computed(() => (
|
||||||
|
props.dimension === 'users' ? '用户' : 'API Key'
|
||||||
|
))
|
||||||
|
|
||||||
|
const leaderboardTitle = computed(() => `${dimensionLabel.value}费用排行榜`)
|
||||||
|
const leaderboardUnavailable = computed(() => (
|
||||||
|
leaderboardError.value && !leaderboardHasLoaded.value
|
||||||
|
))
|
||||||
|
const leaderboardTotalPages = computed(() => (
|
||||||
|
Math.max(1, Math.ceil(leaderboard.value.length / LEADERBOARD_PAGE_SIZE))
|
||||||
|
))
|
||||||
|
const paginatedLeaderboard = computed(() => {
|
||||||
|
const start = (leaderboardCurrentPage.value - 1) * LEADERBOARD_PAGE_SIZE
|
||||||
|
return leaderboard.value.slice(start, start + LEADERBOARD_PAGE_SIZE)
|
||||||
|
})
|
||||||
|
|
||||||
|
const trendGranularityTabStates = computed(() => (
|
||||||
|
analyticsGranularityTabs.map(option => ({
|
||||||
|
...option,
|
||||||
|
disabled: option.value === 'hour' ? !isAnalyticsSingleDayRange(filters.timeRange.value) : false,
|
||||||
|
}))
|
||||||
|
))
|
||||||
|
|
||||||
|
const trendChartDescription = computed(() => (
|
||||||
|
formatAnalyticsTrendDescription(
|
||||||
|
resolvedTrendGranularity.value,
|
||||||
|
'请求量、费用、成本与总计 Tokens',
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
|
const trendLeadText = computed(() => {
|
||||||
|
if (leaderboardUnavailable.value) {
|
||||||
|
return `${dimensionLabel.value}排行榜接口未返回结果,请稍后重试。`
|
||||||
|
}
|
||||||
|
if (!selectedItem.value) {
|
||||||
|
return `从左侧选择${dimensionLabel.value}后,这里会显示与首页仪表盘一致的趋势视图。`
|
||||||
|
}
|
||||||
|
return `${selectedLabel.value} · ${trendChartDescription.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const trendEmptyText = computed(() => {
|
||||||
|
if (leaderboardUnavailable.value) {
|
||||||
|
return `${dimensionLabel.value}排行榜暂不可用`
|
||||||
|
}
|
||||||
|
if (!selectedItem.value) {
|
||||||
|
return `请选择一个${dimensionLabel.value}查看趋势`
|
||||||
|
}
|
||||||
|
if (panelError.value && !panelHasLoaded.value) {
|
||||||
|
return `${dimensionLabel.value}趋势暂不可用`
|
||||||
|
}
|
||||||
|
return isInitialPanelLoading.value ? '加载中...' : '暂无趋势数据'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isInitialPanelLoading = computed(() => (
|
||||||
|
panelLoading.value && !panelHasLoaded.value && selectedTrendBuckets.value.length === 0
|
||||||
|
))
|
||||||
|
|
||||||
|
const isPanelRefreshing = computed(() => (
|
||||||
|
panelLoading.value && panelHasLoaded.value && selectedTrendBuckets.value.length > 0
|
||||||
|
))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
props.dimension,
|
||||||
|
filters.timeRange.value.preset,
|
||||||
|
filters.timeRange.value.start_date,
|
||||||
|
filters.timeRange.value.end_date,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
leaderboardCurrentPage.value = 1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
leaderboardTotalPages,
|
||||||
|
(totalPages) => {
|
||||||
|
if (leaderboardCurrentPage.value > totalPages) {
|
||||||
|
leaderboardCurrentPage.value = totalPages
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[paginatedLeaderboard, selectedId],
|
||||||
|
([items, currentSelectedId]) => {
|
||||||
|
if (!items.length) return
|
||||||
|
if (!items.some(item => item.id === currentSelectedId)) {
|
||||||
|
selectedId.value = items[0].id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function computeNiceAxisMax(maxValue: number, intervals: number): number {
|
||||||
|
if (!Number.isFinite(maxValue) || maxValue <= 0) return 1
|
||||||
|
const rawStep = maxValue / intervals
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(rawStep))
|
||||||
|
const normalized = rawStep / magnitude
|
||||||
|
const niceNormalized = normalized <= 1
|
||||||
|
? 1
|
||||||
|
: normalized <= 2
|
||||||
|
? 2
|
||||||
|
: normalized <= 2.5
|
||||||
|
? 2.5
|
||||||
|
: normalized <= 5
|
||||||
|
? 5
|
||||||
|
: 10
|
||||||
|
return niceNormalized * magnitude * intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendScaleMaxima = computed(() => {
|
||||||
|
const intervals = Math.max(trendAxisTickCount - 1, 1)
|
||||||
|
const requestMax = Math.max(0, ...selectedTrendBuckets.value.map(bucket => bucket.requests_total))
|
||||||
|
const tokenMax = Math.max(0, ...selectedTrendBuckets.value.map(bucket => bucket.total_tokens))
|
||||||
|
const costMax = Math.max(
|
||||||
|
0,
|
||||||
|
...selectedTrendBuckets.value.map(bucket => Math.max(bucket.total_cost_usd, bucket.actual_total_cost_usd)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
requests: computeNiceAxisMax(requestMax, intervals),
|
||||||
|
tokens: computeNiceAxisMax(tokenMax, intervals),
|
||||||
|
cost: computeNiceAxisMax(costMax, intervals),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const trendChartData = computed<ChartData<'line'> | null>(() => {
|
||||||
|
if (!selectedTrendBuckets.value.length) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: selectedTrendBuckets.value.map(bucket => (
|
||||||
|
formatAnalyticsTrendBucketLabel(bucket.bucket_start, resolvedTrendGranularity.value)
|
||||||
|
)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '费用',
|
||||||
|
data: selectedTrendBuckets.value.map(bucket => bucket.total_cost_usd),
|
||||||
|
borderColor: 'rgb(232, 145, 89)',
|
||||||
|
backgroundColor: 'rgba(232, 145, 89, 0.12)',
|
||||||
|
borderWidth: 2.2,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.28,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
|
yAxisID: 'y2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '成本',
|
||||||
|
data: selectedTrendBuckets.value.map(bucket => bucket.actual_total_cost_usd),
|
||||||
|
borderColor: 'rgb(196, 92, 124)',
|
||||||
|
backgroundColor: 'rgba(196, 92, 124, 0.12)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [7, 5],
|
||||||
|
fill: false,
|
||||||
|
tension: 0.28,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
|
yAxisID: 'y2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '总计 Tokens',
|
||||||
|
data: selectedTrendBuckets.value.map(bucket => bucket.total_tokens),
|
||||||
|
borderColor: 'rgb(103, 149, 230)',
|
||||||
|
backgroundColor: 'rgba(103, 149, 230, 0.16)',
|
||||||
|
borderWidth: 2.6,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
|
yAxisID: 'y1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '请求数',
|
||||||
|
data: selectedTrendBuckets.value.map(bucket => bucket.requests_total),
|
||||||
|
borderColor: 'rgb(146, 126, 214)',
|
||||||
|
backgroundColor: 'rgba(146, 126, 214, 0.08)',
|
||||||
|
borderWidth: 2.2,
|
||||||
|
borderDash: [7, 5],
|
||||||
|
fill: false,
|
||||||
|
tension: 0.28,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
|
yAxisID: 'y',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const trendChartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.05)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(92, 98, 109)',
|
||||||
|
maxRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: resolvedTrendGranularity.value === 'hour' ? 12 : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
min: 0,
|
||||||
|
max: trendScaleMaxima.value.requests,
|
||||||
|
beginAtZero: true,
|
||||||
|
alignToPixels: true,
|
||||||
|
bounds: 'ticks',
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.05)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(92, 98, 109)',
|
||||||
|
count: trendAxisTickCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
min: 0,
|
||||||
|
max: trendScaleMaxima.value.tokens,
|
||||||
|
beginAtZero: true,
|
||||||
|
alignToPixels: true,
|
||||||
|
bounds: 'ticks',
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(92, 98, 109)',
|
||||||
|
count: trendAxisTickCount,
|
||||||
|
callback: value => {
|
||||||
|
const tokenValue = Number(value)
|
||||||
|
const tokenMax = trendScaleMaxima.value.tokens
|
||||||
|
const costMax = trendScaleMaxima.value.cost
|
||||||
|
const ratio = tokenMax > 0 ? tokenValue / tokenMax : 0
|
||||||
|
const costValue = ratio * costMax
|
||||||
|
return [formatTokens(tokenValue), formatCurrency(costValue)]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y2: {
|
||||||
|
type: 'linear',
|
||||||
|
display: false,
|
||||||
|
position: 'right',
|
||||||
|
min: 0,
|
||||||
|
max: trendScaleMaxima.value.cost,
|
||||||
|
beginAtZero: true,
|
||||||
|
alignToPixels: true,
|
||||||
|
bounds: 'ticks',
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(92, 98, 109)',
|
||||||
|
count: trendAxisTickCount,
|
||||||
|
callback: value => formatCurrency(Number(value)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: items => {
|
||||||
|
const bucket = selectedTrendBuckets.value[items[0]?.dataIndex ?? -1]
|
||||||
|
if (!bucket) return ''
|
||||||
|
return formatAnalyticsTrendTooltipTitle(bucket.bucket_start, resolvedTrendGranularity.value)
|
||||||
|
},
|
||||||
|
label: context => {
|
||||||
|
const value = Number(context.raw ?? 0)
|
||||||
|
if (context.dataset.label === '总计 Tokens') {
|
||||||
|
return `${context.dataset.label}: ${formatTokens(value)}`
|
||||||
|
}
|
||||||
|
if (context.dataset.label === '费用' || context.dataset.label === '成本') {
|
||||||
|
return `${context.dataset.label}: ${formatCurrency(value)}`
|
||||||
|
}
|
||||||
|
return `${context.dataset.label}: ${value.toLocaleString()}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function exportLeaderboard() {
|
||||||
|
if (!leaderboard.value.length) return
|
||||||
|
downloadCsvFromObjects(
|
||||||
|
`leaderboard-${props.dimension}-${new Date().toISOString().slice(0, 10)}`,
|
||||||
|
leaderboard.value as unknown as Record<string, unknown>[],
|
||||||
|
[
|
||||||
|
{ key: 'rank', label: '排名' },
|
||||||
|
{ key: 'name', label: '名称' },
|
||||||
|
{ key: 'requests', label: '请求数' },
|
||||||
|
{ key: 'tokens', label: 'Tokens' },
|
||||||
|
{ key: 'cost', label: '费用' },
|
||||||
|
{ key: 'actualCost', label: '成本' },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,921 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium">
|
||||||
|
系统资源监控
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
6 项核心资源的实时压力指数,越满表示越紧张
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="systemStatusTimestamp"
|
||||||
|
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ systemStatusTimestamp }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Card
|
||||||
|
v-for="item in systemMetricCards"
|
||||||
|
:key="item.key"
|
||||||
|
class="min-h-[148px] border-border/60 bg-card p-3.5"
|
||||||
|
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2.5">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground/85">
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 text-xl font-semibold tracking-tight text-foreground">
|
||||||
|
{{ item.value }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 line-clamp-2 text-[10px] text-muted-foreground">
|
||||||
|
{{ item.detail }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border"
|
||||||
|
:class="item.iconClass"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2.5 space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="min-w-0 truncate text-[10px] text-muted-foreground">
|
||||||
|
{{ item.meta }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium"
|
||||||
|
:class="item.badgeClass"
|
||||||
|
>
|
||||||
|
{{ item.badge }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all"
|
||||||
|
:class="item.progressClass"
|
||||||
|
:style="{ width: item.progressWidth }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
|
||||||
|
<span>{{ item.caption }}</span>
|
||||||
|
<span class="tabular-nums">{{ item.captionValue }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium">
|
||||||
|
运行概览
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
汇总健康状态、今日负载与当前风险信号
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isRefreshing"
|
||||||
|
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
更新中
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Card
|
||||||
|
v-for="item in statusCards"
|
||||||
|
:key="item.key"
|
||||||
|
class="min-h-[138px] border-border/60 bg-card p-4"
|
||||||
|
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground/85">
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">
|
||||||
|
{{ item.value }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
|
{{ item.detail }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border"
|
||||||
|
:class="item.iconClass"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-3 border-t border-border/50 pt-3">
|
||||||
|
<span class="min-w-0 truncate text-[11px] text-muted-foreground">
|
||||||
|
{{ item.caption }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium"
|
||||||
|
:class="item.badgeClass"
|
||||||
|
>
|
||||||
|
{{ item.badge }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card class="overflow-hidden border-border/60 bg-card p-4">
|
||||||
|
<div :class="{ 'opacity-60 transition-opacity': isRefreshing }">
|
||||||
|
<PercentileChart
|
||||||
|
title="响应延迟百分位"
|
||||||
|
subtitle="观察 P50 / P90 / P99 响应耗时的分布变化"
|
||||||
|
:series="percentiles"
|
||||||
|
mode="response"
|
||||||
|
:loading="isInitialLoading"
|
||||||
|
:unavailable="performanceUnavailable"
|
||||||
|
unavailable-text="性能数据暂不可用"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden border-border/60 bg-card p-4">
|
||||||
|
<div :class="{ 'opacity-60 transition-opacity': isRefreshing }">
|
||||||
|
<PercentileChart
|
||||||
|
title="首字节延迟百分位"
|
||||||
|
subtitle="聚焦首包返回速度,判断链路是否出现抖动"
|
||||||
|
:series="percentiles"
|
||||||
|
mode="ttfb"
|
||||||
|
:loading="isInitialLoading"
|
||||||
|
:unavailable="performanceUnavailable"
|
||||||
|
unavailable-text="性能数据暂不可用"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden border-border/60 bg-card p-4">
|
||||||
|
<div :class="{ 'opacity-60 transition-opacity': isRefreshing }">
|
||||||
|
<ErrorDistributionChart
|
||||||
|
title="错误分布"
|
||||||
|
subtitle="按错误类别观察主要失败来源"
|
||||||
|
:distribution="errorDistribution"
|
||||||
|
:loading="isInitialLoading"
|
||||||
|
:unavailable="performanceUnavailable"
|
||||||
|
unavailable-text="性能数据暂不可用"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden border-border/60 bg-card p-4">
|
||||||
|
<div class="space-y-4" :class="{ 'opacity-60 transition-opacity': isRefreshing }">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
错误趋势
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
观察错误量在当前时间范围内的波动与峰值
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!performanceUnavailable"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<div class="rounded-full border border-rose-500/20 bg-rose-500/8 px-2.5 py-1 text-[10px] font-medium text-rose-700 dark:text-rose-300">
|
||||||
|
总错误 {{ errorTotalCount }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="errorPeakLabel"
|
||||||
|
class="rounded-full border border-border/70 bg-muted/20 px-2.5 py-1 text-[10px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
峰值 {{ errorPeakLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isInitialLoading"
|
||||||
|
class="p-6"
|
||||||
|
>
|
||||||
|
<LoadingState />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="performanceUnavailable"
|
||||||
|
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
性能数据暂不可用
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="errorTrendChartData"
|
||||||
|
class="rounded-2xl border border-border/60 bg-background p-3"
|
||||||
|
>
|
||||||
|
<LineChart
|
||||||
|
:data="errorTrendChartData"
|
||||||
|
:options="errorTrendChartOptions"
|
||||||
|
:height="248"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无错误趋势数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium">
|
||||||
|
提供商健康度
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
v-if="isRefreshing"
|
||||||
|
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
更新中
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="performanceUnavailable"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
性能数据暂不可用
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="providerStatus.length === 0 && !loading"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无提供商状态数据
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<Card
|
||||||
|
v-for="provider in providerStatus"
|
||||||
|
:key="provider.provider_name"
|
||||||
|
class="space-y-2 border-border/60 bg-card p-3"
|
||||||
|
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-xs font-semibold" :title="provider.provider_name">
|
||||||
|
{{ provider.provider_name }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
{{ provider.requests_total?.toLocaleString() ?? 0 }} 请求
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
:variant="provider.error_rate > 5 ? 'destructive' : provider.success_rate >= 98 ? 'success' : 'warning'"
|
||||||
|
class="h-5 px-1.5 text-[9px]"
|
||||||
|
>
|
||||||
|
{{ provider.error_rate > 5 ? '异常' : provider.success_rate >= 98 ? '稳定' : '波动' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-x-3 gap-y-2 text-[10px] text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
成功率
|
||||||
|
<div class="mt-0.5 font-semibold text-foreground tabular-nums">
|
||||||
|
{{ provider.success_rate.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
错误率
|
||||||
|
<div class="mt-0.5 font-semibold text-foreground tabular-nums">
|
||||||
|
{{ provider.error_rate.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
响应
|
||||||
|
<div class="mt-0.5 font-semibold text-foreground tabular-nums">
|
||||||
|
{{ formatLatency(provider.avg_response_time_ms) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
TTFB
|
||||||
|
<div class="mt-0.5 font-semibold text-foreground tabular-nums">
|
||||||
|
{{ formatLatency(provider.avg_first_byte_time_ms) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type Component } from 'vue'
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js'
|
||||||
|
import { Activity, AlertTriangle, Cpu, Database, Gauge, HardDrive, Key } from 'lucide-vue-next'
|
||||||
|
import type { MonitoringMetricStatus } from '@/api/audit'
|
||||||
|
import { Badge, Card } from '@/components/ui'
|
||||||
|
import { LoadingState } from '@/components/common'
|
||||||
|
import { PercentileChart, ErrorDistributionChart } from '@/components/stats'
|
||||||
|
import LineChart from '@/components/charts/LineChart.vue'
|
||||||
|
import { usePerformanceData } from '@/composables/analytics'
|
||||||
|
import { formatCurrency, formatTokens } from '@/utils/format'
|
||||||
|
|
||||||
|
const {
|
||||||
|
latency,
|
||||||
|
percentiles,
|
||||||
|
errorDistribution,
|
||||||
|
errorTrend,
|
||||||
|
errorTotal: errorTotalCount,
|
||||||
|
errorRate,
|
||||||
|
providerStatus,
|
||||||
|
healthSummary,
|
||||||
|
systemStatus,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
hasLoaded,
|
||||||
|
} = usePerformanceData()
|
||||||
|
|
||||||
|
const isInitialLoading = computed(() => loading.value && !hasLoaded.value)
|
||||||
|
const isRefreshing = computed(() => loading.value && hasLoaded.value)
|
||||||
|
const performanceUnavailable = computed(() => loadError.value && !hasLoaded.value)
|
||||||
|
const errorPeakPoint = computed(() => (
|
||||||
|
errorTrend.value.slice().sort((left, right) => right.total - left.total)[0] ?? null
|
||||||
|
))
|
||||||
|
const errorPeakLabel = computed(() => (
|
||||||
|
errorPeakPoint.value && errorPeakPoint.value.total > 0
|
||||||
|
? `${errorPeakPoint.value.date.slice(5)} · ${errorPeakPoint.value.total}`
|
||||||
|
: null
|
||||||
|
))
|
||||||
|
|
||||||
|
function formatLatency(value: number) {
|
||||||
|
if (!value || value <= 0) return '-'
|
||||||
|
if (value < 1000) return `${Math.round(value)}ms`
|
||||||
|
return `${(value / 1000).toFixed(2)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(value: number | null | undefined, ready: boolean) {
|
||||||
|
return ready && value != null ? value.toLocaleString() : '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRatio(active: number | null | undefined, total: number | null | undefined, ready: boolean) {
|
||||||
|
return ready && active != null && total != null ? `${active}/${total}` : '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusTone = 'success' | 'warning' | 'danger' | 'info' | 'neutral'
|
||||||
|
|
||||||
|
interface StatusCardItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
detail: string
|
||||||
|
caption: string
|
||||||
|
badge: string
|
||||||
|
badgeClass: string
|
||||||
|
iconClass: string
|
||||||
|
icon: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemMetricCardItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
detail: string
|
||||||
|
meta: string
|
||||||
|
caption: string
|
||||||
|
captionValue: string
|
||||||
|
badge: string
|
||||||
|
badgeClass: string
|
||||||
|
iconClass: string
|
||||||
|
progressClass: string
|
||||||
|
progressWidth: string
|
||||||
|
icon: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
function toneStyle(tone: StatusTone) {
|
||||||
|
switch (tone) {
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
badgeClass: 'border-emerald-500/20 bg-emerald-500/8 text-emerald-700 dark:text-emerald-300',
|
||||||
|
iconClass: 'border-emerald-500/20 bg-emerald-500/8 text-emerald-700 dark:text-emerald-300',
|
||||||
|
}
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
badgeClass: 'border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-300',
|
||||||
|
iconClass: 'border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-300',
|
||||||
|
}
|
||||||
|
case 'danger':
|
||||||
|
return {
|
||||||
|
badgeClass: 'border-rose-500/20 bg-rose-500/8 text-rose-700 dark:text-rose-300',
|
||||||
|
iconClass: 'border-rose-500/20 bg-rose-500/8 text-rose-700 dark:text-rose-300',
|
||||||
|
}
|
||||||
|
case 'info':
|
||||||
|
return {
|
||||||
|
badgeClass: 'border-sky-500/20 bg-sky-500/8 text-sky-700 dark:text-sky-300',
|
||||||
|
iconClass: 'border-sky-500/20 bg-sky-500/8 text-sky-700 dark:text-sky-300',
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
badgeClass: 'border-border/70 bg-muted/20 text-muted-foreground',
|
||||||
|
iconClass: 'border-border/70 bg-muted/20 text-muted-foreground',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toneProgressClass(tone: StatusTone) {
|
||||||
|
switch (tone) {
|
||||||
|
case 'success':
|
||||||
|
return 'bg-emerald-500/80'
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-amber-500/80'
|
||||||
|
case 'danger':
|
||||||
|
return 'bg-rose-500/80'
|
||||||
|
case 'info':
|
||||||
|
return 'bg-sky-500/80'
|
||||||
|
default:
|
||||||
|
return 'bg-muted-foreground/40'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthTone(unhealthy: number, total: number): StatusTone {
|
||||||
|
if (total <= 0) return 'neutral'
|
||||||
|
if (unhealthy <= 0) return 'success'
|
||||||
|
if (unhealthy / total < 0.25) return 'warning'
|
||||||
|
return 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
function availabilityTone(active: number, total: number): StatusTone {
|
||||||
|
if (total <= 0) return 'neutral'
|
||||||
|
if (active === total) return 'success'
|
||||||
|
if (active > 0) return 'warning'
|
||||||
|
return 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyTone(avgMs: number): StatusTone {
|
||||||
|
if (!avgMs || avgMs <= 0) return 'neutral'
|
||||||
|
if (avgMs < 1500) return 'success'
|
||||||
|
if (avgMs < 3000) return 'warning'
|
||||||
|
return 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
function monitoringTone(status: MonitoringMetricStatus | undefined): StatusTone {
|
||||||
|
switch (status) {
|
||||||
|
case 'ok':
|
||||||
|
return 'success'
|
||||||
|
case 'warning':
|
||||||
|
case 'degraded':
|
||||||
|
return 'warning'
|
||||||
|
case 'danger':
|
||||||
|
case 'error':
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number | null | undefined) {
|
||||||
|
return value == null ? '--' : `${value.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactCount(value: number | null | undefined) {
|
||||||
|
return value == null ? '--' : value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number | null | undefined) {
|
||||||
|
if (value == null) return '--'
|
||||||
|
if (value === 0) return '0 B'
|
||||||
|
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let current = value
|
||||||
|
let unitIndex = 0
|
||||||
|
|
||||||
|
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
current /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = current >= 100 ? 0 : current >= 10 ? 1 : 2
|
||||||
|
return `${current.toFixed(digits)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeLabel(timestamp: string | null | undefined) {
|
||||||
|
if (!timestamp) return null
|
||||||
|
return `更新 ${new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPercent(value: number | null | undefined) {
|
||||||
|
if (value == null) return null
|
||||||
|
return Math.max(0, Math.min(100, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressWidth(value: number | null | undefined) {
|
||||||
|
const safeValue = clampPercent(value)
|
||||||
|
const width = safeValue == null ? 12 : Math.max(6, safeValue)
|
||||||
|
return `${width}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingToPressurePercent(value: number | null | undefined) {
|
||||||
|
const safeValue = clampPercent(value)
|
||||||
|
return safeValue == null ? null : Math.max(0, 100 - safeValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPressureValue(value: number | null | undefined) {
|
||||||
|
return formatPercent(clampPercent(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyPressureValue(latencyMs: number | null | undefined, status: MonitoringMetricStatus | undefined) {
|
||||||
|
if (latencyMs == null) {
|
||||||
|
return statusPressureValue(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latencyMs >= 200) return 100
|
||||||
|
if (latencyMs >= 120) return 88
|
||||||
|
if (latencyMs >= 80) return 72
|
||||||
|
if (latencyMs >= 40) return 34
|
||||||
|
if (latencyMs >= 20) return 24
|
||||||
|
return 16
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPressureValue(status: MonitoringMetricStatus | undefined) {
|
||||||
|
switch (status) {
|
||||||
|
case 'ok':
|
||||||
|
return 16
|
||||||
|
case 'warning':
|
||||||
|
return 72
|
||||||
|
case 'degraded':
|
||||||
|
return 82
|
||||||
|
case 'danger':
|
||||||
|
return 92
|
||||||
|
case 'error':
|
||||||
|
return 100
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESSURE_CAPTION = '压力指数'
|
||||||
|
|
||||||
|
function pressureMetric(value: number | null | undefined) {
|
||||||
|
return clampPercent(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function storagePressureMetric(value: number | null | undefined) {
|
||||||
|
return remainingToPressurePercent(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackPressureWidth() {
|
||||||
|
return progressWidth(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackPressureCaptionValue() {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCards = computed<StatusCardItem[]>(() => {
|
||||||
|
if (performanceUnavailable.value) {
|
||||||
|
return [
|
||||||
|
{ key: 'endpoint-health', label: 'Endpoint 健康', icon: Database },
|
||||||
|
{ key: 'key-health', label: 'Key 健康', icon: Key },
|
||||||
|
{ key: 'provider-status', label: 'Provider 在线', icon: Cpu },
|
||||||
|
{ key: 'today-traffic', label: '今日流量', icon: Activity },
|
||||||
|
{ key: 'recent-alerts', label: '近 1h 告警', icon: AlertTriangle },
|
||||||
|
{ key: 'latency', label: '平均延迟', icon: Gauge },
|
||||||
|
].map(item => ({
|
||||||
|
...item,
|
||||||
|
value: '不可用',
|
||||||
|
detail: '性能接口未返回结果',
|
||||||
|
caption: '请稍后重试',
|
||||||
|
badge: '不可用',
|
||||||
|
...toneStyle('neutral'),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthReady = healthSummary.value !== null
|
||||||
|
const systemReady = systemStatus.value !== null
|
||||||
|
|
||||||
|
const endpointTotal = healthSummary.value?.endpoints.total ?? 0
|
||||||
|
const endpointActive = healthSummary.value?.endpoints.active ?? 0
|
||||||
|
const endpointUnhealthy = healthSummary.value?.endpoints.unhealthy ?? 0
|
||||||
|
const endpointTone = toneStyle(healthTone(endpointUnhealthy, endpointTotal))
|
||||||
|
|
||||||
|
const keyTotal = healthSummary.value?.keys.total ?? systemStatus.value?.api_keys.total ?? 0
|
||||||
|
const keyActive = healthSummary.value?.keys.active ?? systemStatus.value?.api_keys.active ?? 0
|
||||||
|
const keyUnhealthy = healthSummary.value?.keys.unhealthy ?? Math.max(keyTotal - keyActive, 0)
|
||||||
|
const keyCircuitOpen = healthSummary.value?.keys.circuit_open ?? 0
|
||||||
|
const keyTone = toneStyle(healthTone(keyUnhealthy, keyTotal))
|
||||||
|
|
||||||
|
const providerTotal = systemStatus.value?.providers.total ?? 0
|
||||||
|
const providerActive = systemStatus.value?.providers.active ?? 0
|
||||||
|
const userActive = systemStatus.value?.users.active ?? 0
|
||||||
|
const userTotal = systemStatus.value?.users.total ?? 0
|
||||||
|
const providerTone = toneStyle(availabilityTone(providerActive, providerTotal))
|
||||||
|
|
||||||
|
const todayRequests = systemStatus.value?.today_stats.requests ?? 0
|
||||||
|
const todayTokens = systemStatus.value?.today_stats.tokens ?? 0
|
||||||
|
const todayCost = systemStatus.value?.today_stats.cost_usd ?? 0
|
||||||
|
const throughputTone = toneStyle(todayRequests > 0 ? 'info' : 'neutral')
|
||||||
|
|
||||||
|
const recentAlerts = systemStatus.value?.recent_errors ?? 0
|
||||||
|
const alertsTone = toneStyle(recentAlerts <= 0 && errorRate.value < 2 ? 'success' : recentAlerts < 5 && errorRate.value < 5 ? 'warning' : 'danger')
|
||||||
|
|
||||||
|
const responseAvg = latency.value.response_time_ms.avg ?? 0
|
||||||
|
const ttfbAvg = latency.value.first_byte_time_ms.avg ?? 0
|
||||||
|
const latencyStyle = toneStyle(latencyTone(responseAvg))
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'endpoint-health',
|
||||||
|
label: 'Endpoint 健康',
|
||||||
|
value: formatRatio(endpointActive, endpointTotal, healthReady),
|
||||||
|
detail: healthReady ? `异常端点 ${endpointUnhealthy}` : '健康摘要载入中',
|
||||||
|
caption: healthReady ? '当前可用端点覆盖' : '等待健康摘要返回',
|
||||||
|
badge: healthReady ? (endpointUnhealthy > 0 ? '关注中' : '稳定') : '载入中',
|
||||||
|
...endpointTone,
|
||||||
|
icon: Database,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'key-health',
|
||||||
|
label: 'Key 健康',
|
||||||
|
value: formatRatio(keyActive, keyTotal, healthReady || systemReady),
|
||||||
|
detail: healthReady ? `异常 Key ${keyUnhealthy} · 熔断 ${keyCircuitOpen}` : '健康摘要载入中',
|
||||||
|
caption: healthReady ? '密钥池可用状态' : '等待健康摘要返回',
|
||||||
|
badge: healthReady ? (keyUnhealthy > 0 || keyCircuitOpen > 0 ? '波动' : '健康') : '载入中',
|
||||||
|
...keyTone,
|
||||||
|
icon: Key,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'provider-status',
|
||||||
|
label: 'Provider 在线',
|
||||||
|
value: formatRatio(providerActive, providerTotal, systemReady),
|
||||||
|
detail: systemReady ? `活跃用户 ${userActive}/${userTotal}` : '系统状态载入中',
|
||||||
|
caption: systemReady ? '提供商接入状态' : '等待系统状态返回',
|
||||||
|
badge: systemReady ? (providerActive === providerTotal ? '在线' : '需关注') : '载入中',
|
||||||
|
...providerTone,
|
||||||
|
icon: Cpu,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'today-traffic',
|
||||||
|
label: '今日流量',
|
||||||
|
value: formatCount(todayRequests, systemReady),
|
||||||
|
detail: systemReady ? `Tokens ${formatTokens(todayTokens)}` : '系统状态载入中',
|
||||||
|
caption: systemReady ? `成本 ${formatCurrency(todayCost)}` : '等待系统状态返回',
|
||||||
|
badge: systemReady ? (todayRequests > 0 ? '活跃' : '空闲') : '载入中',
|
||||||
|
...throughputTone,
|
||||||
|
icon: Activity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recent-alerts',
|
||||||
|
label: '近 1h 告警',
|
||||||
|
value: formatCount(recentAlerts, systemReady),
|
||||||
|
detail: `当前范围失败 ${errorTotalCount.value.toLocaleString()}`,
|
||||||
|
caption: `错误率 ${errorRate.value.toFixed(1)}%`,
|
||||||
|
badge: systemReady
|
||||||
|
? recentAlerts <= 0 && errorRate.value < 2 ? '低风险' : recentAlerts < 5 && errorRate.value < 5 ? '需关注' : '偏高'
|
||||||
|
: '载入中',
|
||||||
|
...alertsTone,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'latency-baseline',
|
||||||
|
label: '延迟基线',
|
||||||
|
value: formatLatency(responseAvg),
|
||||||
|
detail: `TTFB ${formatLatency(ttfbAvg)}`,
|
||||||
|
caption: `P50 ${formatLatency(latency.value.response_time_ms.p50 ?? 0)}`,
|
||||||
|
badge: responseAvg > 0 ? (responseAvg < 1500 ? '顺畅' : responseAvg < 3000 ? '一般' : '偏慢') : '无数据',
|
||||||
|
...latencyStyle,
|
||||||
|
icon: Gauge,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const systemStatusTimestamp = computed(() => formatTimeLabel(systemStatus.value?.timestamp))
|
||||||
|
|
||||||
|
const systemMetricCards = computed<SystemMetricCardItem[]>(() => {
|
||||||
|
const metrics = systemStatus.value?.system_metrics
|
||||||
|
|
||||||
|
if (!metrics) {
|
||||||
|
const detail = systemStatus.value ? 'system_metrics 未返回' : '系统状态载入中'
|
||||||
|
const tone = toneStyle('neutral')
|
||||||
|
const progressClass = toneProgressClass('neutral')
|
||||||
|
return [
|
||||||
|
{ key: 'cpu', label: 'CPU', icon: Cpu },
|
||||||
|
{ key: 'memory', label: '内存', icon: HardDrive },
|
||||||
|
{ key: 'redis-latency', label: 'Redis', icon: Activity },
|
||||||
|
{ key: 'redis-memory', label: 'Redis 内存', icon: HardDrive },
|
||||||
|
{ key: 'postgres-pool', label: 'PostgreSQL', icon: Database },
|
||||||
|
{ key: 'postgres-storage', label: 'PostgreSQL 空间', icon: HardDrive },
|
||||||
|
].map(item => ({
|
||||||
|
...item,
|
||||||
|
value: '--',
|
||||||
|
detail,
|
||||||
|
meta: '等待监控数据',
|
||||||
|
caption: PRESSURE_CAPTION,
|
||||||
|
captionValue: fallbackPressureCaptionValue(),
|
||||||
|
badge: '未知',
|
||||||
|
progressWidth: fallbackPressureWidth(),
|
||||||
|
progressClass,
|
||||||
|
...tone,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpuTone = monitoringTone(metrics.cpu.status)
|
||||||
|
const memoryTone = monitoringTone(metrics.memory.status)
|
||||||
|
const redisTone = monitoringTone(metrics.redis.status)
|
||||||
|
const redisMemoryTone = monitoringTone(metrics.redis.memory_status ?? metrics.redis.status)
|
||||||
|
const postgresTone = monitoringTone(metrics.postgres.status)
|
||||||
|
const postgresStorageTone = monitoringTone(metrics.postgres.storage_status ?? metrics.postgres.status)
|
||||||
|
const redisMemoryCeiling = metrics.redis.memory_ceiling_bytes ?? metrics.redis.maxmemory_bytes ?? null
|
||||||
|
const redisMemorySource = metrics.redis.memory_source ?? 'unknown'
|
||||||
|
const redisMemorySourceLabel = redisMemorySource === 'configured'
|
||||||
|
? '手动容量'
|
||||||
|
: redisMemorySource === 'maxmemory'
|
||||||
|
? '上限'
|
||||||
|
: redisMemorySource === 'system'
|
||||||
|
? '估算上限'
|
||||||
|
: '未配置容量'
|
||||||
|
const cpuPressure = pressureMetric(metrics.cpu.usage_percent ?? metrics.cpu.load_percent)
|
||||||
|
const memoryPressure = pressureMetric(metrics.memory.used_percent)
|
||||||
|
const redisHealthPressure = latencyPressureValue(metrics.redis.latency_ms, metrics.redis.status)
|
||||||
|
const redisMemoryPressure = pressureMetric(metrics.redis.memory_percent ?? statusPressureValue(metrics.redis.memory_status))
|
||||||
|
const postgresPoolPressure = pressureMetric(
|
||||||
|
metrics.postgres.usage_percent ?? metrics.postgres.pool_usage_percent ?? metrics.postgres.server_usage_percent,
|
||||||
|
)
|
||||||
|
const postgresStoragePressure = storagePressureMetric(metrics.postgres.storage_free_percent)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'cpu',
|
||||||
|
label: 'CPU',
|
||||||
|
value: formatPercent(metrics.cpu.usage_percent ?? metrics.cpu.load_percent),
|
||||||
|
detail: metrics.cpu.usage_percent != null
|
||||||
|
? `实时占用 · Load ${formatPercent(metrics.cpu.load_percent)}`
|
||||||
|
: (metrics.cpu.message ?? 'CPU 监控数据不可用'),
|
||||||
|
meta: metrics.cpu.core_count > 0 ? `${metrics.cpu.core_count} 核` : '核心数未知',
|
||||||
|
caption: PRESSURE_CAPTION,
|
||||||
|
captionValue: formatPressureValue(cpuPressure),
|
||||||
|
badge: metrics.cpu.label,
|
||||||
|
progressWidth: progressWidth(cpuPressure),
|
||||||
|
progressClass: toneProgressClass(cpuTone),
|
||||||
|
...toneStyle(cpuTone),
|
||||||
|
icon: Cpu,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'memory',
|
||||||
|
label: '内存',
|
||||||
|
value: formatPercent(metrics.memory.used_percent),
|
||||||
|
detail: metrics.memory.total_bytes != null
|
||||||
|
? `${formatBytes(metrics.memory.used_bytes)} / ${formatBytes(metrics.memory.total_bytes)}`
|
||||||
|
: (metrics.memory.message ?? '内存监控数据不可用'),
|
||||||
|
meta: metrics.memory.available_bytes != null ? `可用 ${formatBytes(metrics.memory.available_bytes)}` : '等待系统指标',
|
||||||
|
caption: PRESSURE_CAPTION,
|
||||||
|
captionValue: formatPressureValue(memoryPressure),
|
||||||
|
badge: metrics.memory.label,
|
||||||
|
progressWidth: progressWidth(memoryPressure),
|
||||||
|
progressClass: toneProgressClass(memoryTone),
|
||||||
|
...toneStyle(memoryTone),
|
||||||
|
icon: HardDrive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'redis-latency',
|
||||||
|
label: 'Redis',
|
||||||
|
value: metrics.redis.latency_ms != null ? formatLatency(metrics.redis.latency_ms) : metrics.redis.label,
|
||||||
|
detail: metrics.redis.message ?? '缓存链路实时探测',
|
||||||
|
meta: metrics.redis.latency_ms != null ? 'PING 延迟' : '连接状态',
|
||||||
|
caption: PRESSURE_CAPTION,
|
||||||
|
captionValue: formatPressureValue(redisHealthPressure),
|
||||||
|
badge: metrics.redis.label,
|
||||||
|
progressWidth: progressWidth(redisHealthPressure),
|
||||||
|
progressClass: toneProgressClass(redisTone),
|
||||||
|
...toneStyle(redisTone),
|
||||||
|
icon: Activity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'redis-memory',
|
||||||
|
label: 'Redis 内存',
|
||||||
|
value: formatBytes(metrics.redis.used_memory_bytes),
|
||||||
|
detail: metrics.redis.peak_memory_bytes != null
|
||||||
|
? `峰值 ${formatBytes(metrics.redis.peak_memory_bytes)}`
|
||||||
|
: (redisMemorySource === 'unknown' ? '可在系统设置中手动填写 Redis 总内存' : '等待 Redis 内存指标'),
|
||||||
|
meta: redisMemoryCeiling != null
|
||||||
|
? `${redisMemorySourceLabel} ${formatBytes(redisMemoryCeiling)}`
|
||||||
|
: '未提供内存上限',
|
||||||
|
caption: PRESSURE_CAPTION,
|
||||||
|
captionValue: formatPressureValue(redisMemoryPressure),
|
||||||
|
badge: metrics.redis.memory_label ?? metrics.redis.label,
|
||||||
|
progressWidth: progressWidth(redisMemoryPressure),
|
||||||
|
progressClass: toneProgressClass(redisMemoryTone),
|
||||||
|
...toneStyle(redisMemoryTone),
|
||||||
|
icon: HardDrive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'postgres-pool',
|
||||||
|
label: 'PostgreSQL',
|
||||||
|
value: formatPercent(metrics.postgres.usage_percent),
|
||||||
|
detail: metrics.postgres.message
|
||||||
|
?? (metrics.postgres.server_connections != null && metrics.postgres.server_max_connections != null
|
||||||
|
? `服务端 ${formatCompactCount(metrics.postgres.server_connections)} / ${formatCompactCount(metrics.postgres.server_max_connections)} 连接`
|
||||||
|
: `${formatCompactCount(metrics.postgres.checked_out)} / ${formatCompactCount(metrics.postgres.max_capacity)} 连接占用`),
|
||||||
|
meta: metrics.postgres.server_connections != null && metrics.postgres.server_max_connections != null
|
||||||
|
? `应用池 ${formatCompactCount(metrics.postgres.checked_out)} / ${formatCompactCount(metrics.postgres.max_capacity)}`
|
||||||
|
: `池 ${formatCompactCount(metrics.postgres.pool_size)} · 溢出 ${formatCompactCount(metrics.postgres.overflow)}`,
|
||||||
|
caption: PRESSURE_CAPTION,
|
||||||
|
captionValue: formatPressureValue(postgresPoolPressure),
|
||||||
|
badge: metrics.postgres.label,
|
||||||
|
progressWidth: progressWidth(postgresPoolPressure),
|
||||||
|
progressClass: toneProgressClass(postgresTone),
|
||||||
|
...toneStyle(postgresTone),
|
||||||
|
icon: Database,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'postgres-storage',
|
||||||
|
label: 'PostgreSQL 空间',
|
||||||
|
value: formatBytes(metrics.postgres.storage_free_bytes),
|
||||||
|
detail: metrics.postgres.database_size_bytes != null
|
||||||
|
? `库体积 ${formatBytes(metrics.postgres.database_size_bytes)}`
|
||||||
|
: (metrics.postgres.storage_message ?? '等待 PostgreSQL 空间指标'),
|
||||||
|
meta: metrics.postgres.storage_total_bytes != null
|
||||||
|
? `总容量 ${formatBytes(metrics.postgres.storage_total_bytes)}`
|
||||||
|
: (metrics.postgres.storage_message ?? '数据目录总量未知'),
|
||||||
|
caption: PRESSURE_CAPTION,
|
||||||
|
captionValue: formatPressureValue(postgresStoragePressure),
|
||||||
|
badge: metrics.postgres.storage_label ?? metrics.postgres.label,
|
||||||
|
progressWidth: progressWidth(postgresStoragePressure),
|
||||||
|
progressClass: toneProgressClass(postgresStorageTone),
|
||||||
|
...toneStyle(postgresStorageTone),
|
||||||
|
icon: HardDrive,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorTrendChartData = computed<ChartData<'line'> | null>(() => {
|
||||||
|
if (!errorTrend.value.length) return null
|
||||||
|
return {
|
||||||
|
labels: errorTrend.value.map(d => d.date?.slice(5) ?? ''),
|
||||||
|
datasets: [{
|
||||||
|
label: '错误数',
|
||||||
|
data: errorTrend.value.map(d => d.total),
|
||||||
|
borderColor: 'rgb(226, 85, 118)',
|
||||||
|
backgroundColor: 'rgba(226, 85, 118, 0.16)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.34,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
|
borderWidth: 2.4,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorTrendChartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: items => errorTrend.value[items[0]?.dataIndex ?? -1]?.date ?? '',
|
||||||
|
label: context => `错误数: ${Number(context.raw ?? 0).toLocaleString()}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.06)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(100, 116, 139)',
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.07)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(100, 116, 139)',
|
||||||
|
precision: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<CardSection
|
||||||
|
title="监控容量"
|
||||||
|
description="为远程或托管 Redis / PostgreSQL 手动填写总容量,便于监控面板计算剩余空间"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="loading || !hasChanges"
|
||||||
|
@click="$emit('save')"
|
||||||
|
>
|
||||||
|
{{ loading ? '保存中...' : '保存' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="redis-memory-total-gb"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Redis 总内存 (GB)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="redis-memory-total-gb"
|
||||||
|
:model-value="redisMemoryTotalGB"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
class="mt-1"
|
||||||
|
@update:model-value="$emit('update:redisMemoryTotalGB', Number($event))"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
用于远程 Redis 未上报完整容量时计算剩余内存,0 表示继续自动探测
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="postgres-storage-total-gb"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
PostgreSQL 总空间 (GB)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="postgres-storage-total-gb"
|
||||||
|
:model-value="postgresStorageTotalGB"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
class="mt-1"
|
||||||
|
@update:model-value="$emit('update:postgresStorageTotalGB', Number($event))"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
监控面板会用“总空间 - 当前数据库体积”估算剩余空间,0 表示未设置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import Input from '@/components/ui/input.vue'
|
||||||
|
import Label from '@/components/ui/label.vue'
|
||||||
|
import { CardSection } from '@/components/layout'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
redisMemoryTotalGB: number
|
||||||
|
postgresStorageTotalGB: number
|
||||||
|
loading: boolean
|
||||||
|
hasChanges: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
save: []
|
||||||
|
'update:redisMemoryTotalGB': [value: number]
|
||||||
|
'update:postgresStorageTotalGB': [value: number]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -19,6 +19,9 @@ export interface SystemConfig {
|
|||||||
auto_delete_expired_keys: boolean
|
auto_delete_expired_keys: boolean
|
||||||
// 格式转换
|
// 格式转换
|
||||||
enable_format_conversion: boolean
|
enable_format_conversion: boolean
|
||||||
|
// 监控容量
|
||||||
|
redis_memory_total_bytes: number
|
||||||
|
postgres_storage_total_bytes: number
|
||||||
// 请求记录
|
// 请求记录
|
||||||
request_record_level: string
|
request_record_level: string
|
||||||
max_request_body_size: number
|
max_request_body_size: number
|
||||||
@@ -55,6 +58,9 @@ const CONFIG_KEYS = [
|
|||||||
'auto_delete_expired_keys',
|
'auto_delete_expired_keys',
|
||||||
// 格式转换
|
// 格式转换
|
||||||
'enable_format_conversion',
|
'enable_format_conversion',
|
||||||
|
// 监控容量
|
||||||
|
'redis_memory_total_bytes',
|
||||||
|
'postgres_storage_total_bytes',
|
||||||
// 请求记录
|
// 请求记录
|
||||||
'request_record_level',
|
'request_record_level',
|
||||||
'max_request_body_size',
|
'max_request_body_size',
|
||||||
@@ -92,6 +98,9 @@ function createDefaultConfig(): SystemConfig {
|
|||||||
auto_delete_expired_keys: false,
|
auto_delete_expired_keys: false,
|
||||||
// 格式转换
|
// 格式转换
|
||||||
enable_format_conversion: false,
|
enable_format_conversion: false,
|
||||||
|
// 监控容量
|
||||||
|
redis_memory_total_bytes: 0,
|
||||||
|
postgres_storage_total_bytes: 0,
|
||||||
// 请求记录
|
// 请求记录
|
||||||
request_record_level: 'basic',
|
request_record_level: 'basic',
|
||||||
max_request_body_size: 1048576,
|
max_request_body_size: 1048576,
|
||||||
@@ -114,6 +123,18 @@ function createDefaultConfig(): SystemConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BYTES_PER_GB = 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
function toBytesFromGB(value: number): number {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return 0
|
||||||
|
return Math.round(value * BYTES_PER_GB)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGBFromBytes(value: number): number {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return 0
|
||||||
|
return Math.round((value / BYTES_PER_GB) * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
export function useSystemConfig() {
|
export function useSystemConfig() {
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
const { refreshSiteInfo } = useSiteInfo()
|
const { refreshSiteInfo } = useSiteInfo()
|
||||||
@@ -126,6 +147,7 @@ export function useSystemConfig() {
|
|||||||
const siteInfoLoading = ref(false)
|
const siteInfoLoading = ref(false)
|
||||||
const proxyConfigLoading = ref(false)
|
const proxyConfigLoading = ref(false)
|
||||||
const basicConfigLoading = ref(false)
|
const basicConfigLoading = ref(false)
|
||||||
|
const monitoringConfigLoading = ref(false)
|
||||||
const logConfigLoading = ref(false)
|
const logConfigLoading = ref(false)
|
||||||
const cleanupConfigLoading = ref(false)
|
const cleanupConfigLoading = ref(false)
|
||||||
|
|
||||||
@@ -166,6 +188,14 @@ export function useSystemConfig() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasMonitoringConfigChanges = computed(() => {
|
||||||
|
if (!originalConfig.value) return false
|
||||||
|
return (
|
||||||
|
systemConfig.value.redis_memory_total_bytes !== originalConfig.value.redis_memory_total_bytes ||
|
||||||
|
systemConfig.value.postgres_storage_total_bytes !== originalConfig.value.postgres_storage_total_bytes
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const hasCleanupConfigChanges = computed(() => {
|
const hasCleanupConfigChanges = computed(() => {
|
||||||
if (!originalConfig.value) return false
|
if (!originalConfig.value) return false
|
||||||
return (
|
return (
|
||||||
@@ -200,6 +230,20 @@ export function useSystemConfig() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const redisMemoryTotalGB = computed({
|
||||||
|
get: () => toGBFromBytes(systemConfig.value.redis_memory_total_bytes),
|
||||||
|
set: (val: number) => {
|
||||||
|
systemConfig.value.redis_memory_total_bytes = toBytesFromGB(val)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const postgresStorageTotalGB = computed({
|
||||||
|
get: () => toGBFromBytes(systemConfig.value.postgres_storage_total_bytes),
|
||||||
|
set: (val: number) => {
|
||||||
|
systemConfig.value.postgres_storage_total_bytes = toBytesFromGB(val)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 敏感请求头数组和字符串之间的转换
|
// 敏感请求头数组和字符串之间的转换
|
||||||
const sensitiveHeadersStr = computed({
|
const sensitiveHeadersStr = computed({
|
||||||
get: () => systemConfig.value.sensitive_headers.join(', '),
|
get: () => systemConfig.value.sensitive_headers.join(', '),
|
||||||
@@ -397,6 +441,40 @@ export function useSystemConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveMonitoringConfig() {
|
||||||
|
monitoringConfigLoading.value = true
|
||||||
|
try {
|
||||||
|
const configItems = [
|
||||||
|
{
|
||||||
|
key: 'redis_memory_total_bytes',
|
||||||
|
value: systemConfig.value.redis_memory_total_bytes,
|
||||||
|
description: 'Redis 总内存容量(字节)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'postgres_storage_total_bytes',
|
||||||
|
value: systemConfig.value.postgres_storage_total_bytes,
|
||||||
|
description: 'PostgreSQL 总存储空间(字节)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
configItems.map((item) =>
|
||||||
|
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (originalConfig.value) {
|
||||||
|
originalConfig.value.redis_memory_total_bytes = systemConfig.value.redis_memory_total_bytes
|
||||||
|
originalConfig.value.postgres_storage_total_bytes = systemConfig.value.postgres_storage_total_bytes
|
||||||
|
}
|
||||||
|
success('监控容量配置已保存')
|
||||||
|
} catch (err) {
|
||||||
|
error('保存监控容量配置失败')
|
||||||
|
log.error('保存监控容量配置失败:', err)
|
||||||
|
} finally {
|
||||||
|
monitoringConfigLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveCleanupConfig() {
|
async function saveCleanupConfig() {
|
||||||
cleanupConfigLoading.value = true
|
cleanupConfigLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -497,17 +575,21 @@ export function useSystemConfig() {
|
|||||||
siteInfoLoading,
|
siteInfoLoading,
|
||||||
proxyConfigLoading,
|
proxyConfigLoading,
|
||||||
basicConfigLoading,
|
basicConfigLoading,
|
||||||
|
monitoringConfigLoading,
|
||||||
logConfigLoading,
|
logConfigLoading,
|
||||||
cleanupConfigLoading,
|
cleanupConfigLoading,
|
||||||
// 变动检测
|
// 变动检测
|
||||||
hasSiteInfoChanges,
|
hasSiteInfoChanges,
|
||||||
hasProxyConfigChanges,
|
hasProxyConfigChanges,
|
||||||
hasBasicConfigChanges,
|
hasBasicConfigChanges,
|
||||||
|
hasMonitoringConfigChanges,
|
||||||
hasLogConfigChanges,
|
hasLogConfigChanges,
|
||||||
hasCleanupConfigChanges,
|
hasCleanupConfigChanges,
|
||||||
// 计算属性
|
// 计算属性
|
||||||
maxRequestBodySizeKB,
|
maxRequestBodySizeKB,
|
||||||
maxResponseBodySizeKB,
|
maxResponseBodySizeKB,
|
||||||
|
redisMemoryTotalGB,
|
||||||
|
postgresStorageTotalGB,
|
||||||
sensitiveHeadersStr,
|
sensitiveHeadersStr,
|
||||||
// 加载函数
|
// 加载函数
|
||||||
loadSystemConfig,
|
loadSystemConfig,
|
||||||
@@ -516,6 +598,7 @@ export function useSystemConfig() {
|
|||||||
saveSiteInfo,
|
saveSiteInfo,
|
||||||
saveProxyConfig,
|
saveProxyConfig,
|
||||||
saveBasicConfig,
|
saveBasicConfig,
|
||||||
|
saveMonitoringConfig,
|
||||||
saveLogConfig,
|
saveLogConfig,
|
||||||
saveCleanupConfig,
|
saveCleanupConfig,
|
||||||
handleAutoCleanupToggle,
|
handleAutoCleanupToggle,
|
||||||
|
|||||||
+914
-1354
File diff suppressed because it is too large
Load Diff
+105
-318
@@ -1,79 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 pb-8">
|
<div class="space-y-6 pb-8">
|
||||||
<!-- 面包屑旁的折叠按钮 -->
|
<!-- 跳转数据分析链接 -->
|
||||||
<Teleport
|
<div class="text-right">
|
||||||
to="#header-actions-right"
|
<RouterLink
|
||||||
defer
|
:to="isAdminPage ? '/admin/analytics' : '/dashboard/reports'"
|
||||||
>
|
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
<button
|
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
|
||||||
:title="statsExpanded ? '收起用量分析' : '展开用量分析'"
|
|
||||||
@click="statsExpanded = !statsExpanded"
|
|
||||||
>
|
>
|
||||||
<PanelTopClose
|
{{ isAdminPage ? '查看数据分析 →' : '查看综合报表 →' }}
|
||||||
v-if="statsExpanded"
|
</RouterLink>
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
<PanelTopOpen
|
|
||||||
v-else
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- 用量分析面板(可折叠) -->
|
|
||||||
<div
|
|
||||||
v-if="statsExpanded"
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<!-- 活跃度热图 + 请求间隔时间线 -->
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
||||||
<ActivityHeatmapCard
|
|
||||||
:data="activityHeatmapData"
|
|
||||||
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
|
||||||
:is-loading="isLoadingHeatmap"
|
|
||||||
:has-error="heatmapError"
|
|
||||||
/>
|
|
||||||
<IntervalTimelineCard
|
|
||||||
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
|
|
||||||
:is-admin="isAdminPage"
|
|
||||||
:hours="24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分析统计 -->
|
|
||||||
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
|
|
||||||
<div
|
|
||||||
v-if="isAdminPage"
|
|
||||||
class="grid grid-cols-1 lg:grid-cols-3 gap-4"
|
|
||||||
>
|
|
||||||
<UsageModelTable
|
|
||||||
:data="enhancedModelStats"
|
|
||||||
:is-admin="authStore.isAdmin"
|
|
||||||
/>
|
|
||||||
<UsageProviderTable
|
|
||||||
:data="providerStats"
|
|
||||||
:is-admin="authStore.isAdmin"
|
|
||||||
/>
|
|
||||||
<UsageApiFormatTable
|
|
||||||
:data="apiFormatStats"
|
|
||||||
:is-admin="authStore.isAdmin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<!-- 用户:模型 + API格式(2列) -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="grid grid-cols-1 lg:grid-cols-2 gap-4"
|
|
||||||
>
|
|
||||||
<UsageModelTable
|
|
||||||
:data="enhancedModelStats"
|
|
||||||
:is-admin="authStore.isAdmin"
|
|
||||||
/>
|
|
||||||
<UsageApiFormatTable
|
|
||||||
:data="apiFormatStats"
|
|
||||||
:is-admin="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 使用记录 -->
|
<!-- 使用记录 -->
|
||||||
@@ -85,13 +19,17 @@
|
|||||||
:time-range="timeRange"
|
:time-range="timeRange"
|
||||||
:filter-search="filterSearch"
|
:filter-search="filterSearch"
|
||||||
:filter-user="filterUser"
|
:filter-user="filterUser"
|
||||||
|
:filter-api-key="filterApiKey"
|
||||||
:filter-model="filterModel"
|
:filter-model="filterModel"
|
||||||
:filter-provider="filterProvider"
|
:filter-provider="filterProvider"
|
||||||
:filter-api-format="filterApiFormat"
|
:filter-api-format="filterApiFormat"
|
||||||
:filter-status="filterStatus"
|
:filter-status="filterStatus"
|
||||||
:available-users="availableUsers"
|
:available-users="availableUsers"
|
||||||
|
:available-api-keys="availableApiKeys"
|
||||||
:available-models="availableModels"
|
:available-models="availableModels"
|
||||||
|
:available-api-formats="availableApiFormats"
|
||||||
:available-providers="availableProviders"
|
:available-providers="availableProviders"
|
||||||
|
:available-statuses="availableStatuses"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
:total-records="effectiveTotalRecords"
|
:total-records="effectiveTotalRecords"
|
||||||
@@ -100,6 +38,7 @@
|
|||||||
@update:time-range="handleTimeRangeChange"
|
@update:time-range="handleTimeRangeChange"
|
||||||
@update:filter-search="handleFilterSearchChange"
|
@update:filter-search="handleFilterSearchChange"
|
||||||
@update:filter-user="handleFilterUserChange"
|
@update:filter-user="handleFilterUserChange"
|
||||||
|
@update:filter-api-key="handleFilterApiKeyChange"
|
||||||
@update:filter-model="handleFilterModelChange"
|
@update:filter-model="handleFilterModelChange"
|
||||||
@update:filter-provider="handleFilterProviderChange"
|
@update:filter-provider="handleFilterProviderChange"
|
||||||
@update:filter-api-format="handleFilterApiFormatChange"
|
@update:filter-api-format="handleFilterApiFormatChange"
|
||||||
@@ -124,29 +63,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
// useLocalStorage no longer needed (analytics panel removed)
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { usageApi } from '@/api/usage'
|
import { analyticsApi, type AnalyticsFilterOption } from '@/api/analytics'
|
||||||
import { usersApi } from '@/api/users'
|
|
||||||
import { meApi } from '@/api/me'
|
|
||||||
import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next'
|
|
||||||
import {
|
import {
|
||||||
UsageModelTable,
|
|
||||||
UsageProviderTable,
|
|
||||||
UsageApiFormatTable,
|
|
||||||
UsageRecordsTable,
|
UsageRecordsTable,
|
||||||
ActivityHeatmapCard,
|
|
||||||
RequestDetailDrawer,
|
RequestDetailDrawer,
|
||||||
IntervalTimelineCard
|
|
||||||
} from '@/features/usage/components'
|
} from '@/features/usage/components'
|
||||||
import {
|
import {
|
||||||
useUsageData,
|
useUsageData,
|
||||||
getDateRangeFromPeriod
|
getDateRangeFromPeriod
|
||||||
} from '@/features/usage/composables'
|
} from '@/features/usage/composables'
|
||||||
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
|
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
|
||||||
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import type { ActivityHeatmap } from '@/types/activity'
|
// ActivityHeatmap moved to analytics page
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -156,9 +86,6 @@ const authStore = useAuthStore()
|
|||||||
// 判断是否是管理员页面
|
// 判断是否是管理员页面
|
||||||
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
||||||
|
|
||||||
// 用量分析面板折叠状态(默认展开,持久化到 localStorage)
|
|
||||||
const statsExpanded = useLocalStorage('usage-stats-expanded', true)
|
|
||||||
|
|
||||||
// 时间范围选择
|
// 时间范围选择
|
||||||
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today'))
|
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today'))
|
||||||
|
|
||||||
@@ -170,155 +97,28 @@ const pageSizeOptions = [10, 20, 50, 100]
|
|||||||
// 筛选状态
|
// 筛选状态
|
||||||
const filterSearch = ref('')
|
const filterSearch = ref('')
|
||||||
const filterUser = ref('__all__')
|
const filterUser = ref('__all__')
|
||||||
|
const filterApiKey = ref('__all__')
|
||||||
const filterModel = ref('__all__')
|
const filterModel = ref('__all__')
|
||||||
const filterProvider = ref('__all__')
|
const filterProvider = ref('__all__')
|
||||||
const filterApiFormat = ref('__all__')
|
const filterApiFormat = ref('__all__')
|
||||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||||
|
|
||||||
// 用户列表(仅管理员页面使用)
|
|
||||||
const availableUsers = ref<UserOption[]>([])
|
|
||||||
|
|
||||||
// 使用 composables
|
// 使用 composables
|
||||||
const {
|
const {
|
||||||
isLoadingRecords,
|
isLoadingRecords,
|
||||||
providerStats,
|
|
||||||
apiFormatStats,
|
|
||||||
currentRecords,
|
currentRecords,
|
||||||
totalRecords,
|
totalRecords,
|
||||||
enhancedModelStats,
|
availableUsers,
|
||||||
|
availableApiKeys,
|
||||||
availableModels,
|
availableModels,
|
||||||
|
availableApiFormats,
|
||||||
availableProviders,
|
availableProviders,
|
||||||
|
availableStatuses,
|
||||||
loadStats,
|
loadStats,
|
||||||
loadRecords
|
loadRecords
|
||||||
} = useUsageData({ isAdminPage })
|
} = useUsageData({ isAdminPage })
|
||||||
|
|
||||||
// 热力图状态
|
// 热力图已移至数据分析页
|
||||||
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
|
|
||||||
const isLoadingHeatmap = ref(false)
|
|
||||||
const heatmapError = ref(false)
|
|
||||||
const ADMIN_ANALYTICS_REFRESH_INTERVAL = 60000
|
|
||||||
let adminAnalyticsRefreshInFlight: Promise<void> | null = null
|
|
||||||
let lastAdminAnalyticsRefreshAt = 0
|
|
||||||
|
|
||||||
// 加载热力图数据
|
|
||||||
async function loadHeatmapData() {
|
|
||||||
isLoadingHeatmap.value = true
|
|
||||||
heatmapError.value = false
|
|
||||||
try {
|
|
||||||
if (isAdminPage.value) {
|
|
||||||
activityHeatmapData.value = await usageApi.getActivityHeatmap()
|
|
||||||
} else {
|
|
||||||
activityHeatmapData.value = await meApi.getActivityHeatmap()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error('加载热力图数据失败:', error)
|
|
||||||
heatmapError.value = true
|
|
||||||
} finally {
|
|
||||||
isLoadingHeatmap.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAdminUsers() {
|
|
||||||
try {
|
|
||||||
const users = await usersApi.getAllUsers()
|
|
||||||
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
|
||||||
} catch (error) {
|
|
||||||
log.error('加载用户列表失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAdminAnalytics(options: { force?: boolean } = {}) {
|
|
||||||
if (!isAdminPage.value) return
|
|
||||||
if (!options.force && !isPageVisible.value) return
|
|
||||||
|
|
||||||
const now = Date.now()
|
|
||||||
if (!options.force && now - lastAdminAnalyticsRefreshAt < ADMIN_ANALYTICS_REFRESH_INTERVAL) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (adminAnalyticsRefreshInFlight) {
|
|
||||||
return adminAnalyticsRefreshInFlight
|
|
||||||
}
|
|
||||||
|
|
||||||
adminAnalyticsRefreshInFlight = (async () => {
|
|
||||||
let hasSuccessfulRefresh = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadStats(timeRange.value)
|
|
||||||
hasSuccessfulRefresh = true
|
|
||||||
} catch (error) {
|
|
||||||
log.error('加载统计数据失败:', error)
|
|
||||||
warning('统计数据加载失败,请刷新重试')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadHeatmapData()
|
|
||||||
hasSuccessfulRefresh = true
|
|
||||||
} catch (error) {
|
|
||||||
log.error('加载热力图数据失败:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasSuccessfulRefresh) {
|
|
||||||
lastAdminAnalyticsRefreshAt = Date.now()
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await adminAnalyticsRefreshInFlight
|
|
||||||
} finally {
|
|
||||||
adminAnalyticsRefreshInFlight = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户页面需要前端筛选
|
|
||||||
const filteredRecords = computed(() => {
|
|
||||||
if (!isAdminPage.value) {
|
|
||||||
let records = [...currentRecords.value]
|
|
||||||
|
|
||||||
if (filterModel.value !== '__all__') {
|
|
||||||
records = records.filter(record => record.model === filterModel.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterProvider.value !== '__all__') {
|
|
||||||
records = records.filter(record => record.provider === filterProvider.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterApiFormat.value !== '__all__') {
|
|
||||||
records = records.filter(record =>
|
|
||||||
record.api_format?.toUpperCase() === filterApiFormat.value.toUpperCase()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterStatus.value !== '__all__') {
|
|
||||||
if (filterStatus.value === 'stream') {
|
|
||||||
records = records.filter(record =>
|
|
||||||
record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
|
||||||
)
|
|
||||||
} else if (filterStatus.value === 'standard') {
|
|
||||||
records = records.filter(record =>
|
|
||||||
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
|
||||||
)
|
|
||||||
} else if (filterStatus.value === 'active') {
|
|
||||||
records = records.filter(record =>
|
|
||||||
record.status === 'pending' || record.status === 'streaming'
|
|
||||||
)
|
|
||||||
} else if (filterStatus.value === 'failed') {
|
|
||||||
// 失败请求需要同时考虑新旧两种判断方式:
|
|
||||||
// 1. 新方式:status = "failed"
|
|
||||||
// 2. 旧方式:status_code >= 400 或 error_message 不为空
|
|
||||||
records = records.filter(record =>
|
|
||||||
record.status === 'failed' ||
|
|
||||||
(record.status_code && record.status_code >= 400) ||
|
|
||||||
record.error_message
|
|
||||||
)
|
|
||||||
} else if (filterStatus.value === 'cancelled') {
|
|
||||||
records = records.filter(record => record.status === 'cancelled')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
return currentRecords.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取活跃请求的 ID 列表
|
// 获取活跃请求的 ID 列表
|
||||||
const activeRequestIds = computed(() => {
|
const activeRequestIds = computed(() => {
|
||||||
@@ -349,11 +149,10 @@ async function pollActiveRequests() {
|
|||||||
pollInFlight = true
|
pollInFlight = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 根据页面类型选择不同的 API
|
const { requests } = await analyticsApi.getActiveRequests({
|
||||||
const idsParam = activeRequestIds.value.join(',')
|
scope: isAdminPage.value ? { kind: 'global' } : { kind: 'me' },
|
||||||
const { requests } = isAdminPage.value
|
ids: activeRequestIds.value,
|
||||||
? await usageApi.getActiveRequests(activeRequestIds.value)
|
})
|
||||||
: await meApi.getActiveRequests(idsParam)
|
|
||||||
|
|
||||||
let shouldRefresh = false
|
let shouldRefresh = false
|
||||||
|
|
||||||
@@ -512,23 +311,8 @@ onUnmounted(() => {
|
|||||||
stopGlobalAutoRefresh()
|
stopGlobalAutoRefresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 用户页面的前端分页(后端一次性返回所有记录,前端分页+筛选)
|
const paginatedRecords = computed(() => currentRecords.value)
|
||||||
const paginatedRecords = computed(() => {
|
const effectiveTotalRecords = computed(() => totalRecords.value)
|
||||||
if (!isAdminPage.value) {
|
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
|
||||||
const end = start + pageSize.value
|
|
||||||
return filteredRecords.value.slice(start, end)
|
|
||||||
}
|
|
||||||
return currentRecords.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// 用户页面使用前端筛选后的总数,管理员页面使用后端返回的总数
|
|
||||||
const effectiveTotalRecords = computed(() => {
|
|
||||||
if (!isAdminPage.value) {
|
|
||||||
return filteredRecords.value.length
|
|
||||||
}
|
|
||||||
return totalRecords.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// 显示的记录
|
// 显示的记录
|
||||||
const displayRecords = computed(() => paginatedRecords.value)
|
const displayRecords = computed(() => paginatedRecords.value)
|
||||||
@@ -538,33 +322,64 @@ const displayRecords = computed(() => paginatedRecords.value)
|
|||||||
const detailModalOpen = ref(false)
|
const detailModalOpen = ref(false)
|
||||||
const selectedRequestId = ref<string | null>(null)
|
const selectedRequestId = ref<string | null>(null)
|
||||||
|
|
||||||
|
function hasOption(options: AnalyticsFilterOption[], value: string) {
|
||||||
|
return options.some(option => option.value === value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilterSelections() {
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
if (filterUser.value !== '__all__' && !hasOption(availableUsers.value, filterUser.value)) {
|
||||||
|
filterUser.value = '__all__'
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (filterApiKey.value !== '__all__' && !hasOption(availableApiKeys.value, filterApiKey.value)) {
|
||||||
|
filterApiKey.value = '__all__'
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (filterModel.value !== '__all__' && !hasOption(availableModels.value, filterModel.value)) {
|
||||||
|
filterModel.value = '__all__'
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (filterProvider.value !== '__all__' && !hasOption(availableProviders.value, filterProvider.value)) {
|
||||||
|
filterProvider.value = '__all__'
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (filterApiFormat.value !== '__all__' && !hasOption(availableApiFormats.value, filterApiFormat.value)) {
|
||||||
|
filterApiFormat.value = '__all__'
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (filterStatus.value !== '__all__' && !hasOption(availableStatuses.value, filterStatus.value)) {
|
||||||
|
filterStatus.value = '__all__'
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncFilterOptions() {
|
||||||
|
await loadStats(timeRange.value, getCurrentFilters())
|
||||||
|
|
||||||
|
if (!normalizeFilterSelections()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadStats(timeRange.value, getCurrentFilters())
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化加载
|
// 初始化加载
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
|
||||||
if (isAdminPage.value) {
|
const statsTask = syncFilterOptions().catch(err => {
|
||||||
// 管理员页面优先加载记录,统计面板在后台顺序刷新,避免瞬时并发打满后端。
|
log.error('加载统计数据失败:', err)
|
||||||
await loadRecords(
|
warning('统计数据加载失败,请刷新重试')
|
||||||
{ page: currentPage.value, pageSize: pageSize.value },
|
})
|
||||||
getCurrentFilters(),
|
await statsTask
|
||||||
timeRange.value
|
await loadRecords(
|
||||||
)
|
{ page: currentPage.value, pageSize: pageSize.value },
|
||||||
void (async () => {
|
getCurrentFilters()
|
||||||
await refreshAdminAnalytics({ force: true })
|
)
|
||||||
await loadAdminUsers()
|
|
||||||
})()
|
|
||||||
} else {
|
|
||||||
// 用户页面:loadStats 已包含记录加载,不需要单独调用 loadRecords
|
|
||||||
await Promise.allSettled([
|
|
||||||
loadStats(timeRange.value).catch(err => {
|
|
||||||
log.error('加载统计数据失败:', err)
|
|
||||||
warning('统计数据加载失败,请刷新重试')
|
|
||||||
}),
|
|
||||||
loadHeatmapData().catch(err => {
|
|
||||||
log.error('加载热力图数据失败:', err)
|
|
||||||
})
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (globalAutoRefresh.value && isPageVisible.value) {
|
if (globalAutoRefresh.value && isPageVisible.value) {
|
||||||
startGlobalAutoRefresh()
|
startGlobalAutoRefresh()
|
||||||
@@ -575,32 +390,21 @@ onMounted(async () => {
|
|||||||
async function handleTimeRangeChange(value: DateRangeParams) {
|
async function handleTimeRangeChange(value: DateRangeParams) {
|
||||||
timeRange.value = value
|
timeRange.value = value
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
if (isAdminPage.value) {
|
await syncFilterOptions()
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
await refreshAdminAnalytics({ force: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await loadStats(timeRange.value)
|
|
||||||
// 用户页面:loadStats 已包含记录加载
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理分页变化
|
// 处理分页变化
|
||||||
async function handlePageChange(page: number) {
|
async function handlePageChange(page: number) {
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
if (isAdminPage.value) {
|
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
|
||||||
}
|
|
||||||
// 用户页面使用前端分页,无需重新请求
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理每页大小变化
|
// 处理每页大小变化
|
||||||
async function handlePageSizeChange(size: number) {
|
async function handlePageSizeChange(size: number) {
|
||||||
pageSize.value = size
|
pageSize.value = size
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
if (isAdminPage.value) {
|
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters(), timeRange.value)
|
|
||||||
}
|
|
||||||
// 用户页面使用前端分页,无需重新请求
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前筛选参数
|
// 获取当前筛选参数
|
||||||
@@ -608,6 +412,7 @@ function getCurrentFilters() {
|
|||||||
return {
|
return {
|
||||||
search: filterSearch.value.trim() || undefined,
|
search: filterSearch.value.trim() || undefined,
|
||||||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
||||||
|
api_key_id: filterApiKey.value !== '__all__' ? filterApiKey.value : undefined,
|
||||||
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||||
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
|
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
|
||||||
@@ -619,57 +424,49 @@ function getCurrentFilters() {
|
|||||||
async function handleFilterSearchChange(value: string) {
|
async function handleFilterSearchChange(value: string) {
|
||||||
filterSearch.value = value
|
filterSearch.value = value
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
|
||||||
}
|
|
||||||
// 用户页面:search 需要重新从后端拉取数据(后端支持 search 参数)
|
|
||||||
// 但通过 filteredRecords 做前端过滤已覆盖,无需额外请求
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFilterUserChange(value: string) {
|
async function handleFilterUserChange(value: string) {
|
||||||
filterUser.value = value
|
filterUser.value = value
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
await syncFilterOptions()
|
||||||
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
|
}
|
||||||
|
|
||||||
if (isAdminPage.value) {
|
async function handleFilterApiKeyChange(value: string) {
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
filterApiKey.value = value
|
||||||
}
|
currentPage.value = 1
|
||||||
|
await syncFilterOptions()
|
||||||
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFilterModelChange(value: string) {
|
async function handleFilterModelChange(value: string) {
|
||||||
filterModel.value = value
|
filterModel.value = value
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
await syncFilterOptions()
|
||||||
if (isAdminPage.value) {
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFilterProviderChange(value: string) {
|
async function handleFilterProviderChange(value: string) {
|
||||||
filterProvider.value = value
|
filterProvider.value = value
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
await syncFilterOptions()
|
||||||
if (isAdminPage.value) {
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFilterApiFormatChange(value: string) {
|
async function handleFilterApiFormatChange(value: string) {
|
||||||
filterApiFormat.value = value
|
filterApiFormat.value = value
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
await syncFilterOptions()
|
||||||
if (isAdminPage.value) {
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFilterStatusChange(value: string) {
|
async function handleFilterStatusChange(value: string) {
|
||||||
filterStatus.value = value as FilterStatusValue
|
filterStatus.value = value as FilterStatusValue
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
await syncFilterOptions()
|
||||||
if (isAdminPage.value) {
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新数据
|
// 刷新数据
|
||||||
@@ -678,18 +475,8 @@ async function refreshData() {
|
|||||||
if (refreshInFlight) return refreshInFlight
|
if (refreshInFlight) return refreshInFlight
|
||||||
|
|
||||||
refreshInFlight = (async () => {
|
refreshInFlight = (async () => {
|
||||||
if (isAdminPage.value) {
|
await syncFilterOptions()
|
||||||
await loadRecords(
|
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
{ page: currentPage.value, pageSize: pageSize.value },
|
|
||||||
getCurrentFilters(),
|
|
||||||
timeRange.value
|
|
||||||
)
|
|
||||||
void refreshAdminAnalytics()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadStats(timeRange.value)
|
|
||||||
// 用户页面:loadStats 已包含记录加载
|
|
||||||
})()
|
})()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@ dependencies = [
|
|||||||
"aiosmtplib>=5.1.0",
|
"aiosmtplib>=5.1.0",
|
||||||
"redis>=7.1.0",
|
"redis>=7.1.0",
|
||||||
"prometheus-client>=0.24.1",
|
"prometheus-client>=0.24.1",
|
||||||
|
"psutil>=5.9.8",
|
||||||
"apscheduler>=3.11.2",
|
"apscheduler>=3.11.2",
|
||||||
"ldap3>=2.9.1",
|
"ldap3>=2.9.1",
|
||||||
"msgpack>=1.1.2",
|
"msgpack>=1.1.2",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from .provider_query import router as provider_query_router
|
|||||||
from .provider_strategy import router as provider_strategy_router
|
from .provider_strategy import router as provider_strategy_router
|
||||||
from .providers import router as providers_router
|
from .providers import router as providers_router
|
||||||
from .security import router as security_router
|
from .security import router as security_router
|
||||||
from .stats import router as stats_router
|
|
||||||
from .system import router as system_router
|
from .system import router as system_router
|
||||||
from .usage import router as usage_router
|
from .usage import router as usage_router
|
||||||
from .users import router as users_router
|
from .users import router as users_router
|
||||||
@@ -39,7 +38,6 @@ router.include_router(provider_oauth_router)
|
|||||||
router.include_router(adaptive_router)
|
router.include_router(adaptive_router)
|
||||||
router.include_router(models_router)
|
router.include_router(models_router)
|
||||||
router.include_router(security_router)
|
router.include_router(security_router)
|
||||||
router.include_router(stats_router)
|
|
||||||
router.include_router(provider_query_router)
|
router.include_router(provider_query_router)
|
||||||
router.include_router(modules_router)
|
router.include_router(modules_router)
|
||||||
router.include_router(pool_router)
|
router.include_router(pool_router)
|
||||||
|
|||||||
@@ -2,14 +2,21 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy import case, func
|
from sqlalchemy import case, func, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
psutil = None
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
from src.api.base.context import ApiRequestContext
|
from src.api.base.context import ApiRequestContext
|
||||||
from src.api.base.pagination import PaginationMeta, build_pagination_payload
|
from src.api.base.pagination import PaginationMeta, build_pagination_payload
|
||||||
@@ -17,6 +24,7 @@ from src.api.base.pipeline import get_pipeline
|
|||||||
from src.config.constants import CacheTTL
|
from src.config.constants import CacheTTL
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
|
from src.database.database import get_pool_status
|
||||||
from src.models.database import (
|
from src.models.database import (
|
||||||
ApiKey,
|
ApiKey,
|
||||||
AuditEventType,
|
AuditEventType,
|
||||||
@@ -25,7 +33,9 @@ from src.models.database import (
|
|||||||
Usage,
|
Usage,
|
||||||
)
|
)
|
||||||
from src.models.database import User as DBUser
|
from src.models.database import User as DBUser
|
||||||
|
from src.clients.redis_client import get_redis_client
|
||||||
from src.services.health.monitor import HealthMonitor
|
from src.services.health.monitor import HealthMonitor
|
||||||
|
from src.services.system.config import SystemConfigService
|
||||||
from src.services.system.audit import audit_service
|
from src.services.system.audit import audit_service
|
||||||
from src.utils.cache_decorator import cache_result
|
from src.utils.cache_decorator import cache_result
|
||||||
from src.utils.database_helpers import escape_like_pattern
|
from src.utils.database_helpers import escape_like_pattern
|
||||||
@@ -34,6 +44,334 @@ router = APIRouter(prefix="/api/admin/monitoring", tags=["Admin - Monitoring"])
|
|||||||
pipeline = get_pipeline()
|
pipeline = get_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
def _unavailable_metric(message: str, **fields: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": "unknown",
|
||||||
|
"label": _label_for_status("unknown"),
|
||||||
|
**fields,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _round_metric(value: float | int | None, digits: int = 1) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return round(float(value), digits)
|
||||||
|
|
||||||
|
|
||||||
|
def _metric_status(percent: float | None, *, warning: float, danger: float) -> str:
|
||||||
|
if percent is None:
|
||||||
|
return "unknown"
|
||||||
|
if percent >= danger:
|
||||||
|
return "danger"
|
||||||
|
if percent >= warning:
|
||||||
|
return "warning"
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _remaining_status(percent: float | None, *, warning: float, danger: float) -> str:
|
||||||
|
if percent is None:
|
||||||
|
return "unknown"
|
||||||
|
if percent <= danger:
|
||||||
|
return "danger"
|
||||||
|
if percent <= warning:
|
||||||
|
return "warning"
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _label_for_status(status: str) -> str:
|
||||||
|
return {
|
||||||
|
"ok": "正常",
|
||||||
|
"warning": "偏高",
|
||||||
|
"danger": "紧张",
|
||||||
|
"degraded": "降级",
|
||||||
|
"error": "异常",
|
||||||
|
"unknown": "未知",
|
||||||
|
}.get(status, "未知")
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_int(value: Any) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_manual_capacity_bytes(db: Session, key: str) -> int | None:
|
||||||
|
value = _optional_int(SystemConfigService.get_config(db, key, default=0))
|
||||||
|
if value is None or value <= 0:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cpu_metric() -> dict[str, Any]:
|
||||||
|
core_count = int((psutil.cpu_count() if psutil is not None else os.cpu_count()) or 0)
|
||||||
|
load_percent: float | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(os, "getloadavg") and core_count > 0:
|
||||||
|
load_percent = _round_metric((os.getloadavg()[0] / core_count) * 100)
|
||||||
|
except OSError:
|
||||||
|
load_percent = None
|
||||||
|
|
||||||
|
if psutil is None:
|
||||||
|
status = _metric_status(load_percent, warning=70, danger=85)
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"label": _label_for_status(status),
|
||||||
|
"usage_percent": None,
|
||||||
|
"load_percent": load_percent,
|
||||||
|
"core_count": core_count,
|
||||||
|
"message": "psutil is not installed",
|
||||||
|
}
|
||||||
|
|
||||||
|
cpu_percent = _round_metric(psutil.cpu_percent(interval=0.05))
|
||||||
|
status = _metric_status(cpu_percent, warning=70, danger=85)
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"label": _label_for_status(status),
|
||||||
|
"usage_percent": cpu_percent,
|
||||||
|
"load_percent": load_percent,
|
||||||
|
"core_count": core_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_memory_metric() -> dict[str, Any]:
|
||||||
|
if psutil is None:
|
||||||
|
return _unavailable_metric(
|
||||||
|
"psutil is not installed",
|
||||||
|
used_percent=None,
|
||||||
|
used_bytes=None,
|
||||||
|
available_bytes=None,
|
||||||
|
total_bytes=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
memory = psutil.virtual_memory()
|
||||||
|
used_percent = _round_metric(memory.percent)
|
||||||
|
status = _metric_status(used_percent, warning=75, danger=90)
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"label": _label_for_status(status),
|
||||||
|
"used_percent": used_percent,
|
||||||
|
"used_bytes": int(memory.used),
|
||||||
|
"available_bytes": int(memory.available),
|
||||||
|
"total_bytes": int(memory.total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_redis_metric(db: Session) -> dict[str, Any]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
redis = await get_redis_client(require_redis=False)
|
||||||
|
if redis is None:
|
||||||
|
status = "degraded"
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"label": _label_for_status(status),
|
||||||
|
"latency_ms": None,
|
||||||
|
"memory_status": "unknown",
|
||||||
|
"memory_label": _label_for_status("unknown"),
|
||||||
|
"used_memory_bytes": None,
|
||||||
|
"peak_memory_bytes": None,
|
||||||
|
"maxmemory_bytes": None,
|
||||||
|
"memory_ceiling_bytes": None,
|
||||||
|
"memory_source": "unknown",
|
||||||
|
"available_memory_bytes": None,
|
||||||
|
"memory_percent": None,
|
||||||
|
"message": "Redis client not initialized",
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.ping()
|
||||||
|
latency_ms = _round_metric((time.perf_counter() - started) * 1000)
|
||||||
|
# 优先使用 Redis 自身的 INFO 输出,避免依赖应用所在机器的本地内存视角。
|
||||||
|
memory_info = await redis.info(section="memory")
|
||||||
|
used_memory_bytes = _optional_int(memory_info.get("used_memory"))
|
||||||
|
peak_memory_bytes = _optional_int(memory_info.get("used_memory_peak"))
|
||||||
|
maxmemory_bytes = _optional_int(memory_info.get("maxmemory"))
|
||||||
|
total_system_memory_bytes = _optional_int(memory_info.get("total_system_memory"))
|
||||||
|
configured_memory_bytes = _get_manual_capacity_bytes(db, "redis_memory_total_bytes")
|
||||||
|
if maxmemory_bytes is not None and maxmemory_bytes <= 0:
|
||||||
|
maxmemory_bytes = None
|
||||||
|
|
||||||
|
memory_ceiling_bytes = configured_memory_bytes or maxmemory_bytes or total_system_memory_bytes
|
||||||
|
if configured_memory_bytes is not None:
|
||||||
|
memory_source = "configured"
|
||||||
|
elif maxmemory_bytes is not None:
|
||||||
|
memory_source = "maxmemory"
|
||||||
|
elif total_system_memory_bytes is not None:
|
||||||
|
memory_source = "system"
|
||||||
|
else:
|
||||||
|
memory_source = "unknown"
|
||||||
|
available_memory_bytes = (
|
||||||
|
max(int(memory_ceiling_bytes) - int(used_memory_bytes), 0)
|
||||||
|
if memory_ceiling_bytes is not None and used_memory_bytes is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
memory_percent = _round_metric(
|
||||||
|
(used_memory_bytes / memory_ceiling_bytes * 100)
|
||||||
|
if used_memory_bytes is not None and memory_ceiling_bytes not in (None, 0)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
status = "warning" if latency_ms is not None and latency_ms >= 80 else "ok"
|
||||||
|
memory_status = _metric_status(memory_percent, warning=75, danger=90)
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"label": _label_for_status(status),
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"memory_status": memory_status,
|
||||||
|
"memory_label": _label_for_status(memory_status),
|
||||||
|
"used_memory_bytes": used_memory_bytes,
|
||||||
|
"peak_memory_bytes": peak_memory_bytes,
|
||||||
|
"maxmemory_bytes": maxmemory_bytes,
|
||||||
|
"memory_ceiling_bytes": memory_ceiling_bytes,
|
||||||
|
"memory_source": memory_source,
|
||||||
|
"available_memory_bytes": available_memory_bytes,
|
||||||
|
"memory_percent": memory_percent,
|
||||||
|
"message": None,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"label": _label_for_status("error"),
|
||||||
|
"latency_ms": None,
|
||||||
|
"memory_status": "unknown",
|
||||||
|
"memory_label": _label_for_status("unknown"),
|
||||||
|
"used_memory_bytes": None,
|
||||||
|
"peak_memory_bytes": None,
|
||||||
|
"maxmemory_bytes": None,
|
||||||
|
"memory_ceiling_bytes": None,
|
||||||
|
"memory_source": "unknown",
|
||||||
|
"available_memory_bytes": None,
|
||||||
|
"memory_percent": None,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_postgres_metric(db: Session) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
pool_status = get_pool_status()
|
||||||
|
checked_out = int(pool_status.get("checked_out") or 0)
|
||||||
|
max_capacity = int(pool_status.get("max_capacity") or 0)
|
||||||
|
pool_usage_percent = _round_metric(
|
||||||
|
(checked_out / max_capacity * 100) if max_capacity > 0 else 0
|
||||||
|
)
|
||||||
|
usage_percent = pool_usage_percent
|
||||||
|
storage_total_bytes: int | None = None
|
||||||
|
storage_free_bytes: int | None = None
|
||||||
|
storage_free_percent: float | None = None
|
||||||
|
database_size_bytes: int | None = None
|
||||||
|
server_connections: int | None = None
|
||||||
|
server_max_connections: int | None = None
|
||||||
|
server_usage_percent: float | None = None
|
||||||
|
message: str | None = None
|
||||||
|
storage_message: str | None = None
|
||||||
|
storage_total_bytes = _get_manual_capacity_bytes(db, "postgres_storage_total_bytes")
|
||||||
|
|
||||||
|
bind = db.get_bind()
|
||||||
|
dialect_name = bind.dialect.name if bind is not None else ""
|
||||||
|
if dialect_name == "postgresql":
|
||||||
|
try:
|
||||||
|
# 只读取 PostgreSQL 原生 SQL 可见的指标,避免依赖本机数据目录或宿主机磁盘。
|
||||||
|
stats = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
pg_database_size(current_database()) AS database_size_bytes,
|
||||||
|
current_setting('max_connections')::int AS server_max_connections,
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT numbackends
|
||||||
|
FROM pg_stat_database
|
||||||
|
WHERE datname = current_database()
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS server_connections
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
database_size_bytes = _optional_int(stats.get("database_size_bytes"))
|
||||||
|
server_max_connections = _optional_int(stats.get("server_max_connections"))
|
||||||
|
server_connections = _optional_int(stats.get("server_connections"))
|
||||||
|
server_usage_percent = _round_metric(
|
||||||
|
(server_connections / server_max_connections * 100)
|
||||||
|
if server_connections is not None and server_max_connections not in (None, 0)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if server_usage_percent is not None:
|
||||||
|
usage_percent = server_usage_percent
|
||||||
|
if storage_total_bytes is not None and database_size_bytes is not None:
|
||||||
|
storage_free_bytes = max(storage_total_bytes - database_size_bytes, 0)
|
||||||
|
storage_free_percent = _round_metric(
|
||||||
|
(storage_free_bytes / storage_total_bytes * 100)
|
||||||
|
if storage_total_bytes > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
storage_message = "请在系统设置中填写 PostgreSQL 总空间"
|
||||||
|
except Exception as exc:
|
||||||
|
message = f"PostgreSQL 原生指标不可用: {exc}"
|
||||||
|
if storage_total_bytes is not None:
|
||||||
|
storage_message = "已配置 PostgreSQL 总空间,但当前库体积不可用"
|
||||||
|
else:
|
||||||
|
storage_message = "请在系统设置中填写 PostgreSQL 总空间"
|
||||||
|
else:
|
||||||
|
storage_message = "当前数据库不是 PostgreSQL"
|
||||||
|
|
||||||
|
status = _metric_status(usage_percent, warning=70, danger=90)
|
||||||
|
storage_status = _remaining_status(storage_free_percent, warning=20, danger=10)
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"label": _label_for_status(status),
|
||||||
|
"usage_percent": usage_percent,
|
||||||
|
"pool_usage_percent": pool_usage_percent,
|
||||||
|
"checked_out": checked_out,
|
||||||
|
"pool_size": int(pool_status.get("pool_size") or 0),
|
||||||
|
"overflow": int(pool_status.get("overflow") or 0),
|
||||||
|
"max_capacity": max_capacity,
|
||||||
|
"pool_timeout": int(pool_status.get("pool_timeout") or 0),
|
||||||
|
"server_connections": server_connections,
|
||||||
|
"server_max_connections": server_max_connections,
|
||||||
|
"server_usage_percent": server_usage_percent,
|
||||||
|
"storage_status": storage_status,
|
||||||
|
"storage_label": _label_for_status(storage_status),
|
||||||
|
"storage_total_bytes": storage_total_bytes,
|
||||||
|
"storage_free_bytes": storage_free_bytes,
|
||||||
|
"storage_free_percent": storage_free_percent,
|
||||||
|
"database_size_bytes": database_size_bytes,
|
||||||
|
"storage_message": storage_message,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"label": _label_for_status("error"),
|
||||||
|
"usage_percent": None,
|
||||||
|
"pool_usage_percent": None,
|
||||||
|
"checked_out": 0,
|
||||||
|
"pool_size": 0,
|
||||||
|
"overflow": 0,
|
||||||
|
"max_capacity": 0,
|
||||||
|
"pool_timeout": 0,
|
||||||
|
"server_connections": None,
|
||||||
|
"server_max_connections": None,
|
||||||
|
"server_usage_percent": None,
|
||||||
|
"storage_status": "unknown",
|
||||||
|
"storage_label": _label_for_status("unknown"),
|
||||||
|
"storage_total_bytes": None,
|
||||||
|
"storage_free_bytes": None,
|
||||||
|
"storage_free_percent": None,
|
||||||
|
"database_size_bytes": None,
|
||||||
|
"storage_message": None,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/audit-logs")
|
@router.get("/audit-logs")
|
||||||
async def get_audit_logs(
|
async def get_audit_logs(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -96,6 +434,7 @@ async def get_system_status(request: Request, db: Session = Depends(get_db)) ->
|
|||||||
- `api_keys`: API Key 统计(total: 总数, active: 活跃数)
|
- `api_keys`: API Key 统计(total: 总数, active: 活跃数)
|
||||||
- `today_stats`: 今日统计(requests: 请求数, tokens: token 数, cost_usd: 成本)
|
- `today_stats`: 今日统计(requests: 请求数, tokens: token 数, cost_usd: 成本)
|
||||||
- `recent_errors`: 最近 1 小时内的错误数
|
- `recent_errors`: 最近 1 小时内的错误数
|
||||||
|
- `system_metrics`: 系统资源摘要(cpu / memory / redis / postgres)
|
||||||
"""
|
"""
|
||||||
adapter = AdminSystemStatusAdapter()
|
adapter = AdminSystemStatusAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -381,6 +720,11 @@ class AdminSystemStatusAdapter(AdminApiAdapter):
|
|||||||
recent_errors=int(recent_errors or 0),
|
recent_errors=int(recent_errors or 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cpu_metric = _build_cpu_metric()
|
||||||
|
memory_metric = _build_memory_metric()
|
||||||
|
redis_metric = await _build_redis_metric(db)
|
||||||
|
postgres_metric = _build_postgres_metric(db)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"users": {"total": total_users, "active": active_users},
|
"users": {"total": total_users, "active": active_users},
|
||||||
@@ -392,6 +736,12 @@ class AdminSystemStatusAdapter(AdminApiAdapter):
|
|||||||
"cost_usd": f"${today_cost:.4f}",
|
"cost_usd": f"${today_cost:.4f}",
|
||||||
},
|
},
|
||||||
"recent_errors": recent_errors,
|
"recent_errors": recent_errors,
|
||||||
|
"system_metrics": {
|
||||||
|
"cpu": cpu_metric,
|
||||||
|
"memory": memory_metric,
|
||||||
|
"redis": redis_metric,
|
||||||
|
"postgres": postgres_metric,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
"""Stats admin routes export."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from .comparison import router as comparison_router
|
|
||||||
from .cost import router as cost_router
|
|
||||||
from .errors import router as errors_router
|
|
||||||
from .leaderboard import router as leaderboard_router
|
|
||||||
from .performance import router as performance_router
|
|
||||||
from .quota import router as quota_router
|
|
||||||
from .time_series import router as time_series_router
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/stats", tags=["Admin - Stats"])
|
|
||||||
router.include_router(leaderboard_router)
|
|
||||||
router.include_router(time_series_router)
|
|
||||||
router.include_router(cost_router)
|
|
||||||
router.include_router(quota_router)
|
|
||||||
router.include_router(performance_router)
|
|
||||||
router.include_router(errors_router)
|
|
||||||
router.include_router(comparison_router)
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
"""Shared helpers for admin stats routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from datetime import date, datetime, time, timedelta, timezone
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy import and_, or_
|
|
||||||
|
|
||||||
from src.api.base.pipeline import get_pipeline
|
|
||||||
from src.config.settings import config
|
|
||||||
from src.models.database import Usage
|
|
||||||
from src.services.system.time_range import TimeRangeParams
|
|
||||||
|
|
||||||
pipeline = get_pipeline()
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_admin_default_range(
|
|
||||||
params: TimeRangeParams | None,
|
|
||||||
) -> TimeRangeParams | None:
|
|
||||||
"""Apply a default range to avoid unbounded scans."""
|
|
||||||
if params is not None:
|
|
||||||
return params
|
|
||||||
|
|
||||||
days = int(getattr(config, "admin_usage_default_days", 0) or 0)
|
|
||||||
if days <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
today = datetime.now(timezone.utc).date()
|
|
||||||
start_date = today - timedelta(days=days - 1)
|
|
||||||
return TimeRangeParams(
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=today,
|
|
||||||
timezone="UTC",
|
|
||||||
tz_offset_minutes=0,
|
|
||||||
).validate_and_resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_time_range_params(
|
|
||||||
start_date: date | None,
|
|
||||||
end_date: date | None,
|
|
||||||
preset: str | None,
|
|
||||||
timezone_name: str | None,
|
|
||||||
tz_offset_minutes: int | None,
|
|
||||||
) -> TimeRangeParams | None:
|
|
||||||
if not preset and start_date is None and end_date is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return TimeRangeParams(
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=end_date,
|
|
||||||
preset=preset,
|
|
||||||
timezone=timezone_name,
|
|
||||||
tz_offset_minutes=tz_offset_minutes or 0,
|
|
||||||
).validate_and_resolve()
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_filters(filters: dict[str, Any]) -> str:
|
|
||||||
raw = json.dumps(filters, sort_keys=True, ensure_ascii=False, default=str)
|
|
||||||
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
|
||||||
|
|
||||||
|
|
||||||
def _build_time_range_from_days(
|
|
||||||
days: int, timezone_name: str | None, tz_offset_minutes: int | None
|
|
||||||
) -> TimeRangeParams:
|
|
||||||
base = TimeRangeParams(
|
|
||||||
preset="today",
|
|
||||||
timezone=timezone_name,
|
|
||||||
tz_offset_minutes=tz_offset_minutes or 0,
|
|
||||||
).validate_and_resolve()
|
|
||||||
user_today = base.start_date
|
|
||||||
start_date = user_today - timedelta(days=days - 1)
|
|
||||||
return TimeRangeParams(
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=user_today,
|
|
||||||
timezone=timezone_name,
|
|
||||||
tz_offset_minutes=tz_offset_minutes or 0,
|
|
||||||
).validate_and_resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def _linear_regression(values: list[float]) -> tuple[float, float]:
|
|
||||||
n = len(values)
|
|
||||||
if n <= 1:
|
|
||||||
return 0.0, values[0] if values else 0.0
|
|
||||||
xs = list(range(n))
|
|
||||||
sum_x = sum(xs)
|
|
||||||
sum_y = sum(values)
|
|
||||||
sum_x2 = sum(x * x for x in xs)
|
|
||||||
sum_xy = sum(x * y for x, y in zip(xs, values))
|
|
||||||
denom = n * sum_x2 - sum_x * sum_x
|
|
||||||
if denom == 0:
|
|
||||||
return 0.0, values[-1]
|
|
||||||
slope = (n * sum_xy - sum_x * sum_y) / denom
|
|
||||||
intercept = (sum_y - slope * sum_x) / n
|
|
||||||
return slope, intercept
|
|
||||||
|
|
||||||
|
|
||||||
def _build_cache_key(
|
|
||||||
leaderboard_type: str,
|
|
||||||
metric: str,
|
|
||||||
time_range: TimeRangeParams | None,
|
|
||||||
filters: dict[str, Any],
|
|
||||||
) -> str:
|
|
||||||
start_value = time_range.start_date.isoformat() if time_range else "all"
|
|
||||||
end_value = time_range.end_date.isoformat() if time_range else "all"
|
|
||||||
tz_value = time_range.timezone if time_range else "utc"
|
|
||||||
offset_value = time_range.tz_offset_minutes if time_range else 0
|
|
||||||
return (
|
|
||||||
f"leaderboard:{leaderboard_type}:{metric}:{start_value}:{end_value}:"
|
|
||||||
f"{tz_value}:{offset_value}:{_hash_filters(filters)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_today_range(time_range: TimeRangeParams | None) -> bool:
|
|
||||||
if not time_range:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
user_today = time_range._get_user_today()
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
return time_range.end_date == user_today
|
|
||||||
|
|
||||||
|
|
||||||
def _split_daily_and_usage_segments(
|
|
||||||
time_range: TimeRangeParams | None,
|
|
||||||
use_daily: bool,
|
|
||||||
) -> tuple[tuple[datetime, datetime] | None, list[tuple[datetime, datetime]] | None]:
|
|
||||||
if not time_range:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
start_utc, end_utc = time_range.to_utc_datetime_range()
|
|
||||||
if not use_daily:
|
|
||||||
return None, [(start_utc, end_utc)]
|
|
||||||
|
|
||||||
complete_dates, head_boundary, tail_boundary = time_range.get_complete_utc_dates()
|
|
||||||
daily_range = None
|
|
||||||
if complete_dates:
|
|
||||||
daily_start = datetime.combine(complete_dates[0], time.min, tzinfo=timezone.utc)
|
|
||||||
daily_end = datetime.combine(
|
|
||||||
complete_dates[-1] + timedelta(days=1), time.min, tzinfo=timezone.utc
|
|
||||||
)
|
|
||||||
daily_range = (daily_start, daily_end)
|
|
||||||
|
|
||||||
usage_segments: list[tuple[datetime, datetime]] = []
|
|
||||||
if head_boundary:
|
|
||||||
usage_segments.append(head_boundary)
|
|
||||||
if tail_boundary:
|
|
||||||
usage_segments.append(tail_boundary)
|
|
||||||
|
|
||||||
if not daily_range and not usage_segments:
|
|
||||||
usage_segments = [(start_utc, end_utc)]
|
|
||||||
|
|
||||||
return daily_range, usage_segments
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_usage_time_segments(
|
|
||||||
query: Any, segments: list[tuple[datetime, datetime]] | None
|
|
||||||
) -> Any | None:
|
|
||||||
if segments is None:
|
|
||||||
return query
|
|
||||||
if not segments:
|
|
||||||
return None
|
|
||||||
|
|
||||||
conditions = []
|
|
||||||
for start_utc, end_utc in segments:
|
|
||||||
if start_utc >= end_utc:
|
|
||||||
continue
|
|
||||||
conditions.append(and_(Usage.created_at >= start_utc, Usage.created_at < end_utc))
|
|
||||||
|
|
||||||
if not conditions:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return query.filter(or_(*conditions))
|
|
||||||
|
|
||||||
|
|
||||||
def _union_queries(queries: list[Any]) -> Any | None:
|
|
||||||
base = None
|
|
||||||
for query in queries:
|
|
||||||
if query is None:
|
|
||||||
continue
|
|
||||||
if base is None:
|
|
||||||
base = query
|
|
||||||
else:
|
|
||||||
base = base.union_all(query)
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
def _metric_order(
|
|
||||||
metric: Literal["requests", "tokens", "cost"], order: Literal["asc", "desc"], expr: Any
|
|
||||||
) -> Any:
|
|
||||||
return expr.asc() if order == "asc" else expr.desc()
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
"""Admin comparison stats routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
|
||||||
from src.api.base.context import ApiRequestContext
|
|
||||||
from src.config.constants import CacheTTL
|
|
||||||
from src.database import get_db
|
|
||||||
from src.services.system.stats_aggregator import AggregatedStats, StatsFilter, query_stats_hybrid
|
|
||||||
from src.services.system.time_range import TimeRangeParams
|
|
||||||
from src.utils.cache_decorator import cache_result
|
|
||||||
|
|
||||||
from .common import pipeline
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminComparisonAdapter(AdminApiAdapter):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
current_start: date,
|
|
||||||
current_end: date,
|
|
||||||
comparison_type: Literal["period", "year"],
|
|
||||||
timezone_name: str | None,
|
|
||||||
tz_offset_minutes: int | None,
|
|
||||||
) -> None:
|
|
||||||
self.current_start = current_start
|
|
||||||
self.current_end = current_end
|
|
||||||
self.comparison_type = comparison_type
|
|
||||||
self.timezone_name = timezone_name
|
|
||||||
self.tz_offset_minutes = tz_offset_minutes
|
|
||||||
|
|
||||||
@cache_result(
|
|
||||||
key_prefix="admin:stats:comparison",
|
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
|
||||||
user_specific=False,
|
|
||||||
vary_by=[
|
|
||||||
"current_start",
|
|
||||||
"current_end",
|
|
||||||
"comparison_type",
|
|
||||||
"timezone_name",
|
|
||||||
"tz_offset_minutes",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
if self.current_start > self.current_end:
|
|
||||||
raise HTTPException(status_code=400, detail="current_start must be <= current_end")
|
|
||||||
|
|
||||||
days = (self.current_end - self.current_start).days + 1
|
|
||||||
|
|
||||||
def _safe_year_shift(value: date) -> date:
|
|
||||||
try:
|
|
||||||
return value.replace(year=value.year - 1)
|
|
||||||
except ValueError:
|
|
||||||
return value.replace(year=value.year - 1, day=28)
|
|
||||||
|
|
||||||
if self.comparison_type == "period":
|
|
||||||
comparison_end = self.current_start - timedelta(days=1)
|
|
||||||
comparison_start = comparison_end - timedelta(days=days - 1)
|
|
||||||
else:
|
|
||||||
comparison_start = _safe_year_shift(self.current_start)
|
|
||||||
comparison_end = _safe_year_shift(self.current_end)
|
|
||||||
|
|
||||||
current_range = TimeRangeParams(
|
|
||||||
start_date=self.current_start,
|
|
||||||
end_date=self.current_end,
|
|
||||||
timezone=self.timezone_name,
|
|
||||||
tz_offset_minutes=self.tz_offset_minutes or 0,
|
|
||||||
).validate_and_resolve()
|
|
||||||
comparison_range = TimeRangeParams(
|
|
||||||
start_date=comparison_start,
|
|
||||||
end_date=comparison_end,
|
|
||||||
timezone=self.timezone_name,
|
|
||||||
tz_offset_minutes=self.tz_offset_minutes or 0,
|
|
||||||
).validate_and_resolve()
|
|
||||||
|
|
||||||
current_stats = query_stats_hybrid(context.db, current_range, filters=StatsFilter())
|
|
||||||
comparison_stats = query_stats_hybrid(context.db, comparison_range, filters=StatsFilter())
|
|
||||||
|
|
||||||
def _stats_payload(stats: AggregatedStats) -> dict[str, Any]:
|
|
||||||
total_tokens = (
|
|
||||||
stats.input_tokens
|
|
||||||
+ stats.output_tokens
|
|
||||||
+ stats.cache_creation_tokens
|
|
||||||
+ stats.cache_read_tokens
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"total_requests": stats.total_requests,
|
|
||||||
"total_tokens": total_tokens,
|
|
||||||
"total_cost": float(stats.total_cost),
|
|
||||||
"actual_total_cost": float(stats.actual_total_cost),
|
|
||||||
"avg_response_time_ms": float(stats.avg_response_time_ms),
|
|
||||||
"error_requests": stats.error_requests,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _pct_change(current: float, previous: float) -> float | None:
|
|
||||||
if previous == 0:
|
|
||||||
return None if current != 0 else 0.0
|
|
||||||
return round((current - previous) / previous * 100, 2)
|
|
||||||
|
|
||||||
current_payload = _stats_payload(current_stats)
|
|
||||||
comparison_payload = _stats_payload(comparison_stats)
|
|
||||||
changes = {
|
|
||||||
key: _pct_change(float(current_payload[key]), float(comparison_payload[key]))
|
|
||||||
for key in current_payload.keys()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"current": current_payload,
|
|
||||||
"comparison": comparison_payload,
|
|
||||||
"change_percent": changes,
|
|
||||||
"current_start": self.current_start.isoformat(),
|
|
||||||
"current_end": self.current_end.isoformat(),
|
|
||||||
"comparison_start": comparison_start.isoformat(),
|
|
||||||
"comparison_end": comparison_end.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/comparison")
|
|
||||||
async def get_comparison(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_start: date = Query(...),
|
|
||||||
current_end: date = Query(...),
|
|
||||||
comparison_type: Literal["period", "year"] = Query("period"),
|
|
||||||
timezone_name: str | None = Query(None, alias="timezone"),
|
|
||||||
tz_offset_minutes: int | None = Query(0),
|
|
||||||
) -> Any:
|
|
||||||
adapter = AdminComparisonAdapter(
|
|
||||||
current_start=current_start,
|
|
||||||
current_end=current_end,
|
|
||||||
comparison_type=comparison_type,
|
|
||||||
timezone_name=timezone_name,
|
|
||||||
tz_offset_minutes=tz_offset_minutes,
|
|
||||||
)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
"""Admin cost stats routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
|
||||||
from src.api.base.context import ApiRequestContext
|
|
||||||
from src.config.constants import CacheTTL
|
|
||||||
from src.database import get_db
|
|
||||||
from src.models.database import Usage
|
|
||||||
from src.services.system.stats_aggregator import query_time_series
|
|
||||||
from src.services.system.time_range import TimeRangeParams
|
|
||||||
from src.utils.cache_decorator import cache_result
|
|
||||||
|
|
||||||
from .common import (
|
|
||||||
_apply_admin_default_range,
|
|
||||||
_build_time_range_from_days,
|
|
||||||
_build_time_range_params,
|
|
||||||
_linear_regression,
|
|
||||||
pipeline,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminCostForecastAdapter(AdminApiAdapter):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
time_range: TimeRangeParams | None,
|
|
||||||
days: int,
|
|
||||||
forecast_days: int,
|
|
||||||
timezone_name: str | None,
|
|
||||||
tz_offset_minutes: int | None,
|
|
||||||
) -> None:
|
|
||||||
self.time_range = time_range
|
|
||||||
self.days = days
|
|
||||||
self.forecast_days = forecast_days
|
|
||||||
self.timezone_name = timezone_name
|
|
||||||
self.fallback_tz_offset_minutes = tz_offset_minutes
|
|
||||||
|
|
||||||
@cache_result(
|
|
||||||
key_prefix="admin:stats:cost:forecast",
|
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
|
||||||
user_specific=False,
|
|
||||||
vary_by=[
|
|
||||||
"time_range.start_date",
|
|
||||||
"time_range.end_date",
|
|
||||||
"time_range.preset",
|
|
||||||
"time_range.timezone",
|
|
||||||
"time_range.tz_offset_minutes",
|
|
||||||
"days",
|
|
||||||
"forecast_days",
|
|
||||||
"timezone_name",
|
|
||||||
"fallback_tz_offset_minutes",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
time_range = self.time_range or _build_time_range_from_days(
|
|
||||||
self.days, self.timezone_name, self.fallback_tz_offset_minutes
|
|
||||||
)
|
|
||||||
time_range.granularity = "day"
|
|
||||||
try:
|
|
||||||
series = query_time_series(context.db, time_range)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
history = [
|
|
||||||
{"date": item["date"], "total_cost": float(item.get("total_cost", 0.0))}
|
|
||||||
for item in series
|
|
||||||
]
|
|
||||||
|
|
||||||
values = [item["total_cost"] for item in history]
|
|
||||||
slope, intercept = _linear_regression(values)
|
|
||||||
|
|
||||||
forecast = []
|
|
||||||
if history:
|
|
||||||
last_date = date.fromisoformat(history[-1]["date"])
|
|
||||||
else:
|
|
||||||
last_date = time_range.end_date
|
|
||||||
for i in range(self.forecast_days):
|
|
||||||
idx = len(values) + i
|
|
||||||
predicted = max(0.0, slope * idx + intercept)
|
|
||||||
forecast.append(
|
|
||||||
{
|
|
||||||
"date": (last_date + timedelta(days=i + 1)).isoformat(),
|
|
||||||
"total_cost": round(predicted, 4),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"history": history,
|
|
||||||
"forecast": forecast,
|
|
||||||
"slope": round(slope, 6),
|
|
||||||
"intercept": round(intercept, 6),
|
|
||||||
"start_date": time_range.start_date.isoformat(),
|
|
||||||
"end_date": time_range.end_date.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cost/forecast")
|
|
||||||
async def get_cost_forecast(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
start_date: date | None = Query(None),
|
|
||||||
end_date: date | None = Query(None),
|
|
||||||
preset: str | None = Query(None),
|
|
||||||
timezone_name: str | None = Query(None, alias="timezone"),
|
|
||||||
tz_offset_minutes: int | None = Query(0),
|
|
||||||
days: int = Query(30, ge=7, le=365),
|
|
||||||
forecast_days: int = Query(7, ge=1, le=90),
|
|
||||||
) -> Any:
|
|
||||||
time_range = _build_time_range_params(
|
|
||||||
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
|
||||||
)
|
|
||||||
adapter = AdminCostForecastAdapter(
|
|
||||||
time_range=time_range,
|
|
||||||
days=days,
|
|
||||||
forecast_days=forecast_days,
|
|
||||||
timezone_name=timezone_name,
|
|
||||||
tz_offset_minutes=tz_offset_minutes,
|
|
||||||
)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
class AdminCostSavingsAdapter(AdminApiAdapter):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
time_range: TimeRangeParams | None,
|
|
||||||
provider_name: str | None,
|
|
||||||
model: str | None,
|
|
||||||
) -> None:
|
|
||||||
self.time_range = _apply_admin_default_range(time_range)
|
|
||||||
self.provider_name = provider_name
|
|
||||||
self.model = model
|
|
||||||
|
|
||||||
@cache_result(
|
|
||||||
key_prefix="admin:stats:cost:savings",
|
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
|
||||||
user_specific=False,
|
|
||||||
vary_by=[
|
|
||||||
"time_range.start_date",
|
|
||||||
"time_range.end_date",
|
|
||||||
"time_range.preset",
|
|
||||||
"time_range.timezone",
|
|
||||||
"time_range.tz_offset_minutes",
|
|
||||||
"provider_name",
|
|
||||||
"model",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
if not self.time_range:
|
|
||||||
return {
|
|
||||||
"cache_read_tokens": 0,
|
|
||||||
"cache_read_cost": 0.0,
|
|
||||||
"cache_creation_cost": 0.0,
|
|
||||||
"estimated_full_cost": 0.0,
|
|
||||||
"cache_savings": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
|
||||||
query = context.db.query(
|
|
||||||
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
|
||||||
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
|
||||||
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
|
||||||
func.sum(
|
|
||||||
func.coalesce(Usage.output_price_per_1m, 0) * Usage.cache_read_input_tokens
|
|
||||||
).label("estimated_full_cost_raw"),
|
|
||||||
).filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
|
||||||
if self.provider_name:
|
|
||||||
query = query.filter(Usage.provider_name == self.provider_name)
|
|
||||||
if self.model:
|
|
||||||
query = query.filter(Usage.model == self.model)
|
|
||||||
|
|
||||||
row = query.first()
|
|
||||||
cache_read_tokens = int(getattr(row, "cache_read_tokens", 0) or 0)
|
|
||||||
cache_read_cost = float(getattr(row, "cache_read_cost", 0) or 0.0)
|
|
||||||
cache_creation_cost = float(getattr(row, "cache_creation_cost", 0) or 0.0)
|
|
||||||
estimated_full_cost = float(getattr(row, "estimated_full_cost_raw", 0) or 0.0) / 1_000_000
|
|
||||||
if estimated_full_cost <= 0 and cache_read_cost > 0:
|
|
||||||
estimated_full_cost = cache_read_cost * 10
|
|
||||||
cache_savings = estimated_full_cost - cache_read_cost
|
|
||||||
|
|
||||||
return {
|
|
||||||
"cache_read_tokens": cache_read_tokens,
|
|
||||||
"cache_read_cost": round(cache_read_cost, 6),
|
|
||||||
"cache_creation_cost": round(cache_creation_cost, 6),
|
|
||||||
"estimated_full_cost": round(estimated_full_cost, 6),
|
|
||||||
"cache_savings": round(cache_savings, 6),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cost/savings")
|
|
||||||
async def get_cost_savings(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
start_date: date | None = Query(None),
|
|
||||||
end_date: date | None = Query(None),
|
|
||||||
preset: str | None = Query(None),
|
|
||||||
timezone_name: str | None = Query(None, alias="timezone"),
|
|
||||||
tz_offset_minutes: int | None = Query(0),
|
|
||||||
provider_name: str | None = Query(None),
|
|
||||||
model: str | None = Query(None),
|
|
||||||
) -> Any:
|
|
||||||
time_range = _build_time_range_params(
|
|
||||||
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
|
||||||
)
|
|
||||||
adapter = AdminCostSavingsAdapter(
|
|
||||||
time_range=time_range, provider_name=provider_name, model=model
|
|
||||||
)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"""Admin error stats routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
|
||||||
from src.api.base.context import ApiRequestContext
|
|
||||||
from src.config.constants import CacheTTL
|
|
||||||
from src.database import get_db
|
|
||||||
from src.models.database import StatsDailyError, Usage
|
|
||||||
from src.services.system.time_range import TimeRangeParams
|
|
||||||
from src.utils.cache_decorator import cache_result
|
|
||||||
|
|
||||||
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminErrorDistributionAdapter(AdminApiAdapter):
|
|
||||||
def __init__(self, time_range: TimeRangeParams | None) -> None:
|
|
||||||
self.time_range = _apply_admin_default_range(time_range)
|
|
||||||
|
|
||||||
@cache_result(
|
|
||||||
key_prefix="admin:stats:errors:distribution",
|
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
|
||||||
user_specific=False,
|
|
||||||
vary_by=[
|
|
||||||
"time_range.start_date",
|
|
||||||
"time_range.end_date",
|
|
||||||
"time_range.preset",
|
|
||||||
"time_range.timezone",
|
|
||||||
"time_range.tz_offset_minutes",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
if not self.time_range:
|
|
||||||
return {"distribution": [], "trend": []}
|
|
||||||
|
|
||||||
time_range = self.time_range
|
|
||||||
is_utc = (time_range.timezone in {None, "UTC"}) and time_range.tz_offset_minutes == 0
|
|
||||||
distribution: dict[str, int] = {}
|
|
||||||
trend: dict[str, dict[str, int]] = {}
|
|
||||||
|
|
||||||
if is_utc:
|
|
||||||
start_utc, end_utc = time_range.to_utc_datetime_range()
|
|
||||||
rows = (
|
|
||||||
context.db.query(StatsDailyError)
|
|
||||||
.filter(StatsDailyError.date >= start_utc, StatsDailyError.date < end_utc)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
for row in rows:
|
|
||||||
date_str = (
|
|
||||||
row.date.astimezone(timezone.utc).date().isoformat()
|
|
||||||
if row.date.tzinfo
|
|
||||||
else row.date.date().isoformat()
|
|
||||||
)
|
|
||||||
distribution[row.error_category] = distribution.get(row.error_category, 0) + int(
|
|
||||||
row.count or 0
|
|
||||||
)
|
|
||||||
trend.setdefault(date_str, {})
|
|
||||||
trend[date_str][row.error_category] = trend[date_str].get(
|
|
||||||
row.error_category, 0
|
|
||||||
) + int(row.count or 0)
|
|
||||||
else:
|
|
||||||
for local_date, day_start_utc, day_end_utc in time_range.get_local_day_hours():
|
|
||||||
rows = (
|
|
||||||
context.db.query(
|
|
||||||
Usage.error_category,
|
|
||||||
func.count(Usage.id).label("count"),
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
Usage.created_at >= day_start_utc,
|
|
||||||
Usage.created_at < day_end_utc,
|
|
||||||
Usage.error_category.isnot(None),
|
|
||||||
)
|
|
||||||
.group_by(Usage.error_category)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
date_str = local_date.isoformat()
|
|
||||||
for row in rows:
|
|
||||||
distribution[row.error_category] = distribution.get(
|
|
||||||
row.error_category, 0
|
|
||||||
) + int(row.count or 0)
|
|
||||||
trend.setdefault(date_str, {})
|
|
||||||
trend[date_str][row.error_category] = trend[date_str].get(
|
|
||||||
row.error_category, 0
|
|
||||||
) + int(row.count or 0)
|
|
||||||
|
|
||||||
trend_items = []
|
|
||||||
for day in sorted(trend.keys()):
|
|
||||||
counts = trend[day]
|
|
||||||
total = sum(counts.values())
|
|
||||||
trend_items.append({"date": day, "total": total, "categories": counts})
|
|
||||||
|
|
||||||
distribution_items = [
|
|
||||||
{"category": category, "count": count}
|
|
||||||
for category, count in sorted(
|
|
||||||
distribution.items(), key=lambda item: item[1], reverse=True
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return {"distribution": distribution_items, "trend": trend_items}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/errors/distribution")
|
|
||||||
async def get_error_distribution(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
start_date: date | None = Query(None),
|
|
||||||
end_date: date | None = Query(None),
|
|
||||||
preset: str | None = Query(None),
|
|
||||||
timezone_name: str | None = Query(None, alias="timezone"),
|
|
||||||
tz_offset_minutes: int | None = Query(0),
|
|
||||||
) -> Any:
|
|
||||||
time_range = _build_time_range_params(
|
|
||||||
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
|
||||||
)
|
|
||||||
adapter = AdminErrorDistributionAdapter(time_range=time_range)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
@@ -1,759 +0,0 @@
|
|||||||
"""Admin leaderboard stats routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import date
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
|
||||||
from src.api.base.context import ApiRequestContext
|
|
||||||
from src.clients.redis_client import get_redis_client_sync
|
|
||||||
from src.config.constants import CacheTTL
|
|
||||||
from src.core.enums import UserRole
|
|
||||||
from src.database import get_db
|
|
||||||
from src.models.database import (
|
|
||||||
ApiKey,
|
|
||||||
StatsDailyApiKey,
|
|
||||||
StatsDailyModel,
|
|
||||||
StatsUserDaily,
|
|
||||||
Usage,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from src.services.system.time_range import TimeRangeParams
|
|
||||||
|
|
||||||
from .common import (
|
|
||||||
_apply_admin_default_range,
|
|
||||||
_apply_usage_time_segments,
|
|
||||||
_build_cache_key,
|
|
||||||
_build_time_range_params,
|
|
||||||
_is_today_range,
|
|
||||||
_metric_order,
|
|
||||||
_split_daily_and_usage_segments,
|
|
||||||
_union_queries,
|
|
||||||
pipeline,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminUserLeaderboardAdapter(AdminApiAdapter):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
time_range: TimeRangeParams | None,
|
|
||||||
metric: Literal["requests", "tokens", "cost"],
|
|
||||||
order: Literal["asc", "desc"],
|
|
||||||
limit: int,
|
|
||||||
offset: int,
|
|
||||||
provider_name: str | None,
|
|
||||||
model: str | None,
|
|
||||||
include_inactive: bool,
|
|
||||||
exclude_admin: bool,
|
|
||||||
) -> None:
|
|
||||||
self.time_range = _apply_admin_default_range(time_range)
|
|
||||||
self.metric = metric
|
|
||||||
self.order = order
|
|
||||||
self.limit = limit
|
|
||||||
self.offset = offset
|
|
||||||
self.provider_name = provider_name
|
|
||||||
self.model = model
|
|
||||||
self.include_inactive = include_inactive
|
|
||||||
self.exclude_admin = exclude_admin
|
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
db = context.db
|
|
||||||
cacheable = not _is_today_range(self.time_range)
|
|
||||||
redis_client = get_redis_client_sync()
|
|
||||||
cache_key = None
|
|
||||||
if cacheable and redis_client:
|
|
||||||
cache_key = _build_cache_key(
|
|
||||||
"users",
|
|
||||||
self.metric,
|
|
||||||
self.time_range,
|
|
||||||
{
|
|
||||||
"order": self.order,
|
|
||||||
"limit": self.limit,
|
|
||||||
"offset": self.offset,
|
|
||||||
"provider_name": self.provider_name,
|
|
||||||
"model": self.model,
|
|
||||||
"include_inactive": self.include_inactive,
|
|
||||||
"exclude_admin": self.exclude_admin,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
cached = await redis_client.get(cache_key)
|
|
||||||
if cached:
|
|
||||||
try:
|
|
||||||
return json.loads(cached)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
use_daily = self.time_range is not None and not self.provider_name and not self.model
|
|
||||||
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
|
|
||||||
|
|
||||||
daily_query = None
|
|
||||||
if daily_range:
|
|
||||||
daily_query = (
|
|
||||||
db.query(
|
|
||||||
StatsUserDaily.user_id.label("entity_id"),
|
|
||||||
func.sum(StatsUserDaily.total_requests).label("requests"),
|
|
||||||
func.sum(
|
|
||||||
StatsUserDaily.input_tokens
|
|
||||||
+ StatsUserDaily.output_tokens
|
|
||||||
+ StatsUserDaily.cache_creation_tokens
|
|
||||||
+ StatsUserDaily.cache_read_tokens
|
|
||||||
).label("tokens"),
|
|
||||||
func.sum(StatsUserDaily.total_cost).label("cost"),
|
|
||||||
)
|
|
||||||
.filter(StatsUserDaily.date >= daily_range[0], StatsUserDaily.date < daily_range[1])
|
|
||||||
.group_by(StatsUserDaily.user_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
usage_query = db.query(
|
|
||||||
Usage.user_id.label("entity_id"),
|
|
||||||
func.count(Usage.id).label("requests"),
|
|
||||||
func.sum(
|
|
||||||
Usage.input_tokens
|
|
||||||
+ Usage.output_tokens
|
|
||||||
+ Usage.cache_creation_input_tokens
|
|
||||||
+ Usage.cache_read_input_tokens
|
|
||||||
).label("tokens"),
|
|
||||||
func.sum(Usage.total_cost_usd).label("cost"),
|
|
||||||
).filter(
|
|
||||||
Usage.user_id.isnot(None),
|
|
||||||
Usage.status.notin_(["pending", "streaming"]),
|
|
||||||
Usage.provider_name.notin_(["unknown", "pending"]),
|
|
||||||
)
|
|
||||||
if self.provider_name:
|
|
||||||
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
|
|
||||||
if self.model:
|
|
||||||
usage_query = usage_query.filter(Usage.model == self.model)
|
|
||||||
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
|
|
||||||
if usage_query is not None:
|
|
||||||
usage_query = usage_query.group_by(Usage.user_id)
|
|
||||||
|
|
||||||
union_query = _union_queries([daily_query, usage_query])
|
|
||||||
if union_query is None:
|
|
||||||
return {
|
|
||||||
"items": [],
|
|
||||||
"total": 0,
|
|
||||||
"metric": self.metric,
|
|
||||||
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
|
|
||||||
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
union_subq = union_query.subquery()
|
|
||||||
agg_subq = (
|
|
||||||
db.query(
|
|
||||||
union_subq.c.entity_id.label("entity_id"),
|
|
||||||
func.sum(union_subq.c.requests).label("requests"),
|
|
||||||
func.sum(union_subq.c.tokens).label("tokens"),
|
|
||||||
func.sum(union_subq.c.cost).label("cost"),
|
|
||||||
)
|
|
||||||
.group_by(union_subq.c.entity_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
base_query = (
|
|
||||||
db.query(
|
|
||||||
User.id.label("id"),
|
|
||||||
User.username,
|
|
||||||
User.email,
|
|
||||||
agg_subq.c.requests,
|
|
||||||
agg_subq.c.tokens,
|
|
||||||
agg_subq.c.cost,
|
|
||||||
)
|
|
||||||
.join(agg_subq, agg_subq.c.entity_id == User.id)
|
|
||||||
.filter(User.is_deleted.is_(False))
|
|
||||||
)
|
|
||||||
if not self.include_inactive:
|
|
||||||
base_query = base_query.filter(User.is_active.is_(True))
|
|
||||||
if self.exclude_admin:
|
|
||||||
base_query = base_query.filter(User.role != UserRole.ADMIN)
|
|
||||||
|
|
||||||
metric_expr = {
|
|
||||||
"requests": agg_subq.c.requests,
|
|
||||||
"tokens": agg_subq.c.tokens,
|
|
||||||
"cost": agg_subq.c.cost,
|
|
||||||
}[self.metric]
|
|
||||||
order_expr = _metric_order(self.metric, self.order, metric_expr)
|
|
||||||
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
|
|
||||||
|
|
||||||
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
|
|
||||||
rows = (
|
|
||||||
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
|
|
||||||
.order_by(order_expr)
|
|
||||||
.offset(self.offset)
|
|
||||||
.limit(self.limit)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for row in rows:
|
|
||||||
name = row.username or row.email or str(row.id)
|
|
||||||
value = row.metric_value or 0
|
|
||||||
if self.metric in {"requests", "tokens"}:
|
|
||||||
value = int(value)
|
|
||||||
else:
|
|
||||||
value = float(value)
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"rank": int(row.rank),
|
|
||||||
"id": row.id,
|
|
||||||
"name": name,
|
|
||||||
"value": value,
|
|
||||||
"requests": int(row.requests or 0),
|
|
||||||
"tokens": int(row.tokens or 0),
|
|
||||||
"cost": float(row.cost or 0.0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context.add_audit_metadata(
|
|
||||||
action="leaderboard_users",
|
|
||||||
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
|
|
||||||
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
|
|
||||||
preset=self.time_range.preset if self.time_range else None,
|
|
||||||
timezone=self.time_range.timezone if self.time_range else None,
|
|
||||||
metric=self.metric,
|
|
||||||
order=self.order,
|
|
||||||
limit=self.limit,
|
|
||||||
offset=self.offset,
|
|
||||||
provider_name=self.provider_name,
|
|
||||||
model=self.model,
|
|
||||||
include_inactive=self.include_inactive,
|
|
||||||
exclude_admin=self.exclude_admin,
|
|
||||||
result_count=len(items),
|
|
||||||
total=total,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"metric": self.metric,
|
|
||||||
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
|
|
||||||
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if cacheable and redis_client and cache_key:
|
|
||||||
try:
|
|
||||||
await redis_client.setex(
|
|
||||||
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class AdminApiKeyLeaderboardAdapter(AdminApiAdapter):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
time_range: TimeRangeParams | None,
|
|
||||||
metric: Literal["requests", "tokens", "cost"],
|
|
||||||
order: Literal["asc", "desc"],
|
|
||||||
limit: int,
|
|
||||||
offset: int,
|
|
||||||
provider_name: str | None,
|
|
||||||
model: str | None,
|
|
||||||
include_inactive: bool,
|
|
||||||
exclude_admin: bool,
|
|
||||||
) -> None:
|
|
||||||
self.time_range = _apply_admin_default_range(time_range)
|
|
||||||
self.metric = metric
|
|
||||||
self.order = order
|
|
||||||
self.limit = limit
|
|
||||||
self.offset = offset
|
|
||||||
self.provider_name = provider_name
|
|
||||||
self.model = model
|
|
||||||
self.include_inactive = include_inactive
|
|
||||||
self.exclude_admin = exclude_admin
|
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
db = context.db
|
|
||||||
cacheable = not _is_today_range(self.time_range)
|
|
||||||
redis_client = get_redis_client_sync()
|
|
||||||
cache_key = None
|
|
||||||
if cacheable and redis_client:
|
|
||||||
cache_key = _build_cache_key(
|
|
||||||
"api_keys",
|
|
||||||
self.metric,
|
|
||||||
self.time_range,
|
|
||||||
{
|
|
||||||
"order": self.order,
|
|
||||||
"limit": self.limit,
|
|
||||||
"offset": self.offset,
|
|
||||||
"provider_name": self.provider_name,
|
|
||||||
"model": self.model,
|
|
||||||
"include_inactive": self.include_inactive,
|
|
||||||
"exclude_admin": self.exclude_admin,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
cached = await redis_client.get(cache_key)
|
|
||||||
if cached:
|
|
||||||
try:
|
|
||||||
return json.loads(cached)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
use_daily = self.time_range is not None and not self.provider_name and not self.model
|
|
||||||
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
|
|
||||||
|
|
||||||
daily_query = None
|
|
||||||
if daily_range:
|
|
||||||
daily_query = (
|
|
||||||
db.query(
|
|
||||||
StatsDailyApiKey.api_key_id.label("entity_id"),
|
|
||||||
func.sum(StatsDailyApiKey.total_requests).label("requests"),
|
|
||||||
func.sum(
|
|
||||||
StatsDailyApiKey.input_tokens
|
|
||||||
+ StatsDailyApiKey.output_tokens
|
|
||||||
+ StatsDailyApiKey.cache_creation_tokens
|
|
||||||
+ StatsDailyApiKey.cache_read_tokens
|
|
||||||
).label("tokens"),
|
|
||||||
func.sum(StatsDailyApiKey.total_cost).label("cost"),
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
StatsDailyApiKey.date >= daily_range[0], StatsDailyApiKey.date < daily_range[1]
|
|
||||||
)
|
|
||||||
.group_by(StatsDailyApiKey.api_key_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
usage_query = db.query(
|
|
||||||
Usage.api_key_id.label("entity_id"),
|
|
||||||
func.count(Usage.id).label("requests"),
|
|
||||||
func.sum(
|
|
||||||
Usage.input_tokens
|
|
||||||
+ Usage.output_tokens
|
|
||||||
+ Usage.cache_creation_input_tokens
|
|
||||||
+ Usage.cache_read_input_tokens
|
|
||||||
).label("tokens"),
|
|
||||||
func.sum(Usage.total_cost_usd).label("cost"),
|
|
||||||
).filter(
|
|
||||||
Usage.api_key_id.isnot(None),
|
|
||||||
Usage.status.notin_(["pending", "streaming"]),
|
|
||||||
Usage.provider_name.notin_(["unknown", "pending"]),
|
|
||||||
)
|
|
||||||
if self.provider_name:
|
|
||||||
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
|
|
||||||
if self.model:
|
|
||||||
usage_query = usage_query.filter(Usage.model == self.model)
|
|
||||||
|
|
||||||
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
|
|
||||||
if usage_query is None:
|
|
||||||
return {
|
|
||||||
"items": [],
|
|
||||||
"total": 0,
|
|
||||||
"metric": self.metric,
|
|
||||||
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
|
|
||||||
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
|
|
||||||
}
|
|
||||||
usage_query = usage_query.group_by(Usage.api_key_id)
|
|
||||||
|
|
||||||
union_query = _union_queries([daily_query, usage_query])
|
|
||||||
if union_query is None:
|
|
||||||
return {
|
|
||||||
"items": [],
|
|
||||||
"total": 0,
|
|
||||||
"metric": self.metric,
|
|
||||||
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
|
|
||||||
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
union_subq = union_query.subquery()
|
|
||||||
agg_subq = (
|
|
||||||
db.query(
|
|
||||||
union_subq.c.entity_id.label("entity_id"),
|
|
||||||
func.sum(union_subq.c.requests).label("requests"),
|
|
||||||
func.sum(union_subq.c.tokens).label("tokens"),
|
|
||||||
func.sum(union_subq.c.cost).label("cost"),
|
|
||||||
)
|
|
||||||
.group_by(union_subq.c.entity_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
base_query = (
|
|
||||||
db.query(
|
|
||||||
ApiKey,
|
|
||||||
User,
|
|
||||||
agg_subq.c.requests,
|
|
||||||
agg_subq.c.tokens,
|
|
||||||
agg_subq.c.cost,
|
|
||||||
)
|
|
||||||
.join(agg_subq, agg_subq.c.entity_id == ApiKey.id)
|
|
||||||
.join(User, User.id == ApiKey.user_id)
|
|
||||||
.filter(User.is_deleted.is_(False))
|
|
||||||
)
|
|
||||||
if not self.include_inactive:
|
|
||||||
base_query = base_query.filter(ApiKey.is_active.is_(True))
|
|
||||||
if self.exclude_admin:
|
|
||||||
base_query = base_query.filter(User.role != UserRole.ADMIN)
|
|
||||||
|
|
||||||
metric_expr = {
|
|
||||||
"requests": agg_subq.c.requests,
|
|
||||||
"tokens": agg_subq.c.tokens,
|
|
||||||
"cost": agg_subq.c.cost,
|
|
||||||
}[self.metric]
|
|
||||||
order_expr = _metric_order(self.metric, self.order, metric_expr)
|
|
||||||
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
|
|
||||||
|
|
||||||
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
|
|
||||||
rows = (
|
|
||||||
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
|
|
||||||
.order_by(order_expr)
|
|
||||||
.offset(self.offset)
|
|
||||||
.limit(self.limit)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for row in rows:
|
|
||||||
api_key = row.ApiKey
|
|
||||||
name = api_key.name or api_key.get_display_key()
|
|
||||||
value = row.metric_value or 0
|
|
||||||
if self.metric in {"requests", "tokens"}:
|
|
||||||
value = int(value)
|
|
||||||
else:
|
|
||||||
value = float(value)
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"rank": int(row.rank),
|
|
||||||
"id": api_key.id,
|
|
||||||
"name": name,
|
|
||||||
"value": value,
|
|
||||||
"requests": int(row.requests or 0),
|
|
||||||
"tokens": int(row.tokens or 0),
|
|
||||||
"cost": float(row.cost or 0.0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context.add_audit_metadata(
|
|
||||||
action="leaderboard_api_keys",
|
|
||||||
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
|
|
||||||
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
|
|
||||||
preset=self.time_range.preset if self.time_range else None,
|
|
||||||
timezone=self.time_range.timezone if self.time_range else None,
|
|
||||||
metric=self.metric,
|
|
||||||
order=self.order,
|
|
||||||
limit=self.limit,
|
|
||||||
offset=self.offset,
|
|
||||||
provider_name=self.provider_name,
|
|
||||||
model=self.model,
|
|
||||||
include_inactive=self.include_inactive,
|
|
||||||
exclude_admin=self.exclude_admin,
|
|
||||||
result_count=len(items),
|
|
||||||
total=total,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"metric": self.metric,
|
|
||||||
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
|
|
||||||
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if cacheable and redis_client and cache_key:
|
|
||||||
try:
|
|
||||||
await redis_client.setex(
|
|
||||||
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class AdminModelLeaderboardAdapter(AdminApiAdapter):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
time_range: TimeRangeParams | None,
|
|
||||||
metric: Literal["requests", "tokens", "cost"],
|
|
||||||
order: Literal["asc", "desc"],
|
|
||||||
limit: int,
|
|
||||||
offset: int,
|
|
||||||
provider_name: str | None,
|
|
||||||
model: str | None,
|
|
||||||
) -> None:
|
|
||||||
self.time_range = _apply_admin_default_range(time_range)
|
|
||||||
self.metric = metric
|
|
||||||
self.order = order
|
|
||||||
self.limit = limit
|
|
||||||
self.offset = offset
|
|
||||||
self.provider_name = provider_name
|
|
||||||
self.model = model
|
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
db = context.db
|
|
||||||
cacheable = not _is_today_range(self.time_range)
|
|
||||||
redis_client = get_redis_client_sync()
|
|
||||||
cache_key = None
|
|
||||||
if cacheable and redis_client:
|
|
||||||
cache_key = _build_cache_key(
|
|
||||||
"models",
|
|
||||||
self.metric,
|
|
||||||
self.time_range,
|
|
||||||
{
|
|
||||||
"order": self.order,
|
|
||||||
"limit": self.limit,
|
|
||||||
"offset": self.offset,
|
|
||||||
"provider_name": self.provider_name,
|
|
||||||
"model": self.model,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
cached = await redis_client.get(cache_key)
|
|
||||||
if cached:
|
|
||||||
try:
|
|
||||||
return json.loads(cached)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
use_daily = self.time_range is not None and not self.provider_name
|
|
||||||
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
|
|
||||||
|
|
||||||
daily_query = None
|
|
||||||
if daily_range:
|
|
||||||
daily_query = (
|
|
||||||
db.query(
|
|
||||||
StatsDailyModel.model.label("entity_id"),
|
|
||||||
func.sum(StatsDailyModel.total_requests).label("requests"),
|
|
||||||
func.sum(
|
|
||||||
StatsDailyModel.input_tokens
|
|
||||||
+ StatsDailyModel.output_tokens
|
|
||||||
+ StatsDailyModel.cache_creation_tokens
|
|
||||||
+ StatsDailyModel.cache_read_tokens
|
|
||||||
).label("tokens"),
|
|
||||||
func.sum(StatsDailyModel.total_cost).label("cost"),
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
StatsDailyModel.date >= daily_range[0], StatsDailyModel.date < daily_range[1]
|
|
||||||
)
|
|
||||||
.group_by(StatsDailyModel.model)
|
|
||||||
)
|
|
||||||
if self.model:
|
|
||||||
daily_query = daily_query.filter(StatsDailyModel.model == self.model)
|
|
||||||
|
|
||||||
usage_query = db.query(
|
|
||||||
Usage.model.label("entity_id"),
|
|
||||||
func.count(Usage.id).label("requests"),
|
|
||||||
func.sum(
|
|
||||||
Usage.input_tokens
|
|
||||||
+ Usage.output_tokens
|
|
||||||
+ Usage.cache_creation_input_tokens
|
|
||||||
+ Usage.cache_read_input_tokens
|
|
||||||
).label("tokens"),
|
|
||||||
func.sum(Usage.total_cost_usd).label("cost"),
|
|
||||||
).filter(
|
|
||||||
Usage.status.notin_(["pending", "streaming"]),
|
|
||||||
Usage.provider_name.notin_(["unknown", "pending"]),
|
|
||||||
)
|
|
||||||
if self.provider_name:
|
|
||||||
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
|
|
||||||
if self.model:
|
|
||||||
usage_query = usage_query.filter(Usage.model == self.model)
|
|
||||||
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
|
|
||||||
if usage_query is not None:
|
|
||||||
usage_query = usage_query.group_by(Usage.model)
|
|
||||||
|
|
||||||
union_query = _union_queries([daily_query, usage_query])
|
|
||||||
if union_query is None:
|
|
||||||
return {
|
|
||||||
"items": [],
|
|
||||||
"total": 0,
|
|
||||||
"metric": self.metric,
|
|
||||||
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
|
|
||||||
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
union_subq = union_query.subquery()
|
|
||||||
agg_subq = (
|
|
||||||
db.query(
|
|
||||||
union_subq.c.entity_id.label("entity_id"),
|
|
||||||
func.sum(union_subq.c.requests).label("requests"),
|
|
||||||
func.sum(union_subq.c.tokens).label("tokens"),
|
|
||||||
func.sum(union_subq.c.cost).label("cost"),
|
|
||||||
)
|
|
||||||
.group_by(union_subq.c.entity_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
base_query = db.query(
|
|
||||||
agg_subq.c.entity_id.label("id"),
|
|
||||||
agg_subq.c.entity_id.label("name"),
|
|
||||||
agg_subq.c.requests,
|
|
||||||
agg_subq.c.tokens,
|
|
||||||
agg_subq.c.cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
metric_expr = {
|
|
||||||
"requests": agg_subq.c.requests,
|
|
||||||
"tokens": agg_subq.c.tokens,
|
|
||||||
"cost": agg_subq.c.cost,
|
|
||||||
}[self.metric]
|
|
||||||
order_expr = _metric_order(self.metric, self.order, metric_expr)
|
|
||||||
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
|
|
||||||
|
|
||||||
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
|
|
||||||
rows = (
|
|
||||||
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
|
|
||||||
.order_by(order_expr)
|
|
||||||
.offset(self.offset)
|
|
||||||
.limit(self.limit)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for row in rows:
|
|
||||||
value = row.metric_value or 0
|
|
||||||
if self.metric in {"requests", "tokens"}:
|
|
||||||
value = int(value)
|
|
||||||
else:
|
|
||||||
value = float(value)
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"rank": int(row.rank),
|
|
||||||
"id": row.id,
|
|
||||||
"name": row.name,
|
|
||||||
"value": value,
|
|
||||||
"requests": int(row.requests or 0),
|
|
||||||
"tokens": int(row.tokens or 0),
|
|
||||||
"cost": float(row.cost or 0.0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context.add_audit_metadata(
|
|
||||||
action="leaderboard_models",
|
|
||||||
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
|
|
||||||
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
|
|
||||||
preset=self.time_range.preset if self.time_range else None,
|
|
||||||
timezone=self.time_range.timezone if self.time_range else None,
|
|
||||||
metric=self.metric,
|
|
||||||
order=self.order,
|
|
||||||
limit=self.limit,
|
|
||||||
offset=self.offset,
|
|
||||||
provider_name=self.provider_name,
|
|
||||||
model=self.model,
|
|
||||||
result_count=len(items),
|
|
||||||
total=total,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"metric": self.metric,
|
|
||||||
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
|
|
||||||
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if cacheable and redis_client and cache_key:
|
|
||||||
try:
|
|
||||||
await redis_client.setex(
|
|
||||||
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/leaderboard/users")
|
|
||||||
async def get_user_leaderboard(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
start_date: date | None = Query(None),
|
|
||||||
end_date: date | None = Query(None),
|
|
||||||
preset: str | None = Query(None),
|
|
||||||
timezone_name: str | None = Query(None, alias="timezone"),
|
|
||||||
tz_offset_minutes: int | None = Query(0),
|
|
||||||
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
|
|
||||||
order: Literal["desc", "asc"] = Query("desc"),
|
|
||||||
limit: int = Query(10, ge=1, le=100),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
provider_name: str | None = Query(None),
|
|
||||||
model: str | None = Query(None),
|
|
||||||
include_inactive: bool = Query(False),
|
|
||||||
exclude_admin: bool = Query(False),
|
|
||||||
) -> Any:
|
|
||||||
time_range = _build_time_range_params(
|
|
||||||
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
|
||||||
)
|
|
||||||
adapter = AdminUserLeaderboardAdapter(
|
|
||||||
time_range=time_range,
|
|
||||||
metric=metric,
|
|
||||||
order=order,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
provider_name=provider_name,
|
|
||||||
model=model,
|
|
||||||
include_inactive=include_inactive,
|
|
||||||
exclude_admin=exclude_admin,
|
|
||||||
)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/leaderboard/api-keys")
|
|
||||||
async def get_api_key_leaderboard(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
start_date: date | None = Query(None),
|
|
||||||
end_date: date | None = Query(None),
|
|
||||||
preset: str | None = Query(None),
|
|
||||||
timezone_name: str | None = Query(None, alias="timezone"),
|
|
||||||
tz_offset_minutes: int | None = Query(0),
|
|
||||||
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
|
|
||||||
order: Literal["desc", "asc"] = Query("desc"),
|
|
||||||
limit: int = Query(10, ge=1, le=100),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
provider_name: str | None = Query(None),
|
|
||||||
model: str | None = Query(None),
|
|
||||||
include_inactive: bool = Query(False),
|
|
||||||
exclude_admin: bool = Query(False),
|
|
||||||
) -> Any:
|
|
||||||
time_range = _build_time_range_params(
|
|
||||||
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
|
||||||
)
|
|
||||||
adapter = AdminApiKeyLeaderboardAdapter(
|
|
||||||
time_range=time_range,
|
|
||||||
metric=metric,
|
|
||||||
order=order,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
provider_name=provider_name,
|
|
||||||
model=model,
|
|
||||||
include_inactive=include_inactive,
|
|
||||||
exclude_admin=exclude_admin,
|
|
||||||
)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/leaderboard/models")
|
|
||||||
async def get_model_leaderboard(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
start_date: date | None = Query(None),
|
|
||||||
end_date: date | None = Query(None),
|
|
||||||
preset: str | None = Query(None),
|
|
||||||
timezone_name: str | None = Query(None, alias="timezone"),
|
|
||||||
tz_offset_minutes: int | None = Query(0),
|
|
||||||
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
|
|
||||||
order: Literal["desc", "asc"] = Query("desc"),
|
|
||||||
limit: int = Query(10, ge=1, le=100),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
provider_name: str | None = Query(None),
|
|
||||||
model: str | None = Query(None),
|
|
||||||
) -> Any:
|
|
||||||
time_range = _build_time_range_params(
|
|
||||||
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
|
||||||
)
|
|
||||||
adapter = AdminModelLeaderboardAdapter(
|
|
||||||
time_range=time_range,
|
|
||||||
metric=metric,
|
|
||||||
order=order,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
provider_name=provider_name,
|
|
||||||
model=model,
|
|
||||||
)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"""Admin performance stats routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
|
||||||
from src.api.base.context import ApiRequestContext
|
|
||||||
from src.config.constants import CacheTTL
|
|
||||||
from src.database import get_db
|
|
||||||
from src.models.database import StatsDaily
|
|
||||||
from src.services.system.stats_aggregator import StatsAggregatorService
|
|
||||||
from src.services.system.time_range import TimeRangeParams
|
|
||||||
from src.utils.cache_decorator import cache_result
|
|
||||||
|
|
||||||
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminPercentilesAdapter(AdminApiAdapter):
|
|
||||||
def __init__(self, time_range: TimeRangeParams | None) -> None:
|
|
||||||
self.time_range = _apply_admin_default_range(time_range)
|
|
||||||
|
|
||||||
@cache_result(
|
|
||||||
key_prefix="admin:stats:performance:percentiles",
|
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
|
||||||
user_specific=False,
|
|
||||||
vary_by=[
|
|
||||||
"time_range.start_date",
|
|
||||||
"time_range.end_date",
|
|
||||||
"time_range.preset",
|
|
||||||
"time_range.timezone",
|
|
||||||
"time_range.tz_offset_minutes",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
if not self.time_range:
|
|
||||||
return []
|
|
||||||
|
|
||||||
time_range = self.time_range
|
|
||||||
is_utc = (time_range.timezone in {None, "UTC"}) and time_range.tz_offset_minutes == 0
|
|
||||||
|
|
||||||
if is_utc:
|
|
||||||
start_utc, end_utc = time_range.to_utc_datetime_range()
|
|
||||||
rows = (
|
|
||||||
context.db.query(StatsDaily)
|
|
||||||
.filter(StatsDaily.date >= start_utc, StatsDaily.date < end_utc)
|
|
||||||
.order_by(StatsDaily.date.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
result = []
|
|
||||||
for row in rows:
|
|
||||||
date_str = (
|
|
||||||
row.date.astimezone(timezone.utc).date().isoformat()
|
|
||||||
if row.date.tzinfo
|
|
||||||
else row.date.date().isoformat()
|
|
||||||
)
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"date": date_str,
|
|
||||||
"p50_response_time_ms": row.p50_response_time_ms,
|
|
||||||
"p90_response_time_ms": row.p90_response_time_ms,
|
|
||||||
"p99_response_time_ms": row.p99_response_time_ms,
|
|
||||||
"p50_first_byte_time_ms": row.p50_first_byte_time_ms,
|
|
||||||
"p90_first_byte_time_ms": row.p90_first_byte_time_ms,
|
|
||||||
"p99_first_byte_time_ms": row.p99_first_byte_time_ms,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
return StatsAggregatorService.compute_percentiles_by_local_day(context.db, time_range)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/performance/percentiles")
|
|
||||||
async def get_percentiles(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
start_date: date | None = Query(None),
|
|
||||||
end_date: date | None = Query(None),
|
|
||||||
preset: str | None = Query(None),
|
|
||||||
timezone_name: str | None = Query(None, alias="timezone"),
|
|
||||||
tz_offset_minutes: int | None = Query(0),
|
|
||||||
) -> Any:
|
|
||||||
time_range = _build_time_range_params(
|
|
||||||
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
|
||||||
)
|
|
||||||
adapter = AdminPercentilesAdapter(time_range=time_range)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""Admin quota usage stats routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
|
||||||
from src.api.base.context import ApiRequestContext
|
|
||||||
from src.config.constants import CacheTTL
|
|
||||||
from src.core.enums import ProviderBillingType
|
|
||||||
from src.database import get_db
|
|
||||||
from src.models.database import Provider
|
|
||||||
from src.utils.cache_decorator import cache_result
|
|
||||||
|
|
||||||
from .common import pipeline
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminQuotaUsageAdapter(AdminApiAdapter):
|
|
||||||
@cache_result(
|
|
||||||
key_prefix="admin:stats:providers:quota_usage",
|
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
|
||||||
user_specific=False,
|
|
||||||
)
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
db = context.db
|
|
||||||
providers = (
|
|
||||||
db.query(Provider)
|
|
||||||
.filter(
|
|
||||||
(Provider.billing_type == ProviderBillingType.MONTHLY_QUOTA)
|
|
||||||
| (Provider.monthly_quota_usd.isnot(None))
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for provider in providers:
|
|
||||||
quota = float(provider.monthly_quota_usd or 0)
|
|
||||||
used = float(provider.monthly_used_usd or 0)
|
|
||||||
remaining = max(quota - used, 0.0)
|
|
||||||
usage_percent = round((used / quota) * 100, 2) if quota > 0 else 0.0
|
|
||||||
|
|
||||||
reset_at = provider.quota_last_reset_at
|
|
||||||
if reset_at:
|
|
||||||
days_elapsed = max(1, (now - reset_at).days)
|
|
||||||
else:
|
|
||||||
days_elapsed = max(1, now.day - 1)
|
|
||||||
|
|
||||||
daily_rate = used / days_elapsed if used > 0 else 0.0
|
|
||||||
estimated_exhaust_at = None
|
|
||||||
if daily_rate > 0 and remaining > 0:
|
|
||||||
estimated_exhaust_at = now + timedelta(days=remaining / daily_rate)
|
|
||||||
if provider.quota_expires_at:
|
|
||||||
if not estimated_exhaust_at or provider.quota_expires_at < estimated_exhaust_at:
|
|
||||||
estimated_exhaust_at = provider.quota_expires_at
|
|
||||||
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"id": provider.id,
|
|
||||||
"name": provider.name,
|
|
||||||
"quota_usd": float(quota),
|
|
||||||
"used_usd": float(used),
|
|
||||||
"remaining_usd": float(remaining),
|
|
||||||
"usage_percent": usage_percent,
|
|
||||||
"quota_expires_at": (
|
|
||||||
provider.quota_expires_at.isoformat() if provider.quota_expires_at else None
|
|
||||||
),
|
|
||||||
"estimated_exhaust_at": (
|
|
||||||
estimated_exhaust_at.isoformat() if estimated_exhaust_at else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
result.sort(key=lambda x: x["usage_percent"], reverse=True)
|
|
||||||
return {"providers": result}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers/quota-usage")
|
|
||||||
async def get_quota_usage(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> Any:
|
|
||||||
adapter = AdminQuotaUsageAdapter()
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Admin stats routes (compat export)."""
|
|
||||||
|
|
||||||
from . import router
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"""Admin time series stats routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
|
||||||
from src.api.base.context import ApiRequestContext
|
|
||||||
from src.config.constants import CacheTTL
|
|
||||||
from src.database import get_db
|
|
||||||
from src.services.system.stats_aggregator import TimeSeriesFilter, query_time_series
|
|
||||||
from src.services.system.time_range import TimeRangeParams
|
|
||||||
from src.utils.cache_decorator import cache_result
|
|
||||||
|
|
||||||
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminTimeSeriesAdapter(AdminApiAdapter):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
time_range: TimeRangeParams | None,
|
|
||||||
user_id: str | None,
|
|
||||||
model: str | None,
|
|
||||||
provider_name: str | None,
|
|
||||||
) -> None:
|
|
||||||
self.time_range = _apply_admin_default_range(time_range)
|
|
||||||
self.user_id = user_id
|
|
||||||
self.model = model
|
|
||||||
self.provider_name = provider_name
|
|
||||||
|
|
||||||
@cache_result(
|
|
||||||
key_prefix="admin:stats:time_series",
|
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
|
||||||
user_specific=False,
|
|
||||||
vary_by=[
|
|
||||||
"time_range.start_date",
|
|
||||||
"time_range.end_date",
|
|
||||||
"time_range.preset",
|
|
||||||
"time_range.timezone",
|
|
||||||
"time_range.tz_offset_minutes",
|
|
||||||
"time_range.granularity",
|
|
||||||
"user_id",
|
|
||||||
"model",
|
|
||||||
"provider_name",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
if not self.time_range:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
return query_time_series(
|
|
||||||
context.db,
|
|
||||||
self.time_range,
|
|
||||||
filters=TimeSeriesFilter(
|
|
||||||
user_id=self.user_id, model=self.model, provider_name=self.provider_name
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/time-series")
|
|
||||||
async def get_time_series(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
start_date: date | None = Query(None),
|
|
||||||
end_date: date | None = Query(None),
|
|
||||||
preset: str | None = Query(None),
|
|
||||||
granularity: Literal["hour", "day", "week", "month"] = Query("day"),
|
|
||||||
timezone_name: str | None = Query(None, alias="timezone"),
|
|
||||||
tz_offset_minutes: int | None = Query(0),
|
|
||||||
user_id: str | None = Query(None),
|
|
||||||
model: str | None = Query(None),
|
|
||||||
provider_name: str | None = Query(None),
|
|
||||||
) -> Any:
|
|
||||||
time_range = _build_time_range_params(
|
|
||||||
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
|
||||||
)
|
|
||||||
if time_range:
|
|
||||||
time_range.granularity = granularity
|
|
||||||
adapter = AdminTimeSeriesAdapter(
|
|
||||||
time_range=time_range,
|
|
||||||
user_id=user_id,
|
|
||||||
model=model,
|
|
||||||
provider_name=provider_name,
|
|
||||||
)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
+46
-1364
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,9 @@ from fastapi.responses import StreamingResponse
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
|
||||||
from src.api.base.context import ApiRequestContext
|
from src.api.base.context import ApiRequestContext
|
||||||
from src.api.base.pipeline import get_pipeline
|
from src.api.base.pipeline import get_pipeline
|
||||||
from src.api.dashboard.routes import DashboardAdapter
|
|
||||||
from src.clients.http_client import HTTPClientPool
|
from src.clients.http_client import HTTPClientPool
|
||||||
from src.config.constants import CacheTTL
|
from src.config.constants import CacheTTL
|
||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
@@ -232,7 +232,7 @@ async def proxy_video_stream(
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VideoTaskListAdapter(DashboardAdapter):
|
class VideoTaskListAdapter(AuthenticatedApiAdapter):
|
||||||
"""视频任务列表适配器"""
|
"""视频任务列表适配器"""
|
||||||
|
|
||||||
status: str | None
|
status: str | None
|
||||||
@@ -338,7 +338,7 @@ class VideoTaskListAdapter(DashboardAdapter):
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VideoTaskStatsAdapter(DashboardAdapter):
|
class VideoTaskStatsAdapter(AuthenticatedApiAdapter):
|
||||||
"""视频任务统计适配器"""
|
"""视频任务统计适配器"""
|
||||||
|
|
||||||
@cache_result(
|
@cache_result(
|
||||||
@@ -417,7 +417,7 @@ class VideoTaskStatsAdapter(DashboardAdapter):
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VideoTaskDetailAdapter(DashboardAdapter):
|
class VideoTaskDetailAdapter(AuthenticatedApiAdapter):
|
||||||
"""视频任务详情适配器"""
|
"""视频任务详情适配器"""
|
||||||
|
|
||||||
task_id: str
|
task_id: str
|
||||||
@@ -507,7 +507,7 @@ class VideoTaskDetailAdapter(DashboardAdapter):
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VideoTaskCancelAdapter(DashboardAdapter):
|
class VideoTaskCancelAdapter(AuthenticatedApiAdapter):
|
||||||
"""视频任务取消适配器"""
|
"""视频任务取消适配器"""
|
||||||
|
|
||||||
task_id: str
|
task_id: str
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .routes import router as analytics_router
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(analytics_router)
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.core.enums import UserRole
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import User
|
||||||
|
from src.services.analytics import AnalyticsQueryService
|
||||||
|
from src.services.analytics.query_service import (
|
||||||
|
DELETED_API_KEY_FILTER,
|
||||||
|
DELETED_USER_FILTER,
|
||||||
|
AnalyticsFilters,
|
||||||
|
AnalyticsSearch,
|
||||||
|
BreakdownMetric,
|
||||||
|
LeaderboardEntity,
|
||||||
|
LeaderboardMetric,
|
||||||
|
)
|
||||||
|
from src.services.system.time_range import TimeRangeParams
|
||||||
|
from src.services.usage.service import UsageService
|
||||||
|
from src.utils.auth_utils import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/analytics", tags=["Analytics"])
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsScopePayload(BaseModel):
|
||||||
|
kind: Literal["global", "me", "user", "api_key"] = "me"
|
||||||
|
user_id: str | None = None
|
||||||
|
api_key_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsTimeRangePayload(BaseModel):
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
preset: (
|
||||||
|
Literal[
|
||||||
|
"today",
|
||||||
|
"last7days",
|
||||||
|
"last30days",
|
||||||
|
"last180days",
|
||||||
|
"last1year",
|
||||||
|
]
|
||||||
|
| None
|
||||||
|
) = None
|
||||||
|
granularity: Literal["hour", "day", "week", "month"] = "day"
|
||||||
|
timezone: str | None = "UTC"
|
||||||
|
tz_offset_minutes: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsFiltersPayload(BaseModel):
|
||||||
|
user_ids: list[str] = Field(default_factory=list)
|
||||||
|
provider_names: list[str] = Field(default_factory=list)
|
||||||
|
models: list[str] = Field(default_factory=list)
|
||||||
|
target_models: list[str] = Field(default_factory=list)
|
||||||
|
api_key_ids: list[str] = Field(default_factory=list)
|
||||||
|
api_formats: list[str] = Field(default_factory=list)
|
||||||
|
request_types: list[str] = Field(default_factory=list)
|
||||||
|
statuses: list[str] = Field(default_factory=list)
|
||||||
|
error_categories: list[str] = Field(default_factory=list)
|
||||||
|
is_stream: bool | None = None
|
||||||
|
has_format_conversion: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsBaseRequest(BaseModel):
|
||||||
|
scope: AnalyticsScopePayload = Field(default_factory=AnalyticsScopePayload)
|
||||||
|
time_range: AnalyticsTimeRangePayload = Field(
|
||||||
|
default_factory=lambda: AnalyticsTimeRangePayload(preset="last30days")
|
||||||
|
)
|
||||||
|
filters: AnalyticsFiltersPayload = Field(default_factory=AnalyticsFiltersPayload)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsBreakdownRequest(AnalyticsBaseRequest):
|
||||||
|
dimension: Literal["model", "provider", "api_format", "api_key", "user"] = "model"
|
||||||
|
metric: BreakdownMetric = "total_cost_usd"
|
||||||
|
limit: int = Field(default=50, ge=1, le=200)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsSearchPayload(BaseModel):
|
||||||
|
text: str | None = None
|
||||||
|
request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsPaginationPayload(BaseModel):
|
||||||
|
limit: int = Field(default=100, ge=1, le=5000)
|
||||||
|
offset: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsRecordsRequest(AnalyticsBaseRequest):
|
||||||
|
search: AnalyticsSearchPayload = Field(default_factory=AnalyticsSearchPayload)
|
||||||
|
pagination: AnalyticsPaginationPayload = Field(default_factory=AnalyticsPaginationPayload)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsLeaderboardRequest(AnalyticsBaseRequest):
|
||||||
|
entity: LeaderboardEntity = "user"
|
||||||
|
metric: LeaderboardMetric = "total_cost_usd"
|
||||||
|
limit: int = Field(default=20, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsHeatmapRequest(BaseModel):
|
||||||
|
scope: AnalyticsScopePayload = Field(default_factory=AnalyticsScopePayload)
|
||||||
|
user_id: str | None = None
|
||||||
|
api_key_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsActiveRequestsRequest(BaseModel):
|
||||||
|
scope: AnalyticsScopePayload = Field(default_factory=AnalyticsScopePayload)
|
||||||
|
ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsIntervalTimelineRequest(BaseModel):
|
||||||
|
scope: AnalyticsScopePayload = Field(default_factory=AnalyticsScopePayload)
|
||||||
|
user_id: str | None = None
|
||||||
|
hours: int = Field(default=24, ge=1, le=720)
|
||||||
|
limit: int = Field(default=2000, ge=100, le=50000)
|
||||||
|
include_user_info: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsCacheAffinityRequest(BaseModel):
|
||||||
|
scope: AnalyticsScopePayload = Field(default_factory=AnalyticsScopePayload)
|
||||||
|
user_id: str | None = None
|
||||||
|
api_key_id: str | None = None
|
||||||
|
hours: int = Field(default=168, ge=1, le=720)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_time_range(payload: AnalyticsTimeRangePayload) -> TimeRangeParams:
|
||||||
|
try:
|
||||||
|
return TimeRangeParams(
|
||||||
|
start_date=payload.start_date,
|
||||||
|
end_date=payload.end_date,
|
||||||
|
preset=payload.preset,
|
||||||
|
granularity=payload.granularity,
|
||||||
|
timezone=payload.timezone,
|
||||||
|
tz_offset_minutes=payload.tz_offset_minutes,
|
||||||
|
).validate_and_resolve()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _build_filters(payload: AnalyticsFiltersPayload) -> AnalyticsFilters:
|
||||||
|
return AnalyticsFilters(
|
||||||
|
user_ids=payload.user_ids,
|
||||||
|
provider_names=payload.provider_names,
|
||||||
|
models=payload.models,
|
||||||
|
target_models=payload.target_models,
|
||||||
|
api_key_ids=payload.api_key_ids,
|
||||||
|
api_formats=payload.api_formats,
|
||||||
|
request_types=payload.request_types,
|
||||||
|
statuses=payload.statuses,
|
||||||
|
error_categories=payload.error_categories,
|
||||||
|
is_stream=payload.is_stream,
|
||||||
|
has_format_conversion=payload.has_format_conversion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_search(payload: AnalyticsSearchPayload) -> AnalyticsSearch:
|
||||||
|
return AnalyticsSearch(
|
||||||
|
text=payload.text.strip() if payload.text else None,
|
||||||
|
request_id=payload.request_id.strip() if payload.request_id else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_scope(current_user: User, scope: AnalyticsScopePayload) -> None:
|
||||||
|
if current_user.role != UserRole.ADMIN and scope.kind != "me":
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin can access non-personal analytics scope")
|
||||||
|
|
||||||
|
if scope.kind == "user" and not scope.user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="scope.user_id is required for user scope")
|
||||||
|
|
||||||
|
if scope.kind == "api_key" and not scope.api_key_id:
|
||||||
|
raise HTTPException(status_code=400, detail="scope.api_key_id is required for api_key scope")
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_sensitive_cost_fields(payload: object) -> object:
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return [_redact_sensitive_cost_fields(item) for item in payload]
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
redacted = {
|
||||||
|
key: _redact_sensitive_cost_fields(value)
|
||||||
|
for key, value in payload.items()
|
||||||
|
}
|
||||||
|
if "providers" in redacted:
|
||||||
|
redacted["providers"] = []
|
||||||
|
if "provider_health" in redacted:
|
||||||
|
redacted["provider_health"] = []
|
||||||
|
if "provider_name" in redacted:
|
||||||
|
redacted["provider_name"] = None
|
||||||
|
if "provider_api_key_name" in redacted:
|
||||||
|
redacted["provider_api_key_name"] = None
|
||||||
|
if "rate_multiplier" in redacted:
|
||||||
|
redacted["rate_multiplier"] = 1.0
|
||||||
|
for key in list(redacted.keys()):
|
||||||
|
if key.startswith("actual_") and key.endswith("_usd"):
|
||||||
|
redacted[key] = 0.0
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_non_admin_breakdown_allowed(
|
||||||
|
current_user: User,
|
||||||
|
body: AnalyticsBreakdownRequest,
|
||||||
|
) -> None:
|
||||||
|
if current_user.role == UserRole.ADMIN:
|
||||||
|
return
|
||||||
|
if body.dimension == "provider":
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin can access provider breakdown")
|
||||||
|
if body.metric == "actual_total_cost_usd":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Only admin can access actual cost breakdown",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_scope_targets(
|
||||||
|
current_user: User,
|
||||||
|
scope: AnalyticsScopePayload,
|
||||||
|
*,
|
||||||
|
user_id: str | None = None,
|
||||||
|
api_key_id: str | None = None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
_validate_scope(current_user, scope)
|
||||||
|
|
||||||
|
resolved_user_id = user_id
|
||||||
|
resolved_api_key_id = api_key_id
|
||||||
|
|
||||||
|
if scope.kind == "me":
|
||||||
|
current_user_id = str(current_user.id)
|
||||||
|
if resolved_user_id not in {None, current_user_id}:
|
||||||
|
raise HTTPException(status_code=400, detail="scope.kind=me cannot target another user")
|
||||||
|
resolved_user_id = current_user_id
|
||||||
|
elif scope.kind == "user":
|
||||||
|
if resolved_user_id not in {None, scope.user_id}:
|
||||||
|
raise HTTPException(status_code=400, detail="scope.user_id conflicts with user_id")
|
||||||
|
resolved_user_id = scope.user_id
|
||||||
|
elif scope.kind == "api_key":
|
||||||
|
if resolved_api_key_id not in {None, scope.api_key_id}:
|
||||||
|
raise HTTPException(status_code=400, detail="scope.api_key_id conflicts with api_key_id")
|
||||||
|
resolved_api_key_id = scope.api_key_id
|
||||||
|
|
||||||
|
return resolved_user_id, resolved_api_key_id
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_optional_ids(ids: list[str]) -> list[str]:
|
||||||
|
normalized = [item.strip() for item in ids if item and item.strip()]
|
||||||
|
return list(dict.fromkeys(normalized))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/overview")
|
||||||
|
async def analytics_overview(
|
||||||
|
body: AnalyticsBaseRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_validate_scope(current_user, body.scope)
|
||||||
|
time_range = _build_time_range(body.time_range)
|
||||||
|
result = AnalyticsQueryService.overview(
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
time_range=time_range,
|
||||||
|
scope_kind=body.scope.kind,
|
||||||
|
scope_user_id=body.scope.user_id,
|
||||||
|
scope_api_key_id=body.scope.api_key_id,
|
||||||
|
filters=_build_filters(body.filters),
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"query_context": {
|
||||||
|
"scope": body.scope.model_dump(),
|
||||||
|
"time_range": body.time_range.model_dump(),
|
||||||
|
},
|
||||||
|
**result,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
result
|
||||||
|
if current_user.role == UserRole.ADMIN
|
||||||
|
else _redact_sensitive_cost_fields(result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/timeseries")
|
||||||
|
async def analytics_timeseries(
|
||||||
|
body: AnalyticsBaseRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_validate_scope(current_user, body.scope)
|
||||||
|
time_range = _build_time_range(body.time_range)
|
||||||
|
result = AnalyticsQueryService.timeseries(
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
time_range=time_range,
|
||||||
|
scope_kind=body.scope.kind,
|
||||||
|
scope_user_id=body.scope.user_id,
|
||||||
|
scope_api_key_id=body.scope.api_key_id,
|
||||||
|
filters=_build_filters(body.filters),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
result
|
||||||
|
if current_user.role == UserRole.ADMIN
|
||||||
|
else _redact_sensitive_cost_fields(result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/breakdown")
|
||||||
|
async def analytics_breakdown(
|
||||||
|
body: AnalyticsBreakdownRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_validate_scope(current_user, body.scope)
|
||||||
|
_ensure_non_admin_breakdown_allowed(current_user, body)
|
||||||
|
time_range = _build_time_range(body.time_range)
|
||||||
|
result = AnalyticsQueryService.breakdown(
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
time_range=time_range,
|
||||||
|
scope_kind=body.scope.kind,
|
||||||
|
scope_user_id=body.scope.user_id,
|
||||||
|
scope_api_key_id=body.scope.api_key_id,
|
||||||
|
filters=_build_filters(body.filters),
|
||||||
|
dimension=body.dimension,
|
||||||
|
metric=body.metric,
|
||||||
|
limit=body.limit,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
result
|
||||||
|
if current_user.role == UserRole.ADMIN
|
||||||
|
else _redact_sensitive_cost_fields(result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/records")
|
||||||
|
async def analytics_records(
|
||||||
|
body: AnalyticsRecordsRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_validate_scope(current_user, body.scope)
|
||||||
|
time_range = _build_time_range(body.time_range)
|
||||||
|
result = AnalyticsQueryService.records(
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
time_range=time_range,
|
||||||
|
scope_kind=body.scope.kind,
|
||||||
|
scope_user_id=body.scope.user_id,
|
||||||
|
scope_api_key_id=body.scope.api_key_id,
|
||||||
|
filters=_build_filters(body.filters),
|
||||||
|
search=_build_search(body.search),
|
||||||
|
limit=body.pagination.limit,
|
||||||
|
offset=body.pagination.offset,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
result
|
||||||
|
if current_user.role == UserRole.ADMIN
|
||||||
|
else _redact_sensitive_cost_fields(result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/filter-options")
|
||||||
|
async def analytics_filter_options(
|
||||||
|
body: AnalyticsBaseRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_validate_scope(current_user, body.scope)
|
||||||
|
time_range = _build_time_range(body.time_range)
|
||||||
|
result = AnalyticsQueryService.filter_options(
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
time_range=time_range,
|
||||||
|
scope_kind=body.scope.kind,
|
||||||
|
scope_user_id=body.scope.user_id,
|
||||||
|
scope_api_key_id=body.scope.api_key_id,
|
||||||
|
filters=_build_filters(body.filters),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
result
|
||||||
|
if current_user.role == UserRole.ADMIN
|
||||||
|
else _redact_sensitive_cost_fields(result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/heatmap")
|
||||||
|
async def analytics_heatmap(
|
||||||
|
body: AnalyticsHeatmapRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resolved_user_id, resolved_api_key_id = _resolve_scope_targets(
|
||||||
|
current_user,
|
||||||
|
body.scope,
|
||||||
|
user_id=body.user_id,
|
||||||
|
api_key_id=body.api_key_id,
|
||||||
|
)
|
||||||
|
result = await UsageService.get_cached_heatmap(
|
||||||
|
db=db,
|
||||||
|
user_id=None if resolved_user_id == DELETED_USER_FILTER else resolved_user_id,
|
||||||
|
api_key_id=None if resolved_api_key_id == DELETED_API_KEY_FILTER else resolved_api_key_id,
|
||||||
|
deleted_user_only=resolved_user_id == DELETED_USER_FILTER,
|
||||||
|
deleted_api_key_only=resolved_api_key_id == DELETED_API_KEY_FILTER,
|
||||||
|
include_actual_cost=current_user.role == UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
return result if current_user.role == UserRole.ADMIN else _redact_sensitive_cost_fields(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/active-requests")
|
||||||
|
async def analytics_active_requests(
|
||||||
|
body: AnalyticsActiveRequestsRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resolved_user_id, resolved_api_key_id = _resolve_scope_targets(current_user, body.scope)
|
||||||
|
requests = UsageService.get_active_requests_status(
|
||||||
|
db=db,
|
||||||
|
ids=_normalize_optional_ids(body.ids) or None,
|
||||||
|
user_id=resolved_user_id,
|
||||||
|
api_key_id=resolved_api_key_id,
|
||||||
|
include_admin_fields=current_user.role == UserRole.ADMIN,
|
||||||
|
maintain_status=True,
|
||||||
|
)
|
||||||
|
payload = {"requests": requests}
|
||||||
|
return payload if current_user.role == UserRole.ADMIN else _redact_sensitive_cost_fields(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/interval-timeline")
|
||||||
|
async def analytics_interval_timeline(
|
||||||
|
body: AnalyticsIntervalTimelineRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resolved_user_id, resolved_api_key_id = _resolve_scope_targets(
|
||||||
|
current_user,
|
||||||
|
body.scope,
|
||||||
|
user_id=body.user_id,
|
||||||
|
)
|
||||||
|
if resolved_api_key_id:
|
||||||
|
raise HTTPException(status_code=400, detail="interval timeline does not support api_key scope")
|
||||||
|
if body.include_user_info and current_user.role != UserRole.ADMIN:
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin can request user timeline labels")
|
||||||
|
return UsageService.get_interval_timeline(
|
||||||
|
db=db,
|
||||||
|
hours=body.hours,
|
||||||
|
limit=body.limit,
|
||||||
|
user_id=resolved_user_id,
|
||||||
|
include_user_info=body.include_user_info and resolved_user_id is None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cache-affinity/ttl-analysis")
|
||||||
|
async def analytics_cache_affinity_ttl_analysis(
|
||||||
|
body: AnalyticsCacheAffinityRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resolved_user_id, resolved_api_key_id = _resolve_scope_targets(
|
||||||
|
current_user,
|
||||||
|
body.scope,
|
||||||
|
user_id=body.user_id,
|
||||||
|
api_key_id=body.api_key_id,
|
||||||
|
)
|
||||||
|
if current_user.role != UserRole.ADMIN:
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin can access cache affinity analysis")
|
||||||
|
return UsageService.analyze_cache_affinity_ttl(
|
||||||
|
db=db,
|
||||||
|
user_id=resolved_user_id,
|
||||||
|
api_key_id=resolved_api_key_id,
|
||||||
|
hours=body.hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cache-affinity/hit-analysis")
|
||||||
|
async def analytics_cache_affinity_hit_analysis(
|
||||||
|
body: AnalyticsCacheAffinityRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resolved_user_id, resolved_api_key_id = _resolve_scope_targets(
|
||||||
|
current_user,
|
||||||
|
body.scope,
|
||||||
|
user_id=body.user_id,
|
||||||
|
api_key_id=body.api_key_id,
|
||||||
|
)
|
||||||
|
if current_user.role != UserRole.ADMIN:
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin can access cache affinity analysis")
|
||||||
|
return UsageService.get_cache_hit_analysis(
|
||||||
|
db=db,
|
||||||
|
user_id=resolved_user_id,
|
||||||
|
api_key_id=resolved_api_key_id,
|
||||||
|
hours=body.hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leaderboard")
|
||||||
|
async def analytics_leaderboard(
|
||||||
|
body: AnalyticsLeaderboardRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_validate_scope(current_user, body.scope)
|
||||||
|
time_range = _build_time_range(body.time_range)
|
||||||
|
result = AnalyticsQueryService.leaderboard(
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
time_range=time_range,
|
||||||
|
scope_kind=body.scope.kind,
|
||||||
|
scope_user_id=body.scope.user_id,
|
||||||
|
scope_api_key_id=body.scope.api_key_id,
|
||||||
|
filters=_build_filters(body.filters),
|
||||||
|
entity=body.entity,
|
||||||
|
metric=body.metric,
|
||||||
|
limit=body.limit,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
result
|
||||||
|
if current_user.role == UserRole.ADMIN
|
||||||
|
else _redact_sensitive_cost_fields(result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/performance")
|
||||||
|
async def analytics_performance(
|
||||||
|
body: AnalyticsBaseRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_validate_scope(current_user, body.scope)
|
||||||
|
time_range = _build_time_range(body.time_range)
|
||||||
|
result = AnalyticsQueryService.performance(
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
time_range=time_range,
|
||||||
|
scope_kind=body.scope.kind,
|
||||||
|
scope_user_id=body.scope.user_id,
|
||||||
|
scope_api_key_id=body.scope.api_key_id,
|
||||||
|
filters=_build_filters(body.filters),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
result
|
||||||
|
if current_user.role == UserRole.ADMIN
|
||||||
|
else _redact_sensitive_cost_fields(result)
|
||||||
|
)
|
||||||
@@ -770,9 +770,6 @@ class AuthCurrentUserAdapter(AuthenticatedApiAdapter):
|
|||||||
"role": user.role.value,
|
"role": user.role.value,
|
||||||
"is_active": user.is_active,
|
"is_active": user.is_active,
|
||||||
"billing": WalletService.serialize_wallet_summary(wallet),
|
"billing": WalletService.serialize_wallet_summary(wallet),
|
||||||
"allowed_providers": user.allowed_providers,
|
|
||||||
"allowed_api_formats": user.allowed_api_formats,
|
|
||||||
"allowed_models": user.allowed_models,
|
|
||||||
"created_at": user.created_at.isoformat(),
|
"created_at": user.created_at.isoformat(),
|
||||||
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
|
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
|
||||||
"auth_source": user.auth_source.value,
|
"auth_source": user.auth_source.value,
|
||||||
|
|||||||
@@ -73,7 +73,47 @@ async def _set_cached_models(
|
|||||||
logger.warning(f"[ModelsService] 缓存写入失败: {e}")
|
logger.warning(f"[ModelsService] 缓存写入失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["AccessRestrictions", "invalidate_models_list_cache", "ModelInfo"]
|
_PUBLIC_GLOBAL_MODEL_CONFIG_KEYS = (
|
||||||
|
"description",
|
||||||
|
"icon_url",
|
||||||
|
"streaming",
|
||||||
|
"vision",
|
||||||
|
"function_calling",
|
||||||
|
"extended_thinking",
|
||||||
|
"image_generation",
|
||||||
|
"structured_output",
|
||||||
|
"family",
|
||||||
|
"knowledge_cutoff",
|
||||||
|
"input_modalities",
|
||||||
|
"output_modalities",
|
||||||
|
"context_limit",
|
||||||
|
"output_limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_public_global_model_config(raw_config: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
"""Return only user-safe GlobalModel config keys.
|
||||||
|
|
||||||
|
Internal routing fields such as ``model_mappings`` should never be exposed
|
||||||
|
through public or user-facing model catalog APIs.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw_config, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
sanitized = {
|
||||||
|
key: raw_config[key]
|
||||||
|
for key in _PUBLIC_GLOBAL_MODEL_CONFIG_KEYS
|
||||||
|
if key in raw_config
|
||||||
|
}
|
||||||
|
return sanitized or None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AccessRestrictions",
|
||||||
|
"invalidate_models_list_cache",
|
||||||
|
"ModelInfo",
|
||||||
|
"sanitize_public_global_model_config",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -35,10 +35,8 @@ from .context import ApiRequestContext
|
|||||||
|
|
||||||
# 高频轮询端点,抑制其 debug 日志以减少噪音
|
# 高频轮询端点,抑制其 debug 日志以减少噪音
|
||||||
QUIET_POLLING_PATHS: set[str] = {
|
QUIET_POLLING_PATHS: set[str] = {
|
||||||
"/api/admin/usage/active",
|
"/api/analytics/active-requests",
|
||||||
"/api/admin/usage/records",
|
"/api/analytics/records",
|
||||||
"/api/admin/usage/stats",
|
|
||||||
"/api/admin/usage/aggregation/stats",
|
|
||||||
"/api/admin/health/status",
|
"/api/admin/health/status",
|
||||||
"/api/wallet/today-cost",
|
"/api/wallet/today-cost",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
'"""Dashboard API routers."""'
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from .routes import router as dashboard_router
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
router.include_router(dashboard_router)
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
File diff suppressed because it is too large
Load Diff
+13
-51
@@ -10,12 +10,13 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy import and_, func, or_
|
from sqlalchemy import and_, func, or_
|
||||||
from sqlalchemy.orm import Session, joinedload, load_only
|
from sqlalchemy.orm import Session, joinedload, load_only
|
||||||
|
|
||||||
from src.api.base.adapter import ApiAdapter, ApiMode
|
from src.api.base.adapter import ApiAdapter, ApiMode
|
||||||
from src.api.base.context import ApiRequestContext
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.models_service import sanitize_public_global_model_config
|
||||||
from src.api.base.pipeline import get_pipeline
|
from src.api.base.pipeline import get_pipeline
|
||||||
from src.config.constants import CacheTTL
|
from src.config.constants import CacheTTL
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
@@ -82,7 +83,7 @@ def get_site_info(db: Session = Depends(get_db)) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers", response_model=list[PublicProviderResponse])
|
@router.get("/providers", response_model=list[PublicProviderResponse], include_in_schema=False)
|
||||||
async def get_public_providers(
|
async def get_public_providers(
|
||||||
request: Request,
|
request: Request,
|
||||||
is_active: bool | None = Query(None, description="过滤活跃状态"),
|
is_active: bool | None = Query(None, description="过滤活跃状态"),
|
||||||
@@ -90,38 +91,12 @@ async def get_public_providers(
|
|||||||
limit: int = Query(100, description="返回记录数限制"),
|
limit: int = Query(100, description="返回记录数限制"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
获取提供商列表(用户视图)
|
|
||||||
|
|
||||||
返回系统中可用的提供商列表,包含提供商的基本信息和统计数据。
|
|
||||||
默认只返回活跃的提供商。
|
|
||||||
|
|
||||||
**查询参数**
|
|
||||||
- is_active: 可选,过滤活跃状态。None 表示只返回活跃提供商,True 返回活跃,False 返回非活跃
|
|
||||||
- skip: 跳过的记录数,用于分页,默认 0
|
|
||||||
- limit: 返回记录数限制,默认 100,最大 100
|
|
||||||
|
|
||||||
**返回字段**
|
|
||||||
- id: 提供商唯一标识符
|
|
||||||
- name: 提供商名称(英文标识)
|
|
||||||
- display_name: 提供商显示名称
|
|
||||||
- description: 提供商描述信息
|
|
||||||
- is_active: 是否活跃
|
|
||||||
- provider_priority: 提供商优先级
|
|
||||||
- models_count: 该提供商下的模型总数
|
|
||||||
- active_models_count: 该提供商下活跃的模型数
|
|
||||||
- endpoints_count: 该提供商下的端点总数
|
|
||||||
- active_endpoints_count: 该提供商下活跃的端点数
|
|
||||||
"""
|
|
||||||
|
|
||||||
adapter = PublicProvidersAdapter(is_active=is_active, skip=skip, limit=limit)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models", response_model=list[PublicModelResponse])
|
@router.get("/models", response_model=list[PublicModelResponse])
|
||||||
async def get_public_models(
|
async def get_public_models(
|
||||||
request: Request,
|
request: Request,
|
||||||
provider_id: str | None = Query(None, description="提供商ID过滤"),
|
|
||||||
is_active: bool | None = Query(None, description="过滤活跃状态"),
|
is_active: bool | None = Query(None, description="过滤活跃状态"),
|
||||||
skip: int = Query(0, description="跳过记录数"),
|
skip: int = Query(0, description="跳过记录数"),
|
||||||
limit: int = Query(100, description="返回记录数限制"),
|
limit: int = Query(100, description="返回记录数限制"),
|
||||||
@@ -157,9 +132,7 @@ async def get_public_models(
|
|||||||
- supports_streaming: 是否支持流式输出
|
- supports_streaming: 是否支持流式输出
|
||||||
- is_active: 是否活跃
|
- is_active: 是否活跃
|
||||||
"""
|
"""
|
||||||
adapter = PublicModelsAdapter(
|
adapter = PublicModelsAdapter(is_active=is_active, skip=skip, limit=limit)
|
||||||
provider_id=provider_id, is_active=is_active, skip=skip, limit=limit
|
|
||||||
)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
|
||||||
|
|
||||||
|
|
||||||
@@ -186,7 +159,6 @@ async def get_public_stats(request: Request, db: Session = Depends(get_db)) -> A
|
|||||||
async def search_models(
|
async def search_models(
|
||||||
request: Request,
|
request: Request,
|
||||||
q: str = Query(..., description="搜索关键词"),
|
q: str = Query(..., description="搜索关键词"),
|
||||||
provider_id: int | None = Query(None, description="提供商ID过滤"),
|
|
||||||
limit: int = Query(20, description="返回记录数限制"),
|
limit: int = Query(20, description="返回记录数限制"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -221,7 +193,7 @@ async def search_models(
|
|||||||
- supports_streaming: 是否支持流式输出
|
- supports_streaming: 是否支持流式输出
|
||||||
- is_active: 是否活跃
|
- is_active: 是否活跃
|
||||||
"""
|
"""
|
||||||
adapter = PublicSearchModelsAdapter(query=q, provider_id=provider_id, limit=limit)
|
adapter = PublicSearchModelsAdapter(query=q, limit=limit)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
|
||||||
|
|
||||||
|
|
||||||
@@ -425,7 +397,6 @@ class PublicProvidersAdapter(PublicApiAdapter):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PublicModelsAdapter(PublicApiAdapter):
|
class PublicModelsAdapter(PublicApiAdapter):
|
||||||
provider_id: str | None
|
|
||||||
is_active: bool | None
|
is_active: bool | None
|
||||||
skip: int
|
skip: int
|
||||||
limit: int
|
limit: int
|
||||||
@@ -434,13 +405,13 @@ class PublicModelsAdapter(PublicApiAdapter):
|
|||||||
key_prefix="public:catalog:models",
|
key_prefix="public:catalog:models",
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
user_specific=False,
|
user_specific=False,
|
||||||
vary_by=["provider_id", "is_active", "skip", "limit"],
|
vary_by=["is_active", "skip", "limit"],
|
||||||
)
|
)
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
logger.debug("公共API请求模型列表")
|
logger.debug("公共API请求模型列表")
|
||||||
query = (
|
query = (
|
||||||
db.query(Model, Provider)
|
db.query(Model)
|
||||||
.options(joinedload(Model.global_model))
|
.options(joinedload(Model.global_model))
|
||||||
.join(Provider)
|
.join(Provider)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -450,19 +421,15 @@ class PublicModelsAdapter(PublicApiAdapter):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.provider_id is not None:
|
|
||||||
query = query.filter(Model.provider_id == self.provider_id)
|
|
||||||
results = query.offset(self.skip).limit(self.limit).all()
|
results = query.offset(self.skip).limit(self.limit).all()
|
||||||
|
|
||||||
response = []
|
response = []
|
||||||
for model, provider in results:
|
for model in results:
|
||||||
global_model = model.global_model
|
global_model = model.global_model
|
||||||
display_name = global_model.display_name if global_model else model.provider_model_name
|
display_name = global_model.display_name if global_model else model.provider_model_name
|
||||||
unified_name = global_model.name if global_model else model.provider_model_name
|
unified_name = global_model.name if global_model else model.provider_model_name
|
||||||
model_data = PublicModelResponse(
|
model_data = PublicModelResponse(
|
||||||
id=model.id,
|
id=model.id,
|
||||||
provider_id=model.provider_id,
|
|
||||||
provider_name=provider.name,
|
|
||||||
name=unified_name,
|
name=unified_name,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
description=(
|
description=(
|
||||||
@@ -541,20 +508,19 @@ class PublicStatsAdapter(PublicApiAdapter):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class PublicSearchModelsAdapter(PublicApiAdapter):
|
class PublicSearchModelsAdapter(PublicApiAdapter):
|
||||||
query: str
|
query: str
|
||||||
provider_id: int | None
|
|
||||||
limit: int
|
limit: int
|
||||||
|
|
||||||
@cache_result(
|
@cache_result(
|
||||||
key_prefix="public:catalog:search_models",
|
key_prefix="public:catalog:search_models",
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
user_specific=False,
|
user_specific=False,
|
||||||
vary_by=["query", "provider_id", "limit"],
|
vary_by=["query", "limit"],
|
||||||
)
|
)
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
logger.debug(f"公共API搜索模型: {self.query}")
|
logger.debug(f"公共API搜索模型: {self.query}")
|
||||||
query_stmt = (
|
query_stmt = (
|
||||||
db.query(Model, Provider)
|
db.query(Model)
|
||||||
.options(joinedload(Model.global_model))
|
.options(joinedload(Model.global_model))
|
||||||
.join(Provider)
|
.join(Provider)
|
||||||
.outerjoin(GlobalModel, Model.global_model_id == GlobalModel.id)
|
.outerjoin(GlobalModel, Model.global_model_id == GlobalModel.id)
|
||||||
@@ -571,19 +537,15 @@ class PublicSearchModelsAdapter(PublicApiAdapter):
|
|||||||
| GlobalModel.display_name.ilike(f"%{self.query}%")
|
| GlobalModel.display_name.ilike(f"%{self.query}%")
|
||||||
)
|
)
|
||||||
query_stmt = query_stmt.filter(search_filter)
|
query_stmt = query_stmt.filter(search_filter)
|
||||||
if self.provider_id is not None:
|
|
||||||
query_stmt = query_stmt.filter(Model.provider_id == self.provider_id)
|
|
||||||
results = query_stmt.limit(self.limit).all()
|
results = query_stmt.limit(self.limit).all()
|
||||||
|
|
||||||
response = []
|
response = []
|
||||||
for model, provider in results:
|
for model in results:
|
||||||
global_model = model.global_model
|
global_model = model.global_model
|
||||||
display_name = global_model.display_name if global_model else model.provider_model_name
|
display_name = global_model.display_name if global_model else model.provider_model_name
|
||||||
unified_name = global_model.name if global_model else model.provider_model_name
|
unified_name = global_model.name if global_model else model.provider_model_name
|
||||||
model_data = PublicModelResponse(
|
model_data = PublicModelResponse(
|
||||||
id=model.id,
|
id=model.id,
|
||||||
provider_id=model.provider_id,
|
|
||||||
provider_name=provider.name,
|
|
||||||
name=unified_name,
|
name=unified_name,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
description=(
|
description=(
|
||||||
@@ -832,7 +794,7 @@ class PublicGlobalModelsAdapter(PublicApiAdapter):
|
|||||||
default_price_per_request=gm.default_price_per_request,
|
default_price_per_request=gm.default_price_per_request,
|
||||||
default_tiered_pricing=gm.default_tiered_pricing,
|
default_tiered_pricing=gm.default_tiered_pricing,
|
||||||
supported_capabilities=gm.supported_capabilities,
|
supported_capabilities=gm.supported_capabilities,
|
||||||
config=gm.config,
|
config=sanitize_public_global_model_config(gm.config),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from src.services.auth.service import AuthService
|
|||||||
from src.services.provider.format import normalize_endpoint_signature
|
from src.services.provider.format import normalize_endpoint_signature
|
||||||
|
|
||||||
router = APIRouter(tags=["System Catalog"])
|
router = APIRouter(tags=["System Catalog"])
|
||||||
|
OPENAI_MODEL_OWNER = "aether"
|
||||||
|
|
||||||
# 各格式对应的 API 格式列表(包括对应的 CLI 格式)
|
# 各格式对应的 API 格式列表(包括对应的 CLI 格式)
|
||||||
_CLAUDE_FORMATS = ["claude:chat", "claude:cli"]
|
_CLAUDE_FORMATS = ["claude:chat", "claude:cli"]
|
||||||
@@ -274,7 +275,7 @@ def _build_openai_list_response(models: list[ModelInfo]) -> dict:
|
|||||||
"id": m.id,
|
"id": m.id,
|
||||||
"object": "model",
|
"object": "model",
|
||||||
"created": m.created_timestamp,
|
"created": m.created_timestamp,
|
||||||
"owned_by": m.provider_name,
|
"owned_by": OPENAI_MODEL_OWNER,
|
||||||
}
|
}
|
||||||
for m in models
|
for m in models
|
||||||
]
|
]
|
||||||
@@ -339,7 +340,7 @@ def _build_openai_model_response(model_info: ModelInfo) -> dict:
|
|||||||
"id": model_info.id,
|
"id": model_info.id,
|
||||||
"object": "model",
|
"object": "model",
|
||||||
"created": model_info.created_timestamp,
|
"created": model_info.created_timestamp,
|
||||||
"owned_by": model_info.provider_name,
|
"owned_by": OPENAI_MODEL_OWNER,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,95 +9,17 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session, load_only, selectinload
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.api.handlers.base.request_builder import (
|
|
||||||
PassthroughRequestBuilder,
|
|
||||||
build_test_request_body,
|
|
||||||
get_provider_auth,
|
|
||||||
)
|
|
||||||
from src.clients.redis_client import get_redis_client
|
from src.clients.redis_client import get_redis_client
|
||||||
from src.core.logger import logger
|
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.database.database import get_pool_status
|
from src.database.database import get_pool_status
|
||||||
from src.models.database import GlobalModel, Model, Provider, ProviderAPIKey, ProviderEndpoint
|
from src.models.database import Model, Provider
|
||||||
from src.services.provider.transport import build_provider_url
|
|
||||||
from src.utils.ssl_utils import get_ssl_context
|
|
||||||
|
|
||||||
router = APIRouter(tags=["System Catalog"])
|
router = APIRouter(tags=["System Catalog"])
|
||||||
|
|
||||||
|
|
||||||
# ============== 辅助函数 ==============
|
|
||||||
|
|
||||||
|
|
||||||
def _as_bool(value: str | None, default: bool) -> bool:
|
|
||||||
"""将字符串转换为布尔值"""
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return value.lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_provider(
|
|
||||||
provider: Provider,
|
|
||||||
include_models: bool,
|
|
||||||
include_endpoints: bool,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""序列化 Provider 对象"""
|
|
||||||
provider_data: dict[str, Any] = {
|
|
||||||
"id": provider.id,
|
|
||||||
"name": provider.name,
|
|
||||||
"is_active": provider.is_active,
|
|
||||||
"provider_priority": provider.provider_priority,
|
|
||||||
}
|
|
||||||
|
|
||||||
if include_endpoints:
|
|
||||||
provider_data["endpoints"] = [
|
|
||||||
{
|
|
||||||
"id": endpoint.id,
|
|
||||||
"base_url": endpoint.base_url,
|
|
||||||
"api_format": endpoint.api_format if endpoint.api_format else None,
|
|
||||||
"is_active": endpoint.is_active,
|
|
||||||
}
|
|
||||||
for endpoint in provider.endpoints or []
|
|
||||||
]
|
|
||||||
|
|
||||||
if include_models:
|
|
||||||
provider_data["models"] = [
|
|
||||||
{
|
|
||||||
"id": model.id,
|
|
||||||
"name": (
|
|
||||||
model.global_model.name if model.global_model else model.provider_model_name
|
|
||||||
),
|
|
||||||
"display_name": (
|
|
||||||
model.global_model.display_name
|
|
||||||
if model.global_model
|
|
||||||
else model.provider_model_name
|
|
||||||
),
|
|
||||||
"is_active": model.is_active,
|
|
||||||
"supports_streaming": model.supports_streaming,
|
|
||||||
}
|
|
||||||
for model in provider.models or []
|
|
||||||
if model.is_active
|
|
||||||
]
|
|
||||||
|
|
||||||
return provider_data
|
|
||||||
|
|
||||||
|
|
||||||
def _select_provider(db: Session, provider_name: str | None) -> Provider | None:
|
|
||||||
"""选择 Provider(按 provider_priority 优先级选择)"""
|
|
||||||
query = db.query(Provider).filter(Provider.is_active.is_(True))
|
|
||||||
if provider_name:
|
|
||||||
provider = query.filter(Provider.name == provider_name).first()
|
|
||||||
if provider:
|
|
||||||
return provider
|
|
||||||
|
|
||||||
# 按优先级选择(provider_priority 最小的优先)
|
|
||||||
return query.order_by(Provider.provider_priority.asc()).first()
|
|
||||||
|
|
||||||
|
|
||||||
# ============== 端点 ==============
|
# ============== 端点 ==============
|
||||||
|
|
||||||
|
|
||||||
@@ -161,234 +83,31 @@ async def health_check() -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def root(db: Session = Depends(get_db)) -> Any:
|
async def root() -> Any:
|
||||||
"""Root endpoint - 服务信息概览"""
|
"""Root endpoint - 服务信息概览"""
|
||||||
# 按优先级选择最高优先级的提供商
|
|
||||||
top_provider = (
|
|
||||||
db.query(Provider)
|
|
||||||
.options(load_only(Provider.id, Provider.name, Provider.provider_priority))
|
|
||||||
.filter(Provider.is_active.is_(True))
|
|
||||||
.order_by(Provider.provider_priority.asc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
active_providers = (
|
|
||||||
db.query(func.count(Provider.id)).filter(Provider.is_active.is_(True)).scalar() or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "AI Proxy with Modular Architecture v4.0.0",
|
"message": "AI Proxy with Modular Architecture v4.0.0",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"current_provider": top_provider.name if top_provider else "None",
|
|
||||||
"available_providers": active_providers,
|
|
||||||
"config": {},
|
"config": {},
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
"messages": "/v1/messages",
|
"messages": "/v1/messages",
|
||||||
"count_tokens": "/v1/messages/count_tokens",
|
"count_tokens": "/v1/messages/count_tokens",
|
||||||
"health": "/v1/health",
|
"health": "/v1/health",
|
||||||
"providers": "/v1/providers",
|
|
||||||
"test_connection": "/v1/test-connection",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/v1/providers")
|
@router.get("/v1/providers", include_in_schema=False)
|
||||||
async def list_providers(
|
async def list_providers() -> Any:
|
||||||
db: Session = Depends(get_db),
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
include_models: bool = Query(False),
|
|
||||||
include_endpoints: bool = Query(False),
|
|
||||||
active_only: bool = Query(True),
|
|
||||||
) -> Any:
|
|
||||||
"""列出所有 Provider"""
|
|
||||||
load_options = [
|
|
||||||
load_only(Provider.id, Provider.name, Provider.is_active, Provider.provider_priority)
|
|
||||||
]
|
|
||||||
if include_models:
|
|
||||||
load_options.append(
|
|
||||||
selectinload(Provider.models)
|
|
||||||
.load_only(
|
|
||||||
Model.id,
|
|
||||||
Model.provider_model_name,
|
|
||||||
Model.is_active,
|
|
||||||
Model.supports_streaming,
|
|
||||||
Model.global_model_id,
|
|
||||||
)
|
|
||||||
.selectinload(Model.global_model)
|
|
||||||
.load_only(GlobalModel.id, GlobalModel.name, GlobalModel.display_name)
|
|
||||||
)
|
|
||||||
if include_endpoints:
|
|
||||||
load_options.append(
|
|
||||||
selectinload(Provider.endpoints).load_only(
|
|
||||||
ProviderEndpoint.id,
|
|
||||||
ProviderEndpoint.base_url,
|
|
||||||
ProviderEndpoint.api_format,
|
|
||||||
ProviderEndpoint.is_active,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
base_query = db.query(Provider)
|
|
||||||
if load_options:
|
|
||||||
base_query = base_query.options(*load_options)
|
|
||||||
if active_only:
|
|
||||||
base_query = base_query.filter(Provider.is_active.is_(True))
|
|
||||||
base_query = base_query.order_by(Provider.provider_priority.asc(), Provider.name.asc())
|
|
||||||
|
|
||||||
providers = base_query.all()
|
|
||||||
return {
|
|
||||||
"providers": [
|
|
||||||
_serialize_provider(provider, include_models, include_endpoints)
|
|
||||||
for provider in providers
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/v1/providers/{provider_identifier}")
|
@router.get("/v1/providers/{provider_identifier}", include_in_schema=False)
|
||||||
async def provider_detail(
|
async def provider_detail(provider_identifier: str) -> Any:
|
||||||
provider_identifier: str,
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
db: Session = Depends(get_db),
|
|
||||||
include_models: bool = Query(False),
|
|
||||||
include_endpoints: bool = Query(False),
|
|
||||||
) -> Any:
|
|
||||||
"""获取单个 Provider 详情"""
|
|
||||||
load_options = [
|
|
||||||
load_only(Provider.id, Provider.name, Provider.is_active, Provider.provider_priority)
|
|
||||||
]
|
|
||||||
if include_models:
|
|
||||||
load_options.append(
|
|
||||||
selectinload(Provider.models)
|
|
||||||
.load_only(
|
|
||||||
Model.id,
|
|
||||||
Model.provider_model_name,
|
|
||||||
Model.is_active,
|
|
||||||
Model.supports_streaming,
|
|
||||||
Model.global_model_id,
|
|
||||||
)
|
|
||||||
.selectinload(Model.global_model)
|
|
||||||
.load_only(GlobalModel.id, GlobalModel.name, GlobalModel.display_name)
|
|
||||||
)
|
|
||||||
if include_endpoints:
|
|
||||||
load_options.append(
|
|
||||||
selectinload(Provider.endpoints).load_only(
|
|
||||||
ProviderEndpoint.id,
|
|
||||||
ProviderEndpoint.base_url,
|
|
||||||
ProviderEndpoint.api_format,
|
|
||||||
ProviderEndpoint.is_active,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
base_query = db.query(Provider)
|
|
||||||
if load_options:
|
|
||||||
base_query = base_query.options(*load_options)
|
|
||||||
|
|
||||||
provider = base_query.filter(
|
|
||||||
(Provider.id == provider_identifier) | (Provider.name == provider_identifier)
|
|
||||||
).first()
|
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
return _serialize_provider(provider, include_models, include_endpoints)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/v1/test-connection")
|
@router.get("/v1/test-connection", include_in_schema=False)
|
||||||
@router.get("/test-connection")
|
@router.get("/test-connection", include_in_schema=False)
|
||||||
async def test_connection(
|
async def test_connection() -> Any:
|
||||||
request: Request,
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
db: Session = Depends(get_db),
|
|
||||||
provider: str | None = Query(None),
|
|
||||||
model: str = Query("claude-3-haiku-20240307"),
|
|
||||||
api_format: str | None = Query(None),
|
|
||||||
) -> Any:
|
|
||||||
"""测试 Provider 连接"""
|
|
||||||
selected_provider = _select_provider(db, provider)
|
|
||||||
if not selected_provider:
|
|
||||||
raise HTTPException(status_code=503, detail="No active provider available")
|
|
||||||
|
|
||||||
# Determine endpoint format: prefer explicit api_format; otherwise use the provider's first active endpoint.
|
|
||||||
active_endpoints: list[ProviderEndpoint] = [
|
|
||||||
ep for ep in (selected_provider.endpoints or []) if getattr(ep, "is_active", False)
|
|
||||||
]
|
|
||||||
if not active_endpoints:
|
|
||||||
raise HTTPException(status_code=503, detail="Provider has no active endpoints")
|
|
||||||
|
|
||||||
if api_format:
|
|
||||||
endpoint = next(
|
|
||||||
(ep for ep in active_endpoints if (ep.api_format or "") == api_format),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not endpoint:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Provider has no active endpoint for api_format={api_format}",
|
|
||||||
)
|
|
||||||
format_value = api_format
|
|
||||||
else:
|
|
||||||
endpoint = active_endpoints[0]
|
|
||||||
format_value = endpoint.api_format or "claude:chat"
|
|
||||||
|
|
||||||
# Pick an active ProviderAPIKey that supports this format (best-effort).
|
|
||||||
active_keys: list[ProviderAPIKey] = [
|
|
||||||
k for k in (selected_provider.api_keys or []) if getattr(k, "is_active", False)
|
|
||||||
]
|
|
||||||
if not active_keys:
|
|
||||||
raise HTTPException(status_code=503, detail="Provider has no active api keys")
|
|
||||||
|
|
||||||
def _key_supports_format(k: ProviderAPIKey) -> bool:
|
|
||||||
formats = getattr(k, "api_formats", None)
|
|
||||||
# None => supports all formats; [] => supports none
|
|
||||||
if formats is None:
|
|
||||||
return True
|
|
||||||
if isinstance(formats, list):
|
|
||||||
return str(format_value) in {str(x) for x in formats}
|
|
||||||
# unexpected type: be permissive
|
|
||||||
return True
|
|
||||||
|
|
||||||
key = next((k for k in active_keys if _key_supports_format(k)), active_keys[0])
|
|
||||||
|
|
||||||
# Build a safe test request body in the endpoint's format (via format conversion registry).
|
|
||||||
payload = build_test_request_body(
|
|
||||||
format_value,
|
|
||||||
request_data={
|
|
||||||
"model": model,
|
|
||||||
"messages": [{"role": "user", "content": "Health check"}],
|
|
||||||
"max_tokens": 5,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 获取认证信息(处理 Service Account 等异步认证场景)
|
|
||||||
auth_info = await get_provider_auth(endpoint, key)
|
|
||||||
|
|
||||||
request_builder = PassthroughRequestBuilder()
|
|
||||||
provider_payload, provider_headers = request_builder.build(
|
|
||||||
payload,
|
|
||||||
{},
|
|
||||||
endpoint,
|
|
||||||
key,
|
|
||||||
is_stream=False,
|
|
||||||
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
url = build_provider_url(
|
|
||||||
endpoint,
|
|
||||||
query_params=dict(request.query_params),
|
|
||||||
path_params={"model": model},
|
|
||||||
is_stream=False,
|
|
||||||
key=key,
|
|
||||||
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
|
|
||||||
resp = await client.post(url, json=provider_payload, headers=provider_headers)
|
|
||||||
resp.raise_for_status()
|
|
||||||
response = resp.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"provider": selected_provider.name,
|
|
||||||
"endpoint_id": getattr(endpoint, "id", None),
|
|
||||||
"api_format": format_value,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"response_id": response.get("id", "unknown"),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(f"API connectivity test failed: {exc}")
|
|
||||||
raise HTTPException(status_code=503, detail=str(exc))
|
|
||||||
|
|||||||
+3
-794
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
@@ -14,8 +14,8 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
|
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
|
||||||
from src.api.base.context import ApiRequestContext
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.models_service import sanitize_public_global_model_config
|
||||||
from src.api.base.pipeline import get_pipeline
|
from src.api.base.pipeline import get_pipeline
|
||||||
from src.config.constants import CacheTTL
|
|
||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.core.enums import UserRole
|
from src.core.enums import UserRole
|
||||||
from src.core.exceptions import (
|
from src.core.exceptions import (
|
||||||
@@ -32,7 +32,6 @@ from src.models.api import (
|
|||||||
CreateMyApiKeyRequest,
|
CreateMyApiKeyRequest,
|
||||||
PublicGlobalModelListResponse,
|
PublicGlobalModelListResponse,
|
||||||
PublicGlobalModelResponse,
|
PublicGlobalModelResponse,
|
||||||
UpdateApiKeyProvidersRequest,
|
|
||||||
UpdateMyApiKeyRequest,
|
UpdateMyApiKeyRequest,
|
||||||
UpdatePreferencesRequest,
|
UpdatePreferencesRequest,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
@@ -51,33 +50,14 @@ from src.models.database import (
|
|||||||
from src.services.auth.session_service import SessionService
|
from src.services.auth.session_service import SessionService
|
||||||
from src.services.cache.user_cache import UserCacheService
|
from src.services.cache.user_cache import UserCacheService
|
||||||
from src.services.system.config import SystemConfigService
|
from src.services.system.config import SystemConfigService
|
||||||
from src.services.system.time_range import TimeRangeParams
|
|
||||||
from src.services.usage.query import input_context_expr
|
|
||||||
from src.services.usage.service import UsageService
|
|
||||||
from src.services.user.apikey import ApiKeyService
|
from src.services.user.apikey import ApiKeyService
|
||||||
from src.services.user.bulk_cleanup import pre_clean_api_key
|
from src.services.user.bulk_cleanup import pre_clean_api_key
|
||||||
from src.services.user.preference import PreferenceService
|
from src.services.user.preference import PreferenceService
|
||||||
from src.services.wallet import WalletService
|
|
||||||
from src.utils.cache_decorator import cache_result
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
|
router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
|
||||||
pipeline = get_pipeline()
|
pipeline = get_pipeline()
|
||||||
|
|
||||||
|
|
||||||
def _calculate_token_cache_hit_rate(total_input_context: int, cache_read_tokens: int) -> float:
|
|
||||||
"""计算缓存命中率。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
total_input_context: 已归一化的总输入上下文 token 数
|
|
||||||
(由 query.py 的 input_context_expr() 统一计算,为 input + cache_read)。
|
|
||||||
cache_read_tokens: 缓存读取 token 数。
|
|
||||||
"""
|
|
||||||
context = max(0, int(total_input_context))
|
|
||||||
cached = max(0, int(cache_read_tokens))
|
|
||||||
if context == 0:
|
|
||||||
return 0.0
|
|
||||||
return round(cached / context * 100, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def _update_profile_sync(
|
def _update_profile_sync(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -292,7 +272,6 @@ def _update_my_api_key_sync(
|
|||||||
"key_display": updated.get_display_key(),
|
"key_display": updated.get_display_key(),
|
||||||
"is_active": updated.is_active,
|
"is_active": updated.is_active,
|
||||||
"is_locked": updated.is_locked,
|
"is_locked": updated.is_locked,
|
||||||
"allowed_providers": updated.allowed_providers,
|
|
||||||
"force_capabilities": updated.force_capabilities,
|
"force_capabilities": updated.force_capabilities,
|
||||||
"rate_limit": updated.rate_limit,
|
"rate_limit": updated.rate_limit,
|
||||||
"last_used_at": updated.last_used_at.isoformat() if updated.last_used_at else None,
|
"last_used_at": updated.last_used_at.isoformat() if updated.last_used_at else None,
|
||||||
@@ -302,41 +281,6 @@ def _update_my_api_key_sync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _update_api_key_providers_sync(
|
|
||||||
user_id: str,
|
|
||||||
api_key_id: str,
|
|
||||||
request: UpdateApiKeyProvidersRequest,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
with get_db_context() as db:
|
|
||||||
api_key = (
|
|
||||||
db.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.user_id == user_id).first()
|
|
||||||
)
|
|
||||||
if not api_key:
|
|
||||||
raise NotFoundException("API密钥不存在")
|
|
||||||
if api_key.is_locked:
|
|
||||||
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
|
|
||||||
|
|
||||||
if request.allowed_providers is not None and len(request.allowed_providers) > 0:
|
|
||||||
provider_ids = [cfg.provider_id for cfg in request.allowed_providers]
|
|
||||||
valid = (
|
|
||||||
db.query(Provider.id)
|
|
||||||
.filter(Provider.id.in_(provider_ids), Provider.is_active.is_(True))
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
valid_ids = {p.id for p in valid}
|
|
||||||
invalid = set(provider_ids) - valid_ids
|
|
||||||
if invalid:
|
|
||||||
raise InvalidRequestException(f"无效的提供商ID: {', '.join(invalid)}")
|
|
||||||
|
|
||||||
api_key.allowed_providers = (
|
|
||||||
[cfg.provider_id for cfg in request.allowed_providers]
|
|
||||||
if request.allowed_providers is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
api_key.updated_at = datetime.now(timezone.utc)
|
|
||||||
return {"message": "API密钥可用提供商已更新"}
|
|
||||||
|
|
||||||
|
|
||||||
def _update_api_key_capabilities_sync(
|
def _update_api_key_capabilities_sync(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
api_key_id: str,
|
api_key_id: str,
|
||||||
@@ -397,7 +341,6 @@ def _update_preferences_sync(user_id: str, request: UpdatePreferencesRequest) ->
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
avatar_url=request.avatar_url,
|
avatar_url=request.avatar_url,
|
||||||
bio=request.bio,
|
bio=request.bio,
|
||||||
default_provider_id=request.default_provider_id,
|
|
||||||
theme=request.theme,
|
theme=request.theme,
|
||||||
language=request.language,
|
language=request.language,
|
||||||
timezone=request.timezone,
|
timezone=request.timezone,
|
||||||
@@ -459,29 +402,6 @@ def _update_model_capability_settings_sync(
|
|||||||
"message": "模型能力配置已更新",
|
"message": "模型能力配置已更新",
|
||||||
"model_capability_settings": user.model_capability_settings,
|
"model_capability_settings": user.model_capability_settings,
|
||||||
}, user.email
|
}, user.email
|
||||||
|
|
||||||
|
|
||||||
def _build_time_range_params(
|
|
||||||
start_date: date | None,
|
|
||||||
end_date: date | None,
|
|
||||||
preset: str | None,
|
|
||||||
timezone_name: str | None,
|
|
||||||
tz_offset_minutes: int | None,
|
|
||||||
) -> TimeRangeParams | None:
|
|
||||||
if not preset and start_date is None and end_date is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return TimeRangeParams(
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=end_date,
|
|
||||||
preset=preset,
|
|
||||||
timezone=timezone_name,
|
|
||||||
tz_offset_minutes=tz_offset_minutes or 0,
|
|
||||||
).validate_and_resolve()
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_my_profile(request: Request, db: Session = Depends(get_db)) -> Any:
|
async def get_my_profile(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -661,109 +581,6 @@ async def toggle_my_api_key(key_id: str, request: Request, db: Session = Depends
|
|||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
# ============== 使用统计 ==============
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/usage")
|
|
||||||
async def get_my_usage(
|
|
||||||
request: Request,
|
|
||||||
start_date: date | None = Query(None, description="开始日期(YYYY-MM-DD)"),
|
|
||||||
end_date: date | None = Query(None, description="结束日期(YYYY-MM-DD)"),
|
|
||||||
preset: str | None = Query(None, description="时间预设(today/last7days 等)"),
|
|
||||||
timezone_name: str | None = Query(None, alias="timezone"),
|
|
||||||
tz_offset_minutes: int | None = Query(None, description="时区偏移(分钟)"),
|
|
||||||
search: str | None = Query(None, description="搜索关键词(密钥名、模型名)"),
|
|
||||||
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
|
|
||||||
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
获取使用统计
|
|
||||||
|
|
||||||
获取当前用户的 API 使用统计数据,包括总量汇总、按模型/提供商分组统计及详细记录。
|
|
||||||
|
|
||||||
**返回字段**:
|
|
||||||
- `total_requests`: 总请求数
|
|
||||||
- `total_tokens`: 总 Token 数
|
|
||||||
- `total_cost`: 总成本(USD)
|
|
||||||
- `summary_by_model`: 按模型分组统计(含 `cache_read_tokens`、`cache_hit_rate`)
|
|
||||||
- `summary_by_provider`: 按提供商分组统计(含 `cache_read_tokens`、`cache_hit_rate`)
|
|
||||||
- `records`: 详细使用记录列表
|
|
||||||
- `pagination`: 分页信息
|
|
||||||
"""
|
|
||||||
time_range = _build_time_range_params(
|
|
||||||
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
|
||||||
)
|
|
||||||
adapter = GetUsageAdapter(time_range=time_range, search=search, limit=limit, offset=offset)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/usage/active")
|
|
||||||
async def get_my_active_requests(
|
|
||||||
request: Request,
|
|
||||||
ids: str | None = Query(None, description="请求 ID 列表,逗号分隔"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
获取活跃请求状态
|
|
||||||
|
|
||||||
查询正在进行中的请求状态,用于前端轮询更新流式请求的进度。
|
|
||||||
|
|
||||||
**查询参数**:
|
|
||||||
- `ids`: 要查询的请求 ID 列表,逗号分隔
|
|
||||||
"""
|
|
||||||
adapter = GetActiveRequestsAdapter(ids=ids)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/usage/interval-timeline")
|
|
||||||
async def get_my_interval_timeline(
|
|
||||||
request: Request,
|
|
||||||
hours: int = Query(24, ge=1, le=720, description="分析最近多少小时的数据"),
|
|
||||||
limit: int = Query(2000, ge=100, le=20000, description="最大返回数据点数量"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
获取请求间隔时间线
|
|
||||||
|
|
||||||
获取请求间隔时间线数据,用于散点图展示请求分布情况。
|
|
||||||
|
|
||||||
**返回**: 包含时间戳和间隔时间的数据点列表
|
|
||||||
"""
|
|
||||||
adapter = GetMyIntervalTimelineAdapter(hours=hours, limit=limit)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/usage/heatmap")
|
|
||||||
async def get_my_activity_heatmap(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
获取活动热力图数据
|
|
||||||
|
|
||||||
获取过去 365 天的活动热力图数据,用于展示每日使用频率。
|
|
||||||
此接口有 5 分钟缓存。
|
|
||||||
|
|
||||||
**返回**: 包含日期和请求数量的数据列表
|
|
||||||
"""
|
|
||||||
adapter = GetMyActivityHeatmapAdapter()
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers")
|
|
||||||
async def list_available_providers(request: Request, db: Session = Depends(get_db)) -> Any:
|
|
||||||
"""
|
|
||||||
获取可用提供商列表
|
|
||||||
|
|
||||||
获取当前用户可用的所有提供商及其模型信息。
|
|
||||||
|
|
||||||
**返回字段**: id, name, display_name, endpoints, models 等
|
|
||||||
"""
|
|
||||||
adapter = ListAvailableProvidersAdapter()
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/available-models")
|
@router.get("/available-models")
|
||||||
async def list_available_models(
|
async def list_available_models(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -805,33 +622,6 @@ async def get_endpoint_status(request: Request, db: Session = Depends(get_db)) -
|
|||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
# ============== API密钥与提供商关联 ==============
|
|
||||||
|
|
||||||
|
|
||||||
# UpdateApiKeyProvidersRequest 已移至 src/models/api.py
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api-keys/{api_key_id}/providers")
|
|
||||||
async def update_api_key_providers(
|
|
||||||
api_key_id: str,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
更新 API 密钥可用提供商
|
|
||||||
|
|
||||||
设置指定 API 密钥可以使用哪些提供商。未设置时使用用户默认权限。
|
|
||||||
|
|
||||||
**路径参数**:
|
|
||||||
- `api_key_id`: API 密钥 ID
|
|
||||||
|
|
||||||
**请求体**:
|
|
||||||
- `allowed_providers`: 允许的提供商 ID 列表
|
|
||||||
"""
|
|
||||||
adapter = UpdateApiKeyProvidersAdapter(api_key_id=api_key_id)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api-keys/{api_key_id}/capabilities")
|
@router.put("/api-keys/{api_key_id}/capabilities")
|
||||||
async def update_api_key_capabilities(
|
async def update_api_key_capabilities(
|
||||||
api_key_id: str,
|
api_key_id: str,
|
||||||
@@ -1099,7 +889,6 @@ class ListMyApiKeysAdapter(AuthenticatedApiAdapter):
|
|||||||
"total_requests": real_stats["total_requests"],
|
"total_requests": real_stats["total_requests"],
|
||||||
"total_cost_usd": real_stats["total_cost_usd"],
|
"total_cost_usd": real_stats["total_cost_usd"],
|
||||||
"rate_limit": key.rate_limit,
|
"rate_limit": key.rate_limit,
|
||||||
"allowed_providers": key.allowed_providers,
|
|
||||||
"force_capabilities": key.force_capabilities,
|
"force_capabilities": key.force_capabilities,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1178,7 +967,6 @@ class GetMyApiKeyDetailAdapter(AuthenticatedApiAdapter):
|
|||||||
"key_display": api_key.get_display_key(),
|
"key_display": api_key.get_display_key(),
|
||||||
"is_active": api_key.is_active,
|
"is_active": api_key.is_active,
|
||||||
"is_locked": api_key.is_locked,
|
"is_locked": api_key.is_locked,
|
||||||
"allowed_providers": api_key.allowed_providers,
|
|
||||||
"force_capabilities": api_key.force_capabilities,
|
"force_capabilities": api_key.force_capabilities,
|
||||||
"rate_limit": api_key.rate_limit,
|
"rate_limit": api_key.rate_limit,
|
||||||
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
|
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
|
||||||
@@ -1228,484 +1016,6 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
|
|||||||
return await run_in_threadpool(_toggle_my_api_key_sync, context.user.id, self.key_id)
|
return await run_in_threadpool(_toggle_my_api_key_sync, context.user.id, self.key_id)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GetUsageAdapter(AuthenticatedApiAdapter):
|
|
||||||
"""获取用户使用统计的适配器"""
|
|
||||||
|
|
||||||
time_range: TimeRangeParams | None
|
|
||||||
search: str | None = None
|
|
||||||
limit: int = 100
|
|
||||||
offset: int = 0
|
|
||||||
|
|
||||||
@cache_result(
|
|
||||||
key_prefix="user:usage:records",
|
|
||||||
ttl=3, # 使用记录页强调实时性,避免 15s 缓存导致列表滞后
|
|
||||||
user_specific=True,
|
|
||||||
vary_by=[
|
|
||||||
"time_range.start_date",
|
|
||||||
"time_range.end_date",
|
|
||||||
"time_range.preset",
|
|
||||||
"time_range.timezone",
|
|
||||||
"time_range.tz_offset_minutes",
|
|
||||||
"search",
|
|
||||||
"limit",
|
|
||||||
"offset",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
from sqlalchemy import or_
|
|
||||||
from sqlalchemy.orm import load_only
|
|
||||||
|
|
||||||
from src.models.database import ProviderEndpoint
|
|
||||||
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
|
|
||||||
|
|
||||||
db = context.db
|
|
||||||
user = context.user
|
|
||||||
start_utc = end_utc = None
|
|
||||||
if self.time_range:
|
|
||||||
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
|
||||||
summary_list = UsageService.get_usage_summary(
|
|
||||||
db=db,
|
|
||||||
user_id=user.id,
|
|
||||||
start_date=start_utc,
|
|
||||||
end_date=end_utc,
|
|
||||||
group_by=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 过滤掉 unknown/pending provider 的记录(请求未到达任何提供商)
|
|
||||||
filtered_summary = [
|
|
||||||
item
|
|
||||||
for item in summary_list
|
|
||||||
if item.get("provider") not in ("unknown", "pending", None)
|
|
||||||
]
|
|
||||||
|
|
||||||
total_requests = 0
|
|
||||||
total_input_tokens = 0
|
|
||||||
total_output_tokens = 0
|
|
||||||
total_tokens = 0
|
|
||||||
total_cost = 0.0
|
|
||||||
total_actual_cost = 0.0
|
|
||||||
model_summary = {}
|
|
||||||
provider_summary = {}
|
|
||||||
for item in filtered_summary:
|
|
||||||
total_requests += item["requests"]
|
|
||||||
total_input_tokens += item["input_tokens"]
|
|
||||||
total_output_tokens += item["output_tokens"]
|
|
||||||
total_tokens += item["total_tokens"]
|
|
||||||
total_cost += item["total_cost_usd"]
|
|
||||||
if user.role == UserRole.ADMIN:
|
|
||||||
total_actual_cost += item.get("actual_total_cost_usd", 0.0)
|
|
||||||
|
|
||||||
model_name = item["model"]
|
|
||||||
base_stats = {
|
|
||||||
"model": model_name,
|
|
||||||
"requests": 0,
|
|
||||||
"input_tokens": 0,
|
|
||||||
"output_tokens": 0,
|
|
||||||
"total_tokens": 0,
|
|
||||||
"cache_read_tokens": 0,
|
|
||||||
"cache_creation_tokens": 0,
|
|
||||||
"total_input_context": 0,
|
|
||||||
"cache_hit_rate": 0.0,
|
|
||||||
"total_cost_usd": 0.0,
|
|
||||||
}
|
|
||||||
# 管理员可以看到真实成本
|
|
||||||
if user.role == UserRole.ADMIN:
|
|
||||||
base_stats["actual_total_cost_usd"] = 0.0
|
|
||||||
|
|
||||||
stats = model_summary.setdefault(model_name, base_stats)
|
|
||||||
stats["requests"] += item["requests"]
|
|
||||||
stats["input_tokens"] += item["input_tokens"]
|
|
||||||
stats["output_tokens"] += item["output_tokens"]
|
|
||||||
stats["total_tokens"] += item["total_tokens"]
|
|
||||||
stats["cache_read_tokens"] += int(item.get("cache_read_tokens", 0) or 0)
|
|
||||||
stats["cache_creation_tokens"] += int(item.get("cache_creation_tokens", 0) or 0)
|
|
||||||
stats["total_input_context"] += int(item.get("total_input_context", 0) or 0)
|
|
||||||
stats["total_cost_usd"] += item["total_cost_usd"]
|
|
||||||
# 管理员可以看到真实成本
|
|
||||||
if user.role == UserRole.ADMIN:
|
|
||||||
stats["actual_total_cost_usd"] += item.get("actual_total_cost_usd", 0.0)
|
|
||||||
|
|
||||||
provider_name = item["provider"]
|
|
||||||
provider_base_stats = {
|
|
||||||
"provider": provider_name,
|
|
||||||
"requests": 0,
|
|
||||||
"total_tokens": 0,
|
|
||||||
"output_tokens": 0,
|
|
||||||
"cache_read_tokens": 0,
|
|
||||||
"cache_creation_tokens": 0,
|
|
||||||
"total_input_context": 0,
|
|
||||||
"total_cost_usd": 0.0,
|
|
||||||
"success_count": 0,
|
|
||||||
"total_response_time_ms": 0.0,
|
|
||||||
"response_time_count": 0,
|
|
||||||
}
|
|
||||||
provider_stats = provider_summary.setdefault(provider_name, provider_base_stats)
|
|
||||||
provider_stats["requests"] += item["requests"]
|
|
||||||
provider_stats["total_tokens"] += item["total_tokens"]
|
|
||||||
provider_stats["output_tokens"] += item.get("output_tokens", 0) or 0
|
|
||||||
provider_stats["cache_read_tokens"] += int(item.get("cache_read_tokens", 0) or 0)
|
|
||||||
provider_stats["cache_creation_tokens"] += int(
|
|
||||||
item.get("cache_creation_tokens", 0) or 0
|
|
||||||
)
|
|
||||||
provider_stats["total_input_context"] += int(item.get("total_input_context", 0) or 0)
|
|
||||||
provider_stats["total_cost_usd"] += item["total_cost_usd"]
|
|
||||||
provider_stats["success_count"] += int(item.get("success_count", 0) or 0)
|
|
||||||
success_response_time_count = int(item.get("success_response_time_count", 0) or 0)
|
|
||||||
if success_response_time_count > 0:
|
|
||||||
provider_stats["total_response_time_ms"] += float(
|
|
||||||
item.get("success_response_time_sum_ms", 0.0) or 0.0
|
|
||||||
)
|
|
||||||
provider_stats["response_time_count"] += success_response_time_count
|
|
||||||
|
|
||||||
for model_stats in model_summary.values():
|
|
||||||
model_stats["cache_hit_rate"] = _calculate_token_cache_hit_rate(
|
|
||||||
total_input_context=int(model_stats.get("total_input_context", 0) or 0),
|
|
||||||
cache_read_tokens=int(model_stats.get("cache_read_tokens", 0) or 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
summary_by_model = sorted(model_summary.values(), key=lambda x: x["requests"], reverse=True)
|
|
||||||
summary_by_provider = []
|
|
||||||
for provider_stats in provider_summary.values():
|
|
||||||
avg_response_time_ms = (
|
|
||||||
provider_stats["total_response_time_ms"] / provider_stats["response_time_count"]
|
|
||||||
if provider_stats["response_time_count"] > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
success_rate = (
|
|
||||||
(provider_stats["success_count"] / provider_stats["requests"] * 100)
|
|
||||||
if provider_stats["requests"] > 0
|
|
||||||
else 100
|
|
||||||
)
|
|
||||||
summary_by_provider.append(
|
|
||||||
{
|
|
||||||
"provider": provider_stats["provider"],
|
|
||||||
"requests": provider_stats["requests"],
|
|
||||||
"total_tokens": provider_stats["total_tokens"],
|
|
||||||
"total_input_context": provider_stats["total_input_context"],
|
|
||||||
"output_tokens": provider_stats["output_tokens"],
|
|
||||||
"cache_read_tokens": provider_stats["cache_read_tokens"],
|
|
||||||
"cache_creation_tokens": provider_stats["cache_creation_tokens"],
|
|
||||||
"cache_hit_rate": _calculate_token_cache_hit_rate(
|
|
||||||
total_input_context=int(provider_stats.get("total_input_context", 0) or 0),
|
|
||||||
cache_read_tokens=int(provider_stats.get("cache_read_tokens", 0) or 0),
|
|
||||||
),
|
|
||||||
"total_cost_usd": provider_stats["total_cost_usd"],
|
|
||||||
"success_rate": round(success_rate, 2),
|
|
||||||
"avg_response_time_ms": round(avg_response_time_ms, 2),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
|
|
||||||
|
|
||||||
# 按 api_format 聚合统计(独立查询,因为 get_usage_summary 按 provider+model 分组无此维度)
|
|
||||||
api_format_query = db.query(
|
|
||||||
Usage.api_format,
|
|
||||||
func.count(Usage.id).label("request_count"),
|
|
||||||
func.sum(Usage.total_tokens).label("total_tokens"),
|
|
||||||
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
|
||||||
func.sum(input_context_expr()).label("total_input_context"),
|
|
||||||
func.sum(Usage.output_tokens).label("output_tokens"),
|
|
||||||
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
|
|
||||||
func.sum(Usage.total_cost_usd).label("total_cost_usd"),
|
|
||||||
func.avg(Usage.response_time_ms).label("avg_response_time_ms"),
|
|
||||||
).filter(
|
|
||||||
Usage.user_id == user.id,
|
|
||||||
Usage.status.notin_(["pending", "streaming"]),
|
|
||||||
Usage.provider_name.notin_(["unknown", "pending"]),
|
|
||||||
Usage.api_format.isnot(None),
|
|
||||||
)
|
|
||||||
if start_utc and end_utc:
|
|
||||||
api_format_query = api_format_query.filter(
|
|
||||||
Usage.created_at >= start_utc, Usage.created_at < end_utc
|
|
||||||
)
|
|
||||||
api_format_stats = (
|
|
||||||
api_format_query.group_by(Usage.api_format).order_by(func.count(Usage.id).desc()).all()
|
|
||||||
)
|
|
||||||
summary_by_api_format = [
|
|
||||||
{
|
|
||||||
"api_format": api_format or "unknown",
|
|
||||||
"request_count": count,
|
|
||||||
"total_tokens": int(total_tokens or 0),
|
|
||||||
"total_input_context": int(total_input_context or 0),
|
|
||||||
"output_tokens": int(output_tokens or 0),
|
|
||||||
"cache_read_tokens": int(cache_read_tokens or 0),
|
|
||||||
"cache_creation_tokens": int(cache_creation_tokens or 0),
|
|
||||||
"cache_hit_rate": _calculate_token_cache_hit_rate(
|
|
||||||
total_input_context=total_input_context,
|
|
||||||
cache_read_tokens=cache_read_tokens,
|
|
||||||
),
|
|
||||||
"total_cost_usd": float(total_cost_usd or 0),
|
|
||||||
"avg_response_time_ms": float(avg_response_time_ms or 0),
|
|
||||||
}
|
|
||||||
for (
|
|
||||||
api_format,
|
|
||||||
count,
|
|
||||||
total_tokens,
|
|
||||||
cache_read_tokens,
|
|
||||||
total_input_context,
|
|
||||||
output_tokens,
|
|
||||||
cache_creation_tokens,
|
|
||||||
total_cost_usd,
|
|
||||||
avg_response_time_ms,
|
|
||||||
) in api_format_stats
|
|
||||||
]
|
|
||||||
|
|
||||||
query = (
|
|
||||||
db.query(Usage, ApiKey, ProviderEndpoint)
|
|
||||||
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
|
||||||
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
|
||||||
.filter(Usage.user_id == user.id)
|
|
||||||
)
|
|
||||||
if start_utc and end_utc:
|
|
||||||
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
|
||||||
|
|
||||||
# 通用搜索:密钥名、模型名
|
|
||||||
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
|
||||||
if self.search and self.search.strip():
|
|
||||||
keywords = [kw for kw in self.search.strip().split() if kw][:10]
|
|
||||||
for keyword in keywords:
|
|
||||||
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
|
|
||||||
search_pattern = f"%{escaped}%"
|
|
||||||
query = query.filter(
|
|
||||||
or_(
|
|
||||||
ApiKey.name.ilike(search_pattern, escape="\\"),
|
|
||||||
Usage.model.ilike(search_pattern, escape="\\"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 计算总数用于分页
|
|
||||||
# Perf: avoid Query.count() building a subquery selecting many columns
|
|
||||||
total_records = int(query.with_entities(func.count(Usage.id)).scalar() or 0)
|
|
||||||
|
|
||||||
# Perf: do not load large request/response columns for list view
|
|
||||||
query = query.options(
|
|
||||||
load_only(
|
|
||||||
Usage.id,
|
|
||||||
Usage.user_id,
|
|
||||||
Usage.api_key_id,
|
|
||||||
Usage.provider_name,
|
|
||||||
Usage.model,
|
|
||||||
Usage.target_model,
|
|
||||||
Usage.input_tokens,
|
|
||||||
Usage.output_tokens,
|
|
||||||
Usage.total_tokens,
|
|
||||||
Usage.total_cost_usd,
|
|
||||||
Usage.response_time_ms,
|
|
||||||
Usage.first_byte_time_ms,
|
|
||||||
Usage.is_stream,
|
|
||||||
Usage.status,
|
|
||||||
Usage.created_at,
|
|
||||||
Usage.cache_creation_input_tokens,
|
|
||||||
Usage.cache_read_input_tokens,
|
|
||||||
Usage.status_code,
|
|
||||||
Usage.error_message,
|
|
||||||
Usage.api_format,
|
|
||||||
Usage.endpoint_api_format,
|
|
||||||
Usage.has_format_conversion,
|
|
||||||
Usage.input_price_per_1m,
|
|
||||||
Usage.output_price_per_1m,
|
|
||||||
Usage.cache_creation_price_per_1m,
|
|
||||||
Usage.cache_read_price_per_1m,
|
|
||||||
Usage.actual_total_cost_usd,
|
|
||||||
Usage.rate_multiplier,
|
|
||||||
),
|
|
||||||
load_only(ApiKey.id, ApiKey.name, ApiKey.key_encrypted),
|
|
||||||
load_only(ProviderEndpoint.id, ProviderEndpoint.api_format),
|
|
||||||
)
|
|
||||||
usage_records = (
|
|
||||||
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 复用 summary 聚合中的成功请求响应时间,避免额外 AVG SQL
|
|
||||||
total_success_response_time_ms = sum(
|
|
||||||
float(item.get("success_response_time_sum_ms", 0.0) or 0.0) for item in summary_list
|
|
||||||
)
|
|
||||||
total_success_response_count = sum(
|
|
||||||
int(item.get("success_response_time_count", 0) or 0) for item in summary_list
|
|
||||||
)
|
|
||||||
avg_response_time = (
|
|
||||||
total_success_response_time_ms / total_success_response_count / 1000.0
|
|
||||||
if total_success_response_count > 0
|
|
||||||
else 0.0
|
|
||||||
)
|
|
||||||
wallet = WalletService.get_wallet(db, user_id=user.id)
|
|
||||||
|
|
||||||
# 构建响应数据
|
|
||||||
response_data = {
|
|
||||||
"total_requests": total_requests,
|
|
||||||
"total_input_tokens": total_input_tokens,
|
|
||||||
"total_output_tokens": total_output_tokens,
|
|
||||||
"total_tokens": total_tokens,
|
|
||||||
"total_cost": total_cost,
|
|
||||||
"avg_response_time": avg_response_time,
|
|
||||||
"billing": WalletService.serialize_wallet_summary(wallet),
|
|
||||||
"summary_by_model": summary_by_model,
|
|
||||||
"summary_by_provider": summary_by_provider,
|
|
||||||
"summary_by_api_format": summary_by_api_format,
|
|
||||||
# 分页信息
|
|
||||||
"pagination": {
|
|
||||||
"total": total_records,
|
|
||||||
"limit": self.limit,
|
|
||||||
"offset": self.offset,
|
|
||||||
"has_more": self.offset + self.limit < total_records,
|
|
||||||
},
|
|
||||||
"records": self._build_usage_records(
|
|
||||||
usage_records, is_admin=(user.role == UserRole.ADMIN)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 管理员可以看到真实成本
|
|
||||||
if user.role == UserRole.ADMIN:
|
|
||||||
response_data["total_actual_cost"] = total_actual_cost
|
|
||||||
# 为每条记录添加真实成本和倍率信息
|
|
||||||
for i, (r, _, _) in enumerate(usage_records):
|
|
||||||
# 确保字段有值,避免前端显示 -
|
|
||||||
actual_cost = (
|
|
||||||
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
|
|
||||||
)
|
|
||||||
rate_mult = r.rate_multiplier if r.rate_multiplier is not None else 1.0
|
|
||||||
response_data["records"][i]["actual_cost"] = actual_cost
|
|
||||||
response_data["records"][i]["rate_multiplier"] = rate_mult
|
|
||||||
|
|
||||||
return response_data
|
|
||||||
|
|
||||||
def _build_usage_records(self, usage_records: list, is_admin: bool = False) -> list:
|
|
||||||
"""构建使用记录列表,包含格式转换信息的回填逻辑
|
|
||||||
|
|
||||||
Args:
|
|
||||||
usage_records: 使用记录列表
|
|
||||||
is_admin: 是否为管理员,管理员可以看到模型映射信息
|
|
||||||
"""
|
|
||||||
from src.core.api_format.metadata import can_passthrough_endpoint
|
|
||||||
from src.core.api_format.signature import normalize_signature_key
|
|
||||||
|
|
||||||
records = []
|
|
||||||
for r, api_key, endpoint in usage_records:
|
|
||||||
# 格式转换追踪(兼容历史数据:尽量回填可展示信息)
|
|
||||||
api_format = r.api_format
|
|
||||||
endpoint_api_format = r.endpoint_api_format or (
|
|
||||||
endpoint.api_format if endpoint else None
|
|
||||||
)
|
|
||||||
|
|
||||||
has_format_conversion = r.has_format_conversion
|
|
||||||
if has_format_conversion is None:
|
|
||||||
# 新模式:仅对 signature 进行推断(历史旧值保持 False,避免解析失败)
|
|
||||||
client_raw = str(api_format or "").strip()
|
|
||||||
endpoint_raw = str(endpoint_api_format or "").strip()
|
|
||||||
if client_raw and endpoint_raw and ":" in client_raw and ":" in endpoint_raw:
|
|
||||||
client_fmt = normalize_signature_key(client_raw)
|
|
||||||
endpoint_fmt = normalize_signature_key(endpoint_raw)
|
|
||||||
has_format_conversion = not can_passthrough_endpoint(client_fmt, endpoint_fmt)
|
|
||||||
else:
|
|
||||||
has_format_conversion = False
|
|
||||||
|
|
||||||
records.append(
|
|
||||||
{
|
|
||||||
"id": r.id,
|
|
||||||
"model": r.model,
|
|
||||||
# 只有管理员可以看到模型映射信息,普通用户只能看到请求的模型
|
|
||||||
"target_model": r.target_model if is_admin else None,
|
|
||||||
"api_format": api_format,
|
|
||||||
"endpoint_api_format": endpoint_api_format,
|
|
||||||
"has_format_conversion": bool(has_format_conversion),
|
|
||||||
"input_tokens": r.input_tokens,
|
|
||||||
"output_tokens": r.output_tokens,
|
|
||||||
"total_tokens": r.total_tokens,
|
|
||||||
"cost": float(r.total_cost_usd or 0),
|
|
||||||
"response_time_ms": r.response_time_ms,
|
|
||||||
"first_byte_time_ms": r.first_byte_time_ms,
|
|
||||||
"is_stream": r.is_stream,
|
|
||||||
"status": r.status, # 请求状态: pending, streaming, completed, failed
|
|
||||||
"created_at": r.created_at.isoformat(),
|
|
||||||
"cache_creation_input_tokens": r.cache_creation_input_tokens,
|
|
||||||
"cache_read_input_tokens": r.cache_read_input_tokens,
|
|
||||||
"status_code": r.status_code,
|
|
||||||
"error_message": r.error_message,
|
|
||||||
"input_price_per_1m": r.input_price_per_1m,
|
|
||||||
"output_price_per_1m": r.output_price_per_1m,
|
|
||||||
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
|
|
||||||
"cache_read_price_per_1m": r.cache_read_price_per_1m,
|
|
||||||
"api_key": (
|
|
||||||
{
|
|
||||||
"id": str(api_key.id),
|
|
||||||
"name": api_key.name,
|
|
||||||
"display": api_key.get_display_key(),
|
|
||||||
}
|
|
||||||
if api_key
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GetActiveRequestsAdapter(AuthenticatedApiAdapter):
|
|
||||||
"""轻量级活跃请求状态查询适配器(用于用户端轮询)"""
|
|
||||||
|
|
||||||
ids: str | None = None
|
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
from src.services.usage import UsageService
|
|
||||||
|
|
||||||
db = context.db
|
|
||||||
user = context.user
|
|
||||||
id_list = None
|
|
||||||
if self.ids:
|
|
||||||
id_list = [id.strip() for id in self.ids.split(",") if id.strip()]
|
|
||||||
if not id_list:
|
|
||||||
return {"requests": []}
|
|
||||||
|
|
||||||
requests = UsageService.get_active_requests_status(
|
|
||||||
db=db,
|
|
||||||
ids=id_list,
|
|
||||||
user_id=user.id,
|
|
||||||
maintain_status=True,
|
|
||||||
)
|
|
||||||
return {"requests": requests}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter):
|
|
||||||
"""获取当前用户的请求间隔时间线适配器"""
|
|
||||||
|
|
||||||
hours: int
|
|
||||||
limit: int
|
|
||||||
|
|
||||||
@cache_result(
|
|
||||||
key_prefix="user:usage:interval_timeline",
|
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
|
||||||
user_specific=True,
|
|
||||||
vary_by=["hours", "limit"],
|
|
||||||
)
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
db = context.db
|
|
||||||
user = context.user
|
|
||||||
|
|
||||||
result = UsageService.get_interval_timeline(
|
|
||||||
db=db,
|
|
||||||
hours=self.hours,
|
|
||||||
limit=self.limit,
|
|
||||||
user_id=str(user.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class GetMyActivityHeatmapAdapter(AuthenticatedApiAdapter):
|
|
||||||
"""获取用户活动热力图数据的适配器(带 Redis 缓存)"""
|
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
user = context.user
|
|
||||||
result = await UsageService.get_cached_heatmap(
|
|
||||||
db=context.db,
|
|
||||||
user_id=user.id,
|
|
||||||
include_actual_cost=user.role == UserRole.ADMIN,
|
|
||||||
)
|
|
||||||
context.add_audit_metadata(action="activity_heatmap")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
|
class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
|
||||||
"""获取用户可用模型列表的适配器
|
"""获取用户可用模型列表的适配器
|
||||||
@@ -1807,7 +1117,7 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
|
|||||||
default_price_per_request=gm.default_price_per_request,
|
default_price_per_request=gm.default_price_per_request,
|
||||||
default_tiered_pricing=gm.default_tiered_pricing,
|
default_tiered_pricing=gm.default_tiered_pricing,
|
||||||
supported_capabilities=gm.supported_capabilities,
|
supported_capabilities=gm.supported_capabilities,
|
||||||
config=gm.config,
|
config=sanitize_public_global_model_config(gm.config),
|
||||||
usage_count=user_usage_map.get(gm.name, 0),
|
usage_count=user_usage_map.get(gm.name, 0),
|
||||||
)
|
)
|
||||||
for gm in models
|
for gm in models
|
||||||
@@ -1914,103 +1224,6 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
|
|||||||
return get_available_provider_ids(db, formats, provider_to_formats)
|
return get_available_provider_ids(db, formats, provider_to_formats)
|
||||||
|
|
||||||
|
|
||||||
class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
|
|
||||||
"""获取可用提供商列表的适配器"""
|
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
db = context.db
|
|
||||||
|
|
||||||
# 使用 selectinload 预加载所有关联数据,避免 N+1 查询
|
|
||||||
providers = (
|
|
||||||
db.query(Provider)
|
|
||||||
.options(
|
|
||||||
selectinload(Provider.endpoints),
|
|
||||||
selectinload(Provider.models).selectinload(Model.global_model),
|
|
||||||
)
|
|
||||||
.filter(Provider.is_active.is_(True))
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for provider in providers:
|
|
||||||
# 直接使用预加载的 endpoints,无需额外查询
|
|
||||||
endpoints_data = [
|
|
||||||
{
|
|
||||||
"id": ep.id,
|
|
||||||
"api_format": ep.api_format if ep.api_format else None,
|
|
||||||
"base_url": ep.base_url,
|
|
||||||
"is_active": ep.is_active,
|
|
||||||
}
|
|
||||||
for ep in provider.endpoints
|
|
||||||
]
|
|
||||||
|
|
||||||
models_data = []
|
|
||||||
# 直接使用预加载的 models,无需额外查询
|
|
||||||
direct_models = provider.models
|
|
||||||
for model in direct_models:
|
|
||||||
global_model = model.global_model
|
|
||||||
display_name = (
|
|
||||||
global_model.display_name if global_model else model.provider_model_name
|
|
||||||
)
|
|
||||||
unified_name = global_model.name if global_model else model.provider_model_name
|
|
||||||
models_data.append(
|
|
||||||
{
|
|
||||||
"id": model.id,
|
|
||||||
"name": unified_name,
|
|
||||||
"display_name": display_name,
|
|
||||||
"input_price_per_1m": model.input_price_per_1m,
|
|
||||||
"output_price_per_1m": model.output_price_per_1m,
|
|
||||||
"cache_creation_price_per_1m": model.cache_creation_price_per_1m,
|
|
||||||
"cache_read_price_per_1m": model.cache_read_price_per_1m,
|
|
||||||
"supports_vision": model.supports_vision,
|
|
||||||
"supports_function_calling": model.supports_function_calling,
|
|
||||||
"supports_streaming": model.supports_streaming,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"id": provider.id,
|
|
||||||
"name": provider.name,
|
|
||||||
"description": provider.description,
|
|
||||||
"provider_priority": provider.provider_priority,
|
|
||||||
"endpoints": endpoints_data,
|
|
||||||
"models": models_data,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UpdateApiKeyProvidersAdapter(AuthenticatedApiAdapter):
|
|
||||||
"""更新 API 密钥可用提供商的适配器"""
|
|
||||||
|
|
||||||
api_key_id: str
|
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
||||||
db = context.db
|
|
||||||
user = context.user
|
|
||||||
payload = context.ensure_json_body()
|
|
||||||
try:
|
|
||||||
request = UpdateApiKeyProvidersRequest.model_validate(payload)
|
|
||||||
except ValidationError as e:
|
|
||||||
errors = e.errors()
|
|
||||||
if errors:
|
|
||||||
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
|
||||||
raise InvalidRequestException("请求数据验证失败")
|
|
||||||
|
|
||||||
result = await run_in_threadpool(
|
|
||||||
_update_api_key_providers_sync,
|
|
||||||
user.id,
|
|
||||||
self.api_key_id,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
logger.debug(f"用户 {user.id} 更新API密钥 {self.api_key_id} 的可用提供商")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter):
|
class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter):
|
||||||
"""更新 API Key 的强制能力配置"""
|
"""更新 API Key 的强制能力配置"""
|
||||||
@@ -2046,10 +1259,6 @@ class GetPreferencesAdapter(AuthenticatedApiAdapter):
|
|||||||
return {
|
return {
|
||||||
"avatar_url": preferences.avatar_url,
|
"avatar_url": preferences.avatar_url,
|
||||||
"bio": preferences.bio,
|
"bio": preferences.bio,
|
||||||
"default_provider_id": preferences.default_provider_id,
|
|
||||||
"default_provider": (
|
|
||||||
preferences.default_provider.name if preferences.default_provider else None
|
|
||||||
),
|
|
||||||
"theme": preferences.theme,
|
"theme": preferences.theme,
|
||||||
"language": preferences.language,
|
"language": preferences.language,
|
||||||
"timezone": preferences.timezone,
|
"timezone": preferences.timezone,
|
||||||
|
|||||||
+2
-2
@@ -17,11 +17,11 @@ from fastapi import FastAPI, HTTPException, Request
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from src.api.admin import router as admin_router
|
from src.api.admin import router as admin_router
|
||||||
|
from src.api.analytics import router as analytics_router
|
||||||
from src.api.announcements import router as announcement_router
|
from src.api.announcements import router as announcement_router
|
||||||
|
|
||||||
# API路由
|
# API路由
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
|
||||||
from src.api.internal import router as internal_router
|
from src.api.internal import router as internal_router
|
||||||
from src.api.monitoring import router as monitoring_router
|
from src.api.monitoring import router as monitoring_router
|
||||||
from src.api.payment import router as payment_router
|
from src.api.payment import router as payment_router
|
||||||
@@ -802,7 +802,7 @@ app.include_router(me_router) # 用户个人端点
|
|||||||
app.include_router(wallet_router) # 钱包端点
|
app.include_router(wallet_router) # 钱包端点
|
||||||
app.include_router(payment_router) # 支付回调端点
|
app.include_router(payment_router) # 支付回调端点
|
||||||
app.include_router(announcement_router) # 公告系统
|
app.include_router(announcement_router) # 公告系统
|
||||||
app.include_router(dashboard_router) # 仪表盘端点
|
app.include_router(analytics_router) # 新分析域
|
||||||
app.include_router(public_router) # 公开API端点(用户可查看提供商和模型)
|
app.include_router(public_router) # 公开API端点(用户可查看提供商和模型)
|
||||||
app.include_router(monitoring_router) # 监控端点
|
app.include_router(monitoring_router) # 监控端点
|
||||||
app.include_router(internal_router) # Hub 本地控制面端点
|
app.include_router(internal_router) # Hub 本地控制面端点
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user