Initial commit: Portfolio Website
Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
# Real-Time Dashboards mit WebSockets: Live-Daten ohne Polling
|
||||
|
||||
**Meta-Description:** Aufbau von Echtzeit-Dashboards mit WebSockets. Server-Push, Live-Updates und Performance-Optimierung für datenintensive Anwendungen.
|
||||
|
||||
**Keywords:** Real-Time Dashboard, WebSocket, Live Data, Server Push, Dashboard Development, Data Visualization, Live Updates
|
||||
|
||||
---
|
||||
|
||||
## Einführung
|
||||
|
||||
Polling ist tot. Moderne Dashboards nutzen **Server-Push via WebSockets** für Echtzeit-Updates ohne die Last von ständigen HTTP-Requests.
|
||||
|
||||
---
|
||||
|
||||
## Polling vs. WebSocket
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ POLLING VS. WEBSOCKET │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Polling (Alt): │
|
||||
│ Client: "Neue Daten?" → Server: "Nein" │
|
||||
│ Client: "Neue Daten?" → Server: "Nein" │
|
||||
│ Client: "Neue Daten?" → Server: "Nein" │
|
||||
│ Client: "Neue Daten?" → Server: "Ja! Hier." │
|
||||
│ └── 4 Requests, 3 unnötig │
|
||||
│ │
|
||||
│ WebSocket (Neu): │
|
||||
│ Client ←─────────────→ Server (persistente Verbindung) │
|
||||
│ Server: "Neue Daten!" → Client │
|
||||
│ └── 1 Push, sofort │
|
||||
│ │
|
||||
│ Vorteile WebSocket: │
|
||||
│ ✓ Niedrigere Latenz (ms statt s) │
|
||||
│ ✓ Weniger Server-Last │
|
||||
│ ✓ Weniger Bandbreite │
|
||||
│ ✓ Echte Real-Time-Updates │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Architecture
|
||||
|
||||
```typescript
|
||||
// src/dashboard-server.ts
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
interface DashboardClient {
|
||||
ws: WebSocket;
|
||||
userId: string;
|
||||
subscriptions: Set<string>; // Topics die der Client abonniert hat
|
||||
}
|
||||
|
||||
class DashboardServer {
|
||||
private wss: WebSocketServer;
|
||||
private clients = new Map<string, DashboardClient>();
|
||||
private redis: Redis;
|
||||
private redisSub: Redis;
|
||||
|
||||
constructor(server: any) {
|
||||
this.wss = new WebSocketServer({ server, path: '/dashboard' });
|
||||
this.redis = new Redis(process.env.REDIS_URL!);
|
||||
this.redisSub = new Redis(process.env.REDIS_URL!);
|
||||
|
||||
this.setupWebSocket();
|
||||
this.setupRedisSubscription();
|
||||
}
|
||||
|
||||
private setupWebSocket() {
|
||||
this.wss.on('connection', (ws, req) => {
|
||||
const clientId = crypto.randomUUID();
|
||||
const userId = this.extractUserId(req);
|
||||
|
||||
const client: DashboardClient = {
|
||||
ws,
|
||||
userId,
|
||||
subscriptions: new Set()
|
||||
};
|
||||
|
||||
this.clients.set(clientId, client);
|
||||
console.log(`Dashboard client connected: ${clientId}`);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
this.handleClientMessage(clientId, JSON.parse(data.toString()));
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
this.clients.delete(clientId);
|
||||
console.log(`Dashboard client disconnected: ${clientId}`);
|
||||
});
|
||||
|
||||
// Initial Data senden
|
||||
this.sendInitialData(client);
|
||||
});
|
||||
}
|
||||
|
||||
private handleClientMessage(clientId: string, message: any) {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) return;
|
||||
|
||||
switch (message.type) {
|
||||
case 'subscribe':
|
||||
this.subscribeToTopic(client, message.topic);
|
||||
break;
|
||||
|
||||
case 'unsubscribe':
|
||||
this.unsubscribeFromTopic(client, message.topic);
|
||||
break;
|
||||
|
||||
case 'request-data':
|
||||
this.sendDataForTopic(client, message.topic);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private subscribeToTopic(client: DashboardClient, topic: string) {
|
||||
client.subscriptions.add(topic);
|
||||
console.log(`Client subscribed to: ${topic}`);
|
||||
}
|
||||
|
||||
private unsubscribeFromTopic(client: DashboardClient, topic: string) {
|
||||
client.subscriptions.delete(topic);
|
||||
}
|
||||
|
||||
private setupRedisSubscription() {
|
||||
// Auf Data-Updates hören
|
||||
this.redisSub.psubscribe('dashboard:*');
|
||||
|
||||
this.redisSub.on('pmessage', (pattern, channel, message) => {
|
||||
const topic = channel.replace('dashboard:', '');
|
||||
const data = JSON.parse(message);
|
||||
|
||||
this.broadcastToSubscribers(topic, data);
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastToSubscribers(topic: string, data: any) {
|
||||
this.clients.forEach((client) => {
|
||||
if (client.subscriptions.has(topic) &&
|
||||
client.ws.readyState === WebSocket.OPEN) {
|
||||
client.ws.send(JSON.stringify({
|
||||
type: 'data-update',
|
||||
topic,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Externe Data-Updates publishen
|
||||
async publishUpdate(topic: string, data: any) {
|
||||
await this.redis.publish(`dashboard:${topic}`, JSON.stringify(data));
|
||||
}
|
||||
|
||||
private async sendInitialData(client: DashboardClient) {
|
||||
// Standard-Metriken laden
|
||||
const metrics = await this.loadInitialMetrics();
|
||||
|
||||
client.ws.send(JSON.stringify({
|
||||
type: 'initial-data',
|
||||
data: metrics
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadInitialMetrics() {
|
||||
// Letzte Werte aus Redis laden
|
||||
const keys = ['sales', 'users', 'orders', 'revenue'];
|
||||
const values = await Promise.all(
|
||||
keys.map(k => this.redis.get(`metrics:${k}`))
|
||||
);
|
||||
|
||||
return keys.reduce((acc, key, i) => {
|
||||
acc[key] = JSON.parse(values[i] || '{}');
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Publisher Service
|
||||
|
||||
```typescript
|
||||
// src/services/metrics-publisher.ts
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
class MetricsPublisher {
|
||||
private redis: Redis;
|
||||
|
||||
constructor() {
|
||||
this.redis = new Redis(process.env.REDIS_URL!);
|
||||
}
|
||||
|
||||
async publishSalesUpdate(data: SalesData) {
|
||||
// In Redis speichern (für neue Clients)
|
||||
await this.redis.set('metrics:sales', JSON.stringify(data));
|
||||
|
||||
// An alle Subscriber publishen
|
||||
await this.redis.publish('dashboard:sales', JSON.stringify(data));
|
||||
}
|
||||
|
||||
async publishUserStats(data: UserStats) {
|
||||
await this.redis.set('metrics:users', JSON.stringify(data));
|
||||
await this.redis.publish('dashboard:users', JSON.stringify(data));
|
||||
}
|
||||
|
||||
async publishOrderMetrics(data: OrderMetrics) {
|
||||
await this.redis.set('metrics:orders', JSON.stringify(data));
|
||||
await this.redis.publish('dashboard:orders', JSON.stringify(data));
|
||||
}
|
||||
|
||||
// Batch-Updates für mehrere Metriken
|
||||
async publishBatch(updates: Record<string, any>) {
|
||||
const pipeline = this.redis.pipeline();
|
||||
|
||||
for (const [topic, data] of Object.entries(updates)) {
|
||||
pipeline.set(`metrics:${topic}`, JSON.stringify(data));
|
||||
pipeline.publish(`dashboard:${topic}`, JSON.stringify(data));
|
||||
}
|
||||
|
||||
await pipeline.exec();
|
||||
}
|
||||
}
|
||||
|
||||
// Beispiel: Periodische Updates
|
||||
const publisher = new MetricsPublisher();
|
||||
|
||||
setInterval(async () => {
|
||||
const liveMetrics = await fetchLiveMetrics();
|
||||
await publisher.publishBatch(liveMetrics);
|
||||
}, 1000); // Jede Sekunde
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React Dashboard Client
|
||||
|
||||
```typescript
|
||||
// src/hooks/useDashboard.ts
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface DashboardData {
|
||||
sales: SalesData | null;
|
||||
users: UserStats | null;
|
||||
orders: OrderMetrics | null;
|
||||
revenue: RevenueData | null;
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [data, setData] = useState<DashboardData>({
|
||||
sales: null,
|
||||
users: null,
|
||||
orders: null,
|
||||
revenue: null
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(`${process.env.NEXT_PUBLIC_WS_URL}/dashboard`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
// Alle Topics abonnieren
|
||||
ws.send(JSON.stringify({ type: 'subscribe', topic: 'sales' }));
|
||||
ws.send(JSON.stringify({ type: 'subscribe', topic: 'users' }));
|
||||
ws.send(JSON.stringify({ type: 'subscribe', topic: 'orders' }));
|
||||
ws.send(JSON.stringify({ type: 'subscribe', topic: 'revenue' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
switch (message.type) {
|
||||
case 'initial-data':
|
||||
setData(message.data);
|
||||
break;
|
||||
|
||||
case 'data-update':
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
[message.topic]: message.data
|
||||
}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
// Reconnection Logic
|
||||
setTimeout(() => {
|
||||
// Reconnect...
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const subscribe = useCallback((topic: string) => {
|
||||
wsRef.current?.send(JSON.stringify({ type: 'subscribe', topic }));
|
||||
}, []);
|
||||
|
||||
const unsubscribe = useCallback((topic: string) => {
|
||||
wsRef.current?.send(JSON.stringify({ type: 'unsubscribe', topic }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
isConnected,
|
||||
subscribe,
|
||||
unsubscribe
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard Component
|
||||
|
||||
```tsx
|
||||
// src/components/Dashboard.tsx
|
||||
import { useDashboard } from '../hooks/useDashboard';
|
||||
import { LineChart, BarChart, StatCard } from './charts';
|
||||
|
||||
export function Dashboard() {
|
||||
const { data, isConnected } = useDashboard();
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-100 min-h-screen">
|
||||
{/* Connection Status */}
|
||||
<div className={`mb-4 px-4 py-2 rounded ${
|
||||
isConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
} text-white`}>
|
||||
{isConnected ? '🟢 Live' : '🔴 Reconnecting...'}
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<StatCard
|
||||
title="Umsatz heute"
|
||||
value={data.revenue?.today || 0}
|
||||
change={data.revenue?.changePercent || 0}
|
||||
format="currency"
|
||||
/>
|
||||
<StatCard
|
||||
title="Bestellungen"
|
||||
value={data.orders?.count || 0}
|
||||
change={data.orders?.changePercent || 0}
|
||||
/>
|
||||
<StatCard
|
||||
title="Aktive User"
|
||||
value={data.users?.active || 0}
|
||||
change={data.users?.changePercent || 0}
|
||||
/>
|
||||
<StatCard
|
||||
title="Conversion Rate"
|
||||
value={data.sales?.conversionRate || 0}
|
||||
change={data.sales?.changePercent || 0}
|
||||
format="percent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg p-4 shadow">
|
||||
<h3 className="font-semibold mb-4">Umsatz (Live)</h3>
|
||||
<LineChart
|
||||
data={data.revenue?.timeline || []}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 shadow">
|
||||
<h3 className="font-semibold mb-4">Bestellungen pro Stunde</h3>
|
||||
<BarChart
|
||||
data={data.orders?.hourly || []}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animated Chart Component
|
||||
|
||||
```tsx
|
||||
// src/components/charts/AnimatedLineChart.tsx
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface DataPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function AnimatedLineChart({
|
||||
data,
|
||||
maxPoints = 60
|
||||
}: {
|
||||
data: DataPoint[];
|
||||
maxPoints?: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [displayData, setDisplayData] = useState<DataPoint[]>([]);
|
||||
|
||||
// Neue Datenpunkte animiert hinzufügen
|
||||
useEffect(() => {
|
||||
if (data.length > displayData.length) {
|
||||
// Neuen Punkt hinzufügen
|
||||
setDisplayData(prev => {
|
||||
const newData = [...prev, data[data.length - 1]];
|
||||
// Max Points einhalten
|
||||
if (newData.length > maxPoints) {
|
||||
return newData.slice(-maxPoints);
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Canvas rendern
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || displayData.length === 0) return;
|
||||
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Skalierung berechnen
|
||||
const values = displayData.map(d => d.value);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
|
||||
// Line zeichnen
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
displayData.forEach((point, i) => {
|
||||
const x = (i / (maxPoints - 1)) * width;
|
||||
const y = height - ((point.value - min) / range) * height;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
// Gradient Fill
|
||||
ctx.lineTo(width, height);
|
||||
ctx.lineTo(0, height);
|
||||
ctx.closePath();
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)');
|
||||
gradient.addColorStop(1, 'rgba(59, 130, 246, 0)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
}, [displayData, maxPoints]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={600}
|
||||
height={200}
|
||||
className="w-full h-48"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance-Optimierungen
|
||||
|
||||
### 1. Throttling für High-Frequency Updates
|
||||
|
||||
```typescript
|
||||
function throttle<T>(
|
||||
fn: (data: T) => void,
|
||||
intervalMs: number
|
||||
): (data: T) => void {
|
||||
let lastData: T | null = null;
|
||||
let scheduled = false;
|
||||
|
||||
return (data: T) => {
|
||||
lastData = data;
|
||||
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
setTimeout(() => {
|
||||
fn(lastData!);
|
||||
scheduled = false;
|
||||
}, intervalMs);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Verwendung: Max 10 Updates pro Sekunde
|
||||
const throttledUpdate = throttle((data) => {
|
||||
setData(data);
|
||||
}, 100);
|
||||
```
|
||||
|
||||
### 2. Delta Updates
|
||||
|
||||
```typescript
|
||||
// Nur Änderungen senden
|
||||
function createDelta(previous: any, current: any): any {
|
||||
const delta: any = {};
|
||||
|
||||
for (const key of Object.keys(current)) {
|
||||
if (JSON.stringify(previous[key]) !== JSON.stringify(current[key])) {
|
||||
delta[key] = current[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(delta).length > 0 ? delta : null;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Web Worker für Datenverarbeitung
|
||||
|
||||
```typescript
|
||||
// worker.ts
|
||||
self.onmessage = (event) => {
|
||||
const { type, data } = event.data;
|
||||
|
||||
if (type === 'process-metrics') {
|
||||
// Schwere Berechnung im Worker
|
||||
const processed = processHeavyData(data);
|
||||
self.postMessage({ type: 'metrics-processed', data: processed });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
Real-Time Dashboards mit WebSockets bieten:
|
||||
|
||||
1. **Instant Updates**: Daten erscheinen sofort
|
||||
2. **Weniger Load**: Keine unnötigen Requests
|
||||
3. **Bessere UX**: Flüssige Animationen möglich
|
||||
4. **Skalierbarkeit**: Redis Pub/Sub für Multi-Server
|
||||
|
||||
Für datenintensive Dashboards ist WebSocket-Push der Standard 2026.
|
||||
|
||||
---
|
||||
|
||||
## Bildprompts
|
||||
|
||||
1. "Dashboard with live updating charts and metrics, data flowing in real-time, modern analytics interface"
|
||||
2. "WebSocket connection stream feeding into dashboard, visualization of data flow"
|
||||
3. "Multiple dashboard widgets updating simultaneously, business analytics, clean dark theme"
|
||||
|
||||
---
|
||||
|
||||
## Quellen
|
||||
|
||||
- [WebSocket API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
|
||||
- [Redis Pub/Sub](https://redis.io/docs/manual/pubsub/)
|
||||
- [React Real-Time Charts](https://recharts.org/)
|
||||
- [Canvas API Performance](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas)
|
||||
Reference in New Issue
Block a user