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.
256 lines
7.3 KiB
TypeScript
256 lines
7.3 KiB
TypeScript
/**
|
|
* 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;
|