From 8f4384d6f2b980abdec9f668f07360b4e50d6695 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 13 Apr 2026 20:45:34 +0800 Subject: [PATCH] feat: global time filter and local tab controls in quota monitor --- web/src/views/QuotaMonitor.vue | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 179 insertions(+), 22 deletions(-) diff --git a/web/src/views/QuotaMonitor.vue b/web/src/views/QuotaMonitor.vue index 8b0a8cd..ee6b53c 100644 --- a/web/src/views/QuotaMonitor.vue +++ b/web/src/views/QuotaMonitor.vue @@ -5,6 +5,15 @@
@@ -83,7 +92,17 @@
-

各 Agent 计算力消耗排行榜

+
+

各 Agent 计算力消耗排行榜

+
+ + +
+
@@ -108,10 +127,13 @@

任务级成本分析

-
- - - +
+ +
@@ -167,7 +189,17 @@
-

最近的大模型调用日志

+
+

最近的大模型调用日志 (明细)

+
+ + +
+
@@ -214,9 +246,21 @@ const loading = ref(true) const rawData = ref([]) const timeScale = ref('day') // hour, day, week, month -const taskSort = ref('cost') // cost, tokens, time const activeTab = ref('task') // 'trend', 'agent', 'task', 'log' +// Global Time Range Filter +const globalTimeRange = ref('7d') + +// Tab filters & sorters +const searchAgent = ref('') +const sortAgent = ref('tokens') + +const searchTask = ref('') +const taskSort = ref('cost') + +const searchLog = ref('') +const sortLog = ref('time') + onMounted(async () => { try { const res = await fetch('/api/agent/quota') @@ -231,16 +275,27 @@ onMounted(async () => { } }) -// Overview stats -const totalCost = computed(() => rawData.value.reduce((s, d) => s + (d.costUsd || 0), 0)) -const totalInputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.input || 0), 0)) -const totalOutputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.output || 0), 0)) -const totalCalls = computed(() => rawData.value.length) +const filteredData = computed(() => { + if (globalTimeRange.value === 'all') return rawData.value + const now = Date.now() + let cutoff = 0 + if (globalTimeRange.value === '1h') cutoff = now - 3600 * 1000 + else if (globalTimeRange.value === '24h') cutoff = now - 24 * 3600 * 1000 + else if (globalTimeRange.value === '7d') cutoff = now - 7 * 24 * 3600 * 1000 + else if (globalTimeRange.value === '30d') cutoff = now - 30 * 24 * 3600 * 1000 + return rawData.value.filter(d => new Date(d.timestamp).getTime() >= cutoff) +}) + +// Overview stats (derived from filteredData) +const totalCost = computed(() => filteredData.value.reduce((s, d) => s + (d.costUsd || 0), 0)) +const totalInputTokens = computed(() => filteredData.value.reduce((s, d) => s + (d.usage?.input || 0), 0)) +const totalOutputTokens = computed(() => filteredData.value.reduce((s, d) => s + (d.usage?.output || 0), 0)) +const totalCalls = computed(() => filteredData.value.length) // Time Breakdown const timeStats = computed(() => { const map = {} - rawData.value.forEach(d => { + filteredData.value.forEach(d => { const date = new Date(d.timestamp) let label = '' if (timeScale.value === 'hour') { @@ -277,7 +332,7 @@ const maxTimeCalls = computed(() => { // Agent Breakdown const agentStats = computed(() => { const map = {} - rawData.value.forEach(d => { + filteredData.value.forEach(d => { const id = d.agentId || 'unknown' if (!map[id]) map[id] = { id, cost: 0, input: 0, output: 0, tokens: 0, calls: 0, model: d.model || 'claude-3-5-*' } map[id].cost += (d.costUsd || 0) @@ -287,7 +342,18 @@ const agentStats = computed(() => { map[id].calls += 1 if (d.model && d.model !== 'unknown') map[id].model = d.model }) - return Object.values(map).sort((a,b) => b.tokens - a.tokens) + + let result = Object.values(map) + if (searchAgent.value) { + const q = searchAgent.value.toLowerCase() + result = result.filter(a => a.id.toLowerCase().includes(q) || a.model.toLowerCase().includes(q)) + } + + if (sortAgent.value === 'calls') result.sort((a,b) => b.calls - a.calls) + else if (sortAgent.value === 'cost') result.sort((a,b) => b.cost - a.cost) + else result.sort((a,b) => b.tokens - a.tokens) + + return result }) const maxAgentTokens = computed(() => { if (agentStats.value.length === 0) return 1 @@ -297,14 +363,13 @@ const maxAgentTokens = computed(() => { // Task-level Breakdown const taskStats = computed(() => { const map = {} - rawData.value.forEach(d => { - // 从 convId 中提取 taskId + filteredData.value.forEach(d => { const convId = d.convId || '' let taskId = null const taskMatch = convId.match(/(?:task|callback)-?(T-[a-z0-9]+)/i) if (taskMatch) taskId = taskMatch[1] else if (convId.startsWith('tpl-train-')) taskId = convId - else return // 跳过非任务类型的记录(如 conv-xxx 用户对话) + else return if (!map[taskId]) map[taskId] = { taskId, cost: 0, input: 0, output: 0, calls: 0, duration: 0, agentSet: new Set(), details: [], expanded: false, firstTime: d.timestamp } map[taskId].cost += (d.costUsd || 0) @@ -317,15 +382,36 @@ const taskStats = computed(() => { if (d.timestamp < map[taskId].firstTime) map[taskId].firstTime = d.timestamp }) - const arr = Object.values(map).map(t => ({ ...t, agents: [...t.agentSet] })) + let arr = Object.values(map).map(t => ({ ...t, agents: [...t.agentSet] })) + + if (searchTask.value) { + const q = searchTask.value.toLowerCase() + arr = arr.filter(t => t.taskId.toLowerCase().includes(q) || t.agents.some(a => a.toLowerCase().includes(q))) + } + if (taskSort.value === 'cost') arr.sort((a, b) => b.cost - a.cost) else if (taskSort.value === 'tokens') arr.sort((a, b) => (b.input + b.output) - (a.input + a.output)) else arr.sort((a, b) => new Date(b.firstTime) - new Date(a.firstTime)) - return arr.slice(0, 50) + return arr.slice(0, 100) // 提升显示上限 }) // Recent Logs table -const recentLogs = computed(() => rawData.value.slice(0, 50)) +const recentLogs = computed(() => { + let list = filteredData.value + if (searchLog.value) { + const q = searchLog.value.toLowerCase() + list = list.filter(d => (d.agentId && d.agentId.toLowerCase().includes(q)) || + (d.model && d.model.toLowerCase().includes(q)) || + (d.convId && d.convId.toLowerCase().includes(q))) + } + + let sorted = [...list] + if (sortLog.value === 'cost') sorted.sort((a,b) => (b.costUsd||0) - (a.costUsd||0)) + else if (sortLog.value === 'duration') sorted.sort((a,b) => (b.durationMs||0) - (a.durationMs||0)) + else sorted.sort((a,b) => new Date(b.timestamp) - new Date(a.timestamp)) + + return sorted.slice(0, 200) // Increase limits +}) function formatTime(iso) { const d = new Date(iso) @@ -351,7 +437,7 @@ function formatTime(iso) { } .page-header { - margin-bottom: 32px; + margin-bottom: 24px; } .page-header h1 { font-size: 28px; @@ -362,6 +448,41 @@ function formatTime(iso) { .subtitle { color: var(--text-secondary); font-size: 14px; + margin-bottom: 20px; +} +.global-time-filter { + display: flex; + align-items: center; + gap: 10px; + background: var(--bg-surface); + padding: 8px 16px; + border-radius: 8px; + border: 1px solid var(--border-color); + width: fit-content; +} +.filter-label { + font-size: 13px; + color: var(--text-secondary); + margin-right: 8px; +} +.global-time-filter button { + background: transparent; + border: 1px solid transparent; + font-size: 13px; + padding: 6px 16px; + border-radius: 6px; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; +} +.global-time-filter button:hover { + background: var(--bg-secondary); +} +.global-time-filter button.active { + background: var(--accent-primary); + color: #fff; + border-color: rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(35, 131, 226, 0.2); } .overview-grid { @@ -453,6 +574,42 @@ function formatTime(iso) { gap: 8px; } +/* Tab Controls for Search and Sort */ +.tab-controls { + display: flex; + gap: 12px; + align-items: center; +} +.search-input { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + width: 220px; + outline: none; + transition: border-color 0.2s; +} +.search-input:focus { + border-color: var(--accent-primary); +} +.sort-select { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 6px 30px 6px 12px; + border-radius: 6px; + font-size: 13px; + outline: none; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 14px; +} + /* 按时间过滤 */ .time-filters { display: flex; -- libgit2 0.21.4