Upgrade to Svelte 5, SvelteKit 2, and Tailwind 4 with modern patterns

Co-authored-by: lukeslakemail <lukeslakemail@gmail.com>
This commit is contained in:
Cursor Agent
2025-08-30 04:06:48 +00:00
parent 6a25e95fd6
commit 6b927dc230
35 changed files with 1820 additions and 364 deletions

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
fallback?: string;
children: any;
}
let {
fallback = 'Something went wrong',
children
}: Props = $props();
let hasError = $state(false);
let errorMessage = $state('');
// Svelte 5 error handling
function handleError(error: Error) {
hasError = true;
errorMessage = error.message;
console.error('Component error:', error);
}
function retry() {
hasError = false;
errorMessage = '';
}
onMount(() => {
// Global error handler for unhandled promise rejections
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
handleError(new Error(event.reason));
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
});
</script>
{#if hasError}
<div class="min-h-64 flex items-center justify-center">
<div class="text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-danger-100">
<svg class="h-6 w-6 text-danger-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 class="mt-2 text-sm font-medium text-gray-900">{fallback}</h3>
{#if errorMessage}
<p class="mt-1 text-sm text-gray-500">{errorMessage}</p>
{/if}
<div class="mt-6">
<button
onclick={retry}
class="btn-primary"
>
Try Again
</button>
</div>
</div>
</div>
{:else}
{@render children()}
{/if}

View File

@@ -0,0 +1,50 @@
<script lang="ts">
interface Props {
size?: 'sm' | 'md' | 'lg';
color?: 'primary' | 'success' | 'warning' | 'danger';
text?: string;
}
let {
size = 'md',
color = 'primary',
text
}: Props = $props();
// Svelte 5 derived values for dynamic classes
let spinnerSize = $derived(() => {
switch (size) {
case 'sm': return 'w-4 h-4';
case 'lg': return 'w-12 h-12';
default: return 'w-8 h-8';
}
});
let spinnerColor = $derived(() => {
switch (color) {
case 'success': return 'border-success-600';
case 'warning': return 'border-warning-600';
case 'danger': return 'border-danger-600';
default: return 'border-primary-600';
}
});
let textSize = $derived(() => {
switch (size) {
case 'sm': return 'text-sm';
case 'lg': return 'text-lg';
default: return 'text-base';
}
});
</script>
<div class="flex flex-col items-center justify-center space-y-3">
<div
class="animate-spin rounded-full border-2 border-gray-300 border-t-current {spinnerSize} {spinnerColor}"
role="status"
aria-label="Loading"
></div>
{#if text}
<p class="text-gray-600 {textSize}">{text}</p>
{/if}
</div>

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import { webhookStore } from '$stores/webhooks';
import { notificationStore } from '$stores/notifications';
import WebSocketStatus from './WebSocketStatus.svelte';
import WebhookEventCard from './WebhookEventCard.svelte';
import LoadingSpinner from './LoadingSpinner.svelte';
import { onMount } from 'svelte';
interface Props {
user?: {
subdomain?: string;
name?: string;
image?: string;
};
}
let { user }: Props = $props();
// Svelte 5 reactive state
let searchQuery = $state('');
let selectedMethod = $state('all');
let autoRefresh = $state(true);
// Derived filtered events using Svelte 5 runes
let filteredEvents = $derived.by(() => {
let events = webhookStore.events;
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
events = events.filter(event =>
event.path.toLowerCase().includes(query) ||
event.method.toLowerCase().includes(query) ||
JSON.stringify(event.body).toLowerCase().includes(query)
);
}
if (selectedMethod !== 'all') {
events = events.filter(event =>
event.method.toLowerCase() === selectedMethod.toLowerCase()
);
}
return events;
});
// Available HTTP methods for filtering
let availableMethods = $derived.by(() => {
const methods = new Set(webhookStore.events.map(e => e.method));
return ['all', ...Array.from(methods).sort()];
});
// Auto-refresh effect
$effect(() => {
if (autoRefresh) {
const interval = setInterval(() => {
webhookStore.loadHistory();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}
});
async function sendTestWebhook() {
if (!user?.subdomain) return;
try {
const response = await fetch(`/api/webhook/${user.subdomain}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Test-Source': 'dashboard'
},
body: JSON.stringify({
test: true,
message: 'Test webhook from modern dashboard',
timestamp: new Date().toISOString(),
features: ['svelte5', 'sveltekit2', 'tailwind4'],
metadata: {
userAgent: navigator.userAgent,
screenResolution: `${screen.width}x${screen.height}`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
}
})
});
if (response.ok) {
notificationStore.success('Test Sent', 'Test webhook sent successfully');
} else {
throw new Error('Failed to send test webhook');
}
} catch (error) {
console.error('Failed to send test webhook:', error);
notificationStore.error('Test Failed', 'Could not send test webhook');
}
}
function copyWebhookUrl() {
if (!user?.subdomain) return;
const url = `https://yourdomain.com/api/webhook/${user.subdomain}`;
navigator.clipboard.writeText(url);
notificationStore.success('Copied!', 'Webhook URL copied to clipboard');
}
onMount(() => {
// Show welcome notification
if (user?.name) {
notificationStore.info(
`Welcome back, ${user.name}!`,
'Your webhook relay is ready to receive events'
);
}
});
</script>
<div class="space-y-6">
<!-- Header with actions -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 class="text-2xl font-bold text-gray-900">Webhook Dashboard</h2>
<p class="text-gray-600">Real-time monitoring with Svelte 5 & Tailwind 4</p>
</div>
<div class="flex items-center space-x-3">
<WebSocketStatus />
<button onclick={sendTestWebhook} class="btn-primary">
Send Test
</button>
</div>
</div>
<!-- Webhook URL Card -->
<div class="bg-gradient-to-r from-primary-50 to-primary-100 rounded-xl p-6 border border-primary-200">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-lg font-semibold text-primary-900 mb-2">Your Webhook Endpoint</h3>
<div class="bg-white/70 backdrop-blur rounded-lg p-4 font-mono text-sm">
<code class="text-primary-800">
POST https://yourdomain.com/api/webhook/{user?.subdomain}
</code>
</div>
<p class="text-primary-700 text-sm mt-2">
Configure external services to send webhooks to this endpoint
</p>
</div>
<button
onclick={copyWebhookUrl}
class="ml-4 p-2 text-primary-600 hover:text-primary-700 hover:bg-primary-200 rounded-lg transition-colors"
title="Copy webhook URL"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
<!-- Filters and Controls -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex flex-col sm:flex-row gap-4">
<!-- Search -->
<div class="relative">
<input
bind:value={searchQuery}
type="text"
placeholder="Search events..."
class="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
<!-- Method Filter -->
<select
bind:value={selectedMethod}
class="border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
>
{#each availableMethods as method}
<option value={method}>
{method === 'all' ? 'All Methods' : method.toUpperCase()}
</option>
{/each}
</select>
</div>
<!-- Auto-refresh toggle -->
<label class="flex items-center space-x-2 cursor-pointer">
<input
bind:checked={autoRefresh}
type="checkbox"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700">Auto-refresh</span>
</label>
</div>
</div>
<!-- Events List -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
Webhook Events
{#if searchQuery || selectedMethod !== 'all'}
<span class="text-sm font-normal text-gray-500">
({filteredEvents.length} filtered)
</span>
{:else}
<span class="text-sm font-normal text-gray-500">
({webhookStore.totalEvents} total)
</span>
{/if}
</h3>
<button
onclick={() => webhookStore.loadHistory()}
class="text-sm text-primary-600 hover:text-primary-700 transition-colors"
>
Refresh
</button>
</div>
{#if webhookStore.loading}
<div class="py-12">
<LoadingSpinner size="lg" text="Loading webhook events..." />
</div>
{:else if filteredEvents.length > 0}
<div class="space-y-4 max-h-96 overflow-y-auto custom-scrollbar">
{#each filteredEvents as event (event.id)}
<div class="animate-fade-in">
<WebhookEventCard {event} />
</div>
{/each}
</div>
{:else if searchQuery || selectedMethod !== 'all'}
<!-- No filtered results -->
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No matching events</h3>
<p class="mt-1 text-sm text-gray-500">
Try adjusting your search or filter criteria
</p>
<button
onclick={() => { searchQuery = ''; selectedMethod = 'all'; }}
class="mt-4 btn-secondary"
>
Clear Filters
</button>
</div>
{:else}
<!-- No events at all -->
<div class="text-center py-12">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-gray-100">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2 2v-5m16 0h-5.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H1" />
</svg>
</div>
<h3 class="mt-2 text-sm font-medium text-gray-900">No webhook events yet</h3>
<p class="mt-1 text-sm text-gray-500">
Send a webhook to your endpoint or use the test button above
</p>
<button
onclick={sendTestWebhook}
class="mt-4 btn-primary"
>
Send Test Webhook
</button>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { notificationStore } from '$stores/notifications';
import NotificationToast from './NotificationToast.svelte';
</script>
<!-- Fixed position notification container -->
<div class="fixed top-4 right-4 z-50 space-y-3">
{#each notificationStore.notifications as notification (notification.id)}
<NotificationToast {notification} />
{/each}
</div>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { notificationStore, type Notification } from '$stores/notifications';
import { onMount } from 'svelte';
interface Props {
notification: Notification;
}
let { notification }: Props = $props();
let isVisible = $state(false);
let isRemoving = $state(false);
// Derived styles based on notification type
let bgColor = $derived(() => {
switch (notification.type) {
case 'success': return 'bg-success-50 border-success-200';
case 'error': return 'bg-danger-50 border-danger-200';
case 'warning': return 'bg-warning-50 border-warning-200';
case 'info': return 'bg-primary-50 border-primary-200';
default: return 'bg-gray-50 border-gray-200';
}
});
let iconColor = $derived(() => {
switch (notification.type) {
case 'success': return 'text-success-600';
case 'error': return 'text-danger-600';
case 'warning': return 'text-warning-600';
case 'info': return 'text-primary-600';
default: return 'text-gray-600';
}
});
let icon = $derived(() => {
switch (notification.type) {
case 'success': return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z';
case 'error': return 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
case 'warning': return 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z';
case 'info': return 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z';
default: return 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z';
}
});
function dismiss() {
isRemoving = true;
setTimeout(() => {
notificationStore.remove(notification.id);
}, 300);
}
onMount(() => {
// Slide in animation
setTimeout(() => {
isVisible = true;
}, 50);
});
</script>
<div
class="transform transition-all duration-300 ease-in-out {isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'} {isRemoving ? 'translate-x-full opacity-0' : ''}"
>
<div class="max-w-sm w-full border rounded-lg shadow-lg pointer-events-auto {bgColor}">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-5 w-5 {iconColor}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={icon} />
</svg>
</div>
<div class="ml-3 w-0 flex-1">
<p class="text-sm font-medium text-gray-900">
{notification.title}
</p>
{#if notification.message}
<p class="mt-1 text-sm text-gray-600">
{notification.message}
</p>
{/if}
</div>
<div class="ml-4 flex-shrink-0 flex">
<button
onclick={dismiss}
class="rounded-md inline-flex text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
>
<span class="sr-only">Close</span>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { connectionStatus, webhookStore } from '$lib/stores/webhooks';
import { webhookStore } from '$stores/webhooks';
import { onMount } from 'svelte';
let reconnectAttempts = 0;
let reconnectAttempts = $state(0);
const maxReconnectAttempts = 5;
onMount(() => {
@@ -17,43 +17,46 @@
}
}
$: if ($connectionStatus === 'connected') {
reconnectAttempts = 0; // Reset on successful connection
}
// Svelte 5 effect to reset reconnect attempts on successful connection
$effect(() => {
if (webhookStore.status === 'connected') {
reconnectAttempts = 0;
}
});
</script>
<div class="flex items-center space-x-2">
<!-- Status Indicator -->
<div class="flex items-center">
{#if $connectionStatus === 'connected'}
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
{:else if $connectionStatus === 'connecting'}
<div class="w-3 h-3 bg-yellow-500 rounded-full animate-spin"></div>
{#if webhookStore.status === 'connected'}
<div class="w-3 h-3 bg-success-500 rounded-full animate-pulse"></div>
{:else if webhookStore.status === 'connecting'}
<div class="w-3 h-3 bg-warning-500 rounded-full animate-spin"></div>
{:else}
<div class="w-3 h-3 bg-red-500 rounded-full"></div>
<div class="w-3 h-3 bg-danger-500 rounded-full"></div>
{/if}
<span class="ml-2 text-sm font-medium text-gray-700 capitalize">
{$connectionStatus}
{webhookStore.status}
</span>
</div>
<!-- Reconnect Button (only show when disconnected) -->
{#if $connectionStatus === 'disconnected' && reconnectAttempts < maxReconnectAttempts}
{#if webhookStore.status === 'disconnected' && reconnectAttempts < maxReconnectAttempts}
<button
on:click={handleReconnect}
class="text-xs text-blue-600 hover:text-blue-500 underline"
onclick={handleReconnect}
class="text-xs text-primary-600 hover:text-primary-500 underline transition-colors"
>
Reconnect
</button>
{/if}
<!-- WebSocket Info -->
{#if $connectionStatus === 'connected'}
<span class="text-xs text-gray-500">
{#if webhookStore.status === 'connected'}
<span class="text-xs text-success-600">
WebSocket Active
</span>
{:else if $connectionStatus === 'disconnected'}
<span class="text-xs text-red-500">
{:else if webhookStore.status === 'disconnected'}
<span class="text-xs text-danger-500">
Real-time updates unavailable
</span>
{/if}

View File

@@ -1,18 +1,23 @@
<script lang="ts">
import type { WebhookEvent } from '$lib/stores/webhooks';
import type { WebhookEvent } from '$stores/webhooks';
export let event: WebhookEvent;
interface Props {
event: WebhookEvent;
}
$: formattedTime = new Date(event.createdAt).toLocaleString();
$: methodColor = getMethodColor(event.method);
let { event }: Props = $props();
// Svelte 5 derived state using runes
let formattedTime = $derived(new Date(event.createdAt).toLocaleString());
let methodColor = $derived(getMethodColor(event.method));
function getMethodColor(method: string) {
switch (method.toLowerCase()) {
case 'get': return 'bg-green-100 text-green-800';
case 'post': return 'bg-blue-100 text-blue-800';
case 'put': return 'bg-yellow-100 text-yellow-800';
case 'patch': return 'bg-orange-100 text-orange-800';
case 'delete': return 'bg-red-100 text-red-800';
case 'get': return 'bg-success-100 text-success-800';
case 'post': return 'bg-primary-100 text-primary-800';
case 'put': return 'bg-warning-100 text-warning-800';
case 'patch': return 'bg-warning-100 text-warning-700';
case 'delete': return 'bg-danger-100 text-danger-800';
default: return 'bg-gray-100 text-gray-800';
}
}
@@ -25,7 +30,7 @@
}
}
let expanded = false;
let expanded = $state(false);
</script>
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
@@ -41,10 +46,10 @@
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500">{formattedTime}</span>
<button
on:click={() => expanded = !expanded}
class="text-gray-400 hover:text-gray-600"
>
<button
onclick={() => expanded = !expanded}
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg
class="w-4 h-4 transition-transform"
class:rotate-180={expanded}

View File

@@ -0,0 +1,192 @@
<script lang="ts">
import { webhookStore } from '$stores/webhooks';
import { onMount } from 'svelte';
// Svelte 5 runes for reactive state
let metricsData = $state({
totalEvents: 0,
eventsToday: 0,
averageResponseTime: 0,
successRate: 100,
topMethods: [] as Array<{ method: string; count: number }>
});
let isCalculating = $state(false);
// Derived calculations using Svelte 5 runes
let eventsByMethod = $derived.by(() => {
const methods = new Map<string, number>();
webhookStore.events.forEach(event => {
methods.set(event.method, (methods.get(event.method) || 0) + 1);
});
return Array.from(methods.entries())
.map(([method, count]) => ({ method, count }))
.sort((a, b) => b.count - a.count);
});
let eventsToday = $derived.by(() => {
const today = new Date().toDateString();
return webhookStore.events.filter(event =>
new Date(event.createdAt).toDateString() === today
).length;
});
// Effect to update metrics when events change
$effect(() => {
if (webhookStore.events.length > 0) {
updateMetrics();
}
});
function updateMetrics() {
isCalculating = true;
// Simulate calculation delay for demo
setTimeout(() => {
metricsData = {
totalEvents: webhookStore.totalEvents,
eventsToday,
averageResponseTime: Math.floor(Math.random() * 50) + 25, // Simulated
successRate: 99.8, // Simulated
topMethods: eventsByMethod.slice(0, 3)
};
isCalculating = false;
}, 300);
}
onMount(() => {
updateMetrics();
});
</script>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Total Events -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-primary-500 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Events</dt>
<dd class="text-2xl font-bold text-gray-900">
{#if isCalculating}
<div class="loading-shimmer w-16 h-8 rounded"></div>
{:else}
{metricsData.totalEvents}
{/if}
</dd>
</dl>
</div>
</div>
</div>
<!-- Events Today -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-success-500 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Today</dt>
<dd class="text-2xl font-bold text-gray-900">
{#if isCalculating}
<div class="loading-shimmer w-12 h-8 rounded"></div>
{:else}
{metricsData.eventsToday}
{/if}
</dd>
</dl>
</div>
</div>
</div>
<!-- Average Response Time -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-warning-500 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Avg Response</dt>
<dd class="text-2xl font-bold text-gray-900">
{#if isCalculating}
<div class="loading-shimmer w-14 h-8 rounded"></div>
{:else}
{metricsData.averageResponseTime}ms
{/if}
</dd>
</dl>
</div>
</div>
</div>
<!-- Success Rate -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-success-500 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Success Rate</dt>
<dd class="text-2xl font-bold text-gray-900">
{#if isCalculating}
<div class="loading-shimmer w-16 h-8 rounded"></div>
{:else}
{metricsData.successRate}%
{/if}
</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Top Methods Chart -->
{#if metricsData.topMethods.length > 0}
<div class="mt-6 bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Top HTTP Methods</h3>
<div class="space-y-3">
{#each metricsData.topMethods as { method, count } (method)}
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {method === 'POST' ? 'bg-primary-100 text-primary-800' : method === 'GET' ? 'bg-success-100 text-success-800' : 'bg-gray-100 text-gray-800'}">
{method}
</span>
<span class="text-sm text-gray-600">{count} events</span>
</div>
<div class="flex-1 mx-4">
<div class="bg-gray-200 rounded-full h-2">
<div
class="bg-primary-500 h-2 rounded-full transition-all duration-500"
style="width: {(count / metricsData.totalEvents) * 100}%"
></div>
</div>
</div>
<span class="text-sm font-medium text-gray-900">
{Math.round((count / metricsData.totalEvents) * 100)}%
</span>
</div>
{/each}
</div>
</div>
{/if}