- Neues PerformancePanel.svelte mit:
- Kosten-Übersicht (Session, Heute, Gesamt)
- Token-Statistiken mit Input/Output Ratio-Balken
- Latenz-Verteilung (Min, P50, P95, Max)
- Fehlerrate-Anzeige
- Letzte Sessions Übersicht
- Neuer Tab "📈 Kosten" im mittleren Panel
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
467 lines
11 KiB
Svelte
467 lines
11 KiB
Svelte
<script lang="ts">
|
|
// Performance-Panel — Kosten, Token, Latenz-Statistiken
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { onMount } from 'svelte';
|
|
import { sessionStats, monitorStats, monitorEvents } from '$lib/stores/app';
|
|
|
|
// Aggregierte Statistiken
|
|
interface AggregatedStats {
|
|
totalSessions: number;
|
|
totalCost: number;
|
|
totalTokensIn: number;
|
|
totalTokensOut: number;
|
|
totalMessages: number;
|
|
todayCost: number;
|
|
todaySessions: number;
|
|
}
|
|
|
|
interface SessionSummary {
|
|
id: string;
|
|
title: string;
|
|
cost_usd: number;
|
|
token_input: number;
|
|
token_output: number;
|
|
message_count: number;
|
|
created_at: string;
|
|
}
|
|
|
|
let aggregated = $state<AggregatedStats>({
|
|
totalSessions: 0,
|
|
totalCost: 0,
|
|
totalTokensIn: 0,
|
|
totalTokensOut: 0,
|
|
totalMessages: 0,
|
|
todayCost: 0,
|
|
todaySessions: 0,
|
|
});
|
|
|
|
let recentSessions = $state<SessionSummary[]>([]);
|
|
let loading = $state(true);
|
|
|
|
// Latenz-Verteilung aus Monitor-Events berechnen
|
|
let latencyStats = $derived(() => {
|
|
const apiEvents = $monitorEvents.filter(e => e.type === 'api' && e.durationMs);
|
|
if (apiEvents.length === 0) return null;
|
|
|
|
const durations = apiEvents.map(e => e.durationMs!).sort((a, b) => a - b);
|
|
const sum = durations.reduce((a, b) => a + b, 0);
|
|
|
|
return {
|
|
count: durations.length,
|
|
min: durations[0],
|
|
max: durations[durations.length - 1],
|
|
avg: Math.round(sum / durations.length),
|
|
p50: durations[Math.floor(durations.length * 0.5)],
|
|
p95: durations[Math.floor(durations.length * 0.95)] || durations[durations.length - 1],
|
|
};
|
|
});
|
|
|
|
// Fehlerrate berechnen
|
|
let errorRate = $derived(() => {
|
|
const total = $monitorEvents.length;
|
|
if (total === 0) return 0;
|
|
const errors = $monitorEvents.filter(e => e.type === 'error').length;
|
|
return Math.round((errors / total) * 100);
|
|
});
|
|
|
|
onMount(async () => {
|
|
await loadStats();
|
|
});
|
|
|
|
async function loadStats() {
|
|
loading = true;
|
|
try {
|
|
// Sessions aus DB laden
|
|
const sessions = await invoke<SessionSummary[]>('list_sessions', { limit: 100 });
|
|
|
|
// Heute-Datum für Filter
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
// Aggregieren
|
|
let totalCost = 0;
|
|
let totalTokensIn = 0;
|
|
let totalTokensOut = 0;
|
|
let totalMessages = 0;
|
|
let todayCost = 0;
|
|
let todaySessions = 0;
|
|
|
|
for (const s of sessions) {
|
|
totalCost += s.cost_usd || 0;
|
|
totalTokensIn += s.token_input || 0;
|
|
totalTokensOut += s.token_output || 0;
|
|
totalMessages += s.message_count || 0;
|
|
|
|
if (s.created_at?.startsWith(today)) {
|
|
todayCost += s.cost_usd || 0;
|
|
todaySessions++;
|
|
}
|
|
}
|
|
|
|
aggregated = {
|
|
totalSessions: sessions.length,
|
|
totalCost,
|
|
totalTokensIn,
|
|
totalTokensOut,
|
|
totalMessages,
|
|
todayCost,
|
|
todaySessions,
|
|
};
|
|
|
|
// Letzte 5 Sessions für Übersicht
|
|
recentSessions = sessions.slice(0, 5);
|
|
} catch (err) {
|
|
console.error('Fehler beim Laden der Statistiken:', err);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// Formatierung
|
|
function formatCost(cost: number): string {
|
|
if (cost < 0.01) return `$${(cost * 100).toFixed(2)}¢`;
|
|
return `$${cost.toFixed(2)}`;
|
|
}
|
|
|
|
function formatTokens(tokens: number): string {
|
|
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
|
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
|
|
return tokens.toString();
|
|
}
|
|
|
|
function formatLatency(ms: number): string {
|
|
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
|
|
return `${ms}ms`;
|
|
}
|
|
</script>
|
|
|
|
<div class="performance-panel">
|
|
<div class="panel-header">
|
|
<h2>📈 Performance</h2>
|
|
<button class="refresh-btn" onclick={loadStats} disabled={loading} title="Aktualisieren">
|
|
{loading ? '⏳' : '🔄'}
|
|
</button>
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div class="loading">Lade Statistiken...</div>
|
|
{:else}
|
|
<!-- Kosten-Übersicht -->
|
|
<section class="stats-section">
|
|
<h3>💰 Kosten</h3>
|
|
<div class="stats-grid">
|
|
<div class="stat-card accent">
|
|
<span class="stat-value">{formatCost($sessionStats.totalCost)}</span>
|
|
<span class="stat-label">Aktuelle Session</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-value">{formatCost(aggregated.todayCost)}</span>
|
|
<span class="stat-label">Heute ({aggregated.todaySessions} Sessions)</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-value">{formatCost(aggregated.totalCost)}</span>
|
|
<span class="stat-label">Gesamt ({aggregated.totalSessions} Sessions)</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Token-Statistiken -->
|
|
<section class="stats-section">
|
|
<h3>🔢 Token</h3>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<span class="stat-value">{formatTokens($sessionStats.totalTokensIn)}</span>
|
|
<span class="stat-label">Input (Session)</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-value">{formatTokens($sessionStats.totalTokensOut)}</span>
|
|
<span class="stat-label">Output (Session)</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-value">{formatTokens(aggregated.totalTokensIn + aggregated.totalTokensOut)}</span>
|
|
<span class="stat-label">Gesamt (alle)</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Token Ratio Balken -->
|
|
{#if $sessionStats.totalTokensIn > 0 || $sessionStats.totalTokensOut > 0}
|
|
{@const total = $sessionStats.totalTokensIn + $sessionStats.totalTokensOut}
|
|
{@const inPercent = Math.round(($sessionStats.totalTokensIn / total) * 100)}
|
|
<div class="ratio-bar">
|
|
<div class="ratio-segment input" style="width: {inPercent}%">
|
|
<span>In {inPercent}%</span>
|
|
</div>
|
|
<div class="ratio-segment output" style="width: {100 - inPercent}%">
|
|
<span>Out {100 - inPercent}%</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Performance-Indikatoren -->
|
|
<section class="stats-section">
|
|
<h3>⚡ Leistung</h3>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<span class="stat-value">{$monitorStats.apiCalls}</span>
|
|
<span class="stat-label">API-Calls</span>
|
|
</div>
|
|
<div class="stat-card" class:error={errorRate() > 5}>
|
|
<span class="stat-value">{errorRate()}%</span>
|
|
<span class="stat-label">Fehlerrate</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-value">{$monitorStats.avgLatencyMs}ms</span>
|
|
<span class="stat-label">Ø Latenz</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Latenz-Details -->
|
|
{#if latencyStats()}
|
|
{@const stats = latencyStats()}
|
|
<div class="latency-details">
|
|
<div class="latency-row">
|
|
<span>Min:</span>
|
|
<span class="value">{formatLatency(stats.min)}</span>
|
|
</div>
|
|
<div class="latency-row">
|
|
<span>P50:</span>
|
|
<span class="value">{formatLatency(stats.p50)}</span>
|
|
</div>
|
|
<div class="latency-row">
|
|
<span>P95:</span>
|
|
<span class="value highlight">{formatLatency(stats.p95)}</span>
|
|
</div>
|
|
<div class="latency-row">
|
|
<span>Max:</span>
|
|
<span class="value">{formatLatency(stats.max)}</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Letzte Sessions -->
|
|
{#if recentSessions.length > 1}
|
|
<section class="stats-section">
|
|
<h3>📋 Letzte Sessions</h3>
|
|
<div class="session-list">
|
|
{#each recentSessions as session}
|
|
<div class="session-row">
|
|
<span class="session-title" title={session.title}>
|
|
{session.title.length > 25 ? session.title.substring(0, 25) + '...' : session.title}
|
|
</span>
|
|
<span class="session-cost">{formatCost(session.cost_usd)}</span>
|
|
<span class="session-tokens">{formatTokens(session.token_input + session.token_output)}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.performance-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
font-size: 0.75rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--bg-tertiary);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
}
|
|
|
|
.panel-header h2 {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.refresh-btn {
|
|
padding: 4px 8px;
|
|
background: var(--bg-tertiary);
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.refresh-btn:hover:not(:disabled) {
|
|
background: var(--accent);
|
|
}
|
|
|
|
.refresh-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: var(--spacing-lg);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.stats-section {
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border-bottom: 1px solid var(--bg-tertiary);
|
|
}
|
|
|
|
.stats-section h3 {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
margin: 0 0 var(--spacing-xs);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: var(--spacing-xs);
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--bg-secondary);
|
|
padding: var(--spacing-sm);
|
|
border-radius: var(--radius-sm);
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-card.accent {
|
|
background: rgba(99, 102, 241, 0.15);
|
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
|
}
|
|
|
|
.stat-card.error {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
}
|
|
|
|
.stat-value {
|
|
display: block;
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.stat-card.accent .stat-value {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.stat-card.error .stat-value {
|
|
color: var(--error);
|
|
}
|
|
|
|
.stat-label {
|
|
display: block;
|
|
font-size: 0.6rem;
|
|
color: var(--text-secondary);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* Token Ratio Bar */
|
|
.ratio-bar {
|
|
display: flex;
|
|
height: 20px;
|
|
border-radius: var(--radius-sm);
|
|
overflow: hidden;
|
|
margin-top: var(--spacing-xs);
|
|
}
|
|
|
|
.ratio-segment {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 0.6rem;
|
|
font-weight: 500;
|
|
color: white;
|
|
min-width: 30px;
|
|
}
|
|
|
|
.ratio-segment.input {
|
|
background: #3b82f6;
|
|
}
|
|
|
|
.ratio-segment.output {
|
|
background: #22c55e;
|
|
}
|
|
|
|
.ratio-segment span {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Latenz Details */
|
|
.latency-details {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: var(--spacing-xs);
|
|
margin-top: var(--spacing-xs);
|
|
padding: var(--spacing-xs);
|
|
background: var(--bg-secondary);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
.latency-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
font-size: 0.6rem;
|
|
}
|
|
|
|
.latency-row span:first-child {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.latency-row .value {
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.latency-row .value.highlight {
|
|
color: #eab308;
|
|
}
|
|
|
|
/* Session Liste */
|
|
.session-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.session-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto auto;
|
|
gap: var(--spacing-sm);
|
|
padding: var(--spacing-xs);
|
|
background: var(--bg-secondary);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.65rem;
|
|
}
|
|
|
|
.session-title {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.session-cost {
|
|
color: var(--accent);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.session-tokens {
|
|
color: var(--text-secondary);
|
|
}
|
|
</style>
|