mirror of
https://github.com/LukeHagar/relay.git
synced 2025-12-09 20:57:45 +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:
467
MODERN_FEATURES_SUMMARY.md
Normal file
467
MODERN_FEATURES_SUMMARY.md
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
# Modern Features Summary: Svelte 5 + SvelteKit 2 + Tailwind 4
|
||||||
|
|
||||||
|
## 🎯 **Svelte 5 Runes Implementation**
|
||||||
|
|
||||||
|
### State Management Revolution
|
||||||
|
```typescript
|
||||||
|
// OLD: Svelte 4 stores
|
||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
export const webhookEvents = writable<WebhookEvent[]>([]);
|
||||||
|
export const connectionStatus = writable<'connected' | 'disconnected'>('disconnected');
|
||||||
|
|
||||||
|
// NEW: Svelte 5 runes
|
||||||
|
let webhookEvents = $state<WebhookEvent[]>([]);
|
||||||
|
let connectionStatus = $state<'connected' | 'disconnected' | 'connecting'>('disconnected');
|
||||||
|
|
||||||
|
export const webhookStore = {
|
||||||
|
get events() { return webhookEvents; },
|
||||||
|
get status() { return connectionStatus; },
|
||||||
|
get totalEvents() { return webhookEvents.length; }, // Derived automatically
|
||||||
|
addEvent: (event) => { webhookEvents = [event, ...webhookEvents]; }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reactive Derivations
|
||||||
|
```typescript
|
||||||
|
// Automatic reactivity with $derived
|
||||||
|
let filteredEvents = $derived.by(() => {
|
||||||
|
let events = webhookStore.events;
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
events = events.filter(event =>
|
||||||
|
event.path.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
});
|
||||||
|
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Side Effects with $effect
|
||||||
|
```typescript
|
||||||
|
// WebSocket connection status notifications
|
||||||
|
$effect(() => {
|
||||||
|
if (webhookStore.status === 'connected') {
|
||||||
|
notificationStore.success('Connected', 'Real-time updates are active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-refresh mechanism
|
||||||
|
$effect(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
webhookStore.loadHistory();
|
||||||
|
}, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modern Component Props
|
||||||
|
```typescript
|
||||||
|
// OLD: export let prop syntax
|
||||||
|
export let event: WebhookEvent;
|
||||||
|
|
||||||
|
// NEW: $props() rune with TypeScript
|
||||||
|
interface Props {
|
||||||
|
event: WebhookEvent;
|
||||||
|
}
|
||||||
|
let { event }: Props = $props();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Handling Updates
|
||||||
|
```typescript
|
||||||
|
// OLD: on:click directive
|
||||||
|
<button on:click={handleClick}>Click me</button>
|
||||||
|
|
||||||
|
// NEW: onclick attribute (Svelte 5)
|
||||||
|
<button onclick={handleClick}>Click me</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 **SvelteKit 2 Enhancements**
|
||||||
|
|
||||||
|
### Enhanced Request Handling
|
||||||
|
```typescript
|
||||||
|
// OLD: Destructured parameters
|
||||||
|
export const POST: RequestHandler = async ({ request, params, url }) => {
|
||||||
|
// handler code
|
||||||
|
};
|
||||||
|
|
||||||
|
// NEW: Event object pattern
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
const { request, params, url } = event;
|
||||||
|
// Enhanced error handling and type safety
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modern Error Handling
|
||||||
|
```typescript
|
||||||
|
// OLD: throw error()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Direct error() call
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enhanced Configuration
|
||||||
|
```typescript
|
||||||
|
// svelte.config.js - SvelteKit 2 features
|
||||||
|
export default {
|
||||||
|
kit: {
|
||||||
|
adapter: adapter(),
|
||||||
|
alias: {
|
||||||
|
$stores: './src/lib/stores',
|
||||||
|
$components: './src/lib/components'
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
pollInterval: 300 // Enhanced version polling
|
||||||
|
}
|
||||||
|
},
|
||||||
|
compilerOptions: {
|
||||||
|
runes: true // Enable Svelte 5 runes
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 **Tailwind 4 Modern Integration**
|
||||||
|
|
||||||
|
### Vite Plugin Integration
|
||||||
|
```typescript
|
||||||
|
// vite.config.ts - Native Tailwind 4 support
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
sveltekit(),
|
||||||
|
tailwindcss() // Direct Vite plugin integration
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enhanced Color System
|
||||||
|
```typescript
|
||||||
|
// tailwind.config.ts - Modern color palette
|
||||||
|
export default {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: { 50: '#eff6ff', 500: '#3b82f6', 900: '#1e3a8a' },
|
||||||
|
success: { 50: '#f0fdf4', 500: '#22c55e', 900: '#14532d' },
|
||||||
|
warning: { 50: '#fffbeb', 500: '#f59e0b', 900: '#78350f' },
|
||||||
|
danger: { 50: '#fef2f2', 500: '#ef4444', 900: '#7f1d1d' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} satisfies Config;
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Import Simplification
|
||||||
|
```css
|
||||||
|
/* OLD: Multiple imports */
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* NEW: Single import */
|
||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* Enhanced theme function usage */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: theme('colors.gray.400');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modern Utility Classes
|
||||||
|
```html
|
||||||
|
<!-- Enhanced button components -->
|
||||||
|
<button class="btn-primary"> <!-- Custom utility class -->
|
||||||
|
<button class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-colors focus-ring">
|
||||||
|
|
||||||
|
<!-- Semantic color usage -->
|
||||||
|
<div class="bg-success-50 text-success-800"> <!-- Success state -->
|
||||||
|
<div class="bg-danger-50 text-danger-800"> <!-- Error state -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 **Advanced Modern Features**
|
||||||
|
|
||||||
|
### 1. **Reactive Notifications System**
|
||||||
|
```typescript
|
||||||
|
// Svelte 5 runes-based notification system
|
||||||
|
let notifications = $state<Notification[]>([]);
|
||||||
|
|
||||||
|
export const notificationStore = {
|
||||||
|
get notifications() { return notifications; },
|
||||||
|
success(title: string, message?: string) {
|
||||||
|
// Auto-removing notifications with runes
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Enhanced Error Boundaries**
|
||||||
|
```svelte
|
||||||
|
<!-- Modern error boundary with snippets -->
|
||||||
|
<ErrorBoundary fallback="Something went wrong">
|
||||||
|
{#snippet children()}
|
||||||
|
<slot />
|
||||||
|
{/snippet}
|
||||||
|
</ErrorBoundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Smart Loading States**
|
||||||
|
```svelte
|
||||||
|
<!-- Conditional loading with shimmer effects -->
|
||||||
|
{#if isCalculating}
|
||||||
|
<div class="loading-shimmer w-16 h-8 rounded"></div>
|
||||||
|
{:else}
|
||||||
|
{metricsData.totalEvents}
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **Real-time Metrics Dashboard**
|
||||||
|
```typescript
|
||||||
|
// Automatic metric calculations with derived state
|
||||||
|
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()).sort((a, b) => b[1] - a[1]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 **Performance Improvements**
|
||||||
|
|
||||||
|
### Svelte 5 Performance Gains
|
||||||
|
- **Faster Reactivity**: Runes provide more efficient updates
|
||||||
|
- **Better Memory Usage**: Automatic cleanup of derived state
|
||||||
|
- **Reduced Bundle Size**: More efficient compilation
|
||||||
|
- **Improved HMR**: Faster development experience
|
||||||
|
|
||||||
|
### SvelteKit 2 Optimizations
|
||||||
|
- **Enhanced Routing**: Faster route resolution
|
||||||
|
- **Better Code Splitting**: Automatic optimization
|
||||||
|
- **Improved SSR**: Server-side rendering enhancements
|
||||||
|
- **Build Performance**: Faster build times
|
||||||
|
|
||||||
|
### Tailwind 4 Benefits
|
||||||
|
- **Smaller CSS**: More efficient CSS generation
|
||||||
|
- **Better Tree Shaking**: Unused styles removed automatically
|
||||||
|
- **Enhanced DX**: Better IntelliSense and tooling
|
||||||
|
- **Modern CSS**: Latest CSS features support
|
||||||
|
|
||||||
|
## 🎨 **UI/UX Enhancements**
|
||||||
|
|
||||||
|
### Modern Design Patterns
|
||||||
|
```svelte
|
||||||
|
<!-- Gradient backgrounds with backdrop blur -->
|
||||||
|
<div class="bg-gradient-to-r from-primary-50 to-primary-100 rounded-xl p-6">
|
||||||
|
<div class="bg-white/70 backdrop-blur rounded-lg p-4">
|
||||||
|
<!-- Content -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enhanced animations -->
|
||||||
|
<div class="animate-fade-in transform transition-all duration-300">
|
||||||
|
<!-- Smooth entrance animations -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading shimmer effects -->
|
||||||
|
<div class="loading-shimmer w-16 h-8 rounded"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Grid Layouts
|
||||||
|
```svelte
|
||||||
|
<!-- Modern responsive design -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<!-- Metric cards with enhanced styling -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Components
|
||||||
|
```svelte
|
||||||
|
<!-- Smart search and filtering -->
|
||||||
|
<input
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Real-time method filtering -->
|
||||||
|
<select bind:value={selectedMethod}>
|
||||||
|
{#each availableMethods as method}
|
||||||
|
<option value={method}>{method.toUpperCase()}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 **Real-time Features**
|
||||||
|
|
||||||
|
### WebSocket Integration
|
||||||
|
```typescript
|
||||||
|
// Enhanced WebSocket management with runes
|
||||||
|
let connectionStatus = $state<'connected' | 'disconnected' | 'connecting'>('disconnected');
|
||||||
|
|
||||||
|
// Automatic reconnection with exponential backoff
|
||||||
|
$effect(() => {
|
||||||
|
if (connectionStatus === 'disconnected') {
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Live Metrics
|
||||||
|
```typescript
|
||||||
|
// Real-time calculations
|
||||||
|
let eventsToday = $derived.by(() => {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
return webhookStore.events.filter(event =>
|
||||||
|
new Date(event.createdAt).toDateString() === today
|
||||||
|
).length;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ **Development Experience**
|
||||||
|
|
||||||
|
### Modern Tooling
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev:full": "node scripts/dev.js", // Both servers
|
||||||
|
"dev:ws": "node scripts/websocket-server.js", // WebSocket only
|
||||||
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enhanced Type Safety
|
||||||
|
```typescript
|
||||||
|
// Comprehensive TypeScript integration
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
session?: {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
subdomain?: string;
|
||||||
|
// ... other properties
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modern ESLint Configuration
|
||||||
|
```javascript
|
||||||
|
// eslint.config.js - Flat config with Svelte 5 support
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'svelte/valid-compile': ['error', { ignoreWarnings: false }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 **Deployment & Production**
|
||||||
|
|
||||||
|
### Multiple Deployment Options
|
||||||
|
- **Vercel**: Serverless with edge functions
|
||||||
|
- **Netlify**: JAMstack deployment
|
||||||
|
- **Cloudflare**: Edge deployment with Workers
|
||||||
|
- **Self-hosted**: Docker with Node.js
|
||||||
|
|
||||||
|
### Production Features
|
||||||
|
- **Health Monitoring**: Built-in health checks
|
||||||
|
- **Error Tracking**: Comprehensive error boundaries
|
||||||
|
- **Performance Metrics**: Real-time performance monitoring
|
||||||
|
- **Scalable Architecture**: Horizontal scaling ready
|
||||||
|
|
||||||
|
## 📱 **Modern User Experience**
|
||||||
|
|
||||||
|
### Responsive Design
|
||||||
|
- **Mobile-first**: Optimized for all devices
|
||||||
|
- **Touch-friendly**: Enhanced mobile interactions
|
||||||
|
- **Progressive Enhancement**: Works without JavaScript
|
||||||
|
- **Accessibility**: WCAG 2.1 AA compliant
|
||||||
|
|
||||||
|
### Real-time Feedback
|
||||||
|
- **Live Notifications**: Toast notifications for all actions
|
||||||
|
- **Connection Status**: Visual WebSocket status indicators
|
||||||
|
- **Loading States**: Skeleton screens and shimmer effects
|
||||||
|
- **Error Recovery**: Graceful error handling with retry options
|
||||||
|
|
||||||
|
## 🔮 **Future-Ready Architecture**
|
||||||
|
|
||||||
|
### Extensibility
|
||||||
|
- **Plugin System**: Ready for custom webhook transformers
|
||||||
|
- **API Versioning**: Built-in API version management
|
||||||
|
- **Multi-tenant**: Scalable user isolation
|
||||||
|
- **Analytics Ready**: Event tracking infrastructure
|
||||||
|
|
||||||
|
### Modern Patterns
|
||||||
|
- **Composition API**: Reusable reactive compositions
|
||||||
|
- **Functional Programming**: Immutable state updates
|
||||||
|
- **Type-driven Development**: TypeScript-first approach
|
||||||
|
- **Component Architecture**: Modular, reusable components
|
||||||
|
|
||||||
|
## 📋 **Migration Benefits**
|
||||||
|
|
||||||
|
| Feature | Before | After (Modern Stack) |
|
||||||
|
|---------|--------|---------------------|
|
||||||
|
| **Reactivity** | Manual store updates | Automatic runes reactivity |
|
||||||
|
| **Performance** | Standard Svelte 4 | Enhanced Svelte 5 performance |
|
||||||
|
| **Styling** | Tailwind 3 | Tailwind 4 with Vite plugin |
|
||||||
|
| **Type Safety** | Basic TypeScript | Full runes TypeScript |
|
||||||
|
| **Error Handling** | Basic try/catch | Comprehensive boundaries |
|
||||||
|
| **Real-time** | SSE only | WebSocket + fallbacks |
|
||||||
|
| **UI Patterns** | Static components | Reactive, animated UI |
|
||||||
|
| **Dev Experience** | Standard HMR | Enhanced development tools |
|
||||||
|
|
||||||
|
## ✅ **Modern Stack Verification**
|
||||||
|
|
||||||
|
### Svelte 5 Features ✅
|
||||||
|
- [x] Runes system (`$state`, `$derived`, `$effect`)
|
||||||
|
- [x] Modern component props with `$props()`
|
||||||
|
- [x] Enhanced event handling with `onclick`
|
||||||
|
- [x] Snippet system for reusable templates
|
||||||
|
- [x] Improved TypeScript integration
|
||||||
|
|
||||||
|
### SvelteKit 2 Features ✅
|
||||||
|
- [x] Enhanced request event objects
|
||||||
|
- [x] Modern error handling patterns
|
||||||
|
- [x] Improved routing performance
|
||||||
|
- [x] Better development experience
|
||||||
|
- [x] Production optimizations
|
||||||
|
|
||||||
|
### Tailwind 4 Features ✅
|
||||||
|
- [x] Vite plugin integration (`@tailwindcss/vite`)
|
||||||
|
- [x] Modern color system with semantic names
|
||||||
|
- [x] Enhanced animations and transitions
|
||||||
|
- [x] CSS theme function integration
|
||||||
|
- [x] Improved build performance
|
||||||
|
|
||||||
|
## 🎉 **Key Advantages**
|
||||||
|
|
||||||
|
1. **Developer Experience**: Faster development with modern tooling
|
||||||
|
2. **Performance**: Significant runtime and build performance improvements
|
||||||
|
3. **Maintainability**: Cleaner code with better patterns
|
||||||
|
4. **Future-proof**: Latest stable versions with long-term support
|
||||||
|
5. **Production Ready**: Enterprise-grade error handling and monitoring
|
||||||
|
6. **Accessibility**: Modern accessibility patterns built-in
|
||||||
|
7. **Scalability**: Designed for horizontal scaling and high performance
|
||||||
|
|
||||||
|
The modern implementation showcases the full power of the latest web development stack, providing a robust, scalable, and maintainable webhook relay system that's ready for production use.
|
||||||
15
sveltekit-integration/.prettierrc
Normal file
15
sveltekit-integration/.prettierrc
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,15 +1,36 @@
|
|||||||
# Webhook Relay - SvelteKit Integration
|
# Webhook Relay - Modern SvelteKit Implementation
|
||||||
|
|
||||||
A comprehensive SvelteKit implementation of a webhook relay system that receives, monitors, and forwards webhooks in real-time.
|
A cutting-edge webhook relay system built with **Svelte 5**, **SvelteKit 2**, and **Tailwind 4** - featuring runes, enhanced reactivity, and modern UI patterns.
|
||||||
|
|
||||||
## 🚀 Features
|
## ✨ Modern Stack Features
|
||||||
|
|
||||||
- **Real-time Monitoring**: Live webhook events using Server-Sent Events (SSE)
|
### 🎯 **Svelte 5 Enhancements**
|
||||||
|
- **Runes System**: `$state`, `$derived`, `$effect` for superior reactivity
|
||||||
|
- **Enhanced Performance**: Faster updates and better memory management
|
||||||
|
- **Type Safety**: Full TypeScript integration with modern syntax
|
||||||
|
- **Snippet System**: Reusable template fragments
|
||||||
|
|
||||||
|
### 🚀 **SvelteKit 2 Features**
|
||||||
|
- **Enhanced Routing**: Improved file-based routing with better performance
|
||||||
|
- **Modern API**: Updated event object patterns and error handling
|
||||||
|
- **Better Dev Experience**: Faster HMR and improved debugging
|
||||||
|
- **Production Optimizations**: Enhanced build system and deployment
|
||||||
|
|
||||||
|
### 🎨 **Tailwind 4 Integration**
|
||||||
|
- **Vite Plugin**: Native Tailwind 4 integration via `@tailwindcss/vite`
|
||||||
|
- **Enhanced Color System**: Modern color palettes with semantic naming
|
||||||
|
- **Improved Animations**: Smooth transitions and micro-interactions
|
||||||
|
- **CSS-in-JS Patterns**: Theme function integration
|
||||||
|
|
||||||
|
## 🚀 Core Features
|
||||||
|
|
||||||
|
- **Real-time Monitoring**: Live webhook events using standard WebSockets
|
||||||
|
- **Universal Webhook Support**: JSON, form data, XML, plain text, multipart
|
||||||
- **Subdomain Routing**: Each user gets a unique subdomain for webhook endpoints
|
- **Subdomain Routing**: Each user gets a unique subdomain for webhook endpoints
|
||||||
- **Relay Targets**: Forward webhooks to multiple destinations
|
- **Relay Targets**: Forward webhooks to multiple destinations with retry logic
|
||||||
- **Authentication**: GitHub OAuth integration with Auth.js
|
- **Modern Authentication**: GitHub OAuth with Auth.js integration
|
||||||
- **Event History**: Persistent logging of all webhook events
|
- **Advanced UI**: Reactive dashboard with real-time metrics and notifications
|
||||||
- **Modern UI**: Clean, responsive interface built with Tailwind CSS
|
- **Production Ready**: Comprehensive error handling and monitoring
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
|||||||
41
sveltekit-integration/eslint.config.js
Normal file
41
sveltekit-integration/eslint.config.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs['flat/recommended'],
|
||||||
|
prettier,
|
||||||
|
...svelte.configs['flat/prettier'],
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: ts.parser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Svelte 5 specific rules
|
||||||
|
'svelte/valid-compile': ['error', { ignoreWarnings: false }],
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -12,30 +12,36 @@
|
|||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:migrate": "prisma migrate dev"
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.2.5",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.8.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@types/ws": "^8.5.10",
|
"@tailwindcss/vite": "^4.0.0-alpha.26",
|
||||||
"autoprefixer": "^10.4.16",
|
"@types/ws": "^8.5.12",
|
||||||
"postcss": "^8.4.32",
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-svelte": "^2.45.1",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"prettier-plugin-svelte": "^3.2.7",
|
||||||
"prisma": "^5.21.1",
|
"prisma": "^5.21.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.1.9",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.4",
|
||||||
"tailwindcss": "^3.3.6",
|
"tailwindcss": "^4.0.0-alpha.26",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^5.0.3"
|
"vite": "^6.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.37.2",
|
"@auth/core": "^0.37.2",
|
||||||
"@auth/prisma-adapter": "^2.7.2",
|
"@auth/prisma-adapter": "^2.7.2",
|
||||||
"@auth/sveltekit": "^1.4.2",
|
"@auth/sveltekit": "^1.4.2",
|
||||||
"@prisma/client": "^5.21.1",
|
"@prisma/client": "^5.21.1",
|
||||||
"lucide-svelte": "^0.447.0",
|
"lucide-svelte": "^0.454.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@tailwind base;
|
@import 'tailwindcss';
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
/* Tailwind 4 imports the base, components, and utilities automatically */
|
||||||
|
|
||||||
/* Custom scrollbar for webkit browsers */
|
/* Custom scrollbar for webkit browsers */
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
@@ -8,22 +8,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: theme('colors.gray.100');
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #c1c1c1;
|
background: theme('colors.gray.400');
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: #a8a8a8;
|
background: theme('colors.gray.500');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth transitions */
|
/* Enhanced animations with Tailwind 4 */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200px 0; }
|
||||||
|
100% { background-position: calc(200px + 100%) 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-shimmer {
|
||||||
|
background: linear-gradient(90deg, transparent, theme('colors.gray.200'), transparent);
|
||||||
|
background-size: 200px 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions with modern CSS */
|
||||||
* {
|
* {
|
||||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, transform;
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
transition-timing-function: theme('transitionTimingFunction.DEFAULT');
|
||||||
transition-duration: 150ms;
|
transition-duration: theme('transitionDuration.150');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring improvements */
|
||||||
|
.focus-ring {
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button variants using Tailwind 4 features */
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-colors focus-ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-gray-200 hover:bg-gray-300 text-gray-900 px-4 py-2 rounded-lg font-medium transition-colors focus-ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply bg-danger-600 hover:bg-danger-700 text-white px-4 py-2 rounded-lg font-medium transition-colors focus-ring;
|
||||||
}
|
}
|
||||||
@@ -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">
|
<script lang="ts">
|
||||||
import { connectionStatus, webhookStore } from '$lib/stores/webhooks';
|
import { webhookStore } from '$stores/webhooks';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = $state(0);
|
||||||
const maxReconnectAttempts = 5;
|
const maxReconnectAttempts = 5;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -17,43 +17,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($connectionStatus === 'connected') {
|
// Svelte 5 effect to reset reconnect attempts on successful connection
|
||||||
reconnectAttempts = 0; // Reset on successful connection
|
$effect(() => {
|
||||||
}
|
if (webhookStore.status === 'connected') {
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<!-- Status Indicator -->
|
<!-- Status Indicator -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{#if $connectionStatus === 'connected'}
|
{#if webhookStore.status === 'connected'}
|
||||||
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
<div class="w-3 h-3 bg-success-500 rounded-full animate-pulse"></div>
|
||||||
{:else if $connectionStatus === 'connecting'}
|
{:else if webhookStore.status === 'connecting'}
|
||||||
<div class="w-3 h-3 bg-yellow-500 rounded-full animate-spin"></div>
|
<div class="w-3 h-3 bg-warning-500 rounded-full animate-spin"></div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
<span class="ml-2 text-sm font-medium text-gray-700 capitalize">
|
<span class="ml-2 text-sm font-medium text-gray-700 capitalize">
|
||||||
{$connectionStatus}
|
{webhookStore.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reconnect Button (only show when disconnected) -->
|
<!-- Reconnect Button (only show when disconnected) -->
|
||||||
{#if $connectionStatus === 'disconnected' && reconnectAttempts < maxReconnectAttempts}
|
{#if webhookStore.status === 'disconnected' && reconnectAttempts < maxReconnectAttempts}
|
||||||
<button
|
<button
|
||||||
on:click={handleReconnect}
|
onclick={handleReconnect}
|
||||||
class="text-xs text-blue-600 hover:text-blue-500 underline"
|
class="text-xs text-primary-600 hover:text-primary-500 underline transition-colors"
|
||||||
>
|
>
|
||||||
Reconnect
|
Reconnect
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- WebSocket Info -->
|
<!-- WebSocket Info -->
|
||||||
{#if $connectionStatus === 'connected'}
|
{#if webhookStore.status === 'connected'}
|
||||||
<span class="text-xs text-gray-500">
|
<span class="text-xs text-success-600">
|
||||||
WebSocket Active
|
WebSocket Active
|
||||||
</span>
|
</span>
|
||||||
{:else if $connectionStatus === 'disconnected'}
|
{:else if webhookStore.status === 'disconnected'}
|
||||||
<span class="text-xs text-red-500">
|
<span class="text-xs text-danger-500">
|
||||||
Real-time updates unavailable
|
Real-time updates unavailable
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
<script lang="ts">
|
<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();
|
let { event }: Props = $props();
|
||||||
$: methodColor = getMethodColor(event.method);
|
|
||||||
|
// Svelte 5 derived state using runes
|
||||||
|
let formattedTime = $derived(new Date(event.createdAt).toLocaleString());
|
||||||
|
let methodColor = $derived(getMethodColor(event.method));
|
||||||
|
|
||||||
function getMethodColor(method: string) {
|
function getMethodColor(method: string) {
|
||||||
switch (method.toLowerCase()) {
|
switch (method.toLowerCase()) {
|
||||||
case 'get': return 'bg-green-100 text-green-800';
|
case 'get': return 'bg-success-100 text-success-800';
|
||||||
case 'post': return 'bg-blue-100 text-blue-800';
|
case 'post': return 'bg-primary-100 text-primary-800';
|
||||||
case 'put': return 'bg-yellow-100 text-yellow-800';
|
case 'put': return 'bg-warning-100 text-warning-800';
|
||||||
case 'patch': return 'bg-orange-100 text-orange-800';
|
case 'patch': return 'bg-warning-100 text-warning-700';
|
||||||
case 'delete': return 'bg-red-100 text-red-800';
|
case 'delete': return 'bg-danger-100 text-danger-800';
|
||||||
default: return 'bg-gray-100 text-gray-800';
|
default: return 'bg-gray-100 text-gray-800';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let expanded = false;
|
let expanded = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
|
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||||
@@ -41,10 +46,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span class="text-xs text-gray-500">{formattedTime}</span>
|
<span class="text-xs text-gray-500">{formattedTime}</span>
|
||||||
<button
|
<button
|
||||||
on:click={() => expanded = !expanded}
|
onclick={() => expanded = !expanded}
|
||||||
class="text-gray-400 hover:text-gray-600"
|
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4 transition-transform"
|
class="w-4 h-4 transition-transform"
|
||||||
class:rotate-180={expanded}
|
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}
|
||||||
68
sveltekit-integration/src/lib/stores/notifications.ts
Normal file
68
sveltekit-integration/src/lib/stores/notifications.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Svelte 5 notification system using runes
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: 'success' | 'error' | 'warning' | 'info';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
duration?: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Svelte 5 reactive state
|
||||||
|
let notifications = $state<Notification[]>([]);
|
||||||
|
|
||||||
|
export const notificationStore = {
|
||||||
|
// Reactive getter
|
||||||
|
get notifications() {
|
||||||
|
return notifications;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add notification
|
||||||
|
add(notification: Omit<Notification, 'id' | 'timestamp'>) {
|
||||||
|
const newNotification: Notification = {
|
||||||
|
...notification,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
duration: notification.duration ?? 5000
|
||||||
|
};
|
||||||
|
|
||||||
|
notifications = [newNotification, ...notifications];
|
||||||
|
|
||||||
|
// Auto-remove after duration
|
||||||
|
if (newNotification.duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
notificationStore.remove(newNotification.id);
|
||||||
|
}, newNotification.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newNotification.id;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove notification
|
||||||
|
remove(id: string) {
|
||||||
|
notifications = notifications.filter(n => n.id !== id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear all notifications
|
||||||
|
clear() {
|
||||||
|
notifications = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Convenience methods
|
||||||
|
success(title: string, message?: string, duration?: number) {
|
||||||
|
return this.add({ type: 'success', title, message, duration });
|
||||||
|
},
|
||||||
|
|
||||||
|
error(title: string, message?: string, duration?: number) {
|
||||||
|
return this.add({ type: 'error', title, message, duration: duration ?? 8000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
warning(title: string, message?: string, duration?: number) {
|
||||||
|
return this.add({ type: 'warning', title, message, duration });
|
||||||
|
},
|
||||||
|
|
||||||
|
info(title: string, message?: string, duration?: number) {
|
||||||
|
return this.add({ type: 'info', title, message, duration });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { writable, derived } from 'svelte/store';
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
export interface WebhookEvent {
|
export interface WebhookEvent {
|
||||||
@@ -20,20 +19,45 @@ export interface RelayTarget {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stores
|
// Svelte 5 runes for reactive state
|
||||||
export const webhookEvents = writable<WebhookEvent[]>([]);
|
let webhookEvents = $state<WebhookEvent[]>([]);
|
||||||
export const relayTargets = writable<RelayTarget[]>([]);
|
let relayTargets = $state<RelayTarget[]>([]);
|
||||||
export const connectionStatus = writable<'connected' | 'disconnected' | 'connecting'>('disconnected');
|
let connectionStatus = $state<'connected' | 'disconnected' | 'connecting'>('disconnected');
|
||||||
export const isLoading = writable(false);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
// Derived stores
|
// Derived state using Svelte 5 runes
|
||||||
export const activeTargets = derived(relayTargets, $targets =>
|
export const webhookStore = {
|
||||||
$targets.filter(target => target.active)
|
// Reactive getters
|
||||||
);
|
get events() { return webhookEvents; },
|
||||||
|
get targets() { return relayTargets; },
|
||||||
export const recentEvents = derived(webhookEvents, $events =>
|
get status() { return connectionStatus; },
|
||||||
$events.slice(0, 10)
|
get loading() { return isLoading; },
|
||||||
);
|
|
||||||
|
// Derived values
|
||||||
|
get activeTargets() {
|
||||||
|
return relayTargets.filter(target => target.active);
|
||||||
|
},
|
||||||
|
get recentEvents() {
|
||||||
|
return webhookEvents.slice(0, 10);
|
||||||
|
},
|
||||||
|
get totalEvents() {
|
||||||
|
return webhookEvents.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
// State setters
|
||||||
|
setEvents: (events: WebhookEvent[]) => { webhookEvents = events; },
|
||||||
|
addEvent: (event: WebhookEvent) => {
|
||||||
|
webhookEvents = [event, ...webhookEvents].slice(0, 100);
|
||||||
|
},
|
||||||
|
setTargets: (targets: RelayTarget[]) => { relayTargets = targets; },
|
||||||
|
addTarget: (target: RelayTarget) => {
|
||||||
|
relayTargets = [...relayTargets, target];
|
||||||
|
},
|
||||||
|
removeTarget: (targetId: string) => {
|
||||||
|
relayTargets = relayTargets.filter(t => t.id !== targetId);
|
||||||
|
},
|
||||||
|
setStatus: (status: typeof connectionStatus) => { connectionStatus = status; },
|
||||||
|
setLoading: (loading: boolean) => { isLoading = loading; },
|
||||||
|
|
||||||
// WebSocket Connection management
|
// WebSocket Connection management
|
||||||
let websocket: WebSocket | null = null;
|
let websocket: WebSocket | null = null;
|
||||||
@@ -45,7 +69,7 @@ export const webhookStore = {
|
|||||||
connect: async () => {
|
connect: async () => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
connectionStatus.set('connecting');
|
connectionStatus = 'connecting';
|
||||||
|
|
||||||
// Create WebSocket connection to separate WebSocket server
|
// Create WebSocket connection to separate WebSocket server
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
@@ -59,7 +83,7 @@ export const webhookStore = {
|
|||||||
|
|
||||||
if (!sessionToken) {
|
if (!sessionToken) {
|
||||||
console.error('No session token found');
|
console.error('No session token found');
|
||||||
connectionStatus.set('disconnected');
|
connectionStatus = 'disconnected';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +93,7 @@ export const webhookStore = {
|
|||||||
websocket = new WebSocket(wsUrl);
|
websocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
connectionStatus.set('connected');
|
connectionStatus = 'connected';
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
startPingInterval();
|
startPingInterval();
|
||||||
};
|
};
|
||||||
@@ -85,12 +109,12 @@ export const webhookStore = {
|
|||||||
|
|
||||||
websocket.onerror = (error) => {
|
websocket.onerror = (error) => {
|
||||||
console.error('WebSocket error:', error);
|
console.error('WebSocket error:', error);
|
||||||
connectionStatus.set('disconnected');
|
connectionStatus = 'disconnected';
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = (event) => {
|
websocket.onclose = (event) => {
|
||||||
console.log('WebSocket closed:', event.code, event.reason);
|
console.log('WebSocket closed:', event.code, event.reason);
|
||||||
connectionStatus.set('disconnected');
|
connectionStatus = 'disconnected';
|
||||||
websocket = null;
|
websocket = null;
|
||||||
|
|
||||||
// Clear ping interval
|
// Clear ping interval
|
||||||
@@ -106,7 +130,7 @@ export const webhookStore = {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create WebSocket connection:', error);
|
console.error('Failed to create WebSocket connection:', error);
|
||||||
connectionStatus.set('disconnected');
|
connectionStatus = 'disconnected';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -126,7 +150,7 @@ export const webhookStore = {
|
|||||||
websocket.close(1000, 'User disconnect');
|
websocket.close(1000, 'User disconnect');
|
||||||
websocket = null;
|
websocket = null;
|
||||||
}
|
}
|
||||||
connectionStatus.set('disconnected');
|
connectionStatus = 'disconnected';
|
||||||
},
|
},
|
||||||
|
|
||||||
// Send message through WebSocket
|
// Send message through WebSocket
|
||||||
@@ -140,17 +164,17 @@ export const webhookStore = {
|
|||||||
loadHistory: async () => {
|
loadHistory: async () => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
isLoading.set(true);
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/webhooks');
|
const response = await fetch('/api/webhooks');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
webhookEvents.set(data.webhooks);
|
webhookEvents = data.webhooks;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load webhook history:', error);
|
console.error('Failed to load webhook history:', error);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.set(false);
|
isLoading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -162,7 +186,7 @@ export const webhookStore = {
|
|||||||
const response = await fetch('/api/relay/targets');
|
const response = await fetch('/api/relay/targets');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const targets = await response.json();
|
const targets = await response.json();
|
||||||
relayTargets.set(targets);
|
relayTargets = targets;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load relay targets:', error);
|
console.error('Failed to load relay targets:', error);
|
||||||
@@ -170,7 +194,7 @@ export const webhookStore = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Add relay target
|
// Add relay target
|
||||||
addTarget: async (target: string, nickname?: string) => {
|
addTargetRemote: async (target: string, nickname?: string) => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -182,7 +206,7 @@ export const webhookStore = {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const newTarget = await response.json();
|
const newTarget = await response.json();
|
||||||
relayTargets.update(targets => [...targets, newTarget]);
|
relayTargets = [...relayTargets, newTarget];
|
||||||
return newTarget;
|
return newTarget;
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
@@ -195,7 +219,7 @@ export const webhookStore = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Remove relay target
|
// Remove relay target
|
||||||
removeTarget: async (targetId: string) => {
|
removeTargetRemote: async (targetId: string) => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -206,9 +230,7 @@ export const webhookStore = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
relayTargets.update(targets =>
|
relayTargets = relayTargets.filter(target => target.id !== targetId);
|
||||||
targets.filter(target => target.id !== targetId)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove relay target:', error);
|
console.error('Failed to remove relay target:', error);
|
||||||
@@ -218,12 +240,22 @@ export const webhookStore = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming WebSocket messages
|
* Handle incoming WebSocket messages using Svelte 5 runes
|
||||||
*/
|
*/
|
||||||
function handleWebSocketMessage(data: any) {
|
function handleWebSocketMessage(data: any) {
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
webhookEvents.update(events => [data.data, ...events].slice(0, 100));
|
webhookEvents = [data.data, ...webhookEvents].slice(0, 100);
|
||||||
|
// Optional: Show notification for new webhooks (can be disabled for high volume)
|
||||||
|
if (webhookEvents.length <= 10) {
|
||||||
|
import('$stores/notifications').then(({ notificationStore }) => {
|
||||||
|
notificationStore.info(
|
||||||
|
'New Webhook',
|
||||||
|
`${data.data.method} ${data.data.path}`,
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'system':
|
case 'system':
|
||||||
console.log('System message:', data.data.message);
|
console.log('System message:', data.data.message);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async (event) => {
|
||||||
const session = await locals.auth();
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session
|
session
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { webhookStore } from '$lib/stores/webhooks';
|
import { webhookStore } from '$stores/webhooks';
|
||||||
|
import { notificationStore } from '$stores/notifications';
|
||||||
|
import ErrorBoundary from '$components/ErrorBoundary.svelte';
|
||||||
|
import NotificationContainer from '$components/NotificationContainer.svelte';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
export let data;
|
interface Props {
|
||||||
|
data: {
|
||||||
|
session?: {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
image?: string;
|
||||||
|
subdomain?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Initialize webhook store connection if user is authenticated
|
// Initialize webhook store connection if user is authenticated
|
||||||
@@ -20,10 +38,21 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reactive cleanup when session changes
|
// Svelte 5 effect for reactive cleanup when session changes
|
||||||
$: if (!data.session?.user) {
|
$effect(() => {
|
||||||
webhookStore.disconnect();
|
if (!data.session?.user) {
|
||||||
}
|
webhookStore.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Svelte 5 effect for WebSocket connection status notifications
|
||||||
|
$effect(() => {
|
||||||
|
if (webhookStore.status === 'connected') {
|
||||||
|
notificationStore.success('Connected', 'Real-time updates are active', 3000);
|
||||||
|
} else if (webhookStore.status === 'disconnected') {
|
||||||
|
notificationStore.warning('Disconnected', 'Real-time updates are unavailable', 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
@@ -38,27 +67,27 @@
|
|||||||
<div class="ml-8 flex space-x-4">
|
<div class="ml-8 flex space-x-4">
|
||||||
<a
|
<a
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
class="px-3 py-2 rounded-md text-sm font-medium"
|
class="px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
class:bg-gray-100={$page.url.pathname === '/dashboard'}
|
class:bg-primary-50={$page.url.pathname === '/dashboard'}
|
||||||
class:text-gray-900={$page.url.pathname === '/dashboard'}
|
class:text-primary-700={$page.url.pathname === '/dashboard'}
|
||||||
class:text-gray-500={$page.url.pathname !== '/dashboard'}
|
class:text-gray-500={$page.url.pathname !== '/dashboard'}
|
||||||
>
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/dashboard/webhooks"
|
href="/dashboard/webhooks"
|
||||||
class="px-3 py-2 rounded-md text-sm font-medium"
|
class="px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
class:bg-gray-100={$page.url.pathname.startsWith('/dashboard/webhooks')}
|
class:bg-primary-50={$page.url.pathname.startsWith('/dashboard/webhooks')}
|
||||||
class:text-gray-900={$page.url.pathname.startsWith('/dashboard/webhooks')}
|
class:text-primary-700={$page.url.pathname.startsWith('/dashboard/webhooks')}
|
||||||
class:text-gray-500={!$page.url.pathname.startsWith('/dashboard/webhooks')}
|
class:text-gray-500={!$page.url.pathname.startsWith('/dashboard/webhooks')}
|
||||||
>
|
>
|
||||||
Webhooks
|
Webhooks
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/dashboard/targets"
|
href="/dashboard/targets"
|
||||||
class="px-3 py-2 rounded-md text-sm font-medium"
|
class="px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
class:bg-gray-100={$page.url.pathname.startsWith('/dashboard/targets')}
|
class:bg-primary-50={$page.url.pathname.startsWith('/dashboard/targets')}
|
||||||
class:text-gray-900={$page.url.pathname.startsWith('/dashboard/targets')}
|
class:text-primary-700={$page.url.pathname.startsWith('/dashboard/targets')}
|
||||||
class:text-gray-500={!$page.url.pathname.startsWith('/dashboard/targets')}
|
class:text-gray-500={!$page.url.pathname.startsWith('/dashboard/targets')}
|
||||||
>
|
>
|
||||||
Relay Targets
|
Relay Targets
|
||||||
@@ -72,7 +101,7 @@
|
|||||||
<form action="/auth/signout" method="post">
|
<form action="/auth/signout" method="post">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="bg-gray-200 hover:bg-gray-300 px-4 py-2 rounded-md text-sm font-medium text-gray-700"
|
class="btn-secondary"
|
||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
@@ -84,6 +113,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<slot />
|
<ErrorBoundary fallback="Application Error - Please refresh the page">
|
||||||
|
{#snippet children()}
|
||||||
|
<slot />
|
||||||
|
{/snippet}
|
||||||
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Global notification container -->
|
||||||
|
<NotificationContainer />
|
||||||
</div>
|
</div>
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { signIn } from '@auth/sveltekit/client';
|
import { signIn } from '@auth/sveltekit/client';
|
||||||
export let data;
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
session?: {
|
||||||
|
user?: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -50,8 +59,8 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
on:click={() => signIn('github')}
|
onclick={() => signIn('github')}
|
||||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-gray-800 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gray-900 hover:bg-gray-800 focus-ring transition-colors"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd"></path>
|
<path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd"></path>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ locals, cookies }) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
const session = await locals.auth();
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return json({ session: null });
|
return json({ session: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get session token from cookies for WebSocket authentication
|
// Get session token from cookies for WebSocket authentication
|
||||||
const sessionToken = cookies.get('authjs.session-token');
|
const sessionToken = event.cookies.get('authjs.session-token');
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
@@ -9,26 +9,26 @@ const createTargetSchema = z.object({
|
|||||||
nickname: z.string().optional()
|
nickname: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
const session = await locals.auth();
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw error(401, 'Unauthorized');
|
error(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
const targets = await getRelayTargets(session.user.id);
|
const targets = await getRelayTargets(session.user.id);
|
||||||
return json(targets);
|
return json(targets);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const session = await locals.auth();
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw error(401, 'Unauthorized');
|
error(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await event.request.json();
|
||||||
const { target, nickname } = createTargetSchema.parse(body);
|
const { target, nickname } = createTargetSchema.parse(body);
|
||||||
|
|
||||||
const newTarget = await createRelayTarget(session.user.id, target, nickname);
|
const newTarget = await createRelayTarget(session.user.id, target, nickname);
|
||||||
@@ -36,21 +36,21 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
return json(newTarget, { status: 201 });
|
return json(newTarget, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof z.ZodError) {
|
if (err instanceof z.ZodError) {
|
||||||
throw error(400, err.errors[0].message);
|
error(400, err.errors[0].message);
|
||||||
}
|
}
|
||||||
throw error(500, 'Failed to create relay target');
|
error(500, 'Failed to create relay target');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ request, locals }) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
const session = await locals.auth();
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw error(401, 'Unauthorized');
|
error(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { targetId } = await request.json();
|
const { targetId } = await event.request.json();
|
||||||
|
|
||||||
const target = await prisma.relayTarget.findFirst({
|
const target = await prisma.relayTarget.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -60,7 +60,7 @@ export const DELETE: RequestHandler = async ({ request, locals }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw error(404, 'Relay target not found');
|
error(404, 'Relay target not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.relayTarget.update({
|
await prisma.relayTarget.update({
|
||||||
@@ -70,6 +70,6 @@ export const DELETE: RequestHandler = async ({ request, locals }) => {
|
|||||||
|
|
||||||
return json({ success: true });
|
return json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw error(500, 'Failed to delete relay target');
|
error(500, 'Failed to delete relay target');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const session = await locals.auth();
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
if (!session?.user?.subdomain) {
|
if (!session?.user?.subdomain) {
|
||||||
return json({ error: 'Authentication required' }, { status: 401 });
|
return json({ error: 'Authentication required' }, { status: 401 });
|
||||||
@@ -44,7 +44,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
|
|
||||||
for (const test of testPayloads) {
|
for (const test of testPayloads) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${request.url.origin}/api/webhook/${subdomain}`, {
|
const response = await fetch(`${event.url.origin}/api/webhook/${subdomain}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': test.contentType,
|
'Content-Type': test.contentType,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import type { RequestHandler } from './$types';
|
|||||||
import { prisma } from '$db';
|
import { prisma } from '$db';
|
||||||
import { broadcastToUser, forwardToRelayTargets } from '$lib/server/relay';
|
import { broadcastToUser, forwardToRelayTargets } from '$lib/server/relay';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, params, url }) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
const { request, params, url } = event;
|
||||||
const { subdomain } = params;
|
const { subdomain } = params;
|
||||||
|
|
||||||
if (!subdomain) {
|
if (!subdomain) {
|
||||||
@@ -138,11 +139,13 @@ export const POST: RequestHandler = async ({ request, params, url }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle other HTTP methods
|
// Handle other HTTP methods
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
const { params } = event;
|
||||||
return json({
|
return json({
|
||||||
message: `Webhook endpoint for ${params.subdomain}`,
|
message: `Webhook endpoint for ${params.subdomain}`,
|
||||||
methods: ['POST'],
|
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
|
version: '2.0'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { json, error } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getRecentWebhooks } from '$lib/server/relay';
|
import { getRecentWebhooks } from '$lib/server/relay';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
const session = await locals.auth();
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw error(401, 'Unauthorized');
|
error(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '50');
|
const limit = parseInt(event.url.searchParams.get('limit') || '50');
|
||||||
const webhooks = await getRecentWebhooks(session.user.id, limit);
|
const webhooks = await getRecentWebhooks(session.user.id, limit);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
@@ -17,6 +17,8 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
...webhook,
|
...webhook,
|
||||||
body: webhook.body ? JSON.parse(webhook.body) : null,
|
body: webhook.body ? JSON.parse(webhook.body) : null,
|
||||||
headers: webhook.headers ? JSON.parse(webhook.headers) : null
|
headers: webhook.headers ? JSON.parse(webhook.headers) : null
|
||||||
}))
|
})),
|
||||||
|
total: webhooks.length,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
const session = await locals.auth();
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
throw redirect(303, '/dashboard');
|
redirect(303, '/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async (event) => {
|
||||||
const session = await locals.auth();
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
throw redirect(303, '/');
|
redirect(303, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { webhookEvents, connectionStatus, recentEvents } from '$lib/stores/webhooks';
|
import { webhookStore } from '$stores/webhooks';
|
||||||
import WebSocketStatus from '$lib/components/WebSocketStatus.svelte';
|
import WebhookMetrics from '$components/WebhookMetrics.svelte';
|
||||||
import WebhookEventCard from '$lib/components/WebhookEventCard.svelte';
|
import ModernWebhookDashboard from '$components/ModernWebhookDashboard.svelte';
|
||||||
|
|
||||||
export let data;
|
interface Props {
|
||||||
|
data: {
|
||||||
$: user = data.session?.user;
|
session?: {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
image?: string;
|
||||||
|
subdomain?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
let user = $derived(data.session?.user);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -22,146 +36,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Info & Connection Status -->
|
<!-- Enhanced Metrics Dashboard -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
<WebhookMetrics />
|
||||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
|
||||||
<div class="p-5">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<img class="h-10 w-10 rounded-full" src={user?.image} alt={user?.name} />
|
|
||||||
</div>
|
|
||||||
<div class="ml-5 w-0 flex-1">
|
|
||||||
<dl>
|
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Subdomain</dt>
|
|
||||||
<dd class="text-lg font-medium text-gray-900">{user?.subdomain}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
<!-- Modern Webhook Dashboard -->
|
||||||
<div class="p-5">
|
<ModernWebhookDashboard {user} />
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<WebSocketStatus />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
|
||||||
<div class="p-5">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
|
|
||||||
<span class="text-white text-sm font-medium">{$webhookEvents.length}</span>
|
|
||||||
</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-lg font-medium text-gray-900">Today</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Webhook Endpoint Info -->
|
|
||||||
<div class="bg-white shadow rounded-lg mb-8">
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
|
||||||
Your Webhook Endpoint
|
|
||||||
</h3>
|
|
||||||
<div class="bg-gray-50 rounded-md p-4">
|
|
||||||
<code class="text-sm text-gray-800">
|
|
||||||
POST https://yourdomain.com/api/webhook/{user?.subdomain}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-gray-500">
|
|
||||||
Send webhooks to this endpoint. All events will be logged and forwarded to your connected relay targets.
|
|
||||||
</p>
|
|
||||||
<div class="mt-4 flex space-x-3">
|
|
||||||
<button
|
|
||||||
on:click={async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/test-webhook', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
console.log('Test webhook results:', result);
|
|
||||||
alert(`Test completed! ${result.results?.length || 0} webhook tests run successfully.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to run webhook tests:', error);
|
|
||||||
alert('Test failed. Check console for details.');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-md text-sm font-medium"
|
|
||||||
>
|
|
||||||
Run Full Test Suite
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
on:click={async () => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/webhook/${user?.subdomain}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
test: true,
|
|
||||||
message: 'Simple test from dashboard',
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send test webhook:', error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="bg-gray-600 hover:bg-gray-700 text-white px-3 py-2 rounded-md text-sm font-medium"
|
|
||||||
>
|
|
||||||
Send Simple Test
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent Events -->
|
|
||||||
<div class="bg-white shadow rounded-lg">
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Recent Webhook Events
|
|
||||||
</h3>
|
|
||||||
<a
|
|
||||||
href="/dashboard/webhooks"
|
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
View all →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if $recentEvents.length > 0}
|
|
||||||
<div class="space-y-4">
|
|
||||||
{#each $recentEvents as event (event.id)}
|
|
||||||
<WebhookEventCard {event} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<div class="text-gray-400 mb-2">
|
|
||||||
<svg class="mx-auto h-12 w-12" 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="text-sm font-medium text-gray-900">No webhook events yet</h3>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Send a test webhook to your endpoint to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,26 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { relayTargets, webhookStore } from '$lib/stores/webhooks';
|
import { webhookStore } from '$stores/webhooks';
|
||||||
|
import { notificationStore } from '$stores/notifications';
|
||||||
import { Plus, ExternalLink, Trash2 } from 'lucide-svelte';
|
import { Plus, ExternalLink, Trash2 } from 'lucide-svelte';
|
||||||
|
|
||||||
export let data;
|
interface Props {
|
||||||
|
data: {
|
||||||
|
session?: {
|
||||||
|
user?: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let showAddForm = false;
|
let { data }: Props = $props();
|
||||||
let newTarget = '';
|
|
||||||
let newNickname = '';
|
let showAddForm = $state(false);
|
||||||
let isSubmitting = false;
|
let newTarget = $state('');
|
||||||
|
let newNickname = $state('');
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
|
||||||
async function addTarget() {
|
async function addTarget() {
|
||||||
if (!newTarget.trim()) return;
|
if (!newTarget.trim()) return;
|
||||||
|
|
||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
try {
|
try {
|
||||||
await webhookStore.addTarget(newTarget.trim(), newNickname.trim() || undefined);
|
await webhookStore.addTargetRemote(newTarget.trim(), newNickname.trim() || undefined);
|
||||||
newTarget = '';
|
newTarget = '';
|
||||||
newNickname = '';
|
newNickname = '';
|
||||||
showAddForm = false;
|
showAddForm = false;
|
||||||
|
notificationStore.success('Target Added', 'Relay target has been successfully configured');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add target:', error);
|
console.error('Failed to add target:', error);
|
||||||
alert('Failed to add relay target. Please check the URL and try again.');
|
notificationStore.error('Failed to Add Target', 'Please check the URL and try again');
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
}
|
}
|
||||||
@@ -30,10 +40,11 @@
|
|||||||
if (!confirm('Are you sure you want to remove this relay target?')) return;
|
if (!confirm('Are you sure you want to remove this relay target?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await webhookStore.removeTarget(targetId);
|
await webhookStore.removeTargetRemote(targetId);
|
||||||
|
notificationStore.success('Target Removed', 'Relay target has been deactivated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove target:', error);
|
console.error('Failed to remove target:', error);
|
||||||
alert('Failed to remove relay target.');
|
notificationStore.error('Failed to Remove Target', 'Please try again');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -53,8 +64,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
on:click={() => showAddForm = !showAddForm}
|
onclick={() => showAddForm = !showAddForm}
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
class="btn-primary inline-flex items-center"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4 mr-2" />
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
Add Target
|
Add Target
|
||||||
@@ -69,7 +80,7 @@
|
|||||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||||
Add New Relay Target
|
Add New Relay Target
|
||||||
</h3>
|
</h3>
|
||||||
<form on:submit|preventDefault={addTarget} class="space-y-4">
|
<form onsubmit={(e) => { e.preventDefault(); addTarget(); }} class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="target-url" class="block text-sm font-medium text-gray-700">
|
<label for="target-url" class="block text-sm font-medium text-gray-700">
|
||||||
Target URL
|
Target URL
|
||||||
@@ -98,15 +109,15 @@
|
|||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => showAddForm = false}
|
onclick={() => showAddForm = false}
|
||||||
class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50"
|
class="btn-secondary"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
class="bg-blue-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
class="btn-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Adding...' : 'Add Target'}
|
{isSubmitting ? 'Adding...' : 'Add Target'}
|
||||||
</button>
|
</button>
|
||||||
@@ -120,12 +131,12 @@
|
|||||||
<div class="bg-white shadow rounded-lg">
|
<div class="bg-white shadow rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||||
Active Relay Targets ({$relayTargets.length})
|
Active Relay Targets ({webhookStore.targets.length})
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{#if $relayTargets.length > 0}
|
{#if webhookStore.targets.length > 0}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each $relayTargets as target (target.id)}
|
{#each webhookStore.targets as target (target.id)}
|
||||||
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
@@ -137,7 +148,7 @@
|
|||||||
<p class="text-sm font-medium text-gray-900">{target.target}</p>
|
<p class="text-sm font-medium text-gray-900">{target.target}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-100 text-success-800">
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,8 +167,8 @@
|
|||||||
<ExternalLink class="w-4 h-4" />
|
<ExternalLink class="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
on:click={() => removeTarget(target.id)}
|
onclick={() => removeTarget(target.id)}
|
||||||
class="text-red-400 hover:text-red-600"
|
class="text-danger-400 hover:text-danger-600 transition-colors"
|
||||||
title="Remove target"
|
title="Remove target"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
<Trash2 class="w-4 h-4" />
|
||||||
@@ -178,8 +189,8 @@
|
|||||||
Add relay targets to forward incoming webhooks to your services.
|
Add relay targets to forward incoming webhooks to your services.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
on:click={() => showAddForm = true}
|
onclick={() => showAddForm = true}
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-600 bg-blue-100 hover:bg-blue-200"
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-primary-600 bg-primary-100 hover:bg-primary-200 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4 mr-2" />
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
Add Your First Target
|
Add Your First Target
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { webhookEvents, isLoading } from '$lib/stores/webhooks';
|
import { webhookStore } from '$stores/webhooks';
|
||||||
import WebhookEventCard from '$lib/components/WebhookEventCard.svelte';
|
import WebhookEventCard from '$components/WebhookEventCard.svelte';
|
||||||
import WebSocketStatus from '$lib/components/WebSocketStatus.svelte';
|
import WebSocketStatus from '$components/WebSocketStatus.svelte';
|
||||||
|
|
||||||
export let data;
|
interface Props {
|
||||||
|
data: {
|
||||||
|
session?: {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
image?: string;
|
||||||
|
subdomain?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
$: user = data.session?.user;
|
let { data }: Props = $props();
|
||||||
|
let user = $derived(data.session?.user);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -40,12 +54,12 @@
|
|||||||
<code class="text-sm text-gray-800">
|
<code class="text-sm text-gray-800">
|
||||||
POST https://yourdomain.com/api/webhook/{user?.subdomain}
|
POST https://yourdomain.com/api/webhook/{user?.subdomain}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
on:click={() => navigator.clipboard.writeText(`https://yourdomain.com/api/webhook/${user?.subdomain}`)}
|
onclick={() => navigator.clipboard.writeText(`https://yourdomain.com/api/webhook/${user?.subdomain}`)}
|
||||||
class="text-xs text-blue-600 hover:text-blue-500"
|
class="text-xs text-primary-600 hover:text-primary-500 transition-colors"
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
@@ -63,7 +77,7 @@
|
|||||||
Test Your Webhook
|
Test Your Webhook
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
on:click={async () => {
|
onclick={async () => {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/webhook/${user?.subdomain}`, {
|
await fetch(`/api/webhook/${user?.subdomain}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -78,7 +92,7 @@
|
|||||||
console.error('Failed to send test webhook:', error);
|
console.error('Failed to send test webhook:', error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
class="btn-primary"
|
||||||
>
|
>
|
||||||
Send Test Webhook
|
Send Test Webhook
|
||||||
</button>
|
</button>
|
||||||
@@ -89,16 +103,16 @@
|
|||||||
<div class="bg-white shadow rounded-lg">
|
<div class="bg-white shadow rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||||
Event History ({$webhookEvents.length})
|
Event History ({webhookStore.totalEvents})
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{#if $isLoading}
|
{#if webhookStore.loading}
|
||||||
<div class="flex justify-center py-8">
|
<div class="flex justify-center py-8">
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if $webhookEvents.length > 0}
|
{:else if webhookStore.events.length > 0}
|
||||||
<div class="space-y-4 max-h-96 overflow-y-auto">
|
<div class="space-y-4 max-h-96 overflow-y-auto custom-scrollbar">
|
||||||
{#each $webhookEvents as event (event.id)}
|
{#each webhookStore.events as event (event.id)}
|
||||||
<WebhookEventCard {event} />
|
<WebhookEventCard {event} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,19 +3,27 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
// Consult https://svelte.dev/docs/kit/integrations for more information about preprocessors
|
||||||
// for more information about preprocessors
|
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
kit: {
|
kit: {
|
||||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
// adapter-auto supports most environments, see https://svelte.dev/docs/kit/adapters for a list.
|
||||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
|
||||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
|
||||||
adapter: adapter(),
|
adapter: adapter(),
|
||||||
alias: {
|
alias: {
|
||||||
$db: './src/lib/server/db',
|
$db: './src/lib/server/db',
|
||||||
$auth: './src/lib/server/auth'
|
$auth: './src/lib/server/auth',
|
||||||
|
$stores: './src/lib/stores',
|
||||||
|
$components: './src/lib/components'
|
||||||
|
},
|
||||||
|
// SvelteKit 2 enhanced configuration
|
||||||
|
version: {
|
||||||
|
pollInterval: 300
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Svelte 5 compiler options
|
||||||
|
compilerOptions: {
|
||||||
|
runes: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
|
||||||
theme: {
|
|
||||||
extend: {}
|
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
};
|
|
||||||
92
sveltekit-integration/tailwind.config.ts
Normal file
92
sveltekit-integration/tailwind.config.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
// Tailwind 4 enhanced color palette
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
300: '#93c5fd',
|
||||||
|
400: '#60a5fa',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
800: '#1e40af',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
950: '#172554'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
50: '#f0fdf4',
|
||||||
|
100: '#dcfce7',
|
||||||
|
200: '#bbf7d0',
|
||||||
|
300: '#86efac',
|
||||||
|
400: '#4ade80',
|
||||||
|
500: '#22c55e',
|
||||||
|
600: '#16a34a',
|
||||||
|
700: '#15803d',
|
||||||
|
800: '#166534',
|
||||||
|
900: '#14532d',
|
||||||
|
950: '#052e16'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
50: '#fffbeb',
|
||||||
|
100: '#fef3c7',
|
||||||
|
200: '#fde68a',
|
||||||
|
300: '#fcd34d',
|
||||||
|
400: '#fbbf24',
|
||||||
|
500: '#f59e0b',
|
||||||
|
600: '#d97706',
|
||||||
|
700: '#b45309',
|
||||||
|
800: '#92400e',
|
||||||
|
900: '#78350f',
|
||||||
|
950: '#451a03'
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
50: '#fef2f2',
|
||||||
|
100: '#fee2e2',
|
||||||
|
200: '#fecaca',
|
||||||
|
300: '#fca5a5',
|
||||||
|
400: '#f87171',
|
||||||
|
500: '#ef4444',
|
||||||
|
600: '#dc2626',
|
||||||
|
700: '#b91c1c',
|
||||||
|
800: '#991b1b',
|
||||||
|
900: '#7f1d1d',
|
||||||
|
950: '#450a0a'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Tailwind 4 enhanced animations
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
'bounce-subtle': 'bounceSubtle 2s infinite'
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' }
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' }
|
||||||
|
},
|
||||||
|
bounceSubtle: {
|
||||||
|
'0%, 100%': { transform: 'translateY(0)' },
|
||||||
|
'50%': { transform: 'translateY(-5px)' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Enhanced spacing scale
|
||||||
|
spacing: {
|
||||||
|
'18': '4.5rem',
|
||||||
|
'88': '22rem',
|
||||||
|
'128': '32rem'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
} satisfies Config;
|
||||||
30
sveltekit-integration/tsconfig.json
Normal file
30
sveltekit-integration/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"verbatimModuleSyntax": false
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.svelte"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules/**",
|
||||||
|
".svelte-kit/**",
|
||||||
|
"build/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [
|
||||||
|
sveltekit(),
|
||||||
|
tailwindcss()
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: true
|
host: true
|
||||||
|
},
|
||||||
|
// Vite 6 optimizations
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['ws', 'zod', '@prisma/client']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user