Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions frontend/app/components/RealTimeMetricsWithWebSocket.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
'use client';

import { useEffect, useState, useCallback } from 'react';
import { Line } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { LoadingSpinner, InlineLoading } from '@/app/components/ui/LoadingSpinner';
import { Alert, AlertDescription, AlertTitle } from '@/app/components/ui/Alert';
import { Button } from '@/app/components/ui/Button';
import useWebSocket from '../hooks/useWebSocket';

// Register ChartJS components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);

type MetricData = {
timestamp: number;
value: number;
};

const WEBSOCKET_URL = process.env.NEXT_PUBLIC_WEBSOCKET_URL || 'ws://localhost:3001';

export function RealTimeMetricsWithWebSocket() {
const [metrics, setMetrics] = useState<MetricData[]>([]);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleWebSocketMessage = useCallback((data: any) => {
try {
// Process the incoming WebSocket message
// This assumes the server sends data in the format: { timestamp: number, value: number }
const newMetric: MetricData = {
timestamp: data.timestamp || Date.now(),
value: data.value || 0,
};

setMetrics(prev => {
// Keep only the last 100 data points for performance
const updated = [...prev, newMetric].slice(-100);
return updated;
});
} catch (err) {
console.error('Error processing WebSocket message:', err);
setError('Failed to process incoming data');
}
}, []);

const handleError = useCallback((error: Error) => {
console.error('WebSocket error:', error);
setError(error.message || 'Connection error');
}, []);

const handleConnect = useCallback(() => {
console.log('WebSocket connected');
setError(null);
if (!isInitialized) {
setIsInitialized(true);
}
}, [isInitialized]);

const handleDisconnect = useCallback(() => {
console.log('WebSocket disconnected');
}, []);

// Initialize WebSocket connection
const {
state: connectionState,
reconnect,
isConnected,
isConnecting,
} = useWebSocket({
url: WEBSOCKET_URL,
onMessage: handleWebSocketMessage,
onConnect: handleConnect,
onDisconnect: handleDisconnect,
onError: handleError,
});

// Process metrics for chart
const processChartData = () => {
if (metrics.length === 0) {
return {
labels: [],
datasets: [{
label: 'Metrics',
data: [],
borderColor: '#4F46E5',
backgroundColor: 'rgba(79, 70, 229, 0.2)',
borderWidth: 2,
tension: 0.4,
fill: true,
}],
};
}

return {
labels: metrics.map((m) => new Date(m.timestamp).toLocaleTimeString()),
datasets: [
{
label: 'Metrics',
data: metrics.map((m) => m.value),
borderColor: '#4F46E5',
backgroundColor: 'rgba(79, 70, 229, 0.2)',
borderWidth: 2,
tension: 0.4,
fill: true,
},
],
};
};

const chartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 300,
easing: 'easeInOutQuad' as const,
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 8,
},
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
mode: 'index' as const,
intersect: false,
},
},
};

// Show loading state if we're still connecting and have no data
if (isConnecting && !isInitialized) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-2 text-gray-600">Connecting to real-time data...</p>
</div>
</div>
);
}

// Show error state if there's an error
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>Connection Error</AlertTitle>
<AlertDescription className="space-y-2">
<p>{error}</p>
<Button onClick={reconnect} size="sm">
Reconnect
</Button>
</AlertDescription>
</Alert>
);
}

// Show empty state if no data is available yet
if (metrics.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 space-y-4 text-center p-4 border border-dashed rounded-lg">
<p className="text-gray-500">Waiting for real-time data...</p>
<InlineLoading text="Connecting to data stream" />
</div>
);
}

return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Real-time Metrics</h3>
<div className="flex items-center space-x-2">
{!isConnected && (
<div className="flex items-center space-x-1 text-sm text-yellow-600">
<span className="h-2 w-2 rounded-full bg-yellow-500"></span>
<span>Reconnecting...</span>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={reconnect}
disabled={isConnecting}
>
{isConnecting ? 'Connecting...' : 'Refresh'}
</Button>
</div>
</div>

<div className="relative h-80 w-full">
<Line data={processChartData()} options={chartOptions} />
</div>

<div className="text-xs text-gray-500 flex justify-between">
<span>{metrics.length} data points</span>
<span>Last updated: {new Date().toLocaleTimeString()}</span>
</div>
</div>
);
}
66 changes: 66 additions & 0 deletions frontend/app/components/ui/Alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as React from 'react';
import { VariantProps, cva } from 'class-variance-authority';
import { cn } from '../../lib/utils';

const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
success:
'border-green-500/50 text-green-700 dark:border-green-500 [&>svg]:text-green-500',
warning:
'border-yellow-500/50 text-yellow-700 dark:border-yellow-500 [&>svg]:text-yellow-500',
info: 'border-blue-500/50 text-blue-700 dark:border-blue-500 [&>svg]:text-blue-500',
},
},
defaultVariants: {
variant: 'default',
},
}
);

interface AlertProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof alertVariants> {}

const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
);
Alert.displayName = 'Alert';

const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';

const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';

export { Alert, AlertTitle, AlertDescription };
55 changes: 55 additions & 0 deletions frontend/app/components/ui/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';

const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'underline-offset-4 hover:underline text-primary',
},
size: {
default: 'h-10 py-2 px-4',
sm: 'h-9 px-3 rounded-md',
lg: 'h-11 px-8 rounded-md',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';

export { Button, buttonVariants };
Loading