mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-02 15:52:26 +08:00
135
.github/workflows/docker-publish.yml
vendored
Normal file
135
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
name: Build and Publish Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
branches: [master, main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
build_base:
|
||||||
|
description: 'Rebuild base image'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
BASE_IMAGE_NAME: ${{ github.repository }}-base
|
||||||
|
APP_IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-base-changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
base_changed: ${{ steps.check.outputs.base_changed }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Check if base image needs rebuild
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event.inputs.build_base }}" == "true" ]; then
|
||||||
|
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if base-related files changed
|
||||||
|
if git diff --name-only HEAD~1 HEAD | grep -qE '^(Dockerfile\.base|pyproject\.toml|frontend/package.*\.json)$'; then
|
||||||
|
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "base_changed=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-base:
|
||||||
|
needs: check-base-changes
|
||||||
|
if: needs.check-base-changes.outputs.base_changed == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for base image
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha,prefix=
|
||||||
|
|
||||||
|
- name: Build and push base image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.base
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
build-app:
|
||||||
|
needs: [check-base-changes, build-base]
|
||||||
|
if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for app image
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha,prefix=
|
||||||
|
|
||||||
|
- name: Update Dockerfile.app to use registry base image
|
||||||
|
run: |
|
||||||
|
sed -i "s|FROM aether-base:latest|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest|g" Dockerfile.app
|
||||||
|
|
||||||
|
- name: Build and push app image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.app
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
22
README.md
22
README.md
@@ -46,7 +46,7 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
|
|||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
### Docker Compose(推荐)
|
### Docker Compose(推荐:预构建镜像)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆代码
|
# 1. 克隆代码
|
||||||
@@ -58,16 +58,24 @@ cp .env.example .env
|
|||||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||||
|
|
||||||
# 3. 部署
|
# 3. 部署
|
||||||
./deploy.sh # 自动构建、启动、迁移
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 4. 更新
|
||||||
|
docker-compose pull && docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 更新
|
### Docker Compose(本地构建镜像)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 拉取最新代码
|
# 1. 克隆代码
|
||||||
git pull
|
git clone https://github.com/fawney19/Aether.git
|
||||||
|
cd aether
|
||||||
|
|
||||||
# 自动部署脚本
|
# 2. 配置环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||||
|
|
||||||
|
# 3. 部署 / 更新(自动构建、启动、迁移)
|
||||||
./deploy.sh
|
./deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,7 +83,7 @@ git pull
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动依赖
|
# 启动依赖
|
||||||
docker-compose up -d postgres redis
|
docker-compose -f docker-compose.build.yml up -d postgres redis
|
||||||
|
|
||||||
# 后端
|
# 后端
|
||||||
uv sync
|
uv sync
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ cd "$(dirname "$0")"
|
|||||||
|
|
||||||
# 兼容 docker-compose 和 docker compose
|
# 兼容 docker-compose 和 docker compose
|
||||||
if command -v docker-compose &> /dev/null; then
|
if command -v docker-compose &> /dev/null; then
|
||||||
DC="docker-compose"
|
DC="docker-compose -f docker-compose.build.yml"
|
||||||
else
|
else
|
||||||
DC="docker compose"
|
DC="docker compose -f docker-compose.build.yml"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 缓存文件
|
# 缓存文件
|
||||||
|
|||||||
78
docker-compose.build.yml
Normal file
78
docker-compose.build.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Aether 部署配置 - 本地构建
|
||||||
|
# 使用方法:
|
||||||
|
# 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest .
|
||||||
|
# 启动服务: docker-compose -f docker-compose.build.yml up -d --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: aether-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: aether
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: aether-redis
|
||||||
|
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.app
|
||||||
|
image: aether-app:latest
|
||||||
|
container_name: aether-app
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
||||||
|
PORT: 8084
|
||||||
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
JWT_ALGORITHM: HS256
|
||||||
|
JWT_EXPIRATION_DELTA: 86400
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
API_KEY_PREFIX: ${API_KEY_PREFIX:-sk}
|
||||||
|
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
PYTHONIOENCODING: utf-8
|
||||||
|
LANG: C.UTF-8
|
||||||
|
LC_ALL: C.UTF-8
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-8084}:80"
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Aether 部署配置
|
# Aether 部署配置 - 使用预构建镜像
|
||||||
# 使用 ./deploy.sh 自动部署
|
# 使用方法: docker-compose up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: aether-app:latest
|
image: ghcr.io/fawney19/aether:latest
|
||||||
container_name: aether-app
|
container_name: aether-app
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
||||||
@@ -65,11 +65,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8084}:80"
|
- "${APP_PORT:-8084}:80"
|
||||||
volumes:
|
volumes:
|
||||||
# 挂载日志目录到主机,便于调试和持久化
|
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
@@ -14,8 +14,10 @@
|
|||||||
"@vueuse/core": "^13.9.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
"axios": "^1.12.1",
|
"axios": "^1.12.1",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.0",
|
"dompurify": "^3.3.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-vue-next": "^0.544.0",
|
"lucide-vue-next": "^0.544.0",
|
||||||
@@ -2723,6 +2725,16 @@
|
|||||||
"pnpm": ">=8"
|
"pnpm": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chartjs-adapter-date-fns": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": ">=2.8.0",
|
||||||
|
"date-fns": ">=2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -2923,6 +2935,16 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/de-indent": {
|
"node_modules/de-indent": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||||
|
|||||||
@@ -21,8 +21,10 @@
|
|||||||
"@vueuse/core": "^13.9.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
"axios": "^1.12.1",
|
"axios": "^1.12.1",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.0",
|
"dompurify": "^3.3.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-vue-next": "^0.544.0",
|
"lucide-vue-next": "^0.544.0",
|
||||||
|
|||||||
@@ -156,3 +156,116 @@ export const {
|
|||||||
clearProviderCache,
|
clearProviderCache,
|
||||||
listAffinities
|
listAffinities
|
||||||
} = cacheApi
|
} = cacheApi
|
||||||
|
|
||||||
|
// ==================== 缓存亲和性分析 API ====================
|
||||||
|
|
||||||
|
export interface TTLAnalysisUser {
|
||||||
|
group_id: string
|
||||||
|
username: string | null
|
||||||
|
email: string | null
|
||||||
|
request_count: number
|
||||||
|
interval_distribution: {
|
||||||
|
within_5min: number
|
||||||
|
within_15min: number
|
||||||
|
within_30min: number
|
||||||
|
within_60min: number
|
||||||
|
over_60min: number
|
||||||
|
}
|
||||||
|
interval_percentages: {
|
||||||
|
within_5min: number
|
||||||
|
within_15min: number
|
||||||
|
within_30min: number
|
||||||
|
within_60min: number
|
||||||
|
over_60min: number
|
||||||
|
}
|
||||||
|
percentiles: {
|
||||||
|
p50: number | null
|
||||||
|
p75: number | null
|
||||||
|
p90: number | null
|
||||||
|
}
|
||||||
|
avg_interval_minutes: number | null
|
||||||
|
min_interval_minutes: number | null
|
||||||
|
max_interval_minutes: number | null
|
||||||
|
recommended_ttl_minutes: number
|
||||||
|
recommendation_reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTLAnalysisResponse {
|
||||||
|
analysis_period_hours: number
|
||||||
|
total_users_analyzed: number
|
||||||
|
ttl_distribution: {
|
||||||
|
'5min': number
|
||||||
|
'15min': number
|
||||||
|
'30min': number
|
||||||
|
'60min': number
|
||||||
|
}
|
||||||
|
users: TTLAnalysisUser[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheHitAnalysisResponse {
|
||||||
|
analysis_period_hours: number
|
||||||
|
total_requests: number
|
||||||
|
requests_with_cache_hit: number
|
||||||
|
request_cache_hit_rate: number
|
||||||
|
total_input_tokens: number
|
||||||
|
total_cache_read_tokens: number
|
||||||
|
total_cache_creation_tokens: number
|
||||||
|
token_cache_hit_rate: number
|
||||||
|
total_cache_read_cost_usd: number
|
||||||
|
total_cache_creation_cost_usd: number
|
||||||
|
estimated_savings_usd: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntervalTimelinePoint {
|
||||||
|
x: string // ISO 时间字符串
|
||||||
|
y: number // 间隔分钟数
|
||||||
|
user_id?: string // 用户 ID(仅 include_user_info=true 时存在)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntervalTimelineResponse {
|
||||||
|
analysis_period_hours: number
|
||||||
|
total_points: number
|
||||||
|
points: IntervalTimelinePoint[]
|
||||||
|
users?: Record<string, string> // user_id -> username 映射(仅 include_user_info=true 时存在)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cacheAnalysisApi = {
|
||||||
|
/**
|
||||||
|
* 分析缓存亲和性 TTL 推荐
|
||||||
|
*/
|
||||||
|
async analyzeTTL(params?: {
|
||||||
|
user_id?: string
|
||||||
|
api_key_id?: string
|
||||||
|
hours?: number
|
||||||
|
}): Promise<TTLAnalysisResponse> {
|
||||||
|
const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析缓存命中情况
|
||||||
|
*/
|
||||||
|
async analyzeHit(params?: {
|
||||||
|
user_id?: string
|
||||||
|
api_key_id?: string
|
||||||
|
hours?: number
|
||||||
|
}): Promise<CacheHitAnalysisResponse> {
|
||||||
|
const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取请求间隔时间线数据
|
||||||
|
*
|
||||||
|
* @param params.include_user_info 是否包含用户信息(用于管理员多用户视图)
|
||||||
|
*/
|
||||||
|
async getIntervalTimeline(params?: {
|
||||||
|
hours?: number
|
||||||
|
limit?: number
|
||||||
|
user_id?: string
|
||||||
|
include_user_info?: boolean
|
||||||
|
}): Promise<IntervalTimelineResponse> {
|
||||||
|
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -253,5 +253,18 @@ export const meApi = {
|
|||||||
}> {
|
}> {
|
||||||
const response = await apiClient.put('/api/users/me/model-capabilities', data)
|
const response = await apiClient.put('/api/users/me/model-capabilities', data)
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取请求间隔时间线(用于散点图)
|
||||||
|
async getIntervalTimeline(params?: {
|
||||||
|
hours?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<{
|
||||||
|
analysis_period_hours: number
|
||||||
|
total_points: number
|
||||||
|
points: Array<{ x: string; y: number }>
|
||||||
|
}> {
|
||||||
|
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
372
frontend/src/components/charts/ScatterChart.vue
Normal file
372
frontend/src/components/charts/ScatterChart.vue
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full relative">
|
||||||
|
<canvas ref="chartRef"></canvas>
|
||||||
|
<div
|
||||||
|
v-if="crosshairStats"
|
||||||
|
class="absolute top-2 right-2 bg-gray-800/90 text-gray-100 px-3 py-2 rounded-lg text-sm shadow-lg border border-gray-600"
|
||||||
|
>
|
||||||
|
<div class="font-medium text-yellow-400">Y = {{ crosshairStats.yValue.toFixed(1) }} 分钟</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
<span class="text-green-400">{{ crosshairStats.belowCount }}</span> / {{ crosshairStats.totalCount }} 点在横线以下
|
||||||
|
<span class="ml-2 text-blue-400">({{ crosshairStats.belowPercent.toFixed(1) }}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
ScatterController,
|
||||||
|
TimeScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
type ChartData,
|
||||||
|
type ChartOptions,
|
||||||
|
type Plugin,
|
||||||
|
type Scale
|
||||||
|
} from 'chart.js'
|
||||||
|
import 'chartjs-adapter-date-fns'
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
ScatterController,
|
||||||
|
TimeScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
)
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: ChartData<'scatter'>
|
||||||
|
options?: ChartOptions<'scatter'>
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CrosshairStats {
|
||||||
|
yValue: number
|
||||||
|
belowCount: number
|
||||||
|
totalCount: number
|
||||||
|
belowPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
height: 300
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLCanvasElement>()
|
||||||
|
let chart: ChartJS<'scatter'> | null = null
|
||||||
|
|
||||||
|
const crosshairY = ref<number | null>(null)
|
||||||
|
|
||||||
|
const crosshairStats = computed<CrosshairStats | null>(() => {
|
||||||
|
if (crosshairY.value === null || !props.data.datasets) return null
|
||||||
|
|
||||||
|
let totalCount = 0
|
||||||
|
let belowCount = 0
|
||||||
|
|
||||||
|
for (const dataset of props.data.datasets) {
|
||||||
|
if (!dataset.data) continue
|
||||||
|
for (const point of dataset.data) {
|
||||||
|
const p = point as { x: string; y: number }
|
||||||
|
if (typeof p.y === 'number') {
|
||||||
|
totalCount++
|
||||||
|
if (p.y <= crosshairY.value) {
|
||||||
|
belowCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCount === 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
yValue: crosshairY.value,
|
||||||
|
belowCount,
|
||||||
|
totalCount,
|
||||||
|
belowPercent: (belowCount / totalCount) * 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const crosshairPlugin: Plugin<'scatter'> = {
|
||||||
|
id: 'crosshairLine',
|
||||||
|
afterDraw: (chartInstance) => {
|
||||||
|
if (crosshairY.value === null) return
|
||||||
|
|
||||||
|
const { ctx, chartArea, scales } = chartInstance
|
||||||
|
const yScale = scales.y
|
||||||
|
if (!yScale || !chartArea) return
|
||||||
|
|
||||||
|
const yPixel = yScale.getPixelForValue(crosshairY.value)
|
||||||
|
|
||||||
|
if (yPixel < chartArea.top || yPixel > chartArea.bottom) return
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(chartArea.left, yPixel)
|
||||||
|
ctx.lineTo(chartArea.right, yPixel)
|
||||||
|
ctx.strokeStyle = 'rgba(250, 204, 21, 0.8)'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.setLineDash([6, 4])
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义非线性 Y 轴转换函数
|
||||||
|
// 0-10 分钟占据 70% 的空间,10-120 分钟占据 30% 的空间
|
||||||
|
const BREAKPOINT = 10 // 分界点:10 分钟
|
||||||
|
const LOWER_RATIO = 0.7 // 0-10 分钟占 70% 空间
|
||||||
|
|
||||||
|
// 将实际值转换为显示值(用于绘图)
|
||||||
|
function toDisplayValue(realValue: number): number {
|
||||||
|
if (realValue <= BREAKPOINT) {
|
||||||
|
// 0-10 分钟线性映射到 0-70
|
||||||
|
return realValue * (LOWER_RATIO * 100 / BREAKPOINT)
|
||||||
|
} else {
|
||||||
|
// 10-120 分钟映射到 70-100
|
||||||
|
const upperRange = 120 - BREAKPOINT
|
||||||
|
const displayUpperRange = (1 - LOWER_RATIO) * 100
|
||||||
|
return LOWER_RATIO * 100 + ((realValue - BREAKPOINT) / upperRange) * displayUpperRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将显示值转换回实际值(用于读取鼠标位置)
|
||||||
|
function toRealValue(displayValue: number): number {
|
||||||
|
const breakpointDisplay = LOWER_RATIO * 100
|
||||||
|
if (displayValue <= breakpointDisplay) {
|
||||||
|
return displayValue / (LOWER_RATIO * 100 / BREAKPOINT)
|
||||||
|
} else {
|
||||||
|
const upperRange = 120 - BREAKPOINT
|
||||||
|
const displayUpperRange = (1 - LOWER_RATIO) * 100
|
||||||
|
return BREAKPOINT + ((displayValue - breakpointDisplay) / displayUpperRange) * upperRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换数据点的 Y 值
|
||||||
|
function transformData(data: ChartData<'scatter'>): ChartData<'scatter'> {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
datasets: data.datasets.map(dataset => ({
|
||||||
|
...dataset,
|
||||||
|
data: (dataset.data as Array<{ x: string; y: number }>).map(point => ({
|
||||||
|
...point,
|
||||||
|
y: toDisplayValue(point.y),
|
||||||
|
_originalY: point.y // 保存原始值用于 tooltip
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: ChartOptions<'scatter'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest',
|
||||||
|
intersect: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: {
|
||||||
|
unit: 'hour',
|
||||||
|
displayFormats: {
|
||||||
|
hour: 'MM-dd HH:mm'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.1)'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(107, 114, 128)',
|
||||||
|
maxRotation: 45
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '时间',
|
||||||
|
color: 'rgb(107, 114, 128)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
min: 0,
|
||||||
|
max: 100, // 显示值范围 0-100
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.1)'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(107, 114, 128)',
|
||||||
|
// 自定义刻度值:在实际值 0, 2, 5, 10, 30, 60, 120 处显示
|
||||||
|
callback: function(this: Scale, tickValue: string | number) {
|
||||||
|
const displayVal = Number(tickValue)
|
||||||
|
const realVal = toRealValue(displayVal)
|
||||||
|
// 只在特定的显示位置显示刻度
|
||||||
|
const targetTicks = [0, 2, 5, 10, 30, 60, 120]
|
||||||
|
for (const target of targetTicks) {
|
||||||
|
const targetDisplay = toDisplayValue(target)
|
||||||
|
if (Math.abs(displayVal - targetDisplay) < 1) {
|
||||||
|
return `${target}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
stepSize: 5, // 显示值的步长
|
||||||
|
autoSkip: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '间隔 (分钟)',
|
||||||
|
color: 'rgb(107, 114, 128)'
|
||||||
|
},
|
||||||
|
afterBuildTicks: function(scale: Scale) {
|
||||||
|
// 在特定实际值处设置刻度
|
||||||
|
const targetTicks = [0, 2, 5, 10, 30, 60, 120]
|
||||||
|
scale.ticks = targetTicks.map(val => ({
|
||||||
|
value: toDisplayValue(val),
|
||||||
|
label: `${val}`
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgb(31, 41, 55)',
|
||||||
|
titleColor: 'rgb(243, 244, 246)',
|
||||||
|
bodyColor: 'rgb(243, 244, 246)',
|
||||||
|
borderColor: 'rgb(75, 85, 99)',
|
||||||
|
borderWidth: 1,
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const point = context.raw as { x: string; y: number; _originalY?: number }
|
||||||
|
const realY = point._originalY ?? toRealValue(point.y)
|
||||||
|
return `间隔: ${realY.toFixed(1)} 分钟`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHover: (event, _elements, chartInstance) => {
|
||||||
|
const canvas = chartInstance.canvas
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const mouseY = (event.native as MouseEvent)?.clientY
|
||||||
|
|
||||||
|
if (mouseY === undefined) {
|
||||||
|
crosshairY.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chartArea, scales } = chartInstance
|
||||||
|
const yScale = scales.y
|
||||||
|
|
||||||
|
if (!chartArea || !yScale) return
|
||||||
|
|
||||||
|
const relativeY = mouseY - rect.top
|
||||||
|
|
||||||
|
if (relativeY < chartArea.top || relativeY > chartArea.bottom) {
|
||||||
|
crosshairY.value = null
|
||||||
|
} else {
|
||||||
|
const displayValue = yScale.getValueForPixel(relativeY)
|
||||||
|
// 转换回实际值
|
||||||
|
crosshairY.value = displayValue !== undefined ? toRealValue(displayValue) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance.draw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改 crosshairPlugin 使用显示值
|
||||||
|
const crosshairPluginWithTransform: Plugin<'scatter'> = {
|
||||||
|
id: 'crosshairLine',
|
||||||
|
afterDraw: (chartInstance) => {
|
||||||
|
if (crosshairY.value === null) return
|
||||||
|
|
||||||
|
const { ctx, chartArea, scales } = chartInstance
|
||||||
|
const yScale = scales.y
|
||||||
|
if (!yScale || !chartArea) return
|
||||||
|
|
||||||
|
// 将实际值转换为显示值再获取像素位置
|
||||||
|
const displayValue = toDisplayValue(crosshairY.value)
|
||||||
|
const yPixel = yScale.getPixelForValue(displayValue)
|
||||||
|
|
||||||
|
if (yPixel < chartArea.top || yPixel > chartArea.bottom) return
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(chartArea.left, yPixel)
|
||||||
|
ctx.lineTo(chartArea.right, yPixel)
|
||||||
|
ctx.strokeStyle = 'rgba(250, 204, 21, 0.8)'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.setLineDash([6, 4])
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
crosshairY.value = null
|
||||||
|
if (chart) {
|
||||||
|
chart.draw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
|
||||||
|
// 转换数据
|
||||||
|
const transformedData = transformData(props.data)
|
||||||
|
|
||||||
|
chart = new ChartJS(chartRef.value, {
|
||||||
|
type: 'scatter',
|
||||||
|
data: transformedData,
|
||||||
|
options: {
|
||||||
|
...defaultOptions,
|
||||||
|
...props.options
|
||||||
|
},
|
||||||
|
plugins: [crosshairPluginWithTransform]
|
||||||
|
})
|
||||||
|
|
||||||
|
chartRef.value.addEventListener('mouseleave', handleMouseLeave)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart() {
|
||||||
|
if (chart) {
|
||||||
|
chart.data = transformData(props.data)
|
||||||
|
chart.update('none')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
createChart()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (chartRef.value) {
|
||||||
|
chartRef.value.removeEventListener('mouseleave', handleMouseLeave)
|
||||||
|
}
|
||||||
|
if (chart) {
|
||||||
|
chart.destroy()
|
||||||
|
chart = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.data, updateChart, { deep: true })
|
||||||
|
watch(() => props.options, () => {
|
||||||
|
if (chart) {
|
||||||
|
chart.options = {
|
||||||
|
...defaultOptions,
|
||||||
|
...props.options
|
||||||
|
}
|
||||||
|
chart.update()
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
@@ -188,7 +188,7 @@ const monthMarkers = computed(() => {
|
|||||||
if (month === lastMonth) {
|
if (month === lastMonth) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
markers[index] = String(month + 1)
|
markers[index] = `${month + 1}月`
|
||||||
lastMonth = month
|
lastMonth = month
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
208
frontend/src/composables/useTTLAnalysis.ts
Normal file
208
frontend/src/composables/useTTLAnalysis.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* TTL 分析 composable
|
||||||
|
* 封装缓存亲和性 TTL 分析相关的状态和逻辑
|
||||||
|
*/
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import {
|
||||||
|
cacheAnalysisApi,
|
||||||
|
type TTLAnalysisResponse,
|
||||||
|
type CacheHitAnalysisResponse,
|
||||||
|
type IntervalTimelineResponse
|
||||||
|
} from '@/api/cache'
|
||||||
|
import type { ChartData } from 'chart.js'
|
||||||
|
|
||||||
|
// 时间范围选项
|
||||||
|
export const ANALYSIS_HOURS_OPTIONS = [
|
||||||
|
{ value: '12', label: '12 小时' },
|
||||||
|
{ value: '24', label: '24 小时' },
|
||||||
|
{ value: '72', label: '3 天' },
|
||||||
|
{ value: '168', label: '7 天' },
|
||||||
|
{ value: '336', label: '14 天' },
|
||||||
|
{ value: '720', label: '30 天' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// 间隔颜色配置
|
||||||
|
export const INTERVAL_COLORS = {
|
||||||
|
short: 'rgba(34, 197, 94, 0.6)', // green: 0-5 分钟
|
||||||
|
medium: 'rgba(59, 130, 246, 0.6)', // blue: 5-15 分钟
|
||||||
|
normal: 'rgba(168, 85, 247, 0.6)', // purple: 15-30 分钟
|
||||||
|
long: 'rgba(249, 115, 22, 0.6)', // orange: 30-60 分钟
|
||||||
|
veryLong: 'rgba(239, 68, 68, 0.6)' // red: >60 分钟
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据间隔时间获取对应的颜色
|
||||||
|
*/
|
||||||
|
export function getIntervalColor(interval: number): string {
|
||||||
|
if (interval <= 5) return INTERVAL_COLORS.short
|
||||||
|
if (interval <= 15) return INTERVAL_COLORS.medium
|
||||||
|
if (interval <= 30) return INTERVAL_COLORS.normal
|
||||||
|
if (interval <= 60) return INTERVAL_COLORS.long
|
||||||
|
return INTERVAL_COLORS.veryLong
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 TTL 推荐的 Badge 样式
|
||||||
|
*/
|
||||||
|
export function getTTLBadgeVariant(ttl: number): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||||
|
if (ttl <= 5) return 'default'
|
||||||
|
if (ttl <= 15) return 'secondary'
|
||||||
|
if (ttl <= 30) return 'outline'
|
||||||
|
return 'destructive'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取使用频率标签
|
||||||
|
*/
|
||||||
|
export function getFrequencyLabel(ttl: number): string {
|
||||||
|
if (ttl <= 5) return '高频'
|
||||||
|
if (ttl <= 15) return '中高频'
|
||||||
|
if (ttl <= 30) return '中频'
|
||||||
|
return '低频'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取使用频率样式类名
|
||||||
|
*/
|
||||||
|
export function getFrequencyClass(ttl: number): string {
|
||||||
|
if (ttl <= 5) return 'text-success font-medium'
|
||||||
|
if (ttl <= 15) return 'text-blue-500 font-medium'
|
||||||
|
if (ttl <= 30) return 'text-muted-foreground'
|
||||||
|
return 'text-destructive'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTTLAnalysis() {
|
||||||
|
const { error: showError, info: showInfo } = useToast()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const ttlAnalysis = ref<TTLAnalysisResponse | null>(null)
|
||||||
|
const hitAnalysis = ref<CacheHitAnalysisResponse | null>(null)
|
||||||
|
const ttlAnalysisLoading = ref(false)
|
||||||
|
const hitAnalysisLoading = ref(false)
|
||||||
|
const analysisHours = ref('24')
|
||||||
|
|
||||||
|
// 用户散点图展开状态
|
||||||
|
const expandedUserId = ref<string | null>(null)
|
||||||
|
const userTimelineData = ref<IntervalTimelineResponse | null>(null)
|
||||||
|
const userTimelineLoading = ref(false)
|
||||||
|
|
||||||
|
// 计算属性:是否正在加载
|
||||||
|
const isLoading = computed(() => ttlAnalysisLoading.value || hitAnalysisLoading.value)
|
||||||
|
|
||||||
|
// 获取 TTL 分析数据
|
||||||
|
async function fetchTTLAnalysis() {
|
||||||
|
ttlAnalysisLoading.value = true
|
||||||
|
try {
|
||||||
|
const hours = parseInt(analysisHours.value)
|
||||||
|
const result = await cacheAnalysisApi.analyzeTTL({ hours })
|
||||||
|
ttlAnalysis.value = result
|
||||||
|
|
||||||
|
if (result.total_users_analyzed === 0) {
|
||||||
|
const periodText = hours >= 24 ? `${hours / 24} 天` : `${hours} 小时`
|
||||||
|
showInfo(`未找到符合条件的数据(最近 ${periodText})`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('获取 TTL 分析失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
ttlAnalysisLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取缓存命中分析数据
|
||||||
|
async function fetchHitAnalysis() {
|
||||||
|
hitAnalysisLoading.value = true
|
||||||
|
try {
|
||||||
|
hitAnalysis.value = await cacheAnalysisApi.analyzeHit({
|
||||||
|
hours: parseInt(analysisHours.value)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showError('获取缓存命中分析失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
hitAnalysisLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定用户的时间线数据
|
||||||
|
async function fetchUserTimeline(userId: string) {
|
||||||
|
userTimelineLoading.value = true
|
||||||
|
try {
|
||||||
|
userTimelineData.value = await cacheAnalysisApi.getIntervalTimeline({
|
||||||
|
hours: parseInt(analysisHours.value),
|
||||||
|
limit: 2000,
|
||||||
|
user_id: userId
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showError('获取用户时间线数据失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
userTimelineLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换用户行展开状态
|
||||||
|
async function toggleUserExpand(userId: string) {
|
||||||
|
if (expandedUserId.value === userId) {
|
||||||
|
expandedUserId.value = null
|
||||||
|
userTimelineData.value = null
|
||||||
|
} else {
|
||||||
|
expandedUserId.value = userId
|
||||||
|
await fetchUserTimeline(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新所有分析数据
|
||||||
|
async function refreshAnalysis() {
|
||||||
|
expandedUserId.value = null
|
||||||
|
userTimelineData.value = null
|
||||||
|
await Promise.all([fetchTTLAnalysis(), fetchHitAnalysis()])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户时间线散点图数据
|
||||||
|
const userTimelineChartData = computed<ChartData<'scatter'>>(() => {
|
||||||
|
if (!userTimelineData.value || userTimelineData.value.points.length === 0) {
|
||||||
|
return { datasets: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = userTimelineData.value.points
|
||||||
|
|
||||||
|
return {
|
||||||
|
datasets: [{
|
||||||
|
label: '请求间隔',
|
||||||
|
data: points.map(p => ({ x: p.x, y: p.y })),
|
||||||
|
backgroundColor: points.map(p => getIntervalColor(p.y)),
|
||||||
|
borderColor: points.map(p => getIntervalColor(p.y).replace('0.6', '1')),
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听时间范围变化
|
||||||
|
watch(analysisHours, () => {
|
||||||
|
refreshAnalysis()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
ttlAnalysis,
|
||||||
|
hitAnalysis,
|
||||||
|
ttlAnalysisLoading,
|
||||||
|
hitAnalysisLoading,
|
||||||
|
analysisHours,
|
||||||
|
expandedUserId,
|
||||||
|
userTimelineData,
|
||||||
|
userTimelineLoading,
|
||||||
|
isLoading,
|
||||||
|
userTimelineChartData,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
fetchTTLAnalysis,
|
||||||
|
fetchHitAnalysis,
|
||||||
|
fetchUserTimeline,
|
||||||
|
toggleUserExpand,
|
||||||
|
refreshAnalysis
|
||||||
|
}
|
||||||
|
}
|
||||||
205
frontend/src/features/usage/components/IntervalTimelineCard.vue
Normal file
205
frontend/src/features/usage/components/IntervalTimelineCard.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<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="hasMultipleUsers && userLegend.length > 0" class="flex items-center gap-2 flex-wrap justify-end text-[11px]">
|
||||||
|
<div
|
||||||
|
v-for="user in userLegend"
|
||||||
|
:key="user.id"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-2.5 h-2.5 rounded-full"
|
||||||
|
:style="{ backgroundColor: user.color }"
|
||||||
|
/>
|
||||||
|
<span class="text-muted-foreground">{{ user.name }}</span>
|
||||||
|
</div>
|
||||||
|
</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, 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'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
title: string
|
||||||
|
isAdmin: boolean
|
||||||
|
hours?: number
|
||||||
|
}>(), {
|
||||||
|
hours: 168 // 默认7天
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const timelineData = ref<IntervalTimelineResponse | null>(null)
|
||||||
|
const primaryColor = ref('201, 100, 66') // 默认主题色
|
||||||
|
|
||||||
|
// 获取主题色
|
||||||
|
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()
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 预定义的颜色列表(用于区分不同用户)
|
||||||
|
const USER_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 hasMultipleUsers = computed(() =>
|
||||||
|
props.isAdmin && timelineData.value?.users && Object.keys(timelineData.value.users).length > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// 用户图例
|
||||||
|
const userLegend = computed(() => {
|
||||||
|
if (!props.isAdmin || !timelineData.value?.users) return []
|
||||||
|
|
||||||
|
const users = Object.entries(timelineData.value.users)
|
||||||
|
return users.map(([userId, username], index) => ({
|
||||||
|
id: userId,
|
||||||
|
name: username || userId.slice(0, 8),
|
||||||
|
color: USER_COLORS[index % USER_COLORS.length]
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建图表数据
|
||||||
|
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] = USER_COLORS[index % USER_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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单用户或用户视图:使用主题色
|
||||||
|
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 (props.isAdmin && hasMultipleUsers.value) {
|
||||||
|
return `${datasetLabel}: ${realY.toFixed(1)} 分钟`
|
||||||
|
}
|
||||||
|
return `间隔: ${realY.toFixed(1)} 分钟`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (props.isAdmin) {
|
||||||
|
// 管理员:获取所有用户数据
|
||||||
|
timelineData.value = await cacheAnalysisApi.getIntervalTimeline({
|
||||||
|
hours: props.hours,
|
||||||
|
include_user_info: true,
|
||||||
|
limit: 2000,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 普通用户:获取自己的数据
|
||||||
|
timelineData.value = await meApi.getIntervalTimeline({
|
||||||
|
hours: props.hours,
|
||||||
|
limit: 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载请求间隔时间线失败:', error)
|
||||||
|
timelineData.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.hours, () => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.isAdmin, () => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -5,3 +5,4 @@ export { default as UsageRecordsTable } from './UsageRecordsTable.vue'
|
|||||||
export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue'
|
export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue'
|
||||||
export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue'
|
export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue'
|
||||||
export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue'
|
export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue'
|
||||||
|
export { default as IntervalTimelineCard } from './IntervalTimelineCard.vue'
|
||||||
|
|||||||
@@ -126,3 +126,20 @@ export function formatBillingType(type: string | undefined | null): string {
|
|||||||
}
|
}
|
||||||
return typeMap[type || ''] || type || '按量付费'
|
return typeMap[type || ''] || type || '按量付费'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format cost with 4 decimal places (for cache analysis)
|
||||||
|
export function formatCost(cost: number | null | undefined): string {
|
||||||
|
if (cost === null || cost === undefined) return '-'
|
||||||
|
return `$${cost.toFixed(4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format remaining time from unix timestamp
|
||||||
|
export function formatRemainingTime(expireAt: number | undefined, currentTime: number): string {
|
||||||
|
if (!expireAt) return '未知'
|
||||||
|
const remaining = expireAt - currentTime
|
||||||
|
if (remaining <= 0) return '已过期'
|
||||||
|
|
||||||
|
const minutes = Math.floor(remaining / 60)
|
||||||
|
const seconds = Math.floor(remaining % 60)
|
||||||
|
return `${minutes}分${seconds}秒`
|
||||||
|
}
|
||||||
@@ -12,10 +12,27 @@ import TableRow from '@/components/ui/table-row.vue'
|
|||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
import Pagination from '@/components/ui/pagination.vue'
|
import Pagination from '@/components/ui/pagination.vue'
|
||||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||||
import { Trash2, Eraser, Search, X } from 'lucide-vue-next'
|
import Select from '@/components/ui/select.vue'
|
||||||
|
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||||
|
import SelectContent from '@/components/ui/select-content.vue'
|
||||||
|
import SelectItem from '@/components/ui/select-item.vue'
|
||||||
|
import SelectValue from '@/components/ui/select-value.vue'
|
||||||
|
import ScatterChart from '@/components/charts/ScatterChart.vue'
|
||||||
|
import { Trash2, Eraser, Search, X, BarChart3, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { cacheApi, type CacheStats, type CacheConfig, type UserAffinity } from '@/api/cache'
|
import { cacheApi, type CacheStats, type CacheConfig, type UserAffinity } from '@/api/cache'
|
||||||
|
import type { TTLAnalysisUser } from '@/api/cache'
|
||||||
|
import { formatNumber, formatTokens, formatCost, formatRemainingTime } from '@/utils/format'
|
||||||
|
import {
|
||||||
|
useTTLAnalysis,
|
||||||
|
ANALYSIS_HOURS_OPTIONS,
|
||||||
|
getTTLBadgeVariant,
|
||||||
|
getFrequencyLabel,
|
||||||
|
getFrequencyClass
|
||||||
|
} from '@/composables/useTTLAnalysis'
|
||||||
|
|
||||||
|
// ==================== 缓存统计与亲和性列表 ====================
|
||||||
|
|
||||||
const stats = ref<CacheStats | null>(null)
|
const stats = ref<CacheStats | null>(null)
|
||||||
const config = ref<CacheConfig | null>(null)
|
const config = ref<CacheConfig | null>(null)
|
||||||
@@ -27,28 +44,40 @@ const matchedUserId = ref<string | null>(null)
|
|||||||
const clearingRowAffinityKey = ref<string | null>(null)
|
const clearingRowAffinityKey = ref<string | null>(null)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
|
const currentTime = ref(Math.floor(Date.now() / 1000))
|
||||||
|
|
||||||
const { success: showSuccess, error: showError, info: showInfo } = useToast()
|
const { success: showSuccess, error: showError, info: showInfo } = useToast()
|
||||||
const { confirm: showConfirm } = useConfirm()
|
const { confirm: showConfirm } = useConfirm()
|
||||||
const currentTime = ref(Math.floor(Date.now() / 1000))
|
|
||||||
|
|
||||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let skipNextKeywordWatch = false
|
let skipNextKeywordWatch = false
|
||||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
// 计算分页后的数据
|
// ==================== TTL 分析 (使用 composable) ====================
|
||||||
|
|
||||||
|
const {
|
||||||
|
ttlAnalysis,
|
||||||
|
hitAnalysis,
|
||||||
|
ttlAnalysisLoading,
|
||||||
|
analysisHours,
|
||||||
|
expandedUserId,
|
||||||
|
userTimelineData,
|
||||||
|
userTimelineLoading,
|
||||||
|
userTimelineChartData,
|
||||||
|
toggleUserExpand,
|
||||||
|
refreshAnalysis
|
||||||
|
} = useTTLAnalysis()
|
||||||
|
|
||||||
|
// ==================== 计算属性 ====================
|
||||||
|
|
||||||
const paginatedAffinityList = computed(() => {
|
const paginatedAffinityList = computed(() => {
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
const end = start + pageSize.value
|
const end = start + pageSize.value
|
||||||
return affinityList.value.slice(start, end)
|
return affinityList.value.slice(start, end)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 页码变化处理
|
// ==================== 缓存统计方法 ====================
|
||||||
function handlePageChange() {
|
|
||||||
// 分页变化时滚动到顶部
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取缓存统计
|
|
||||||
async function fetchCacheStats() {
|
async function fetchCacheStats() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -61,7 +90,6 @@ async function fetchCacheStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取缓存配置
|
|
||||||
async function fetchCacheConfig() {
|
async function fetchCacheConfig() {
|
||||||
try {
|
try {
|
||||||
config.value = await cacheApi.getConfig()
|
config.value = await cacheApi.getConfig()
|
||||||
@@ -70,7 +98,6 @@ async function fetchCacheConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取缓存亲和性列表
|
|
||||||
async function fetchAffinityList(keyword?: string) {
|
async function fetchAffinityList(keyword?: string) {
|
||||||
listLoading.value = true
|
listLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -107,17 +134,14 @@ async function resetAffinitySearch() {
|
|||||||
await fetchAffinityList()
|
await fetchAffinityList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除缓存(按 affinity_key 或用户标识符)
|
|
||||||
async function clearUserCache(identifier: string, displayName?: string) {
|
async function clearUserCache(identifier: string, displayName?: string) {
|
||||||
const target = identifier?.trim()
|
const target = identifier?.trim()
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
showError('无法识别标识符')
|
showError('无法识别标识符')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = displayName || target
|
const label = displayName || target
|
||||||
|
|
||||||
const confirmed = await showConfirm({
|
const confirmed = await showConfirm({
|
||||||
title: '确认清除',
|
title: '确认清除',
|
||||||
message: `确定要清除 ${label} 的缓存吗?`,
|
message: `确定要清除 ${label} 的缓存吗?`,
|
||||||
@@ -125,12 +149,9 @@ async function clearUserCache(identifier: string, displayName?: string) {
|
|||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clearingRowAffinityKey.value = target
|
clearingRowAffinityKey.value = target
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cacheApi.clearUserCache(target)
|
await cacheApi.clearUserCache(target)
|
||||||
showSuccess('清除成功')
|
showSuccess('清除成功')
|
||||||
@@ -144,7 +165,6 @@ async function clearUserCache(identifier: string, displayName?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除所有缓存
|
|
||||||
async function clearAllCache() {
|
async function clearAllCache() {
|
||||||
const firstConfirm = await showConfirm({
|
const firstConfirm = await showConfirm({
|
||||||
title: '危险操作',
|
title: '危险操作',
|
||||||
@@ -152,10 +172,7 @@ async function clearAllCache() {
|
|||||||
confirmText: '继续',
|
confirmText: '继续',
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
|
if (!firstConfirm) return
|
||||||
if (!firstConfirm) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondConfirm = await showConfirm({
|
const secondConfirm = await showConfirm({
|
||||||
title: '再次确认',
|
title: '再次确认',
|
||||||
@@ -163,10 +180,7 @@ async function clearAllCache() {
|
|||||||
confirmText: '确认清除',
|
confirmText: '确认清除',
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
|
if (!secondConfirm) return
|
||||||
if (!secondConfirm) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cacheApi.clearAllCache()
|
await cacheApi.clearAllCache()
|
||||||
@@ -179,33 +193,39 @@ async function clearAllCache() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算剩余时间(使用实时更新的 currentTime)
|
// ==================== 工具方法 ====================
|
||||||
function getRemainingTime(expireAt?: number) {
|
|
||||||
if (!expireAt) return '未知'
|
|
||||||
const remaining = expireAt - currentTime.value
|
|
||||||
if (remaining <= 0) return '已过期'
|
|
||||||
|
|
||||||
const minutes = Math.floor(remaining / 60)
|
function getRemainingTime(expireAt?: number): string {
|
||||||
const seconds = Math.floor(remaining % 60)
|
return formatRemainingTime(expireAt, currentTime.value)
|
||||||
return `${minutes}分${seconds}秒`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动倒计时定时器
|
function formatIntervalDescription(user: TTLAnalysisUser): string {
|
||||||
|
const p90 = user.percentiles.p90
|
||||||
|
if (p90 === null || p90 === undefined) return '-'
|
||||||
|
if (p90 < 1) {
|
||||||
|
const seconds = Math.round(p90 * 60)
|
||||||
|
return `90% 请求间隔 < ${seconds} 秒`
|
||||||
|
}
|
||||||
|
return `90% 请求间隔 < ${p90.toFixed(1)} 分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange() {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 定时器管理 ====================
|
||||||
|
|
||||||
function startCountdown() {
|
function startCountdown() {
|
||||||
if (countdownTimer) {
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
clearInterval(countdownTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
countdownTimer = setInterval(() => {
|
countdownTimer = setInterval(() => {
|
||||||
currentTime.value = Math.floor(Date.now() / 1000)
|
currentTime.value = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
// 过滤掉已过期的项目
|
|
||||||
const beforeCount = affinityList.value.length
|
const beforeCount = affinityList.value.length
|
||||||
affinityList.value = affinityList.value.filter(item => {
|
affinityList.value = affinityList.value.filter(
|
||||||
return item.expire_at && item.expire_at > currentTime.value
|
item => item.expire_at && item.expire_at > currentTime.value
|
||||||
})
|
)
|
||||||
|
|
||||||
// 如果有项目被移除,显示提示
|
|
||||||
if (beforeCount > affinityList.value.length) {
|
if (beforeCount > affinityList.value.length) {
|
||||||
const removedCount = beforeCount - affinityList.value.length
|
const removedCount = beforeCount - affinityList.value.length
|
||||||
showInfo(`${removedCount} 个缓存已自动过期移除`)
|
showInfo(`${removedCount} 个缓存已自动过期移除`)
|
||||||
@@ -213,7 +233,6 @@ function startCountdown() {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止倒计时定时器
|
|
||||||
function stopCountdown() {
|
function stopCountdown() {
|
||||||
if (countdownTimer) {
|
if (countdownTimer) {
|
||||||
clearInterval(countdownTimer)
|
clearInterval(countdownTimer)
|
||||||
@@ -221,15 +240,25 @@ function stopCountdown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 刷新所有数据 ====================
|
||||||
|
|
||||||
|
async function refreshData() {
|
||||||
|
await Promise.all([
|
||||||
|
fetchCacheStats(),
|
||||||
|
fetchCacheConfig(),
|
||||||
|
fetchAffinityList()
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
watch(tableKeyword, (value) => {
|
watch(tableKeyword, (value) => {
|
||||||
if (skipNextKeywordWatch) {
|
if (skipNextKeywordWatch) {
|
||||||
skipNextKeywordWatch = false
|
skipNextKeywordWatch = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchDebounceTimer) {
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||||
clearTimeout(searchDebounceTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyword = value.trim()
|
const keyword = value.trim()
|
||||||
searchDebounceTimer = setTimeout(() => {
|
searchDebounceTimer = setTimeout(() => {
|
||||||
@@ -243,21 +272,11 @@ onMounted(() => {
|
|||||||
fetchCacheConfig()
|
fetchCacheConfig()
|
||||||
fetchAffinityList()
|
fetchAffinityList()
|
||||||
startCountdown()
|
startCountdown()
|
||||||
|
refreshAnalysis()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 刷新所有数据
|
|
||||||
async function refreshData() {
|
|
||||||
await Promise.all([
|
|
||||||
fetchCacheStats(),
|
|
||||||
fetchCacheConfig(),
|
|
||||||
fetchAffinityList()
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (searchDebounceTimer) {
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||||
clearTimeout(searchDebounceTimer)
|
|
||||||
}
|
|
||||||
stopCountdown()
|
stopCountdown()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -272,31 +291,18 @@ onBeforeUnmount(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 核心指标 -->
|
<!-- 亲和性系统状态 -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<!-- 缓存命中率 -->
|
|
||||||
<Card class="p-4">
|
<Card class="p-4">
|
||||||
<div class="text-xs text-muted-foreground">命中率</div>
|
<div class="text-xs text-muted-foreground">活跃亲和性</div>
|
||||||
<div class="text-2xl font-bold text-success mt-1">
|
|
||||||
{{ stats ? (stats.affinity_stats.cache_hit_rate * 100).toFixed(1) : '0.0' }}%
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-muted-foreground mt-1">
|
|
||||||
{{ stats?.affinity_stats?.cache_hits || 0 }} / {{ (stats?.affinity_stats?.cache_hits || 0) + (stats?.affinity_stats?.cache_misses || 0) }}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- 活跃缓存数 -->
|
|
||||||
<Card class="p-4">
|
|
||||||
<div class="text-xs text-muted-foreground">活跃缓存</div>
|
|
||||||
<div class="text-2xl font-bold mt-1">
|
<div class="text-2xl font-bold mt-1">
|
||||||
{{ stats?.affinity_stats?.total_affinities || 0 }}
|
{{ stats?.affinity_stats?.active_affinities || 0 }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted-foreground mt-1">
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
TTL {{ config?.cache_ttl_seconds || 300 }}s
|
TTL {{ config?.cache_ttl_seconds || 300 }}s
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Provider切换 -->
|
|
||||||
<Card class="p-4">
|
<Card class="p-4">
|
||||||
<div class="text-xs text-muted-foreground">Provider 切换</div>
|
<div class="text-xs text-muted-foreground">Provider 切换</div>
|
||||||
<div class="text-2xl font-bold mt-1" :class="(stats?.affinity_stats?.provider_switches || 0) > 0 ? 'text-destructive' : ''">
|
<div class="text-2xl font-bold mt-1" :class="(stats?.affinity_stats?.provider_switches || 0) > 0 ? 'text-destructive' : ''">
|
||||||
@@ -307,7 +313,16 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 预留比例 -->
|
<Card class="p-4">
|
||||||
|
<div class="text-xs text-muted-foreground">缓存失效</div>
|
||||||
|
<div class="text-2xl font-bold mt-1" :class="(stats?.affinity_stats?.cache_invalidations || 0) > 0 ? 'text-warning' : ''">
|
||||||
|
{{ stats?.affinity_stats?.cache_invalidations || 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
|
因 Provider 不可用
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card class="p-4">
|
<Card class="p-4">
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-1">
|
<div class="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
预留比例
|
预留比例
|
||||||
@@ -322,14 +337,13 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted-foreground mt-1">
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
失效 {{ stats?.affinity_stats?.cache_invalidations || 0 }}
|
当前 {{ stats ? (stats.cache_reservation_ratio * 100).toFixed(0) : '-' }}%
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 缓存亲和性列表 -->
|
<!-- 缓存亲和性列表 -->
|
||||||
<Card class="overflow-hidden">
|
<Card class="overflow-hidden">
|
||||||
<!-- 标题和操作栏 -->
|
|
||||||
<div class="px-6 py-3 border-b border-border/60">
|
<div class="px-6 py-3 border-b border-border/60">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -365,8 +379,8 @@ onBeforeUnmount(() => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="w-28">用户</TableHead>
|
<TableHead class="w-36">用户</TableHead>
|
||||||
<TableHead class="w-36">Key</TableHead>
|
<TableHead class="w-28">Key</TableHead>
|
||||||
<TableHead class="w-28">Provider</TableHead>
|
<TableHead class="w-28">Provider</TableHead>
|
||||||
<TableHead class="w-40">模型</TableHead>
|
<TableHead class="w-40">模型</TableHead>
|
||||||
<TableHead class="w-36">API 格式 / Key</TableHead>
|
<TableHead class="w-36">API 格式 / Key</TableHead>
|
||||||
@@ -380,12 +394,12 @@ onBeforeUnmount(() => {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Badge v-if="item.is_standalone" variant="outline" class="text-warning border-warning/30 text-[10px] px-1">独立</Badge>
|
<Badge v-if="item.is_standalone" variant="outline" class="text-warning border-warning/30 text-[10px] px-1">独立</Badge>
|
||||||
<span class="text-sm font-medium truncate max-w-[90px]" :title="item.username ?? undefined">{{ item.username || '未知' }}</span>
|
<span class="text-sm font-medium truncate max-w-[120px]" :title="item.username ?? undefined">{{ item.username || '未知' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-sm truncate max-w-[100px]" :title="item.user_api_key_name || undefined">{{ item.user_api_key_name || '未命名' }}</span>
|
<span class="text-sm truncate max-w-[80px]" :title="item.user_api_key_name || undefined">{{ item.user_api_key_name || '未命名' }}</span>
|
||||||
<Badge v-if="item.rate_multiplier !== 1.0" variant="outline" class="text-warning border-warning/30 text-[10px] px-2">{{ item.rate_multiplier }}x</Badge>
|
<Badge v-if="item.rate_multiplier !== 1.0" variant="outline" class="text-warning border-warning/30 text-[10px] px-2">{{ item.rate_multiplier }}x</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted-foreground font-mono">{{ item.user_api_key_prefix || '---' }}</div>
|
<div class="text-xs text-muted-foreground font-mono">{{ item.user_api_key_prefix || '---' }}</div>
|
||||||
@@ -439,5 +453,157 @@ onBeforeUnmount(() => {
|
|||||||
@update:page-size="pageSize = $event"
|
@update:page-size="pageSize = $event"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- TTL 分析区域 -->
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="px-6 py-3 border-b border-border/60">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<BarChart3 class="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h3 class="text-base font-semibold">TTL 分析</h3>
|
||||||
|
<span class="text-xs text-muted-foreground">分析用户请求间隔,推荐合适的缓存 TTL</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Select v-model="analysisHours">
|
||||||
|
<SelectTrigger class="w-28 h-8">
|
||||||
|
<SelectValue placeholder="时间段" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="option in ANALYSIS_HOURS_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 缓存命中概览 -->
|
||||||
|
<div v-if="hitAnalysis" class="px-6 py-4 border-b border-border/40 bg-muted/30">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">请求命中率</div>
|
||||||
|
<div class="text-2xl font-bold text-success">{{ hitAnalysis.request_cache_hit_rate }}%</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{{ formatNumber(hitAnalysis.requests_with_cache_hit) }} / {{ formatNumber(hitAnalysis.total_requests) }} 请求</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">Token 命中率</div>
|
||||||
|
<div class="text-2xl font-bold">{{ hitAnalysis.token_cache_hit_rate }}%</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens 命中</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">缓存创建费用</div>
|
||||||
|
<div class="text-2xl font-bold">{{ formatCost(hitAnalysis.total_cache_creation_cost_usd) }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{{ formatTokens(hitAnalysis.total_cache_creation_tokens) }} tokens</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">缓存读取费用</div>
|
||||||
|
<div class="text-2xl font-bold">{{ formatCost(hitAnalysis.total_cache_read_cost_usd) }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">预估节省</div>
|
||||||
|
<div class="text-2xl font-bold text-success">{{ formatCost(hitAnalysis.estimated_savings_usd) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户 TTL 分析表格 -->
|
||||||
|
<Table v-if="ttlAnalysis && ttlAnalysis.users.length > 0">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead class="w-10"></TableHead>
|
||||||
|
<TableHead class="w-[20%]">用户</TableHead>
|
||||||
|
<TableHead class="w-[15%] text-center">请求数</TableHead>
|
||||||
|
<TableHead class="w-[15%] text-center">使用频率</TableHead>
|
||||||
|
<TableHead class="w-[15%] text-center">推荐 TTL</TableHead>
|
||||||
|
<TableHead>说明</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<template v-for="user in ttlAnalysis.users" :key="user.group_id">
|
||||||
|
<TableRow
|
||||||
|
class="cursor-pointer hover:bg-muted/50"
|
||||||
|
@click="toggleUserExpand(user.group_id)"
|
||||||
|
>
|
||||||
|
<TableCell class="p-2">
|
||||||
|
<button class="p-1 hover:bg-muted rounded">
|
||||||
|
<ChevronDown v-if="expandedUserId === user.group_id" class="h-4 w-4 text-muted-foreground" />
|
||||||
|
<ChevronRight v-else class="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span class="text-sm font-medium">{{ user.username || '未知用户' }}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-center">
|
||||||
|
<span class="text-sm font-medium">{{ user.request_count }}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-center">
|
||||||
|
<span class="text-sm" :class="getFrequencyClass(user.recommended_ttl_minutes)">
|
||||||
|
{{ getFrequencyLabel(user.recommended_ttl_minutes) }}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-center">
|
||||||
|
<Badge :variant="getTTLBadgeVariant(user.recommended_ttl_minutes)">
|
||||||
|
{{ user.recommended_ttl_minutes }} 分钟
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ formatIntervalDescription(user) }}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<!-- 展开行:显示用户散点图 -->
|
||||||
|
<TableRow v-if="expandedUserId === user.group_id" class="bg-muted/30">
|
||||||
|
<TableCell colspan="6" class="p-0">
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h4 class="text-sm font-medium">请求间隔时间线</h4>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-green-500"></span> 0-5分钟</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-blue-500"></span> 5-15分钟</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-purple-500"></span> 15-30分钟</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-orange-500"></span> 30-60分钟</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-red-500"></span> >60分钟</span>
|
||||||
|
<span v-if="userTimelineData" class="ml-2">共 {{ userTimelineData.total_points }} 个数据点</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="userTimelineLoading" class="h-64 flex items-center justify-center">
|
||||||
|
<span class="text-sm text-muted-foreground">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="userTimelineData && userTimelineData.points.length > 0" class="h-64">
|
||||||
|
<ScatterChart :data="userTimelineChartData" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="h-64 flex items-center justify-center">
|
||||||
|
<span class="text-sm text-muted-foreground">暂无数据</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<!-- 分析完成但无数据 -->
|
||||||
|
<div v-else-if="ttlAnalysis && ttlAnalysis.users.length === 0" class="px-6 py-12 text-center">
|
||||||
|
<BarChart3 class="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
未找到符合条件的用户数据
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
|
尝试增加分析天数或降低最小请求数阈值
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-else-if="ttlAnalysisLoading" class="px-6 py-12 text-center">
|
||||||
|
<p class="text-sm text-muted-foreground">正在分析用户请求数据...</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 pb-8">
|
<div class="space-y-6 pb-8">
|
||||||
<!-- 活跃度热图 -->
|
<!-- 活跃度热图 + 请求间隔时间线 -->
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
<ActivityHeatmapCard
|
<ActivityHeatmapCard
|
||||||
:data="activityHeatmapData"
|
:data="activityHeatmapData"
|
||||||
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
||||||
/>
|
/>
|
||||||
|
<IntervalTimelineCard
|
||||||
|
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
|
||||||
|
:is-admin="isAdminPage"
|
||||||
|
:hours="168"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 分析统计 -->
|
<!-- 分析统计 -->
|
||||||
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
|
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
|
||||||
@@ -87,7 +94,8 @@ import {
|
|||||||
UsageApiFormatTable,
|
UsageApiFormatTable,
|
||||||
UsageRecordsTable,
|
UsageRecordsTable,
|
||||||
ActivityHeatmapCard,
|
ActivityHeatmapCard,
|
||||||
RequestDetailDrawer
|
RequestDetailDrawer,
|
||||||
|
IntervalTimelineCard
|
||||||
} from '@/features/usage/components'
|
} from '@/features/usage/components'
|
||||||
import {
|
import {
|
||||||
useUsageData,
|
useUsageData,
|
||||||
|
|||||||
@@ -800,3 +800,184 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
|
|||||||
"tiers": tiers,
|
"tiers": tiers,
|
||||||
"source": pricing_source, # 定价来源: 'provider' 或 'global'
|
"source": pricing_source, # 定价来源: 'provider' 或 'global'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 缓存亲和性分析 ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cache-affinity/ttl-analysis")
|
||||||
|
async def analyze_cache_affinity_ttl(
|
||||||
|
request: Request,
|
||||||
|
user_id: Optional[str] = Query(None, description="指定用户 ID"),
|
||||||
|
api_key_id: Optional[str] = Query(None, description="指定 API Key ID"),
|
||||||
|
hours: int = Query(168, ge=1, le=720, description="分析最近多少小时的数据"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
分析用户请求间隔分布,推荐合适的缓存亲和性 TTL。
|
||||||
|
|
||||||
|
通过分析同一用户连续请求之间的时间间隔,判断用户的使用模式:
|
||||||
|
- 高频用户(间隔短):5 分钟 TTL 足够
|
||||||
|
- 中频用户:15-30 分钟 TTL
|
||||||
|
- 低频用户(间隔长):需要 60 分钟 TTL
|
||||||
|
"""
|
||||||
|
adapter = CacheAffinityTTLAnalysisAdapter(
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
hours=hours,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cache-affinity/hit-analysis")
|
||||||
|
async def analyze_cache_hit(
|
||||||
|
request: Request,
|
||||||
|
user_id: Optional[str] = Query(None, description="指定用户 ID"),
|
||||||
|
api_key_id: Optional[str] = Query(None, description="指定 API Key ID"),
|
||||||
|
hours: int = Query(168, ge=1, le=720, description="分析最近多少小时的数据"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
分析缓存命中情况。
|
||||||
|
|
||||||
|
返回缓存命中率、节省的费用等统计信息。
|
||||||
|
"""
|
||||||
|
adapter = CacheHitAnalysisAdapter(
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
hours=hours,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
class CacheAffinityTTLAnalysisAdapter(AdminApiAdapter):
|
||||||
|
"""缓存亲和性 TTL 分析适配器"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_id: Optional[str],
|
||||||
|
api_key_id: Optional[str],
|
||||||
|
hours: int,
|
||||||
|
):
|
||||||
|
self.user_id = user_id
|
||||||
|
self.api_key_id = api_key_id
|
||||||
|
self.hours = hours
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
|
||||||
|
result = UsageService.analyze_cache_affinity_ttl(
|
||||||
|
db=db,
|
||||||
|
user_id=self.user_id,
|
||||||
|
api_key_id=self.api_key_id,
|
||||||
|
hours=self.hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="cache_affinity_ttl_analysis",
|
||||||
|
user_id=self.user_id,
|
||||||
|
api_key_id=self.api_key_id,
|
||||||
|
hours=self.hours,
|
||||||
|
total_users_analyzed=result.get("total_users_analyzed", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class CacheHitAnalysisAdapter(AdminApiAdapter):
|
||||||
|
"""缓存命中分析适配器"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_id: Optional[str],
|
||||||
|
api_key_id: Optional[str],
|
||||||
|
hours: int,
|
||||||
|
):
|
||||||
|
self.user_id = user_id
|
||||||
|
self.api_key_id = api_key_id
|
||||||
|
self.hours = hours
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
|
||||||
|
result = UsageService.get_cache_hit_analysis(
|
||||||
|
db=db,
|
||||||
|
user_id=self.user_id,
|
||||||
|
api_key_id=self.api_key_id,
|
||||||
|
hours=self.hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="cache_hit_analysis",
|
||||||
|
user_id=self.user_id,
|
||||||
|
api_key_id=self.api_key_id,
|
||||||
|
hours=self.hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cache-affinity/interval-timeline")
|
||||||
|
async def get_interval_timeline(
|
||||||
|
request: Request,
|
||||||
|
hours: int = Query(168, ge=1, le=720, description="分析最近多少小时的数据"),
|
||||||
|
limit: int = Query(1000, ge=100, le=5000, description="最大返回数据点数量"),
|
||||||
|
user_id: Optional[str] = Query(None, description="指定用户 ID"),
|
||||||
|
include_user_info: bool = Query(False, description="是否包含用户信息(用于管理员多用户视图)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取请求间隔时间线数据,用于散点图展示。
|
||||||
|
|
||||||
|
返回每个请求的时间点和与上一个请求的间隔(分钟),
|
||||||
|
可用于可视化用户请求模式。
|
||||||
|
|
||||||
|
当 include_user_info=true 且未指定 user_id 时,返回数据会包含:
|
||||||
|
- points 中每个点包含 user_id 字段
|
||||||
|
- users 字段包含 user_id -> username 的映射
|
||||||
|
"""
|
||||||
|
adapter = IntervalTimelineAdapter(
|
||||||
|
hours=hours,
|
||||||
|
limit=limit,
|
||||||
|
user_id=user_id,
|
||||||
|
include_user_info=include_user_info,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
class IntervalTimelineAdapter(AdminApiAdapter):
|
||||||
|
"""请求间隔时间线适配器"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hours: int,
|
||||||
|
limit: int,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
include_user_info: bool = False,
|
||||||
|
):
|
||||||
|
self.hours = hours
|
||||||
|
self.limit = limit
|
||||||
|
self.user_id = user_id
|
||||||
|
self.include_user_info = include_user_info
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
|
||||||
|
result = UsageService.get_interval_timeline(
|
||||||
|
db=db,
|
||||||
|
hours=self.hours,
|
||||||
|
limit=self.limit,
|
||||||
|
user_id=self.user_id,
|
||||||
|
include_user_info=self.include_user_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="interval_timeline",
|
||||||
|
hours=self.hours,
|
||||||
|
limit=self.limit,
|
||||||
|
user_id=self.user_id,
|
||||||
|
include_user_info=self.include_user_info,
|
||||||
|
total_points=result.get("total_points", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ async def get_my_active_requests(
|
|||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage/interval-timeline")
|
||||||
|
async def get_my_interval_timeline(
|
||||||
|
request: Request,
|
||||||
|
hours: int = Query(168, ge=1, le=720, description="分析最近多少小时的数据"),
|
||||||
|
limit: int = Query(1000, ge=100, le=5000, description="最大返回数据点数量"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取当前用户的请求间隔时间线数据,用于散点图展示"""
|
||||||
|
adapter = GetMyIntervalTimelineAdapter(hours=hours, limit=limit)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers")
|
@router.get("/providers")
|
||||||
async def list_available_providers(request: Request, db: Session = Depends(get_db)):
|
async def list_available_providers(request: Request, db: Session = Depends(get_db)):
|
||||||
adapter = ListAvailableProvidersAdapter()
|
adapter = ListAvailableProvidersAdapter()
|
||||||
@@ -676,6 +688,27 @@ class GetActiveRequestsAdapter(AuthenticatedApiAdapter):
|
|||||||
return {"requests": requests}
|
return {"requests": requests}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter):
|
||||||
|
"""获取当前用户的请求间隔时间线适配器"""
|
||||||
|
|
||||||
|
hours: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
user = context.user
|
||||||
|
|
||||||
|
result = UsageService.get_interval_timeline(
|
||||||
|
db=db,
|
||||||
|
hours=self.hours,
|
||||||
|
limit=self.limit,
|
||||||
|
user_id=str(user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
|
class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|||||||
3
src/services/cache/aware_scheduler.py
vendored
3
src/services/cache/aware_scheduler.py
vendored
@@ -862,7 +862,8 @@ class CacheAwareScheduler:
|
|||||||
|
|
||||||
# Key 级别的能力匹配检查
|
# Key 级别的能力匹配检查
|
||||||
# 注意:模型级别的能力检查已在 _check_model_support 中完成
|
# 注意:模型级别的能力检查已在 _check_model_support 中完成
|
||||||
if capability_requirements:
|
# 始终执行检查,即使 capability_requirements 为空
|
||||||
|
# 因为 check_capability_match 会检查 Key 的 EXCLUSIVE 能力是否被浪费
|
||||||
from src.core.key_capabilities import check_capability_match
|
from src.core.key_capabilities import check_capability_match
|
||||||
|
|
||||||
key_caps: Dict[str, bool] = dict(key.capabilities or {})
|
key_caps: Dict[str, bool] = dict(key.capabilities or {})
|
||||||
|
|||||||
@@ -67,12 +67,13 @@ class ErrorClassifier:
|
|||||||
|
|
||||||
# 表示客户端请求错误的关键词(不区分大小写)
|
# 表示客户端请求错误的关键词(不区分大小写)
|
||||||
# 这些错误是由用户请求本身导致的,换 Provider 也无济于事
|
# 这些错误是由用户请求本身导致的,换 Provider 也无济于事
|
||||||
|
# 注意:标准 API 返回的 error.type 已在 CLIENT_ERROR_TYPES 中处理
|
||||||
|
# 这里主要用于匹配非标准格式或第三方代理的错误消息
|
||||||
CLIENT_ERROR_PATTERNS: Tuple[str, ...] = (
|
CLIENT_ERROR_PATTERNS: Tuple[str, ...] = (
|
||||||
"could not process image", # 图片处理失败
|
"could not process image", # 图片处理失败
|
||||||
"image too large", # 图片过大
|
"image too large", # 图片过大
|
||||||
"invalid image", # 无效图片
|
"invalid image", # 无效图片
|
||||||
"unsupported image", # 不支持的图片格式
|
"unsupported image", # 不支持的图片格式
|
||||||
"invalid_request_error", # OpenAI/Claude 通用客户端错误类型
|
|
||||||
"content_policy_violation", # 内容违规
|
"content_policy_violation", # 内容违规
|
||||||
"invalid_api_key", # 无效的 API Key(不同于认证失败)
|
"invalid_api_key", # 无效的 API Key(不同于认证失败)
|
||||||
"context_length_exceeded", # 上下文长度超限
|
"context_length_exceeded", # 上下文长度超限
|
||||||
@@ -85,6 +86,7 @@ class ErrorClassifier:
|
|||||||
"image exceeds", # 图片超出限制
|
"image exceeds", # 图片超出限制
|
||||||
"pdf too large", # PDF 过大
|
"pdf too large", # PDF 过大
|
||||||
"file too large", # 文件过大
|
"file too large", # 文件过大
|
||||||
|
"tool_use_id", # tool_result 引用了不存在的 tool_use(兼容非标准代理)
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -105,10 +107,22 @@ class ErrorClassifier:
|
|||||||
self.adaptive_manager = adaptive_manager or get_adaptive_manager()
|
self.adaptive_manager = adaptive_manager or get_adaptive_manager()
|
||||||
self.cache_scheduler = cache_scheduler
|
self.cache_scheduler = cache_scheduler
|
||||||
|
|
||||||
|
# 表示客户端错误的 error type(不区分大小写)
|
||||||
|
# 这些 type 表明是请求本身的问题,不应重试
|
||||||
|
CLIENT_ERROR_TYPES: Tuple[str, ...] = (
|
||||||
|
"invalid_request_error", # Claude/OpenAI 标准客户端错误类型
|
||||||
|
"invalid_argument", # Gemini 参数错误
|
||||||
|
"failed_precondition", # Gemini 前置条件错误
|
||||||
|
)
|
||||||
|
|
||||||
def _is_client_error(self, error_text: Optional[str]) -> bool:
|
def _is_client_error(self, error_text: Optional[str]) -> bool:
|
||||||
"""
|
"""
|
||||||
检测错误响应是否为客户端错误(不应重试)
|
检测错误响应是否为客户端错误(不应重试)
|
||||||
|
|
||||||
|
判断逻辑:
|
||||||
|
1. 检查 error.type 是否为已知的客户端错误类型
|
||||||
|
2. 检查错误文本是否包含已知的客户端错误模式
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
error_text: 错误响应文本
|
error_text: 错误响应文本
|
||||||
|
|
||||||
@@ -118,6 +132,19 @@ class ErrorClassifier:
|
|||||||
if not error_text:
|
if not error_text:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 尝试解析 JSON 并检查 error type
|
||||||
|
try:
|
||||||
|
data = json.loads(error_text)
|
||||||
|
if isinstance(data.get("error"), dict):
|
||||||
|
error_type = data["error"].get("type", "")
|
||||||
|
if error_type and any(
|
||||||
|
t.lower() in error_type.lower() for t in self.CLIENT_ERROR_TYPES
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except (json.JSONDecodeError, TypeError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 回退到关键词匹配
|
||||||
error_lower = error_text.lower()
|
error_lower = error_text.lower()
|
||||||
return any(pattern.lower() in error_lower for pattern in self.CLIENT_ERROR_PATTERNS)
|
return any(pattern.lower() in error_lower for pattern in self.CLIENT_ERROR_PATTERNS)
|
||||||
|
|
||||||
|
|||||||
@@ -1394,3 +1394,461 @@ class UsageService:
|
|||||||
}
|
}
|
||||||
for r in records
|
for r in records
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ========== 缓存亲和性分析方法 ==========
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def analyze_cache_affinity_ttl(
|
||||||
|
db: Session,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
api_key_id: Optional[str] = None,
|
||||||
|
hours: int = 168,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
分析用户请求间隔分布,推荐合适的缓存亲和性 TTL
|
||||||
|
|
||||||
|
通过分析同一用户连续请求之间的时间间隔,判断用户的使用模式:
|
||||||
|
- 高频用户(间隔短):5 分钟 TTL 足够
|
||||||
|
- 中频用户:15-30 分钟 TTL
|
||||||
|
- 低频用户(间隔长):需要 60 分钟 TTL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
user_id: 指定用户 ID(可选,为空则分析所有用户)
|
||||||
|
api_key_id: 指定 API Key ID(可选)
|
||||||
|
hours: 分析最近多少小时的数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含分析结果的字典
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# 计算时间范围
|
||||||
|
start_date = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# 构建 SQL 查询 - 使用窗口函数计算请求间隔
|
||||||
|
# 按 user_id 或 api_key_id 分组,计算同一组内连续请求的时间差
|
||||||
|
group_by_field = "api_key_id" if api_key_id else "user_id"
|
||||||
|
|
||||||
|
# 构建过滤条件
|
||||||
|
filter_clause = ""
|
||||||
|
if user_id or api_key_id:
|
||||||
|
filter_clause = f"AND {group_by_field} = :filter_id"
|
||||||
|
|
||||||
|
sql = text(f"""
|
||||||
|
WITH user_requests AS (
|
||||||
|
SELECT
|
||||||
|
{group_by_field} as group_id,
|
||||||
|
created_at,
|
||||||
|
LAG(created_at) OVER (
|
||||||
|
PARTITION BY {group_by_field}
|
||||||
|
ORDER BY created_at
|
||||||
|
) as prev_request_at
|
||||||
|
FROM usage
|
||||||
|
WHERE status = 'completed'
|
||||||
|
AND created_at > :start_date
|
||||||
|
AND {group_by_field} IS NOT NULL
|
||||||
|
{filter_clause}
|
||||||
|
),
|
||||||
|
intervals AS (
|
||||||
|
SELECT
|
||||||
|
group_id,
|
||||||
|
EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes
|
||||||
|
FROM user_requests
|
||||||
|
WHERE prev_request_at IS NOT NULL
|
||||||
|
),
|
||||||
|
user_stats AS (
|
||||||
|
SELECT
|
||||||
|
group_id,
|
||||||
|
COUNT(*) as request_count,
|
||||||
|
COUNT(*) FILTER (WHERE interval_minutes <= 5) as within_5min,
|
||||||
|
COUNT(*) FILTER (WHERE interval_minutes > 5 AND interval_minutes <= 15) as within_15min,
|
||||||
|
COUNT(*) FILTER (WHERE interval_minutes > 15 AND interval_minutes <= 30) as within_30min,
|
||||||
|
COUNT(*) FILTER (WHERE interval_minutes > 30 AND interval_minutes <= 60) as within_60min,
|
||||||
|
COUNT(*) FILTER (WHERE interval_minutes > 60) as over_60min,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY interval_minutes) as median_interval,
|
||||||
|
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY interval_minutes) as p75_interval,
|
||||||
|
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY interval_minutes) as p90_interval,
|
||||||
|
AVG(interval_minutes) as avg_interval,
|
||||||
|
MIN(interval_minutes) as min_interval,
|
||||||
|
MAX(interval_minutes) as max_interval
|
||||||
|
FROM intervals
|
||||||
|
GROUP BY group_id
|
||||||
|
HAVING COUNT(*) >= 2
|
||||||
|
)
|
||||||
|
SELECT * FROM user_stats
|
||||||
|
ORDER BY request_count DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
params: Dict[str, Any] = {
|
||||||
|
"start_date": start_date,
|
||||||
|
}
|
||||||
|
if user_id:
|
||||||
|
params["filter_id"] = user_id
|
||||||
|
elif api_key_id:
|
||||||
|
params["filter_id"] = api_key_id
|
||||||
|
|
||||||
|
result = db.execute(sql, params)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
# 收集所有 user_id 以便批量查询用户信息
|
||||||
|
group_ids = [row[0] for row in rows]
|
||||||
|
|
||||||
|
# 如果是按 user_id 分组,查询用户信息
|
||||||
|
user_info_map: Dict[str, Dict[str, str]] = {}
|
||||||
|
if group_by_field == "user_id" and group_ids:
|
||||||
|
users = db.query(User).filter(User.id.in_(group_ids)).all()
|
||||||
|
for user in users:
|
||||||
|
user_info_map[str(user.id)] = {
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 处理结果
|
||||||
|
users_analysis = []
|
||||||
|
for row in rows:
|
||||||
|
# row 是一个 tuple,按查询顺序访问
|
||||||
|
(
|
||||||
|
group_id,
|
||||||
|
request_count,
|
||||||
|
within_5min,
|
||||||
|
within_15min,
|
||||||
|
within_30min,
|
||||||
|
within_60min,
|
||||||
|
over_60min,
|
||||||
|
median_interval,
|
||||||
|
p75_interval,
|
||||||
|
p90_interval,
|
||||||
|
avg_interval,
|
||||||
|
min_interval,
|
||||||
|
max_interval,
|
||||||
|
) = row
|
||||||
|
|
||||||
|
# 计算推荐 TTL
|
||||||
|
recommended_ttl = UsageService._calculate_recommended_ttl(
|
||||||
|
p75_interval, p90_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取用户信息
|
||||||
|
user_info = user_info_map.get(str(group_id), {})
|
||||||
|
|
||||||
|
# 计算各区间占比
|
||||||
|
total_intervals = request_count
|
||||||
|
users_analysis.append({
|
||||||
|
"group_id": group_id,
|
||||||
|
"username": user_info.get("username"),
|
||||||
|
"email": user_info.get("email"),
|
||||||
|
"request_count": request_count,
|
||||||
|
"interval_distribution": {
|
||||||
|
"within_5min": within_5min,
|
||||||
|
"within_15min": within_15min,
|
||||||
|
"within_30min": within_30min,
|
||||||
|
"within_60min": within_60min,
|
||||||
|
"over_60min": over_60min,
|
||||||
|
},
|
||||||
|
"interval_percentages": {
|
||||||
|
"within_5min": round(within_5min / total_intervals * 100, 1),
|
||||||
|
"within_15min": round(within_15min / total_intervals * 100, 1),
|
||||||
|
"within_30min": round(within_30min / total_intervals * 100, 1),
|
||||||
|
"within_60min": round(within_60min / total_intervals * 100, 1),
|
||||||
|
"over_60min": round(over_60min / total_intervals * 100, 1),
|
||||||
|
},
|
||||||
|
"percentiles": {
|
||||||
|
"p50": round(float(median_interval), 2) if median_interval else None,
|
||||||
|
"p75": round(float(p75_interval), 2) if p75_interval else None,
|
||||||
|
"p90": round(float(p90_interval), 2) if p90_interval else None,
|
||||||
|
},
|
||||||
|
"avg_interval_minutes": round(float(avg_interval), 2) if avg_interval else None,
|
||||||
|
"min_interval_minutes": round(float(min_interval), 2) if min_interval else None,
|
||||||
|
"max_interval_minutes": round(float(max_interval), 2) if max_interval else None,
|
||||||
|
"recommended_ttl_minutes": recommended_ttl,
|
||||||
|
"recommendation_reason": UsageService._get_ttl_recommendation_reason(
|
||||||
|
recommended_ttl, p75_interval, p90_interval
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 汇总统计
|
||||||
|
ttl_distribution = {"5min": 0, "15min": 0, "30min": 0, "60min": 0}
|
||||||
|
for analysis in users_analysis:
|
||||||
|
ttl = analysis["recommended_ttl_minutes"]
|
||||||
|
if ttl <= 5:
|
||||||
|
ttl_distribution["5min"] += 1
|
||||||
|
elif ttl <= 15:
|
||||||
|
ttl_distribution["15min"] += 1
|
||||||
|
elif ttl <= 30:
|
||||||
|
ttl_distribution["30min"] += 1
|
||||||
|
else:
|
||||||
|
ttl_distribution["60min"] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"analysis_period_hours": hours,
|
||||||
|
"total_users_analyzed": len(users_analysis),
|
||||||
|
"ttl_distribution": ttl_distribution,
|
||||||
|
"users": users_analysis,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_recommended_ttl(
|
||||||
|
p75_interval: Optional[float],
|
||||||
|
p90_interval: Optional[float],
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
根据请求间隔分布计算推荐的缓存 TTL
|
||||||
|
|
||||||
|
策略:
|
||||||
|
- 如果 90% 的请求间隔都在 5 分钟内 → 5 分钟 TTL
|
||||||
|
- 如果 75% 的请求间隔在 15 分钟内 → 15 分钟 TTL
|
||||||
|
- 如果 75% 的请求间隔在 30 分钟内 → 30 分钟 TTL
|
||||||
|
- 否则 → 60 分钟 TTL
|
||||||
|
"""
|
||||||
|
if p90_interval is None or p75_interval is None:
|
||||||
|
return 5 # 默认值
|
||||||
|
|
||||||
|
# 如果 90% 的间隔都在 5 分钟内
|
||||||
|
if p90_interval <= 5:
|
||||||
|
return 5
|
||||||
|
|
||||||
|
# 如果 75% 的间隔在 15 分钟内
|
||||||
|
if p75_interval <= 15:
|
||||||
|
return 15
|
||||||
|
|
||||||
|
# 如果 75% 的间隔在 30 分钟内
|
||||||
|
if p75_interval <= 30:
|
||||||
|
return 30
|
||||||
|
|
||||||
|
# 低频用户,需要更长的 TTL
|
||||||
|
return 60
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_ttl_recommendation_reason(
|
||||||
|
ttl: int,
|
||||||
|
p75_interval: Optional[float],
|
||||||
|
p90_interval: Optional[float],
|
||||||
|
) -> str:
|
||||||
|
"""生成 TTL 推荐理由"""
|
||||||
|
if p75_interval is None or p90_interval is None:
|
||||||
|
return "数据不足,使用默认值"
|
||||||
|
|
||||||
|
if ttl == 5:
|
||||||
|
return f"高频用户:90% 的请求间隔在 {p90_interval:.1f} 分钟内"
|
||||||
|
elif ttl == 15:
|
||||||
|
return f"中高频用户:75% 的请求间隔在 {p75_interval:.1f} 分钟内"
|
||||||
|
elif ttl == 30:
|
||||||
|
return f"中频用户:75% 的请求间隔在 {p75_interval:.1f} 分钟内"
|
||||||
|
else:
|
||||||
|
return f"低频用户:75% 的请求间隔为 {p75_interval:.1f} 分钟,建议使用长 TTL"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_cache_hit_analysis(
|
||||||
|
db: Session,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
api_key_id: Optional[str] = None,
|
||||||
|
hours: int = 168,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
分析缓存命中情况
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
user_id: 指定用户 ID(可选)
|
||||||
|
api_key_id: 指定 API Key ID(可选)
|
||||||
|
hours: 分析最近多少小时的数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
缓存命中分析结果
|
||||||
|
"""
|
||||||
|
start_date = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# 基础查询
|
||||||
|
query = db.query(
|
||||||
|
func.count(Usage.id).label("total_requests"),
|
||||||
|
func.sum(Usage.input_tokens).label("total_input_tokens"),
|
||||||
|
func.sum(Usage.cache_read_input_tokens).label("total_cache_read_tokens"),
|
||||||
|
func.sum(Usage.cache_creation_input_tokens).label("total_cache_creation_tokens"),
|
||||||
|
func.sum(Usage.cache_read_cost_usd).label("total_cache_read_cost"),
|
||||||
|
func.sum(Usage.cache_creation_cost_usd).label("total_cache_creation_cost"),
|
||||||
|
).filter(
|
||||||
|
Usage.status == "completed",
|
||||||
|
Usage.created_at >= start_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query = query.filter(Usage.user_id == user_id)
|
||||||
|
if api_key_id:
|
||||||
|
query = query.filter(Usage.api_key_id == api_key_id)
|
||||||
|
|
||||||
|
result = query.first()
|
||||||
|
|
||||||
|
total_requests = result.total_requests or 0
|
||||||
|
total_input_tokens = result.total_input_tokens or 0
|
||||||
|
total_cache_read_tokens = result.total_cache_read_tokens or 0
|
||||||
|
total_cache_creation_tokens = result.total_cache_creation_tokens or 0
|
||||||
|
total_cache_read_cost = float(result.total_cache_read_cost or 0)
|
||||||
|
total_cache_creation_cost = float(result.total_cache_creation_cost or 0)
|
||||||
|
|
||||||
|
# 计算缓存命中率(按 token 数)
|
||||||
|
# 总输入上下文 = input_tokens + cache_read_tokens(因为 input_tokens 不含 cache_read)
|
||||||
|
# 或者如果 input_tokens 已经包含 cache_read,则直接用 input_tokens
|
||||||
|
# 这里假设 cache_read_tokens 是额外的,命中率 = cache_read / (input + cache_read)
|
||||||
|
total_context_tokens = total_input_tokens + total_cache_read_tokens
|
||||||
|
cache_hit_rate = 0.0
|
||||||
|
if total_context_tokens > 0:
|
||||||
|
cache_hit_rate = total_cache_read_tokens / total_context_tokens * 100
|
||||||
|
|
||||||
|
# 计算节省的费用
|
||||||
|
# 缓存读取价格是正常输入价格的 10%,所以节省了 90%
|
||||||
|
# 节省 = cache_read_tokens * (正常价格 - 缓存价格) = cache_read_cost * 9
|
||||||
|
# 因为 cache_read_cost 是按 10% 价格算的,如果按 100% 算就是 10 倍
|
||||||
|
estimated_savings = total_cache_read_cost * 9 # 节省了 90%
|
||||||
|
|
||||||
|
# 统计有缓存命中的请求数
|
||||||
|
requests_with_cache_hit = db.query(func.count(Usage.id)).filter(
|
||||||
|
Usage.status == "completed",
|
||||||
|
Usage.created_at >= start_date,
|
||||||
|
Usage.cache_read_input_tokens > 0,
|
||||||
|
)
|
||||||
|
if user_id:
|
||||||
|
requests_with_cache_hit = requests_with_cache_hit.filter(Usage.user_id == user_id)
|
||||||
|
if api_key_id:
|
||||||
|
requests_with_cache_hit = requests_with_cache_hit.filter(Usage.api_key_id == api_key_id)
|
||||||
|
requests_with_cache_hit = requests_with_cache_hit.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"analysis_period_hours": hours,
|
||||||
|
"total_requests": total_requests,
|
||||||
|
"requests_with_cache_hit": requests_with_cache_hit,
|
||||||
|
"request_cache_hit_rate": round(requests_with_cache_hit / total_requests * 100, 2) if total_requests > 0 else 0,
|
||||||
|
"total_input_tokens": total_input_tokens,
|
||||||
|
"total_cache_read_tokens": total_cache_read_tokens,
|
||||||
|
"total_cache_creation_tokens": total_cache_creation_tokens,
|
||||||
|
"token_cache_hit_rate": round(cache_hit_rate, 2),
|
||||||
|
"total_cache_read_cost_usd": round(total_cache_read_cost, 4),
|
||||||
|
"total_cache_creation_cost_usd": round(total_cache_creation_cost, 4),
|
||||||
|
"estimated_savings_usd": round(estimated_savings, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_interval_timeline(
|
||||||
|
db: Session,
|
||||||
|
hours: int = 168,
|
||||||
|
limit: int = 1000,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
include_user_info: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取请求间隔时间线数据,用于散点图展示
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
hours: 分析最近多少小时的数据
|
||||||
|
limit: 最大返回数据点数量
|
||||||
|
user_id: 指定用户 ID(可选,为空则返回所有用户)
|
||||||
|
include_user_info: 是否包含用户信息(用于管理员多用户视图)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含时间线数据点的字典
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
start_date = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# 构建用户过滤条件
|
||||||
|
user_filter = "AND u.user_id = :user_id" if user_id else ""
|
||||||
|
|
||||||
|
# 根据是否需要用户信息选择不同的查询
|
||||||
|
if include_user_info and not user_id:
|
||||||
|
# 管理员视图:返回带用户信息的数据点
|
||||||
|
sql = text(f"""
|
||||||
|
WITH request_intervals AS (
|
||||||
|
SELECT
|
||||||
|
u.created_at,
|
||||||
|
u.user_id,
|
||||||
|
usr.username,
|
||||||
|
LAG(u.created_at) OVER (
|
||||||
|
PARTITION BY u.user_id
|
||||||
|
ORDER BY u.created_at
|
||||||
|
) as prev_request_at
|
||||||
|
FROM usage u
|
||||||
|
LEFT JOIN users usr ON u.user_id = usr.id
|
||||||
|
WHERE u.status = 'completed'
|
||||||
|
AND u.created_at > :start_date
|
||||||
|
AND u.user_id IS NOT NULL
|
||||||
|
{user_filter}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
created_at,
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes
|
||||||
|
FROM request_intervals
|
||||||
|
WHERE prev_request_at IS NOT NULL
|
||||||
|
AND EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 <= 120
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT :limit
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
# 普通视图:只返回时间和间隔
|
||||||
|
sql = text(f"""
|
||||||
|
WITH request_intervals AS (
|
||||||
|
SELECT
|
||||||
|
u.created_at,
|
||||||
|
u.user_id,
|
||||||
|
LAG(u.created_at) OVER (
|
||||||
|
PARTITION BY u.user_id
|
||||||
|
ORDER BY u.created_at
|
||||||
|
) as prev_request_at
|
||||||
|
FROM usage u
|
||||||
|
WHERE u.status = 'completed'
|
||||||
|
AND u.created_at > :start_date
|
||||||
|
AND u.user_id IS NOT NULL
|
||||||
|
{user_filter}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
created_at,
|
||||||
|
EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes
|
||||||
|
FROM request_intervals
|
||||||
|
WHERE prev_request_at IS NOT NULL
|
||||||
|
AND EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 <= 120
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT :limit
|
||||||
|
""")
|
||||||
|
|
||||||
|
params: Dict[str, Any] = {"start_date": start_date, "limit": limit}
|
||||||
|
if user_id:
|
||||||
|
params["user_id"] = user_id
|
||||||
|
|
||||||
|
result = db.execute(sql, params)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
# 转换为时间线数据点
|
||||||
|
points = []
|
||||||
|
users_map: Dict[str, str] = {} # user_id -> username
|
||||||
|
|
||||||
|
if include_user_info and not user_id:
|
||||||
|
for row in rows:
|
||||||
|
created_at, row_user_id, username, interval_minutes = row
|
||||||
|
points.append({
|
||||||
|
"x": created_at.isoformat(),
|
||||||
|
"y": round(float(interval_minutes), 2),
|
||||||
|
"user_id": str(row_user_id),
|
||||||
|
})
|
||||||
|
if row_user_id and username:
|
||||||
|
users_map[str(row_user_id)] = username
|
||||||
|
else:
|
||||||
|
for row in rows:
|
||||||
|
created_at, interval_minutes = row
|
||||||
|
points.append({
|
||||||
|
"x": created_at.isoformat(),
|
||||||
|
"y": round(float(interval_minutes), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
response: Dict[str, Any] = {
|
||||||
|
"analysis_period_hours": hours,
|
||||||
|
"total_points": len(points),
|
||||||
|
"points": points,
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_user_info and not user_id:
|
||||||
|
response["users"] = users_map
|
||||||
|
|
||||||
|
return response
|
||||||
|
|||||||
Reference in New Issue
Block a user