CF Dashboard
Dashboard para visualizar métricas de Cloudflare Analytics del dominio mmoreno.dev. Estado: desplegado.
Tipo: proyecto personal · Stack: React + Vite + Cloudflare Workers + Cloudflare Pages
Estado
🟢 Activo — ya tengo dominio propio en Cloudflare (mmoreno.dev), así que la dependencia que lo tenía pausado está resuelta. Dashboard en producción, métricas reales.
Idea original
Dashboard en React que consume la API de Cloudflare para mostrar:
- Tráfico por día/hora
- Amenazas bloqueadas
- Hit rate de caché
- Países de origen de visitantes
- Distribución por tipo de recurso
Arquitectura
┌──────────────────┐ /api/metrics ┌────────────────────────┐ GraphQL/REST
│ React App │ ──────────────────▶ │ Cloudflare Worker │ ────────────────▶ Cloudflare API
│ Dashboard │ ◀───────────────── │ (proxy seguro) │ ◀────────────────
└──────────────────┘ JSON + caché └────────────────────────┘
▲
│
Cloudflare Pages
(hosting estático)El Worker se encarga de:
- Recibir la petición del frontend
- Añadir el
Authorization: Bearer <API_TOKEN> - Llamar a la API de Cloudflare Analytics
- Cachear la respuesta (KV) para no superar rate limits
- Devolver JSON saneado al frontend
Stack
- React 19 + Vite — Frontend
- Cloudflare Workers — Proxy seguro para la API key
- Cloudflare Pages — Hosting del frontend estático
- Cloudflare KV — Caché de respuestas de métricas
- Wrangler — CLI de deploy
Worker — código completo
// src/worker.js
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const url = new URL(request.url);
// CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': 'https://mmoreno.dev',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
if (url.pathname === '/api/metrics') {
const zoneId = ZONE_ID; // bound via wrangler.toml
const range = url.searchParams.get('range') || '24h';
// Cache key
const cacheKey = `metrics:${zoneId}:${range}`;
const cached = await METRICS_KV.get(cacheKey, 'json');
if (cached) {
return jsonResponse(cached, { 'X-Cache': 'HIT' });
}
// Calcular fechas según rango
const since = computeSince(range);
const query = `
query {
viewer {
zones(filter: { zoneTag: "${zoneId}" }) {
totals: httpRequests1hGroups(
limit: 24,
filter: { datetime_gt: "${since}" }
) {
dimensions { datetime }
sum { requests, threats, cached, bytes }
}
countries: httpRequests1hGroups(
limit: 200,
filter: { datetime_gt: "${since}" }
) {
dimensions { countryName }
sum { requests, threats }
}
}
}
}
`;
const response = await fetch('https://api.cloudflare.com/client/v4/graphql', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
const data = await response.json();
// Cache por 5 minutos
await METRICS_KV.put(cacheKey, JSON.stringify(data), {
expirationTtl: 300,
});
return jsonResponse(data, { 'X-Cache': 'MISS' });
}
return new Response('Not found', { status: 404 });
}
function jsonResponse(data, extraHeaders = {}) {
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': 'https://mmoreno.dev',
...extraHeaders,
},
});
}
function computeSince(range) {
const now = new Date();
const hours = range === '24h' ? 24 : range === '7d' ? 168 : 720;
now.setHours(now.getHours() - hours);
return now.toISOString();
}wrangler.toml
name = "cf-dashboard-api"
main = "src/worker.js"
compatibility_date = "2024-01-01"
[vars]
ZONE_ID = "tu-zone-id-de-mmorenodev"
# Secrets (NO subir al repo, set con `wrangler secret put API_TOKEN`)
# API_TOKEN = "..."
[[kv_namespaces]]
binding = "METRICS_KV"
id = "tu-kv-namespace-id"Despliegue
Frontend (Cloudflare Pages)
cd frontend
npm run build
npx wrangler pages deploy dist --project-name=cf-dashboardConfigurar dominio custom cf.mmoreno.dev (o subdominio preferido) en Pages.
Worker
# Primera vez
npx wrangler kv:namespace create METRICS_KV
# Anota el id y pégalo en wrangler.toml
# Secret (no se sube al repo)
npx wrangler secret put API_TOKEN
# Deploy
npx wrangler deployURL del Worker: https://cf-dashboard-api.tu-cuenta.workers.dev/api/metrics
Decisiones técnicas
La API key nunca sale del Worker
Igual que con Groq API en Claude Chat: la API key de Cloudflare solo vive en el Worker como secret (cifrada en Cloudflare). El frontend llama al Worker, el Worker llama a Cloudflare.
Si pones la key en el frontend, cualquiera puede usarla y agotar tu cuota.
Cache con KV para no superar rate limits
Cloudflare Analytics API tiene límites. Cachear 5 minutos por (zone, range) evita reventar el rate limit si recargas la página varias veces. El header X-Cache: HIT/MISS permite verificar que funciona.
GraphQL sobre REST
Para series temporales con varios buckets, GraphQL con httpRequests1hGroups devuelve en una sola query lo que REST te obligaría a pedir en N peticiones. Trade-off: query más compleja de mantener, pero latencia mucho menor.
Rango configurable
El parámetro ?range=24h|7d|30d se traduce a hours y a la fecha de inicio de la query. Mantiene el código simple y el Worker stateless.
Lecciones aprendidas
El proxy es la pieza clave
Sin Worker, tendrías que montar un servidor Node/Express solo para ocultar la API key. Cloudflare Workers resuelve eso en 80 líneas de JS y un wrangler deploy.
KV no es un cache "gratis" infinito
KV cobra por lectura y escritura. 5 minutos de TTL es el sweet spot entre frescura y coste. Para métricas que se ven en tiempo real, considera Durable Objects o WebSockets (más complejo).
Cloudflare Pages + Workers es un stack completo
Pages para estático, Workers para lógica, KV para caché, R2 si necesitas almacenamiento, D1 si necesitas SQL. Todo bajo el mismo login y el mismo dashboard. Es difícil no recomendarlo para proyectos pequeños/medios.
Próximos pasos
- [ ] Añadir autenticación (Cloudflare Access) para que el dashboard no sea público
- [ ] WebSockets para refresco en tiempo real
- [ ] Más métricas: cache hit ratio por path, top user agents
- [ ] Alertas vía Discord webhook cuando
threatssupere umbral