claude-desktop/src/lib/components/PerformancePanel.svelte
Eddy 6b8f28145f Performance-Panel mit Kosten-Tracker und Statistiken
- 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>
2026-04-14 14:25:13 +02:00

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>