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 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 () =&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 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(() =&gt; {
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(() =&gt; {
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(() =&gt; {
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(() =&gt; {
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;
... ...