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.
This commit is contained in:
parent
705105d9b6
commit
7098335ce7
17 changed files with 3059 additions and 1 deletions
167
components/realtime/ConnectionStatus.tsx
Normal file
167
components/realtime/ConnectionStatus.tsx
Normal 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;
|
||||||
256
components/realtime/LiveChart.tsx
Normal file
256
components/realtime/LiveChart.tsx
Normal 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;
|
||||||
255
components/realtime/LiveFeed.tsx
Normal file
255
components/realtime/LiveFeed.tsx
Normal 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;
|
||||||
325
components/realtime/NotificationToast.tsx
Normal file
325
components/realtime/NotificationToast.tsx
Normal 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;
|
||||||
27
components/realtime/index.ts
Normal file
27
components/realtime/index.ts
Normal 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';
|
||||||
235
lib/realtime/SocketContext.tsx
Normal file
235
lib/realtime/SocketContext.tsx
Normal 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
273
lib/realtime/events.ts
Normal 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
92
lib/realtime/index.ts
Normal 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
137
lib/realtime/rooms.ts
Normal 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;
|
||||||
|
}
|
||||||
379
lib/realtime/socketClient.ts
Normal file
379
lib/realtime/socketClient.ts
Normal 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;
|
||||||
343
lib/realtime/socketServer.ts
Normal file
343
lib/realtime/socketServer.ts
Normal 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
152
lib/realtime/types.ts
Normal 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
258
lib/realtime/useSocket.ts
Normal 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;
|
||||||
|
|
@ -30,6 +30,8 @@
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-hook-form": "^7.8.6",
|
"react-hook-form": "^7.8.6",
|
||||||
|
"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
78
pages/api/socket.ts
Normal 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
63
pages/api/socket/stats.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: {},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue