From 9c850c4f8469c94fb470587d73b70bbfe8948dcb Mon Sep 17 00:00:00 2001 From: fawney19 Date: Thu, 11 Dec 2025 17:49:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E4=BB=AA=E8=A1=A8=E6=9D=BF=E5=92=8C=E6=95=A3?= =?UTF-8?q?=E7=82=B9=E5=9B=BE=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/cache.ts | 113 ++++++ .../src/components/charts/ScatterChart.vue | 372 ++++++++++++++++++ frontend/src/views/admin/CacheMonitoring.vue | 332 ++++++++++++---- 3 files changed, 734 insertions(+), 83 deletions(-) create mode 100644 frontend/src/components/charts/ScatterChart.vue 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/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/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 + 说明 + + + + + +
+ + +
+ +

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

+

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

+
+ + +
+

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

+
+