Commit 8f4384d6f2b980abdec9f668f07360b4e50d6695

Authored by yibo
1 parent ace0869d

feat: global time filter and local tab controls in quota monitor

1. Added global time range selector (1h/24h/7d/30d/all) that uniformly restricts the dataset for the whole dashboard.
2. Added 'Search' inputs and 'Sort' dropdowns for Agent Ranking, Task Breakdown, and Recent Logs tabs.
3. Decoupled global filteredData state from original rawData to allow smooth, consistent indicator rendering.
Showing 1 changed file with 179 additions and 22 deletions
web/src/views/QuotaMonitor.vue
@@ -5,6 +5,15 @@ @@ -5,6 +5,15 @@
5 <header class="page-header"> 5 <header class="page-header">
6 <h1>📊 算力消耗与 Token 监控 (Quota Monitor)</h1> 6 <h1>📊 算力消耗与 Token 监控 (Quota Monitor)</h1>
7 <p class="subtitle">实时监控企业级 Multi-Agent 系统的大模型 API 成本与耗时</p> 7 <p class="subtitle">实时监控企业级 Multi-Agent 系统的大模型 API 成本与耗时</p>
  8 +
  9 + <div class="global-time-filter">
  10 + <span class="filter-label">🕒 全局数据范围:</span>
  11 + <button :class="{active: globalTimeRange === '1h'}" @click="globalTimeRange = '1h'">最近 1 小时</button>
  12 + <button :class="{active: globalTimeRange === '24h'}" @click="globalTimeRange = '24h'">最近 24 小时</button>
  13 + <button :class="{active: globalTimeRange === '7d'}" @click="globalTimeRange = '7d'">最近 7 天</button>
  14 + <button :class="{active: globalTimeRange === '30d'}" @click="globalTimeRange = '30d'">最近 30 天</button>
  15 + <button :class="{active: globalTimeRange === 'all'}" @click="globalTimeRange = 'all'">全部</button>
  16 + </div>
8 </header> 17 </header>
9 18
10 <main class="dashboard-content" v-if="!loading"> 19 <main class="dashboard-content" v-if="!loading">
@@ -83,7 +92,17 @@ @@ -83,7 +92,17 @@
83 92
84 <!-- 按照 Agent 的消耗排行榜 --> 93 <!-- 按照 Agent 的消耗排行榜 -->
85 <section v-if="activeTab === 'agent'" class="chart-card flex-double"> 94 <section v-if="activeTab === 'agent'" class="chart-card flex-double">
86 - <h3>各 Agent 计算力消耗排行榜</h3> 95 + <div class="card-header">
  96 + <h3>各 Agent 计算力消耗排行榜</h3>
  97 + <div class="tab-controls">
  98 + <input type="text" v-model="searchAgent" placeholder="🔍 搜索 Agent 或 模型..." class="search-input" />
  99 + <select v-model="sortAgent" class="sort-select">
  100 + <option value="tokens">⬇️ 按消耗 Tokens</option>
  101 + <option value="calls">⬇️ 按调用次数</option>
  102 + <option value="cost">⬇️ 按预估花费</option>
  103 + </select>
  104 + </div>
  105 + </div>
87 <div class="bar-chart"> 106 <div class="bar-chart">
88 <div v-for="agent in agentStats" :key="agent.id" class="bar-row"> 107 <div v-for="agent in agentStats" :key="agent.id" class="bar-row">
89 <div class="bar-label"> 108 <div class="bar-label">
@@ -108,10 +127,13 @@ @@ -108,10 +127,13 @@
108 <section v-if="activeTab === 'task'" class="chart-card"> 127 <section v-if="activeTab === 'task'" class="chart-card">
109 <div class="card-header"> 128 <div class="card-header">
110 <h3>任务级成本分析</h3> 129 <h3>任务级成本分析</h3>
111 - <div class="time-filters">  
112 - <button :class="{active: taskSort === 'cost'}" @click="taskSort = 'cost'">按花费</button>  
113 - <button :class="{active: taskSort === 'tokens'}" @click="taskSort = 'tokens'">按 Tokens</button>  
114 - <button :class="{active: taskSort === 'time'}" @click="taskSort = 'time'">按时间</button> 130 + <div class="tab-controls">
  131 + <input type="text" v-model="searchTask" placeholder="🔍 搜索 任务ID 或 Agent..." class="search-input" />
  132 + <select v-model="taskSort" class="sort-select">
  133 + <option value="cost">⬇️ 按花费</option>
  134 + <option value="tokens">⬇️ 按 Tokens</option>
  135 + <option value="time">⬇️ 按首条调用时间</option>
  136 + </select>
115 </div> 137 </div>
116 </div> 138 </div>
117 <div class="task-table-wrapper"> 139 <div class="task-table-wrapper">
@@ -167,7 +189,17 @@ @@ -167,7 +189,17 @@
167 189
168 <!-- 详细调用日志 --> 190 <!-- 详细调用日志 -->
169 <section v-if="activeTab === 'log'" class="chart-card"> 191 <section v-if="activeTab === 'log'" class="chart-card">
170 - <h3>最近的大模型调用日志</h3> 192 + <div class="card-header">
  193 + <h3>最近的大模型调用日志 (明细)</h3>
  194 + <div class="tab-controls">
  195 + <input type="text" v-model="searchLog" placeholder="🔍 搜索 Agent 或 模型..." class="search-input" />
  196 + <select v-model="sortLog" class="sort-select">
  197 + <option value="time">⬇️ 按时间</option>
  198 + <option value="cost">⬇️ 按花费</option>
  199 + <option value="duration">⬇️ 按耗时</option>
  200 + </select>
  201 + </div>
  202 + </div>
171 <div class="log-table-wrapper"> 203 <div class="log-table-wrapper">
172 <table class="log-table"> 204 <table class="log-table">
173 <thead> 205 <thead>
@@ -214,9 +246,21 @@ const loading = ref(true) @@ -214,9 +246,21 @@ const loading = ref(true)
214 const rawData = ref([]) 246 const rawData = ref([])
215 247
216 const timeScale = ref('day') // hour, day, week, month 248 const timeScale = ref('day') // hour, day, week, month
217 -const taskSort = ref('cost') // cost, tokens, time  
218 const activeTab = ref('task') // 'trend', 'agent', 'task', 'log' 249 const activeTab = ref('task') // 'trend', 'agent', 'task', 'log'
219 250
  251 +// Global Time Range Filter
  252 +const globalTimeRange = ref('7d')
  253 +
  254 +// Tab filters & sorters
  255 +const searchAgent = ref('')
  256 +const sortAgent = ref('tokens')
  257 +
  258 +const searchTask = ref('')
  259 +const taskSort = ref('cost')
  260 +
  261 +const searchLog = ref('')
  262 +const sortLog = ref('time')
  263 +
220 onMounted(async () => { 264 onMounted(async () => {
221 try { 265 try {
222 const res = await fetch('/api/agent/quota') 266 const res = await fetch('/api/agent/quota')
@@ -231,16 +275,27 @@ onMounted(async () =&gt; { @@ -231,16 +275,27 @@ onMounted(async () =&gt; {
231 } 275 }
232 }) 276 })
233 277
234 -// Overview stats  
235 -const totalCost = computed(() => rawData.value.reduce((s, d) => s + (d.costUsd || 0), 0))  
236 -const totalInputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.input || 0), 0))  
237 -const totalOutputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.output || 0), 0))  
238 -const totalCalls = computed(() => rawData.value.length) 278 +const filteredData = computed(() => {
  279 + if (globalTimeRange.value === 'all') return rawData.value
  280 + const now = Date.now()
  281 + let cutoff = 0
  282 + if (globalTimeRange.value === '1h') cutoff = now - 3600 * 1000
  283 + else if (globalTimeRange.value === '24h') cutoff = now - 24 * 3600 * 1000
  284 + else if (globalTimeRange.value === '7d') cutoff = now - 7 * 24 * 3600 * 1000
  285 + else if (globalTimeRange.value === '30d') cutoff = now - 30 * 24 * 3600 * 1000
  286 + return rawData.value.filter(d => new Date(d.timestamp).getTime() >= cutoff)
  287 +})
  288 +
  289 +// Overview stats (derived from filteredData)
  290 +const totalCost = computed(() => filteredData.value.reduce((s, d) => s + (d.costUsd || 0), 0))
  291 +const totalInputTokens = computed(() => filteredData.value.reduce((s, d) => s + (d.usage?.input || 0), 0))
  292 +const totalOutputTokens = computed(() => filteredData.value.reduce((s, d) => s + (d.usage?.output || 0), 0))
  293 +const totalCalls = computed(() => filteredData.value.length)
239 294
240 // Time Breakdown 295 // Time Breakdown
241 const timeStats = computed(() => { 296 const timeStats = computed(() => {
242 const map = {} 297 const map = {}
243 - rawData.value.forEach(d => { 298 + filteredData.value.forEach(d => {
244 const date = new Date(d.timestamp) 299 const date = new Date(d.timestamp)
245 let label = '' 300 let label = ''
246 if (timeScale.value === 'hour') { 301 if (timeScale.value === 'hour') {
@@ -277,7 +332,7 @@ const maxTimeCalls = computed(() =&gt; { @@ -277,7 +332,7 @@ const maxTimeCalls = computed(() =&gt; {
277 // Agent Breakdown 332 // Agent Breakdown
278 const agentStats = computed(() => { 333 const agentStats = computed(() => {
279 const map = {} 334 const map = {}
280 - rawData.value.forEach(d => { 335 + filteredData.value.forEach(d => {
281 const id = d.agentId || 'unknown' 336 const id = d.agentId || 'unknown'
282 if (!map[id]) map[id] = { id, cost: 0, input: 0, output: 0, tokens: 0, calls: 0, model: d.model || 'claude-3-5-*' } 337 if (!map[id]) map[id] = { id, cost: 0, input: 0, output: 0, tokens: 0, calls: 0, model: d.model || 'claude-3-5-*' }
283 map[id].cost += (d.costUsd || 0) 338 map[id].cost += (d.costUsd || 0)
@@ -287,7 +342,18 @@ const agentStats = computed(() =&gt; { @@ -287,7 +342,18 @@ const agentStats = computed(() =&gt; {
287 map[id].calls += 1 342 map[id].calls += 1
288 if (d.model && d.model !== 'unknown') map[id].model = d.model 343 if (d.model && d.model !== 'unknown') map[id].model = d.model
289 }) 344 })
290 - return Object.values(map).sort((a,b) => b.tokens - a.tokens) 345 +
  346 + let result = Object.values(map)
  347 + if (searchAgent.value) {
  348 + const q = searchAgent.value.toLowerCase()
  349 + result = result.filter(a => a.id.toLowerCase().includes(q) || a.model.toLowerCase().includes(q))
  350 + }
  351 +
  352 + if (sortAgent.value === 'calls') result.sort((a,b) => b.calls - a.calls)
  353 + else if (sortAgent.value === 'cost') result.sort((a,b) => b.cost - a.cost)
  354 + else result.sort((a,b) => b.tokens - a.tokens)
  355 +
  356 + return result
291 }) 357 })
292 const maxAgentTokens = computed(() => { 358 const maxAgentTokens = computed(() => {
293 if (agentStats.value.length === 0) return 1 359 if (agentStats.value.length === 0) return 1
@@ -297,14 +363,13 @@ const maxAgentTokens = computed(() =&gt; { @@ -297,14 +363,13 @@ const maxAgentTokens = computed(() =&gt; {
297 // Task-level Breakdown 363 // Task-level Breakdown
298 const taskStats = computed(() => { 364 const taskStats = computed(() => {
299 const map = {} 365 const map = {}
300 - rawData.value.forEach(d => {  
301 - // 从 convId 中提取 taskId 366 + filteredData.value.forEach(d => {
302 const convId = d.convId || '' 367 const convId = d.convId || ''
303 let taskId = null 368 let taskId = null
304 const taskMatch = convId.match(/(?:task|callback)-?(T-[a-z0-9]+)/i) 369 const taskMatch = convId.match(/(?:task|callback)-?(T-[a-z0-9]+)/i)
305 if (taskMatch) taskId = taskMatch[1] 370 if (taskMatch) taskId = taskMatch[1]
306 else if (convId.startsWith('tpl-train-')) taskId = convId 371 else if (convId.startsWith('tpl-train-')) taskId = convId
307 - else return // 跳过非任务类型的记录(如 conv-xxx 用户对话) 372 + else return
308 373
309 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 } 374 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 }
310 map[taskId].cost += (d.costUsd || 0) 375 map[taskId].cost += (d.costUsd || 0)
@@ -317,15 +382,36 @@ const taskStats = computed(() =&gt; { @@ -317,15 +382,36 @@ const taskStats = computed(() =&gt; {
317 if (d.timestamp < map[taskId].firstTime) map[taskId].firstTime = d.timestamp 382 if (d.timestamp < map[taskId].firstTime) map[taskId].firstTime = d.timestamp
318 }) 383 })
319 384
320 - const arr = Object.values(map).map(t => ({ ...t, agents: [...t.agentSet] })) 385 + let arr = Object.values(map).map(t => ({ ...t, agents: [...t.agentSet] }))
  386 +
  387 + if (searchTask.value) {
  388 + const q = searchTask.value.toLowerCase()
  389 + arr = arr.filter(t => t.taskId.toLowerCase().includes(q) || t.agents.some(a => a.toLowerCase().includes(q)))
  390 + }
  391 +
321 if (taskSort.value === 'cost') arr.sort((a, b) => b.cost - a.cost) 392 if (taskSort.value === 'cost') arr.sort((a, b) => b.cost - a.cost)
322 else if (taskSort.value === 'tokens') arr.sort((a, b) => (b.input + b.output) - (a.input + a.output)) 393 else if (taskSort.value === 'tokens') arr.sort((a, b) => (b.input + b.output) - (a.input + a.output))
323 else arr.sort((a, b) => new Date(b.firstTime) - new Date(a.firstTime)) 394 else arr.sort((a, b) => new Date(b.firstTime) - new Date(a.firstTime))
324 - return arr.slice(0, 50) 395 + return arr.slice(0, 100) // 提升显示上限
325 }) 396 })
326 397
327 // Recent Logs table 398 // Recent Logs table
328 -const recentLogs = computed(() => rawData.value.slice(0, 50)) 399 +const recentLogs = computed(() => {
  400 + let list = filteredData.value
  401 + if (searchLog.value) {
  402 + const q = searchLog.value.toLowerCase()
  403 + list = list.filter(d => (d.agentId && d.agentId.toLowerCase().includes(q)) ||
  404 + (d.model && d.model.toLowerCase().includes(q)) ||
  405 + (d.convId && d.convId.toLowerCase().includes(q)))
  406 + }
  407 +
  408 + let sorted = [...list]
  409 + if (sortLog.value === 'cost') sorted.sort((a,b) => (b.costUsd||0) - (a.costUsd||0))
  410 + else if (sortLog.value === 'duration') sorted.sort((a,b) => (b.durationMs||0) - (a.durationMs||0))
  411 + else sorted.sort((a,b) => new Date(b.timestamp) - new Date(a.timestamp))
  412 +
  413 + return sorted.slice(0, 200) // Increase limits
  414 +})
329 415
330 function formatTime(iso) { 416 function formatTime(iso) {
331 const d = new Date(iso) 417 const d = new Date(iso)
@@ -351,7 +437,7 @@ function formatTime(iso) { @@ -351,7 +437,7 @@ function formatTime(iso) {
351 } 437 }
352 438
353 .page-header { 439 .page-header {
354 - margin-bottom: 32px; 440 + margin-bottom: 24px;
355 } 441 }
356 .page-header h1 { 442 .page-header h1 {
357 font-size: 28px; 443 font-size: 28px;
@@ -362,6 +448,41 @@ function formatTime(iso) { @@ -362,6 +448,41 @@ function formatTime(iso) {
362 .subtitle { 448 .subtitle {
363 color: var(--text-secondary); 449 color: var(--text-secondary);
364 font-size: 14px; 450 font-size: 14px;
  451 + margin-bottom: 20px;
  452 +}
  453 +.global-time-filter {
  454 + display: flex;
  455 + align-items: center;
  456 + gap: 10px;
  457 + background: var(--bg-surface);
  458 + padding: 8px 16px;
  459 + border-radius: 8px;
  460 + border: 1px solid var(--border-color);
  461 + width: fit-content;
  462 +}
  463 +.filter-label {
  464 + font-size: 13px;
  465 + color: var(--text-secondary);
  466 + margin-right: 8px;
  467 +}
  468 +.global-time-filter button {
  469 + background: transparent;
  470 + border: 1px solid transparent;
  471 + font-size: 13px;
  472 + padding: 6px 16px;
  473 + border-radius: 6px;
  474 + color: var(--text-primary);
  475 + cursor: pointer;
  476 + transition: all 0.2s;
  477 +}
  478 +.global-time-filter button:hover {
  479 + background: var(--bg-secondary);
  480 +}
  481 +.global-time-filter button.active {
  482 + background: var(--accent-primary);
  483 + color: #fff;
  484 + border-color: rgba(0,0,0,0.1);
  485 + box-shadow: 0 2px 4px rgba(35, 131, 226, 0.2);
365 } 486 }
366 487
367 .overview-grid { 488 .overview-grid {
@@ -453,6 +574,42 @@ function formatTime(iso) { @@ -453,6 +574,42 @@ function formatTime(iso) {
453 gap: 8px; 574 gap: 8px;
454 } 575 }
455 576
  577 +/* Tab Controls for Search and Sort */
  578 +.tab-controls {
  579 + display: flex;
  580 + gap: 12px;
  581 + align-items: center;
  582 +}
  583 +.search-input {
  584 + background: var(--bg-secondary);
  585 + border: 1px solid var(--border-color);
  586 + color: var(--text-primary);
  587 + padding: 6px 12px;
  588 + border-radius: 6px;
  589 + font-size: 13px;
  590 + width: 220px;
  591 + outline: none;
  592 + transition: border-color 0.2s;
  593 +}
  594 +.search-input:focus {
  595 + border-color: var(--accent-primary);
  596 +}
  597 +.sort-select {
  598 + background: var(--bg-secondary);
  599 + border: 1px solid var(--border-color);
  600 + color: var(--text-primary);
  601 + padding: 6px 30px 6px 12px;
  602 + border-radius: 6px;
  603 + font-size: 13px;
  604 + outline: none;
  605 + cursor: pointer;
  606 + appearance: none;
  607 + 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");
  608 + background-repeat: no-repeat;
  609 + background-position: right 8px center;
  610 + background-size: 14px;
  611 +}
  612 +
456 /* 按时间过滤 */ 613 /* 按时间过滤 */
457 .time-filters { 614 .time-filters {
458 display: flex; 615 display: flex;