QuotaMonitor.vue 9.32 KB
<template>
  <div class="quota-page">
    <AppNav />
    <div class="quota-layout">
      <header class="page-header">
        <h1>📊 算力消耗与 Token 监控 (Quota Monitor)</h1>
        <p class="subtitle">实时监控企业级 Multi-Agent 系统的大模型 API 成本与耗时</p>
      </header>

      <main class="dashboard-content" v-if="!loading">
        <!-- 核心指标 -->
        <section class="overview-grid">
          <div class="stat-card sum-card">
            <h3>总消耗金额 (USD)</h3>
            <div class="stat-value highlight">${{ totalCost.toFixed(4) }}</div>
          </div>
          <div class="stat-card">
            <h3>总输入 Tokens</h3>
            <div class="stat-value">{{ totalInputTokens.toLocaleString() }}</div>
          </div>
          <div class="stat-card">
            <h3>总输出 Tokens</h3>
            <div class="stat-value">{{ totalOutputTokens.toLocaleString() }}</div>
          </div>
          <div class="stat-card">
            <h3>平均单次推理耗时</h3>
            <div class="stat-value">{{ avgDuration.toFixed(1) }}s</div>
          </div>
        </section>

        <div class="charts-row">
          <!-- 按照 Agent 的消耗排行榜 -->
          <section class="chart-card flex-double">
            <h3>🤖 各 Agent 计算力消耗排行榜</h3>
            <div class="bar-chart">
              <div v-for="agent in agentStats" :key="agent.id" class="bar-row">
                <div class="bar-label">
                  <span class="agent-id">{{ agent.id }}</span>
                  <span class="agent-calls">{{ agent.calls }} 次调用</span>
                </div>
                <div class="bar-track-wrapper">
                  <div class="bar-track">
                    <div class="bar-fill" :style="{ width: ((agent.cost / maxAgentCost) * 100) + '%' }"></div>
                  </div>
                  <div class="bar-value">${{ agent.cost.toFixed(4) }}</div>
                </div>
                <div class="bar-subtext">
                  (In: {{ agent.input.toLocaleString() }} / Out: {{ agent.output.toLocaleString() }})
                </div>
              </div>
              <div v-if="agentStats.length === 0" class="empty-state">暂无数据</div>
            </div>
          </section>
        </div>

        <!-- 详细调用日志 -->
        <section class="chart-card">
          <h3>🕒 最近的大模型调用日志</h3>
          <div class="log-table-wrapper">
            <table class="log-table">
              <thead>
                <tr>
                  <th>时间</th>
                  <th>Agent</th>
                  <th>对话 ID</th>
                  <th>耗时</th>
                  <th>输入 Tokens</th>
                  <th>输出 Tokens</th>
                  <th>消耗金额</th>
                </tr>
              </thead>
              <tbody>
                <tr v-for="(log, i) in recentLogs" :key="i">
                  <td class="log-time">{{ formatTime(log.timestamp) }}</td>
                  <td class="log-agent">{{ log.agentId }}</td>
                  <td class="log-conv" :title="log.convId">{{ log.convId.substring(0, 12) }}...</td>
                  <td class="log-duration">{{ (log.durationMs / 1000).toFixed(1) }}s</td>
                  <td class="log-tokens in">+{{ (log.usage?.input || 0).toLocaleString() }}</td>
                  <td class="log-tokens out">+{{ (log.usage?.output || 0).toLocaleString() }}</td>
                  <td class="log-cost">${{ log.costUsd.toFixed(6) }}</td>
                </tr>
              </tbody>
            </table>
            <div v-if="recentLogs.length === 0" class="empty-state">暂无日志</div>
          </div>
        </section>
      </main>

      <div v-else class="loading-state">
        <div class="spinner"></div> 加载监控数据中...
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import AppNav from '../components/AppNav.vue'

const loading = ref(true)
const rawData = ref([])

onMounted(async () => {
  try {
    const res = await fetch('/api/agent/quota')
    if (res.ok) {
      const data = await res.json()
      // Filter out invalid records if any
      rawData.value = data.filter(d => d && d.timestamp).sort((a,b) => new Date(b.timestamp) - new Date(a.timestamp))
    }
  } catch(e) {
    console.error('Failed to load quota', e)
  } finally {
    loading.value = false
  }
})

// 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 avgDuration = computed(() => {
  if (rawData.value.length === 0) return 0
  const sumMs = rawData.value.reduce((s, d) => s + (d.durationMs || 0), 0)
  return (sumMs / rawData.value.length) / 1000
})

// Agent Breakdown
const agentStats = computed(() => {
  const map = {}
  rawData.value.forEach(d => {
    const id = d.agentId || 'unknown'
    if (!map[id]) map[id] = { id, cost: 0, input: 0, output: 0, calls: 0 }
    map[id].cost += (d.costUsd || 0)
    map[id].input += (d.usage?.input || 0)
    map[id].output += (d.usage?.output || 0)
    map[id].calls += 1
  })
  return Object.values(map).sort((a,b) => b.cost - a.cost)
})
const maxAgentCost = computed(() => {
  if (agentStats.value.length === 0) return 1
  return Math.max(...agentStats.value.map(a => a.cost), 0.0001)
})

// Recent Logs table
const recentLogs = computed(() => rawData.value.slice(0, 100))

function formatTime(iso) {
  const d = new Date(iso)
  return d.toLocaleDateString() + ' ' + d.toLocaleTimeString()
}
</script>

<style scoped>
.quota-page {
  height: 100vh;
  display: flex;
  flex-direction: column;
  background: var(--bg-primary);
  color: var(--text-primary);
  overflow-y: auto;
}

.quota-layout {
  max-width: 1200px;
  margin: 0 auto;
  padding: 32px 40px;
  width: 100%;
}

.page-header {
  margin-bottom: 32px;
}
.page-header h1 {
  font-size: 28px;
  font-weight: 700;
  margin-bottom: 8px;
  color: var(--text-primary);
}
.subtitle {
  color: var(--text-secondary);
  font-size: 14px;
}

.overview-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 20px;
  margin-bottom: 24px;
}

.stat-card {
  background: var(--bg-surface);
  border: 1px solid var(--border-color);
  border-radius: 12px;
  padding: 24px;
  display: flex;
  flex-direction: column;
  box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}
.stat-card h3 {
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--text-secondary);
  margin-bottom: 12px;
}
.stat-value {
  font-size: 32px;
  font-weight: 700;
  color: var(--text-primary);
}
.stat-value.highlight {
  color: var(--accent-primary);
}
.sum-card {
  background: linear-gradient(145deg, rgba(82, 168, 255, 0.1) 0%, rgba(35, 131, 226, 0.02) 100%);
  border-color: rgba(82, 168, 255, 0.2);
}

.charts-row {
  display: flex;
  gap: 24px;
  margin-bottom: 24px;
}
.chart-card {
  background: var(--bg-surface);
  border: 1px solid var(--border-color);
  border-radius: 12px;
  padding: 24px;
  flex: 1;
}
.chart-card h3 {
  font-size: 16px;
  margin-bottom: 20px;
  color: var(--text-primary);
  display: flex;
  align-items: center;
  gap: 8px;
}

.bar-chart {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.bar-row {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.bar-label {
  display: flex;
  justify-content: space-between;
  font-size: 13px;
}
.agent-id { font-weight: 600; color: var(--text-primary); }
.agent-calls { color: var(--text-secondary); font-size: 12px; }
.bar-track-wrapper {
  display: flex;
  align-items: center;
  gap: 12px;
}
.bar-track {
  flex: 1;
  height: 8px;
  background: var(--bg-secondary);
  border-radius: 4px;
  overflow: hidden;
}
.bar-fill {
  height: 100%;
  background: var(--accent-primary);
  border-radius: 4px;
  transition: width 0.5s ease-out;
}
.bar-value {
  font-size: 13px;
  font-weight: 600;
  width: 70px;
  text-align: right;
  color: var(--text-primary);
}
.bar-subtext {
  font-size: 11px;
  color: var(--text-faint);
}

.log-table-wrapper {
  overflow-x: auto;
}
.log-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
}
.log-table th {
  text-align: left;
  padding: 12px;
  border-bottom: 1px solid var(--border-color);
  color: var(--text-secondary);
  font-weight: 500;
}
.log-table td {
  padding: 12px;
  border-bottom: 1px solid var(--border-color);
  color: var(--text-primary);
}
.log-time { color: var(--text-secondary) !important; font-variant-numeric: tabular-nums; }
.log-agent { font-weight: 500; }
.log-conv { color: var(--text-faint) !important; font-family: monospace; }
.log-tokens.in { color: #10b981 !important; }
.log-tokens.out { color: #8b5cf6 !important; }
.log-cost { font-weight: 600; color: var(--accent-primary) !important; }

.empty-state {
  padding: 40px;
  text-align: center;
  color: var(--text-faint);
  font-style: italic;
}
.loading-state {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 100px;
  color: var(--text-secondary);
  gap: 12px;
}
.spinner {
  width: 20px; height: 20px;
  border: 2px solid var(--border-color);
  border-top-color: var(--accent-primary);
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>