Commit 8f4384d6f2b980abdec9f668f07360b4e50d6695
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 | 5 | <header class="page-header"> |
| 6 | 6 | <h1>📊 算力消耗与 Token 监控 (Quota Monitor)</h1> |
| 7 | 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 | 17 | </header> |
| 9 | 18 | |
| 10 | 19 | <main class="dashboard-content" v-if="!loading"> |
| ... | ... | @@ -83,7 +92,17 @@ |
| 83 | 92 | |
| 84 | 93 | <!-- 按照 Agent 的消耗排行榜 --> |
| 85 | 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 | 106 | <div class="bar-chart"> |
| 88 | 107 | <div v-for="agent in agentStats" :key="agent.id" class="bar-row"> |
| 89 | 108 | <div class="bar-label"> |
| ... | ... | @@ -108,10 +127,13 @@ |
| 108 | 127 | <section v-if="activeTab === 'task'" class="chart-card"> |
| 109 | 128 | <div class="card-header"> |
| 110 | 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 | 137 | </div> |
| 116 | 138 | </div> |
| 117 | 139 | <div class="task-table-wrapper"> |
| ... | ... | @@ -167,7 +189,17 @@ |
| 167 | 189 | |
| 168 | 190 | <!-- 详细调用日志 --> |
| 169 | 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 | 203 | <div class="log-table-wrapper"> |
| 172 | 204 | <table class="log-table"> |
| 173 | 205 | <thead> |
| ... | ... | @@ -214,9 +246,21 @@ const loading = ref(true) |
| 214 | 246 | const rawData = ref([]) |
| 215 | 247 | |
| 216 | 248 | const timeScale = ref('day') // hour, day, week, month |
| 217 | -const taskSort = ref('cost') // cost, tokens, time | |
| 218 | 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 | 264 | onMounted(async () => { |
| 221 | 265 | try { |
| 222 | 266 | const res = await fetch('/api/agent/quota') |
| ... | ... | @@ -231,16 +275,27 @@ onMounted(async () => { |
| 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 | 295 | // Time Breakdown |
| 241 | 296 | const timeStats = computed(() => { |
| 242 | 297 | const map = {} |
| 243 | - rawData.value.forEach(d => { | |
| 298 | + filteredData.value.forEach(d => { | |
| 244 | 299 | const date = new Date(d.timestamp) |
| 245 | 300 | let label = '' |
| 246 | 301 | if (timeScale.value === 'hour') { |
| ... | ... | @@ -277,7 +332,7 @@ const maxTimeCalls = computed(() => { |
| 277 | 332 | // Agent Breakdown |
| 278 | 333 | const agentStats = computed(() => { |
| 279 | 334 | const map = {} |
| 280 | - rawData.value.forEach(d => { | |
| 335 | + filteredData.value.forEach(d => { | |
| 281 | 336 | const id = d.agentId || 'unknown' |
| 282 | 337 | if (!map[id]) map[id] = { id, cost: 0, input: 0, output: 0, tokens: 0, calls: 0, model: d.model || 'claude-3-5-*' } |
| 283 | 338 | map[id].cost += (d.costUsd || 0) |
| ... | ... | @@ -287,7 +342,18 @@ const agentStats = computed(() => { |
| 287 | 342 | map[id].calls += 1 |
| 288 | 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 | 358 | const maxAgentTokens = computed(() => { |
| 293 | 359 | if (agentStats.value.length === 0) return 1 |
| ... | ... | @@ -297,14 +363,13 @@ const maxAgentTokens = computed(() => { |
| 297 | 363 | // Task-level Breakdown |
| 298 | 364 | const taskStats = computed(() => { |
| 299 | 365 | const map = {} |
| 300 | - rawData.value.forEach(d => { | |
| 301 | - // 从 convId 中提取 taskId | |
| 366 | + filteredData.value.forEach(d => { | |
| 302 | 367 | const convId = d.convId || '' |
| 303 | 368 | let taskId = null |
| 304 | 369 | const taskMatch = convId.match(/(?:task|callback)-?(T-[a-z0-9]+)/i) |
| 305 | 370 | if (taskMatch) taskId = taskMatch[1] |
| 306 | 371 | else if (convId.startsWith('tpl-train-')) taskId = convId |
| 307 | - else return // 跳过非任务类型的记录(如 conv-xxx 用户对话) | |
| 372 | + else return | |
| 308 | 373 | |
| 309 | 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 | 375 | map[taskId].cost += (d.costUsd || 0) |
| ... | ... | @@ -317,15 +382,36 @@ const taskStats = computed(() => { |
| 317 | 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 | 392 | if (taskSort.value === 'cost') arr.sort((a, b) => b.cost - a.cost) |
| 322 | 393 | else if (taskSort.value === 'tokens') arr.sort((a, b) => (b.input + b.output) - (a.input + a.output)) |
| 323 | 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 | 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 | 416 | function formatTime(iso) { |
| 331 | 417 | const d = new Date(iso) |
| ... | ... | @@ -351,7 +437,7 @@ function formatTime(iso) { |
| 351 | 437 | } |
| 352 | 438 | |
| 353 | 439 | .page-header { |
| 354 | - margin-bottom: 32px; | |
| 440 | + margin-bottom: 24px; | |
| 355 | 441 | } |
| 356 | 442 | .page-header h1 { |
| 357 | 443 | font-size: 28px; |
| ... | ... | @@ -362,6 +448,41 @@ function formatTime(iso) { |
| 362 | 448 | .subtitle { |
| 363 | 449 | color: var(--text-secondary); |
| 364 | 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 | 488 | .overview-grid { |
| ... | ... | @@ -453,6 +574,42 @@ function formatTime(iso) { |
| 453 | 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 | 614 | .time-filters { |
| 458 | 615 | display: flex; | ... | ... |