diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..a3b5f09 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/README.md b/README.md index 2faf73d..693815d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户 ## 部署 -### Docker Compose(推荐) +### Docker Compose(推荐:预构建镜像) ```bash # 1. 克隆代码 @@ -58,16 +58,24 @@ cp .env.example .env python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env # 3. 部署 -./deploy.sh # 自动构建、启动、迁移 +docker-compose up -d + +# 4. 更新 +docker-compose pull && docker-compose up -d ``` -### 更新 +### Docker Compose(本地构建镜像) ```bash -# 拉取最新代码 -git pull +# 1. 克隆代码 +git clone https://github.com/fawney19/Aether.git +cd aether -# 自动部署脚本 +# 2. 配置环境变量 +cp .env.example .env +python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env + +# 3. 部署 / 更新(自动构建、启动、迁移) ./deploy.sh ``` @@ -75,7 +83,7 @@ git pull ```bash # 启动依赖 -docker-compose up -d postgres redis +docker-compose -f docker-compose.build.yml up -d postgres redis # 后端 uv sync diff --git a/deploy.sh b/deploy.sh index 233396e..6e039d3 100755 --- a/deploy.sh +++ b/deploy.sh @@ -11,9 +11,9 @@ cd "$(dirname "$0")" # 兼容 docker-compose 和 docker compose if command -v docker-compose &> /dev/null; then - DC="docker-compose" + DC="docker-compose -f docker-compose.build.yml" else - DC="docker compose" + DC="docker compose -f docker-compose.build.yml" fi # 缓存文件 diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 0000000..e89a878 --- /dev/null +++ b/docker-compose.build.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index efcd7b5..60afccf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ -# Aether 部署配置 -# 使用 ./deploy.sh 自动部署 +# Aether 部署配置 - 使用预构建镜像 +# 使用方法: docker-compose up -d services: postgres: @@ -37,7 +37,7 @@ services: restart: unless-stopped app: - image: aether-app:latest + image: ghcr.io/fawney19/aether:latest container_name: aether-app environment: DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether @@ -65,11 +65,9 @@ services: ports: - "${APP_PORT:-8084}:80" volumes: - # 挂载日志目录到主机,便于调试和持久化 - ./logs:/app/logs restart: unless-stopped - volumes: postgres_data: - redis_data: \ No newline at end of file + redis_data: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 06bd399..633fa18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,8 +14,10 @@ "@vueuse/core": "^13.9.0", "axios": "^1.12.1", "chart.js": "^4.5.0", + "chartjs-adapter-date-fns": "^3.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dompurify": "^3.3.0", "highlight.js": "^11.11.1", "lucide-vue-next": "^0.544.0", @@ -2723,6 +2725,16 @@ "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": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2923,6 +2935,16 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 85fd474..5bbd977 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,8 +21,10 @@ "@vueuse/core": "^13.9.0", "axios": "^1.12.1", "chart.js": "^4.5.0", + "chartjs-adapter-date-fns": "^3.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dompurify": "^3.3.0", "highlight.js": "^11.11.1", "lucide-vue-next": "^0.544.0", diff --git a/frontend/src/api/cache.ts b/frontend/src/api/cache.ts index e0bf159..c8d3b59 100644 --- a/frontend/src/api/cache.ts +++ b/frontend/src/api/cache.ts @@ -156,3 +156,116 @@ export const { clearProviderCache, listAffinities } = 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 // user_id -> username 映射(仅 include_user_info=true 时存在) +} + +export const cacheAnalysisApi = { + /** + * 分析缓存亲和性 TTL 推荐 + */ + async analyzeTTL(params?: { + user_id?: string + api_key_id?: string + hours?: number + }): Promise { + 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 { + 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 { + const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params }) + return response.data + } +} diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index 2b2357b..7d2b5c3 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -253,5 +253,18 @@ export const meApi = { }> { const response = await apiClient.put('/api/users/me/model-capabilities', 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 } } diff --git a/frontend/src/components/charts/ScatterChart.vue b/frontend/src/components/charts/ScatterChart.vue new file mode 100644 index 0000000..24e43e4 --- /dev/null +++ b/frontend/src/components/charts/ScatterChart.vue @@ -0,0 +1,372 @@ + + + diff --git a/frontend/src/components/stats/ActivityHeatmap.vue b/frontend/src/components/stats/ActivityHeatmap.vue index c63680b..ee1a048 100644 --- a/frontend/src/components/stats/ActivityHeatmap.vue +++ b/frontend/src/components/stats/ActivityHeatmap.vue @@ -188,7 +188,7 @@ const monthMarkers = computed(() => { if (month === lastMonth) { return } - markers[index] = String(month + 1) + markers[index] = `${month + 1}月` lastMonth = month }) diff --git a/frontend/src/composables/useTTLAnalysis.ts b/frontend/src/composables/useTTLAnalysis.ts new file mode 100644 index 0000000..71d8d96 --- /dev/null +++ b/frontend/src/composables/useTTLAnalysis.ts @@ -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(null) + const hitAnalysis = ref(null) + const ttlAnalysisLoading = ref(false) + const hitAnalysisLoading = ref(false) + const analysisHours = ref('24') + + // 用户散点图展开状态 + const expandedUserId = ref(null) + const userTimelineData = ref(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>(() => { + 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 + } +} diff --git a/frontend/src/features/usage/components/IntervalTimelineCard.vue b/frontend/src/features/usage/components/IntervalTimelineCard.vue new file mode 100644 index 0000000..d9a932b --- /dev/null +++ b/frontend/src/features/usage/components/IntervalTimelineCard.vue @@ -0,0 +1,205 @@ + + + diff --git a/frontend/src/features/usage/components/index.ts b/frontend/src/features/usage/components/index.ts index e41b01c..c477f01 100644 --- a/frontend/src/features/usage/components/index.ts +++ b/frontend/src/features/usage/components/index.ts @@ -5,3 +5,4 @@ export { default as UsageRecordsTable } from './UsageRecordsTable.vue' export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue' export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue' export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue' +export { default as IntervalTimelineCard } from './IntervalTimelineCard.vue' diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 460cc9e..0865876 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -125,4 +125,21 @@ export function formatBillingType(type: string | undefined | null): string { 'free_tier': '免费套餐' } 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}秒` } \ No newline at end of file diff --git a/frontend/src/views/admin/CacheMonitoring.vue b/frontend/src/views/admin/CacheMonitoring.vue index 3d9714e..bc0d9aa 100644 --- a/frontend/src/views/admin/CacheMonitoring.vue +++ b/frontend/src/views/admin/CacheMonitoring.vue @@ -12,10 +12,27 @@ import TableRow from '@/components/ui/table-row.vue' import Input from '@/components/ui/input.vue' import Pagination from '@/components/ui/pagination.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 { useConfirm } from '@/composables/useConfirm' 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(null) const config = ref(null) @@ -27,28 +44,40 @@ const matchedUserId = ref(null) const clearingRowAffinityKey = ref(null) const currentPage = ref(1) const pageSize = ref(20) +const currentTime = ref(Math.floor(Date.now() / 1000)) + const { success: showSuccess, error: showError, info: showInfo } = useToast() const { confirm: showConfirm } = useConfirm() -const currentTime = ref(Math.floor(Date.now() / 1000)) let searchDebounceTimer: ReturnType | null = null let skipNextKeywordWatch = false let countdownTimer: ReturnType | null = null -// 计算分页后的数据 +// ==================== TTL 分析 (使用 composable) ==================== + +const { + ttlAnalysis, + hitAnalysis, + ttlAnalysisLoading, + analysisHours, + expandedUserId, + userTimelineData, + userTimelineLoading, + userTimelineChartData, + toggleUserExpand, + refreshAnalysis +} = useTTLAnalysis() + +// ==================== 计算属性 ==================== + const paginatedAffinityList = computed(() => { const start = (currentPage.value - 1) * pageSize.value const end = start + pageSize.value return affinityList.value.slice(start, end) }) -// 页码变化处理 -function handlePageChange() { - // 分页变化时滚动到顶部 - window.scrollTo({ top: 0, behavior: 'smooth' }) -} +// ==================== 缓存统计方法 ==================== -// 获取缓存统计 async function fetchCacheStats() { loading.value = true try { @@ -61,7 +90,6 @@ async function fetchCacheStats() { } } -// 获取缓存配置 async function fetchCacheConfig() { try { config.value = await cacheApi.getConfig() @@ -70,7 +98,6 @@ async function fetchCacheConfig() { } } -// 获取缓存亲和性列表 async function fetchAffinityList(keyword?: string) { listLoading.value = true try { @@ -107,17 +134,14 @@ async function resetAffinitySearch() { await fetchAffinityList() } -// 清除缓存(按 affinity_key 或用户标识符) async function clearUserCache(identifier: string, displayName?: string) { const target = identifier?.trim() - if (!target) { showError('无法识别标识符') return } const label = displayName || target - const confirmed = await showConfirm({ title: '确认清除', message: `确定要清除 ${label} 的缓存吗?`, @@ -125,12 +149,9 @@ async function clearUserCache(identifier: string, displayName?: string) { variant: 'destructive' }) - if (!confirmed) { - return - } + if (!confirmed) return clearingRowAffinityKey.value = target - try { await cacheApi.clearUserCache(target) showSuccess('清除成功') @@ -144,7 +165,6 @@ async function clearUserCache(identifier: string, displayName?: string) { } } -// 清除所有缓存 async function clearAllCache() { const firstConfirm = await showConfirm({ title: '危险操作', @@ -152,10 +172,7 @@ async function clearAllCache() { confirmText: '继续', variant: 'destructive' }) - - if (!firstConfirm) { - return - } + if (!firstConfirm) return const secondConfirm = await showConfirm({ title: '再次确认', @@ -163,10 +180,7 @@ async function clearAllCache() { confirmText: '确认清除', variant: 'destructive' }) - - if (!secondConfirm) { - return - } + if (!secondConfirm) return try { 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) - const seconds = Math.floor(remaining % 60) - return `${minutes}分${seconds}秒` +function getRemainingTime(expireAt?: number): string { + return formatRemainingTime(expireAt, currentTime.value) } -// 启动倒计时定时器 -function startCountdown() { - if (countdownTimer) { - clearInterval(countdownTimer) +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() { + if (countdownTimer) clearInterval(countdownTimer) countdownTimer = setInterval(() => { currentTime.value = Math.floor(Date.now() / 1000) - // 过滤掉已过期的项目 const beforeCount = affinityList.value.length - affinityList.value = affinityList.value.filter(item => { - return item.expire_at && item.expire_at > currentTime.value - }) + affinityList.value = affinityList.value.filter( + item => item.expire_at && item.expire_at > currentTime.value + ) - // 如果有项目被移除,显示提示 if (beforeCount > affinityList.value.length) { const removedCount = beforeCount - affinityList.value.length showInfo(`${removedCount} 个缓存已自动过期移除`) @@ -213,7 +233,6 @@ function startCountdown() { }, 1000) } -// 停止倒计时定时器 function stopCountdown() { if (countdownTimer) { clearInterval(countdownTimer) @@ -221,15 +240,25 @@ function stopCountdown() { } } +// ==================== 刷新所有数据 ==================== + +async function refreshData() { + await Promise.all([ + fetchCacheStats(), + fetchCacheConfig(), + fetchAffinityList() + ]) +} + +// ==================== 生命周期 ==================== + watch(tableKeyword, (value) => { if (skipNextKeywordWatch) { skipNextKeywordWatch = false return } - if (searchDebounceTimer) { - clearTimeout(searchDebounceTimer) - } + if (searchDebounceTimer) clearTimeout(searchDebounceTimer) const keyword = value.trim() searchDebounceTimer = setTimeout(() => { @@ -243,21 +272,11 @@ onMounted(() => { fetchCacheConfig() fetchAffinityList() startCountdown() + refreshAnalysis() }) -// 刷新所有数据 -async function refreshData() { - await Promise.all([ - fetchCacheStats(), - fetchCacheConfig(), - fetchAffinityList() - ]) -} - onBeforeUnmount(() => { - if (searchDebounceTimer) { - clearTimeout(searchDebounceTimer) - } + if (searchDebounceTimer) clearTimeout(searchDebounceTimer) stopCountdown() }) @@ -272,31 +291,18 @@ onBeforeUnmount(() => {

- +
- -
命中率
-
- {{ stats ? (stats.affinity_stats.cache_hit_rate * 100).toFixed(1) : '0.0' }}% -
-
- {{ stats?.affinity_stats?.cache_hits || 0 }} / {{ (stats?.affinity_stats?.cache_hits || 0) + (stats?.affinity_stats?.cache_misses || 0) }} -
-
- - - -
活跃缓存
+
活跃亲和性
- {{ stats?.affinity_stats?.total_affinities || 0 }} + {{ stats?.affinity_stats?.active_affinities || 0 }}
TTL {{ config?.cache_ttl_seconds || 300 }}s
-
Provider 切换
@@ -307,7 +313,16 @@ onBeforeUnmount(() => {
- + +
缓存失效
+
+ {{ stats?.affinity_stats?.cache_invalidations || 0 }} +
+
+ 因 Provider 不可用 +
+
+
预留比例 @@ -322,14 +337,13 @@ onBeforeUnmount(() => {
- 失效 {{ stats?.affinity_stats?.cache_invalidations || 0 }} + 当前 {{ stats ? (stats.cache_reservation_ratio * 100).toFixed(0) : '-' }}%
-
@@ -365,8 +379,8 @@ onBeforeUnmount(() => { - 用户 - Key + 用户 + Key Provider 模型 API 格式 / Key @@ -380,12 +394,12 @@ onBeforeUnmount(() => {
独立 - {{ item.username || '未知' }} + {{ item.username || '未知' }}
- {{ item.user_api_key_name || '未命名' }} + {{ item.user_api_key_name || '未命名' }} {{ item.rate_multiplier }}x
{{ item.user_api_key_prefix || '---' }}
@@ -439,5 +453,157 @@ onBeforeUnmount(() => { @update:page-size="pageSize = $event" /> + + + +
+
+
+ +

TTL 分析

+ 分析用户请求间隔,推荐合适的缓存 TTL +
+
+ +
+
+
+ + +
+
+
+
请求命中率
+
{{ hitAnalysis.request_cache_hit_rate }}%
+
{{ formatNumber(hitAnalysis.requests_with_cache_hit) }} / {{ formatNumber(hitAnalysis.total_requests) }} 请求
+
+
+
Token 命中率
+
{{ hitAnalysis.token_cache_hit_rate }}%
+
{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens 命中
+
+
+
缓存创建费用
+
{{ formatCost(hitAnalysis.total_cache_creation_cost_usd) }}
+
{{ formatTokens(hitAnalysis.total_cache_creation_tokens) }} tokens
+
+
+
缓存读取费用
+
{{ formatCost(hitAnalysis.total_cache_read_cost_usd) }}
+
{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens
+
+
+
预估节省
+
{{ formatCost(hitAnalysis.estimated_savings_usd) }}
+
+
+
+ + +
+ + + + 用户 + 请求数 + 使用频率 + 推荐 TTL + 说明 + + + + + +
+ + +
+ +

+ 未找到符合条件的用户数据 +

+

+ 尝试增加分析天数或降低最小请求数阈值 +

+
+ + +
+

正在分析用户请求数据...

+
+
diff --git a/frontend/src/views/shared/Usage.vue b/frontend/src/views/shared/Usage.vue index efa5a80..6a0ba19 100644 --- a/frontend/src/views/shared/Usage.vue +++ b/frontend/src/views/shared/Usage.vue @@ -1,10 +1,17 @@