mirror of
https://github.com/LukeHagar/relay.git
synced 2025-12-11 04:21:28 +00:00
Upgrade to Svelte 5, SvelteKit 2, and Tailwind 4 with modern patterns
Co-authored-by: lukeslakemail <lukeslakemail@gmail.com>
This commit is contained in:
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
192
sveltekit-integration/src/lib/components/WebhookMetrics.svelte
Normal file
192
sveltekit-integration/src/lib/components/WebhookMetrics.svelte
Normal 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}
|
||||
Reference in New Issue
Block a user