Spaces:
Sleeping
Sleeping
| <template> | |
| <div class="cost-analytics"> | |
| <!-- Header --> | |
| <div class="page-header"> | |
| <div> | |
| <h1 class="page-title">💰 Kostenanalyse</h1> | |
| <p class="page-subtitle">Übersicht der API-Kosten und Nutzungsstatistiken</p> | |
| </div> | |
| <div class="header-actions"> | |
| <select v-model="selectedPeriod" @change="loadCostData" class="period-select"> | |
| <option value="today">Heute</option> | |
| <option value="week">Diese Woche</option> | |
| <option value="month">Dieser Monat</option> | |
| <option value="all">Alle Zeit</option> | |
| </select> | |
| <button @click="exportData" class="btn btn-secondary"> | |
| 📊 Export CSV | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Loading State --> | |
| <div v-if="loading" class="loading-state"> | |
| <div class="spinner"></div> | |
| <p>Lade Kostendaten...</p> | |
| </div> | |
| <!-- Error State --> | |
| <div v-else-if="error" class="error-state"> | |
| <p>❌ {{ error }}</p> | |
| <button @click="loadCostData" class="btn btn-primary">Erneut versuchen</button> | |
| </div> | |
| <!-- Content --> | |
| <template v-else> | |
| <!-- Summary Cards --> | |
| <div class="summary-grid"> | |
| <div class="summary-card total-cost"> | |
| <div class="card-icon">💵</div> | |
| <div class="card-content"> | |
| <h3>Gesamtkosten</h3> | |
| <div class="card-value">${{ formatCurrency(costSummary.total_cost) }}</div> | |
| <span class="card-period">{{ selectedPeriod }}</span> | |
| </div> | |
| </div> | |
| <div class="summary-card requests"> | |
| <div class="card-icon">📤</div> | |
| <div class="card-content"> | |
| <h3>Anfragen</h3> | |
| <div class="card-value">{{ costSummary.total_requests || 0 }}</div> | |
| <span class="card-period">Gesamt</span> | |
| </div> | |
| </div> | |
| <div class="summary-card tokens"> | |
| <div class="card-icon">🔤</div> | |
| <div class="card-content"> | |
| <h3>Tokens</h3> | |
| <div class="card-value">{{ formatNumber(costSummary.total_tokens) }}</div> | |
| <span class="card-period">Verbraucht</span> | |
| </div> | |
| </div> | |
| <div class="summary-card avg-cost"> | |
| <div class="card-icon">📈</div> | |
| <div class="card-content"> | |
| <h3>Ø pro Anfrage</h3> | |
| <div class="card-value">${{ formatCurrency(costSummary.avg_cost_per_request) }}</div> | |
| <span class="card-period">Durchschnitt</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Provider Breakdown --> | |
| <div class="section"> | |
| <h2 class="section-title">🏢 Kosten nach Provider</h2> | |
| <div class="provider-grid"> | |
| <div v-for="(data, provider) in providerCosts" :key="provider" class="provider-card"> | |
| <div class="provider-header"> | |
| <span class="provider-icon">{{ provider === 'openrouter' ? '🌐' : '🤖' }}</span> | |
| <span class="provider-name">{{ formatProviderName(provider) }}</span> | |
| </div> | |
| <div class="provider-stats"> | |
| <div class="stat"> | |
| <span class="stat-label">Kosten</span> | |
| <span class="stat-value">${{ formatCurrency(data.cost || 0) }}</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Anfragen</span> | |
| <span class="stat-value">{{ data.requests || 0 }}</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Tokens</span> | |
| <span class="stat-value">{{ formatNumber(data.tokens || 0) }}</span> | |
| </div> | |
| </div> | |
| <div class="provider-bar"> | |
| <div | |
| class="provider-bar-fill" | |
| :style="{ width: getProviderPercentage(data.cost) + '%' }" | |
| :class="provider" | |
| ></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Agent Breakdown --> | |
| <div class="section"> | |
| <h2 class="section-title">🤖 Kosten nach Agent</h2> | |
| <div class="agent-table"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Agent</th> | |
| <th>Anfragen</th> | |
| <th>Tokens</th> | |
| <th>Kosten</th> | |
| <th>Anteil</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr v-for="agent in agentCosts" :key="agent.agent_id"> | |
| <td class="agent-name"> | |
| <span class="agent-icon">{{ getAgentIcon(agent.agent_id) }}</span> | |
| {{ agent.name || agent.agent_id }} | |
| </td> | |
| <td>{{ agent.requests || 0 }}</td> | |
| <td>{{ formatNumber(agent.tokens || 0) }}</td> | |
| <td class="cost-cell">${{ formatCurrency(agent.cost || 0) }}</td> | |
| <td> | |
| <div class="progress-bar"> | |
| <div | |
| class="progress-fill" | |
| :style="{ width: getAgentPercentage(agent.cost) + '%' }" | |
| ></div> | |
| <span class="progress-text">{{ getAgentPercentage(agent.cost).toFixed(1) }}%</span> | |
| </div> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Daily Trend (if available) --> | |
| <div v-if="dailyCosts.length > 0" class="section"> | |
| <h2 class="section-title">📅 Täglicher Verlauf</h2> | |
| <div class="daily-chart"> | |
| <div class="chart-bars"> | |
| <div | |
| v-for="day in dailyCosts" | |
| :key="day.date" | |
| class="chart-bar" | |
| :title="`${day.date}: $${formatCurrency(day.cost)}`" | |
| > | |
| <div | |
| class="bar-fill" | |
| :style="{ height: getDailyBarHeight(day.cost) + '%' }" | |
| ></div> | |
| <span class="bar-label">{{ formatDate(day.date) }}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| </template> | |
| <script setup> | |
| import { ref, onMounted, computed } from 'vue'; | |
| import saapApi from '@/services/saapApi'; | |
| // State | |
| const loading = ref(true); | |
| const error = ref(null); | |
| const selectedPeriod = ref('month'); | |
| const costSummary = ref({}); | |
| const providerCosts = ref({}); | |
| const agentCosts = ref([]); | |
| const dailyCosts = ref([]); | |
| // Load all cost data | |
| const loadCostData = async () => { | |
| loading.value = true; | |
| error.value = null; | |
| try { | |
| // Load cost summary | |
| const summaryData = await saapApi.getCostSummary(selectedPeriod.value); | |
| costSummary.value = summaryData; | |
| // Load provider breakdown | |
| try { | |
| const providerData = await saapApi.getCostByProvider(); | |
| providerCosts.value = providerData.providers || {}; | |
| } catch (e) { | |
| console.warn('Provider costs not available:', e); | |
| providerCosts.value = {}; | |
| } | |
| // Load agent breakdown | |
| try { | |
| const agentData = await saapApi.getCostByAgent(); | |
| agentCosts.value = agentData.agents || []; | |
| } catch (e) { | |
| console.warn('Agent costs not available:', e); | |
| agentCosts.value = []; | |
| } | |
| // Load daily breakdown | |
| try { | |
| const dailyData = await saapApi.getDailyCosts(30); | |
| dailyCosts.value = dailyData.daily || []; | |
| } catch (e) { | |
| console.warn('Daily costs not available:', e); | |
| dailyCosts.value = []; | |
| } | |
| } catch (e) { | |
| console.error('Failed to load cost data:', e); | |
| error.value = 'Kostendaten konnten nicht geladen werden.'; | |
| } finally { | |
| loading.value = false; | |
| } | |
| }; | |
| // Formatting helpers | |
| const formatCurrency = (value) => { | |
| if (!value && value !== 0) return '0.00'; | |
| return parseFloat(value).toFixed(4); | |
| }; | |
| const formatNumber = (value) => { | |
| if (!value) return '0'; | |
| return new Intl.NumberFormat('de-DE').format(value); | |
| }; | |
| const formatProviderName = (provider) => { | |
| const names = { | |
| 'openrouter': 'OpenRouter', | |
| 'colossus': 'Colossus (Local)' | |
| }; | |
| return names[provider] || provider; | |
| }; | |
| const formatDate = (dateStr) => { | |
| if (!dateStr) return ''; | |
| const date = new Date(dateStr); | |
| return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); | |
| }; | |
| const getAgentIcon = (agentId) => { | |
| const icons = { | |
| 'jane_alesi': '👩💼', | |
| 'john_alesi': '👨💻', | |
| 'lara_alesi': '👩⚕️', | |
| 'theo_alesi': '💰', | |
| 'justus_alesi': '⚖️', | |
| 'leon_alesi': '🔧', | |
| 'luna_alesi': '🌙' | |
| }; | |
| return icons[agentId] || '🤖'; | |
| }; | |
| // Percentage calculations | |
| const getProviderPercentage = (cost) => { | |
| const total = costSummary.value.total_cost || 1; | |
| return Math.min((cost / total) * 100, 100); | |
| }; | |
| const getAgentPercentage = (cost) => { | |
| const total = costSummary.value.total_cost || 1; | |
| return Math.min((cost / total) * 100, 100); | |
| }; | |
| const getDailyBarHeight = (cost) => { | |
| const maxCost = Math.max(...dailyCosts.value.map(d => d.cost || 0), 0.001); | |
| return Math.min((cost / maxCost) * 100, 100); | |
| }; | |
| // Export functionality | |
| const exportData = async () => { | |
| try { | |
| const blob = await saapApi.exportCostData('csv'); | |
| const url = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `saap-costs-${selectedPeriod.value}-${new Date().toISOString().split('T')[0]}.csv`; | |
| a.click(); | |
| window.URL.revokeObjectURL(url); | |
| } catch (e) { | |
| console.error('Export failed:', e); | |
| alert('Export fehlgeschlagen'); | |
| } | |
| }; | |
| // Initialize | |
| onMounted(() => { | |
| loadCostData(); | |
| }); | |
| </script> | |
| <style scoped> | |
| .cost-analytics { | |
| padding: 2rem; | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| } | |
| .page-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| margin-bottom: 2rem; | |
| } | |
| .page-title { | |
| font-size: 2rem; | |
| font-weight: 700; | |
| color: var(--text-primary, #1f2937); | |
| margin: 0; | |
| } | |
| .page-subtitle { | |
| color: var(--text-secondary, #6b7280); | |
| margin-top: 0.5rem; | |
| } | |
| .header-actions { | |
| display: flex; | |
| gap: 1rem; | |
| align-items: center; | |
| } | |
| .period-select { | |
| padding: 0.5rem 1rem; | |
| border-radius: 0.5rem; | |
| border: 1px solid var(--border-color, #e5e7eb); | |
| background: white; | |
| font-size: 0.875rem; | |
| } | |
| .btn { | |
| padding: 0.5rem 1rem; | |
| border-radius: 0.5rem; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .btn-primary { | |
| background: var(--primary-color, #8b5cf6); | |
| color: white; | |
| border: none; | |
| } | |
| .btn-secondary { | |
| background: white; | |
| color: var(--text-primary, #1f2937); | |
| border: 1px solid var(--border-color, #e5e7eb); | |
| } | |
| .btn:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| /* Loading & Error States */ | |
| .loading-state, .error-state { | |
| text-align: center; | |
| padding: 4rem 2rem; | |
| } | |
| .spinner { | |
| width: 40px; | |
| height: 40px; | |
| border: 3px solid var(--border-color, #e5e7eb); | |
| border-top-color: var(--primary-color, #8b5cf6); | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| margin: 0 auto 1rem; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| /* Summary Cards */ | |
| .summary-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | |
| gap: 1.5rem; | |
| margin-bottom: 2rem; | |
| } | |
| .summary-card { | |
| background: white; | |
| border-radius: 1rem; | |
| padding: 1.5rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | |
| border: 1px solid var(--border-color, #e5e7eb); | |
| } | |
| .card-icon { | |
| font-size: 2.5rem; | |
| } | |
| .card-content h3 { | |
| font-size: 0.875rem; | |
| color: var(--text-secondary, #6b7280); | |
| margin: 0; | |
| } | |
| .card-value { | |
| font-size: 1.75rem; | |
| font-weight: 700; | |
| color: var(--text-primary, #1f2937); | |
| } | |
| .card-period { | |
| font-size: 0.75rem; | |
| color: var(--text-secondary, #6b7280); | |
| } | |
| /* Sections */ | |
| .section { | |
| background: white; | |
| border-radius: 1rem; | |
| padding: 1.5rem; | |
| margin-bottom: 1.5rem; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | |
| border: 1px solid var(--border-color, #e5e7eb); | |
| } | |
| .section-title { | |
| font-size: 1.25rem; | |
| font-weight: 600; | |
| margin: 0 0 1.5rem; | |
| color: var(--text-primary, #1f2937); | |
| } | |
| /* Provider Grid */ | |
| .provider-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); | |
| gap: 1rem; | |
| } | |
| .provider-card { | |
| background: var(--bg-secondary, #f9fafb); | |
| border-radius: 0.75rem; | |
| padding: 1rem; | |
| } | |
| .provider-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| margin-bottom: 1rem; | |
| } | |
| .provider-icon { | |
| font-size: 1.5rem; | |
| } | |
| .provider-name { | |
| font-weight: 600; | |
| color: var(--text-primary, #1f2937); | |
| } | |
| .provider-stats { | |
| display: flex; | |
| gap: 1.5rem; | |
| margin-bottom: 1rem; | |
| } | |
| .stat { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .stat-label { | |
| font-size: 0.75rem; | |
| color: var(--text-secondary, #6b7280); | |
| } | |
| .stat-value { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| color: var(--text-primary, #1f2937); | |
| } | |
| .provider-bar { | |
| height: 8px; | |
| background: var(--border-color, #e5e7eb); | |
| border-radius: 4px; | |
| overflow: hidden; | |
| } | |
| .provider-bar-fill { | |
| height: 100%; | |
| border-radius: 4px; | |
| transition: width 0.3s ease; | |
| } | |
| .provider-bar-fill.openrouter { | |
| background: linear-gradient(90deg, #8b5cf6, #a78bfa); | |
| } | |
| .provider-bar-fill.colossus { | |
| background: linear-gradient(90deg, #14b8a6, #5eead4); | |
| } | |
| /* Agent Table */ | |
| .agent-table { | |
| overflow-x: auto; | |
| } | |
| .agent-table table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| } | |
| .agent-table th, | |
| .agent-table td { | |
| padding: 0.75rem 1rem; | |
| text-align: left; | |
| border-bottom: 1px solid var(--border-color, #e5e7eb); | |
| } | |
| .agent-table th { | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| color: var(--text-secondary, #6b7280); | |
| text-transform: uppercase; | |
| } | |
| .agent-name { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .agent-icon { | |
| font-size: 1.25rem; | |
| } | |
| .cost-cell { | |
| font-weight: 600; | |
| color: var(--text-primary, #1f2937); | |
| } | |
| .progress-bar { | |
| position: relative; | |
| height: 20px; | |
| background: var(--bg-secondary, #f3f4f6); | |
| border-radius: 10px; | |
| overflow: hidden; | |
| min-width: 100px; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, #8b5cf6, #a78bfa); | |
| border-radius: 10px; | |
| transition: width 0.3s ease; | |
| } | |
| .progress-text { | |
| position: absolute; | |
| right: 8px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| font-size: 0.75rem; | |
| font-weight: 500; | |
| } | |
| /* Daily Chart */ | |
| .daily-chart { | |
| padding: 1rem 0; | |
| } | |
| .chart-bars { | |
| display: flex; | |
| gap: 0.5rem; | |
| height: 200px; | |
| align-items: flex-end; | |
| } | |
| .chart-bar { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| height: 100%; | |
| } | |
| .bar-fill { | |
| width: 100%; | |
| max-width: 40px; | |
| background: linear-gradient(180deg, #8b5cf6, #a78bfa); | |
| border-radius: 4px 4px 0 0; | |
| transition: height 0.3s ease; | |
| } | |
| .bar-label { | |
| font-size: 0.625rem; | |
| color: var(--text-secondary, #6b7280); | |
| margin-top: 0.5rem; | |
| writing-mode: vertical-rl; | |
| text-orientation: mixed; | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| .cost-analytics { | |
| padding: 1rem; | |
| } | |
| .page-header { | |
| flex-direction: column; | |
| gap: 1rem; | |
| } | |
| .header-actions { | |
| width: 100%; | |
| } | |
| .summary-grid { | |
| grid-template-columns: 1fr 1fr; | |
| } | |
| } | |
| </style> | |