بناء التطبيقات اللحظية

لوحات المعلومات الحية وبث البيانات

5 دقيقة للقراءة

Real-time dashboards keep users informed with live data updates. We'll build a monitoring dashboard using Server-Sent Events (SSE) for efficient one-way streaming, with live charts and metric cards.

WebSockets vs Server-Sent Events

// When to use which:

// WebSockets - Full duplex communication
// - Chat applications
// - Collaborative editing
// - Gaming
// - When client needs to send frequent updates

// Server-Sent Events (SSE) - Server to client only
// - Live dashboards
// - Notifications
// - Stock tickers
// - News feeds
// - Simpler to implement, auto-reconnection built-in
// - Works with HTTP/2 multiplexing

Setting Up SSE Endpoint

// app/api/dashboard/stream/route.ts
import { NextRequest } from 'next/server';

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      // Send initial connection message
      controller.enqueue(
        encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
      );

      // Set up metric polling
      const sendMetrics = async () => {
        try {
          const metrics = await fetchDashboardMetrics();
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({ type: 'metrics', data: metrics })}\n\n`)
          );
        } catch (error) {
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({ type: 'error', message: 'Failed to fetch metrics' })}\n\n`)
          );
        }
      };

      // Send metrics immediately
      await sendMetrics();

      // Then every 5 seconds
      const interval = setInterval(sendMetrics, 5000);

      // Handle client disconnect
      request.signal.addEventListener('abort', () => {
        clearInterval(interval);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
    },
  });
}

async function fetchDashboardMetrics() {
  // Simulate fetching metrics from various sources
  return {
    timestamp: Date.now(),
    activeUsers: Math.floor(Math.random() * 1000) + 500,
    requestsPerSecond: Math.floor(Math.random() * 500) + 100,
    avgResponseTime: Math.floor(Math.random() * 100) + 20,
    errorRate: (Math.random() * 2).toFixed(2),
    cpuUsage: Math.floor(Math.random() * 40) + 30,
    memoryUsage: Math.floor(Math.random() * 30) + 50,
  };
}

SSE with Named Events

// app/api/dashboard/events/route.ts
import { NextRequest } from 'next/server';

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      // Helper to send named events
      const sendEvent = (eventName: string, data: any) => {
        controller.enqueue(
          encoder.encode(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`)
        );
      };

      // Send different event types
      sendEvent('connection', { status: 'connected', timestamp: Date.now() });

      // Metrics updates every 5s
      const metricsInterval = setInterval(() => {
        sendEvent('metrics', {
          cpu: Math.floor(Math.random() * 100),
          memory: Math.floor(Math.random() * 100),
          disk: Math.floor(Math.random() * 100),
        });
      }, 5000);

      // Alerts when thresholds exceeded (random for demo)
      const alertInterval = setInterval(() => {
        if (Math.random() > 0.8) {
          sendEvent('alert', {
            severity: Math.random() > 0.5 ? 'warning' : 'critical',
            message: 'High memory usage detected',
            timestamp: Date.now(),
          });
        }
      }, 10000);

      // Activity feed updates
      const activityInterval = setInterval(() => {
        sendEvent('activity', {
          type: ['login', 'purchase', 'signup', 'error'][Math.floor(Math.random() * 4)],
          user: `user_${Math.floor(Math.random() * 1000)}`,
          timestamp: Date.now(),
        });
      }, 2000);

      request.signal.addEventListener('abort', () => {
        clearInterval(metricsInterval);
        clearInterval(alertInterval);
        clearInterval(activityInterval);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
    },
  });
}

React Hook for SSE

// hooks/use-event-source.ts
import { useEffect, useState, useRef, useCallback } from 'react';

interface UseEventSourceOptions {
  onMessage?: (data: any) => void;
  onError?: (error: Event) => void;
  onOpen?: () => void;
  reconnectInterval?: number;
  maxRetries?: number;
}

export function useEventSource(url: string, options: UseEventSourceOptions = {}) {
  const [isConnected, setIsConnected] = useState(false);
  const [lastMessage, setLastMessage] = useState<any>(null);
  const [error, setError] = useState<Error | null>(null);
  const eventSourceRef = useRef<EventSource | null>(null);
  const retriesRef = useRef(0);

  const connect = useCallback(() => {
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
    }

    const eventSource = new EventSource(url);
    eventSourceRef.current = eventSource;

    eventSource.onopen = () => {
      setIsConnected(true);
      setError(null);
      retriesRef.current = 0;
      options.onOpen?.();
    };

    eventSource.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        setLastMessage(data);
        options.onMessage?.(data);
      } catch (e) {
        console.error('Failed to parse SSE message:', e);
      }
    };

    eventSource.onerror = (event) => {
      setIsConnected(false);
      options.onError?.(event);

      // Auto-reconnect logic
      const maxRetries = options.maxRetries ?? 5;
      const reconnectInterval = options.reconnectInterval ?? 3000;

      if (retriesRef.current < maxRetries) {
        retriesRef.current++;
        setTimeout(connect, reconnectInterval);
      } else {
        setError(new Error('Max reconnection attempts reached'));
      }
    };
  }, [url, options]);

  useEffect(() => {
    connect();

    return () => {
      eventSourceRef.current?.close();
    };
  }, [connect]);

  const disconnect = useCallback(() => {
    eventSourceRef.current?.close();
    setIsConnected(false);
  }, []);

  return { isConnected, lastMessage, error, disconnect, reconnect: connect };
}

// Hook for named events
export function useNamedEventSource(url: string, eventHandlers: Record<string, (data: any) => void>) {
  const eventSourceRef = useRef<EventSource | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const eventSource = new EventSource(url);
    eventSourceRef.current = eventSource;

    eventSource.onopen = () => setIsConnected(true);
    eventSource.onerror = () => setIsConnected(false);

    // Register handlers for each event type
    Object.entries(eventHandlers).forEach(([eventName, handler]) => {
      eventSource.addEventListener(eventName, (event) => {
        try {
          const data = JSON.parse(event.data);
          handler(data);
        } catch (e) {
          console.error(`Failed to parse ${eventName} event:`, e);
        }
      });
    });

    return () => eventSource.close();
  }, [url]); // Note: eventHandlers should be memoized

  return { isConnected };
}

Building the Dashboard UI

// components/dashboard/live-dashboard.tsx
'use client';

import { useState, useMemo } from 'react';
import { useNamedEventSource } from '@/hooks/use-event-source';
import { MetricCard } from './metric-card';
import { LiveChart } from './live-chart';
import { ActivityFeed } from './activity-feed';
import { AlertBanner } from './alert-banner';

interface Metrics {
  cpu: number;
  memory: number;
  disk: number;
}

interface Activity {
  type: string;
  user: string;
  timestamp: number;
}

interface Alert {
  severity: 'warning' | 'critical';
  message: string;
  timestamp: number;
}

export function LiveDashboard() {
  const [metrics, setMetrics] = useState<Metrics>({ cpu: 0, memory: 0, disk: 0 });
  const [metricsHistory, setMetricsHistory] = useState<(Metrics & { timestamp: number })[]>([]);
  const [activities, setActivities] = useState<Activity[]>([]);
  const [alerts, setAlerts] = useState<Alert[]>([]);

  const eventHandlers = useMemo(() => ({
    connection: (data: any) => {
      console.log('Dashboard connected:', data);
    },
    metrics: (data: Metrics) => {
      setMetrics(data);
      setMetricsHistory((prev) => [...prev.slice(-59), { ...data, timestamp: Date.now() }]);
    },
    activity: (data: Activity) => {
      setActivities((prev) => [data, ...prev.slice(0, 49)]);
    },
    alert: (data: Alert) => {
      setAlerts((prev) => [data, ...prev.slice(0, 9)]);
    },
  }), []);

  const { isConnected } = useNamedEventSource('/api/dashboard/events', eventHandlers);

  const dismissAlert = (timestamp: number) => {
    setAlerts((prev) => prev.filter((a) => a.timestamp !== timestamp));
  };

  return (
    <div className="min-h-screen bg-gray-900 text-white p-6">
      {/* Connection Status */}
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold">System Dashboard</h1>
        <div className="flex items-center gap-2">
          <div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
          <span className="text-sm text-gray-400">
            {isConnected ? 'Live' : 'Disconnected'}
          </span>
        </div>
      </div>

      {/* Alerts */}
      {alerts.length > 0 && (
        <div className="mb-6 space-y-2">
          {alerts.map((alert) => (
            <AlertBanner key={alert.timestamp} alert={alert} onDismiss={() => dismissAlert(alert.timestamp)} />
          ))}
        </div>
      )}

      {/* Metric Cards */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
        <MetricCard title="CPU Usage" value={metrics.cpu} unit="%" color="blue" />
        <MetricCard title="Memory" value={metrics.memory} unit="%" color="green" />
        <MetricCard title="Disk" value={metrics.disk} unit="%" color="purple" />
      </div>

      {/* Charts and Activity */}
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        <div className="lg:col-span-2">
          <LiveChart data={metricsHistory} />
        </div>
        <div>
          <ActivityFeed activities={activities} />
        </div>
      </div>
    </div>
  );
}

Metric Card Component

// components/dashboard/metric-card.tsx
'use client';

import { useEffect, useState } from 'react';

interface MetricCardProps {
  title: string;
  value: number;
  unit: string;
  color: 'blue' | 'green' | 'purple' | 'orange' | 'red';
}

const colorClasses = {
  blue: 'from-blue-500 to-blue-600',
  green: 'from-green-500 to-green-600',
  purple: 'from-purple-500 to-purple-600',
  orange: 'from-orange-500 to-orange-600',
  red: 'from-red-500 to-red-600',
};

export function MetricCard({ title, value, unit, color }: MetricCardProps) {
  const [displayValue, setDisplayValue] = useState(value);
  const [trend, setTrend] = useState<'up' | 'down' | 'stable'>('stable');

  useEffect(() => {
    // Animate value changes
    const diff = value - displayValue;
    if (Math.abs(diff) > 0.1) {
      setTrend(diff > 0 ? 'up' : 'down');
      const steps = 10;
      const increment = diff / steps;
      let step = 0;

      const interval = setInterval(() => {
        step++;
        setDisplayValue((prev) => prev + increment);
        if (step >= steps) {
          clearInterval(interval);
          setDisplayValue(value);
        }
      }, 30);

      return () => clearInterval(interval);
    }
  }, [value]);

  return (
    <div className={`rounded-xl bg-gradient-to-br ${colorClasses[color]} p-6 shadow-lg`}>
      <div className="flex items-start justify-between">
        <div>
          <p className="text-sm font-medium text-white/80">{title}</p>
          <p className="mt-2 text-4xl font-bold text-white">
            {displayValue.toFixed(0)}
            <span className="text-lg ml-1">{unit}</span>
          </p>
        </div>
        <TrendIndicator trend={trend} />
      </div>
      <ProgressBar value={displayValue} max={100} />
    </div>
  );
}

function TrendIndicator({ trend }: { trend: 'up' | 'down' | 'stable' }) {
  if (trend === 'stable') return null;

  return (
    <div className={`p-2 rounded-full ${trend === 'up' ? 'bg-red-500/20' : 'bg-green-500/20'}`}>
      <svg
        className={`w-4 h-4 ${trend === 'up' ? 'text-red-200 rotate-0' : 'text-green-200 rotate-180'}`}
        fill="none"
        viewBox="0 0 24 24"
        stroke="currentColor"
      >
        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
      </svg>
    </div>
  );
}

function ProgressBar({ value, max }: { value: number; max: number }) {
  const percentage = Math.min((value / max) * 100, 100);

  return (
    <div className="mt-4 h-2 bg-white/20 rounded-full overflow-hidden">
      <div
        className="h-full bg-white/40 rounded-full transition-all duration-300"
        style={{ width: `${percentage}%` }}
      />
    </div>
  );
}

Live Chart with Recharts

// components/dashboard/live-chart.tsx
'use client';

import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';

interface ChartData {
  timestamp: number;
  cpu: number;
  memory: number;
  disk: number;
}

interface LiveChartProps {
  data: ChartData[];
}

export function LiveChart({ data }: LiveChartProps) {
  const formattedData = data.map((d) => ({
    ...d,
    time: new Date(d.timestamp).toLocaleTimeString(),
  }));

  return (
    <div className="bg-gray-800 rounded-xl p-6">
      <h2 className="text-lg font-semibold mb-4">System Metrics Over Time</h2>
      <div className="h-80">
        <ResponsiveContainer width="100%" height="100%">
          <LineChart data={formattedData}>
            <CartesianGrid strokeDasharray="3 3" stroke="#374151" />
            <XAxis
              dataKey="time"
              stroke="#9CA3AF"
              tick={{ fill: '#9CA3AF', fontSize: 12 }}
              interval="preserveStartEnd"
            />
            <YAxis
              stroke="#9CA3AF"
              tick={{ fill: '#9CA3AF', fontSize: 12 }}
              domain={[0, 100]}
            />
            <Tooltip
              contentStyle={{
                backgroundColor: '#1F2937',
                border: 'none',
                borderRadius: '8px',
                color: '#fff',
              }}
            />
            <Legend />
            <Line
              type="monotone"
              dataKey="cpu"
              stroke="#3B82F6"
              strokeWidth={2}
              dot={false}
              name="CPU"
            />
            <Line
              type="monotone"
              dataKey="memory"
              stroke="#10B981"
              strokeWidth={2}
              dot={false}
              name="Memory"
            />
            <Line
              type="monotone"
              dataKey="disk"
              stroke="#8B5CF6"
              strokeWidth={2}
              dot={false}
              name="Disk"
            />
          </LineChart>
        </ResponsiveContainer>
      </div>
    </div>
  );
}

Activity Feed Component

// components/dashboard/activity-feed.tsx
'use client';

import { useEffect, useRef } from 'react';

interface Activity {
  type: string;
  user: string;
  timestamp: number;
}

const activityIcons: Record<string, string> = {
  login: '🔐',
  purchase: '💳',
  signup: '👤',
  error: '⚠️',
};

const activityColors: Record<string, string> = {
  login: 'bg-blue-500/20 text-blue-400',
  purchase: 'bg-green-500/20 text-green-400',
  signup: 'bg-purple-500/20 text-purple-400',
  error: 'bg-red-500/20 text-red-400',
};

export function ActivityFeed({ activities }: { activities: Activity[] }) {
  const containerRef = useRef<HTMLDivElement>(null);

  // Auto-scroll to show new activities
  useEffect(() => {
    if (containerRef.current && activities.length > 0) {
      containerRef.current.scrollTop = 0;
    }
  }, [activities]);

  return (
    <div className="bg-gray-800 rounded-xl p-6 h-full">
      <h2 className="text-lg font-semibold mb-4">Live Activity</h2>
      <div ref={containerRef} className="space-y-3 max-h-80 overflow-y-auto">
        {activities.map((activity, index) => (
          <div
            key={`${activity.timestamp}-${index}`}
            className={`flex items-center gap-3 p-3 rounded-lg ${activityColors[activity.type]} animate-fade-in`}
          >
            <span className="text-xl">{activityIcons[activity.type]}</span>
            <div className="flex-1 min-w-0">
              <p className="text-sm font-medium truncate">
                {activity.type.charAt(0).toUpperCase() + activity.type.slice(1)}
              </p>
              <p className="text-xs opacity-75">{activity.user}</p>
            </div>
            <span className="text-xs opacity-50">
              {new Date(activity.timestamp).toLocaleTimeString()}
            </span>
          </div>
        ))}
        {activities.length === 0 && (
          <p className="text-gray-500 text-center py-8">Waiting for activity...</p>
        )}
      </div>
    </div>
  );
}

Alert Banner Component

// components/dashboard/alert-banner.tsx
'use client';

interface Alert {
  severity: 'warning' | 'critical';
  message: string;
  timestamp: number;
}

export function AlertBanner({ alert, onDismiss }: { alert: Alert; onDismiss: () => void }) {
  const colors = {
    warning: 'bg-yellow-500/20 border-yellow-500 text-yellow-200',
    critical: 'bg-red-500/20 border-red-500 text-red-200',
  };

  const icons = {
    warning: '⚠️',
    critical: '🚨',
  };

  return (
    <div className={`flex items-center justify-between p-4 rounded-lg border ${colors[alert.severity]} animate-slide-down`}>
      <div className="flex items-center gap-3">
        <span className="text-xl">{icons[alert.severity]}</span>
        <div>
          <p className="font-medium">{alert.message}</p>
          <p className="text-sm opacity-75">
            {new Date(alert.timestamp).toLocaleTimeString()}
          </p>
        </div>
      </div>
      <button
        onClick={onDismiss}
        className="p-2 hover:bg-white/10 rounded-lg transition-colors"
      >
        <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
        </svg>
      </button>
    </div>
  );
}

Streaming Data from Database Changes

// app/api/db-changes/route.ts
// Using Postgres LISTEN/NOTIFY for real-time database changes
import { NextRequest } from 'next/server';
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();
  const client = await pool.connect();

  const stream = new ReadableStream({
    async start(controller) {
      // Subscribe to changes
      await client.query('LISTEN table_changes');

      client.on('notification', (msg) => {
        if (msg.channel === 'table_changes') {
          const payload = JSON.parse(msg.payload || '{}');
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)
          );
        }
      });

      request.signal.addEventListener('abort', () => {
        client.release();
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
    },
  });
}
-- Database trigger for NOTIFY
CREATE OR REPLACE FUNCTION notify_table_changes()
RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify(
    'table_changes',
    json_build_object(
      'operation', TG_OP,
      'table', TG_TABLE_NAME,
      'data', CASE
        WHEN TG_OP = 'DELETE' THEN row_to_json(OLD)
        ELSE row_to_json(NEW)
      END
    )::text
  );
  RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER orders_changes
  AFTER INSERT OR UPDATE OR DELETE ON orders
  FOR EACH ROW EXECUTE FUNCTION notify_table_changes();

What You've Learned

In this lesson, you've built:

  • SSE endpoints - Server-Sent Events for efficient one-way streaming
  • Named events - Different event types for metrics, alerts, and activity
  • React SSE hooks - Auto-reconnection and event handling
  • Live dashboard UI - Metric cards, charts, and activity feeds
  • Database streaming - Real-time updates from Postgres NOTIFY

SSE is simpler than WebSockets when you only need server-to-client updates. Combined with Postgres LISTEN/NOTIFY, you can build fully reactive dashboards without external dependencies.


لوحات المعلومات الحية وبث البيانات

لوحات المعلومات في الوقت الحقيقي تبقي المستخدمين على اطلاع بتحديثات البيانات الحية. سنبني لوحة معلومات للمراقبة باستخدام Server-Sent Events (SSE) للبث أحادي الاتجاه الفعال، مع رسوم بيانية حية وبطاقات مقاييس.

WebSockets مقابل Server-Sent Events

// متى تستخدم أيهما:

// WebSockets - اتصال ثنائي الاتجاه كامل
// - تطبيقات الدردشة
// - التحرير التعاوني
// - الألعاب
// - عندما يحتاج العميل لإرسال تحديثات متكررة

// Server-Sent Events (SSE) - من الخادم للعميل فقط
// - لوحات المعلومات الحية
// - الإشعارات
// - شريط الأسهم
// - تغذيات الأخبار
// - أبسط للتنفيذ، إعادة الاتصال التلقائي مدمجة
// - تعمل مع تعدد إرسال HTTP/2

إعداد نقطة نهاية SSE

// app/api/dashboard/stream/route.ts
import { NextRequest } from 'next/server';

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      // إرسال رسالة الاتصال الأولية
      controller.enqueue(
        encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
      );

      // إعداد استطلاع المقاييس
      const sendMetrics = async () => {
        try {
          const metrics = await fetchDashboardMetrics();
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({ type: 'metrics', data: metrics })}\n\n`)
          );
        } catch (error) {
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({ type: 'error', message: 'فشل في جلب المقاييس' })}\n\n`)
          );
        }
      };

      // إرسال المقاييس فوراً
      await sendMetrics();

      // ثم كل 5 ثوانٍ
      const interval = setInterval(sendMetrics, 5000);

      // التعامل مع قطع اتصال العميل
      request.signal.addEventListener('abort', () => {
        clearInterval(interval);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
    },
  });
}

async function fetchDashboardMetrics() {
  // محاكاة جلب المقاييس من مصادر متعددة
  return {
    timestamp: Date.now(),
    activeUsers: Math.floor(Math.random() * 1000) + 500,
    requestsPerSecond: Math.floor(Math.random() * 500) + 100,
    avgResponseTime: Math.floor(Math.random() * 100) + 20,
    errorRate: (Math.random() * 2).toFixed(2),
    cpuUsage: Math.floor(Math.random() * 40) + 30,
    memoryUsage: Math.floor(Math.random() * 30) + 50,
  };
}

SSE مع الأحداث المسماة

// app/api/dashboard/events/route.ts
import { NextRequest } from 'next/server';

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      // مساعد لإرسال الأحداث المسماة
      const sendEvent = (eventName: string, data: any) => {
        controller.enqueue(
          encoder.encode(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`)
        );
      };

      // إرسال أنواع أحداث مختلفة
      sendEvent('connection', { status: 'connected', timestamp: Date.now() });

      // تحديثات المقاييس كل 5 ثوانٍ
      const metricsInterval = setInterval(() => {
        sendEvent('metrics', {
          cpu: Math.floor(Math.random() * 100),
          memory: Math.floor(Math.random() * 100),
          disk: Math.floor(Math.random() * 100),
        });
      }, 5000);

      // التنبيهات عند تجاوز العتبات (عشوائي للعرض)
      const alertInterval = setInterval(() => {
        if (Math.random() > 0.8) {
          sendEvent('alert', {
            severity: Math.random() > 0.5 ? 'warning' : 'critical',
            message: 'تم اكتشاف استخدام ذاكرة عالٍ',
            timestamp: Date.now(),
          });
        }
      }, 10000);

      // تحديثات تغذية النشاط
      const activityInterval = setInterval(() => {
        sendEvent('activity', {
          type: ['login', 'purchase', 'signup', 'error'][Math.floor(Math.random() * 4)],
          user: `user_${Math.floor(Math.random() * 1000)}`,
          timestamp: Date.now(),
        });
      }, 2000);

      request.signal.addEventListener('abort', () => {
        clearInterval(metricsInterval);
        clearInterval(alertInterval);
        clearInterval(activityInterval);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
    },
  });
}

هوك React لـ SSE

// hooks/use-event-source.ts
import { useEffect, useState, useRef, useCallback } from 'react';

interface UseEventSourceOptions {
  onMessage?: (data: any) => void;
  onError?: (error: Event) => void;
  onOpen?: () => void;
  reconnectInterval?: number;
  maxRetries?: number;
}

export function useEventSource(url: string, options: UseEventSourceOptions = {}) {
  const [isConnected, setIsConnected] = useState(false);
  const [lastMessage, setLastMessage] = useState<any>(null);
  const [error, setError] = useState<Error | null>(null);
  const eventSourceRef = useRef<EventSource | null>(null);
  const retriesRef = useRef(0);

  const connect = useCallback(() => {
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
    }

    const eventSource = new EventSource(url);
    eventSourceRef.current = eventSource;

    eventSource.onopen = () => {
      setIsConnected(true);
      setError(null);
      retriesRef.current = 0;
      options.onOpen?.();
    };

    eventSource.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        setLastMessage(data);
        options.onMessage?.(data);
      } catch (e) {
        console.error('فشل في تحليل رسالة SSE:', e);
      }
    };

    eventSource.onerror = (event) => {
      setIsConnected(false);
      options.onError?.(event);

      // منطق إعادة الاتصال التلقائي
      const maxRetries = options.maxRetries ?? 5;
      const reconnectInterval = options.reconnectInterval ?? 3000;

      if (retriesRef.current < maxRetries) {
        retriesRef.current++;
        setTimeout(connect, reconnectInterval);
      } else {
        setError(new Error('تم الوصول للحد الأقصى لمحاولات إعادة الاتصال'));
      }
    };
  }, [url, options]);

  useEffect(() => {
    connect();

    return () => {
      eventSourceRef.current?.close();
    };
  }, [connect]);

  const disconnect = useCallback(() => {
    eventSourceRef.current?.close();
    setIsConnected(false);
  }, []);

  return { isConnected, lastMessage, error, disconnect, reconnect: connect };
}

// هوك للأحداث المسماة
export function useNamedEventSource(url: string, eventHandlers: Record<string, (data: any) => void>) {
  const eventSourceRef = useRef<EventSource | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const eventSource = new EventSource(url);
    eventSourceRef.current = eventSource;

    eventSource.onopen = () => setIsConnected(true);
    eventSource.onerror = () => setIsConnected(false);

    // تسجيل المعالجات لكل نوع حدث
    Object.entries(eventHandlers).forEach(([eventName, handler]) => {
      eventSource.addEventListener(eventName, (event) => {
        try {
          const data = JSON.parse(event.data);
          handler(data);
        } catch (e) {
          console.error(`فشل في تحليل حدث ${eventName}:`, e);
        }
      });
    });

    return () => eventSource.close();
  }, [url]); // ملاحظة: eventHandlers يجب أن تكون محفوظة

  return { isConnected };
}

بناء واجهة لوحة المعلومات

// components/dashboard/live-dashboard.tsx
'use client';

import { useState, useMemo } from 'react';
import { useNamedEventSource } from '@/hooks/use-event-source';
import { MetricCard } from './metric-card';
import { LiveChart } from './live-chart';
import { ActivityFeed } from './activity-feed';
import { AlertBanner } from './alert-banner';

interface Metrics {
  cpu: number;
  memory: number;
  disk: number;
}

interface Activity {
  type: string;
  user: string;
  timestamp: number;
}

interface Alert {
  severity: 'warning' | 'critical';
  message: string;
  timestamp: number;
}

export function LiveDashboard() {
  const [metrics, setMetrics] = useState<Metrics>({ cpu: 0, memory: 0, disk: 0 });
  const [metricsHistory, setMetricsHistory] = useState<(Metrics & { timestamp: number })[]>([]);
  const [activities, setActivities] = useState<Activity[]>([]);
  const [alerts, setAlerts] = useState<Alert[]>([]);

  const eventHandlers = useMemo(() => ({
    connection: (data: any) => {
      console.log('لوحة المعلومات متصلة:', data);
    },
    metrics: (data: Metrics) => {
      setMetrics(data);
      setMetricsHistory((prev) => [...prev.slice(-59), { ...data, timestamp: Date.now() }]);
    },
    activity: (data: Activity) => {
      setActivities((prev) => [data, ...prev.slice(0, 49)]);
    },
    alert: (data: Alert) => {
      setAlerts((prev) => [data, ...prev.slice(0, 9)]);
    },
  }), []);

  const { isConnected } = useNamedEventSource('/api/dashboard/events', eventHandlers);

  const dismissAlert = (timestamp: number) => {
    setAlerts((prev) => prev.filter((a) => a.timestamp !== timestamp));
  };

  return (
    <div className="min-h-screen bg-gray-900 text-white p-6">
      {/* حالة الاتصال */}
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold">لوحة معلومات النظام</h1>
        <div className="flex items-center gap-2">
          <div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
          <span className="text-sm text-gray-400">
            {isConnected ? 'مباشر' : 'غير متصل'}
          </span>
        </div>
      </div>

      {/* التنبيهات */}
      {alerts.length > 0 && (
        <div className="mb-6 space-y-2">
          {alerts.map((alert) => (
            <AlertBanner key={alert.timestamp} alert={alert} onDismiss={() => dismissAlert(alert.timestamp)} />
          ))}
        </div>
      )}

      {/* بطاقات المقاييس */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
        <MetricCard title="استخدام CPU" value={metrics.cpu} unit="%" color="blue" />
        <MetricCard title="الذاكرة" value={metrics.memory} unit="%" color="green" />
        <MetricCard title="القرص" value={metrics.disk} unit="%" color="purple" />
      </div>

      {/* الرسوم البيانية والنشاط */}
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        <div className="lg:col-span-2">
          <LiveChart data={metricsHistory} />
        </div>
        <div>
          <ActivityFeed activities={activities} />
        </div>
      </div>
    </div>
  );
}

مكون بطاقة المقياس

// components/dashboard/metric-card.tsx
'use client';

import { useEffect, useState } from 'react';

interface MetricCardProps {
  title: string;
  value: number;
  unit: string;
  color: 'blue' | 'green' | 'purple' | 'orange' | 'red';
}

const colorClasses = {
  blue: 'from-blue-500 to-blue-600',
  green: 'from-green-500 to-green-600',
  purple: 'from-purple-500 to-purple-600',
  orange: 'from-orange-500 to-orange-600',
  red: 'from-red-500 to-red-600',
};

export function MetricCard({ title, value, unit, color }: MetricCardProps) {
  const [displayValue, setDisplayValue] = useState(value);
  const [trend, setTrend] = useState<'up' | 'down' | 'stable'>('stable');

  useEffect(() => {
    // تحريك تغييرات القيمة
    const diff = value - displayValue;
    if (Math.abs(diff) > 0.1) {
      setTrend(diff > 0 ? 'up' : 'down');
      const steps = 10;
      const increment = diff / steps;
      let step = 0;

      const interval = setInterval(() => {
        step++;
        setDisplayValue((prev) => prev + increment);
        if (step >= steps) {
          clearInterval(interval);
          setDisplayValue(value);
        }
      }, 30);

      return () => clearInterval(interval);
    }
  }, [value]);

  return (
    <div className={`rounded-xl bg-gradient-to-br ${colorClasses[color]} p-6 shadow-lg`}>
      <div className="flex items-start justify-between">
        <div>
          <p className="text-sm font-medium text-white/80">{title}</p>
          <p className="mt-2 text-4xl font-bold text-white">
            {displayValue.toFixed(0)}
            <span className="text-lg ml-1">{unit}</span>
          </p>
        </div>
        <TrendIndicator trend={trend} />
      </div>
      <ProgressBar value={displayValue} max={100} />
    </div>
  );
}

function TrendIndicator({ trend }: { trend: 'up' | 'down' | 'stable' }) {
  if (trend === 'stable') return null;

  return (
    <div className={`p-2 rounded-full ${trend === 'up' ? 'bg-red-500/20' : 'bg-green-500/20'}`}>
      <svg
        className={`w-4 h-4 ${trend === 'up' ? 'text-red-200 rotate-0' : 'text-green-200 rotate-180'}`}
        fill="none"
        viewBox="0 0 24 24"
        stroke="currentColor"
      >
        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
      </svg>
    </div>
  );
}

function ProgressBar({ value, max }: { value: number; max: number }) {
  const percentage = Math.min((value / max) * 100, 100);

  return (
    <div className="mt-4 h-2 bg-white/20 rounded-full overflow-hidden">
      <div
        className="h-full bg-white/40 rounded-full transition-all duration-300"
        style={{ width: `${percentage}%` }}
      />
    </div>
  );
}

رسم بياني حي مع Recharts

// components/dashboard/live-chart.tsx
'use client';

import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';

interface ChartData {
  timestamp: number;
  cpu: number;
  memory: number;
  disk: number;
}

interface LiveChartProps {
  data: ChartData[];
}

export function LiveChart({ data }: LiveChartProps) {
  const formattedData = data.map((d) => ({
    ...d,
    time: new Date(d.timestamp).toLocaleTimeString(),
  }));

  return (
    <div className="bg-gray-800 rounded-xl p-6">
      <h2 className="text-lg font-semibold mb-4">مقاييس النظام عبر الوقت</h2>
      <div className="h-80">
        <ResponsiveContainer width="100%" height="100%">
          <LineChart data={formattedData}>
            <CartesianGrid strokeDasharray="3 3" stroke="#374151" />
            <XAxis
              dataKey="time"
              stroke="#9CA3AF"
              tick={{ fill: '#9CA3AF', fontSize: 12 }}
              interval="preserveStartEnd"
            />
            <YAxis
              stroke="#9CA3AF"
              tick={{ fill: '#9CA3AF', fontSize: 12 }}
              domain={[0, 100]}
            />
            <Tooltip
              contentStyle={{
                backgroundColor: '#1F2937',
                border: 'none',
                borderRadius: '8px',
                color: '#fff',
              }}
            />
            <Legend />
            <Line
              type="monotone"
              dataKey="cpu"
              stroke="#3B82F6"
              strokeWidth={2}
              dot={false}
              name="CPU"
            />
            <Line
              type="monotone"
              dataKey="memory"
              stroke="#10B981"
              strokeWidth={2}
              dot={false}
              name="الذاكرة"
            />
            <Line
              type="monotone"
              dataKey="disk"
              stroke="#8B5CF6"
              strokeWidth={2}
              dot={false}
              name="القرص"
            />
          </LineChart>
        </ResponsiveContainer>
      </div>
    </div>
  );
}

مكون تغذية النشاط

// components/dashboard/activity-feed.tsx
'use client';

import { useEffect, useRef } from 'react';

interface Activity {
  type: string;
  user: string;
  timestamp: number;
}

const activityIcons: Record<string, string> = {
  login: '🔐',
  purchase: '💳',
  signup: '👤',
  error: '⚠️',
};

const activityColors: Record<string, string> = {
  login: 'bg-blue-500/20 text-blue-400',
  purchase: 'bg-green-500/20 text-green-400',
  signup: 'bg-purple-500/20 text-purple-400',
  error: 'bg-red-500/20 text-red-400',
};

export function ActivityFeed({ activities }: { activities: Activity[] }) {
  const containerRef = useRef<HTMLDivElement>(null);

  // التمرير التلقائي لإظهار الأنشطة الجديدة
  useEffect(() => {
    if (containerRef.current && activities.length > 0) {
      containerRef.current.scrollTop = 0;
    }
  }, [activities]);

  return (
    <div className="bg-gray-800 rounded-xl p-6 h-full">
      <h2 className="text-lg font-semibold mb-4">النشاط المباشر</h2>
      <div ref={containerRef} className="space-y-3 max-h-80 overflow-y-auto">
        {activities.map((activity, index) => (
          <div
            key={`${activity.timestamp}-${index}`}
            className={`flex items-center gap-3 p-3 rounded-lg ${activityColors[activity.type]} animate-fade-in`}
          >
            <span className="text-xl">{activityIcons[activity.type]}</span>
            <div className="flex-1 min-w-0">
              <p className="text-sm font-medium truncate">
                {activity.type.charAt(0).toUpperCase() + activity.type.slice(1)}
              </p>
              <p className="text-xs opacity-75">{activity.user}</p>
            </div>
            <span className="text-xs opacity-50">
              {new Date(activity.timestamp).toLocaleTimeString()}
            </span>
          </div>
        ))}
        {activities.length === 0 && (
          <p className="text-gray-500 text-center py-8">في انتظار النشاط...</p>
        )}
      </div>
    </div>
  );
}

مكون شريط التنبيه

// components/dashboard/alert-banner.tsx
'use client';

interface Alert {
  severity: 'warning' | 'critical';
  message: string;
  timestamp: number;
}

export function AlertBanner({ alert, onDismiss }: { alert: Alert; onDismiss: () => void }) {
  const colors = {
    warning: 'bg-yellow-500/20 border-yellow-500 text-yellow-200',
    critical: 'bg-red-500/20 border-red-500 text-red-200',
  };

  const icons = {
    warning: '⚠️',
    critical: '🚨',
  };

  return (
    <div className={`flex items-center justify-between p-4 rounded-lg border ${colors[alert.severity]} animate-slide-down`}>
      <div className="flex items-center gap-3">
        <span className="text-xl">{icons[alert.severity]}</span>
        <div>
          <p className="font-medium">{alert.message}</p>
          <p className="text-sm opacity-75">
            {new Date(alert.timestamp).toLocaleTimeString()}
          </p>
        </div>
      </div>
      <button
        onClick={onDismiss}
        className="p-2 hover:bg-white/10 rounded-lg transition-colors"
      >
        <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
        </svg>
      </button>
    </div>
  );
}

بث البيانات من تغييرات قاعدة البيانات

// app/api/db-changes/route.ts
// استخدام Postgres LISTEN/NOTIFY لتغييرات قاعدة البيانات في الوقت الحقيقي
import { NextRequest } from 'next/server';
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();
  const client = await pool.connect();

  const stream = new ReadableStream({
    async start(controller) {
      // الاشتراك في التغييرات
      await client.query('LISTEN table_changes');

      client.on('notification', (msg) => {
        if (msg.channel === 'table_changes') {
          const payload = JSON.parse(msg.payload || '{}');
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)
          );
        }
      });

      request.signal.addEventListener('abort', () => {
        client.release();
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
    },
  });
}
-- محفز قاعدة البيانات لـ NOTIFY
CREATE OR REPLACE FUNCTION notify_table_changes()
RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify(
    'table_changes',
    json_build_object(
      'operation', TG_OP,
      'table', TG_TABLE_NAME,
      'data', CASE
        WHEN TG_OP = 'DELETE' THEN row_to_json(OLD)
        ELSE row_to_json(NEW)
      END
    )::text
  );
  RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER orders_changes
  AFTER INSERT OR UPDATE OR DELETE ON orders
  FOR EACH ROW EXECUTE FUNCTION notify_table_changes();

ما تعلمته

في هذا الدرس، بنيت:

  • نقاط نهاية SSE - Server-Sent Events للبث أحادي الاتجاه الفعال
  • الأحداث المسماة - أنواع أحداث مختلفة للمقاييس والتنبيهات والنشاط
  • هوكس React لـ SSE - إعادة الاتصال التلقائي ومعالجة الأحداث
  • واجهة لوحة معلومات حية - بطاقات مقاييس ورسوم بيانية وتغذيات نشاط
  • بث قاعدة البيانات - تحديثات في الوقت الحقيقي من Postgres NOTIFY

SSE أبسط من WebSockets عندما تحتاج فقط لتحديثات من الخادم للعميل. مع دمج Postgres LISTEN/NOTIFY، يمكنك بناء لوحات معلومات تفاعلية بالكامل بدون اعتماديات خارجية.

اختبار

الوحدة 5: بناء التطبيقات اللحظية

خذ الاختبار