From 7098335ce79cd6e3b35cdb485b89539953655fa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Nov 2025 03:51:51 +0000 Subject: [PATCH] Add real-time updates system with Socket.io Implement Agent 6: Real-Time Updates feature for LocalGreenChain: - Add Socket.io server with room-based subscriptions - Create client-side hooks (useSocket, useLiveFeed, usePlantUpdates) - Add SocketProvider context for application-wide state - Implement UI components: - ConnectionStatus: Shows WebSocket connection state - LiveFeed: Real-time event feed display - NotificationToast: Toast notifications with auto-dismiss - LiveChart: Real-time data visualization - Add event type definitions and formatting utilities - Create socket API endpoint for WebSocket initialization - Add socket stats endpoint for monitoring - Extend tailwind with fadeIn/slideIn animations Integrates with existing EventStream SSE system for fallback. --- components/realtime/ConnectionStatus.tsx | 167 ++++++++++ components/realtime/LiveChart.tsx | 256 +++++++++++++++ components/realtime/LiveFeed.tsx | 255 +++++++++++++++ components/realtime/NotificationToast.tsx | 325 +++++++++++++++++++ components/realtime/index.ts | 27 ++ lib/realtime/SocketContext.tsx | 235 ++++++++++++++ lib/realtime/events.ts | 273 ++++++++++++++++ lib/realtime/index.ts | 92 ++++++ lib/realtime/rooms.ts | 137 ++++++++ lib/realtime/socketClient.ts | 379 ++++++++++++++++++++++ lib/realtime/socketServer.ts | 343 ++++++++++++++++++++ lib/realtime/types.ts | 152 +++++++++ lib/realtime/useSocket.ts | 258 +++++++++++++++ package.json | 2 + pages/api/socket.ts | 78 +++++ pages/api/socket/stats.ts | 63 ++++ tailwind.config.js | 18 +- 17 files changed, 3059 insertions(+), 1 deletion(-) create mode 100644 components/realtime/ConnectionStatus.tsx create mode 100644 components/realtime/LiveChart.tsx create mode 100644 components/realtime/LiveFeed.tsx create mode 100644 components/realtime/NotificationToast.tsx create mode 100644 components/realtime/index.ts create mode 100644 lib/realtime/SocketContext.tsx create mode 100644 lib/realtime/events.ts create mode 100644 lib/realtime/index.ts create mode 100644 lib/realtime/rooms.ts create mode 100644 lib/realtime/socketClient.ts create mode 100644 lib/realtime/socketServer.ts create mode 100644 lib/realtime/types.ts create mode 100644 lib/realtime/useSocket.ts create mode 100644 pages/api/socket.ts create mode 100644 pages/api/socket/stats.ts diff --git a/components/realtime/ConnectionStatus.tsx b/components/realtime/ConnectionStatus.tsx new file mode 100644 index 0000000..110c487 --- /dev/null +++ b/components/realtime/ConnectionStatus.tsx @@ -0,0 +1,167 @@ +/** + * Connection Status Indicator Component + * + * Shows the current WebSocket connection status with visual feedback. + */ + +import React from 'react'; +import classNames from 'classnames'; +import { useConnectionStatus } from '../../lib/realtime/useSocket'; +import type { ConnectionStatus as ConnectionStatusType } from '../../lib/realtime/types'; + +interface ConnectionStatusProps { + showLabel?: boolean; + showLatency?: boolean; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +/** + * Get status color classes + */ +function getStatusColor(status: ConnectionStatusType): string { + switch (status) { + case 'connected': + return 'bg-green-500'; + case 'connecting': + case 'reconnecting': + return 'bg-yellow-500 animate-pulse'; + case 'disconnected': + return 'bg-gray-400'; + case 'error': + return 'bg-red-500'; + default: + return 'bg-gray-400'; + } +} + +/** + * Get status label + */ +function getStatusLabel(status: ConnectionStatusType): string { + switch (status) { + case 'connected': + return 'Connected'; + case 'connecting': + return 'Connecting...'; + case 'reconnecting': + return 'Reconnecting...'; + case 'disconnected': + return 'Disconnected'; + case 'error': + return 'Connection Error'; + default: + return 'Unknown'; + } +} + +/** + * Get size classes + */ +function getSizeClasses(size: 'sm' | 'md' | 'lg'): { dot: string; text: string } { + switch (size) { + case 'sm': + return { dot: 'w-2 h-2', text: 'text-xs' }; + case 'md': + return { dot: 'w-3 h-3', text: 'text-sm' }; + case 'lg': + return { dot: 'w-4 h-4', text: 'text-base' }; + default: + return { dot: 'w-3 h-3', text: 'text-sm' }; + } +} + +/** + * Connection Status component + */ +export function ConnectionStatus({ + showLabel = true, + showLatency = false, + size = 'md', + className, +}: ConnectionStatusProps) { + const { status, latency } = useConnectionStatus(); + const sizeClasses = getSizeClasses(size); + + return ( +
+ {/* Status dot */} + + + {/* Label */} + {showLabel && ( + + {getStatusLabel(status)} + + )} + + {/* Latency */} + {showLatency && status === 'connected' && latency !== undefined && ( + + ({latency}ms) + + )} +
+ ); +} + +/** + * Compact connection indicator (dot only) + */ +export function ConnectionDot({ className }: { className?: string }) { + const { status } = useConnectionStatus(); + + return ( + + ); +} + +/** + * Connection banner for showing reconnection status + */ +export function ConnectionBanner() { + const { status } = useConnectionStatus(); + + if (status === 'connected') { + return null; + } + + const bannerClasses = classNames( + 'fixed top-0 left-0 right-0 py-2 px-4 text-center text-sm font-medium z-50', + { + 'bg-yellow-100 text-yellow-800': status === 'connecting' || status === 'reconnecting', + 'bg-red-100 text-red-800': status === 'error', + 'bg-gray-100 text-gray-800': status === 'disconnected', + } + ); + + return ( +
+ {status === 'connecting' && 'Connecting to real-time updates...'} + {status === 'reconnecting' && 'Connection lost. Reconnecting...'} + {status === 'error' && 'Connection error. Please check your network.'} + {status === 'disconnected' && 'Disconnected from real-time updates.'} +
+ ); +} + +export default ConnectionStatus; diff --git a/components/realtime/LiveChart.tsx b/components/realtime/LiveChart.tsx new file mode 100644 index 0000000..c61825b --- /dev/null +++ b/components/realtime/LiveChart.tsx @@ -0,0 +1,256 @@ +/** + * Live Chart Component + * + * Displays real-time data as a simple line chart. + */ + +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { useSocket } from '../../lib/realtime/useSocket'; +import type { TransparencyEventType } from '../../lib/realtime/types'; + +interface LiveChartProps { + eventTypes?: TransparencyEventType[]; + dataKey?: string; + title?: string; + color?: string; + height?: number; + maxDataPoints?: number; + showGrid?: boolean; + className?: string; +} + +/** + * Simple SVG line chart for real-time data + */ +export function LiveChart({ + eventTypes = ['system.metric'], + dataKey = 'value', + title = 'Live Data', + color = '#3B82F6', + height = 120, + maxDataPoints = 30, + showGrid = true, + className, +}: LiveChartProps) { + const { events } = useSocket({ + eventTypes, + maxEvents: maxDataPoints, + }); + + // Extract data points + const dataPoints = useMemo(() => { + return events + .filter((e) => e.data && typeof e.data[dataKey] === 'number') + .map((e) => ({ + value: e.data[dataKey] as number, + timestamp: new Date(e.timestamp).getTime(), + })) + .reverse() + .slice(-maxDataPoints); + }, [events, dataKey, maxDataPoints]); + + // Calculate chart dimensions + const chartWidth = 400; + const chartHeight = height - 40; + const padding = { top: 10, right: 10, bottom: 20, left: 40 }; + const innerWidth = chartWidth - padding.left - padding.right; + const innerHeight = chartHeight - padding.top - padding.bottom; + + // Calculate scales + const { minValue, maxValue, points, pathD } = useMemo(() => { + if (dataPoints.length === 0) { + return { minValue: 0, maxValue: 100, points: [], pathD: '' }; + } + + const values = dataPoints.map((d) => d.value); + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + + const pts = dataPoints.map((d, i) => ({ + x: padding.left + (i / Math.max(1, dataPoints.length - 1)) * innerWidth, + y: padding.top + innerHeight - ((d.value - min) / range) * innerHeight, + })); + + const d = pts.length > 0 + ? `M ${pts.map((p) => `${p.x},${p.y}`).join(' L ')}` + : ''; + + return { minValue: min, maxValue: max, points: pts, pathD: d }; + }, [dataPoints, innerWidth, innerHeight, padding]); + + // Latest value + const latestValue = dataPoints.length > 0 ? dataPoints[dataPoints.length - 1].value : null; + + return ( +
+ {/* Header */} +
+

{title}

+ {latestValue !== null && ( + + {latestValue.toFixed(1)} + + )} +
+ + {/* Chart */} + + {/* Grid */} + {showGrid && ( + + {/* Horizontal grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => ( + + ))} + {/* Vertical grid lines */} + {[0, 0.5, 1].map((ratio) => ( + + ))} + + )} + + {/* Y-axis labels */} + + + {maxValue.toFixed(0)} + + + {minValue.toFixed(0)} + + + + {/* Line path */} + {pathD && ( + <> + {/* Gradient area */} + + + + + + + + + + )} + + {/* Data points */} + {points.map((p, i) => ( + + ))} + + {/* No data message */} + {dataPoints.length === 0 && ( + + Waiting for data... + + )} + +
+ ); +} + +/** + * Event count chart - shows event frequency over time + */ +export function EventCountChart({ + className, +}: { + className?: string; +}) { + const { events } = useSocket({ maxEvents: 100 }); + + // Group events by minute + const countsByMinute = useMemo(() => { + const counts: Record = {}; + const now = Date.now(); + + // Initialize last 10 minutes + for (let i = 0; i < 10; i++) { + const minute = Math.floor((now - i * 60000) / 60000); + counts[minute] = 0; + } + + // Count events + events.forEach((e) => { + const minute = Math.floor(new Date(e.timestamp).getTime() / 60000); + if (counts[minute] !== undefined) { + counts[minute]++; + } + }); + + return Object.entries(counts) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([, count]) => count); + }, [events]); + + const maxCount = Math.max(...countsByMinute, 1); + + return ( +
+

Events per Minute

+ +
+ {countsByMinute.map((count, i) => ( +
+ ))} +
+ +
+ 10m ago + Now +
+
+ ); +} + +export default LiveChart; diff --git a/components/realtime/LiveFeed.tsx b/components/realtime/LiveFeed.tsx new file mode 100644 index 0000000..0b0297b --- /dev/null +++ b/components/realtime/LiveFeed.tsx @@ -0,0 +1,255 @@ +/** + * Live Feed Component + * + * Displays a real-time feed of events from the LocalGreenChain system. + */ + +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { useLiveFeed } from '../../lib/realtime/useSocket'; +import type { LiveFeedItem, RoomType, TransparencyEventType } from '../../lib/realtime/types'; +import { EventCategory, getEventCategory } from '../../lib/realtime/events'; +import { ConnectionStatus } from './ConnectionStatus'; + +interface LiveFeedProps { + rooms?: RoomType[]; + eventTypes?: TransparencyEventType[]; + maxItems?: number; + showConnectionStatus?: boolean; + showTimestamps?: boolean; + showClearButton?: boolean; + filterCategory?: EventCategory; + className?: string; + emptyMessage?: string; +} + +/** + * Format timestamp for display + */ +function formatTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - timestamp; + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + + if (diffSec < 60) { + return 'Just now'; + } else if (diffMin < 60) { + return `${diffMin}m ago`; + } else if (diffHour < 24) { + return `${diffHour}h ago`; + } else { + return date.toLocaleDateString(); + } +} + +/** + * Get color classes for event type + */ +function getColorClasses(color: string): { bg: string; border: string; text: string } { + switch (color) { + case 'green': + return { + bg: 'bg-green-50', + border: 'border-green-200', + text: 'text-green-800', + }; + case 'blue': + return { + bg: 'bg-blue-50', + border: 'border-blue-200', + text: 'text-blue-800', + }; + case 'yellow': + return { + bg: 'bg-yellow-50', + border: 'border-yellow-200', + text: 'text-yellow-800', + }; + case 'red': + return { + bg: 'bg-red-50', + border: 'border-red-200', + text: 'text-red-800', + }; + case 'purple': + return { + bg: 'bg-purple-50', + border: 'border-purple-200', + text: 'text-purple-800', + }; + case 'gray': + default: + return { + bg: 'bg-gray-50', + border: 'border-gray-200', + text: 'text-gray-800', + }; + } +} + +/** + * Single feed item component + */ +function FeedItem({ + item, + showTimestamp, +}: { + item: LiveFeedItem; + showTimestamp: boolean; +}) { + const colors = getColorClasses(item.formatted.color); + + return ( +
+
+ {/* Icon */} + + {item.formatted.icon} + + + {/* Content */} +
+
+ + {item.formatted.title} + + {showTimestamp && ( + + {formatTimestamp(item.timestamp)} + + )} +
+

+ {item.formatted.description} +

+
+
+
+ ); +} + +/** + * Live Feed component + */ +export function LiveFeed({ + rooms, + eventTypes, + maxItems = 20, + showConnectionStatus = true, + showTimestamps = true, + showClearButton = true, + filterCategory, + className, + emptyMessage = 'No events yet. Real-time updates will appear here.', +}: LiveFeedProps) { + const { items, isConnected, status, clearFeed } = useLiveFeed({ + rooms, + eventTypes, + maxEvents: maxItems, + }); + + // Filter items by category if specified + const filteredItems = useMemo(() => { + if (!filterCategory) return items; + + return items.filter((item) => { + const category = getEventCategory(item.event.type); + return category === filterCategory; + }); + }, [items, filterCategory]); + + return ( +
+ {/* Header */} +
+
+

Live Feed

+ {showConnectionStatus && } +
+ +
+ {filteredItems.length > 0 && ( + + {filteredItems.length} event{filteredItems.length !== 1 ? 's' : ''} + + )} + {showClearButton && filteredItems.length > 0 && ( + + )} +
+
+ + {/* Feed content */} +
+ {filteredItems.length === 0 ? ( +
+
📡
+

{emptyMessage}

+ {!isConnected && ( +

+ Status: {status} +

+ )} +
+ ) : ( + filteredItems.map((item) => ( + + )) + )} +
+
+ ); +} + +/** + * Compact live feed for sidebars + */ +export function CompactLiveFeed({ + maxItems = 5, + className, +}: { + maxItems?: number; + className?: string; +}) { + const { items } = useLiveFeed({ maxEvents: maxItems }); + + if (items.length === 0) { + return null; + } + + return ( +
+ {items.slice(0, maxItems).map((item) => ( +
+ {item.formatted.icon} + + {item.formatted.description} + +
+ ))} +
+ ); +} + +export default LiveFeed; diff --git a/components/realtime/NotificationToast.tsx b/components/realtime/NotificationToast.tsx new file mode 100644 index 0000000..9f99959 --- /dev/null +++ b/components/realtime/NotificationToast.tsx @@ -0,0 +1,325 @@ +/** + * Notification Toast Component + * + * Displays real-time notifications as toast messages. + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import classNames from 'classnames'; +import { useSocketContext } from '../../lib/realtime/SocketContext'; +import type { RealtimeNotification } from '../../lib/realtime/types'; + +interface NotificationToastProps { + position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; + maxVisible?: number; + autoHideDuration?: number; + className?: string; +} + +/** + * Get position classes + */ +function getPositionClasses(position: NotificationToastProps['position']): string { + switch (position) { + case 'top-left': + return 'top-4 left-4'; + case 'bottom-right': + return 'bottom-4 right-4'; + case 'bottom-left': + return 'bottom-4 left-4'; + case 'top-right': + default: + return 'top-4 right-4'; + } +} + +/** + * Get notification type styles + */ +function getTypeStyles(type: RealtimeNotification['type']): { + bg: string; + border: string; + icon: string; + iconColor: string; +} { + switch (type) { + case 'success': + return { + bg: 'bg-green-50', + border: 'border-green-200', + icon: '✓', + iconColor: 'text-green-600', + }; + case 'warning': + return { + bg: 'bg-yellow-50', + border: 'border-yellow-200', + icon: '⚠', + iconColor: 'text-yellow-600', + }; + case 'error': + return { + bg: 'bg-red-50', + border: 'border-red-200', + icon: '✕', + iconColor: 'text-red-600', + }; + case 'info': + default: + return { + bg: 'bg-blue-50', + border: 'border-blue-200', + icon: 'ℹ', + iconColor: 'text-blue-600', + }; + } +} + +/** + * Single toast notification + */ +function Toast({ + notification, + onDismiss, + autoHideDuration, +}: { + notification: RealtimeNotification; + onDismiss: (id: string) => void; + autoHideDuration: number; +}) { + const [isVisible, setIsVisible] = useState(false); + const [isLeaving, setIsLeaving] = useState(false); + const styles = getTypeStyles(notification.type); + + // Animate in + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), 10); + return () => clearTimeout(timer); + }, []); + + // Auto hide + useEffect(() => { + if (autoHideDuration <= 0) return; + + const timer = setTimeout(() => { + handleDismiss(); + }, autoHideDuration); + + return () => clearTimeout(timer); + }, [autoHideDuration]); + + const handleDismiss = useCallback(() => { + setIsLeaving(true); + setTimeout(() => { + onDismiss(notification.id); + }, 300); + }, [notification.id, onDismiss]); + + return ( +
+
+ {/* Icon */} + + {styles.icon} + + + {/* Content */} +
+

+ {notification.title} +

+

+ {notification.message} +

+
+ + {/* Close button */} + +
+
+ ); +} + +/** + * Notification Toast container + */ +export function NotificationToast({ + position = 'top-right', + maxVisible = 5, + autoHideDuration = 5000, + className, +}: NotificationToastProps) { + const { notifications, dismissNotification } = useSocketContext(); + + // Only show non-read, non-dismissed notifications + const visibleNotifications = notifications + .filter((n) => !n.read && !n.dismissed) + .slice(0, maxVisible); + + if (visibleNotifications.length === 0) { + return null; + } + + return ( +
+ {visibleNotifications.map((notification) => ( + + ))} +
+ ); +} + +/** + * Notification bell with badge + */ +export function NotificationBell({ + onClick, + className, +}: { + onClick?: () => void; + className?: string; +}) { + const { unreadCount } = useSocketContext(); + + return ( + + ); +} + +/** + * Notification list dropdown + */ +export function NotificationList({ + className, + onClose, +}: { + className?: string; + onClose?: () => void; +}) { + const { notifications, markNotificationRead, markAllRead } = useSocketContext(); + + return ( +
+ {/* Header */} +
+

Notifications

+ {notifications.length > 0 && ( + + )} +
+ + {/* List */} +
+ {notifications.length === 0 ? ( +
+
🔔
+

No notifications

+
+ ) : ( + notifications.map((notification) => ( +
markNotificationRead(notification.id)} + > +
+ + {getTypeStyles(notification.type).icon} + +
+

+ {notification.title} +

+

+ {notification.message} +

+

+ {new Date(notification.timestamp).toLocaleTimeString()} +

+
+
+
+ )) + )} +
+
+ ); +} + +export default NotificationToast; diff --git a/components/realtime/index.ts b/components/realtime/index.ts new file mode 100644 index 0000000..b08db49 --- /dev/null +++ b/components/realtime/index.ts @@ -0,0 +1,27 @@ +/** + * Real-Time Components for LocalGreenChain + * + * Export all real-time UI components. + */ + +export { + ConnectionStatus, + ConnectionDot, + ConnectionBanner, +} from './ConnectionStatus'; + +export { + LiveFeed, + CompactLiveFeed, +} from './LiveFeed'; + +export { + NotificationToast, + NotificationBell, + NotificationList, +} from './NotificationToast'; + +export { + LiveChart, + EventCountChart, +} from './LiveChart'; diff --git a/lib/realtime/SocketContext.tsx b/lib/realtime/SocketContext.tsx new file mode 100644 index 0000000..e74ed2a --- /dev/null +++ b/lib/realtime/SocketContext.tsx @@ -0,0 +1,235 @@ +/** + * Socket.io Context Provider for LocalGreenChain + * + * Provides socket connection state to the entire application. + */ + +import React, { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode } from 'react'; +import { getSocketClient, RealtimeSocketClient } from './socketClient'; +import type { + ConnectionStatus, + TransparencyEvent, + RoomType, + TransparencyEventType, + ConnectionMetrics, + RealtimeNotification, +} from './types'; +import { toFeedItem } from './events'; + +/** + * Socket context value type + */ +interface SocketContextValue { + // Connection state + status: ConnectionStatus; + isConnected: boolean; + metrics: ConnectionMetrics; + + // Events + events: TransparencyEvent[]; + latestEvent: TransparencyEvent | null; + + // Notifications + notifications: RealtimeNotification[]; + unreadCount: number; + + // Actions + connect: () => void; + disconnect: () => void; + joinRoom: (room: RoomType) => Promise; + leaveRoom: (room: RoomType) => Promise; + subscribeToTypes: (types: TransparencyEventType[]) => Promise; + clearEvents: () => void; + markNotificationRead: (id: string) => void; + dismissNotification: (id: string) => void; + markAllRead: () => void; +} + +const SocketContext = createContext(null); + +/** + * Provider props + */ +interface SocketProviderProps { + children: ReactNode; + userId?: string; + autoConnect?: boolean; + maxEvents?: number; + maxNotifications?: number; +} + +/** + * Convert event to notification + */ +function eventToNotification(event: TransparencyEvent): RealtimeNotification { + const feedItem = toFeedItem(event); + + let notificationType: RealtimeNotification['type'] = 'info'; + if (event.priority === 'CRITICAL') notificationType = 'error'; + else if (event.priority === 'HIGH') notificationType = 'warning'; + else if (event.type.includes('error')) notificationType = 'error'; + else if (event.type.includes('completed') || event.type.includes('verified')) notificationType = 'success'; + + return { + id: event.id, + type: notificationType, + title: feedItem.formatted.title, + message: feedItem.formatted.description, + timestamp: feedItem.timestamp, + eventType: event.type, + data: event.data, + read: false, + dismissed: false, + }; +} + +/** + * Socket Provider component + */ +export function SocketProvider({ + children, + userId, + autoConnect = true, + maxEvents = 100, + maxNotifications = 50, +}: SocketProviderProps) { + const [status, setStatus] = useState('disconnected'); + const [events, setEvents] = useState([]); + const [latestEvent, setLatestEvent] = useState(null); + const [notifications, setNotifications] = useState([]); + const [metrics, setMetrics] = useState({ + status: 'disconnected', + eventsReceived: 0, + reconnectAttempts: 0, + rooms: [], + }); + + const clientRef = useRef(null); + + // Initialize client + useEffect(() => { + if (typeof window === 'undefined') return; + + const client = getSocketClient({ auth: { userId } }); + clientRef.current = client; + + // Set up listeners + const unsubStatus = client.onStatusChange((newStatus) => { + setStatus(newStatus); + setMetrics(client.getMetrics()); + }); + + const unsubEvent = client.onEvent((event) => { + setLatestEvent(event); + setEvents((prev) => [event, ...prev].slice(0, maxEvents)); + setMetrics(client.getMetrics()); + + // Create notification for important events + if (event.priority === 'HIGH' || event.priority === 'CRITICAL') { + const notification = eventToNotification(event); + setNotifications((prev) => [notification, ...prev].slice(0, maxNotifications)); + } + }); + + // Auto connect + if (autoConnect) { + client.connect(); + } + + // Initial metrics + setMetrics(client.getMetrics()); + + return () => { + unsubStatus(); + unsubEvent(); + }; + }, [autoConnect, userId, maxEvents, maxNotifications]); + + const connect = useCallback(() => { + clientRef.current?.connect(); + }, []); + + const disconnect = useCallback(() => { + clientRef.current?.disconnect(); + }, []); + + const joinRoom = useCallback(async (room: RoomType) => { + return clientRef.current?.joinRoom(room) ?? false; + }, []); + + const leaveRoom = useCallback(async (room: RoomType) => { + return clientRef.current?.leaveRoom(room) ?? false; + }, []); + + const subscribeToTypes = useCallback(async (types: TransparencyEventType[]) => { + return clientRef.current?.subscribeToTypes(types) ?? false; + }, []); + + const clearEvents = useCallback(() => { + setEvents([]); + setLatestEvent(null); + }, []); + + const markNotificationRead = useCallback((id: string) => { + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, read: true } : n)) + ); + }, []); + + const dismissNotification = useCallback((id: string) => { + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, dismissed: true } : n)) + ); + }, []); + + const markAllRead = useCallback(() => { + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + }, []); + + const unreadCount = notifications.filter((n) => !n.read && !n.dismissed).length; + + const value: SocketContextValue = { + status, + isConnected: status === 'connected', + metrics, + events, + latestEvent, + notifications: notifications.filter((n) => !n.dismissed), + unreadCount, + connect, + disconnect, + joinRoom, + leaveRoom, + subscribeToTypes, + clearEvents, + markNotificationRead, + dismissNotification, + markAllRead, + }; + + return ( + + {children} + + ); +} + +/** + * Hook to use socket context + */ +export function useSocketContext(): SocketContextValue { + const context = useContext(SocketContext); + if (!context) { + throw new Error('useSocketContext must be used within a SocketProvider'); + } + return context; +} + +/** + * Hook to optionally use socket context (returns null if not in provider) + */ +export function useOptionalSocketContext(): SocketContextValue | null { + return useContext(SocketContext); +} + +export default SocketContext; diff --git a/lib/realtime/events.ts b/lib/realtime/events.ts new file mode 100644 index 0000000..f546df1 --- /dev/null +++ b/lib/realtime/events.ts @@ -0,0 +1,273 @@ +/** + * Real-Time Event Definitions for LocalGreenChain + * + * Defines all real-time event types and utilities for formatting events. + */ + +import type { TransparencyEventType, TransparencyEvent, LiveFeedItem } from './types'; + +/** + * Event categories for grouping and filtering + */ +export enum EventCategory { + PLANT = 'plant', + TRANSPORT = 'transport', + DEMAND = 'demand', + FARM = 'farm', + AGENT = 'agent', + BLOCKCHAIN = 'blockchain', + SYSTEM = 'system', + AUDIT = 'audit', +} + +/** + * Real-time event types enum for client use + */ +export enum RealtimeEvent { + // Plant events + PLANT_REGISTERED = 'plant.registered', + PLANT_CLONED = 'plant.cloned', + PLANT_TRANSFERRED = 'plant.transferred', + PLANT_UPDATED = 'plant.updated', + + // Transport events + TRANSPORT_STARTED = 'transport.started', + TRANSPORT_COMPLETED = 'transport.completed', + TRANSPORT_VERIFIED = 'transport.verified', + + // Demand events + DEMAND_CREATED = 'demand.created', + DEMAND_MATCHED = 'demand.matched', + SUPPLY_COMMITTED = 'supply.committed', + + // Farm events + FARM_REGISTERED = 'farm.registered', + FARM_UPDATED = 'farm.updated', + BATCH_STARTED = 'batch.started', + BATCH_HARVESTED = 'batch.harvested', + + // Agent events + AGENT_ALERT = 'agent.alert', + AGENT_TASK_COMPLETED = 'agent.task_completed', + AGENT_ERROR = 'agent.error', + + // Blockchain events + BLOCKCHAIN_BLOCK_ADDED = 'blockchain.block_added', + BLOCKCHAIN_VERIFIED = 'blockchain.verified', + BLOCKCHAIN_ERROR = 'blockchain.error', + + // System events + SYSTEM_HEALTH = 'system.health', + SYSTEM_ALERT = 'system.alert', + SYSTEM_METRIC = 'system.metric', + + // Audit events + AUDIT_LOGGED = 'audit.logged', + AUDIT_ANOMALY = 'audit.anomaly', +} + +/** + * Map event types to their categories + */ +export const EVENT_CATEGORIES: Record = { + 'plant.registered': EventCategory.PLANT, + 'plant.cloned': EventCategory.PLANT, + 'plant.transferred': EventCategory.PLANT, + 'plant.updated': EventCategory.PLANT, + 'transport.started': EventCategory.TRANSPORT, + 'transport.completed': EventCategory.TRANSPORT, + 'transport.verified': EventCategory.TRANSPORT, + 'demand.created': EventCategory.DEMAND, + 'demand.matched': EventCategory.DEMAND, + 'supply.committed': EventCategory.DEMAND, + 'farm.registered': EventCategory.FARM, + 'farm.updated': EventCategory.FARM, + 'batch.started': EventCategory.FARM, + 'batch.harvested': EventCategory.FARM, + 'agent.alert': EventCategory.AGENT, + 'agent.task_completed': EventCategory.AGENT, + 'agent.error': EventCategory.AGENT, + 'blockchain.block_added': EventCategory.BLOCKCHAIN, + 'blockchain.verified': EventCategory.BLOCKCHAIN, + 'blockchain.error': EventCategory.BLOCKCHAIN, + 'system.health': EventCategory.SYSTEM, + 'system.alert': EventCategory.SYSTEM, + 'system.metric': EventCategory.SYSTEM, + 'audit.logged': EventCategory.AUDIT, + 'audit.anomaly': EventCategory.AUDIT, +}; + +/** + * Event display configuration + */ +interface EventDisplay { + title: string; + icon: string; + color: string; +} + +/** + * Map event types to display properties + */ +export const EVENT_DISPLAY: Record = { + 'plant.registered': { title: 'Plant Registered', icon: '🌱', color: 'green' }, + 'plant.cloned': { title: 'Plant Cloned', icon: '🧬', color: 'green' }, + 'plant.transferred': { title: 'Plant Transferred', icon: '🔄', color: 'blue' }, + 'plant.updated': { title: 'Plant Updated', icon: '📝', color: 'gray' }, + 'transport.started': { title: 'Transport Started', icon: '🚚', color: 'yellow' }, + 'transport.completed': { title: 'Transport Completed', icon: '✅', color: 'green' }, + 'transport.verified': { title: 'Transport Verified', icon: '🔍', color: 'blue' }, + 'demand.created': { title: 'Demand Created', icon: '📊', color: 'purple' }, + 'demand.matched': { title: 'Demand Matched', icon: '🎯', color: 'green' }, + 'supply.committed': { title: 'Supply Committed', icon: '📦', color: 'blue' }, + 'farm.registered': { title: 'Farm Registered', icon: '🏭', color: 'green' }, + 'farm.updated': { title: 'Farm Updated', icon: '🔧', color: 'gray' }, + 'batch.started': { title: 'Batch Started', icon: '🌿', color: 'green' }, + 'batch.harvested': { title: 'Batch Harvested', icon: '🥬', color: 'green' }, + 'agent.alert': { title: 'Agent Alert', icon: '⚠️', color: 'yellow' }, + 'agent.task_completed': { title: 'Task Completed', icon: '✔️', color: 'green' }, + 'agent.error': { title: 'Agent Error', icon: '❌', color: 'red' }, + 'blockchain.block_added': { title: 'Block Added', icon: '🔗', color: 'blue' }, + 'blockchain.verified': { title: 'Blockchain Verified', icon: '✓', color: 'green' }, + 'blockchain.error': { title: 'Blockchain Error', icon: '⛓️‍💥', color: 'red' }, + 'system.health': { title: 'Health Check', icon: '💓', color: 'green' }, + 'system.alert': { title: 'System Alert', icon: '🔔', color: 'yellow' }, + 'system.metric': { title: 'Metric Update', icon: '📈', color: 'blue' }, + 'audit.logged': { title: 'Audit Logged', icon: '📋', color: 'gray' }, + 'audit.anomaly': { title: 'Anomaly Detected', icon: '🚨', color: 'red' }, +}; + +/** + * Get the category for an event type + */ +export function getEventCategory(type: TransparencyEventType): EventCategory { + return EVENT_CATEGORIES[type]; +} + +/** + * Get display properties for an event type + */ +export function getEventDisplay(type: TransparencyEventType): EventDisplay { + return EVENT_DISPLAY[type] || { title: type, icon: '📌', color: 'gray' }; +} + +/** + * Format a description for an event + */ +export function formatEventDescription(event: TransparencyEvent): string { + const { type, data, source } = event; + + switch (type) { + case 'plant.registered': + return `${data.name || 'A plant'} was registered by ${source}`; + case 'plant.cloned': + return `${data.name || 'A plant'} was cloned from ${data.parentName || 'parent'}`; + case 'plant.transferred': + return `${data.name || 'A plant'} was transferred to ${data.newOwner || 'new owner'}`; + case 'plant.updated': + return `${data.name || 'A plant'} information was updated`; + case 'transport.started': + return `Transport started from ${data.from || 'origin'} to ${data.to || 'destination'}`; + case 'transport.completed': + return `Transport completed: ${data.distance || '?'} km traveled`; + case 'transport.verified': + return `Transport verified on blockchain`; + case 'demand.created': + return `Demand signal created for ${data.product || 'product'}`; + case 'demand.matched': + return `Demand matched with ${data.supplier || 'a supplier'}`; + case 'supply.committed': + return `Supply committed: ${data.quantity || '?'} units`; + case 'farm.registered': + return `Vertical farm "${data.name || 'Farm'}" registered`; + case 'farm.updated': + return `Farm settings updated`; + case 'batch.started': + return `Growing batch started: ${data.cropType || 'crops'}`; + case 'batch.harvested': + return `Batch harvested: ${data.yield || '?'} kg`; + case 'agent.alert': + return data.message || 'Agent alert triggered'; + case 'agent.task_completed': + return `Task completed: ${data.taskName || 'task'}`; + case 'agent.error': + return `Agent error: ${data.error || 'unknown error'}`; + case 'blockchain.block_added': + return `New block added to chain`; + case 'blockchain.verified': + return `Blockchain integrity verified`; + case 'blockchain.error': + return `Blockchain error: ${data.error || 'unknown error'}`; + case 'system.health': + return `System health: ${data.status || 'OK'}`; + case 'system.alert': + return data.message || 'System alert'; + case 'system.metric': + return `Metric: ${data.metricName || 'metric'} = ${data.value || '?'}`; + case 'audit.logged': + return `Audit: ${data.action || 'action'} by ${data.actor || 'unknown'}`; + case 'audit.anomaly': + return `Anomaly: ${data.description || 'unusual activity detected'}`; + default: + return `Event from ${source}`; + } +} + +/** + * Convert a TransparencyEvent to a LiveFeedItem + */ +export function toFeedItem(event: TransparencyEvent): LiveFeedItem { + const display = getEventDisplay(event.type); + + return { + id: event.id, + event, + timestamp: new Date(event.timestamp).getTime(), + formatted: { + title: display.title, + description: formatEventDescription(event), + icon: display.icon, + color: display.color, + }, + }; +} + +/** + * Get events by category + */ +export function getEventsByCategory(category: EventCategory): TransparencyEventType[] { + return Object.entries(EVENT_CATEGORIES) + .filter(([, cat]) => cat === category) + .map(([type]) => type as TransparencyEventType); +} + +/** + * Check if an event type belongs to a category + */ +export function isEventInCategory(type: TransparencyEventType, category: EventCategory): boolean { + return EVENT_CATEGORIES[type] === category; +} + +/** + * Priority to numeric value for sorting + */ +export function priorityToNumber(priority: string): number { + switch (priority) { + case 'CRITICAL': return 4; + case 'HIGH': return 3; + case 'NORMAL': return 2; + case 'LOW': return 1; + default: return 0; + } +} + +/** + * Sort events by priority and time + */ +export function sortEvents(events: TransparencyEvent[]): TransparencyEvent[] { + return [...events].sort((a, b) => { + const priorityDiff = priorityToNumber(b.priority) - priorityToNumber(a.priority); + if (priorityDiff !== 0) return priorityDiff; + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); + }); +} diff --git a/lib/realtime/index.ts b/lib/realtime/index.ts new file mode 100644 index 0000000..8c631ec --- /dev/null +++ b/lib/realtime/index.ts @@ -0,0 +1,92 @@ +/** + * Real-Time Module for LocalGreenChain + * + * Provides WebSocket-based real-time updates using Socket.io + * with automatic fallback to SSE. + */ + +// Types +export type { + RoomType, + ConnectionStatus, + SocketAuthPayload, + SocketHandshake, + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData, + RealtimeNotification, + ConnectionMetrics, + LiveFeedItem, + TransparencyEventType, + EventPriority, + TransparencyEvent, +} from './types'; + +// Events +export { + EventCategory, + RealtimeEvent, + EVENT_CATEGORIES, + EVENT_DISPLAY, + getEventCategory, + getEventDisplay, + formatEventDescription, + toFeedItem, + getEventsByCategory, + isEventInCategory, + priorityToNumber, + sortEvents, +} from './events'; + +// Rooms +export { + parseRoom, + createRoom, + getDefaultRooms, + getCategoryRoom, + getEventRooms, + isValidRoom, + canJoinRoom, + ROOM_LIMITS, +} from './rooms'; + +// Server +export { + RealtimeSocketServer, + getSocketServer, +} from './socketServer'; + +// Client +export { + RealtimeSocketClient, + getSocketClient, + createSocketClient, +} from './socketClient'; +export type { + SocketClientConfig, + EventListener, + StatusListener, + ErrorListener, +} from './socketClient'; + +// React Hooks +export { + useSocket, + useLiveFeed, + usePlantUpdates, + useFarmUpdates, + useConnectionStatus, + useEventCount, +} from './useSocket'; +export type { + UseSocketOptions, + UseSocketReturn, +} from './useSocket'; + +// React Context +export { + SocketProvider, + useSocketContext, + useOptionalSocketContext, +} from './SocketContext'; diff --git a/lib/realtime/rooms.ts b/lib/realtime/rooms.ts new file mode 100644 index 0000000..960dd8e --- /dev/null +++ b/lib/realtime/rooms.ts @@ -0,0 +1,137 @@ +/** + * Room Management for Socket.io + * + * Manages room subscriptions for targeted event delivery. + */ + +import type { RoomType, TransparencyEventType } from './types'; +import { EventCategory, getEventCategory } from './events'; + +/** + * Parse a room type to extract its components + */ +export function parseRoom(room: RoomType): { type: string; id?: string } { + if (room.includes(':')) { + const [type, id] = room.split(':'); + return { type, id }; + } + return { type: room }; +} + +/** + * Create a room name for a specific entity + */ +export function createRoom(type: 'plant' | 'farm' | 'user', id: string): RoomType { + return `${type}:${id}` as RoomType; +} + +/** + * Get the default rooms for a user based on their role + */ +export function getDefaultRooms(userId?: string): RoomType[] { + const rooms: RoomType[] = ['global']; + if (userId) { + rooms.push(`user:${userId}` as RoomType); + } + return rooms; +} + +/** + * Get category-based room for an event type + */ +export function getCategoryRoom(type: TransparencyEventType): RoomType { + const category = getEventCategory(type); + + switch (category) { + case EventCategory.PLANT: + return 'plants'; + case EventCategory.TRANSPORT: + return 'transport'; + case EventCategory.FARM: + return 'farms'; + case EventCategory.DEMAND: + return 'demand'; + case EventCategory.SYSTEM: + case EventCategory.AGENT: + case EventCategory.BLOCKCHAIN: + case EventCategory.AUDIT: + return 'system'; + default: + return 'global'; + } +} + +/** + * Determine which rooms should receive an event + */ +export function getEventRooms( + type: TransparencyEventType, + data: Record +): RoomType[] { + const rooms: RoomType[] = ['global']; + + // Add category room + const categoryRoom = getCategoryRoom(type); + if (categoryRoom !== 'global') { + rooms.push(categoryRoom); + } + + // Add entity-specific rooms + if (data.plantId && typeof data.plantId === 'string') { + rooms.push(`plant:${data.plantId}` as RoomType); + } + if (data.farmId && typeof data.farmId === 'string') { + rooms.push(`farm:${data.farmId}` as RoomType); + } + if (data.userId && typeof data.userId === 'string') { + rooms.push(`user:${data.userId}` as RoomType); + } + + return rooms; +} + +/** + * Check if a room is valid + */ +export function isValidRoom(room: string): room is RoomType { + const validPrefixes = ['global', 'plants', 'transport', 'farms', 'demand', 'system']; + const validEntityPrefixes = ['plant:', 'farm:', 'user:']; + + if (validPrefixes.includes(room)) { + return true; + } + + return validEntityPrefixes.some((prefix) => room.startsWith(prefix)); +} + +/** + * Room subscription limits per connection + */ +export const ROOM_LIMITS = { + maxRooms: 50, + maxEntityRooms: 20, + maxGlobalRooms: 10, +}; + +/** + * Check if a connection can join another room + */ +export function canJoinRoom(currentRooms: RoomType[], newRoom: RoomType): boolean { + if (currentRooms.length >= ROOM_LIMITS.maxRooms) { + return false; + } + + const parsed = parseRoom(newRoom); + const entityRooms = currentRooms.filter((r) => r.includes(':')).length; + const globalRooms = currentRooms.filter((r) => !r.includes(':')).length; + + if (parsed.id && entityRooms >= ROOM_LIMITS.maxEntityRooms) { + return false; + } + + if (!parsed.id && globalRooms >= ROOM_LIMITS.maxGlobalRooms) { + return false; + } + + return true; +} diff --git a/lib/realtime/socketClient.ts b/lib/realtime/socketClient.ts new file mode 100644 index 0000000..21a38c8 --- /dev/null +++ b/lib/realtime/socketClient.ts @@ -0,0 +1,379 @@ +/** + * Socket.io Client for LocalGreenChain + * + * Provides a client-side wrapper for Socket.io connections. + */ + +import { io, Socket } from 'socket.io-client'; +import type { + ClientToServerEvents, + ServerToClientEvents, + ConnectionStatus, + RoomType, + TransparencyEventType, + TransparencyEvent, + ConnectionMetrics, +} from './types'; + +type TypedSocket = Socket; + +/** + * Client configuration options + */ +export interface SocketClientConfig { + url?: string; + path?: string; + autoConnect?: boolean; + auth?: { + userId?: string; + token?: string; + sessionId?: string; + }; + reconnection?: boolean; + reconnectionAttempts?: number; + reconnectionDelay?: number; +} + +/** + * Event listener types + */ +export type EventListener = (event: TransparencyEvent) => void; +export type StatusListener = (status: ConnectionStatus) => void; +export type ErrorListener = (error: { code: string; message: string }) => void; + +/** + * Socket.io client wrapper + */ +class RealtimeSocketClient { + private socket: TypedSocket | null = null; + private config: SocketClientConfig; + private status: ConnectionStatus = 'disconnected'; + private eventListeners: Set = new Set(); + private statusListeners: Set = new Set(); + private errorListeners: Set = new Set(); + private metrics: ConnectionMetrics; + private pingInterval: NodeJS.Timeout | null = null; + + constructor(config: SocketClientConfig = {}) { + this.config = { + url: typeof window !== 'undefined' ? window.location.origin : '', + path: '/api/socket', + autoConnect: true, + reconnection: true, + reconnectionAttempts: 10, + reconnectionDelay: 1000, + ...config, + }; + + this.metrics = { + status: 'disconnected', + eventsReceived: 0, + reconnectAttempts: 0, + rooms: [], + }; + } + + /** + * Connect to the server + */ + connect(): void { + if (this.socket?.connected) { + return; + } + + this.updateStatus('connecting'); + + this.socket = io(this.config.url!, { + path: this.config.path, + autoConnect: this.config.autoConnect, + auth: this.config.auth, + reconnection: this.config.reconnection, + reconnectionAttempts: this.config.reconnectionAttempts, + reconnectionDelay: this.config.reconnectionDelay, + transports: ['websocket', 'polling'], + }); + + this.setupEventHandlers(); + } + + /** + * Set up socket event handlers + */ + private setupEventHandlers(): void { + if (!this.socket) return; + + // Connection events + this.socket.on('connect', () => { + this.updateStatus('connected'); + this.metrics.connectedAt = Date.now(); + this.metrics.reconnectAttempts = 0; + this.startPingInterval(); + }); + + this.socket.on('disconnect', () => { + this.updateStatus('disconnected'); + this.stopPingInterval(); + }); + + this.socket.on('connect_error', () => { + this.updateStatus('error'); + this.metrics.reconnectAttempts++; + }); + + // Server events + this.socket.on('connection:established', (data) => { + console.log('[SocketClient] Connected:', data.socketId); + }); + + this.socket.on('connection:error', (error) => { + this.errorListeners.forEach((listener) => listener(error)); + }); + + // Real-time events + this.socket.on('event', (event) => { + this.metrics.eventsReceived++; + this.metrics.lastEventAt = Date.now(); + this.eventListeners.forEach((listener) => listener(event)); + }); + + this.socket.on('event:batch', (events) => { + this.metrics.eventsReceived += events.length; + this.metrics.lastEventAt = Date.now(); + events.forEach((event) => { + this.eventListeners.forEach((listener) => listener(event)); + }); + }); + + // Room events + this.socket.on('room:joined', (room) => { + if (!this.metrics.rooms.includes(room)) { + this.metrics.rooms.push(room); + } + }); + + this.socket.on('room:left', (room) => { + this.metrics.rooms = this.metrics.rooms.filter((r) => r !== room); + }); + + // System events + this.socket.on('system:message', (message) => { + console.log(`[SocketClient] System ${message.type}: ${message.text}`); + }); + + this.socket.on('system:heartbeat', () => { + // Heartbeat received - connection is alive + }); + + // Reconnection events + this.socket.io.on('reconnect_attempt', () => { + this.updateStatus('reconnecting'); + this.metrics.reconnectAttempts++; + }); + + this.socket.io.on('reconnect', () => { + this.updateStatus('connected'); + }); + } + + /** + * Start ping interval for latency measurement + */ + private startPingInterval(): void { + this.stopPingInterval(); + + this.pingInterval = setInterval(() => { + if (this.socket?.connected) { + const start = Date.now(); + this.socket.emit('ping', (serverTime) => { + this.metrics.latency = Date.now() - start; + }); + } + }, 10000); // Every 10 seconds + } + + /** + * Stop ping interval + */ + private stopPingInterval(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + } + + /** + * Update connection status and notify listeners + */ + private updateStatus(status: ConnectionStatus): void { + this.status = status; + this.metrics.status = status; + this.statusListeners.forEach((listener) => listener(status)); + } + + /** + * Disconnect from the server + */ + disconnect(): void { + this.stopPingInterval(); + if (this.socket) { + this.socket.disconnect(); + this.socket = null; + } + this.updateStatus('disconnected'); + } + + /** + * Join a room + */ + joinRoom(room: RoomType): Promise { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve(false); + return; + } + + this.socket.emit('room:join', room, (success) => { + resolve(success); + }); + }); + } + + /** + * Leave a room + */ + leaveRoom(room: RoomType): Promise { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve(false); + return; + } + + this.socket.emit('room:leave', room, (success) => { + resolve(success); + }); + }); + } + + /** + * Subscribe to specific event types + */ + subscribeToTypes(types: TransparencyEventType[]): Promise { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve(false); + return; + } + + this.socket.emit('subscribe:types', types, (success) => { + resolve(success); + }); + }); + } + + /** + * Unsubscribe from specific event types + */ + unsubscribeFromTypes(types: TransparencyEventType[]): Promise { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve(false); + return; + } + + this.socket.emit('unsubscribe:types', types, (success) => { + resolve(success); + }); + }); + } + + /** + * Get recent events + */ + getRecentEvents(limit: number = 50): Promise { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve([]); + return; + } + + this.socket.emit('events:recent', limit, (events) => { + resolve(events); + }); + }); + } + + /** + * Add an event listener + */ + onEvent(listener: EventListener): () => void { + this.eventListeners.add(listener); + return () => this.eventListeners.delete(listener); + } + + /** + * Add a status listener + */ + onStatusChange(listener: StatusListener): () => void { + this.statusListeners.add(listener); + return () => this.statusListeners.delete(listener); + } + + /** + * Add an error listener + */ + onError(listener: ErrorListener): () => void { + this.errorListeners.add(listener); + return () => this.errorListeners.delete(listener); + } + + /** + * Get current connection status + */ + getStatus(): ConnectionStatus { + return this.status; + } + + /** + * Get connection metrics + */ + getMetrics(): ConnectionMetrics { + return { ...this.metrics }; + } + + /** + * Check if connected + */ + isConnected(): boolean { + return this.socket?.connected ?? false; + } + + /** + * Get socket ID + */ + getSocketId(): string | undefined { + return this.socket?.id; + } +} + +// Singleton instance for client-side use +let clientInstance: RealtimeSocketClient | null = null; + +/** + * Get the singleton socket client instance + */ +export function getSocketClient(config?: SocketClientConfig): RealtimeSocketClient { + if (!clientInstance) { + clientInstance = new RealtimeSocketClient(config); + } + return clientInstance; +} + +/** + * Create a new socket client instance + */ +export function createSocketClient(config?: SocketClientConfig): RealtimeSocketClient { + return new RealtimeSocketClient(config); +} + +export { RealtimeSocketClient }; +export default RealtimeSocketClient; diff --git a/lib/realtime/socketServer.ts b/lib/realtime/socketServer.ts new file mode 100644 index 0000000..71d18a2 --- /dev/null +++ b/lib/realtime/socketServer.ts @@ -0,0 +1,343 @@ +/** + * Socket.io Server for LocalGreenChain + * + * Provides real-time WebSocket communication with automatic + * fallback to SSE for environments that don't support WebSockets. + */ + +import { Server as SocketIOServer, Socket } from 'socket.io'; +import type { Server as HTTPServer } from 'http'; +import type { + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData, + RoomType, + TransparencyEventType, + TransparencyEvent, +} from './types'; +import { getEventStream } from '../transparency/EventStream'; +import { getEventRooms, isValidRoom, canJoinRoom, getDefaultRooms } from './rooms'; +import * as crypto from 'crypto'; + +type TypedSocket = Socket; + +/** + * Socket.io server configuration + */ +interface SocketServerConfig { + cors?: { + origin: string | string[]; + credentials?: boolean; + }; + pingTimeout?: number; + pingInterval?: number; +} + +/** + * Socket.io server wrapper for LocalGreenChain + */ +class RealtimeSocketServer { + private io: SocketIOServer | null = null; + private eventStreamSubscriptionId: string | null = null; + private connectedClients: Map = new Map(); + private heartbeatInterval: NodeJS.Timeout | null = null; + + /** + * Initialize the Socket.io server + */ + initialize(httpServer: HTTPServer, config: SocketServerConfig = {}): void { + if (this.io) { + console.log('[RealtimeSocketServer] Already initialized'); + return; + } + + this.io = new SocketIOServer(httpServer, { + path: '/api/socket', + cors: config.cors || { + origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001', + credentials: true, + }, + pingTimeout: config.pingTimeout || 60000, + pingInterval: config.pingInterval || 25000, + transports: ['websocket', 'polling'], + }); + + this.setupMiddleware(); + this.setupEventHandlers(); + this.subscribeToEventStream(); + this.startHeartbeat(); + + console.log('[RealtimeSocketServer] Initialized'); + } + + /** + * Set up authentication and rate limiting middleware + */ + private setupMiddleware(): void { + if (!this.io) return; + + // Authentication middleware + this.io.use((socket, next) => { + const auth = socket.handshake.auth; + + // Generate session ID if not provided + const sessionId = auth.sessionId || `sess_${crypto.randomBytes(8).toString('hex')}`; + + // Attach data to socket + socket.data = { + userId: auth.userId, + sessionId, + connectedAt: Date.now(), + rooms: new Set(), + subscribedTypes: new Set(), + }; + + next(); + }); + } + + /** + * Set up socket event handlers + */ + private setupEventHandlers(): void { + if (!this.io) return; + + this.io.on('connection', (socket: TypedSocket) => { + console.log(`[RealtimeSocketServer] Client connected: ${socket.id}`); + + // Store connected client + this.connectedClients.set(socket.id, socket); + + // Join default rooms + const defaultRooms = getDefaultRooms(socket.data.userId); + defaultRooms.forEach((room) => { + socket.join(room); + socket.data.rooms.add(room); + }); + + // Send connection established event + socket.emit('connection:established', { + socketId: socket.id, + serverTime: Date.now(), + }); + + // Handle room join + socket.on('room:join', (room, callback) => { + if (!isValidRoom(room)) { + callback?.(false); + return; + } + + if (!canJoinRoom(Array.from(socket.data.rooms), room)) { + callback?.(false); + return; + } + + socket.join(room); + socket.data.rooms.add(room); + socket.emit('room:joined', room); + callback?.(true); + }); + + // Handle room leave + socket.on('room:leave', (room, callback) => { + socket.leave(room); + socket.data.rooms.delete(room); + socket.emit('room:left', room); + callback?.(true); + }); + + // Handle type subscriptions + socket.on('subscribe:types', (types, callback) => { + types.forEach((type) => socket.data.subscribedTypes.add(type)); + callback?.(true); + }); + + // Handle type unsubscriptions + socket.on('unsubscribe:types', (types, callback) => { + types.forEach((type) => socket.data.subscribedTypes.delete(type)); + callback?.(true); + }); + + // Handle ping for latency measurement + socket.on('ping', (callback) => { + callback(Date.now()); + }); + + // Handle recent events request + socket.on('events:recent', (limit, callback) => { + const eventStream = getEventStream(); + const events = eventStream.getRecent(Math.min(limit, 100)); + callback(events); + }); + + // Handle disconnect + socket.on('disconnect', (reason) => { + console.log(`[RealtimeSocketServer] Client disconnected: ${socket.id} (${reason})`); + this.connectedClients.delete(socket.id); + }); + }); + } + + /** + * Subscribe to the EventStream for broadcasting events + */ + private subscribeToEventStream(): void { + const eventStream = getEventStream(); + + // Subscribe to all event types + this.eventStreamSubscriptionId = eventStream.subscribe( + eventStream.getAvailableEventTypes(), + (event) => { + this.broadcastEvent(event); + } + ); + + console.log('[RealtimeSocketServer] Subscribed to EventStream'); + } + + /** + * Broadcast an event to appropriate rooms + */ + private broadcastEvent(event: TransparencyEvent): void { + if (!this.io) return; + + // Get rooms that should receive this event + const rooms = getEventRooms(event.type, event.data); + + // Emit to each room + rooms.forEach((room) => { + this.io!.to(room).emit('event', event); + }); + } + + /** + * Start heartbeat to keep connections alive + */ + private startHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + } + + this.heartbeatInterval = setInterval(() => { + if (this.io) { + this.io.emit('system:heartbeat', Date.now()); + } + }, 30000); // Every 30 seconds + } + + /** + * Emit an event to specific rooms + */ + emitToRooms(rooms: RoomType[], eventName: keyof ServerToClientEvents, data: unknown): void { + if (!this.io) return; + + rooms.forEach((room) => { + (this.io!.to(room) as any).emit(eventName, data); + }); + } + + /** + * Emit an event to a specific user + */ + emitToUser(userId: string, eventName: keyof ServerToClientEvents, data: unknown): void { + this.emitToRooms([`user:${userId}` as RoomType], eventName, data); + } + + /** + * Emit a system message to all connected clients + */ + emitSystemMessage(type: 'info' | 'warning' | 'error', text: string): void { + if (!this.io) return; + + this.io.emit('system:message', { type, text }); + } + + /** + * Get connected client count + */ + getConnectedCount(): number { + return this.connectedClients.size; + } + + /** + * Get all connected socket IDs + */ + getConnectedSockets(): string[] { + return Array.from(this.connectedClients.keys()); + } + + /** + * Get server stats + */ + getStats(): { + connectedClients: number; + rooms: string[]; + uptime: number; + } { + return { + connectedClients: this.connectedClients.size, + rooms: this.io ? Array.from(this.io.sockets.adapter.rooms.keys()) : [], + uptime: process.uptime(), + }; + } + + /** + * Shutdown the server gracefully + */ + async shutdown(): Promise { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + } + + if (this.eventStreamSubscriptionId) { + const eventStream = getEventStream(); + eventStream.unsubscribe(this.eventStreamSubscriptionId); + } + + if (this.io) { + // Notify all clients + this.io.emit('system:message', { + type: 'warning', + text: 'Server is shutting down', + }); + + // Disconnect all clients + this.io.disconnectSockets(true); + + // Close server + await new Promise((resolve) => { + this.io!.close(() => { + console.log('[RealtimeSocketServer] Shutdown complete'); + resolve(); + }); + }); + + this.io = null; + } + } + + /** + * Get the Socket.io server instance + */ + getIO(): SocketIOServer | null { + return this.io; + } +} + +// Singleton instance +let socketServerInstance: RealtimeSocketServer | null = null; + +/** + * Get the singleton socket server instance + */ +export function getSocketServer(): RealtimeSocketServer { + if (!socketServerInstance) { + socketServerInstance = new RealtimeSocketServer(); + } + return socketServerInstance; +} + +export { RealtimeSocketServer }; +export default RealtimeSocketServer; diff --git a/lib/realtime/types.ts b/lib/realtime/types.ts new file mode 100644 index 0000000..b313a82 --- /dev/null +++ b/lib/realtime/types.ts @@ -0,0 +1,152 @@ +/** + * Real-Time System Types for LocalGreenChain + * + * Defines all types used in the WebSocket/SSE real-time communication system. + */ + +import type { TransparencyEventType, EventPriority, TransparencyEvent } from '../transparency/EventStream'; + +// Re-export for convenience +export type { TransparencyEventType, EventPriority, TransparencyEvent }; + +/** + * Room types for Socket.io subscriptions + */ +export type RoomType = + | 'global' // All events + | 'plants' // All plant events + | 'transport' // All transport events + | 'farms' // All farm events + | 'demand' // All demand events + | 'system' // System events + | `plant:${string}` // Specific plant + | `farm:${string}` // Specific farm + | `user:${string}`; // User-specific events + +/** + * Connection status states + */ +export type ConnectionStatus = + | 'connecting' + | 'connected' + | 'disconnected' + | 'reconnecting' + | 'error'; + +/** + * Socket authentication payload + */ +export interface SocketAuthPayload { + userId?: string; + token?: string; + sessionId?: string; +} + +/** + * Socket handshake data + */ +export interface SocketHandshake { + auth: SocketAuthPayload; + rooms?: RoomType[]; +} + +/** + * Client-to-server events + */ +export interface ClientToServerEvents { + // Room management + 'room:join': (room: RoomType, callback?: (success: boolean) => void) => void; + 'room:leave': (room: RoomType, callback?: (success: boolean) => void) => void; + + // Event subscriptions + 'subscribe:types': (types: TransparencyEventType[], callback?: (success: boolean) => void) => void; + 'unsubscribe:types': (types: TransparencyEventType[], callback?: (success: boolean) => void) => void; + + // Ping for connection health + 'ping': (callback: (timestamp: number) => void) => void; + + // Request recent events + 'events:recent': (limit: number, callback: (events: TransparencyEvent[]) => void) => void; +} + +/** + * Server-to-client events + */ +export interface ServerToClientEvents { + // Real-time events + 'event': (event: TransparencyEvent) => void; + 'event:batch': (events: TransparencyEvent[]) => void; + + // Connection status + 'connection:established': (data: { socketId: string; serverTime: number }) => void; + 'connection:error': (error: { code: string; message: string }) => void; + + // Room notifications + 'room:joined': (room: RoomType) => void; + 'room:left': (room: RoomType) => void; + + // System messages + 'system:message': (message: { type: 'info' | 'warning' | 'error'; text: string }) => void; + 'system:heartbeat': (timestamp: number) => void; +} + +/** + * Inter-server events (for scaling) + */ +export interface InterServerEvents { + 'event:broadcast': (event: TransparencyEvent, rooms: RoomType[]) => void; +} + +/** + * Socket data attached to each connection + */ +export interface SocketData { + userId?: string; + sessionId: string; + connectedAt: number; + rooms: Set; + subscribedTypes: Set; +} + +/** + * Real-time notification for UI display + */ +export interface RealtimeNotification { + id: string; + type: 'success' | 'info' | 'warning' | 'error'; + title: string; + message: string; + timestamp: number; + eventType?: TransparencyEventType; + data?: Record; + read: boolean; + dismissed: boolean; +} + +/** + * Connection metrics for monitoring + */ +export interface ConnectionMetrics { + status: ConnectionStatus; + connectedAt?: number; + lastEventAt?: number; + eventsReceived: number; + reconnectAttempts: number; + latency?: number; + rooms: RoomType[]; +} + +/** + * Live feed item for display + */ +export interface LiveFeedItem { + id: string; + event: TransparencyEvent; + timestamp: number; + formatted: { + title: string; + description: string; + icon: string; + color: string; + }; +} diff --git a/lib/realtime/useSocket.ts b/lib/realtime/useSocket.ts new file mode 100644 index 0000000..ecd2f30 --- /dev/null +++ b/lib/realtime/useSocket.ts @@ -0,0 +1,258 @@ +/** + * React Hook for Socket.io Real-Time Updates + * + * Provides easy-to-use hooks for real-time data in React components. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { getSocketClient, RealtimeSocketClient } from './socketClient'; +import type { + ConnectionStatus, + TransparencyEvent, + RoomType, + TransparencyEventType, + ConnectionMetrics, + LiveFeedItem, +} from './types'; +import { toFeedItem } from './events'; + +/** + * Hook configuration options + */ +export interface UseSocketOptions { + autoConnect?: boolean; + rooms?: RoomType[]; + eventTypes?: TransparencyEventType[]; + userId?: string; + maxEvents?: number; +} + +/** + * Hook return type + */ +export interface UseSocketReturn { + status: ConnectionStatus; + isConnected: boolean; + events: TransparencyEvent[]; + latestEvent: TransparencyEvent | null; + metrics: ConnectionMetrics; + connect: () => void; + disconnect: () => void; + joinRoom: (room: RoomType) => Promise; + leaveRoom: (room: RoomType) => Promise; + clearEvents: () => void; +} + +/** + * Main socket hook for real-time updates + */ +export function useSocket(options: UseSocketOptions = {}): UseSocketReturn { + const { + autoConnect = true, + rooms = [], + eventTypes = [], + userId, + maxEvents = 100, + } = options; + + const [status, setStatus] = useState('disconnected'); + const [events, setEvents] = useState([]); + const [latestEvent, setLatestEvent] = useState(null); + const [metrics, setMetrics] = useState({ + status: 'disconnected', + eventsReceived: 0, + reconnectAttempts: 0, + rooms: [], + }); + + const clientRef = useRef(null); + const cleanupRef = useRef<(() => void)[]>([]); + + // Initialize client + useEffect(() => { + if (typeof window === 'undefined') return; + + const client = getSocketClient({ auth: { userId } }); + clientRef.current = client; + + // Set up listeners + const unsubStatus = client.onStatusChange((newStatus) => { + setStatus(newStatus); + setMetrics(client.getMetrics()); + }); + + const unsubEvent = client.onEvent((event) => { + setLatestEvent(event); + setEvents((prev) => { + const updated = [event, ...prev]; + return updated.slice(0, maxEvents); + }); + setMetrics(client.getMetrics()); + }); + + cleanupRef.current = [unsubStatus, unsubEvent]; + + // Auto connect + if (autoConnect) { + client.connect(); + } + + // Initial metrics + setMetrics(client.getMetrics()); + + return () => { + cleanupRef.current.forEach((cleanup) => cleanup()); + }; + }, [autoConnect, userId, maxEvents]); + + // Join initial rooms + useEffect(() => { + if (!clientRef.current || status !== 'connected') return; + + rooms.forEach((room) => { + clientRef.current?.joinRoom(room); + }); + }, [status, rooms]); + + // Subscribe to event types + useEffect(() => { + if (!clientRef.current || status !== 'connected' || eventTypes.length === 0) return; + + clientRef.current.subscribeToTypes(eventTypes); + }, [status, eventTypes]); + + const connect = useCallback(() => { + clientRef.current?.connect(); + }, []); + + const disconnect = useCallback(() => { + clientRef.current?.disconnect(); + }, []); + + const joinRoom = useCallback(async (room: RoomType) => { + return clientRef.current?.joinRoom(room) ?? false; + }, []); + + const leaveRoom = useCallback(async (room: RoomType) => { + return clientRef.current?.leaveRoom(room) ?? false; + }, []); + + const clearEvents = useCallback(() => { + setEvents([]); + setLatestEvent(null); + }, []); + + return { + status, + isConnected: status === 'connected', + events, + latestEvent, + metrics, + connect, + disconnect, + joinRoom, + leaveRoom, + clearEvents, + }; +} + +/** + * Hook for live feed display + */ +export function useLiveFeed(options: UseSocketOptions = {}): { + items: LiveFeedItem[]; + isConnected: boolean; + status: ConnectionStatus; + clearFeed: () => void; +} { + const { events, isConnected, status, clearEvents } = useSocket(options); + + const items = events.map((event) => toFeedItem(event)); + + return { + items, + isConnected, + status, + clearFeed: clearEvents, + }; +} + +/** + * Hook for tracking a specific plant's real-time updates + */ +export function usePlantUpdates(plantId: string): { + events: TransparencyEvent[]; + isConnected: boolean; +} { + return useSocket({ + rooms: [`plant:${plantId}` as RoomType], + eventTypes: [ + 'plant.registered', + 'plant.cloned', + 'plant.transferred', + 'plant.updated', + 'transport.started', + 'transport.completed', + ], + }) as { events: TransparencyEvent[]; isConnected: boolean }; +} + +/** + * Hook for tracking a specific farm's real-time updates + */ +export function useFarmUpdates(farmId: string): { + events: TransparencyEvent[]; + isConnected: boolean; +} { + return useSocket({ + rooms: [`farm:${farmId}` as RoomType], + eventTypes: [ + 'farm.registered', + 'farm.updated', + 'batch.started', + 'batch.harvested', + 'agent.alert', + ], + }) as { events: TransparencyEvent[]; isConnected: boolean }; +} + +/** + * Hook for connection status only (lightweight) + */ +export function useConnectionStatus(): { + status: ConnectionStatus; + isConnected: boolean; + latency: number | undefined; +} { + const { status, isConnected, metrics } = useSocket({ autoConnect: true }); + + return { + status, + isConnected, + latency: metrics.latency, + }; +} + +/** + * Hook for event counts (useful for notification badges) + */ +export function useEventCount(options: UseSocketOptions = {}): { + count: number; + unreadCount: number; + markAllRead: () => void; +} { + const { events } = useSocket(options); + const [readCount, setReadCount] = useState(0); + + const markAllRead = useCallback(() => { + setReadCount(events.length); + }, [events.length]); + + return { + count: events.length, + unreadCount: Math.max(0, events.length - readCount), + markAllRead, + }; +} + +export default useSocket; diff --git a/package.json b/package.json index b1350a8..8d639be 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-hook-form": "^7.8.6", + "socket.io": "^4.7.2", + "socket.io-client": "^4.7.2", "socks-proxy-agent": "^8.0.2" }, "devDependencies": { diff --git a/pages/api/socket.ts b/pages/api/socket.ts new file mode 100644 index 0000000..7a42de3 --- /dev/null +++ b/pages/api/socket.ts @@ -0,0 +1,78 @@ +/** + * Socket.io API Endpoint for LocalGreenChain + * + * This endpoint initializes the Socket.io server and handles + * the WebSocket upgrade for real-time communication. + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import type { Server as HTTPServer } from 'http'; +import type { Socket as NetSocket } from 'net'; +import { getSocketServer } from '../../lib/realtime/socketServer'; + +/** + * Extended response type with socket server + */ +interface SocketResponse extends NextApiResponse { + socket: NetSocket & { + server: HTTPServer & { + io?: ReturnType['getIO']; + }; + }; +} + +/** + * Socket.io initialization handler + * + * This endpoint is called when the Socket.io client connects. + * It initializes the Socket.io server if not already running. + */ +export default function handler(req: NextApiRequest, res: SocketResponse) { + // Only allow GET requests for WebSocket upgrade + if (req.method !== 'GET') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + // Check if Socket.io is already initialized + if (res.socket.server.io) { + console.log('[Socket API] Socket.io already initialized'); + res.status(200).json({ status: 'ok', message: 'Socket.io already running' }); + return; + } + + try { + // Get the socket server singleton + const socketServer = getSocketServer(); + + // Initialize with the HTTP server + socketServer.initialize(res.socket.server); + + // Store reference on the server object + res.socket.server.io = socketServer.getIO(); + + console.log('[Socket API] Socket.io initialized successfully'); + + res.status(200).json({ + status: 'ok', + message: 'Socket.io initialized', + stats: socketServer.getStats(), + }); + } catch (error) { + console.error('[Socket API] Failed to initialize Socket.io:', error); + res.status(500).json({ + status: 'error', + message: 'Failed to initialize Socket.io', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} + +/** + * Disable body parsing for WebSocket upgrade + */ +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/pages/api/socket/stats.ts b/pages/api/socket/stats.ts new file mode 100644 index 0000000..bae7751 --- /dev/null +++ b/pages/api/socket/stats.ts @@ -0,0 +1,63 @@ +/** + * Socket.io Stats API Endpoint + * + * Returns statistics about the WebSocket server and connections. + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getSocketServer } from '../../../lib/realtime/socketServer'; +import { getEventStream } from '../../../lib/transparency/EventStream'; + +interface SocketStats { + server: { + connectedClients: number; + rooms: string[]; + uptime: number; + }; + events: { + totalEvents: number; + eventsLast24h: number; + eventsByType: Record; + averageEventsPerMinute: number; + }; + status: 'running' | 'stopped'; +} + +/** + * Get Socket.io server statistics + */ +export default function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'GET') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + try { + const socketServer = getSocketServer(); + const eventStream = getEventStream(); + + const serverStats = socketServer.getStats(); + const eventStats = eventStream.getStats(); + + const stats: SocketStats = { + server: serverStats, + events: { + totalEvents: eventStats.totalEvents, + eventsLast24h: eventStats.eventsLast24h, + eventsByType: eventStats.eventsByType, + averageEventsPerMinute: eventStats.averageEventsPerMinute, + }, + status: socketServer.getIO() ? 'running' : 'stopped', + }; + + res.status(200).json(stats); + } catch (error) { + console.error('[Socket Stats API] Error:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} diff --git a/tailwind.config.js b/tailwind.config.js index 8752b4d..2e16692 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -5,7 +5,23 @@ module.exports = { "./components/**/*.{js,ts,jsx,tsx}", ], theme: { - extend: {}, + extend: { + animation: { + fadeIn: 'fadeIn 0.3s ease-in-out', + slideIn: 'slideIn 0.3s ease-out', + pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0', transform: 'translateY(-10px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + slideIn: { + '0%': { opacity: '0', transform: 'translateX(20px)' }, + '100%': { opacity: '1', transform: 'translateX(0)' }, + }, + }, + }, }, variants: { extend: {},