Skip to content

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:

  1. Recibir la petición del frontend
  2. Añadir el Authorization: Bearer <API_TOKEN>
  3. Llamar a la API de Cloudflare Analytics
  4. Cachear la respuesta (KV) para no superar rate limits
  5. 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 ​

javascript
// 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 ​

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) ​

bash
cd frontend
npm run build
npx wrangler pages deploy dist --project-name=cf-dashboard

Configurar dominio custom cf.mmoreno.dev (o subdominio preferido) en Pages.

Worker ​

bash
# 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 deploy

URL 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 threats supere umbral