Dashboard en Tiempo Real con React
Dashboard interactivo Next.js con WebSockets para métricas en vivo desde CloudWatch y DynamoDB Streams.
Problema & Solución
Problema
Un equipo de operaciones necesita monitorear métricas de su plataforma AWS en tiempo real (CPU, requests/s, errores, latencia p95) sin refrescar la página. Los datos vienen de CloudWatch y DynamoDB. La solución debe actualizar el dashboard automáticamente cada vez que hay nuevos datos.
Solución
Frontend Next.js con componentes de gráficos en tiempo real. WebSocket server (Node.js) que actúa como proxy entre el browser y las APIs de AWS: consulta CloudWatch Metrics cada 60 segundos y escucha DynamoDB Streams en tiempo real. El cliente usa un hook personalizado useMetricsStream que gestiona la conexión WebSocket, reconexión automática y el buffer de datos.
Diagrama de Arquitectura
Browser (Next.js)
│
│ WebSocket (ws://)
▼
┌────────────────────────────────────────────────────┐
│ WebSocket Server (Node.js) │
│ │
│ ┌─────────────────┐ ┌──────────────────────┐ │
│ │ CloudWatch │ │ DynamoDB Streams │ │
│ │ Poller (60s) │ │ Listener (real-time)│ │
│ └────────┬────────┘ └──────────┬───────────┘ │
│ │ │ │
│ └──────────┬─────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Broadcast a │ │
│ │ clientes WS │ │
│ │ suscritos │ │
│ └─────────────────┘ │
└────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────────┐
│ CloudWatch │ │ DynamoDB │
│ Metrics │ │ Streams │
│ (CPU, req, │ │ (nuevos items) │
│ latencia) │ └─────────────────┘
└─────────────┘
Frontend State Machine (useMetricsStream):
DISCONNECTED ──connect──▶ CONNECTING ──open──▶ CONNECTED
▲ │
└──────────── error/close (backoff retry) ◀───┘Implementación
Hook useMetricsStream — gestión del WebSocket en React
El hook gestiona el ciclo de vida completo de la conexión WebSocket: conectar, recibir datos, reconectar con exponential backoff (1s, 2s, 4s, 8s, max 30s) ante desconexiones. Mantiene un buffer circular de los últimos 60 puntos de datos por métrica para renderizar la gráfica. Cleanup en el useEffect garantiza que el socket se cierra cuando el componente se desmonta.
CloudWatch Metrics Poller (server-side)
El WebSocket server consulta CloudWatch GetMetricData cada 60 segundos para las métricas configuradas (CPU, RequestCount, TargetResponseTime, HTTPCode_ELB_5XX_Count). Usa el SDK v3 de AWS con IAM Role (sin credentials hardcodeadas). Los datos se normalizan y se brodacastean a todos los clientes WebSocket suscritos a esa métrica.
DynamoDB Streams — eventos en tiempo real
DynamoDB Streams captura cada INSERT/MODIFY/REMOVE en la tabla. El servidor usa el SDK para leer los stream records con getShardIterator + getRecords en polling (o con Kinesis Client Library para mayor robustez). Cada nuevo item se parsea y se brodacastea al dashboard en <1 segundo desde el evento original.
Renderizado de gráficas con Recharts
Los componentes de gráfica usan Recharts (LineChart, AreaChart). Son 'use client' en Next.js (interactivos). Cada punto de dato nuevo se añade al estado con setData(prev => [...prev.slice(-59), newPoint]) — mantiene los últimos 60 puntos y el componente re-renderiza solo la parte que cambia gracias al virtual DOM de React.
Tech Stack
Frontend
WebSocket Server
AWS SDKs
Infraestructura
Decisiones Técnicas
WebSockets vs Server-Sent Events (SSE) vs Polling
Elegido
WebSockets
Alternativas
- —Server-Sent Events — más simple, nativo en browser, pero solo server→client
- —Long Polling — compatible con cualquier servidor HTTP, más latencia
- —GraphQL Subscriptions — over WebSockets, más estructura pero más overhead
Razón
WebSockets permiten comunicación bidireccional — el cliente puede suscribirse a métricas específicas y el server solo envía lo relevante. SSE es unidireccional (server → client) y más simple pero no permite que el cliente filtre qué datos quiere. Polling (fetch cada N segundos) añade latencia y carga innecesaria.
Snippets de Código
import { useEffect, useRef, useState, useCallback } from "react";
type MetricPoint = { timestamp: number; value: number };
type ConnectionState = "disconnected" | "connecting" | "connected" | "error";
const MAX_BACKOFF_MS = 30_000;
export function useMetricsStream(metricName: string, maxPoints = 60) {
const [data, setData] = useState<MetricPoint[]>([]);
const [state, setState] = useState<ConnectionState>("disconnected");
const wsRef = useRef<WebSocket | null>(null);
const backoffRef = useRef(1000);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const connect = useCallback(() => {
const ws = new WebSocket(
`${process.env.NEXT_PUBLIC_WS_URL}/metrics`
);
wsRef.current = ws;
setState("connecting");
ws.onopen = () => {
setState("connected");
backoffRef.current = 1000; // reset backoff al conectar
ws.send(JSON.stringify({ action: "subscribe", metric: metricName }));
};
ws.onmessage = (event: MessageEvent) => {
const point: MetricPoint = JSON.parse(event.data as string);
setData((prev) => [...prev.slice(-(maxPoints - 1)), point]);
};
ws.onerror = () => setState("error");
ws.onclose = () => {
setState("disconnected");
// Exponential backoff con jitter
const delay = Math.min(backoffRef.current, MAX_BACKOFF_MS);
backoffRef.current = Math.min(backoffRef.current * 2, MAX_BACKOFF_MS);
timeoutRef.current = setTimeout(connect, delay + Math.random() * 1000);
};
}, [metricName, maxPoints]);
useEffect(() => {
connect();
return () => {
clearTimeout(timeoutRef.current);
wsRef.current?.close(1000, "Component unmounted");
};
}, [connect]);
return { data, state };
}import { CloudWatchClient, GetMetricDataCommand } from "@aws-sdk/client-cloudwatch";
import { WebSocketServer, WebSocket } from "ws";
const cw = new CloudWatchClient({ region: "us-east-1" });
const wss = new WebSocketServer({ port: 8080 });
// Map de metric → Set de clients suscritos
const subscriptions = new Map<string, Set<WebSocket>>();
wss.on("connection", (ws) => {
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString()) as { action: string; metric: string };
if (msg.action === "subscribe") {
if (!subscriptions.has(msg.metric)) {
subscriptions.set(msg.metric, new Set());
}
subscriptions.get(msg.metric)!.add(ws);
}
});
ws.on("close", () => {
// Limpia suscripciones del cliente desconectado
for (const clients of subscriptions.values()) {
clients.delete(ws);
}
});
});
async function pollCloudWatch(): Promise<void> {
const now = new Date();
const start = new Date(now.getTime() - 5 * 60 * 1000); // últimos 5 min
const { MetricDataResults } = await cw.send(
new GetMetricDataCommand({
StartTime: start,
EndTime: now,
MetricDataQueries: [
{
Id: "cpu",
MetricStat: {
Metric: {
Namespace: "AWS/ECS",
MetricName: "CPUUtilization",
Dimensions: [{ Name: "ServiceName", Value: "portfolio-api" }],
},
Period: 60,
Stat: "Average",
},
},
],
})
);
for (const result of MetricDataResults ?? []) {
const clients = subscriptions.get(result.Id ?? "");
if (!clients?.size) continue;
const points = (result.Timestamps ?? []).map((ts, i) => ({
timestamp: ts.getTime(),
value: result.Values?.[i] ?? 0,
}));
for (const point of points) {
const payload = JSON.stringify(point);
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
}
}
}
// Poll cada 60 segundos
setInterval(pollCloudWatch, 60_000);
pollCloudWatch(); // ejecuta inmediatamente al arrancar