Compare commits

...
Author SHA1 Message Date
fawney19 4e96b9d870 Merge pull request #418 from Avilianb/feat/codex-responses-websocket
feat: support Codex Responses WebSocket transport
2026-05-15 12:49:45 +08:00
Avilianb d3c0b1aa7f feat: support Codex Responses WebSocket transport 2026-05-10 01:59:48 +08:00
phenetr0andphenetr0 66bfd3e592 fix: avoid double conversion for forced cli sync streams (#367)
Co-authored-by: phenetr0 <[email protected]>
2026-05-03 02:11:16 +08:00
DaoandYour Name 392c557831 优化 OAuth Token 导入解析与号池管理直刷空列表问题 (#330)
* docs: add aether-proxy C++ rewrite design

* chore: ignore local worktrees directory

* 修复 OAuth Token 导入识别与账号信息解析

* 修复号池管理直刷账号列表为空

---------

Co-authored-by: Your Name <[email protected]>
2026-04-30 18:07:00 +08:00
49 c34565b02b fix(provider-test): 修复测试供应商配置时 DetachedInstanceError (#270)
两处修复:

1. endpoint_checker.py: 移除 asyncio.to_thread 包装同步 DB 查询,
   避免跨线程导致 SQLAlchemy Session 失效

2. provider_query.py: 并发测试预加载 ProviderEndpoint 和 ProviderAPIKey 时
   添加 joinedload(provider),确保 expunge 后不会触发懒加载
2026-04-17 10:37:19 +08:00
fawney19 f57fe6e13e fix(migration): 用 UPDATE...FROM 子查询修复 total_tokens 自引用更新问题,增加批次上限防止死循环 2026-03-24 02:07:24 +08:00
fawney19 7c678b715f fix(migration): 修复 usage token 语义迁移脚本
- 将内联 SQL 提取为模块级常量 _UPGRADE_BACKFILL_SQL / _DOWNGRADE_BACKFILL_SQL
- 抽取 run_backfill_in_batches() 统一批量更新逻辑,通过 autocommit_block 释放 ALTER TABLE 锁
- 修正 upgrade 中 total_tokens 计算逻辑:用 COALESCE(input_tokens,0)+COALESCE(output_tokens,0) 补全 input_output_total_tokens
- WHERE 条件改为按实际字段差异筛选待回填行,替换原先仅过滤 input_context_tokens=0 AND total_tokens=0 的不完整条件
- 批量大小由 5000 调整为 500,减少单批锁持有时间
2026-03-24 01:56:36 +08:00
fawney19andNyaDoo bd4e5f3a5d 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]>
2026-03-24 01:51:53 +08:00
York Zang 165d9eab8f fix(proxy): 修正代理连通性测试地址,避免 1.1.1.1 证书校验失败 (#257)
代理连通性测试此前使用 https://1.1.1.1/cdn-cgi/trace 作为探测地址,在标准 TLS 校验下会因为证书与 IP 不匹配而失败。
改为使用基于域名的 Cloudflare trace 地址,避免触发 CERTIFICATE_VERIFY_FAILED,并恢复代理测试结果的准确性。
2026-03-24 00:04:01 +08:00
fawney19andAAEE86 dfb95f09e1 feat(health): 增强健康监控面板,支持按 Key 查看详情与摘要统计
- 后端 health monitor 新增 summary 接口,按 api_format 聚合健康摘要
- 新增 GroupedFormatKey 类型与 key 分组查询接口
- HealthMonitorCard 重构为卡片+详情对话框,展示成功率、响应时间、Key 状态
- 抽取 HealthMonitorDetailDialog 独立组件
- 新增 useRouteQuery composable 用于 URL query 参数双向绑定
- PoolManagement/ProviderManagement 集成健康监控入口
- 新增 health monitor summary 单元测试

Closes #256

Co-authored-by: AAEE86 <[email protected]>
2026-03-24 00:00:08 +08:00
fawney19andhemo94931 4d5c591654 fix: 覆写规则条件可使用映射前请求体
新增 rules_original_body 参数贯穿请求构建链路,确保 body_rules/header_rules
条件评估使用模型映射前的原始请求体;附带将 handlers __init__ 改为延迟导入。

Closes #255

Co-authored-by: hemo94931 <[email protected]>
2026-03-23 17:31:29 +08:00
164 changed files with 20908 additions and 14516 deletions
+1
View File
@@ -204,6 +204,7 @@ logs/
# Git backup
.git.backup/
.worktrees/
# Database backups
backups/
+7 -1
View File
@@ -90,6 +90,11 @@ RUN printf '%s\n' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_upgrade $connection_upgrade {' \
' default upgrade;' \
' "" "";' \
'}' \
'' \
'server {' \
' listen 80;' \
' server_name _;' \
@@ -160,7 +165,8 @@ RUN printf '%s\n' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Upgrade $http_upgrade;' \
' proxy_set_header Connection $connection_upgrade;' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
+7 -1
View File
@@ -112,6 +112,11 @@ RUN printf '%s\n' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_upgrade $connection_upgrade {' \
' default upgrade;' \
' "" "";' \
'}' \
'' \
'server {' \
' listen 80;' \
' server_name _;' \
@@ -182,7 +187,8 @@ RUN printf '%s\n' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Upgrade $http_upgrade;' \
' proxy_set_header Connection $connection_upgrade;' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
@@ -0,0 +1,317 @@
"""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
BACKFILL_BATCH_SIZE = 2000
# 最大批次数,防止因数据异常导致死循环(2000 * 500000 = 10亿行上限)
_MAX_BATCHES = 500000
# 使用子查询中间层展开 input_output_total_tokens 的计算,
# 确保 total_tokens 引用的是本次 SET 后的新值而非旧值。
_UPGRADE_BACKFILL_SQL = sa.text(
"""
UPDATE usage
SET
input_output_total_tokens = src.new_iot,
input_context_tokens = src.new_ict,
total_tokens = src.new_total,
cache_creation_cost_usd_5m = src.new_cc5m,
cache_creation_cost_usd_1h = src.new_cc1h,
actual_cache_creation_cost_usd_5m = src.new_acc5m,
actual_cache_creation_cost_usd_1h = src.new_acc1h,
actual_cache_cost_usd = src.new_accu,
cache_creation_price_per_1m_5m = src.new_cp5m,
cache_creation_price_per_1m_1h = src.new_cp1h,
cache_cost_usd = src.new_ccu
FROM (
SELECT
id,
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
AS new_iot,
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)
AS new_ict,
/* total_tokens 引用本行计算出的 new_iot,避免依赖 SET 顺序 */
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
+ COALESCE(cache_creation_input_tokens, 0)
+ COALESCE(cache_read_input_tokens, 0)
AS new_total,
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 AS new_cc5m,
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 AS new_cc1h,
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 AS new_acc5m,
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 AS new_acc1h,
COALESCE(actual_cache_creation_cost_usd, 0)
+ COALESCE(actual_cache_read_cost_usd, 0) AS new_accu,
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 AS new_cp5m,
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 AS new_cp1h,
COALESCE(cache_creation_cost_usd, 0)
+ COALESCE(cache_read_cost_usd, 0) AS new_ccu
FROM usage
WHERE id IN (
SELECT id FROM usage
WHERE
input_output_total_tokens IS DISTINCT FROM
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
OR input_context_tokens IS DISTINCT FROM
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)
OR total_tokens IS DISTINCT FROM (
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
+ COALESCE(cache_creation_input_tokens, 0)
+ COALESCE(cache_read_input_tokens, 0)
)
OR cache_creation_cost_usd_5m IS DISTINCT FROM (
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
)
OR cache_creation_cost_usd_1h IS DISTINCT FROM (
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
)
OR actual_cache_creation_cost_usd_5m IS DISTINCT FROM (
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
)
OR actual_cache_creation_cost_usd_1h IS DISTINCT FROM (
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
)
OR actual_cache_cost_usd IS DISTINCT FROM (
COALESCE(actual_cache_creation_cost_usd, 0)
+ COALESCE(actual_cache_read_cost_usd, 0)
)
OR cache_creation_price_per_1m_5m IS DISTINCT FROM (
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
)
OR cache_creation_price_per_1m_1h IS DISTINCT FROM (
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
)
OR cache_cost_usd IS DISTINCT FROM (
COALESCE(cache_creation_cost_usd, 0) + COALESCE(cache_read_cost_usd, 0)
)
ORDER BY id
LIMIT :batch_size
)
) AS src
WHERE usage.id = src.id
"""
)
_DOWNGRADE_BACKFILL_SQL = 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 IS DISTINCT FROM
COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
ORDER BY id
LIMIT :batch_size
)
"""
)
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 run_backfill_in_batches(sql: sa.TextClause, batch_size: int = BACKFILL_BATCH_SIZE) -> None:
context = op.get_context()
for _ in range(_MAX_BATCHES):
# Commit the preceding schema transaction before each batch so PostgreSQL
# does not keep ALTER TABLE locks for the entire data backfill.
with context.autocommit_block():
rowcount = op.get_bind().execute(sql, {"batch_size": batch_size}).rowcount
if rowcount == 0:
break
else:
raise RuntimeError(
f"Backfill did not converge after {_MAX_BATCHES} batches "
f"(batch_size={batch_size}). Possible infinite loop due to data anomaly."
)
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))
run_backfill_in_batches(_UPGRADE_BACKFILL_SQL)
def downgrade() -> None:
run_backfill_in_batches(_DOWNGRADE_BACKFILL_SQL)
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,
)
-309
View File
@@ -380,102 +380,6 @@ export interface AdminApiKeysResponse {
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 {
id: string // UUID
is_active: boolean
@@ -753,197 +657,6 @@ export const adminApi = {
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'),
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'),
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
)
},
}
+396
View File
@@ -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
},
}
+202
View File
@@ -33,6 +33,203 @@ export interface AuditFilters {
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 {
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
@@ -78,6 +275,11 @@ export const auditApi = {
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<{
analysis: Record<string, unknown>
-11
View File
@@ -24,13 +24,6 @@ export interface UserPreferences {
[key: string]: unknown // 允许扩展其他偏好设置
}
export interface UserStats {
total_requests?: number
total_cost?: number
last_request_at?: string
[key: string]: unknown // 允许扩展其他统计数据
}
export interface SendVerificationCodeRequest {
email: string
}
@@ -113,13 +106,9 @@ export interface User {
role: string // 'admin' or 'user'
is_active: boolean
billing?: BillingSummary
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
allowed_models?: string[] | null // 允许使用的模型名称列表
created_at: string
last_login_at?: string
preferences?: UserPreferences
stats?: UserStats
}
export const authApi = {
+19 -3
View File
@@ -288,7 +288,12 @@ export const cacheAnalysisApi = {
api_key_id?: string
hours?: number
}): 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
},
@@ -300,7 +305,12 @@ export const cacheAnalysisApi = {
api_key_id?: string
hours?: number
}): 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
},
@@ -319,7 +329,13 @@ export const cacheAnalysisApi = {
return cachedRequest(
cacheKey,
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
},
30000
-382
View File
@@ -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
}
}
+73
View File
@@ -4,6 +4,34 @@ import type { EndpointAPIKey, AllowedModels } from './types'
// Re-export types for convenience
export type { EndpointAPIKey, AllowedModels }
export interface GroupedFormatKey {
id: string
provider_id: string
name: string
auth_type?: string
api_key_masked: string
internal_priority: number
global_priority_by_format: Record<string, number> | null
format_priority: number | null
rate_multipliers: Record<string, number> | null
is_active: boolean
provider_active: boolean
pool_enabled: boolean
circuit_breaker_open: boolean
provider_name: string
api_format: string
api_formats: string[]
capabilities: string[]
health_score: number | null
success_rate: number | null
avg_response_time_ms: number | null
request_count: number
}
function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/**
* 能力定义类型
*/
@@ -125,6 +153,51 @@ export async function getProviderKeys(providerId: string): Promise<EndpointAPIKe
return allKeys
}
/**
* 获取按 API 格式分组的 Key 列表
*/
export async function getKeysGroupedByFormat(): Promise<Record<string, GroupedFormatKey[]>> {
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
const grouped = response.data as Record<string, Array<Record<string, unknown>>>
const result: Record<string, GroupedFormatKey[]> = {}
for (const [apiFormat, keys] of Object.entries(grouped || {})) {
if (!Array.isArray(keys)) continue
result[apiFormat] = keys.map((key) => ({
id: String(key.id || ''),
provider_id: String(key.provider_id || ''),
name: String(key.name || 'Unnamed Key'),
auth_type: typeof key.auth_type === 'string' ? key.auth_type : undefined,
api_key_masked: String(key.api_key_masked || '***'),
internal_priority: toNumberOrNull(key.internal_priority) ?? 0,
global_priority_by_format:
key.global_priority_by_format && typeof key.global_priority_by_format === 'object'
? (key.global_priority_by_format as Record<string, number>)
: null,
format_priority: toNumberOrNull(key.format_priority),
rate_multipliers:
key.rate_multipliers && typeof key.rate_multipliers === 'object'
? (key.rate_multipliers as Record<string, number>)
: null,
is_active: key.is_active !== false,
provider_active: key.provider_active !== false,
pool_enabled: key.pool_enabled === true,
circuit_breaker_open: key.circuit_breaker_open === true,
provider_name: String(key.provider_name || 'Unknown Provider'),
api_format: typeof key.api_format === 'string' ? key.api_format : apiFormat,
api_formats: Array.isArray(key.api_formats) ? key.api_formats.map(item => String(item)) : [apiFormat],
capabilities: Array.isArray(key.capabilities) ? key.capabilities.map(item => String(item)) : [],
health_score: toNumberOrNull(key.health_score),
success_rate: toNumberOrNull(key.success_rate),
avg_response_time_ms: toNumberOrNull(key.avg_response_time_ms),
request_count: toNumberOrNull(key.request_count) ?? 0,
}))
}
return result
}
/**
* 为 Provider 添加 Key
*/
+1 -1
View File
@@ -94,7 +94,7 @@ export async function completeProviderLevelOAuth(
export async function importProviderRefreshToken(
providerId: string,
data: { refresh_token: string; name?: string; proxy_node_id?: string }
data: { refresh_token?: string; access_token?: string; name?: string; proxy_node_id?: string }
): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
return resp.data
@@ -292,6 +292,7 @@ export interface EndpointAPIKey {
oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
oauth_temporary?: boolean | null // 是否为仅 Access Token 导入的临时 OAuth 账号
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
status_snapshot?: ProviderKeyStatusSnapshot | null
@@ -594,6 +595,7 @@ export interface HealthSummary {
total: number
active: number
unhealthy: number
circuit_open?: number
}
}
-192
View File
@@ -1,7 +1,5 @@
import apiClient from './client'
import type { ActivityHeatmap } from '@/types/activity'
import type { TieredPricingConfig } from './endpoints/types'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
import type { UserSession } from '@/types/session'
@@ -25,8 +23,6 @@ export interface Profile {
export interface UserPreferences {
avatar_url?: string
bio?: string
default_provider_id?: string // UUID
default_provider?: Record<string, unknown>
theme: string
language: 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 {
id: string // UUID
name: string
@@ -146,7 +45,6 @@ export interface ApiKey {
total_requests?: number
total_cost_usd?: number
rate_limit?: number | null
allowed_providers?: ProviderConfig[]
force_capabilities?: Record<string, boolean> | null // 强制能力配置
}
@@ -249,51 +147,6 @@ export const meApi = {
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?: {
skip?: number
@@ -334,16 +187,6 @@ export const meApi = {
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密钥能力配置
async updateApiKeyCapabilities(keyId: string, data: {
force_capabilities?: Record<string, boolean> | null
@@ -370,39 +213,4 @@ export const meApi = {
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
)
}
}
+161
View File
@@ -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
},
}
-247
View File
@@ -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
)
}
}
-5
View File
@@ -141,10 +141,5 @@ export const usersApi = {
`/api/admin/users/${userId}/api-keys/${keyId}/full-key`
)
return response.data
},
// 管理员统计
async getUsageStats(): Promise<Record<string, unknown>> {
const response = await apiClient.get('/api/admin/usage/stats')
return response.data
}
}
+28 -14
View File
@@ -1,5 +1,8 @@
<template>
<div class="w-full h-full">
<div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" />
</div>
</template>
@@ -18,6 +21,7 @@ import {
type ChartData,
type ChartOptions
} from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), {
height: 300,
@@ -44,6 +48,7 @@ interface Props {
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'bar'> | null = null
let stopThemeObserver: (() => void) | null = null
const defaultOptions: ChartOptions<'bar'> = {
responsive: true,
@@ -91,9 +96,7 @@ const defaultOptions: ChartOptions<'bar'> = {
}
}
function createChart() {
if (!chartRef.value) return
function buildChartOptions(): ChartOptions<'bar'> {
const stackedOptions = props.stacked ? {
scales: {
x: { ...defaultOptions.scales?.x, stacked: true },
@@ -106,20 +109,26 @@ function createChart() {
}
}
chart = new ChartJS(chartRef.value, {
type: 'bar',
data: props.data,
options: {
return resolveChartTheme({
...defaultOptions,
...stackedOptions,
...props.options
})
}
function createChart() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, {
type: 'bar',
data: resolveChartTheme(props.data),
options: buildChartOptions()
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.data = resolveChartTheme(props.data)
chart.update('none')
}
}
@@ -127,9 +136,17 @@ function updateChart() {
onMounted(async () => {
await nextTick()
createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = resolveChartTheme(props.data)
chart.options = buildChartOptions()
chart.update('none')
})
})
onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) {
chart.destroy()
chart = null
@@ -139,11 +156,8 @@ onUnmounted(() => {
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.update()
chart.options = buildChartOptions()
chart.update('none')
}
}, { deep: true })
</script>
@@ -1,5 +1,8 @@
<template>
<div class="w-full h-full">
<div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" />
</div>
</template>
@@ -16,10 +19,12 @@ import {
type ChartData,
type ChartOptions
} from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), {
height: 300,
options: undefined
options: undefined,
showLegend: true,
})
ChartJS.register(
@@ -34,15 +39,22 @@ interface Props {
data: ChartData<'doughnut'>
options?: ChartOptions<'doughnut'>
height?: number
showLegend?: boolean
}
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'doughnut'> | null = null
let stopThemeObserver: (() => void) | null = null
const defaultOptions: ChartOptions<'doughnut'> = {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
interaction: {
mode: 'nearest',
intersect: true,
},
plugins: {
legend: {
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() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, {
type: 'doughnut',
data: props.data,
options: {
...defaultOptions,
...props.options
}
data: resolveChartTheme(props.data),
options: resolvedOptions(),
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.data = resolveChartTheme(props.data)
chart.update('none')
}
}
@@ -94,9 +143,17 @@ function updateChart() {
onMounted(async () => {
await nextTick()
createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = resolveChartTheme(props.data)
chart.options = resolvedOptions()
chart.update('none')
})
})
onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) {
chart.destroy()
chart = null
@@ -106,8 +163,15 @@ onUnmounted(() => {
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = { ...defaultOptions, ...props.options }
chart.update()
chart.options = resolvedOptions()
chart.update('none')
}
}, { deep: true })
watch(() => props.showLegend, () => {
if (chart) {
chart.options = resolvedOptions()
chart.update('none')
}
})
</script>
+24 -5
View File
@@ -1,5 +1,8 @@
<template>
<div class="w-full h-full">
<div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" />
</div>
</template>
@@ -16,9 +19,11 @@ import {
Title,
Tooltip,
Legend,
Filler,
type ChartData,
type ChartOptions
} from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), {
height: 300,
@@ -34,7 +39,8 @@ ChartJS.register(
LineController,
Title,
Tooltip,
Legend
Legend,
Filler
)
interface Props {
@@ -45,12 +51,17 @@ interface Props {
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'line'> | null = null
let stopThemeObserver: (() => void) | null = null
function buildChartOptions(): ChartOptions<'line'> {
return {
return resolveChartTheme({
...defaultOptions,
...props.options
})
}
function buildChartData(): ChartData<'line'> {
return resolveChartTheme(props.data)
}
const defaultOptions: ChartOptions<'line'> = {
@@ -95,14 +106,14 @@ function createChart() {
chart = new ChartJS(chartRef.value, {
type: 'line',
data: props.data,
data: buildChartData(),
options: buildChartOptions()
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.data = buildChartData()
chart.update('none') // 禁用动画以提高性能
}
}
@@ -110,9 +121,17 @@ function updateChart() {
onMounted(async () => {
await nextTick()
createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = buildChartData()
chart.options = buildChartOptions()
chart.update('none')
})
})
onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) {
chart.destroy()
chart = null
@@ -3,39 +3,24 @@
<Select
v-model="selectedPreset"
>
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
<SelectTrigger :class="presetTriggerClass">
<SelectValue placeholder="选择时间段" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">
今天
</SelectItem>
<SelectItem value="yesterday">
昨天
</SelectItem>
<SelectItem value="last7days">
最近7天
</SelectItem>
<SelectItem value="last30days">
最近30天
</SelectItem>
<SelectItem value="last90days">
最近90天
<SelectItem value="last180days">
最近180天
</SelectItem>
<SelectItem value="this_week">
本周
</SelectItem>
<SelectItem value="last_week">
上周
</SelectItem>
<SelectItem value="this_month">
本月
</SelectItem>
<SelectItem value="last_month">
上月
</SelectItem>
<SelectItem value="this_year">
今年
<SelectItem value="last1year">
最近一年
</SelectItem>
<SelectItem value="custom">
自定义
@@ -50,13 +35,13 @@
<Input
v-model="startDate"
type="date"
class="h-8 w-36 text-xs border-border/60"
:class="dateInputClass"
/>
<span class="text-xs text-muted-foreground">至</span>
<Input
v-model="endDate"
type="date"
class="h-8 w-36 text-xs border-border/60"
:class="dateInputClass"
/>
</div>
@@ -64,10 +49,16 @@
v-if="showGranularity"
v-model="selectedGranularity"
>
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
<SelectTrigger :class="granularityTriggerClass">
<SelectValue placeholder="粒度" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-if="includeAutoGranularity"
value="auto"
>
自动
</SelectItem>
<SelectItem
v-if="allowHourly && canUseHourly"
value="hour"
@@ -100,26 +91,51 @@ import {
} from '@/components/ui'
import type { DateRangeParams } from '@/features/usage/types'
type PickerGranularity = NonNullable<DateRangeParams['granularity']>
const props = defineProps<{
modelValue: DateRangeParams
showGranularity?: boolean
allowHourly?: boolean
includeAutoGranularity?: boolean
compact?: boolean
}>()
const emit = defineEmits<{
'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 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 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(() => {
if (selectedPreset.value === 'today' || selectedPreset.value === 'yesterday') return true
if (selectedPreset.value === 'today') return true
if (selectedPreset.value === 'custom' && 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.start_date !== undefined) startDate.value = value.start_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 = getValueKey(value)
}, { deep: true })
@@ -173,7 +189,7 @@ watch(() => props.modelValue, (value) => {
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
if (!allowHourly.value || !canUseHourly.value) {
if (selectedGranularity.value === 'hour') {
selectedGranularity.value = 'day'
selectedGranularity.value = includeAutoGranularity.value ? 'auto' : 'day'
}
}
@@ -13,8 +13,11 @@
<p class="mt-0.5">
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
</p>
<p class="text-[11px] text-muted-foreground">
成本 {{ formatCurrency(tooltip.day.total_cost) }}
<p class="text-[11px] text-muted-foreground whitespace-nowrap">
{{ 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>
</div>
</Teleport>
@@ -90,7 +93,7 @@
class="flex items-center invisible"
>周六</span>
</div>
<div class="flex-1 min-w-[200px]">
<div class="flex-1 min-w-0">
<div
ref="heatmapWrapper"
class="relative block w-full"
@@ -100,12 +103,12 @@
:style="horizontalGapStyle"
>
<div
v-for="(week, weekIndex) in weekColumns"
v-for="(week, weekIndex) in visibleWeekColumns"
:key="`month-${weekIndex}`"
:style="monthCellStyle"
class="text-center"
>
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
<span v-if="visibleMonthMarkers[weekIndex]">{{ visibleMonthMarkers[weekIndex] }}</span>
</div>
</div>
<div
@@ -113,7 +116,7 @@
:style="horizontalGapStyle"
>
<div
v-for="(week, weekIndex) in weekColumns"
v-for="(week, weekIndex) in visibleWeekColumns"
:key="weekIndex"
class="flex flex-col"
:style="verticalGapStyle"
@@ -161,11 +164,15 @@ const props = withDefaults(defineProps<{
title?: string
subtitle?: string
showHeader?: boolean
valueLabel?: string
actualValueLabel?: string
}>(), {
data: undefined,
title: undefined,
subtitle: undefined,
showHeader: true
showHeader: true,
valueLabel: '费用',
actualValueLabel: undefined,
})
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
@@ -270,7 +277,47 @@ const monthMarkers = computed(() => {
if (month === lastMonth) {
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
})
@@ -282,7 +329,7 @@ let mediaQuery: MediaQueryList | null = null
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
const recalcCellSize = () => {
const columnCount = weekColumns.value.length
const columnCount = visibleWeekColumns.value.length
if (!columnCount || !heatmapWidth.value) {
return
}
@@ -290,12 +337,12 @@ const recalcCellSize = () => {
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
const rawSize = availableSpace / columnCount
// 自适应尺寸,最小 6px
cellSize.value = Math.max(6, rawSize)
// 当所有列都能放下时,让格子继续随容器放大,避免宽屏下出现大块留白。
cellSize.value = Math.max(MIN_CELL_SIZE, rawSize)
}
watch(
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
[() => heatmapWidth.value, () => visibleWeekColumns.value.length, () => cellGap.value],
() => {
recalcCellSize()
},
@@ -345,7 +392,7 @@ onBeforeUnmount(() => {
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const tooltipWidth = 200
const tooltipWidth = 280
const tooltipHeight = 72
// Calculate horizontal position (centered on cell)
@@ -402,10 +449,10 @@ function getCellStyle(requests: number) {
function buildTooltip(day: ActivityHeatmapDay): string {
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]
if (day.actual_total_cost !== undefined) {
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`)
if (props.actualValueLabel && day.actual_total_cost !== undefined) {
parts.push(`${props.actualValueLabel} ${formatCurrency(day.actual_total_cost)}`)
}
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>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
<p
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ 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
v-if="loading"
class="p-6"
@@ -16,57 +36,116 @@
<LoadingState />
</div>
<div
v-else
class="h-[260px]"
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
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>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChartData, ChartOptions } from 'chart.js'
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
import { LoadingState } from '@/components/common'
import type { ErrorDistributionItem } from '@/api/admin'
interface ErrorDistributionItem {
category: string
label: string
count: number
}
interface Props {
title: string
subtitle?: string
distribution: ErrorDistributionItem[]
loading?: boolean
unavailable?: boolean
unavailableText?: string
}
const props = withDefaults(defineProps<Props>(), {
subtitle: undefined,
loading: false
loading: false,
unavailable: false,
unavailableText: '数据暂不可用',
})
const chartData = computed(() => ({
labels: props.distribution.map(item => item.category),
const chartColors = [
'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: [
{
data: props.distribution.map(item => item.count),
backgroundColor: [
'rgba(239, 68, 68, 0.7)',
'rgba(59, 130, 246, 0.7)',
'rgba(234, 179, 8, 0.7)',
'rgba(34, 197, 94, 0.7)',
'rgba(148, 163, 184, 0.7)'
data: distributionWithShare.value.map(item => item.count),
backgroundColor: distributionWithShare.value.map((_, index) => chartColors[index % chartColors.length]),
borderColor: 'rgba(255,255,255,0.92)',
borderWidth: 2,
hoverOffset: 6,
},
],
borderWidth: 0
}
]
}))
const chartOptions = computed(() => ({
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
cutout: '68%',
plugins: {
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>
@@ -1,34 +1,30 @@
<template>
<TableCard :title="title">
<template #actions>
<Select
v-if="showMetricSelect"
:model-value="metric"
@update:model-value="emitMetric"
<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"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">
请求数
</SelectItem>
<SelectItem value="tokens">
Tokens
</SelectItem>
<SelectItem value="cost">
成本
</SelectItem>
</SelectContent>
</Select>
更新中
</div>
</template>
<div
v-if="loading"
v-if="showLoadingState"
class="p-6"
>
<LoadingState />
</div>
<div
v-else-if="showUnavailableState"
class="p-6"
>
<EmptyState
type="error"
:title="unavailableTitle"
:description="unavailableDescription"
/>
</div>
<div
v-else-if="items.length === 0"
class="p-6"
@@ -38,20 +34,29 @@
description="当前时间范围内没有统计结果"
/>
</div>
<Table v-else>
<Table
v-else
class="table-fixed"
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
>
<TableHeader>
<TableRow>
<TableHead class="w-16">
<TableHead class="w-14 px-2.5">
排名
</TableHead>
<TableHead>名称</TableHead>
<TableHead class="text-right">
<TableHead class="w-[34%] px-2.5">
名称
</TableHead>
<TableHead class="w-[15%] px-2.5 text-right">
请求数
</TableHead>
<TableHead class="text-right">
<TableHead class="w-[18%] px-2.5 text-right">
Tokens
</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>
</TableRow>
@@ -60,68 +65,118 @@
<TableRow
v-for="item in items"
: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="px-2.5 py-2.5">
<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'"
>
<TableCell class="font-medium">
{{ item.rank }}
</div>
</TableCell>
<TableCell>{{ item.name }}</TableCell>
<TableCell class="text-right">
{{ item.requests }}
<TableCell class="px-2.5 py-2.5">
<div class="min-w-0">
<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 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) }}
</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) }}
</TableCell>
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums text-muted-foreground">
{{ formatCurrency(item.actualCost) }}
</TableCell>
</TableRow>
</TableBody>
</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>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { EmptyState, LoadingState } from '@/components/common'
import { TableCard } from '@/components/ui'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import { Pagination, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableCard } from '@/components/ui'
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 {
title: string
items: LeaderboardItem[]
metric: 'requests' | 'tokens' | 'cost'
selectedId?: string
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>(), {
selectedId: undefined,
loading: false,
showMetricSelect: true
hasLoaded: false,
currentPage: 1,
totalItems: 0,
pageSize: 5,
showPagination: false,
unavailable: false,
unavailableTitle: '排行榜暂不可用',
unavailableDescription: '接口未返回结果,请稍后重试',
})
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)
function emitMetric(value: string) {
if (value === 'requests' || value === 'tokens' || value === 'cost') {
emit('update:metric', value)
}
}
const selectedId = computed(() => props.selectedId)
const showLoadingState = computed(() => props.loading && (!props.hasLoaded || props.items.length === 0))
const showUnavailableState = computed(() => props.unavailable && !props.loading && props.items.length === 0)
const isRefreshing = computed(() => props.loading && props.hasLoaded && props.items.length > 0)
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>
+179 -39
View File
@@ -1,27 +1,59 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
<p
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ 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
v-if="loading"
class="p-6"
>
<LoadingState />
</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
v-else
class="h-[260px]"
class="rounded-2xl border border-border/60 bg-background p-3"
>
<LineChart
:data="chartData"
:options="chartOptions"
:height="248"
/>
</div>
</div>
@@ -29,9 +61,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ChartData, ChartOptions } from 'chart.js'
import LineChart from '@/components/charts/LineChart.vue'
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 {
title: string
@@ -39,61 +81,159 @@ interface Props {
series: PercentileItem[]
mode: 'response' | 'ttfb'
loading?: boolean
unavailable?: boolean
unavailableText?: string
}
const props = withDefaults(defineProps<Props>(), {
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 {
if (ms == null) return null
return ms / 1000
return Number((ms / 1000).toFixed(3))
}
const chartData = computed(() => {
const p50Key = props.mode === 'response' ? 'p50_response_time_ms' : 'p50_first_byte_time_ms'
const p90Key = props.mode === 'response' ? 'p90_response_time_ms' : 'p90_first_byte_time_ms'
const p99Key = props.mode === 'response' ? 'p99_response_time_ms' : 'p99_first_byte_time_ms'
function getPercentileValue(item: PercentileItem, percentile: '50' | '90' | '99') {
if (props.mode === 'response') {
if (percentile === '50') return msToSeconds(item.p50_response_time_ms)
if (percentile === '90') return msToSeconds(item.p90_response_time_ms)
return msToSeconds(item.p99_response_time_ms)
}
return {
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 => msToSeconds(item[p50Key])),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
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 => msToSeconds(item[p90Key])),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
pointRadius: 2
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 => msToSeconds(item[p99Key])),
borderColor: 'rgb(239, 68, 68)',
tension: 0.25,
pointRadius: 2
}
]
}
})
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(() => ({
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: {
y: {
x: {
grid: {
color: 'rgba(148, 163, 184, 0.06)',
},
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>
@@ -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>
-3
View File
@@ -1,7 +1,4 @@
export { default as ActivityHeatmap } from './ActivityHeatmap.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 ErrorDistributionChart } from './ErrorDistributionChart.vue'
export { default as ComparisonCard } from './ComparisonCard.vue'
+10 -2
View File
@@ -59,7 +59,7 @@ const isHidden = computed(
)
const itemClass = computed(() =>
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',
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
isHidden.value && 'hidden',
@@ -101,8 +101,16 @@ onBeforeUnmount(() => {
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<SelectItemText
as="div"
class="min-w-0 flex-1 overflow-hidden"
>
<span
class="block w-full min-w-0 truncate"
:title="normalizedText"
>
<slot />
</span>
</SelectItemText>
</SelectItemPrimitive>
</template>
@@ -25,7 +25,7 @@ const triggerClass = computed(() =>
:class="triggerClass"
:disabled="disabled"
>
<span class="truncate">
<span class="min-w-0 flex-1 truncate">
<slot />
</span>
<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
}
+287
View File
@@ -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,
}
}
+44
View File
@@ -0,0 +1,44 @@
import { useRoute, useRouter, type LocationQueryValue, type LocationQuery } from 'vue-router'
type QueryValue = LocationQueryValue | LocationQueryValue[]
function normalizeQueryValue(value: QueryValue): string | undefined {
if (Array.isArray(value)) {
return value.length > 0 ? (value[value.length - 1] ?? undefined) : undefined
}
return typeof value === 'string' ? value : undefined
}
function queriesEqual(left: LocationQuery, right: LocationQuery): boolean {
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
for (const key of keys) {
if (normalizeQueryValue(left[key]) !== normalizeQueryValue(right[key])) {
return false
}
}
return true
}
export function useRouteQuery() {
const route = useRoute()
const router = useRouter()
function getQueryValue(key: string): string | undefined {
return normalizeQueryValue(route.query[key])
}
function patchQuery(patch: Record<string, string | undefined | null>) {
const next: LocationQuery = { ...route.query }
for (const [key, value] of Object.entries(patch)) {
if (value == null || value.trim() === '') {
delete next[key]
} else {
next[key] = value
}
}
if (queriesEqual(route.query, next)) return
void router.replace({ query: next }).catch(() => {})
}
return { route, router, getQueryValue, patchQuery }
}
@@ -3,18 +3,98 @@
variant="default"
class="overflow-hidden"
>
<!-- 标题和筛选器 -->
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div class="border-b border-border/60 bg-muted/20 px-6 py-5">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-base font-semibold">
{{ title }}
</h3>
<div class="flex items-center gap-3">
<Label class="text-xs text-muted-foreground">回溯时间:</Label>
<Select
v-model="lookbackHours"
<Badge
variant="outline"
class="text-[10px] uppercase tracking-[0.25em]"
>
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
{{ isAdmin ? 'Admin' : 'Public' }}
</Badge>
</div>
<p class="text-xs text-muted-foreground">
异常优先排序,支持状态筛选、详情诊断与自动刷新,首屏先看出问题的格式。
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<div class="text-xs text-muted-foreground">
最后刷新 {{ generatedAt ? formatRelativeTime(generatedAt) : '未加载' }}
</div>
<div class="flex items-center gap-2 rounded-xl border border-border/60 bg-card/70 px-3 py-1.5">
<Switch v-model="autoRefreshEnabled" />
<span class="text-xs font-medium">
{{ autoRefreshEnabled ? '自动刷新 15s' : '自动刷新已关闭' }}
</span>
</div>
<Button
v-if="isAdmin"
variant="outline"
size="sm"
class="h-9 px-3 text-xs"
:disabled="recoveringAllKeys || (healthSummary?.keys.circuit_open ?? 0) <= 0"
@click="recoverAllConfirmOpen = true"
>
<Loader2
v-if="recoveringAllKeys"
class="mr-2 h-3.5 w-3.5 animate-spin"
/>
恢复全部熔断 Key
</Button>
<RefreshButton
:loading="loadingMonitors"
@click="refreshData({ silent: false })"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3 xl:grid-cols-6">
<Card
v-for="item in summaryCards"
:key="item.label"
variant="inset"
class="p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{{ item.label }}
</div>
<div
class="mt-2 text-xl font-semibold"
:class="item.valueClass"
>
{{ item.value }}
</div>
<div class="mt-1 text-[11px] text-muted-foreground">
{{ item.help }}
</div>
</div>
<div
class="rounded-xl border border-border/60 p-2"
:class="item.iconClass"
>
<component
:is="item.icon"
class="h-4 w-4"
/>
</div>
</div>
</Card>
</div>
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div class="grid flex-1 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div>
<Label class="mb-1.5 block text-xs text-muted-foreground">回溯时间</Label>
<Select v-model="lookbackHours">
<SelectTrigger class="h-9 border-border/60 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -35,18 +115,119 @@
</SelectItem>
</SelectContent>
</Select>
<RefreshButton
:loading="loading"
@click="refreshData"
/>
</div>
<div>
<Label class="mb-1.5 block text-xs text-muted-foreground">状态筛选</Label>
<Select v-model="statusFilter">
<SelectTrigger class="h-9 border-border/60 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="unhealthy">
仅看异常
</SelectItem>
<SelectItem value="warning">
仅看预警
</SelectItem>
<SelectItem value="healthy">
仅看健康
</SelectItem>
<SelectItem value="unknown">
仅看无流量
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label class="mb-1.5 block text-xs text-muted-foreground">格式族</Label>
<Select v-model="familyFilter">
<SelectTrigger class="h-9 border-border/60 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部格式
</SelectItem>
<SelectItem value="claude">
Claude
</SelectItem>
<SelectItem value="openai">
OpenAI
</SelectItem>
<SelectItem value="gemini">
Gemini
</SelectItem>
<SelectItem value="other">
其他
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label class="mb-1.5 block text-xs text-muted-foreground">显示范围</Label>
<div class="flex h-9 items-center justify-between rounded-xl border border-border/60 bg-card/70 px-3">
<span class="text-xs font-medium">仅看有流量</span>
<Switch v-model="onlyTraffic" />
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>显示 {{ filteredMonitors.length }} / {{ monitors.length }} 个格式</span>
<Button
v-if="hasActiveFilters"
variant="ghost"
size="sm"
class="h-8 px-2 text-xs"
@click="resetFilters"
>
重置筛选
</Button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<Badge
variant="success"
class="gap-1"
>
健康
</Badge>
<span class="text-muted-foreground">成功率 ≥ 95%</span>
<Badge
variant="warning"
class="gap-1"
>
预警
</Badge>
<span class="text-muted-foreground">70% - 95%</span>
<Badge
variant="destructive"
class="gap-1"
>
异常
</Badge>
<span class="text-muted-foreground">成功率 &lt; 70%</span>
<Badge
variant="outline"
class="gap-1"
>
无流量
</Badge>
<span class="text-muted-foreground">窗口内没有请求</span>
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="p-6">
<div
v-if="loadingMonitors"
v-if="showInitialLoading"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
@@ -57,62 +238,104 @@
v-else-if="monitors.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Activity class="w-12 h-12 mb-3 opacity-30" />
<Activity class="mb-3 h-12 w-12 opacity-30" />
<p>暂无健康监控数据</p>
<p class="text-xs mt-1">
<p class="mt-1 text-xs">
端点尚未产生请求记录
</p>
</div>
<div
v-else-if="filteredMonitors.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<AlertTriangle class="mb-3 h-12 w-12 opacity-30" />
<p>当前筛选条件下没有匹配项</p>
<p class="mt-1 text-xs">
可以放宽状态、格式族或流量条件
</p>
<Button
variant="outline"
size="sm"
class="mt-4"
@click="resetFilters"
>
重置筛选
</Button>
</div>
<div
v-else
class="space-y-3"
>
<div
v-for="monitor in monitors"
v-for="monitor in filteredMonitors"
:key="monitor.api_format"
class="border border-border/60 rounded-lg p-4 hover:border-primary/50 transition-colors"
class="rounded-xl border p-4 transition-colors"
:class="getMonitorCardClass(monitor)"
>
<!-- 响应式布局:窄屏上下两行,宽屏左右结构 -->
<div class="flex flex-col sm:flex-row sm:gap-6 sm:items-center">
<!-- 第一行/左侧:信息区域 -->
<div class="sm:w-52 flex-shrink-0 space-y-1.5 mb-3 sm:mb-0">
<!-- API 格式标签和成功率 -->
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-6">
<div class="space-y-1.5 xl:w-64 xl:flex-shrink-0">
<div class="flex items-center gap-2 flex-wrap">
<Badge
variant="outline"
class="font-mono text-xs whitespace-nowrap"
class="whitespace-nowrap font-mono text-xs"
>
{{ formatApiFormat(monitor.api_format) }}
</Badge>
<Badge
:variant="getMonitorStatusVariant(monitor)"
class="text-xs whitespace-nowrap"
>
{{ getMonitorStatusLabel(monitor) }}
</Badge>
<Badge
v-if="monitor.total_attempts > 0"
:variant="getSuccessRateVariant(monitor.success_rate)"
class="text-xs whitespace-nowrap"
>
{{ (monitor.success_rate * 100).toFixed(0) }}%
{{ formatPercent(monitor.success_rate) }}
</Badge>
<!-- 提供商信息(仅管理员可见)- 窄屏时显示在同一行 -->
<span
v-if="showProviderInfo && 'provider_count' in monitor"
class="text-xs text-muted-foreground sm:hidden"
class="text-xs text-muted-foreground xl:hidden"
>
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
</span>
</div>
<!-- 提供商信息(仅管理员可见)- 宽屏时显示在下方 -->
<div
v-if="showProviderInfo && 'provider_count' in monitor"
class="text-xs text-muted-foreground hidden sm:block"
class="hidden text-xs text-muted-foreground xl:block"
>
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
</div>
<div class="text-xs text-muted-foreground">
{{ monitor.total_attempts }} 次请求
<span class="mx-1">·</span>
失败 {{ monitor.failed_count }}
<span class="mx-1">·</span>
跳过 {{ monitor.skipped_count }}
</div>
<!-- 第二行/右侧:时间线区域 -->
<div class="flex-1 min-w-0 sm:flex sm:justify-end">
<div class="w-full sm:max-w-5xl">
<div class="text-xs text-muted-foreground">
最近事件 {{ monitor.last_event_at ? formatRelativeTime(monitor.last_event_at) : '暂无' }}
</div>
<div class="pt-1">
<Button
variant="outline"
size="sm"
class="h-8 px-3 text-xs"
@click="openMonitorDetail(monitor)"
>
查看详情
</Button>
</div>
</div>
<div class="min-w-0 flex-1">
<EndpointHealthTimeline
:monitor="monitor"
:lookback-hours="parseInt(lookbackHours)"
@@ -122,16 +345,42 @@
</div>
</div>
</div>
</div>
</Card>
<HealthMonitorDetailDialog
v-model="detailDialogOpen"
:monitor="selectedMonitor"
:is-admin="isAdmin"
:format-keys="selectedFormatKeys"
:loading-format-keys="loadingFormatKeys"
:recovering-key-id="recoveringKeyId"
@navigate-provider="goToProviderManagement"
@navigate-pool="goToPoolManagement"
@recover-key="recoverFormatKey"
/>
<AlertDialog
v-model="recoverAllConfirmOpen"
title="恢复全部熔断 Key"
:description="recoverAllDialogDescription"
type="warning"
confirm-text="立即恢复"
:loading="recoveringAllKeys"
@confirm="confirmRecoverAllKeys"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { Activity, Loader2 } from 'lucide-vue-next'
import { computed, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
import { useRouter } from 'vue-router'
import { Activity, AlertTriangle, Gauge, KeyRound, Loader2, Radio, ShieldAlert } from 'lucide-vue-next'
import AlertDialog from '@/components/common/AlertDialog.vue'
import HealthMonitorDetailDialog from './HealthMonitorDetailDialog.vue'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
import Label from '@/components/ui/label.vue'
import Switch from '@/components/ui/switch.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
@@ -139,12 +388,24 @@ import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import EndpointHealthTimeline from './EndpointHealthTimeline.vue'
import { getEndpointStatusMonitor, getPublicEndpointStatusMonitor } from '@/api/endpoints/health'
import type { EndpointStatusMonitor, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
import { getKeysGroupedByFormat, type GroupedFormatKey } from '@/api/endpoints/keys'
import { getEndpointStatusMonitor, getHealthSummary, getPublicEndpointStatusMonitor, recoverAllKeysHealth, recoverKeyHealth } from '@/api/endpoints/health'
import type { EndpointStatusMonitor, HealthSummary, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
type MonitorItem = EndpointStatusMonitor | PublicEndpointStatusMonitor
type MonitorStatus = 'healthy' | 'warning' | 'unhealthy' | 'unknown'
type SummaryCardItem = {
label: string
value: string
help: string
icon: Component
valueClass: string
iconClass: string
}
const props = withDefaults(defineProps<{
title?: string
isAdmin?: boolean
@@ -155,55 +416,510 @@ const props = withDefaults(defineProps<{
showProviderInfo: false
})
const { error: showError } = useToast()
const AUTO_REFRESH_INTERVAL_MS = 15_000
const STATUS_SORT_ORDER: Record<MonitorStatus, number> = {
unhealthy: 0,
warning: 1,
unknown: 2,
healthy: 3
}
const router = useRouter()
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
const loading = ref(false)
const loadingMonitors = ref(false)
const monitors = ref<(EndpointStatusMonitor | PublicEndpointStatusMonitor)[]>([])
const monitors = ref<MonitorItem[]>([])
const generatedAt = ref<string | null>(null)
const healthSummary = ref<HealthSummary | null>(null)
const lookbackHours = ref('6')
const statusFilter = ref<'all' | MonitorStatus>('all')
const familyFilter = ref<'all' | 'claude' | 'openai' | 'gemini' | 'other'>('all')
const onlyTraffic = ref(false)
const autoRefreshEnabled = ref(true)
const detailDialogOpen = ref(false)
const selectedMonitorFormat = ref<string | null>(null)
const recoverAllConfirmOpen = ref(false)
const recoveringAllKeys = ref(false)
const keysByFormat = ref<Record<string, GroupedFormatKey[]>>({})
const loadingFormatKeys = ref(false)
const recoveringKeyId = ref<string | null>(null)
async function loadMonitors() {
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
let loadRequestId = 0
const isAdmin = computed(() => props.isAdmin)
const showInitialLoading = computed(() => loadingMonitors.value && monitors.value.length === 0)
const unhealthyCount = computed(() =>
monitors.value.filter(item => getMonitorStatus(item) === 'unhealthy').length
)
const warningCount = computed(() =>
monitors.value.filter(item => getMonitorStatus(item) === 'warning').length
)
const trafficCount = computed(() =>
monitors.value.filter(item => item.total_attempts > 0).length
)
const overallSuccessRate = computed(() => {
const totals = monitors.value.reduce((acc, item) => {
acc.success += item.success_count
acc.failed += item.failed_count
return acc
}, { success: 0, failed: 0 })
const denominator = totals.success + totals.failed
if (denominator <= 0) return null
return totals.success / denominator
})
const summaryCards = computed<SummaryCardItem[]>(() => {
const items: SummaryCardItem[] = [
{
label: '异常格式',
value: `${unhealthyCount.value}`,
help: `共 ${monitors.value.length} 个格式`,
icon: AlertTriangle,
valueClass: unhealthyCount.value > 0 ? 'text-destructive' : 'text-foreground',
iconClass: unhealthyCount.value > 0 ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground'
},
{
label: '预警格式',
value: `${warningCount.value}`,
help: '建议优先观察',
icon: Radio,
valueClass: warningCount.value > 0 ? 'text-yellow-600 dark:text-yellow-400' : 'text-foreground',
iconClass: warningCount.value > 0 ? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400' : 'bg-muted/60 text-muted-foreground'
},
{
label: '有流量格式',
value: `${trafficCount.value}`,
help: `最近 ${lookbackHours.value} 小时`,
icon: Activity,
valueClass: 'text-foreground',
iconClass: 'bg-primary/10 text-primary'
},
{
label: '整体成功率',
value: overallSuccessRate.value == null ? '--' : formatPercent(overallSuccessRate.value),
help: '按 success / (success + failed)',
icon: Gauge,
valueClass: overallSuccessRate.value != null && overallSuccessRate.value < 0.95
? 'text-yellow-600 dark:text-yellow-400'
: 'text-foreground',
iconClass: 'bg-primary/10 text-primary'
}
]
if (props.isAdmin) {
items.push(
{
label: '熔断 Key',
value: `${healthSummary.value?.keys.circuit_open ?? 0}`,
help: '已打开熔断器',
icon: ShieldAlert,
valueClass: (healthSummary.value?.keys.circuit_open ?? 0) > 0 ? 'text-destructive' : 'text-foreground',
iconClass: (healthSummary.value?.keys.circuit_open ?? 0) > 0 ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground'
},
{
label: '活跃 Key',
value: `${healthSummary.value?.keys.active ?? 0}`,
help: '当前可调度密钥',
icon: KeyRound,
valueClass: 'text-foreground',
iconClass: 'bg-primary/10 text-primary'
}
)
}
return items
})
const filteredMonitors = computed(() => {
const filtered = monitors.value.filter((monitor) => {
const status = getMonitorStatus(monitor)
const family = getMonitorFamily(monitor.api_format)
if (statusFilter.value !== 'all' && status !== statusFilter.value) {
return false
}
if (familyFilter.value !== 'all' && family !== familyFilter.value) {
return false
}
if (onlyTraffic.value && monitor.total_attempts <= 0) {
return false
}
return true
})
return filtered.sort((left, right) => {
const statusGap = STATUS_SORT_ORDER[getMonitorStatus(left)] - STATUS_SORT_ORDER[getMonitorStatus(right)]
if (statusGap !== 0) return statusGap
const leftTime = left.last_event_at ? new Date(left.last_event_at).getTime() : 0
const rightTime = right.last_event_at ? new Date(right.last_event_at).getTime() : 0
if (leftTime !== rightTime) return rightTime - leftTime
if (left.total_attempts !== right.total_attempts) {
return right.total_attempts - left.total_attempts
}
return left.api_format.localeCompare(right.api_format)
})
})
const hasActiveFilters = computed(() =>
statusFilter.value !== 'all' || familyFilter.value !== 'all' || onlyTraffic.value
)
const selectedMonitor = computed<MonitorItem | null>(() => {
if (!selectedMonitorFormat.value) return null
return monitors.value.find(item => item.api_format === selectedMonitorFormat.value) ?? null
})
const selectedFormatKeys = computed<GroupedFormatKey[]>(() => {
if (!selectedMonitorFormat.value) return []
const keys = keysByFormat.value[selectedMonitorFormat.value] ?? []
return [...keys].sort((left, right) => {
const circuitGap = Number(right.circuit_breaker_open) - Number(left.circuit_breaker_open)
if (circuitGap !== 0) return circuitGap
const leftHealth = left.health_score ?? 1
const rightHealth = right.health_score ?? 1
if (leftHealth !== rightHealth) return leftHealth - rightHealth
if (left.request_count !== right.request_count) {
return right.request_count - left.request_count
}
return left.name.localeCompare(right.name)
})
})
const recoverAllDialogDescription = computed(() => {
const circuitOpen = healthSummary.value?.keys.circuit_open ?? 0
return [
'这会重置所有已熔断 Key 的健康状态并关闭熔断器。',
`当前检测到 **${circuitOpen}** 个熔断 Key。`,
'建议在确认上游服务恢复后执行。'
].join('\n')
})
async function loadMonitors(silent = false) {
const currentRequestId = ++loadRequestId
loadingMonitors.value = true
try {
const params = {
lookback_hours: parseInt(lookbackHours.value),
per_format_limit: 100
per_format_limit: 60
}
const monitorPromise = props.isAdmin
? getEndpointStatusMonitor(params)
: getPublicEndpointStatusMonitor(params)
const [monitorResult, summaryResult] = await Promise.allSettled([
monitorPromise,
props.isAdmin ? getHealthSummary() : Promise.resolve(null)
])
if (currentRequestId !== loadRequestId) return
if (monitorResult.status === 'fulfilled') {
monitors.value = monitorResult.value.formats || []
generatedAt.value = monitorResult.value.generated_at ?? null
} else if (!silent) {
showError(parseApiError(monitorResult.reason, '加载健康监控数据失败'), '错误')
}
if (props.isAdmin) {
const data = await getEndpointStatusMonitor(params)
monitors.value = data.formats || []
if (summaryResult.status === 'fulfilled') {
healthSummary.value = summaryResult.value
}
} else {
const data = await getPublicEndpointStatusMonitor(params)
monitors.value = data.formats || []
healthSummary.value = null
}
if (selectedMonitorFormat.value && !monitors.value.some(item => item.api_format === selectedMonitorFormat.value)) {
detailDialogOpen.value = false
selectedMonitorFormat.value = null
}
} catch (err: unknown) {
if (!silent) {
showError(parseApiError(err, '加载健康监控数据失败'), '错误')
}
} finally {
if (currentRequestId === loadRequestId) {
loadingMonitors.value = false
}
}
}
async function refreshData() {
loading.value = true
async function refreshData(options: { silent?: boolean } = {}) {
await loadMonitors(Boolean(options.silent))
}
async function loadKeysByFormat(force = false) {
if (!props.isAdmin) return
if (!force && Object.keys(keysByFormat.value).length > 0) return
loadingFormatKeys.value = true
try {
await loadMonitors()
keysByFormat.value = await getKeysGroupedByFormat()
} catch (err: unknown) {
showError(parseApiError(err, '加载 Key 诊断数据失败'), '错误')
} finally {
loading.value = false
loadingFormatKeys.value = false
}
}
function getSuccessRateVariant(rate: number): 'default' | 'secondary' | 'destructive' | 'outline' {
if (rate >= 0.95) return 'default'
if (rate >= 0.8) return 'secondary'
return 'destructive'
function resetFilters() {
statusFilter.value = 'all'
familyFilter.value = 'all'
onlyTraffic.value = false
}
function stopAutoRefresh() {
if (!autoRefreshTimer) return
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
function startAutoRefresh() {
stopAutoRefresh()
if (!autoRefreshEnabled.value) return
autoRefreshTimer = setInterval(() => {
if (document.visibilityState === 'hidden') return
void refreshData({ silent: true })
}, AUTO_REFRESH_INTERVAL_MS)
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && autoRefreshEnabled.value) {
void refreshData({ silent: true })
}
}
async function openMonitorDetail(monitor: MonitorItem) {
selectedMonitorFormat.value = monitor.api_format
detailDialogOpen.value = true
if (props.isAdmin) {
await loadKeysByFormat()
}
}
async function confirmRecoverAllKeys() {
if (recoveringAllKeys.value) return
recoveringAllKeys.value = true
try {
const result = await recoverAllKeysHealth()
recoverAllConfirmOpen.value = false
if (result.recovered_count > 0) {
showSuccess(result.message || `已恢复 ${result.recovered_count} 个熔断 Key`)
} else {
showWarning(result.message || '当前没有需要恢复的熔断 Key')
}
await refreshData({ silent: false })
} catch (err: unknown) {
showError(parseApiError(err, '批量恢复熔断 Key 失败'), '错误')
} finally {
recoveringAllKeys.value = false
}
}
async function recoverFormatKey(key: GroupedFormatKey) {
if (recoveringKeyId.value || !selectedMonitorFormat.value) return
recoveringKeyId.value = key.id
try {
const result = await recoverKeyHealth(key.id, selectedMonitorFormat.value)
showSuccess(result.message || '当前格式已恢复')
await Promise.all([
refreshData({ silent: true }),
loadKeysByFormat(true)
])
} catch (err: unknown) {
showError(parseApiError(err, '恢复 Key 健康状态失败'), '错误')
} finally {
recoveringKeyId.value = null
}
}
function goToProviderManagement(providerId: string) {
void router.push({
name: 'ProviderManagement',
query: { providerId }
})
}
function goToPoolManagement(providerId: string, search?: string) {
const query: Record<string, string> = { providerId }
const normalizedSearch = search?.trim()
if (normalizedSearch) {
query.search = normalizedSearch
}
void router.push({
name: 'PoolManagement',
query
})
}
function getMonitorFamily(apiFormat: string): 'claude' | 'openai' | 'gemini' | 'other' {
if (apiFormat.startsWith('claude:')) return 'claude'
if (apiFormat.startsWith('openai:')) return 'openai'
if (apiFormat.startsWith('gemini:')) return 'gemini'
return 'other'
}
function getLatestTimelineStatus(monitor: MonitorItem): MonitorStatus | null {
const timeline = monitor.timeline ?? []
for (let index = timeline.length - 1; index >= 0; index -= 1) {
const status = timeline[index]
if (status === 'healthy' || status === 'warning' || status === 'unhealthy') {
return status
}
}
return null
}
function getMonitorStatus(monitor: MonitorItem): MonitorStatus {
const timelineStatus = getLatestTimelineStatus(monitor)
if (timelineStatus === 'unhealthy') return 'unhealthy'
if (timelineStatus === 'warning') return 'warning'
if (monitor.total_attempts <= 0) {
return 'unknown'
}
if (monitor.success_rate < 0.7) return 'unhealthy'
if (monitor.success_rate < 0.95) return 'warning'
if (timelineStatus === 'healthy' || monitor.success_rate >= 0.95) return 'healthy'
return 'unknown'
}
function getMonitorStatusLabel(monitor: MonitorItem): string {
switch (getMonitorStatus(monitor)) {
case 'unhealthy':
return '异常'
case 'warning':
return '预警'
case 'healthy':
return '健康'
default:
return '无流量'
}
}
function getMonitorStatusVariant(monitor: MonitorItem): 'success' | 'warning' | 'destructive' | 'outline' {
switch (getMonitorStatus(monitor)) {
case 'healthy':
return 'success'
case 'warning':
return 'warning'
case 'unhealthy':
return 'destructive'
default:
return 'outline'
}
}
function getMonitorCardClass(monitor: MonitorItem): string {
switch (getMonitorStatus(monitor)) {
case 'unhealthy':
return 'border-red-300/70 bg-red-500/5 hover:border-red-400/80'
case 'warning':
return 'border-yellow-300/70 bg-yellow-500/5 hover:border-yellow-400/80'
case 'healthy':
return 'border-border/60 hover:border-primary/50'
default:
return 'border-border/40 bg-muted/20 hover:border-border/70'
}
}
function getSuccessRateVariant(rate: number): 'success' | 'warning' | 'destructive' | 'outline' {
if (rate >= 0.95) return 'success'
if (rate >= 0.8) return 'warning'
if (rate >= 0) return 'destructive'
return 'outline'
}
function formatPercent(rate: number): string {
return `${(rate * 100).toFixed(0)}%`
}
function formatRelativeTime(dateString: string): string {
const diff = Date.now() - new Date(dateString).getTime()
if (Number.isNaN(diff)) return '未知'
if (diff < 60_000) return '刚刚'
const minutes = Math.floor(diff / 60_000)
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
if (days < 7) return `${days} 天前`
return formatAbsoluteTime(dateString)
}
function formatAbsoluteTime(dateString: string): string {
return new Date(dateString).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
watch(lookbackHours, () => {
loadMonitors()
void refreshData({ silent: false })
})
watch(() => props.isAdmin, () => {
keysByFormat.value = {}
void refreshData({ silent: false })
})
watch(autoRefreshEnabled, () => {
startAutoRefresh()
})
watch(detailDialogOpen, (open) => {
if (!open) {
selectedMonitorFormat.value = null
}
})
onMounted(() => {
refreshData()
document.addEventListener('visibilitychange', handleVisibilityChange)
startAutoRefresh()
void refreshData({ silent: false })
})
onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
stopAutoRefresh()
})
</script>
@@ -0,0 +1,936 @@
<template>
<Dialog
v-model="open"
size="5xl"
close-on-backdrop
:title="monitor ? `${formatApiFormat(monitor.api_format)} 详情` : '监控详情'"
:description="dialogDescription"
>
<template #header-actions>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
aria-label="关闭详情"
@click="open = false"
>
<X class="h-4 w-4" />
</Button>
</template>
<div
v-if="monitor"
class="space-y-4"
>
<div class="grid grid-cols-2 gap-3 xl:grid-cols-5">
<Card
variant="inset"
class="p-3"
>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
当前状态
</div>
<div class="mt-2">
<Badge :variant="getMonitorStatusVariant(monitor)">
{{ getMonitorStatusLabel(monitor) }}
</Badge>
</div>
</Card>
<Card
variant="inset"
class="p-3"
>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
成功率
</div>
<div class="mt-2 text-xl font-semibold">
{{ formatPercent(monitor.success_rate) }}
</div>
</Card>
<Card
variant="inset"
class="p-3"
>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
请求总数
</div>
<div class="mt-2 text-xl font-semibold">
{{ monitor.total_attempts }}
</div>
</Card>
<Card
variant="inset"
class="p-3"
>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
最近失败
</div>
<div class="mt-2 text-xl font-semibold">
{{ failureEvents.length }}
</div>
</Card>
<Card
variant="inset"
class="p-3"
>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
最近事件
</div>
<div class="mt-2 text-sm font-medium">
{{ monitor.last_event_at ? formatAbsoluteTime(monitor.last_event_at) : '暂无' }}
</div>
</Card>
</div>
<div
v-if="monitorApiPath"
class="rounded-xl border border-border/60 bg-muted/20 px-4 py-3 text-sm"
>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
请求入口
</div>
<div class="mt-2 font-mono text-foreground">
{{ monitorApiPath }}
</div>
</div>
<div
v-if="isAdmin"
class="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]"
>
<Card
variant="inset"
class="p-4"
>
<div class="mb-3 flex items-center justify-between">
<h4 class="text-sm font-semibold">
受影响 Provider
</h4>
<span class="text-xs text-muted-foreground">
{{ providerSummaries.length }} 个
</span>
</div>
<div
v-if="loadingFormatKeys"
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
>
<Loader2 class="h-4 w-4 animate-spin" />
正在加载 Key 诊断数据...
</div>
<div
v-else-if="providerSummaries.length === 0"
class="py-6 text-sm text-muted-foreground"
>
当前格式下没有可诊断的 provider/key 数据。
</div>
<div
v-else
class="max-h-[420px] space-y-2 overflow-y-auto pr-1"
>
<div
v-for="provider in providerSummaries"
:key="provider.provider_id"
class="rounded-xl border p-3 transition-colors"
:class="providerFilter === provider.provider_id
? 'border-primary/60 bg-primary/5'
: 'border-border/50 bg-card/70'"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium">
{{ provider.provider_name }}
</span>
<Badge :variant="provider.circuitOpenKeys > 0 ? 'destructive' : provider.providerActive ? 'success' : 'outline'">
{{ provider.circuitOpenKeys > 0 ? `${provider.circuitOpenKeys} 熔断` : provider.providerActive ? '活跃' : '停用' }}
</Badge>
</div>
<div class="mt-1 text-xs text-muted-foreground">
{{ provider.totalKeys }} 个 Key
<span class="mx-1">·</span>
活跃 {{ provider.activeKeys }}
<span class="mx-1">·</span>
平均健康 {{ formatScore(provider.avgHealthScore) }}
</div>
</div>
<div class="text-right text-xs text-muted-foreground">
<div>{{ provider.requestCount }} 请求</div>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
:class="FILTER_BUTTON_CLASS"
@click="toggleProviderFilter(provider.provider_id)"
>
{{ providerFilter === provider.provider_id ? '取消筛选' : '只看该 Provider' }}
</Button>
<Button
variant="outline"
size="sm"
:class="FILTER_BUTTON_CLASS"
@click="$emit('navigateProvider', provider.provider_id)"
>
前往 Provider
</Button>
<Button
v-if="provider.poolEnabled"
variant="outline"
size="sm"
:class="FILTER_BUTTON_CLASS"
@click="$emit('navigatePool', provider.provider_id)"
>
前往号池
</Button>
</div>
</div>
</div>
</Card>
<Card
variant="inset"
class="p-4"
>
<div class="mb-3 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-semibold">
Key 诊断
</h4>
<span class="text-xs text-muted-foreground">
{{ filteredFormatKeys.length }} / {{ sortedFormatKeys.length }} 个 Key
</span>
</div>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'all' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
@click="keyScopeFilter = 'all'"
>
全部
</Button>
<Button
variant="outline"
size="sm"
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'circuit-open' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
@click="keyScopeFilter = 'circuit-open'"
>
仅看熔断
</Button>
<Button
variant="outline"
size="sm"
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'recoverable' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
@click="keyScopeFilter = 'recoverable'"
>
仅看可恢复项
</Button>
<Button
v-if="providerFilter !== 'all' || keyScopeFilter !== 'all'"
variant="outline"
size="sm"
:class="FILTER_BUTTON_CLASS"
@click="resetFilters"
>
清空局部筛选
</Button>
</div>
<div
v-if="providerFilter !== 'all' || keyScopeFilter !== 'all'"
class="text-xs text-muted-foreground"
>
<span v-if="providerFilter !== 'all'">
Provider:{{ providerFilterName }}
</span>
<span v-if="providerFilter !== 'all' && keyScopeFilter !== 'all'"> · </span>
<span v-if="keyScopeFilter === 'circuit-open'">范围:仅熔断项</span>
<span v-else-if="keyScopeFilter === 'recoverable'">范围:仅可恢复项</span>
</div>
</div>
<div
v-if="loadingFormatKeys"
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
>
<Loader2 class="h-4 w-4 animate-spin" />
正在加载 Key 诊断数据...
</div>
<div
v-else-if="sortedFormatKeys.length === 0"
class="py-6 text-sm text-muted-foreground"
>
当前格式下没有 Key 样本。
</div>
<div
v-else-if="filteredFormatKeys.length === 0"
class="py-6 text-sm text-muted-foreground"
>
当前局部筛选下没有匹配的 Key。
</div>
<div
v-else
class="max-h-[420px] space-y-2 overflow-y-auto pr-1"
>
<div
v-for="key in filteredFormatKeys"
:key="key.id"
class="rounded-xl border border-border/50 bg-card/70 p-3"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium">
{{ key.name }}
</span>
<Badge variant="outline">
{{ key.provider_name }}
</Badge>
<Badge :variant="key.circuit_breaker_open ? 'destructive' : key.is_active && key.provider_active ? 'success' : 'outline'">
{{ key.circuit_breaker_open ? '已熔断' : key.is_active && key.provider_active ? '可用' : '停用' }}
</Badge>
<Badge
v-if="shouldShowRecoverAction(key)"
variant="warning"
>
建议恢复
</Badge>
</div>
<div class="mt-1 font-mono text-xs text-muted-foreground">
{{ key.api_key_masked }}
</div>
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>健康 {{ formatScore(key.health_score) }}</span>
<span v-if="key.success_rate != null">{{ formatPercent(key.success_rate) }}</span>
<span>{{ key.request_count }} 请求</span>
<span v-if="key.avg_response_time_ms != null">均延迟 {{ formatLatency(key.avg_response_time_ms) }}</span>
</div>
</div>
<div class="flex flex-wrap gap-2 sm:justify-end">
<Button
v-if="key.pool_enabled"
variant="outline"
size="sm"
:class="ACTION_BUTTON_CLASS"
@click="$emit('navigatePool', key.provider_id, key.name)"
>
号池内查看
</Button>
<Button
v-if="shouldShowRecoverAction(key)"
variant="outline"
size="sm"
:class="ACTION_BUTTON_CLASS"
:disabled="recoveringKeyId !== null"
@click="$emit('recoverKey', key)"
>
<Loader2
v-if="recoveringKeyId === key.id"
class="mr-2 h-3.5 w-3.5 animate-spin"
/>
恢复健康度
</Button>
</div>
</div>
</div>
</div>
</Card>
</div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<Card
variant="inset"
class="p-4"
>
<div class="mb-3 flex items-center justify-between">
<h4 class="text-sm font-semibold">
最近失败
</h4>
<span class="text-xs text-muted-foreground">
最近 {{ Math.min(failureEvents.length, 8) }} 条
</span>
</div>
<div
v-if="failureEvents.length === 0"
class="py-6 text-sm text-muted-foreground"
>
当前窗口内没有失败事件。
</div>
<div
v-else
class="space-y-2"
>
<div
v-for="event in failureEvents.slice(0, 8)"
:key="buildEventKey(event)"
class="rounded-xl border border-border/50 bg-card/70 p-3"
>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<Badge variant="destructive">
失败
</Badge>
<span
v-if="event.status_code"
class="text-xs font-medium text-foreground"
>
HTTP {{ event.status_code }}
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ formatAbsoluteTime(event.timestamp) }}
</span>
</div>
<div class="mt-2 text-sm text-foreground">
{{ event.error_type || event.error_message || '未提供错误类型' }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
{{ event.latency_ms != null ? `延迟 ${formatLatency(event.latency_ms)}` : '延迟未知' }}
</div>
</div>
</div>
</Card>
<Card
variant="inset"
class="p-4"
>
<div class="mb-3">
<h4 class="text-sm font-semibold">
错误分布
</h4>
</div>
<div class="space-y-4">
<div>
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
Error Type
</div>
<div
v-if="errorTypes.length === 0"
class="text-sm text-muted-foreground"
>
当前没有可归类的错误类型。
</div>
<div
v-else
class="flex flex-wrap gap-2"
>
<Badge
v-for="item in errorTypes"
:key="item.label"
variant="outline"
>
{{ item.label }} × {{ item.count }}
</Badge>
</div>
</div>
<div>
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
Status Code
</div>
<div
v-if="statusCodes.length === 0"
class="text-sm text-muted-foreground"
>
当前没有状态码样本。
</div>
<div
v-else
class="flex flex-wrap gap-2"
>
<Badge
v-for="item in statusCodes"
:key="item.label"
variant="outline"
>
{{ item.label }} × {{ item.count }}
</Badge>
</div>
</div>
<div>
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
延迟样本
</div>
<div class="grid grid-cols-3 gap-2 text-sm">
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
<div class="text-[11px] text-muted-foreground">
P50
</div>
<div class="mt-1 font-semibold">
{{ latencyStats.p50 }}
</div>
</div>
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
<div class="text-[11px] text-muted-foreground">
P95
</div>
<div class="mt-1 font-semibold">
{{ latencyStats.p95 }}
</div>
</div>
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
<div class="text-[11px] text-muted-foreground">
Max
</div>
<div class="mt-1 font-semibold">
{{ latencyStats.max }}
</div>
</div>
</div>
</div>
</div>
</Card>
</div>
<Card
variant="inset"
class="p-4"
>
<div class="mb-3 flex items-center justify-between">
<h4 class="text-sm font-semibold">
最近活动
</h4>
<span class="text-xs text-muted-foreground">
最近 {{ Math.min(sortedEvents.length, 12) }} 条事件
</span>
</div>
<div
v-if="sortedEvents.length === 0"
class="py-6 text-sm text-muted-foreground"
>
当前没有事件样本。
</div>
<div
v-else
class="space-y-2"
>
<div
v-for="event in sortedEvents.slice(0, 12)"
:key="buildEventKey(event)"
class="flex flex-col gap-2 rounded-xl border border-border/50 bg-card/70 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-2 flex-wrap">
<Badge :variant="getEventStatusVariant(event.status)">
{{ getEventStatusLabel(event.status) }}
</Badge>
<span
v-if="event.status_code"
class="text-xs text-foreground"
>
HTTP {{ event.status_code }}
</span>
<span
v-if="event.latency_ms != null"
class="text-xs text-muted-foreground"
>
{{ formatLatency(event.latency_ms) }}
</span>
<span
v-if="event.error_type"
class="truncate text-xs text-muted-foreground"
>
{{ event.error_type }}
</span>
</div>
<div class="text-xs text-muted-foreground">
{{ formatAbsoluteTime(event.timestamp) }}
</div>
</div>
</div>
</Card>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Loader2, X } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import type { GroupedFormatKey } from '@/api/endpoints/keys'
import type { EndpointHealthEvent, EndpointStatusMonitor, PublicEndpointStatusMonitor, PublicHealthEvent } from '@/api/endpoints/types'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
type MonitorItem = EndpointStatusMonitor | PublicEndpointStatusMonitor
type MonitorEvent = EndpointHealthEvent | PublicHealthEvent
type MonitorStatus = 'healthy' | 'warning' | 'unhealthy' | 'unknown'
type CountItem = { label: string; count: number }
type KeyScopeFilter = 'all' | 'circuit-open' | 'recoverable'
type ProviderImpactSummary = {
provider_id: string
provider_name: string
providerActive: boolean
poolEnabled: boolean
totalKeys: number
activeKeys: number
circuitOpenKeys: number
avgHealthScore: number | null
requestCount: number
}
const props = defineProps<{
monitor: MonitorItem | null
isAdmin: boolean
formatKeys: GroupedFormatKey[]
loadingFormatKeys: boolean
recoveringKeyId: string | null
}>()
defineEmits<{
navigateProvider: [providerId: string]
navigatePool: [providerId: string, search?: string]
recoverKey: [key: GroupedFormatKey]
}>()
const open = defineModel<boolean>({ default: false })
const FILTER_BUTTON_CLASS = 'h-8 px-3 text-xs'
const ACTION_BUTTON_CLASS = 'h-8 px-3 text-xs'
const providerFilter = ref<string>('all')
const keyScopeFilter = ref<KeyScopeFilter>('all')
watch(open, (value) => {
if (!value) {
providerFilter.value = 'all'
keyScopeFilter.value = 'all'
}
})
const monitorApiPath = computed(() =>
props.monitor && 'api_path' in props.monitor ? props.monitor.api_path : null
)
const sortedEvents = computed<MonitorEvent[]>(() => {
const events = props.monitor?.events ?? []
return [...events].sort((left, right) =>
new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime()
)
})
const failureEvents = computed<MonitorEvent[]>(() =>
sortedEvents.value.filter(event => event.status === 'failed')
)
const sortedFormatKeys = computed<GroupedFormatKey[]>(() =>
[...props.formatKeys].sort((left, right) => {
const circuitGap = Number(right.circuit_breaker_open) - Number(left.circuit_breaker_open)
if (circuitGap !== 0) return circuitGap
const leftHealth = left.health_score ?? 1
const rightHealth = right.health_score ?? 1
if (leftHealth !== rightHealth) return leftHealth - rightHealth
if (left.request_count !== right.request_count) {
return right.request_count - left.request_count
}
return left.name.localeCompare(right.name)
})
)
const providerSummaries = computed<ProviderImpactSummary[]>(() => {
const grouped = new Map<string, ProviderImpactSummary & { healthValues: number[] }>()
for (const key of sortedFormatKeys.value) {
const current = grouped.get(key.provider_id) ?? {
provider_id: key.provider_id,
provider_name: key.provider_name,
providerActive: key.provider_active,
poolEnabled: key.pool_enabled,
totalKeys: 0,
activeKeys: 0,
circuitOpenKeys: 0,
avgHealthScore: null,
requestCount: 0,
healthValues: []
}
current.totalKeys += 1
current.requestCount += key.request_count
current.providerActive = current.providerActive || key.provider_active
current.poolEnabled = current.poolEnabled || key.pool_enabled
if (key.is_active) current.activeKeys += 1
if (key.circuit_breaker_open) current.circuitOpenKeys += 1
if (key.health_score != null) current.healthValues.push(key.health_score)
grouped.set(key.provider_id, current)
}
return Array.from(grouped.values())
.map((provider) => ({
provider_id: provider.provider_id,
provider_name: provider.provider_name,
providerActive: provider.providerActive,
poolEnabled: provider.poolEnabled,
totalKeys: provider.totalKeys,
activeKeys: provider.activeKeys,
circuitOpenKeys: provider.circuitOpenKeys,
avgHealthScore: provider.healthValues.length > 0
? provider.healthValues.reduce((sum, value) => sum + value, 0) / provider.healthValues.length
: null,
requestCount: provider.requestCount,
}))
.sort((left, right) => {
if (left.circuitOpenKeys !== right.circuitOpenKeys) {
return right.circuitOpenKeys - left.circuitOpenKeys
}
const leftHealth = left.avgHealthScore ?? 1
const rightHealth = right.avgHealthScore ?? 1
if (leftHealth !== rightHealth) return leftHealth - rightHealth
return right.requestCount - left.requestCount
})
})
const providerFilterName = computed(() =>
providerSummaries.value.find(p => p.provider_id === providerFilter.value)?.provider_name ?? '当前 Provider'
)
const filteredFormatKeys = computed<GroupedFormatKey[]>(() =>
sortedFormatKeys.value.filter((key) => {
if (providerFilter.value !== 'all' && key.provider_id !== providerFilter.value) {
return false
}
if (keyScopeFilter.value === 'circuit-open') {
return key.circuit_breaker_open
}
if (keyScopeFilter.value === 'recoverable') {
return shouldShowRecoverAction(key)
}
return true
})
)
const dialogDescription = computed(() => {
if (!props.monitor) return '查看最近失败、错误分布与事件样本'
const timeText = props.monitor.last_event_at
? `最近事件 ${formatRelativeTime(props.monitor.last_event_at)}`
: '当前窗口没有事件'
return `${getMonitorStatusLabel(props.monitor)} · ${props.monitor.total_attempts} 次请求 · ${timeText}`
})
const errorTypes = computed<CountItem[]>(() =>
buildCountItems(
failureEvents.value
.map(event => event.error_type?.trim())
.filter((value): value is string => Boolean(value))
)
)
const statusCodes = computed<CountItem[]>(() =>
buildCountItems(
failureEvents.value
.map(event => event.status_code != null ? String(event.status_code) : null)
.filter((value): value is string => Boolean(value))
)
)
const latencyStats = computed(() => {
const values = sortedEvents.value
.map(event => event.latency_ms)
.filter((value): value is number => value != null)
.sort((left, right) => left - right)
if (values.length === 0) {
return { p50: '--', p95: '--', max: '--' }
}
return {
p50: formatLatency(pickPercentile(values, 0.5)),
p95: formatLatency(pickPercentile(values, 0.95)),
max: formatLatency(values[values.length - 1])
}
})
watch(sortedFormatKeys, (keys) => {
if (providerFilter.value === 'all') return
if (!keys.some(key => key.provider_id === providerFilter.value)) {
providerFilter.value = 'all'
}
})
function resetFilters() {
providerFilter.value = 'all'
keyScopeFilter.value = 'all'
}
function toggleProviderFilter(providerId: string) {
providerFilter.value = providerFilter.value === providerId ? 'all' : providerId
}
function shouldShowRecoverAction(key: GroupedFormatKey): boolean {
if (key.health_score == null) return false
return key.health_score < 0.999
}
function getLatestTimelineStatus(monitor: MonitorItem): MonitorStatus | null {
const timeline = monitor.timeline ?? []
for (let index = timeline.length - 1; index >= 0; index -= 1) {
const status = timeline[index]
if (status === 'healthy' || status === 'warning' || status === 'unhealthy') {
return status
}
}
return null
}
function getMonitorStatus(monitor: MonitorItem): MonitorStatus {
const timelineStatus = getLatestTimelineStatus(monitor)
if (timelineStatus === 'unhealthy') return 'unhealthy'
if (timelineStatus === 'warning') return 'warning'
if (monitor.total_attempts <= 0) {
return 'unknown'
}
if (monitor.success_rate < 0.7) return 'unhealthy'
if (monitor.success_rate < 0.95) return 'warning'
if (timelineStatus === 'healthy' || monitor.success_rate >= 0.95) return 'healthy'
return 'unknown'
}
function getMonitorStatusLabel(monitor: MonitorItem): string {
switch (getMonitorStatus(monitor)) {
case 'unhealthy':
return '异常'
case 'warning':
return '预警'
case 'healthy':
return '健康'
default:
return '无流量'
}
}
function getMonitorStatusVariant(monitor: MonitorItem): 'success' | 'warning' | 'destructive' | 'outline' {
switch (getMonitorStatus(monitor)) {
case 'healthy':
return 'success'
case 'warning':
return 'warning'
case 'unhealthy':
return 'destructive'
default:
return 'outline'
}
}
function getEventStatusVariant(status: string): 'success' | 'warning' | 'destructive' | 'outline' {
switch (status) {
case 'success':
return 'success'
case 'failed':
return 'destructive'
case 'skipped':
return 'warning'
default:
return 'outline'
}
}
function getEventStatusLabel(status: string): string {
switch (status) {
case 'success':
return '成功'
case 'failed':
return '失败'
case 'skipped':
return '跳过'
case 'started':
return '执行中'
default:
return '未知'
}
}
function buildCountItems(values: string[]): CountItem[] {
const counter = new Map<string, number>()
for (const value of values) {
counter.set(value, (counter.get(value) ?? 0) + 1)
}
return Array.from(counter.entries())
.map(([label, count]) => ({ label, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 8)
}
function pickPercentile(sortedValues: number[], percentile: number): number {
if (sortedValues.length === 0) return 0
const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * percentile) - 1))
return sortedValues[index]
}
function formatPercent(rate: number): string {
return `${(rate * 100).toFixed(0)}%`
}
function formatScore(score: number | null | undefined): string {
if (score == null) return '--'
return `${(score * 100).toFixed(0)}%`
}
function formatLatency(latencyMs: number): string {
if (latencyMs < 1000) return `${latencyMs}ms`
return `${(latencyMs / 1000).toFixed(2)}s`
}
function formatRelativeTime(dateString: string): string {
const diff = Date.now() - new Date(dateString).getTime()
if (Number.isNaN(diff)) return '未知'
if (diff < 60_000) return '刚刚'
const minutes = Math.floor(diff / 60_000)
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
if (days < 7) return `${days} 天前`
return formatAbsoluteTime(dateString)
}
function formatAbsoluteTime(dateString: string): string {
return new Date(dateString).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
function buildEventKey(event: MonitorEvent): string {
return `${event.timestamp}-${event.status}-${event.status_code ?? 'na'}-${event.error_type ?? 'none'}-${event.latency_ms ?? 'na'}`
}
</script>
@@ -282,8 +282,6 @@ export interface AliasGroup {
const props = defineProps<{
open: boolean
providerId: string
/** @deprecated */
providerApiFormats?: string[]
models: Model[]
editingGroup?: AliasGroup | null
preselectedModelId?: string | null
@@ -410,8 +410,8 @@
:reset-key="importInputResetKey"
drop-title="拖入授权文件或点击选择"
drop-hint="支持 .json / .txt,可多选"
manual-placeholder="粘贴 Refresh Token 或 JSON 内容"
paste-toggle-text="或手动粘贴 Refresh Token"
manual-placeholder="粘贴 Refresh Token / Access Token 或 JSON 内容"
paste-toggle-text="或手动粘贴 Token"
file-toggle-text="或选择 JSON 文件导入"
textarea-class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
@error="handleImportInputError"
@@ -893,7 +893,7 @@ function isBatchImport(text: string): boolean {
return lines.length > 1
}
function parseImportText(text: string): { refresh_token: string; name?: string } | null {
function parseImportText(text: string): { refresh_token?: string; access_token?: string; name?: string } | null {
const trimmed = text.trim()
if (!trimmed) return null
@@ -907,9 +907,19 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>
const refreshToken = obj.refresh_token
if (typeof refreshToken === 'string' && refreshToken.trim()) {
const refreshTokenCamel = obj.refreshToken
const accessToken = obj.access_token
const accessTokenCamel = obj.accessToken
const normalizedRefreshToken = typeof refreshToken === 'string' && refreshToken.trim()
? refreshToken.trim()
: (typeof refreshTokenCamel === 'string' && refreshTokenCamel.trim() ? refreshTokenCamel.trim() : undefined)
const normalizedAccessToken = typeof accessToken === 'string' && accessToken.trim()
? accessToken.trim()
: (typeof accessTokenCamel === 'string' && accessTokenCamel.trim() ? accessTokenCamel.trim() : undefined)
if (normalizedRefreshToken || normalizedAccessToken) {
return {
refresh_token: refreshToken.trim(),
refresh_token: normalizedRefreshToken,
access_token: normalizedAccessToken,
name: (typeof obj.name === 'string' ? obj.name : undefined) || (typeof obj.oauth_email === 'string' ? obj.oauth_email : undefined),
}
}
@@ -919,9 +929,34 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
// Not JSON: treat as raw token.
}
if (isLikelyJwtToken(trimmed)) {
return { access_token: trimmed }
}
return { refresh_token: trimmed }
}
function isLikelyJwtToken(token: string): boolean {
const parts = token.trim().split('.')
if (parts.length !== 3 || parts.some(part => !part)) return false
try {
const header = JSON.parse(decodeBase64Url(parts[0])) as Record<string, unknown>
const payload = JSON.parse(decodeBase64Url(parts[1])) as Record<string, unknown>
const tokenType = typeof header.typ === 'string' ? header.typ.toLowerCase() : ''
if (tokenType && tokenType !== 'jwt' && tokenType !== 'at+jwt') return false
return ['exp', 'aud', 'iss', 'scope', 'scp'].some(key => key in payload)
} catch {
return false
}
}
function decodeBase64Url(value: string): string {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
return atob(padded)
}
function handleImportInputError(payload: { message: string; title?: string }) {
showError(payload.message, payload.title)
}
@@ -370,7 +370,16 @@
>
{{ getKeyOAuthExpires(key)?.text }}
</span>
<Badge
v-if="key.oauth_temporary"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
title="仅通过 Access Token 导入,无法自动刷新,到期后需要重新导入"
>
临时
</Badge>
<Button
v-else
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
@@ -548,7 +557,7 @@
v-if="key.upstream_metadata.codex?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatCodexUpdatedAt(key.upstream_metadata.codex.updated_at) }}
{{ formatUpdatedAt(key.upstream_metadata.codex.updated_at) }}
</span>
</div>
</div>
@@ -674,7 +683,7 @@
v-if="key.upstream_metadata.antigravity?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
{{ formatUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
</span>
</div>
</div>
@@ -2471,10 +2480,6 @@ function formatUpdatedAt(updatedAt: number): string {
return `${days}天前更新`
}
// 兼容旧函数名
const formatCodexUpdatedAt = formatUpdatedAt
const formatAntigravityUpdatedAt = formatUpdatedAt
function secondsUntilReset(resetTime: string): number | null {
if (!resetTime) return null
const ts = Date.parse(resetTime)
@@ -165,7 +165,6 @@
<ModelMappingDialog
v-model:open="dialogOpen"
:provider-id="provider.id"
:provider-api-formats="providerApiFormats"
:models="models"
:editing-group="editingGroup"
:preselected-model-id="preselectedModelId"
@@ -195,7 +194,6 @@ import { useToast } from '@/composables/useToast'
import {
getProviderModels,
testModel,
API_FORMAT_LABELS,
type Model,
type ProviderModelAlias
} from '@/api/endpoints'
@@ -227,16 +225,6 @@ const preselectedModelId = ref<string | null>(null)
// 列表展开状态
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 {
if (!formats || formats.length === 0) return ''
@@ -20,26 +20,31 @@
</div>
<div
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" />
加载中...
</div>
<div
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" />
加载失败
数据暂不可用
</div>
<ActivityHeatmap
v-else-if="hasData"
:data="data"
:show-header="false"
:value-label="valueLabel"
:actual-value-label="actualValueLabel"
/>
<div
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>
@@ -58,6 +63,9 @@ const props = defineProps<{
title: string
isLoading?: boolean
hasError?: boolean
minHeight?: number
valueLabel?: string
actualValueLabel?: string
}>()
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(() =>
props.data && props.data.days && props.data.days.length > 0
)
const minHeight = computed(() => props.minHeight ?? 160)
</script>
@@ -780,11 +780,11 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
}
merged.status = normalizeTimelineStatus(raw.status ?? merged.status)
if (typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
if (typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
if (typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
if (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.provider_id && typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
if (!merged.provider_name && typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
if (!merged.endpoint_id && typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
if (!merged.key_id && typeof raw.key_id === 'string') merged.key_id = raw.key_id
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.error_type === 'string') merged.error_type = raw.error_type
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">
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 { getProviderKeys } from '@/api/endpoints/keys'
import type { EndpointAPIKey } from '@/api/endpoints/types'
@@ -482,7 +482,7 @@ async function doReplay() {
if (selectedProviderId.value) params.provider_id = selectedProviderId.value
if (selectedKeyId.value) params.api_key_id = selectedKeyId.value
replayResult.value = await dashboardApi.replayRequest(
replayResult.value = await requestDetailsApi.replayRequest(
props.requestId,
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 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 { dashboardApi, type RequestDetail } from '@/api/dashboard'
import { requestDetailsApi, type RequestDetail } from '@/api/request-details'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { log } from '@/utils/logger'
@@ -1319,7 +1319,7 @@ async function ensureBodyContentLoaded() {
const requestId = ++bodyLoadRequestId
bodyLoading.value = true
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
detail.value = {
...detail.value,
@@ -1359,7 +1359,7 @@ async function loadDetail(id: string, silent = false) {
}
error.value = null
try {
const response = await dashboardApi.getRequestDetail(id, { includeBodies: false })
const response = await requestDetailsApi.getRequestDetail(id, { includeBodies: false })
if (requestId !== loadDetailRequestId) return
const previousDetail = detail.value
@@ -1737,7 +1737,7 @@ async function copyCurlCommand() {
if (!props.requestId || curlCopying.value) return
curlCopying.value = true
try {
const data = await dashboardApi.getCurlData(props.requestId)
const data = await requestDetailsApi.getCurlData(props.requestId)
if (data.curl) {
copyToClipboard(data.curl, false)
curlCopied.value = true
@@ -159,7 +159,7 @@
import { computed, ref } from 'vue'
import Card from '@/components/ui/card.vue'
import JsonContent from './JsonContent.vue'
import type { RequestDetail } from '@/api/dashboard'
import type { RequestDetail } from '@/api/request-details'
const props = withDefaults(defineProps<{
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
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
:key="user.value"
: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>
</SelectContent>
</Select>
@@ -58,10 +81,10 @@
</SelectItem>
<SelectItem
v-for="model in availableModels"
:key="model"
:value="model"
:key="model.value"
:value="model.value"
>
{{ model.replace('claude-', '') }}
{{ model.label }}
</SelectItem>
</SelectContent>
</Select>
@@ -81,10 +104,10 @@
</SelectItem>
<SelectItem
v-for="provider in availableProviders"
:key="provider"
:value="provider"
:key="provider.value"
:value="provider.value"
>
{{ provider }}
{{ provider.label }}
</SelectItem>
</SelectContent>
</Select>
@@ -106,7 +129,7 @@
:key="format.value"
:value="format.value"
>
{{ format.label }}
{{ getApiFormatOptionLabel(format) }}
</SelectItem>
</SelectContent>
</Select>
@@ -123,26 +146,12 @@
<SelectItem value="__all__">
全部状态
</SelectItem>
<SelectItem value="stream">
流式
</SelectItem>
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
<SelectItem
v-for="status in availableStatuses"
:key="status.value"
:value="status.value"
>
{{ getStatusOptionLabel(status) }}
</SelectItem>
</SelectContent>
</Select>
@@ -350,18 +359,18 @@
<TableCell
v-if="isAdmin"
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">
<span class="truncate">
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
{{ getUserDisplayName(record) }}
</span>
<span
v-if="record.api_key?.name"
v-if="getUserApiKeyLabel(record)"
class="text-muted-foreground truncate"
:title="record.api_key.name"
:title="getUserApiKeyLabel(record) || undefined"
>
{{ record.api_key.name }}
{{ getUserApiKeyLabel(record) }}
</span>
</div>
</TableCell>
@@ -369,10 +378,10 @@
<TableCell
v-if="!isAdmin"
class="py-4 w-[100px]"
:title="record.api_key?.name || '-'"
:title="getUserApiKeyLabel(record) || '-'"
>
<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
v-if="record.api_key?.display"
class="text-muted-foreground truncate"
@@ -417,7 +426,7 @@
>
<div class="flex items-center gap-1">
<div class="flex flex-col text-xs gap-0.5">
<span>{{ record.provider }}</span>
<span>{{ formatProviderLabel(record.provider) }}</span>
<span
v-if="record.api_key_name"
class="text-muted-foreground truncate"
@@ -672,16 +681,11 @@ import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables'
import { useRowClick } from '@/composables/useRowClick'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { AnalyticsFilterOption } from '@/api/analytics'
import type { DateRangeParams, UsageRecord } from '../types'
import { TimeRangePicker } from '@/components/common'
import ElapsedTimeText from './ElapsedTimeText.vue'
export interface UserOption {
id: string
username: string
email: string
}
const props = defineProps<{
records: UsageRecord[]
isAdmin: boolean
@@ -692,13 +696,17 @@ const props = defineProps<{
// 筛选
filterSearch: string
filterUser: string
filterApiKey: string
filterModel: string
filterProvider: string
filterApiFormat: string
filterStatus: string
availableUsers: UserOption[]
availableModels: string[]
availableProviders: string[]
availableUsers: AnalyticsFilterOption[]
availableApiKeys: AnalyticsFilterOption[]
availableModels: AnalyticsFilterOption[]
availableApiFormats: AnalyticsFilterOption[]
availableProviders: AnalyticsFilterOption[]
availableStatuses: AnalyticsFilterOption[]
// 分页
currentPage: number
pageSize: number
@@ -712,6 +720,7 @@ const emit = defineEmits<{
'update:timeRange': [value: DateRangeParams]
'update:filterSearch': [value: string]
'update:filterUser': [value: string]
'update:filterApiKey': [value: string]
'update:filterModel': [value: string]
'update:filterProvider': [value: string]
'update:filterApiFormat': [value: string]
@@ -723,22 +732,6 @@ const emit = defineEmits<{
'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({
get: () => props.timeRange,
set: (value: DateRangeParams) => emit('update:timeRange', value)
@@ -760,6 +753,44 @@ watch(localSearch, (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()
@@ -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 ActivityHeatmapCard } from './ActivityHeatmapCard.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':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
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':
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case 'last30days':
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
case 'last90days':
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
case 'last180days':
startDate = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000)
break
case 'last1year':
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
break
default:
return {} // 返回空对象表示不过滤时间
@@ -1,16 +1,10 @@
import { ref, computed, type Ref } from 'vue'
import { usageApi } from '@/api/usage'
import { meApi } from '@/api/me'
import { ref, type Ref } from 'vue'
import { analyticsApi, type AnalyticsBaseRequest, type AnalyticsFilterOption, type AnalyticsRecord, type AnalyticsScope } from '@/api/analytics'
import { buildTimeRangeParams } from '@/composables/useAnalyticsFilters'
import type {
UsageStatsState,
ModelStatsItem,
ProviderStatsItem,
ApiFormatStatsItem,
UsageRecord,
DateRangeParams,
EnhancedModelStatsItem
} from '../types'
import { createDefaultStats } from '../types'
import { log } from '@/utils/logger'
import { getErrorStatus } from '@/types/api-error'
@@ -26,27 +20,21 @@ export interface PaginationParams {
export interface FilterParams {
search?: string
user_id?: string
api_key_id?: string
model?: string
provider?: string
api_format?: string
status?: string
}
type FilterOptionDimension = 'user' | 'api_key' | 'model' | 'provider' | 'api_format' | 'status'
export function useUsageData(options: UseUsageDataOptions) {
const { isAdminPage } = options
// 加载状态
const isLoadingStats = ref(true)
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 totalRecords = ref(0)
@@ -56,195 +44,93 @@ export function useUsageData(options: UseUsageDataOptions) {
let loadRecordsRequestId = 0
// 可用的筛选选项(从统计数据获取,而不是从记录中)
const availableModels = ref<string[]>([])
const availableProviders = ref<string[]>([])
const availableUsers = ref<AnalyticsFilterOption[]>([])
const availableModels = ref<AnalyticsFilterOption[]>([])
const availableProviders = ref<AnalyticsFilterOption[]>([])
const availableApiKeys = ref<AnalyticsFilterOption[]>([])
const availableApiFormats = ref<AnalyticsFilterOption[]>([])
const availableStatuses = ref<AnalyticsFilterOption[]>([])
// 增强的模型统计(包含效率分析)
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
return modelStats.value.map(model => ({
...model,
costPerToken: model.total_tokens > 0
? `$${(model.total_cost / model.total_tokens * 1000000).toFixed(2)}/M`
: '-'
}))
})
function mapAnalyticsRecord(record: AnalyticsRecord): UsageRecord {
return {
id: record.id,
user_id: record.user_id || undefined,
username: record.username || undefined,
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
isLoadingStats.value = true
currentDateRange.value = dateRange
try {
if (isAdminPage.value) {
// 管理员页面顺序加载统计数据,避免刷新使用记录时瞬时打满后端 worker。
const statsData = await usageApi.getUsageStats(dateRange)
if (requestId !== loadStatsRequestId) {
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 response = await analyticsApi.getFilterOptions({
scope: buildScope(),
time_range: buildTimeRangeParams(dateRange || currentDateRange.value || {}),
filters: buildAnalyticsFilters(filters),
})
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) {
if (requestId !== loadStatsRequestId) {
return
@@ -252,17 +138,17 @@ export function useUsageData(options: UseUsageDataOptions) {
if (getErrorStatus(error) !== 403) {
log.error('加载统计数据失败:', error)
}
stats.value = createDefaultStats()
modelStats.value = []
availableUsers.value = []
availableModels.value = []
availableProviders.value = []
currentRecords.value = []
} finally {
if (requestId === loadStatsRequestId) {
isLoadingStats.value = false
}
availableApiKeys.value = []
availableApiFormats.value = []
availableStatuses.value = []
}
}
// 加载记录(真正的后端分页)
// 加载记录:前后端统一由后端分页和筛选
async function loadRecords(
pagination: PaginationParams,
filters?: FilterParams,
@@ -278,53 +164,24 @@ export function useUsageData(options: UseUsageDataOptions) {
currentDateRange.value = dateRange
}
// 构建请求参数
const params: Record<string, unknown> = {
const response = await analyticsApi.getRecords({
scope: buildScope(),
time_range: buildTimeRangeParams(currentDateRange.value || {}),
filters: buildAnalyticsFilters(filters),
search: {
text: filters?.search || null,
},
pagination: {
limit: pagination.pageSize,
offset,
...effectiveDateRange
}
// 添加筛选条件
if (filters?.search?.trim()) {
params.search = filters.search.trim()
}
if (isAdminPage.value) {
// 管理员页面:使用管理员 API
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[]
const nextRecords = (response.records || []).map(mapAnalyticsRecord)
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
}
} catch (error) {
if (requestId !== loadRecordsRequestId) {
return
@@ -412,33 +269,19 @@ export function useUsageData(options: UseUsageDataOptions) {
})
}
// 刷新所有数据
async function refreshData(dateRange?: DateRangeParams) {
await loadStats(dateRange)
}
return {
// 状态
loading,
isLoadingStats,
isLoadingRecords,
stats,
modelStats,
providerStats,
apiFormatStats,
currentRecords,
totalRecords,
// 筛选选项
availableUsers,
availableModels,
availableProviders,
availableApiKeys,
availableApiFormats,
availableStatuses,
// 计算属性
enhancedModelStats,
// 方法
loadStats,
loadRecords,
refreshData
}
}
+5 -92
View File
@@ -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 interface UsageRecord {
@@ -82,7 +11,7 @@ export interface UsageRecord {
display: string | null
} | null
provider?: string // 仅管理员可见
api_key_name?: string
api_key_name?: string // 提供商 Key 名称(管理员列使用)
rate_multiplier?: number
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
@@ -113,29 +42,13 @@ export interface DateRangeParams {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
granularity?: 'auto' | 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
}
// 时间段选项
export type PeriodValue = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days'
export type PeriodValue = 'today' | 'last7days' | 'last30days' | 'last180days' | 'last1year'
// 筛选状态(简化为常用维度)
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'active' | 'failed' | 'cancelled'
// 默认统计状态
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: ''
}
}
// 筛选状态由后端 filter-options 决定
export type FilterStatusValue = string
+3 -4
View File
@@ -516,6 +516,7 @@ const navigation = computed(() => {
items: [
{ name: '仪表盘', href: '/dashboard', icon: Home },
{ name: '健康监控', href: '/dashboard/endpoint-status', icon: Activity },
{ name: '综合报表', href: '/dashboard/reports', icon: BarChart3 },
]
},
{
@@ -529,7 +530,7 @@ const navigation = computed(() => {
title: '账户',
items: [
{ 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 },
]
}
@@ -573,10 +574,8 @@ const navigation = computed(() => {
title: '概览',
items: [
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
{ name: '数据分析', href: '/admin/analytics', icon: BarChart3 },
{ 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
View File
@@ -4,10 +4,9 @@
*/
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 { 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'
// ========== 用户数据 ==========
@@ -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[] = [
@@ -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 = [
File diff suppressed because it is too large Load Diff
+8 -18
View File
@@ -112,6 +112,11 @@ const routes: RouteRecordRaw[] = [
name: 'MyUsage',
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
},
{
path: 'reports',
name: 'Reports',
component: () => importWithRetry(() => import('@/views/user/Reports.vue'))
},
{
path: 'endpoint-status',
name: 'EndpointStatus',
@@ -196,19 +201,9 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
},
{
path: 'user-stats',
name: 'UserStats',
component: () => importWithRetry(() => import('@/views/admin/UserStats.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: 'analytics',
name: 'Analytics',
component: () => importWithRetry(() => import('@/views/admin/Analytics.vue'))
},
{
path: 'system',
@@ -272,11 +267,6 @@ const routes: RouteRecordRaw[] = [
path: 'gemini-files',
name: 'GeminiFilesManagement',
component: () => importWithRetry(() => import('@/views/admin/GeminiFilesManagement.vue'))
},
// 保留旧路由兼容性
{
path: 'video-tasks',
redirect: '/admin/async-tasks'
}
]
}
+137
View File
@@ -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)
}
+369
View File
@@ -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))
}
+135
View File
@@ -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()
}
+58
View File
@@ -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)
}
+40 -21
View File
@@ -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
export function formatTokens(num: number | undefined | null): string {
if (num === undefined || num === null || num === 0) {
export function formatTokens(num: number | string | undefined | null): string {
const normalized = toFiniteNumber(num)
if (normalized === null || normalized === 0) {
return '0'
}
// For very small values (< 1000), show as is without unit
if (num < 1000) {
return num.toString()
if (normalized < 1000) {
return normalized.toString()
}
// For values 1K-999K, show in thousands
if (num < 1000000) {
const thousands = num / 1000
if (normalized < 1000000) {
const thousands = normalized / 1000
if (thousands >= 100) {
return `${Math.round(thousands) }K`
} else if (thousands >= 10) {
@@ -22,7 +39,7 @@ export function formatTokens(num: number | undefined | null): string {
}
// For values >= 1M, show in millions
const millions = num / 1000000
const millions = normalized / 1000000
if (millions >= 100) {
return `${Math.round(millions) }M`
} else if (millions >= 10) {
@@ -33,53 +50,54 @@ export function formatTokens(num: number | undefined | null): string {
}
// Currency formatting with high precision for small values
export function formatCurrency(amount: number | undefined | null): string {
if (amount === undefined || amount === null || amount === 0) {
export function formatCurrency(amount: number | string | undefined | null): string {
const normalized = toFiniteNumber(amount)
if (normalized === null || normalized === 0) {
return '$0.00'
}
// For very small amounts (< $0.00001), show up to 8 decimal places
if (amount > 0 && amount < 0.00001) {
const formatted = amount.toFixed(8)
if (normalized > 0 && normalized < 0.00001) {
const formatted = normalized.toFixed(8)
// Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}`
}
// For small amounts (< $0.0001), show up to 6 decimal places
if (amount < 0.0001) {
const formatted = amount.toFixed(6)
if (normalized < 0.0001) {
const formatted = normalized.toFixed(6)
// Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}`
}
// For small amounts (< $0.01), show up to 5 decimal places
if (amount < 0.01) {
const formatted = amount.toFixed(5)
if (normalized < 0.01) {
const formatted = normalized.toFixed(5)
// Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}`
}
// For amounts less than $1, show 4 decimal places
if (amount < 1) {
const formatted = amount.toFixed(4)
if (normalized < 1) {
const formatted = normalized.toFixed(4)
// Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}`
}
// For amounts $1-$100, show 2-3 decimal places
if (amount < 100) {
const formatted = amount.toFixed(3)
if (normalized < 100) {
const formatted = normalized.toFixed(3)
// Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}`
}
// For larger amounts, show 2 decimal places
return `$${ amount.toFixed(2)}`
return `$${ normalized.toFixed(2)}`
}
// Number formatting with locale support
@@ -157,7 +175,8 @@ export function formatRemainingTime(expireAt: number | undefined, currentTime: n
// Cache hit rate formatting
export function formatHitRate(rate: number | undefined): string {
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)
+14
View File
@@ -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
}
+246
View File
@@ -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>
-207
View File
@@ -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>
+46 -6
View File
@@ -1013,7 +1013,7 @@
<!-- Empty keys -->
<div
v-if="keyPage.keys.length === 0 && !keysLoading"
v-if="keyPage.keys.length === 0 && !keysLoading && keysLoadedOnce"
class="flex flex-col items-center justify-center py-16 text-center"
>
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
@@ -1155,6 +1155,7 @@ import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer'
import { useConfirm } from '@/composables/useConfirm'
import { useRouteQuery } from '@/composables/useRouteQuery'
import { parseApiError } from '@/utils/errorParser'
import {
getPoolOverview,
@@ -1209,6 +1210,7 @@ const { confirm } = useConfirm()
const { copyToClipboard } = useClipboard()
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
const proxyNodesStore = useProxyNodesStore()
const { getQueryValue, patchQuery } = useRouteQuery()
// --- Overview ---
const poolProviders = ref<PoolOverviewItem[]>([])
@@ -1230,23 +1232,28 @@ async function loadOverview() {
const enabledProviders = allProviders.filter(item => item.pool_enabled)
poolProviders.value = enabledProviders
// Keep selected provider aligned with dropdown options.
const selectedId = selectedProviderId.value
const queryProviderId = getQueryValue('providerId')
const queryProviderExists = Boolean(
queryProviderId && enabledProviders.some(item => item.provider_id === queryProviderId),
)
const selectedId = selectedProviderId.value || (queryProviderExists ? queryProviderId : null)
const selectedStillExists = Boolean(
selectedId && enabledProviders.some(item => item.provider_id === selectedId),
)
if (!selectedStillExists) {
if (enabledProviders.length > 0) {
// Do not block overview loading on key list fetch; keys area has its own loader.
void selectProvider(enabledProviders[0].provider_id)
await selectProvider(enabledProviders[0].provider_id)
} else {
selectedProviderId.value = null
selectedProviderData.value = null
keysLoadedOnce.value = false
showAccountBatchDialog.value = false
closeProviderProxyPopovers()
resetKeyPage()
}
} else if (selectedId && selectedId !== selectedProviderId.value) {
await selectProvider(selectedId, { preserveSearch: true })
}
} catch (err) {
if (requestId !== overviewRequestId) return
@@ -1430,7 +1437,7 @@ const desktopColumnWidths = computed(() => {
}
})
async function selectProvider(id: string) {
async function selectProvider(id: string, options: { preserveSearch?: boolean } = {}) {
const requestId = ++selectProviderRequestId
selectedProviderId.value = id
selectedProviderData.value = null
@@ -1444,13 +1451,16 @@ async function selectProvider(id: string) {
proxyMobilePopoverOpenKeyId.value = null
suppressFiltersWatch = true
currentPage.value = 1
if (!options.preserveSearch) {
searchQuery.value = ''
}
statusFilter.value = 'all'
suppressFiltersWatch = false
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
keysLoadedOnce.value = false
resetKeyPage(1, pageSize.value)
const keysTask = loadKeys()
// Provider summary is non-blocking for key list rendering.
@@ -1482,6 +1492,7 @@ function createEmptyKeyPage(page = 1, pageSizeValue = 50): PoolKeysPageResponse
const keyPage = ref<PoolKeysPageResponse>(createEmptyKeyPage())
const keysLoading = ref(false)
const keysLoadedOnce = ref(false)
const refreshingCurrentPageQuota = ref(false)
const searchQuery = ref('')
const statusFilter = ref('all')
@@ -1504,6 +1515,33 @@ const keyFormDialogOpen = ref(false)
const oauthKeyEditDialogOpen = ref(false)
const editingKeyDetail = ref<PoolKeyDetail | null>(null)
watch(
() => getQueryValue('search') ?? '',
(value) => {
if (searchQuery.value === value) return
searchQuery.value = value
},
{ immediate: true },
)
watch(searchQuery, (value) => {
patchQuery({ search: value.trim() || undefined })
})
watch(
() => getQueryValue('providerId'),
(value) => {
if (overviewLoading.value) return
if (!value || value === selectedProviderId.value) return
if (!poolProviders.value.some(item => item.provider_id === value)) return
void selectProvider(value, { preserveSearch: true })
},
)
watch(selectedProviderId, (value) => {
patchQuery({ providerId: value || undefined })
})
interface QuotaProgressItem {
label: string
remainingPercent: number
@@ -1670,9 +1708,11 @@ async function loadKeys() {
})
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
keyPage.value = nextPage
keysLoadedOnce.value = true
} catch (err) {
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
resetKeyPage(page, pageSizeValue)
keysLoadedOnce.value = true
showError(parseApiError(err))
} finally {
if (requestId === keysRequestId) {
@@ -286,6 +286,7 @@ import {
} from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import { parseApiError } from '@/utils/errorParser'
import { useRouteQuery } from '@/composables/useRouteQuery'
interface ProviderDeleteProgressState {
providerId: string
@@ -302,6 +303,7 @@ interface ProviderDeleteProgressState {
const { error: showError, success: showSuccess, info: showInfo } = useToast()
const { confirmDanger } = useConfirm()
const { getQueryValue, patchQuery } = useRouteQuery()
// 状态
const loading = ref(false)
@@ -460,6 +462,46 @@ const {
stopTick,
} = useProviderBalance()
watch(
() => getQueryValue('search') ?? '',
(value) => {
if (searchQuery.value === value) return
searchQuery.value = value
},
{ immediate: true },
)
watch(searchQuery, (value) => {
patchQuery({ search: value.trim() || undefined })
})
watch(
() => getQueryValue('providerId'),
(value) => {
if (value) {
if (selectedProviderId.value === value) {
if (!providerDrawerOpen.value) providerDrawerOpen.value = true
return
}
openProviderDrawer(value)
return
}
if (selectedProviderId.value) selectedProviderId.value = null
if (providerDrawerOpen.value) providerDrawerOpen.value = false
},
{ immediate: true },
)
watch(selectedProviderId, (value) => {
patchQuery({ providerId: value || undefined })
})
watch(providerDrawerOpen, (open) => {
if (!open && selectedProviderId.value) {
selectedProviderId.value = null
}
})
// 扩展操作配置对话框
const opsConfigDialogOpen = ref(false)
const opsConfigProviderId = ref('')
@@ -73,6 +73,17 @@
@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
id="section-request-log"
@@ -211,6 +222,7 @@ import UserDataSection from './system-settings/UserDataSection.vue'
import DataManagementSection from './system-settings/DataManagementSection.vue'
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
import BasicConfigSection from './system-settings/BasicConfigSection.vue'
import MonitoringCapacitySection from './system-settings/MonitoringCapacitySection.vue'
import RequestLogSection from './system-settings/RequestLogSection.vue'
import CleanupPolicySection from './system-settings/CleanupPolicySection.vue'
import ScheduledTasksSection from './system-settings/ScheduledTasksSection.vue'
@@ -230,6 +242,7 @@ const tocItems = [
{ id: 'section-data-mgmt', label: '数据管理' },
{ id: 'section-proxy', label: '网络代理' },
{ id: 'section-basic', label: '基础配置' },
{ id: 'section-monitoring-capacity', label: '监控容量' },
{ id: 'section-request-log', label: '请求记录' },
{ id: 'section-cleanup', label: '记录清理策略' },
{ id: 'section-scheduled', label: '定时任务' },
@@ -290,21 +303,26 @@ const {
siteInfoLoading,
proxyConfigLoading,
basicConfigLoading,
monitoringConfigLoading,
logConfigLoading,
cleanupConfigLoading,
hasSiteInfoChanges,
hasProxyConfigChanges,
hasBasicConfigChanges,
hasMonitoringConfigChanges,
hasLogConfigChanges,
hasCleanupConfigChanges,
maxRequestBodySizeKB,
maxResponseBodySizeKB,
redisMemoryTotalGB,
postgresStorageTotalGB,
sensitiveHeadersStr,
loadSystemConfig,
loadSystemVersion,
saveSiteInfo,
saveProxyConfig,
saveBasicConfig,
saveMonitoringConfig,
saveLogConfig,
saveCleanupConfig,
handleAutoCleanupToggle,
-394
View File
@@ -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>
+28 -5
View File
@@ -1019,7 +1019,7 @@ import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { usageApi, type UsageByUser } from '@/api/usage'
import { analyticsApi } from '@/api/analytics'
import { adminApi } from '@/api/admin'
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)
let userStatsRequestId = 0
const userWalletMap = ref<Record<string, AdminWallet>>({})
@@ -1181,10 +1188,26 @@ async function loadUserStats() {
const requestId = ++userStatsRequestId
loadingStats.value = true
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
userStats.value = data.reduce((acc: Record<string, UsageByUser>, stat: UsageByUser) => {
acc[stat.user_id] = stat
userStats.value = response.rows.reduce((acc: Record<string, UserUsageSummary>, row) => {
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
}, {})
} 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
// 格式转换
enable_format_conversion: boolean
// 监控容量
redis_memory_total_bytes: number
postgres_storage_total_bytes: number
// 请求记录
request_record_level: string
max_request_body_size: number
@@ -55,6 +58,9 @@ const CONFIG_KEYS = [
'auto_delete_expired_keys',
// 格式转换
'enable_format_conversion',
// 监控容量
'redis_memory_total_bytes',
'postgres_storage_total_bytes',
// 请求记录
'request_record_level',
'max_request_body_size',
@@ -92,6 +98,9 @@ function createDefaultConfig(): SystemConfig {
auto_delete_expired_keys: false,
// 格式转换
enable_format_conversion: false,
// 监控容量
redis_memory_total_bytes: 0,
postgres_storage_total_bytes: 0,
// 请求记录
request_record_level: 'basic',
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() {
const { success, error } = useToast()
const { refreshSiteInfo } = useSiteInfo()
@@ -126,6 +147,7 @@ export function useSystemConfig() {
const siteInfoLoading = ref(false)
const proxyConfigLoading = ref(false)
const basicConfigLoading = ref(false)
const monitoringConfigLoading = ref(false)
const logConfigLoading = 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(() => {
if (!originalConfig.value) return false
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({
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() {
cleanupConfigLoading.value = true
try {
@@ -497,17 +575,21 @@ export function useSystemConfig() {
siteInfoLoading,
proxyConfigLoading,
basicConfigLoading,
monitoringConfigLoading,
logConfigLoading,
cleanupConfigLoading,
// 变动检测
hasSiteInfoChanges,
hasProxyConfigChanges,
hasBasicConfigChanges,
hasMonitoringConfigChanges,
hasLogConfigChanges,
hasCleanupConfigChanges,
// 计算属性
maxRequestBodySizeKB,
maxResponseBodySizeKB,
redisMemoryTotalGB,
postgresStorageTotalGB,
sensitiveHeadersStr,
// 加载函数
loadSystemConfig,
@@ -516,6 +598,7 @@ export function useSystemConfig() {
saveSiteInfo,
saveProxyConfig,
saveBasicConfig,
saveMonitoringConfig,
saveLogConfig,
saveCleanupConfig,
handleAutoCleanupToggle,
+1
View File
@@ -63,6 +63,7 @@ disable_response_storage = true
name = "OpenAI"
base_url = "${baseUrl.value}/v1"
wire_api = "responses"
supports_websockets = true
requires_openai_auth = true`)
const codexAuthConfig = computed(() => `{
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,5 +1,5 @@
<template>
<div class="space-y-6 pb-8">
<div class="space-y-6 px-4 sm:px-6 lg:px-0 pb-8">
<HealthMonitorCard
title="健康监控"
:is-admin="isAdminPage"
@@ -11,8 +11,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import HealthMonitorCard from '@/features/providers/components/HealthMonitorCard.vue'
const route = useRoute()
const isAdminPage = computed(() => route.path.startsWith('/admin'))
const authStore = useAuthStore()
const isAdminPage = computed(() => authStore.isAdmin || route.path.startsWith('/admin'))
</script>
+102 -315
View File
@@ -1,79 +1,13 @@
<template>
<div class="space-y-6 pb-8">
<!-- 面包屑旁的折叠按钮 -->
<Teleport
to="#header-actions-right"
defer
<!-- 跳转数据分析链接 -->
<div class="text-right">
<RouterLink
: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
v-if="statsExpanded"
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>
{{ isAdminPage ? '查看数据分析 →' : '查看综合报表 →' }}
</RouterLink>
</div>
<!-- 使用记录 -->
@@ -85,13 +19,17 @@
:time-range="timeRange"
:filter-search="filterSearch"
:filter-user="filterUser"
:filter-api-key="filterApiKey"
:filter-model="filterModel"
:filter-provider="filterProvider"
:filter-api-format="filterApiFormat"
:filter-status="filterStatus"
:available-users="availableUsers"
:available-api-keys="availableApiKeys"
:available-models="availableModels"
:available-api-formats="availableApiFormats"
:available-providers="availableProviders"
:available-statuses="availableStatuses"
:current-page="currentPage"
:page-size="pageSize"
:total-records="effectiveTotalRecords"
@@ -100,6 +38,7 @@
@update:time-range="handleTimeRangeChange"
@update:filter-search="handleFilterSearchChange"
@update:filter-user="handleFilterUserChange"
@update:filter-api-key="handleFilterApiKeyChange"
@update:filter-model="handleFilterModelChange"
@update:filter-provider="handleFilterProviderChange"
@update:filter-api-format="handleFilterApiFormatChange"
@@ -124,29 +63,20 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useLocalStorage } from '@vueuse/core'
// useLocalStorage no longer needed (analytics panel removed)
import { useAuthStore } from '@/stores/auth'
import { usageApi } from '@/api/usage'
import { usersApi } from '@/api/users'
import { meApi } from '@/api/me'
import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next'
import { analyticsApi, type AnalyticsFilterOption } from '@/api/analytics'
import {
UsageModelTable,
UsageProviderTable,
UsageApiFormatTable,
UsageRecordsTable,
ActivityHeatmapCard,
RequestDetailDrawer,
IntervalTimelineCard
} from '@/features/usage/components'
import {
useUsageData,
getDateRangeFromPeriod
} from '@/features/usage/composables'
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity'
// ActivityHeatmap moved to analytics page
import { useToast } from '@/composables/useToast'
const route = useRoute()
@@ -156,9 +86,6 @@ const authStore = useAuthStore()
// 判断是否是管理员页面
const isAdminPage = computed(() => route.path.startsWith('/admin'))
// 用量分析面板折叠状态(默认展开,持久化到 localStorage)
const statsExpanded = useLocalStorage('usage-stats-expanded', true)
// 时间范围选择
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today'))
@@ -170,155 +97,28 @@ const pageSizeOptions = [10, 20, 50, 100]
// 筛选状态
const filterSearch = ref('')
const filterUser = ref('__all__')
const filterApiKey = ref('__all__')
const filterModel = ref('__all__')
const filterProvider = ref('__all__')
const filterApiFormat = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__')
// 用户列表(仅管理员页面使用)
const availableUsers = ref<UserOption[]>([])
// 使用 composables
const {
isLoadingRecords,
providerStats,
apiFormatStats,
currentRecords,
totalRecords,
enhancedModelStats,
availableUsers,
availableApiKeys,
availableModels,
availableApiFormats,
availableProviders,
availableStatuses,
loadStats,
loadRecords
} = 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 列表
const activeRequestIds = computed(() => {
@@ -349,11 +149,10 @@ async function pollActiveRequests() {
pollInFlight = true
try {
// 根据页面类型选择不同的 API
const idsParam = activeRequestIds.value.join(',')
const { requests } = isAdminPage.value
? await usageApi.getActiveRequests(activeRequestIds.value)
: await meApi.getActiveRequests(idsParam)
const { requests } = await analyticsApi.getActiveRequests({
scope: isAdminPage.value ? { kind: 'global' } : { kind: 'me' },
ids: activeRequestIds.value,
})
let shouldRefresh = false
@@ -512,23 +311,8 @@ onUnmounted(() => {
stopGlobalAutoRefresh()
})
// 用户页面的前端分页(后端一次性返回所有记录,前端分页+筛选)
const paginatedRecords = computed(() => {
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 paginatedRecords = computed(() => currentRecords.value)
const effectiveTotalRecords = computed(() => totalRecords.value)
// 显示的记录
const displayRecords = computed(() => paginatedRecords.value)
@@ -538,33 +322,64 @@ const displayRecords = computed(() => paginatedRecords.value)
const detailModalOpen = ref(false)
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 () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
if (isAdminPage.value) {
// 管理员页面优先加载记录,统计面板在后台顺序刷新,避免瞬时并发打满后端。
await loadRecords(
{ page: currentPage.value, pageSize: pageSize.value },
getCurrentFilters(),
timeRange.value
)
void (async () => {
await refreshAdminAnalytics({ force: true })
await loadAdminUsers()
})()
} else {
// 用户页面:loadStats 已包含记录加载,不需要单独调用 loadRecords
await Promise.allSettled([
loadStats(timeRange.value).catch(err => {
const statsTask = syncFilterOptions().catch(err => {
log.error('加载统计数据失败:', err)
warning('统计数据加载失败,请刷新重试')
}),
loadHeatmapData().catch(err => {
log.error('加载热力图数据失败:', err)
})
])
}
await statsTask
await loadRecords(
{ page: currentPage.value, pageSize: pageSize.value },
getCurrentFilters()
)
if (globalAutoRefresh.value && isPageVisible.value) {
startGlobalAutoRefresh()
@@ -575,32 +390,21 @@ onMounted(async () => {
async function handleTimeRangeChange(value: DateRangeParams) {
timeRange.value = value
currentPage.value = 1 // 重置到第一页
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
await refreshAdminAnalytics({ force: true })
return
}
await loadStats(timeRange.value)
// 用户页面:loadStats 已包含记录加载
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
// 处理分页变化
async function handlePageChange(page: number) {
currentPage.value = page
if (isAdminPage.value) {
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
// 用户页面使用前端分页,无需重新请求
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
}
// 处理每页大小变化
async function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1 // 重置到第一页
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters(), timeRange.value)
}
// 用户页面使用前端分页,无需重新请求
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
}
// 获取当前筛选参数
@@ -608,6 +412,7 @@ function getCurrentFilters() {
return {
search: filterSearch.value.trim() || 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,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
@@ -619,57 +424,49 @@ function getCurrentFilters() {
async function handleFilterSearchChange(value: string) {
filterSearch.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
// 用户页面:search 需要重新从后端拉取数据(后端支持 search 参数)
// 但通过 filteredRecords 做前端过滤已覆盖,无需额外请求
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterUserChange(value: string) {
filterUser.value = value
currentPage.value = 1 // 重置到第一页
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterApiKeyChange(value: string) {
filterApiKey.value = value
currentPage.value = 1
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterModelChange(value: string) {
filterModel.value = value
currentPage.value = 1 // 重置到第一页
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterProviderChange(value: string) {
filterProvider.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterApiFormatChange(value: string) {
filterApiFormat.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterStatusChange(value: string) {
filterStatus.value = value as FilterStatusValue
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
// 刷新数据
@@ -678,18 +475,8 @@ async function refreshData() {
if (refreshInFlight) return refreshInFlight
refreshInFlight = (async () => {
if (isAdminPage.value) {
await loadRecords(
{ page: currentPage.value, pageSize: pageSize.value },
getCurrentFilters(),
timeRange.value
)
void refreshAdminAnalytics()
return
}
await loadStats(timeRange.value)
// 用户页面:loadStats 已包含记录加载
await syncFilterOptions()
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
})()
try {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -46,6 +46,7 @@ dependencies = [
"aiosmtplib>=5.1.0",
"redis>=7.1.0",
"prometheus-client>=0.24.1",
"psutil>=5.9.8",
"apscheduler>=3.11.2",
"ldap3>=2.9.1",
"msgpack>=1.1.2",
-2
View File
@@ -17,7 +17,6 @@ from .provider_query import router as provider_query_router
from .provider_strategy import router as provider_strategy_router
from .providers import router as providers_router
from .security import router as security_router
from .stats import router as stats_router
from .system import router as system_router
from .usage import router as usage_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(models_router)
router.include_router(security_router)
router.include_router(stats_router)
router.include_router(provider_query_router)
router.include_router(modules_router)
router.include_router(pool_router)
+1 -1
View File
@@ -138,7 +138,7 @@ async def get_health_summary(
- `total_endpoints`: Endpoint 总数
- `active_endpoints`: 活跃 Endpoint 数量
- `total_keys`: Key 总数
- `active_keys`: 活跃 Key 数量
- `active_keys`: 当前可调度 Key 数量(Provider 活跃 + Key 启用 + 存在活跃 Endpoint + 非熔断)
- `circuit_breaker_open_keys`: 熔断的 Key 数量
"""
adapter = AdminHealthSummaryAdapter()
+351 -1
View File
@@ -2,14 +2,21 @@
from __future__ import annotations
import os
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import case, func
from sqlalchemy import case, func, text
from sqlalchemy.orm import Session
try:
import psutil
except ModuleNotFoundError:
psutil = None
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
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.core.logger import logger
from src.database import get_db
from src.database.database import get_pool_status
from src.models.database import (
ApiKey,
AuditEventType,
@@ -25,7 +33,9 @@ from src.models.database import (
Usage,
)
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.system.config import SystemConfigService
from src.services.system.audit import audit_service
from src.utils.cache_decorator import cache_result
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()
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")
async def get_audit_logs(
request: Request,
@@ -96,6 +434,7 @@ async def get_system_status(request: Request, db: Session = Depends(get_db)) ->
- `api_keys`: API Key 统计(total: 总数, active: 活跃数)
- `today_stats`: 今日统计(requests: 请求数, tokens: token 数, cost_usd: 成本)
- `recent_errors`: 最近 1 小时内的错误数
- `system_metrics`: 系统资源摘要(cpu / memory / redis / postgres)
"""
adapter = AdminSystemStatusAdapter()
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),
)
cpu_metric = _build_cpu_metric()
memory_metric = _build_memory_metric()
redis_metric = await _build_redis_metric(db)
postgres_metric = _build_postgres_metric(db)
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"users": {"total": total_users, "active": active_users},
@@ -392,6 +736,12 @@ class AdminSystemStatusAdapter(AdminApiAdapter):
"cost_usd": f"${today_cost:.4f}",
},
"recent_errors": recent_errors,
"system_metrics": {
"cpu": cpu_metric,
"memory": memory_metric,
"redis": redis_metric,
"postgres": postgres_metric,
},
}
+361 -231
View File
@@ -36,7 +36,7 @@ from src.clients.redis_client import get_redis_client
from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
from src.core.provider_oauth_utils import enrich_auth_config, parse_codex_id_token, post_oauth_token
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType
from src.database import get_db_context
@@ -150,6 +150,10 @@ def _mark_refresh_failed_sync(key_id: str, reason: str) -> None:
if not key:
raise NotFoundException("Key 不存在", "key")
current_reason = str(getattr(key, "oauth_invalid_reason", None) or "").strip()
from src.services.provider.oauth_token import is_account_level_block
if is_account_level_block(current_reason):
return
merged_reason = _merge_refresh_failure_reason(current_reason, reason)
if merged_reason is None:
return
@@ -1577,11 +1581,62 @@ def _coerce_import_str(value: Any) -> str | None:
return normalized or None
def _decode_unverified_jwt_json_part(part: str) -> dict[str, Any] | None:
try:
padding = "=" * (-len(part) % 4)
decoded = base64.urlsafe_b64decode(f"{part}{padding}")
payload = json.loads(decoded.decode("utf-8"))
except Exception:
return None
return payload if isinstance(payload, dict) else None
def _looks_like_access_token(token: str) -> bool:
"""识别手工粘贴的 JWT Access Token,避免误当 Refresh Token 去刷新验证。"""
parts = token.strip().split(".")
if len(parts) != 3 or not all(parts):
return False
header = _decode_unverified_jwt_json_part(parts[0])
payload = _decode_unverified_jwt_json_part(parts[1])
if not header or not payload:
return False
token_type = str(header.get("typ") or "").lower()
if token_type and token_type not in {"jwt", "at+jwt"}:
return False
return any(key in payload for key in ("exp", "aud", "iss", "scope", "scp"))
def _build_standard_oauth_import_entry_from_token(token: str) -> dict[str, Any]:
if _looks_like_access_token(token):
return {"access_token": token}
return {"refresh_token": token}
def _normalize_single_import_tokens(
*,
refresh_token: str | None,
access_token: str | None,
) -> tuple[str, str]:
refresh_token_value = (refresh_token or "").strip()
access_token_value = (access_token or "").strip()
if (
refresh_token_value
and not access_token_value
and _looks_like_access_token(refresh_token_value)
):
access_token_value = refresh_token_value
refresh_token_value = ""
return refresh_token_value, access_token_value
def _extract_standard_oauth_import_entry(item: Any) -> dict[str, Any] | None:
if isinstance(item, str):
token = _coerce_import_str(item)
if token:
return {"refresh_token": token}
return _build_standard_oauth_import_entry_from_token(token)
return None
if not isinstance(item, dict):
return None
@@ -1589,10 +1644,17 @@ def _extract_standard_oauth_import_entry(item: Any) -> dict[str, Any] | None:
refresh_token = _coerce_import_str(item.get("refresh_token")) or _coerce_import_str(
item.get("refreshToken")
)
if not refresh_token:
access_token = _coerce_import_str(item.get("access_token")) or _coerce_import_str(
item.get("accessToken")
)
if not refresh_token and not access_token:
return None
entry: dict[str, Any] = {"refresh_token": refresh_token}
entry: dict[str, Any] = {}
if refresh_token:
entry["refresh_token"] = refresh_token
if access_token:
entry["access_token"] = access_token
account_id = (
_coerce_import_str(item.get("account_id"))
@@ -1678,14 +1740,18 @@ def _parse_standard_oauth_import_entries(raw_input: str) -> list[dict[str, Any]]
for line in raw.splitlines():
token = line.strip()
if token and not token.startswith("#"):
result.append({"refresh_token": token})
result.append(_build_standard_oauth_import_entry_from_token(token))
return result
def _parse_tokens_input(raw_input: str) -> list[str]:
"""兼容旧逻辑:仅返回 refresh_token 列表。"""
return [entry["refresh_token"] for entry in _parse_standard_oauth_import_entries(raw_input)]
return [
str(entry["refresh_token"])
for entry in _parse_standard_oauth_import_entries(raw_input)
if entry.get("refresh_token")
]
def _parse_kiro_import_input(raw_input: str) -> list[dict[str, Any]]:
@@ -1788,7 +1854,8 @@ def _parse_kiro_import_input(raw_input: str) -> list[dict[str, Any]]:
class ImportRefreshTokenRequest(BaseModel):
refresh_token: str = Field(..., min_length=1, description="Refresh Token")
refresh_token: str | None = Field(None, min_length=1, description="Refresh Token")
access_token: str | None = Field(None, min_length=1, description="Access Token(Codex 可选)")
name: str | None = Field(None, max_length=100, description="账号名称(可选)")
proxy_node_id: str | None = Field(
None,
@@ -1944,6 +2011,243 @@ def _apply_codex_import_hints(auth_config: dict[str, Any], import_entry: dict[st
auth_config[field] = value
def _decode_access_token_expires_at(access_token: str) -> int | None:
"""从 access token JWT payload 中解析 exp。"""
try:
import jwt
claims = jwt.decode(
access_token,
options={
"verify_signature": False,
"verify_aud": False,
},
)
except Exception:
return None
if not isinstance(claims, dict):
return None
try:
exp = int(claims.get("exp"))
except Exception:
return None
return exp if exp > 0 else None
def _build_access_token_import_payload(
*,
provider_type: str,
access_token: str,
refresh_token: str | None = None,
refresh_error: str | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
expires_at = _decode_access_token_expires_at(access_token)
token_data: dict[str, Any] = {
"access_token": access_token,
"token_type": "Bearer",
}
if expires_at is not None:
token_data["expires_at"] = expires_at
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": "Bearer",
"refresh_token": refresh_token or None,
"expires_at": expires_at,
"scope": None,
"updated_at": int(time.time()),
"imported_from_access_token": True,
"access_token_import_temporary": not bool(refresh_token),
}
if refresh_error:
auth_config["refresh_token_import_error"] = refresh_error[:300]
return token_data, auth_config
async def _build_codex_access_token_import_auth_config(
*,
provider_type: str,
access_token: str,
refresh_token: str | None,
proxy_config: dict[str, Any] | None,
import_entry: dict[str, Any] | None = None,
refresh_error: str | None = None,
) -> tuple[str, dict[str, Any], int | None]:
if provider_type != ProviderType.CODEX.value:
raise InvalidRequestException("仅 Codex 支持 Access Token 导入")
access_token = access_token.strip()
if len(access_token) < 10:
raise InvalidRequestException("Access Token 无效或过短")
token_data, auth_config = _build_access_token_import_payload(
provider_type=provider_type,
access_token=access_token,
refresh_token=refresh_token,
refresh_error=refresh_error,
)
try:
auth_config = await enrich_auth_config(
provider_type=provider_type,
auth_config=auth_config,
token_response=token_data,
access_token=access_token,
proxy_config=proxy_config,
)
except Exception as exc:
logger.warning("Codex Access Token 导入: enrich_auth_config 失败: {}", exc)
parsed = parse_codex_id_token(access_token)
for key, value in parsed.items():
if value and not auth_config.get(key):
auth_config[key] = value
if import_entry:
_apply_codex_import_hints(auth_config, import_entry)
return access_token, auth_config, auth_config.get("expires_at")
async def _exchange_refresh_token_for_import(
*,
provider_type: str,
template: Any,
refresh_token: str,
proxy_config: dict[str, Any] | None,
timeout_seconds: float,
) -> tuple[str, dict[str, Any], int | None]:
token_url = template.oauth.token_url
is_json = "anthropic.com" in token_url
scope_str = " ".join(template.oauth.scopes) if template.oauth.scopes else ""
if is_json:
body: dict[str, Any] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if scope_str:
body["scope"] = scope_str
headers = {"Content-Type": "application/json", "Accept": "application/json"}
data = None
json_body = body
else:
form: dict[str, str] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if scope_str:
form["scope"] = scope_str
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
resp = await post_oauth_token(
provider_type=provider_type,
token_url=token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
if resp.status_code < 200 or resp.status_code >= 300:
error_reason = f"HTTP {resp.status_code}"
try:
error_body = resp.json()
if "error" in error_body:
error_reason = str(error_body.get("error_description") or error_body.get("error"))
except Exception:
error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
raise InvalidRequestException(f"Refresh Token 验证失败: {error_reason}")
token_data = resp.json()
access_token = str(token_data.get("access_token") or "")
new_refresh_token = str(token_data.get("refresh_token") or "") or refresh_token
if not access_token:
raise InvalidRequestException("token refresh 返回缺少 access_token")
expires_in = token_data.get("expires_in")
expires_at: int | None = None
try:
if expires_in is not None:
expires_at = int(time.time()) + int(expires_in)
except Exception:
expires_at = None
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": token_data.get("token_type"),
"refresh_token": new_refresh_token or None,
"expires_at": expires_at,
"scope": token_data.get("scope"),
"updated_at": int(time.time()),
}
auth_config = await enrich_auth_config(
provider_type=provider_type,
auth_config=auth_config,
token_response=token_data,
access_token=access_token,
proxy_config=proxy_config,
)
return access_token, auth_config, expires_at
async def _build_standard_oauth_import_credentials(
*,
provider_type: str,
template: Any,
import_entry: dict[str, Any],
proxy_config: dict[str, Any] | None,
timeout_seconds: float,
) -> tuple[str, dict[str, Any], int | None]:
refresh_token = str(import_entry.get("refresh_token") or "").strip()
access_token_input = str(import_entry.get("access_token") or "").strip()
if refresh_token:
try:
access_token, auth_config, expires_at = await _exchange_refresh_token_for_import(
provider_type=provider_type,
template=template,
refresh_token=refresh_token,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
if provider_type == ProviderType.CODEX.value:
_apply_codex_import_hints(auth_config, import_entry)
return access_token, auth_config, expires_at
except Exception as exc:
if access_token_input and provider_type == ProviderType.CODEX.value:
return await _build_codex_access_token_import_auth_config(
provider_type=provider_type,
access_token=access_token_input,
refresh_token=refresh_token,
proxy_config=proxy_config,
import_entry=import_entry,
refresh_error=str(exc),
)
raise
if access_token_input:
if provider_type != ProviderType.CODEX.value:
raise InvalidRequestException("仅 Codex 支持 Access Token 导入")
return await _build_codex_access_token_import_auth_config(
provider_type=provider_type,
access_token=access_token_input,
refresh_token=None,
proxy_config=proxy_config,
import_entry=import_entry,
)
raise InvalidRequestException("Token 无效或过短")
@router.post(
"/providers/{provider_id}/import-refresh-token",
response_model=ProviderCompleteOAuthResponse,
@@ -1969,9 +2273,9 @@ async def import_refresh_token(
)
if provider_type == ProviderType.KIRO.value:
raw_import = payload.refresh_token.strip()
raw_import = (payload.refresh_token or "").strip()
if not raw_import:
raise InvalidRequestException("Refresh Token 不能为空")
raise InvalidRequestException("Kiro 导入需要 Refresh Token 或完整凭据 JSON")
# 使用统一的解析函数
credentials = _parse_kiro_import_input(raw_import)
@@ -2045,91 +2349,41 @@ async def import_refresh_token(
template = _require_oauth_template(provider_type)
# 用 refresh_token 换取 access_token
refresh_token = payload.refresh_token.strip()
token_url = template.oauth.token_url
is_json = "anthropic.com" in token_url
scope_str = " ".join(template.oauth.scopes) if template.oauth.scopes else ""
refresh_token, access_token_input = _normalize_single_import_tokens(
refresh_token=payload.refresh_token,
access_token=payload.access_token,
)
if not refresh_token and not access_token_input:
raise InvalidRequestException("缺少 refresh_token 或 access_token")
if access_token_input and provider_type != ProviderType.CODEX.value:
raise InvalidRequestException("仅 Codex 支持 Access Token 导入")
if is_json:
body: dict[str, Any] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if scope_str:
body["scope"] = scope_str
headers = {"Content-Type": "application/json", "Accept": "application/json"}
data = None
json_body = body
else:
form: dict[str, str] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if scope_str:
form["scope"] = scope_str
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
# proxy_config 和 key_proxy 已在上方 Kiro 分支之前统一解析
resp = await post_oauth_token(
refresh_error: str | None = None
if refresh_token:
try:
access_token, auth_config, expires_at = await _exchange_refresh_token_for_import(
provider_type=provider_type,
token_url=token_url,
headers=headers,
data=data,
json_body=json_body,
template=template,
refresh_token=refresh_token,
proxy_config=proxy_config,
timeout_seconds=30.0,
)
if resp.status_code < 200 or resp.status_code >= 300:
error_reason = f"HTTP {resp.status_code}"
try:
error_body = resp.json()
if "error" in error_body:
error_reason = str(error_body.get("error_description") or error_body.get("error"))
except Exception:
error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
raise InvalidRequestException(f"Refresh Token 验证失败: {error_reason}")
token = resp.json()
access_token = str(token.get("access_token") or "")
new_refresh_token = str(token.get("refresh_token") or "") or refresh_token
expires_in = token.get("expires_in")
expires_at: int | None = None
try:
if expires_in is not None:
expires_at = int(time.time()) + int(expires_in)
except Exception:
expires_at = None
if not access_token:
raise InvalidRequestException("token refresh 返回缺少 access_token")
# 构建 auth_config
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": token.get("token_type"),
"refresh_token": new_refresh_token or None,
"expires_at": expires_at,
"scope": token.get("scope"),
"updated_at": int(time.time()),
}
auth_config = await enrich_auth_config(
except Exception as exc:
if not access_token_input or provider_type != ProviderType.CODEX.value:
raise
refresh_error = str(exc)
access_token, auth_config, expires_at = await _build_codex_access_token_import_auth_config(
provider_type=provider_type,
auth_config=auth_config,
token_response=token,
access_token=access_token,
access_token=access_token_input,
refresh_token=refresh_token,
proxy_config=proxy_config,
refresh_error=refresh_error,
)
else:
access_token, auth_config, expires_at = await _build_codex_access_token_import_auth_config(
provider_type=provider_type,
access_token=access_token_input,
refresh_token=None,
proxy_config=proxy_config,
)
@@ -2162,7 +2416,7 @@ async def import_refresh_token(
key_id=str(new_key.id),
provider_type=provider_type,
expires_at=expires_at,
has_refresh_token=bool(new_refresh_token),
has_refresh_token=bool(auth_config.get("refresh_token")),
email=auth_config.get("email"),
replaced=replaced,
)
@@ -2284,10 +2538,6 @@ async def _batch_import_standard_oauth_internal(
raise InvalidRequestException("未找到有效的 Token 数据")
api_formats = _get_provider_api_formats(provider)
token_url = template.oauth.token_url
is_json = "anthropic.com" in token_url
scope_str = " ".join(template.oauth.scopes) if template.oauth.scopes else ""
total = len(import_entries)
results: list[BatchImportResultItem] = [None] * total # type: ignore[list-item]
success_count = 0
@@ -2305,152 +2555,32 @@ async def _batch_import_standard_oauth_internal(
async with sem:
try:
refresh_token = import_entry.get("refresh_token", "")
if not refresh_token or len(refresh_token) < 10:
result_item = BatchImportResultItem(
index=idx,
status="error",
error="Token 无效或过短",
)
failed_count += 1
else:
if is_json:
body: dict[str, Any] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if scope_str:
body["scope"] = scope_str
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
data = None
json_body = body
else:
form: dict[str, str] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if scope_str:
form["scope"] = scope_str
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
try:
_release_batch_import_db_connection_before_await(db)
resp = await post_oauth_token(
access_token, auth_config, _expires_at = (
await _build_standard_oauth_import_credentials(
provider_type=provider_type,
token_url=token_url,
headers=headers,
data=data,
json_body=json_body,
template=template,
import_entry=import_entry,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
except Exception as exc:
result_item = BatchImportResultItem(
index=idx,
status="error",
error=f"Token 刷新请求失败: {exc}",
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
_release_batch_import_db_connection_before_await(db)
await progress_hook(
total, processed_count, success_count, failed_count, result_item
)
return
if resp.status_code < 200 or resp.status_code >= 300:
error_reason = f"HTTP {resp.status_code}"
try:
error_body = resp.json()
if "error" in error_body:
error_reason = str(
error_body.get("error_description") or error_body.get("error")
)
except Exception:
error_reason = (
resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
)
result_item = BatchImportResultItem(
index=idx,
status="error",
error=f"Token 验证失败: {error_reason}",
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
_release_batch_import_db_connection_before_await(db)
await progress_hook(
total, processed_count, success_count, failed_count, result_item
)
return
token_data = resp.json()
access_token = str(token_data.get("access_token") or "")
new_refresh_token = str(token_data.get("refresh_token") or "") or refresh_token
if not access_token:
result_item = BatchImportResultItem(
index=idx,
status="error",
error="Token 刷新返回缺少 access_token",
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
_release_batch_import_db_connection_before_await(db)
await progress_hook(
total, processed_count, success_count, failed_count, result_item
)
return
expires_in = token_data.get("expires_in")
expires_at: int | None = None
try:
if expires_in is not None:
expires_at = int(time.time()) + int(expires_in)
except Exception:
expires_at = None
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": token_data.get("token_type"),
"refresh_token": new_refresh_token or None,
"expires_at": expires_at,
"scope": token_data.get("scope"),
"updated_at": int(time.time()),
}
try:
_release_batch_import_db_connection_before_await(db)
auth_config = await enrich_auth_config(
provider_type=provider_type,
auth_config=auth_config,
token_response=token_data,
access_token=access_token,
proxy_config=proxy_config,
)
except Exception as exc:
logger.warning("批量导入: enrich_auth_config 失败 (index={}): {}", idx, exc)
if provider_type == ProviderType.CODEX.value:
_apply_codex_import_hints(auth_config, import_entry)
result_item = BatchImportResultItem(
index=idx,
status="error",
error=f"Token 验证失败: {exc}",
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
_release_batch_import_db_connection_before_await(db)
await progress_hook(
total, processed_count, success_count, failed_count, result_item
)
return
async with db_lock:
try:
+2
View File
@@ -1840,6 +1840,7 @@ async def _run_concurrent_test(
endpoints_by_id = {
str(endpoint.id): endpoint
for endpoint in preload_db.query(ProviderEndpoint)
.options(joinedload(ProviderEndpoint.provider))
.filter(ProviderEndpoint.id.in_(endpoint_ids))
.all()
}
@@ -1847,6 +1848,7 @@ async def _run_concurrent_test(
keys_by_id = {
str(key.id): key
for key in preload_db.query(ProviderAPIKey)
.options(joinedload(ProviderAPIKey.provider))
.filter(ProviderAPIKey.id.in_(key_ids))
.all()
}
-22
View File
@@ -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"]
-196
View File
@@ -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()
-142
View File
@@ -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)
-215
View File
@@ -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)
-125
View File
@@ -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)
-759
View File
@@ -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)
-93
View File
@@ -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)
-90
View File
@@ -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)

Some files were not shown because too many files have changed in this diff Show More