Merge: Real-time updates with Socket.io (Agent 6) - resolved conflicts

This commit is contained in:
Vinnie Esposito 2025-11-23 11:00:12 -06:00
commit 7e61cd1af7
17 changed files with 3059 additions and 1 deletions

View file

@ -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 (
<div
className={classNames(
'inline-flex items-center gap-2',
className
)}
title={getStatusLabel(status)}
>
{/* Status dot */}
<span
className={classNames(
'rounded-full',
sizeClasses.dot,
getStatusColor(status)
)}
/>
{/* Label */}
{showLabel && (
<span className={classNames('text-gray-600', sizeClasses.text)}>
{getStatusLabel(status)}
</span>
)}
{/* Latency */}
{showLatency && status === 'connected' && latency !== undefined && (
<span className={classNames('text-gray-400', sizeClasses.text)}>
({latency}ms)
</span>
)}
</div>
);
}
/**
* Compact connection indicator (dot only)
*/
export function ConnectionDot({ className }: { className?: string }) {
const { status } = useConnectionStatus();
return (
<span
className={classNames(
'inline-block w-2 h-2 rounded-full',
getStatusColor(status),
className
)}
title={getStatusLabel(status)}
/>
);
}
/**
* 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 (
<div className={bannerClasses}>
{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.'}
</div>
);
}
export default ConnectionStatus;

View file

@ -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 (
<div className={classNames('bg-white rounded-lg p-4 border border-gray-200', className)}>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-gray-700">{title}</h4>
{latestValue !== null && (
<span className="text-lg font-bold" style={{ color }}>
{latestValue.toFixed(1)}
</span>
)}
</div>
{/* Chart */}
<svg
width="100%"
height={chartHeight}
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
preserveAspectRatio="xMidYMid meet"
>
{/* Grid */}
{showGrid && (
<g className="text-gray-200">
{/* Horizontal grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => (
<line
key={`h-${ratio}`}
x1={padding.left}
y1={padding.top + innerHeight * ratio}
x2={padding.left + innerWidth}
y2={padding.top + innerHeight * ratio}
stroke="currentColor"
strokeDasharray="2,2"
/>
))}
{/* Vertical grid lines */}
{[0, 0.5, 1].map((ratio) => (
<line
key={`v-${ratio}`}
x1={padding.left + innerWidth * ratio}
y1={padding.top}
x2={padding.left + innerWidth * ratio}
y2={padding.top + innerHeight}
stroke="currentColor"
strokeDasharray="2,2"
/>
))}
</g>
)}
{/* Y-axis labels */}
<g className="text-gray-500 text-xs">
<text x={padding.left - 5} y={padding.top + 4} textAnchor="end">
{maxValue.toFixed(0)}
</text>
<text x={padding.left - 5} y={padding.top + innerHeight} textAnchor="end">
{minValue.toFixed(0)}
</text>
</g>
{/* Line path */}
{pathD && (
<>
{/* Gradient area */}
<defs>
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<path
d={`${pathD} L ${points[points.length - 1]?.x},${padding.top + innerHeight} L ${points[0]?.x},${padding.top + innerHeight} Z`}
fill="url(#areaGradient)"
/>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
{/* Data points */}
{points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r={i === points.length - 1 ? 4 : 2}
fill={color}
/>
))}
{/* No data message */}
{dataPoints.length === 0 && (
<text
x={chartWidth / 2}
y={chartHeight / 2}
textAnchor="middle"
className="text-gray-400 text-sm"
>
Waiting for data...
</text>
)}
</svg>
</div>
);
}
/**
* 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<string, number> = {};
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 (
<div className={classNames('bg-white rounded-lg p-4 border border-gray-200', className)}>
<h4 className="text-sm font-medium text-gray-700 mb-2">Events per Minute</h4>
<div className="flex items-end gap-1 h-16">
{countsByMinute.map((count, i) => (
<div
key={i}
className="flex-1 bg-blue-500 rounded-t transition-all duration-300"
style={{ height: `${(count / maxCount) * 100}%` }}
title={`${count} events`}
/>
))}
</div>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>10m ago</span>
<span>Now</span>
</div>
</div>
);
}
export default LiveChart;

View file

@ -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 (
<div
className={classNames(
'p-3 rounded-lg border transition-all duration-300 animate-fadeIn',
colors.bg,
colors.border
)}
>
<div className="flex items-start gap-3">
{/* Icon */}
<span className="text-xl flex-shrink-0" role="img" aria-label={item.formatted.title}>
{item.formatted.icon}
</span>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className={classNames('font-medium text-sm', colors.text)}>
{item.formatted.title}
</span>
{showTimestamp && (
<span className="text-xs text-gray-400 flex-shrink-0">
{formatTimestamp(item.timestamp)}
</span>
)}
</div>
<p className="text-sm text-gray-600 mt-1 truncate">
{item.formatted.description}
</p>
</div>
</div>
</div>
);
}
/**
* 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 (
<div className={classNames('flex flex-col h-full', className)}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-gray-900">Live Feed</h3>
{showConnectionStatus && <ConnectionStatus size="sm" showLabel={false} />}
</div>
<div className="flex items-center gap-2">
{filteredItems.length > 0 && (
<span className="text-sm text-gray-500">
{filteredItems.length} event{filteredItems.length !== 1 ? 's' : ''}
</span>
)}
{showClearButton && filteredItems.length > 0 && (
<button
onClick={clearFeed}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Clear
</button>
)}
</div>
</div>
{/* Feed content */}
<div className="flex-1 overflow-y-auto space-y-2">
{filteredItems.length === 0 ? (
<div className="text-center py-8">
<div className="text-4xl mb-2">📡</div>
<p className="text-gray-500 text-sm">{emptyMessage}</p>
{!isConnected && (
<p className="text-yellow-600 text-xs mt-2">
Status: {status}
</p>
)}
</div>
) : (
filteredItems.map((item) => (
<FeedItem
key={item.id}
item={item}
showTimestamp={showTimestamps}
/>
))
)}
</div>
</div>
);
}
/**
* 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 (
<div className={classNames('space-y-1', className)}>
{items.slice(0, maxItems).map((item) => (
<div
key={item.id}
className="flex items-center gap-2 py-1 text-sm"
>
<span>{item.formatted.icon}</span>
<span className="truncate text-gray-600">
{item.formatted.description}
</span>
</div>
))}
</div>
);
}
export default LiveFeed;

View file

@ -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 (
<div
className={classNames(
'max-w-sm w-full p-4 rounded-lg border shadow-lg transition-all duration-300',
styles.bg,
styles.border,
{
'opacity-0 translate-x-4': !isVisible || isLeaving,
'opacity-100 translate-x-0': isVisible && !isLeaving,
}
)}
role="alert"
>
<div className="flex items-start gap-3">
{/* Icon */}
<span className={classNames('text-xl font-bold flex-shrink-0', styles.iconColor)}>
{styles.icon}
</span>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 text-sm">
{notification.title}
</h4>
<p className="text-sm text-gray-600 mt-1">
{notification.message}
</p>
</div>
{/* Close button */}
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Dismiss notification"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
);
}
/**
* 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 (
<div
className={classNames(
'fixed z-50 flex flex-col gap-2',
getPositionClasses(position),
className
)}
>
{visibleNotifications.map((notification) => (
<Toast
key={notification.id}
notification={notification}
onDismiss={dismissNotification}
autoHideDuration={autoHideDuration}
/>
))}
</div>
);
}
/**
* Notification bell with badge
*/
export function NotificationBell({
onClick,
className,
}: {
onClick?: () => void;
className?: string;
}) {
const { unreadCount } = useSocketContext();
return (
<button
onClick={onClick}
className={classNames(
'relative p-2 text-gray-600 hover:text-gray-900 transition-colors',
className
)}
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
>
{/* Bell icon */}
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{/* Badge */}
{unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
);
}
/**
* Notification list dropdown
*/
export function NotificationList({
className,
onClose,
}: {
className?: string;
onClose?: () => void;
}) {
const { notifications, markNotificationRead, markAllRead } = useSocketContext();
return (
<div
className={classNames(
'w-80 max-h-96 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden',
className
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<h3 className="font-semibold text-gray-900">Notifications</h3>
{notifications.length > 0 && (
<button
onClick={markAllRead}
className="text-sm text-blue-600 hover:text-blue-800"
>
Mark all read
</button>
)}
</div>
{/* List */}
<div className="overflow-y-auto max-h-72">
{notifications.length === 0 ? (
<div className="py-8 text-center text-gray-500">
<div className="text-3xl mb-2">🔔</div>
<p className="text-sm">No notifications</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={classNames(
'px-4 py-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors',
{ 'bg-blue-50': !notification.read }
)}
onClick={() => markNotificationRead(notification.id)}
>
<div className="flex items-start gap-2">
<span className="text-lg">
{getTypeStyles(notification.type).icon}
</span>
<div className="flex-1 min-w-0">
<p className={classNames('text-sm', { 'font-medium': !notification.read })}>
{notification.title}
</p>
<p className="text-xs text-gray-500 truncate">
{notification.message}
</p>
<p className="text-xs text-gray-400 mt-1">
{new Date(notification.timestamp).toLocaleTimeString()}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
);
}
export default NotificationToast;

View file

@ -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';

View file

@ -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<boolean>;
leaveRoom: (room: RoomType) => Promise<boolean>;
subscribeToTypes: (types: TransparencyEventType[]) => Promise<boolean>;
clearEvents: () => void;
markNotificationRead: (id: string) => void;
dismissNotification: (id: string) => void;
markAllRead: () => void;
}
const SocketContext = createContext<SocketContextValue | null>(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<ConnectionStatus>('disconnected');
const [events, setEvents] = useState<TransparencyEvent[]>([]);
const [latestEvent, setLatestEvent] = useState<TransparencyEvent | null>(null);
const [notifications, setNotifications] = useState<RealtimeNotification[]>([]);
const [metrics, setMetrics] = useState<ConnectionMetrics>({
status: 'disconnected',
eventsReceived: 0,
reconnectAttempts: 0,
rooms: [],
});
const clientRef = useRef<RealtimeSocketClient | null>(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 (
<SocketContext.Provider value={value}>
{children}
</SocketContext.Provider>
);
}
/**
* 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;

273
lib/realtime/events.ts Normal file
View file

@ -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<TransparencyEventType, EventCategory> = {
'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<TransparencyEventType, EventDisplay> = {
'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();
});
}

92
lib/realtime/index.ts Normal file
View file

@ -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';

137
lib/realtime/rooms.ts Normal file
View file

@ -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<string, unknown>
): 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;
}

View file

@ -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<ServerToClientEvents, ClientToServerEvents>;
/**
* 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<EventListener> = new Set();
private statusListeners: Set<StatusListener> = new Set();
private errorListeners: Set<ErrorListener> = 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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<TransparencyEvent[]> {
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;

View file

@ -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<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
/**
* 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<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData> | null = null;
private eventStreamSubscriptionId: string | null = null;
private connectedClients: Map<string, TypedSocket> = 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<RoomType>(),
subscribedTypes: new Set<TransparencyEventType>(),
};
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<void> {
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<void>((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;

152
lib/realtime/types.ts Normal file
View file

@ -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<RoomType>;
subscribedTypes: Set<TransparencyEventType>;
}
/**
* 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<string, unknown>;
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;
};
}

258
lib/realtime/useSocket.ts Normal file
View file

@ -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<boolean>;
leaveRoom: (room: RoomType) => Promise<boolean>;
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<ConnectionStatus>('disconnected');
const [events, setEvents] = useState<TransparencyEvent[]>([]);
const [latestEvent, setLatestEvent] = useState<TransparencyEvent | null>(null);
const [metrics, setMetrics] = useState<ConnectionMetrics>({
status: 'disconnected',
eventsReceived: 0,
reconnectAttempts: 0,
rooms: [],
});
const clientRef = useRef<RealtimeSocketClient | null>(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;

View file

@ -51,6 +51,8 @@
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-hook-form": "^7.8.6", "react-hook-form": "^7.8.6",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"socket.io": "^4.7.2",
"socket.io-client": "^4.7.2",
"socks-proxy-agent": "^8.0.2" "socks-proxy-agent": "^8.0.2"
}, },
"devDependencies": { "devDependencies": {

78
pages/api/socket.ts Normal file
View file

@ -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<typeof getSocketServer>['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,
},
};

63
pages/api/socket/stats.ts Normal file
View file

@ -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<string, number>;
averageEventsPerMinute: number;
};
status: 'running' | 'stopped';
}
/**
* Get Socket.io server statistics
*/
export default function handler(
req: NextApiRequest,
res: NextApiResponse<SocketStats | { error: string }>
) {
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',
});
}
}

View file

@ -5,7 +5,23 @@ module.exports = {
"./components/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}",
], ],
theme: { 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: { variants: {
extend: {}, extend: {},