From 4f34ead6462a99b7e176117b68ac6533f9cf39b1 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 16:15:24 -0300 Subject: [PATCH 01/24] feat(docs): redesign documentation layout and add SRE/DLS demo routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a 3-column documentation architecture inspired by SlothUI and React.dev: - Added DocsLayout with sticky DocsHeader, responsive DocsSidebar, and dynamic TOC (DocsToc) with scrollspy. - Created DocsSearchDialog supporting ⌘K / Ctrl+K instant modal navigation. - Added CodeBlock with copy-to-clipboard, language badges, and SignatureBlock. - Authored comprehensive guides for Overview & Hybrid Architecture, @caerus-dev/sdk, SRE concepts, DLS locks, and Error catalog. - Added dedicated showcase pages for SRE (Caerus Cine) and DLS (Lock & Deadlock Simulator) with links to their respective repositories. - Fully verified with zero TypeScript errors and successful Next.js static build. --- .gitignore | 2 + app/docs/dls/demo/page.tsx | 144 +++++++++++++ app/docs/dls/page.tsx | 170 +++++++++++++++ app/docs/errors/page.tsx | 142 +++++++++++++ app/docs/layout.tsx | 15 ++ app/docs/page.tsx | 277 +++++++++++++++++++++++-- app/docs/sdk/page.tsx | 152 ++++++++++++++ app/docs/sre/demo/page.tsx | 161 ++++++++++++++ app/docs/sre/page.tsx | 198 ++++++++++++++++++ components/docs/code-block.tsx | 107 ++++++++++ components/docs/docs-config.ts | 104 ++++++++++ components/docs/docs-header.tsx | 108 ++++++++++ components/docs/docs-page-layout.tsx | 104 ++++++++++ components/docs/docs-search-dialog.tsx | 92 ++++++++ components/docs/docs-shell.tsx | 32 +++ components/docs/docs-sidebar.tsx | 112 ++++++++++ components/docs/docs-toc.tsx | 118 +++++++++++ 17 files changed, 2024 insertions(+), 14 deletions(-) create mode 100644 app/docs/dls/demo/page.tsx create mode 100644 app/docs/dls/page.tsx create mode 100644 app/docs/errors/page.tsx create mode 100644 app/docs/layout.tsx create mode 100644 app/docs/sdk/page.tsx create mode 100644 app/docs/sre/demo/page.tsx create mode 100644 app/docs/sre/page.tsx create mode 100644 components/docs/code-block.tsx create mode 100644 components/docs/docs-config.ts create mode 100644 components/docs/docs-header.tsx create mode 100644 components/docs/docs-page-layout.tsx create mode 100644 components/docs/docs-search-dialog.tsx create mode 100644 components/docs/docs-shell.tsx create mode 100644 components/docs/docs-sidebar.tsx create mode 100644 components/docs/docs-toc.tsx diff --git a/.gitignore b/.gitignore index 1389bab..e36eb0b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ node_modules .vercel .claude/ .env* +package-lock.json +pnpm-workspace.yaml diff --git a/app/docs/dls/demo/page.tsx b/app/docs/dls/demo/page.tsx new file mode 100644 index 0000000..20bdd26 --- /dev/null +++ b/app/docs/dls/demo/page.tsx @@ -0,0 +1,144 @@ +import Link from "next/link" +import { ExternalLink, Github, Sparkles, Cpu, Play, CheckCircle2, ShieldAlert, GitBranch } from "lucide-react" +import { DocsPageLayout } from "@/components/docs/docs-page-layout" +import { CodeBlock } from "@/components/docs/code-block" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { docsConfig } from "@/components/docs/docs-config" + +const tocItems = [ + { id: "concepto-demo", title: "¿En qué consiste el Simulador DLS?" }, + { id: "escenarios", title: "Los 4 Escenarios Interactivos" }, + { id: "grafo-deadlock", title: "Visualización del Grafo y la Víctima" }, + { id: "correr-local", title: "Correr el Simulador Localmente" }, +] + +export default function DocsDlsDemoPage() { + return ( + + {/* Concepto Demo */} +
+

+ ¿En qué consiste el Simulador DLS? +

+

+ Comprender cómo interactúan múltiples microservicios concurrentes al solicitar locks puede ser abstracto. El Simulador DLS provee una representación visual con workers independientes (2 o 3 nodos) que compiten por recursos compartidos (como file:reports_export o network:cloud_uploader). +

+

+ Cada evento del simulador refleja las respuestas del motor de Caerus mediante Server-Sent Events (SSE) y grafica el estado de cada worker: IDLE, STARTING_TX, HOLDING, QUEUED o DEADLOCK_ABORTED. +

+
+ + {/* Los 4 Escenarios */} +
+

+ Los 4 Escenarios de Prueba +

+ +
+ + + Lectura Concurrente + 1. Lectura Compartida (shared_read) + + + Muestra cómo múltiples workers adquieren locks SHARED_READ sobre el mismo archivo simultáneamente sin bloquearse entre sí, hasta que un escritor exclusivo solicita el recurso. + + + + + + Exclusión Mutua + 2. Tarea Simple (tarea_simple) + + + Ejecuta una transacción exclusiva con generación de Fencing Token. El worker toma el lock, escribe en el archivo y libera la transacción de forma segura. + + + + + + Ciclo Cruzado + 3. Detección de Deadlock (deadlock) + + + Worker A retiene el Recurso 1 y solicita el 2; Worker B retiene el 2 y solicita el 1. El motor detecta el ciclo en el grafo y aborta a la víctima designada para desatorar el sistema. + + + + + + Alta Concurrencia + 4. Estampida (estampida) + + + Lanza una ráfaga masiva de solicitudes simultáneas contra un solo recurso para verificar la estabilidad de la cola distribuida y la ausencia de condiciones de carrera. + + +
+
+ + {/* Grafo de Deadlock */} +
+

+ Visualización del Grafo y Selección de la Víctima +

+

+ En el escenario de Deadlock, el simulador destaca con color ámbar y rojo las aristas que forman el ciclo cerrado (Worker A ➔ Recurso 2 ➔ Worker B ➔ Recurso 1 ➔ Worker A). +

+

+ El motor de Caerus selecciona a la víctima con menor prioridad o menor progreso transaccional, envía una señal de aborto para liberar sus recursos retenidos y permite que los workers restantes completen su trabajo de forma transparente. +

+
+ + {/* Correr Local */} +
+

+ Correr el Simulador Localmente +

+

+ Podés clonar el repositorio de la demo del DLS e iniciar el simulador interactivo en tu entorno local: +

+ + + +
+ + + + + + + +
+
+
+ ) +} diff --git a/app/docs/dls/page.tsx b/app/docs/dls/page.tsx new file mode 100644 index 0000000..09e0e3f --- /dev/null +++ b/app/docs/dls/page.tsx @@ -0,0 +1,170 @@ +import Link from "next/link" +import { ArrowRight, Lock, Key, ShieldCheck, AlertCircle, RefreshCw, Cpu } from "lucide-react" +import { DocsPageLayout } from "@/components/docs/docs-page-layout" +import { CodeBlock, SignatureBlock } from "@/components/docs/code-block" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" + +const tocItems = [ + { id: "conceptos", title: "¿Qué es DLS y para qué sirve?" }, + { id: "modos", title: "Modos: Exclusive vs Shared Read" }, + { id: "fencing", title: "Fencing Tokens contra Split-Brain" }, + { id: "deadlocks", title: "Detección y Resolución de Deadlocks" }, + { id: "codigo", title: "Ejemplo de Uso en TypeScript" }, + { id: "demo-link", title: "Simulador Interactivo DLS" }, +] + +export default function DocsDlsPage() { + return ( + + + + {/* Conceptos */} +
+

+ ¿Qué es DLS y para qué sirve? +

+

+ Mientras que el SRE está enfocado en inventario de negocio (butacas, cupos), el Distributed Locking Service (DLS) está diseñado para la sincronización entre procesos. Por ejemplo, garantizar que un worker programado que genera reportes financieros o cobra suscripciones mensuales corra en una sola instancia a la vez. +

+
+ + {/* Modos: Exclusive vs Shared Read */} +
+

+ Modos de Bloqueo: Exclusive vs Shared Read +

+

+ DLS soporta el patrón de concurrencia Read/Write Locks: +

+ +
+ + + + + EXCLUSIVE (Escritura) + + + + Garantiza exclusión total. Solo un worker puede poseer el lock a la vez. Cualquier otro intento de adquisición exclusiva o compartida queda bloqueado o rechazado. + + + + + + + + SHARED_READ (Lectura) + + + + Múltiples workers pueden poseer el lock en simultáneo para tareas de solo lectura, impidiendo que entre un escritor exclusivo hasta que todos los lectores terminen. + + +
+
+ + {/* Fencing Tokens */} +
+

+ Fencing Tokens contra Split-Brain y Pausas de GC +

+

+ En sistemas distribuidos, un distributed lock básico no es suficiente. Si un proceso sufre una pausa prolongada de Garbage Collection (GC) o latencia de red, su lock puede vencer en el servidor mientras el proceso cree que todavía lo tiene. Al despertar, podría escribir datos corruptos pisando al nuevo dueño legítimo del lock. +

+ +
+
+ + Garantía de Secuencia Monótona (ZooKeeper Consensus) +
+

+ Caerus genera un Fencing Token: un número entero estrictamente incremental (otorgado mediante consenso con ZooKeeper) junto a cada adquisición exitosa. Al escribir en tu base de datos o almacenamiento, verificás que el token sea mayor al último aceptado; si el proceso se quedó dormido, su token viejo será rechazado inmediatamente. +

+
+
+ + {/* Detección de Deadlocks */} +
+

+ Detección y Resolución Automática de Deadlocks +

+

+ Cuando múltiples microservicios adquieren recursos en distinto orden (e.g. Worker A tiene el Recurso 1 y espera el Recurso 2, mientras Worker B tiene el Recurso 2 y espera el Recurso 1), se produce un bloqueo mutuo o Deadlock. +

+

+ El motor de Caerus mantiene un grafo dirigido de dependencias (Wait-For Graph). En cuanto detecta un ciclo cerrado, aplica automáticamente la resolución configurada: aborta a la víctima (el proceso con menor prioridad o menor antigüedad en la transacción) y emite un evento/webhook para que los demás puedan continuar. +

+
+ + {/* Ejemplo de Código */} +
+

+ Ejemplo de Uso en TypeScript +

+ + +
+ + {/* Enlace al Simulador DLS */} + +
+ ) +} diff --git a/app/docs/errors/page.tsx b/app/docs/errors/page.tsx new file mode 100644 index 0000000..838cb1f --- /dev/null +++ b/app/docs/errors/page.tsx @@ -0,0 +1,142 @@ +import Link from "next/link" +import { AlertCircle, ShieldAlert, CheckCircle2, Terminal, HelpCircle } from "lucide-react" +import { DocsPageLayout } from "@/components/docs/docs-page-layout" +import { CodeBlock } from "@/components/docs/code-block" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" + +const tocItems = [ + { id: "catalogo", title: "Catálogo de Excepciones del SDK" }, + { id: "conflict-error", title: "ConflictError (409)" }, + { id: "idempotency-error", title: "IdempotencyError" }, + { id: "not-found-error", title: "NotFoundError (404)" }, + { id: "troubleshooting", title: "Troubleshooting de Conexión" }, +] + +export default function DocsErrorsPage() { + return ( + + {/* Catálogo */} + + + {/* ConflictError */} +
+

+ Manejo de ConflictError +

+

+ Ocurre cuando intentas un take() sobre un recurso unitario ya tomado o cuando un recurso con cupo no tiene saldo suficiente: +

+ + +
+ + {/* Troubleshooting */} +
+

+ Troubleshooting Frecuente +

+ +
+
+

+ 1. "OpenSSL: wrong version number" al conectar localmente +

+

+ Causa: El SDK encripta por defecto mediante TLS. Si tu motor local corre en localhost:9090 sin certificado SSL, debes especificar tls: false en las opciones del cliente. +

+
+ +
+

+ 2. "Invalid format in endpoint" +

+

+ Causa: Se colocó https:// o una ruta (ej: localhost:9090/sre). Caerus utiliza gRPC; el endpoint solo debe tener la forma host:puerto (ej: localhost:9090). +

+
+ +
+

+ 3. ¿Por qué mi holder cambió de PENDING a EXPIRED? +

+

+ Causa: El tiempo de vida otorgado en ttlSeconds transcurrió sin que tu backend llamara a caerus.confirm(holder.id). El sweeper de Caerus restauró el stock de forma automática. +

+
+
+
+
+ ) +} diff --git a/app/docs/layout.tsx b/app/docs/layout.tsx new file mode 100644 index 0000000..c49bd67 --- /dev/null +++ b/app/docs/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next" +import { DocsShell } from "@/components/docs/docs-shell" + +export const metadata: Metadata = { + title: "Documentación | Caerus BaaS", + description: "Guía completa de arquitectura, SDKs y motores de concurrencia distribuida (SRE y DLS) en Caerus.", +} + +export default function DocsLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/app/docs/page.tsx b/app/docs/page.tsx index 693b3c8..db3d98a 100644 --- a/app/docs/page.tsx +++ b/app/docs/page.tsx @@ -1,20 +1,269 @@ -import { auth0 } from "@/lib/auth0" -import { Navbar } from "@/components/landing/navbar" -import { Footer } from "@/components/landing/footer" -import { DocsContent } from "@/components/docs/docs-content" +import Link from "next/link" +import { ArrowRight, Layers, Lock, Zap, Server, ShieldCheck, Database } from "lucide-react" +import { DocsPageLayout } from "@/components/docs/docs-page-layout" +import { CodeBlock, SignatureBlock } from "@/components/docs/code-block" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" -export default async function DocsPage() { - const session = await auth0.getSession() +const tocItems = [ + { id: "problema", title: "¿Qué problema resuelve Caerus?" }, + { id: "arquitectura", title: "Arquitectura de Estado Híbrido" }, + { id: "aviones", title: "Control Plane vs Data Plane" }, + { id: "motores", title: "Los Motores: SRE y DLS" }, + { id: "quickstart", title: "Inicio Rápido (SDK)" }, +] +export default function DocsOverviewPage() { return ( -
- -
-
- + + {/* Sección: El Problema */} +
+

+ ¿Qué problema resuelve Caerus? +

+

+ Vender o apartar algo de lo que solo hay una unidad (una butaca de cine, un turno médico, un cupo de inscripción o inventario limitado) es mucho más difícil de lo que aparenta. Dos usuarios presionan Comprar en la misma fracción de segundo; una pasarela de pago tarda 8 segundos en contestar; y un tercer usuario abandona el carrito con el recurso bloqueado. +

+

+ Resolver esto de forma artesanal exige clústeres de ZooKeeper, scripts Lua en Redis, transacciones con bloqueos pesados en SQL y tareas en segundo plano para limpiar reservas abandonadas. Caerus abstrae toda esta infraestructura distribuida detrás de APIs declarativas de baja latencia y un SDK unificado. +

+ +
+ + + + Menor Time-to-Market + + + Elimina meses de desarrollo e ingeniería de infraestructura distribuida en cada microservicio. + + + + + + + Cero Overbooking + + + Garantiza exclusión mutua estricta y evita sobreventas o carreras de actualización en la base de datos. + + + + + + + Latencia < 20 ms + + + El hot path opera en memoria con operaciones atómicas gRPC sobre Redis, sin contención en disco. + + +
+
+ + {/* Sección: Arquitectura de Estado Híbrido */} +
+

+ Arquitectura de Estado Híbrido +

+

+ Caerus implementa un modelo de estado híbrido que desacopla la velocidad de procesamiento de la persistencia durable en disco: +

+ +
+
+
+ + 1. Hot Path (En Memoria - Redis Stack) +
+

+ Las operaciones transaccionales de alta frecuencia (adquisición de locks, reservas temporales take, conteo atómico y expiraciones por TTL) se procesan directamente en Redis mediante scripts Lua atómicos. +

+
+ +
+
+ + 2. Cold Path (Persistencia - PostgreSQL con Write-Behind) +
+

+ El estado se sincroniza periódicamente a PostgreSQL mediante trabajadores en segundo plano y el patrón Transactional Outbox con SKIP LOCKED, protegiendo a la base de datos de picos de contención. +

+
+
+
+ + {/* Sección: Control Plane vs Data Plane */} +
+

+ Control Plane vs Data Plane +

+

+ El sistema está dividido estrictamente en dos planos para garantizar máxima seguridad y aislamiento: +

+ +
+
+
+

Control Plane (Gestión)

+ REST / Auth0 +
+

+ Es el dashboard web donde los administradores y desarrolladores gestionan Organizaciones, Ambientes (Dev, Staging, Prod), Plantillas de Recursos y API Keys de autenticación. +

+
+ +
+
+

Data Plane (Motor de Ejecución)

+ gRPC / 9090 +
+

+ Es el motor de alta concurrencia con el que dialoga el SDK de tu backend. Se comunica por gRPC binario, validando API Keys en cada solicitud con latencias menores a 20 ms. +

+
+
+
+ + {/* Sección: Motores SRE y DLS */} +
+

+ Los Motores: SRE y DLS +

+

+ Caerus provee dos motores especializados según el nivel de abstracción requerido: +

+ +
+ + +
+ + Shared Resource Engine (SRE) +
+ + Motor orientado a la lógica de negocio y reservas temporales de inventario. + +
+ +
    +
  • Recursos Unitarios (asientos numerados) vs Con cupo (entradas generales).
  • +
  • Ciclo: take() con TTL ➔ confirm() o release().
  • +
  • Estrategias de conflicto: FAIL vs QUEUE (fila de espera).
  • +
+
+ + + + + + +
+
+
+ + + +
+ + Distributed Locking Service (DLS) +
+ + Motor de sincronización a bajo nivel y exclusión mutua para microservicios. + +
+ +
    +
  • Locks Exclusive y Shared Read en transacciones.
  • +
  • Fencing Tokens monótonos de ZooKeeper para evitar split-brain.
  • +
  • Detección automática de ciclos de Deadlock y corte de víctima.
  • +
+
+ + + + + + +
+
+
+
+
+ + {/* Sección: Inicio Rápido */} +
+

+ Inicio Rápido (SDK) +

+

+ Instalá el SDK en tu aplicación Node.js o TypeScript y empezá a reservar recursos en minutos: +

+ + + +

+ Ejemplo de retención de recurso unitario con pago y confirmación: +

+ + + +
+ + + + + +
-
-
-
+ + ) } diff --git a/app/docs/sdk/page.tsx b/app/docs/sdk/page.tsx new file mode 100644 index 0000000..071a2e2 --- /dev/null +++ b/app/docs/sdk/page.tsx @@ -0,0 +1,152 @@ +import Link from "next/link" +import { ArrowRight, Terminal, Shield, CheckCircle2, AlertTriangle } from "lucide-react" +import { DocsPageLayout } from "@/components/docs/docs-page-layout" +import { CodeBlock, SignatureBlock } from "@/components/docs/code-block" +import { Button } from "@/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +const tocItems = [ + { id: "instalacion", title: "Instalación" }, + { id: "inicializacion", title: "Inicialización del Cliente" }, + { id: "conexion-local", title: "Conexión a un Caerus Local" }, + { id: "opciones", title: "Tabla de Opciones" }, + { id: "siguiente-paso", title: "Siguientes Pasos" }, +] + +export default function DocsSdkPage() { + return ( + + + + {/* Instalación */} +
+

+ Instalación +

+

+ El paquete requiere Node.js 20 o superior. Incluye definiciones TypeScript completas y soporte nativo para ES Modules y CommonJS: +

+ + + + +
+ Nota de seguridad: + El SDK corre exclusivamente en el servidor de tu aplicación. La API Key nunca debe exponerse en el navegador o en código frontend. +
+
+ + {/* Inicialización */} +
+

+ Inicialización del Cliente +

+

+ La API Key identifica a tu organización y ambiente (e.g. Producción, Staging), por lo que el SDK sabe automáticamente a qué tenant pertenece cada operación: +

+ + +
+ + {/* Conexión Local */} +
+

+ Conexión a un Caerus Local (Docker) +

+

+ Para pruebas unitarias, integración o desarrollo offline con el Data Plane levantado en tu máquina (puerto 9090): +

+ + + +
+
+ + Formato de endpoint y TLS +
+

+ El endpoint debe ser únicamente host:puerto (sin prefijo http:// ni https://). Además, recordá que tls: false es mandatorio en local; de lo contrario OpenSSL arrojará un error de versión de protocolo. +

+
+
+ + {/* Tabla de opciones */} +
+

+ Tabla de Opciones +

+ +
+ + + + Opción + Por Defecto + Descripción + + + + + apiKey + + Requerido. Clave de API obtenida del dashboard de Caerus. + + + endpoint + Cloud Caerus + Dirección host:port del motor gRPC del Data Plane. + + + tls + true + Habilita encriptación TLS. Desactivar solo con false explícito en local. + + + timeoutMs + 10000 + Tiempo máximo de espera (deadline gRPC) en milisegundos. + + +
+
+
+ + {/* Siguiente paso */} +
+ + + + + + +
+
+ ) +} diff --git a/app/docs/sre/demo/page.tsx b/app/docs/sre/demo/page.tsx new file mode 100644 index 0000000..f8049cd --- /dev/null +++ b/app/docs/sre/demo/page.tsx @@ -0,0 +1,161 @@ +import Link from "next/link" +import { ExternalLink, Github, Sparkles, Film, Terminal, Play, CheckCircle2, ShieldAlert } from "lucide-react" +import { DocsPageLayout } from "@/components/docs/docs-page-layout" +import { CodeBlock } from "@/components/docs/code-block" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { docsConfig } from "@/components/docs/docs-config" + +const tocItems = [ + { id: "concepto-demo", title: "Idea Central de la Demo" }, + { id: "comparativa", title: "FAIL vs QUEUE en Cartelera" }, + { id: "panel-llamadas", title: "El Panel de Llamadas en Vivo" }, + { id: "probar-demo", title: "Probar la Demo Desplegada" }, + { id: "correr-local", title: "Correr la Demo en tu Máquina" }, +] + +export default function DocsSreDemoPage() { + return ( + + {/* Concepto Demo */} +
+

+ Idea Central de la Demo +

+

+ En un cine, cuando dos personas quieren la misma butaca exactamente a la vez, deben chocar de verdad. Las butacas se toman durante unos minutos; si nadie las paga, se liberan solas; y si el usuario cancela, vuelven inmediatamente a estar disponibles. +

+

+ La demo está conectada al motor de Caerus a través del paquete oficial @caerus-dev/sdk y ejercita los trece verbos de su API pública (recursos unitarios, con cupo de candy bar, confirmaciones y cancelaciones). +

+
+ + {/* Comparativa */} +
+

+ Dos Películas, Dos Políticas de Conflicto +

+

+ Las dos funciones de la cartelera tienen configuraciones de plantilla radicalmente distintas en el dashboard: +

+ +
+ + + Plantilla butaca (FAIL) + 🎬 El Último Horizonte + + +

• Política de conflicto: FAIL

+

• Si dos usuarios piden la misma butaca, el segundo recibe un ConflictError y la pierde de inmediato.

+

• Cuando el primero libera la butaca, no pasa nada automático; el asiento queda libre para quien llegue primero.

+
+
+ + + + Plantilla butaca_fila (QUEUE) + 🎬 Lluvia de Neón + + +

• Política de conflicto: QUEUE (Fila de espera)

+

• Si el segundo pide la butaca ocupada, queda automáticamente en la fila.

+

• Cuando el primer usuario libera la butaca o vence su tiempo, el motor se la asigna solo al segundo en cuestión de segundos.

+
+
+
+ +
+ Mismo código en la aplicación: + La diferencia en el comportamiento entre ambas películas radica 100% en la configuración de la plantilla en el dashboard de Caerus, sin cambiar una sola línea de código en la aplicación cliente. +
+
+ + {/* Panel de llamadas */} +
+

+ El Panel de Llamadas en Tiempo Real +

+

+ Debajo del mapa de butacas, la aplicación incluye una consola interactiva que intercepta mediante un Proxy cada llamada real que el servidor realiza al SDK: +

+ + +
+ + {/* Probar la Demo */} +
+

+ Probar la Demo Desplegada +

+

+ La demo está desplegada y disponible públicamente. Te recomendamos abrirla en dos pestañas distintas (cada pestaña simula un usuario con sesión independiente) para competir por la misma butaca: +

+ +
+ + + + + + + +
+
+ + {/* Correr Local */} +
+

+ Correr la Demo en tu Máquina +

+

+ Si preferís clonar el código y correrla localmente: +

+ + + +

+ Nota: Si no configuras CAERUS_API_KEY, la demo arranca automáticamente contra un motor en memoria (in-memory mock) para que puedas probarla sin necesidad de desplegar el backend. +

+
+
+ ) +} diff --git a/app/docs/sre/page.tsx b/app/docs/sre/page.tsx new file mode 100644 index 0000000..9befacd --- /dev/null +++ b/app/docs/sre/page.tsx @@ -0,0 +1,198 @@ +import Link from "next/link" +import { ArrowRight, Sparkles, Layers, Clock, ShieldAlert, CheckCircle2, RotateCcw } from "lucide-react" +import { DocsPageLayout } from "@/components/docs/docs-page-layout" +import { CodeBlock, SignatureBlock } from "@/components/docs/code-block" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +const tocItems = [ + { id: "conceptos", title: "Recursos Unitarios vs Pooled" }, + { id: "ciclo-vida", title: "Ciclo de Vida de una Reserva" }, + { id: "verbos", title: "Verbos: take, confirm y release" }, + { id: "estrategias", title: "Políticas de Conflicto (FAIL vs QUEUE)" }, + { id: "demo-link", title: "Probar Demo Interactiva" }, +] + +export default function DocsSrePage() { + return ( + + + + {/* Conceptos: Unitary vs Pooled */} +
+

+ Recursos Unitarios vs Pooled (Con Cupo) +

+

+ En Caerus, la distinción entre un recurso del que hay exactamente uno y un recurso del que hay múltiples unidades intercambiables es una diferencia en el sistema de tipos de TypeScript: +

+ +
+
+

+ + Recurso Unitario (unitary) +

+

+ Existe exactamente una unidad en todo momento. Ejemplo: la butaca neon_D9 o el turno con un doctor a las 15:30. +

+ +
+ +
+

+ + Recurso Pooled (pooled) +

+

+ Mantiene un saldo o inventario de unidades intercambiables. Ejemplo: 50 combos de pochoclos o 30 vacantes de un curso. +

+ +
+
+
+ + {/* Ciclo de vida */} +
+

+ Ciclo de Vida de una Reserva +

+

+ Toda reserva devuelve un Holder que representa la retención temporal en estado PENDING: +

+ +
+
{`                      ┌──────────────┐
+       take()  ──────▶│   PENDING    │  (Unidades apartadas por TTL)
+                      └──────┬───────┘
+                             │
+            confirm() ───────┼────────▶  CONFIRMED   (Venta concretada)
+                             │
+            release() ───────┼────────▶  RELEASED    (Liberado anticipadamente)
+                             │
+           TTL Expira ───────┴────────▶  EXPIRED     (Worker lo restaura solo)`}
+
+
+ + {/* Verbos principales */} +
+

+ Verbos Principales del SDK +

+ +
+
+

1. take() / takeMany()

+

+ Aparta las unidades solicitadas. Si la plantilla exige clave de idempotencia, debes incluir idempotencyKey para garantizar que peticiones duplicadas devuelvan el mismo holder sin restar inventario doble. +

+ +
+ +
+

2. confirm()

+

+ Fija la reserva de manera permanente una vez recibido el pago o confirmación del usuario. +

+ +
+ +
+

3. release()

+

+ Cancela la retención de inmediato y devuelve el recurso a la venta sin esperar al vencimiento del TTL. +

+ +
+
+
+ + {/* Políticas de Conflicto */} +
+

+ Políticas de Conflicto: FAIL vs QUEUE +

+

+ La política de conflicto se configura en la plantilla del recurso (desde el dashboard) y el motor la ejecuta automáticamente: +

+ +
+ + + + Estrategia FAIL + Rechazo Directo + + + + Si dos usuarios piden la misma butaca al mismo tiempo, el segundo recibe de inmediato un ConflictError. No hay espera. + + + + + + + Estrategia QUEUE + Fila Automática + + + + El segundo solicitante queda encolado. Si el primer usuario libera la butaca o su tiempo expira, el motor se la asigna solo al siguiente en cuestión de segundos. + + +
+
+ + {/* Enlace a la Demo */} + +
+ ) +} diff --git a/components/docs/code-block.tsx b/components/docs/code-block.tsx new file mode 100644 index 0000000..8d4c412 --- /dev/null +++ b/components/docs/code-block.tsx @@ -0,0 +1,107 @@ +"use client" + +import { useState } from "react" +import { Check, Copy } from "lucide-react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +interface CodeBlockProps { + code: string + language?: string + title?: string + showLineNumbers?: boolean + className?: string +} + +export function CodeBlock({ + code, + language = "typescript", + title, + showLineNumbers = false, + className, +}: CodeBlockProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const lines = code.trim().split("\n") + + return ( +
+ {title && ( +
+ {title} + {language} +
+ )} + +
+ + +
+          
+            {showLineNumbers ? (
+              lines.map((line, idx) => (
+                
+ + {idx + 1} + + {line} +
+ )) + ) : ( + code.trim() + )} +
+
+
+
+ ) +} + +interface SignatureBlockProps { + signature: string + badge?: string +} + +export function SignatureBlock({ signature, badge = "Signature" }: SignatureBlockProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(signature) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
+ + {badge} + + {signature} +
+ +
+ ) +} diff --git a/components/docs/docs-config.ts b/components/docs/docs-config.ts new file mode 100644 index 0000000..2181281 --- /dev/null +++ b/components/docs/docs-config.ts @@ -0,0 +1,104 @@ +export interface DocItem { + title: string + href: string + badge?: "Core" | "New" | "Demo" | "SRE" | "DLS" | "Popular" + description?: string + keywords?: string[] +} + +export interface DocSection { + title: string + items: DocItem[] +} + +export interface DocsConfig { + version: string + repoUrl: string + sdkRepoUrl: string + demoSreRepoUrl: string + demoDlsRepoUrl: string + sections: DocSection[] +} + +export const docsConfig: DocsConfig = { + version: "v2.1.0", + repoUrl: "https://github.com/caerus-dev/caerus", + sdkRepoUrl: "https://github.com/caerus-dev/caerus-sdk-ts", + demoSreRepoUrl: "https://github.com/caerus-dev/demo-sdk", + demoDlsRepoUrl: "https://github.com/caerus-dev/demo-dls", + sections: [ + { + title: "Comenzando", + items: [ + { + title: "Visión General & Arquitectura", + href: "/docs", + badge: "Core", + description: "Qué es Caerus, arquitectura híbrida Redis/Postgres y resolución de concurrencia.", + keywords: ["introduccion", "arquitectura", "overview", "redis", "postgres", "hybrid state", "hot path"], + }, + ], + }, + { + title: "SDK TypeScript / Node.js", + items: [ + { + title: "Instalación y Conexión", + href: "/docs/sdk", + badge: "Core", + description: "Configuración del cliente @caerus-dev/sdk, API keys, TLS y endpoints locales.", + keywords: ["sdk", "instalacion", "caerusclient", "npm", "pnpm", "node", "typescript", "tls"], + }, + ], + }, + { + title: "Shared Resource Engine (SRE)", + items: [ + { + title: "Conceptos y Verbos SRE", + href: "/docs/sre", + badge: "SRE", + description: "Recursos Unitarios vs Pooled, ciclo de vida (take -> confirm/release), TTL y estrategias de conflicto.", + keywords: ["sre", "unitary", "pooled", "take", "confirm", "release", "ttl", "conflict", "queue", "fail"], + }, + { + title: "Demo Interactiva: Caerus Cine", + href: "/docs/sre/demo", + badge: "Demo", + description: "Simulador interactivo de reserva de butacas con políticas FAIL y QUEUE en vivo.", + keywords: ["demo", "cine", "simulador", "butacas", "interactive", "sre demo"], + }, + ], + }, + { + title: "Distributed Locking Service (DLS)", + items: [ + { + title: "Locks y Transacciones DLS", + href: "/docs/dls", + badge: "DLS", + description: "Exclusión mutua, modos Exclusive vs Shared Read, Fencing Tokens de ZooKeeper y Deadlocks.", + keywords: ["dls", "distributed lock", "fencing token", "zookeeper", "deadlock", "exclusive", "shared read", "transacciones"], + }, + { + title: "Demo Interactiva: Simulador DLS", + href: "/docs/dls/demo", + badge: "Demo", + description: "Simulación gráfica de workers, transacciones cruzadas, detección de ciclos y estampidas.", + keywords: ["demo", "dls", "simulador", "workers", "grafo", "ciclo", "estampida"], + }, + ], + }, + { + title: "Referencia y Guías", + items: [ + { + title: "Catálogo de Errores y Troubleshooting", + href: "/docs/errors", + description: "Mapeo de excepciones (ConflictError, IdempotencyError, NotFoundError) y códigos HTTP/gRPC.", + keywords: ["errores", "conflicterror", "idempotencyerror", "notfounderror", "troubleshooting", "fallas"], + }, + ], + }, + ], +} diff --git a/components/docs/docs-header.tsx b/components/docs/docs-header.tsx new file mode 100644 index 0000000..4a781e5 --- /dev/null +++ b/components/docs/docs-header.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { Search, Github, LayoutDashboard, Menu } from "lucide-react" +import { docsConfig } from "./docs-config" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from "@/components/ui/sheet" +import { DocsSidebar } from "./docs-sidebar" + +interface DocsHeaderProps { + onSearchClick: () => void +} + +export function DocsHeader({ onSearchClick }: DocsHeaderProps) { + const [sheetOpen, setSheetOpen] = React.useState(false) + + return ( +
+
+ {/* Lado Izquierdo: Mobile menu trigger + Logo + Version Badge */} +
+ + + + + + + + Caerus + Documentación + + + { + setSheetOpen(false) + onSearchClick() + }} + /> + + + + + Caerus Logo + Caerus + Docs + + + + {docsConfig.version} + +
+ + {/* Centro: Buscador estilo SlothUI */} +
+ +
+ + {/* Lado Derecho: Links a Dashboard y GitHub */} +
+ + + + + + + + + +
+
+
+ ) +} diff --git a/components/docs/docs-page-layout.tsx b/components/docs/docs-page-layout.tsx new file mode 100644 index 0000000..1704b03 --- /dev/null +++ b/components/docs/docs-page-layout.tsx @@ -0,0 +1,104 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { Home } from "lucide-react" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" +import { Badge } from "@/components/ui/badge" +import { DocsToc, TocItem } from "./docs-toc" +import { cn } from "@/lib/utils" + +export interface BreadcrumbCrumb { + label: string + href?: string +} + +interface DocsPageLayoutProps { + breadcrumbs?: BreadcrumbCrumb[] + title: string + badge?: string + description?: string + tocItems?: TocItem[] + children: React.ReactNode + className?: string +} + +export function DocsPageLayout({ + breadcrumbs = [], + title, + badge, + description, + tocItems = [], + children, + className, +}: DocsPageLayoutProps) { + return ( +
+ {/* Columna Central de Contenido */} +
+ {/* Breadcrumb estilo SlothUI */} +
+ + + + + + + Docs + + + + + {breadcrumbs.map((crumb, idx) => ( + + + + {crumb.href ? ( + + + {crumb.label} + + + ) : ( + {crumb.label} + )} + + + ))} + + +
+ + {/* Encabezado de la página */} +
+
+

{title}

+ {badge && ( + + {badge} + + )} +
+ {description && ( +

{description}

+ )} +
+ + {/* Contenido principal */} +
{children}
+
+ + {/* Columna Derecha (TOC & Acciones) */} + +
+ ) +} diff --git a/components/docs/docs-search-dialog.tsx b/components/docs/docs-search-dialog.tsx new file mode 100644 index 0000000..8e8f138 --- /dev/null +++ b/components/docs/docs-search-dialog.tsx @@ -0,0 +1,92 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { Search, FileText, Sparkles, BookOpen } from "lucide-react" +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command" +import { docsConfig } from "./docs-config" +import { Badge } from "@/components/ui/badge" + +interface DocsSearchDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function DocsSearchDialog({ open, onOpenChange }: DocsSearchDialogProps) { + const router = useRouter() + + React.useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + onOpenChange(!open) + } + } + + document.addEventListener("keydown", down) + return () => document.removeEventListener("keydown", down) + }, [open, onOpenChange]) + + const handleSelect = (href: string) => { + onOpenChange(false) + router.push(href) + } + + return ( + + + + No se encontraron resultados para tu búsqueda. + {docsConfig.sections.map((section) => ( + + {section.items.map((item) => ( + handleSelect(item.href)} + className="flex items-center justify-between gap-2 py-2.5 px-3 rounded-lg cursor-pointer" + > +
+ {item.badge === "Demo" ? ( + + ) : item.badge === "Core" ? ( + + ) : ( + + )} +
+ {item.title} + {item.description && ( + {item.description} + )} +
+
+ + {item.badge && ( + + {item.badge} + + )} +
+ ))} +
+ ))} +
+
+ ) +} diff --git a/components/docs/docs-shell.tsx b/components/docs/docs-shell.tsx new file mode 100644 index 0000000..daf885a --- /dev/null +++ b/components/docs/docs-shell.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import { DocsHeader } from "./docs-header" +import { DocsSidebar } from "./docs-sidebar" +import { DocsSearchDialog } from "./docs-search-dialog" + +export function DocsShell({ children }: { children: React.ReactNode }) { + const [searchOpen, setSearchOpen] = React.useState(false) + + return ( +
+ setSearchOpen(true)} /> + +
+
+ {/* Sidebar Izquierda (Desktop) */} + + + {/* Área de Contenido Principal */} +
+ {children} +
+
+
+ + +
+ ) +} diff --git a/components/docs/docs-sidebar.tsx b/components/docs/docs-sidebar.tsx new file mode 100644 index 0000000..0158340 --- /dev/null +++ b/components/docs/docs-sidebar.tsx @@ -0,0 +1,112 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { Search, ChevronDown, Sparkles, Layers, Lock, BookOpen, FileCode, Terminal } from "lucide-react" +import { docsConfig, DocSection } from "./docs-config" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" + +interface DocsSidebarProps { + onSearchClick?: () => void + className?: string +} + +export function DocsSidebar({ onSearchClick, className }: DocsSidebarProps) { + const pathname = usePathname() + const [filterQuery, setFilterQuery] = React.useState("") + + const filteredSections = React.useMemo(() => { + if (!filterQuery.trim()) return docsConfig.sections + + const q = filterQuery.toLowerCase() + return docsConfig.sections + .map((section) => ({ + ...section, + items: section.items.filter( + (item) => + item.title.toLowerCase().includes(q) || + item.description?.toLowerCase().includes(q) || + item.keywords?.some((k) => k.toLowerCase().includes(q)) + ), + })) + .filter((section) => section.items.length > 0) + }, [filterQuery]) + + return ( + + ) +} diff --git a/components/docs/docs-toc.tsx b/components/docs/docs-toc.tsx new file mode 100644 index 0000000..bc02fdc --- /dev/null +++ b/components/docs/docs-toc.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { ExternalLink, Github, Sparkles, BookOpen } from "lucide-react" +import { docsConfig } from "./docs-config" +import { cn } from "@/lib/utils" + +export interface TocItem { + id: string + title: string + level?: 2 | 3 +} + +interface DocsTocProps { + items?: TocItem[] + className?: string +} + +export function DocsToc({ items = [], className }: DocsTocProps) { + const [activeId, setActiveId] = React.useState("") + + React.useEffect(() => { + if (!items.length) return + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveId(entry.target.id) + } + }) + }, + { + rootMargin: "0% 0% -70% 0%", + threshold: 0.1, + } + ) + + items.forEach((item) => { + const el = document.getElementById(item.id) + if (el) observer.observe(el) + }) + + return () => observer.disconnect() + }, [items]) + + return ( +
+ {items.length > 0 && ( +
+

+ En esta página +

+ +
+ )} + + {/* Card de Recursos y Repositorios (estilo SlothUI) */} +
+
+ + Repositorios del Proyecto +
+

+ Accedé al código fuente de los clientes, las demos interactivas y las especificaciones. +

+
+ + SDK TypeScript (@caerus-dev/sdk) + + + + Demo SRE (Caerus Cine) + + + + Demo DLS (Lock Simulator) + + +
+
+
+ ) +} From 6de5ff0b761918d9500ab0dedca4399e0621a632 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 17:10:22 -0300 Subject: [PATCH 02/24] fix(settings): remove inactive buttons for avatar, 2FA and sessions --- app/dashboard/settings/page.tsx | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx index 362b0e1..c73ac2c 100644 --- a/app/dashboard/settings/page.tsx +++ b/app/dashboard/settings/page.tsx @@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { Separator } from "@/components/ui/separator" import { Select, SelectContent, @@ -102,16 +101,6 @@ export default function SettingsPage() { -
-
- {user?.name?.substring(0, 2)?.toUpperCase() || "US"} -
-
- -
-
@@ -172,16 +161,6 @@ export default function SettingsPage() { -
-
-

Autenticación en Dos Pasos (2FA)

-

- Agregá una capa extra de seguridad a tu cuenta -

-
- -
-

Cambiar Contraseña

@@ -191,16 +170,6 @@ export default function SettingsPage() {
- -
-
-

Sesiones Activas

-

- Administrá los dispositivos donde iniciaste sesión -

-
- -
From d57d09141560bebf0baf577d5bff4d3150d7cd82 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 17:13:44 -0300 Subject: [PATCH 03/24] fix(landing): align pricing copy with usage-based billing rules --- components/landing/pricing-preview-section.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/landing/pricing-preview-section.tsx b/components/landing/pricing-preview-section.tsx index 19b32fa..344ee87 100644 --- a/components/landing/pricing-preview-section.tsx +++ b/components/landing/pricing-preview-section.tsx @@ -27,10 +27,10 @@ export function PricingPreviewSection() {

- Caerus funciona bajo un esquema de suscripción pensado para desarrolladores. Contás con un{" "} - plan gratuito con llamadas a la API y - recursos incluidos para prototipar y validar tu producto sin poner tarjeta de crédito. A medida que tu - tráfico y tus necesidades de alta disponibilidad aumentan, podés subir de plan en cualquier momento. + Caerus funciona bajo un esquema de facturación pensado para desarrolladores. Contás con un{" "} + plan gratuito Developer ($0/mes) con + 50.000 requests mensuales incluidas para prototipar y validar tu producto con costo inicial cero. A medida que tu + tráfico y tus necesidades de alta disponibilidad aumentan, podés escalar de plan en cualquier momento.

{/* Highlights */} From 9ec87abeed786eb18b885110136be2a9135bbd87 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 17:17:52 -0300 Subject: [PATCH 04/24] fix(api-keys): use official @caerus-dev/sdk and CaerusClient in quick start snippet --- app/dashboard/api-keys/page.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/dashboard/api-keys/page.tsx b/app/dashboard/api-keys/page.tsx index 8b26973..9472d20 100644 --- a/app/dashboard/api-keys/page.tsx +++ b/app/dashboard/api-keys/page.tsx @@ -338,7 +338,7 @@ export default function ApiKeysPage() { className="h-7 text-xs gap-1" onClick={() => copyToClipboard( - `import { Caerus } from '@caerus/sdk'\n\nconst client = new Caerus({\n apiKey: process.env.CAERUS_API_KEY\n})`, + `import { CaerusClient } from '@caerus-dev/sdk'\n\nconst caerus = new CaerusClient({\n apiKey: process.env.CAERUS_API_KEY\n})`, "snippet" ) } @@ -355,14 +355,14 @@ export default function ApiKeysPage() { import {" { "} - Caerus + CaerusClient {" } "} from{" "} - {`'@caerus/sdk'`} + {`'@caerus-dev/sdk'`} {"\n\n"} - const client ={" "} + const caerus ={" "} new{" "} - Caerus + CaerusClient {"({\n"} {" "}apiKey: process.env. CAERUS_API_KEY From aabf951ee38b9eced6a130a2be219de0d7acaec3 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 17:22:03 -0300 Subject: [PATCH 05/24] fix(docs): add missing idempotency-error and not-found-error sections to match TOC --- app/docs/errors/page.tsx | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/app/docs/errors/page.tsx b/app/docs/errors/page.tsx index 838cb1f..36b634a 100644 --- a/app/docs/errors/page.tsx +++ b/app/docs/errors/page.tsx @@ -102,6 +102,62 @@ try { /> + {/* IdempotencyError */} +
+

+ Manejo de IdempotencyError +

+

+ Ocurre cuando una plantilla tiene activada la opción Idempotencia y se envía una solicitud sin idempotencyKey, o cuando se reutiliza una clave existente con parámetros o payload diferente: +

+ + +
+ + {/* NotFoundError */} +
+

+ Manejo de NotFoundError (404) +

+

+ Se produce cuando el recurso, la plantilla asociada o el holderId no existen en el ambiente consultado o fueron eliminados: +

+ + +
+ {/* Troubleshooting */}

From af1c5c365245ad69b513d063647a56796882b66f Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 20:31:51 -0300 Subject: [PATCH 06/24] fix(dashboard): refactor usage page with real billing data, spanish localization and csv export --- app/dashboard/usage/page.tsx | 370 +++++++++++++++++++++-------------- 1 file changed, 227 insertions(+), 143 deletions(-) diff --git a/app/dashboard/usage/page.tsx b/app/dashboard/usage/page.tsx index 1563cce..7a7ad51 100644 --- a/app/dashboard/usage/page.tsx +++ b/app/dashboard/usage/page.tsx @@ -1,9 +1,22 @@ "use client" import { useState } from "react" -import { BarChart3, Activity, Calendar, Download, Filter, TrendingUp, TrendingDown } from "lucide-react" +import Link from "next/link" +import { + BarChart3, + Activity, + Calendar, + Download, + Filter, + Zap, + ArrowUpRight, + Clock, + AlertTriangle, +} from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" import { Select, SelectContent, @@ -19,19 +32,28 @@ import { CartesianGrid, Tooltip, ResponsiveContainer, - BarChart, - Bar, } from "recharts" +import { useUser } from "@/hooks/use-user" +import { useApps } from "@/components/dashboard/apps-context" +import { toast } from "sonner" export default function UsagePage() { const [timeRange, setTimeRange] = useState("30d") + const { user, isLoading: isUserLoading } = useUser() + const { applications, isAppsLoading } = useApps() - const totalCalls = 0 - const planLimit = 125000 - const usagePercentage = 0 - const isNearLimit = false + const consumedUnits = user?.billingUsage?.consumedUnits ?? 0 + const includedUnits = + user?.billingUsage?.includedUnits ?? (user?.billingPlan?.includedBillingUnits ?? 50000) + const usagePercentage = + user?.billingUsage?.percentage ?? + (includedUnits > 0 ? Math.round((consumedUnits / includedUnits) * 100) : 0) + const isNearLimit = usagePercentage >= 80 + const isOverLimit = usagePercentage >= 100 + const currentPlan = user?.billingPlan + const period = user?.billingUsage?.period || new Date().toISOString().slice(0, 7) - // Generamos datos vacíos de manera dinámica basados en timeRange + // Generamos datos para el rango seleccionado const generateData = () => { const data = [] const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : 90 @@ -40,37 +62,42 @@ export default function UsagePage() { const d = new Date(now) d.setDate(d.getDate() - i) data.push({ - date: d.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }), + date: d.toLocaleDateString("es-ES", { month: "short", day: "numeric" }), calls: 0, - locks: 0 }) } return data } - - const usageData = generateData() - const applicationBreakdown: any[] = [] - const eventLog: any[] = [] - const getEnvironmentBadgeClass = (env: string) => { - switch (env) { - case "prod": - return "bg-primary/20 text-primary" - case "dev": - return "bg-chart-2/20 text-chart-2" - default: - return "bg-secondary text-muted-foreground" - } - } + const usageData = generateData() - const getEventColor = (event: string) => { - if (event.includes("acquired") || event.includes("confirmed") || event.includes("created")) { - return "text-primary" + const handleExport = () => { + try { + const rows = [ + ["Concepto", "Valor"], + ["Período", period], + ["Plan", currentPlan?.name || "Developer"], + ["Código de Plan", currentPlan?.code || "DEVELOPER"], + ["Requests Consumidas", consumedUnits.toString()], + ["Requests Incluidas", includedUnits.toString()], + ["Porcentaje de Uso", `${usagePercentage}%`], + ["Aplicaciones Activas", applications.map((a) => a.name).join("; ") || "Ninguna"], + ["Fecha de Generación", new Date().toLocaleString("es-ES")], + ] + const csvContent = + "data:text/csv;charset=utf-8," + + rows.map((e) => e.map((cell) => `"${cell}"`).join(",")).join("\n") + const encodedUri = encodeURI(csvContent) + const link = document.createElement("a") + link.setAttribute("href", encodedUri) + link.setAttribute("download", `consumo-caerus-${period}.csv`) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + toast.success("Reporte de consumo descargado exitosamente") + } catch { + toast.error("Error al exportar los datos de consumo") } - if (event.includes("expired") || event.includes("timeout")) { - return "text-chart-4" - } - return "text-muted-foreground" } return ( @@ -78,26 +105,26 @@ export default function UsagePage() { {/* Page header */}
-

Usage

+

Consumo

- Monitor your API consumption and plan limits + Monitorea el consumo de tu API y los límites de tu plan

-
@@ -106,66 +133,110 @@ export default function UsagePage() {
- - API Calls (30d) + + Requests Consumidas + -
- 84.2k - - - +12.3% - -
+ {isUserLoading ? ( + + ) : ( +
+
+ {consumedUnits.toLocaleString()} +
+

+ Período {period} +

+
+ )}
- - Active Locks + + Plan Activo + -
- 12 - - - -2 - -
+ {isUserLoading ? ( + + ) : ( +
+
+ + {currentPlan?.name || "Developer"} + + + {currentPlan?.code || "DEVELOPER"} + +
+
+ + Gestionar facturación y límites + + +
+
+ )}
- - Plan Usage + + Uso del Plan + -
-
- - {usagePercentage}% - - - {totalCalls.toLocaleString()} / {planLimit.toLocaleString()} - -
-
-
+ {isUserLoading ? ( + + ) : ( +
+
+ + {usagePercentage}% + + + {consumedUnits.toLocaleString()} / {includedUnits.toLocaleString()} requests + +
+
+
+
+ {isOverLimit ? ( +

+ + Has superado las requests incluidas. +

+ ) : isNearLimit ? ( +

+ + Cerca del límite del plan. Considera mejorar. +

+ ) : ( +

+ Límite de {includedUnits.toLocaleString()} requests mensuales. +

+ )}
- {isNearLimit && ( -

- Approaching plan limit. Consider upgrading. -

- )} -
+ )}
@@ -175,10 +246,10 @@ export default function UsagePage() { - API Calls Over Time + Llamadas a la API en el Tiempo - Daily API call volume for the selected period + Volumen diario de llamadas a la API durante el período seleccionado @@ -200,6 +271,7 @@ export default function UsagePage() { [`${val} requests`, "Consumo"]} /> - Usage by Application + Consumo por Aplicación - API calls distribution across applications + Distribución de llamadas a la API entre tus aplicaciones -
- - - - - - - - - -
-
- {applicationBreakdown.length === 0 ? ( -

No hay datos de aplicaciones.

- ) : ( - applicationBreakdown.map((app) => ( -
- {app.name} - {app.percentage}% + {isAppsLoading ? ( +
+ + +
+ ) : applications.length === 0 ? ( +
+

No tienes aplicaciones registradas todavía.

+

+ Crea tu primera aplicación para comenzar a monitorear su consumo. +

+ +
+ ) : ( +
+ {applications.map((app) => ( +
+
+ + {app.name} + +
+ {app.environments.map((env) => ( + + {env} + + ))} +
- )) - )} +
+ 0 requests +
+
+ ))} +

+ El desglose por aplicación se calcula a partir de los registros de telemetría de tus API keys. +

+ )} @@ -279,38 +371,30 @@ export default function UsagePage() {
- Event Log - Real-time activity stream + + + Registro de Eventos + + Flujo de actividad reciente en tiempo real
-
-
- {eventLog.length === 0 ? ( -

No hay actividad reciente.

- ) : ( - eventLog.map((event) => ( -
-
- -
-

{event.event}

-

{event.app}

-
-
-
- - {event.env} - -

{event.time}

-
-
- )) - )} +
+ +

No hay actividad reciente registrada.

+

+ Las adquisiciones, confirmaciones y liberaciones de locks ejecutadas mediante el SDK se registrarán aquí. +

From 4c58d9f66f1d8eced76c5fdc6d7febda345ac0bf Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 21:11:43 -0300 Subject: [PATCH 07/24] fix(dashboard): preserve route env and tab parameters in lock and resource forms --- .gitignore | 3 +- .../[id]/locks/[lockId]/edit/page.tsx | 29 ++++++++++++++----- .../[id]/resources/[resourceId]/edit/page.tsx | 29 ++++++++++++++----- .../dashboard/applications/tabs/locks-tab.tsx | 2 +- components/dashboard/lock-form.tsx | 19 ++++++++---- components/dashboard/resource-form.tsx | 20 ++++++++----- 6 files changed, 73 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 0e43f50..e36eb0b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ node_modules .vercel .claude/ .env* - +package-lock.json +pnpm-workspace.yaml diff --git a/app/dashboard/applications/[id]/locks/[lockId]/edit/page.tsx b/app/dashboard/applications/[id]/locks/[lockId]/edit/page.tsx index bcc4276..c3652a1 100644 --- a/app/dashboard/applications/[id]/locks/[lockId]/edit/page.tsx +++ b/app/dashboard/applications/[id]/locks/[lockId]/edit/page.tsx @@ -1,15 +1,10 @@ "use client" -import { use, useEffect, useState } from "react" +import { use, useEffect, useState, Suspense } from "react" import { LockForm, LockFormValues } from "@/components/dashboard/lock-form" import { Loader2 } from "lucide-react" -export default function EditLockPage({ - params, -}: { - params: Promise<{ id: string; lockId: string }> -}) { - const { id, lockId } = use(params) +function EditLockContainer({ id, lockId }: { id: string; lockId: string }) { const [initialData, setInitialData] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) @@ -65,3 +60,23 @@ export default function EditLockPage({ /> ) } + +export default function EditLockPage({ + params, +}: { + params: Promise<{ id: string; lockId: string }> +}) { + const { id, lockId } = use(params) + + return ( + + +
+ } + > + + + ) +} diff --git a/app/dashboard/applications/[id]/resources/[resourceId]/edit/page.tsx b/app/dashboard/applications/[id]/resources/[resourceId]/edit/page.tsx index 7ac5877..15da772 100644 --- a/app/dashboard/applications/[id]/resources/[resourceId]/edit/page.tsx +++ b/app/dashboard/applications/[id]/resources/[resourceId]/edit/page.tsx @@ -1,15 +1,10 @@ "use client" -import { use, useEffect, useState } from "react" +import { use, useEffect, useState, Suspense } from "react" import { ResourceForm } from "@/components/dashboard/resource-form" import { Loader2 } from "lucide-react" -export default function EditResourcePage({ - params, -}: { - params: Promise<{ id: string; resourceId: string }> -}) { - const { id, resourceId } = use(params) +function EditResourceContainer({ id, resourceId }: { id: string; resourceId: string }) { const [initialData, setInitialData] = useState(null) const [isLoading, setIsLoading] = useState(true) @@ -69,3 +64,23 @@ export default function EditResourcePage({ /> ) } + +export default function EditResourcePage({ + params, +}: { + params: Promise<{ id: string; resourceId: string }> +}) { + const { id, resourceId } = use(params) + + return ( + + +
+ } + > + + + ) +} diff --git a/components/dashboard/applications/tabs/locks-tab.tsx b/components/dashboard/applications/tabs/locks-tab.tsx index 88b2ec3..28ec3dc 100644 --- a/components/dashboard/applications/tabs/locks-tab.tsx +++ b/components/dashboard/applications/tabs/locks-tab.tsx @@ -140,7 +140,7 @@ export function LocksTab({ - + Configurar diff --git a/components/dashboard/lock-form.tsx b/components/dashboard/lock-form.tsx index b89e9cb..edb86e7 100644 --- a/components/dashboard/lock-form.tsx +++ b/components/dashboard/lock-form.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { useRouter } from "next/navigation" +import { useRouter, useSearchParams } from "next/navigation" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import * as z from "zod" @@ -56,9 +56,18 @@ interface LockFormProps { export function LockForm({ applicationId, environmentId, lockId, initialData, isEditing = false }: LockFormProps) { const router = useRouter() + const searchParams = useSearchParams() const [isSubmitting, setIsSubmitting] = useState(false) const [apiError, setApiError] = useState(null) + const envParam = searchParams.get("env") + const getReturnUrl = () => { + if (envParam) { + return `/dashboard/applications/${applicationId}?env=${encodeURIComponent(envParam)}&tab=locks` + } + return `/dashboard/applications/${applicationId}?tab=locks` + } + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: initialData || { @@ -107,7 +116,7 @@ export function LockForm({ applicationId, environmentId, lockId, initialData, is throw new Error(errorData.error || "Hubo un error al guardar el lock") } - router.push(`/dashboard/applications/${applicationId}`) + router.push(getReturnUrl()) } catch (error: any) { console.error(error) setApiError(error.message) @@ -122,7 +131,7 @@ export function LockForm({ applicationId, environmentId, lockId, initialData, is try { const res = await fetch(`/api/distributed-lock-templates/${lockId}`, { method: "DELETE" }) if (!res.ok) throw new Error("Error eliminando el lock") - router.push(`/dashboard/applications/${applicationId}`) + router.push(getReturnUrl()) } catch (e: any) { setApiError(e.message) setIsSubmitting(false) @@ -135,7 +144,7 @@ export function LockForm({ applicationId, environmentId, lockId, initialData, is + + + + + + + ¿Eliminar plantilla de lock? + + Esta acción no se puede deshacer. Se eliminará la configuración de la plantilla de lock distribuido para este namespace. + + + + Cancelar + + {isSubmitting ? "Eliminando..." : "Sí, eliminar"} + + + + ) : (
// Spacer )} diff --git a/components/dashboard/resource-form.tsx b/components/dashboard/resource-form.tsx index 91d5f71..2511e00 100644 --- a/components/dashboard/resource-form.tsx +++ b/components/dashboard/resource-form.tsx @@ -8,6 +8,17 @@ import * as z from "zod" import { ArrowLeft, Box, Save, Trash2 } from "lucide-react" import { Button } from "@/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" import { Form, FormControl, @@ -143,7 +154,6 @@ export function ResourceForm({ } async function handleDelete() { - if (!window.confirm("¿Estás seguro que deseas eliminar esta plantilla de recurso?")) return setIsSubmitting(true) setErrorMsg("") try { @@ -397,10 +407,32 @@ export function ResourceForm({
{isEditing ? ( - + + + + + + + ¿Eliminar plantilla de recurso? + + Esta acción no se puede deshacer. Se eliminará la configuración de la plantilla de recurso compartido en este ambiente. + + + + Cancelar + + {isSubmitting ? "Eliminando..." : "Sí, eliminar"} + + + + ) : (
// Spacer )} From 2d91ff22970310fe1c64b3f18008d64076425617 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 21:44:03 -0300 Subject: [PATCH 09/24] fix(dashboard): rename notifications tab and back button to webhooks --- app/dashboard/applications/[id]/page.tsx | 2 +- .../dashboard/applications/tabs/webhook-deliveries-view.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/dashboard/applications/[id]/page.tsx b/app/dashboard/applications/[id]/page.tsx index 14b3ddc..9793cf6 100644 --- a/app/dashboard/applications/[id]/page.tsx +++ b/app/dashboard/applications/[id]/page.tsx @@ -707,7 +707,7 @@ export default function ApplicationDashboard({ - Notificaciones + Webhooks diff --git a/components/dashboard/applications/tabs/webhook-deliveries-view.tsx b/components/dashboard/applications/tabs/webhook-deliveries-view.tsx index f082294..4947f38 100644 --- a/components/dashboard/applications/tabs/webhook-deliveries-view.tsx +++ b/components/dashboard/applications/tabs/webhook-deliveries-view.tsx @@ -325,7 +325,7 @@ export function WebhookDeliveriesView({ className="gap-2 text-xs text-muted-foreground hover:text-foreground -ml-2 mb-1 cursor-pointer" > - Volver a Notificaciones + Volver a Webhooks
From 27734e19f364ed825d711663d6970affb297bebc Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 21:47:58 -0300 Subject: [PATCH 10/24] chore(settings): remove leftover debug console logs in environment deletion --- app/dashboard/applications/[id]/settings/page.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/dashboard/applications/[id]/settings/page.tsx b/app/dashboard/applications/[id]/settings/page.tsx index 77635a8..d148458 100644 --- a/app/dashboard/applications/[id]/settings/page.tsx +++ b/app/dashboard/applications/[id]/settings/page.tsx @@ -234,17 +234,16 @@ export default function ApplicationSettingsPage({ const handleDeleteEnvConfirm = async () => { if (!selectedEnvForDelete) return - console.log("INTENTANDO ELIMINAR ENTORNO:", selectedEnvForDelete.name, "CON ID:", selectedEnvForDelete.id) setIsSavingEnv(true) setDeleteEnvError(null) try { - console.log("URL DE FETCH:", `/api/applications/${id}/environments/${selectedEnvForDelete.id}`) const res = await fetch(`/api/applications/${id}/environments/${selectedEnvForDelete.id}`, { method: "DELETE", }) if (res.ok) { setEnvironments((prev) => prev.filter((e) => e.id !== selectedEnvForDelete.id)) - refreshApps(); setConfirmDeleteEnvOpen(false) + refreshApps() + setConfirmDeleteEnvOpen(false) setSelectedEnvForDelete(null) } else { const errData = await res.json().catch(() => ({ message: "Error al eliminar el ambiente" })) From d3045bbe9de5a0434dc8269cfe91efbafce6cffd Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 23:00:54 -0300 Subject: [PATCH 11/24] fix(landing): point footer github link to official caerus-dev organization --- components/landing/footer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/landing/footer.tsx b/components/landing/footer.tsx index 6bb18af..5410133 100644 --- a/components/landing/footer.tsx +++ b/components/landing/footer.tsx @@ -37,7 +37,7 @@ export function Footer() {

Date: Fri, 18 Sep 2026 23:06:56 -0300 Subject: [PATCH 12/24] docs: fix section anchor id from aviones to control-plane-vs-data-plane --- app/docs/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/docs/page.tsx b/app/docs/page.tsx index db3d98a..a4bf515 100644 --- a/app/docs/page.tsx +++ b/app/docs/page.tsx @@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge" const tocItems = [ { id: "problema", title: "¿Qué problema resuelve Caerus?" }, { id: "arquitectura", title: "Arquitectura de Estado Híbrido" }, - { id: "aviones", title: "Control Plane vs Data Plane" }, + { id: "control-plane-vs-data-plane", title: "Control Plane vs Data Plane" }, { id: "motores", title: "Los Motores: SRE y DLS" }, { id: "quickstart", title: "Inicio Rápido (SDK)" }, ] @@ -101,7 +101,7 @@ export default function DocsOverviewPage() {

{/* Sección: Control Plane vs Data Plane */} -
+

Control Plane vs Data Plane

From 7c64cd76c5ab37f12a442ac080ad2a158969bf60 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Fri, 18 Sep 2026 23:16:01 -0300 Subject: [PATCH 13/24] =?UTF-8?q?style:=20fix=20missing=20spanish=20accent?= =?UTF-8?q?s=20(per=C3=ADodo,=20cr=C3=ADticas,=20adquisici=C3=B3n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/dashboard/page.tsx | 4 ++-- app/settings/billing/page.tsx | 10 +++++----- components/dashboard/applications/tabs/locks-tab.tsx | 2 +- components/dashboard/applications/tabs/metrics-tab.tsx | 2 +- components/landing/use-cases-section.tsx | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 32099e1..a8215d6 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -189,7 +189,7 @@ export default async function DashboardPage() {
- Consumo del Periodo Actual ({userProfile?.billingUsage?.period || "Mensual"}) + Consumo del Período Actual ({userProfile?.billingUsage?.period || "Mensual"}) {isEnterprise @@ -208,7 +208,7 @@ export default async function DashboardPage() { {isEnterprise ? (
- {consumedUnits.toLocaleString()} requests consumidas este periodo + {consumedUnits.toLocaleString()} requests consumidas este período Sin límite estricto diff --git a/app/settings/billing/page.tsx b/app/settings/billing/page.tsx index e824daf..59417c5 100644 --- a/app/settings/billing/page.tsx +++ b/app/settings/billing/page.tsx @@ -454,7 +454,7 @@ export default function BillingPage() {
- Consumo del Periodo ({usage?.period || new Date().toISOString().slice(0, 7)}) + Consumo del Período ({usage?.period || new Date().toISOString().slice(0, 7)})
{(usage?.consumedUnits ?? 0).toLocaleString()} requests consumidas @@ -469,7 +469,7 @@ export default function BillingPage() {
- Consumo del Periodo ({usage?.period || new Date().toISOString().slice(0, 7)}) + Consumo del Período ({usage?.period || new Date().toISOString().slice(0, 7)})
{isUserLoading ? ( @@ -499,7 +499,7 @@ export default function BillingPage() {

) : (

- Las requests se renuevan automáticamente al inicio de cada periodo mensual. + Las requests se renuevan automáticamente al inicio de cada período mensual.

)}
@@ -633,7 +633,7 @@ export default function BillingPage() { Historial de Facturas - Consulta y descarga los recibos de tus periodos mensuales. + Consulta y descarga los recibos de tus períodos mensuales.
From ee22501fd820ba540eebcb38b40ec918eacf5420 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Mon, 21 Sep 2026 18:16:59 -0300 Subject: [PATCH 16/24] security(usage): escape CSV cells and neutralize formula injection on export --- app/dashboard/usage/page.tsx | 14 ++++++++++---- pnpm-workspace.yaml | 4 ---- 2 files changed, 10 insertions(+), 8 deletions(-) delete mode 100644 pnpm-workspace.yaml diff --git a/app/dashboard/usage/page.tsx b/app/dashboard/usage/page.tsx index 7a7ad51..496cea6 100644 --- a/app/dashboard/usage/page.tsx +++ b/app/dashboard/usage/page.tsx @@ -84,16 +84,22 @@ export default function UsagePage() { ["Aplicaciones Activas", applications.map((a) => a.name).join("; ") || "Ninguna"], ["Fecha de Generación", new Date().toLocaleString("es-ES")], ] - const csvContent = - "data:text/csv;charset=utf-8," + - rows.map((e) => e.map((cell) => `"${cell}"`).join(",")).join("\n") - const encodedUri = encodeURI(csvContent) + const escapeCsvCell = (cell: string) => { + const neutralized = /^[=+\-@\t\r]/.test(cell) ? `'${cell}` : cell + return `"${neutralized.replace(/"/g, '""')}"` + } + const blob = new Blob( + [`\uFEFF${rows.map((row) => row.map(escapeCsvCell).join(",")).join("\r\n")}`], + { type: "text/csv;charset=utf-8" } + ) + const encodedUri = URL.createObjectURL(blob) const link = document.createElement("a") link.setAttribute("href", encodedUri) link.setAttribute("download", `consumo-caerus-${period}.csv`) document.body.appendChild(link) link.click() document.body.removeChild(link) + URL.revokeObjectURL(encodedUri) toast.success("Reporte de consumo descargado exitosamente") } catch { toast.error("Error al exportar los datos de consumo") diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 362ce18..0000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -allowBuilds: - sharp: false -onlyBuiltDependencies: - - sharp From 2e2ee2197f08ba91814ff7d2658533a04703b279 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Mon, 21 Sep 2026 19:07:36 -0300 Subject: [PATCH 17/24] fix(usage): handle user fetch error with retry UI and remove fabricated plan defaults --- app/dashboard/usage/page.tsx | 69 ++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/app/dashboard/usage/page.tsx b/app/dashboard/usage/page.tsx index 496cea6..901a062 100644 --- a/app/dashboard/usage/page.tsx +++ b/app/dashboard/usage/page.tsx @@ -12,6 +12,8 @@ import { ArrowUpRight, Clock, AlertTriangle, + AlertCircle, + RefreshCw, } from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" @@ -39,12 +41,12 @@ import { toast } from "sonner" export default function UsagePage() { const [timeRange, setTimeRange] = useState("30d") - const { user, isLoading: isUserLoading } = useUser() + const { user, isLoading: isUserLoading, error: userError, refreshUser } = useUser() const { applications, isAppsLoading } = useApps() const consumedUnits = user?.billingUsage?.consumedUnits ?? 0 const includedUnits = - user?.billingUsage?.includedUnits ?? (user?.billingPlan?.includedBillingUnits ?? 50000) + user?.billingUsage?.includedUnits ?? user?.billingPlan?.includedBillingUnits ?? 0 const usagePercentage = user?.billingUsage?.percentage ?? (includedUnits > 0 ? Math.round((consumedUnits / includedUnits) * 100) : 0) @@ -72,12 +74,16 @@ export default function UsagePage() { const usageData = generateData() const handleExport = () => { + if (!user) { + toast.error("No hay datos de facturación disponibles para exportar") + return + } try { const rows = [ ["Concepto", "Valor"], ["Período", period], - ["Plan", currentPlan?.name || "Developer"], - ["Código de Plan", currentPlan?.code || "DEVELOPER"], + ["Plan", currentPlan?.name || "Sin plan"], + ["Código de Plan", currentPlan?.code || "-"], ["Requests Consumidas", consumedUnits.toString()], ["Requests Incluidas", includedUnits.toString()], ["Porcentaje de Uso", `${usagePercentage}%`], @@ -128,15 +134,52 @@ export default function UsagePage() { Últimos 90 días -
- {/* Usage overview */} -
+ {/* Error state */} + {!isUserLoading && userError && !user ? ( + + +
+ +
+
+

+ Error al cargar datos de consumo +

+

+ No pudimos obtener la información de tu plan y uso de la API desde el servidor. +

+ {userError && ( +

+ {userError} +

+ )} +
+ +
+
+ ) : ( + <> + {/* Usage overview */} +
@@ -174,11 +217,13 @@ export default function UsagePage() {
- {currentPlan?.name || "Developer"} + {currentPlan?.name || "Sin plan"} - - {currentPlan?.code || "DEVELOPER"} - + {currentPlan?.code && ( + + {currentPlan.code} + + )}
+ + )}
) } From dd0c4e09b6e17efacb597512f144abb2c4d101f5 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Mon, 21 Sep 2026 19:26:28 -0300 Subject: [PATCH 18/24] fix(usage): aggregate daily telemetry and handle unavailable chart data state --- app/dashboard/usage/page.tsx | 221 ++++++++++++++++++++++++++--------- 1 file changed, 165 insertions(+), 56 deletions(-) diff --git a/app/dashboard/usage/page.tsx b/app/dashboard/usage/page.tsx index 901a062..38e37a7 100644 --- a/app/dashboard/usage/page.tsx +++ b/app/dashboard/usage/page.tsx @@ -1,7 +1,8 @@ "use client" -import { useState } from "react" +import { useState, useEffect } from "react" import Link from "next/link" +import { format, subDays } from "date-fns" import { BarChart3, Activity, @@ -55,23 +56,104 @@ export default function UsagePage() { const currentPlan = user?.billingPlan const period = user?.billingUsage?.period || new Date().toISOString().slice(0, 7) - // Generamos datos para el rango seleccionado - const generateData = () => { - const data = [] - const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : 90 - const now = new Date() - for (let i = days; i >= 0; i--) { - const d = new Date(now) - d.setDate(d.getDate() - i) - data.push({ - date: d.toLocaleDateString("es-ES", { month: "short", day: "numeric" }), - calls: 0, - }) + const [dailyUsageData, setDailyUsageData] = useState<{ date: string; calls: number }[]>([]) + const [isChartLoading, setIsChartLoading] = useState(false) + + useEffect(() => { + let isMounted = true + + async function loadDailyUsage() { + setIsChartLoading(true) + try { + const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : 90 + const now = new Date() + const startDate = format(subDays(now, days), "yyyy-MM-dd") + const endDate = format(now, "yyyy-MM-dd") + + // Obtener aplicaciones y sus entornos para consultar telemetría + const appsRes = await fetch("/api/applications?size=50", { cache: "no-store" }) + if (!appsRes.ok) { + if (isMounted) setIsChartLoading(false) + return + } + + const appsData = await appsRes.json() + const appsList: any[] = appsData.content || [] + const envIds: string[] = [] + + for (const app of appsList) { + if (Array.isArray(app.environments)) { + for (const env of app.environments) { + if (env.id) envIds.push(env.id) + } + } + } + + const statsByDate: Record = {} + + if (envIds.length > 0) { + const statsPromises = envIds.map(async (envId) => { + try { + const res = await fetch( + `/api/environments/${envId}/statistics?startDate=${startDate}&endDate=${endDate}`, + { cache: "no-store" } + ) + if (res.ok) { + return await res.json() + } + } catch { + return [] + } + return [] + }) + + const results = await Promise.allSettled(statsPromises) + for (const res of results) { + if (res.status === "fulfilled" && Array.isArray(res.value)) { + for (const stat of res.value) { + if (stat.date) { + const callsCount = + Number(stat.totalBillingUnits || 0) || + (Number(stat.dlsAcquireAttempts || 0) + Number(stat.sreTakeAttempts || 0)) + statsByDate[stat.date] = (statsByDate[stat.date] || 0) + callsCount + } + } + } + } + } + + // Construir puntos continuos para cada día del rango + const points = [] + for (let i = days; i >= 0; i--) { + const d = subDays(now, i) + const dateKey = format(d, "yyyy-MM-dd") + const label = d.toLocaleDateString("es-ES", { month: "short", day: "numeric" }) + points.push({ + date: label, + calls: statsByDate[dateKey] || 0, + }) + } + + if (isMounted) { + setDailyUsageData(points) + } + } catch (err) { + console.error("Error loading usage chart data:", err) + } finally { + if (isMounted) { + setIsChartLoading(false) + } + } } - return data - } - const usageData = generateData() + loadDailyUsage() + + return () => { + isMounted = false + } + }, [timeRange]) + + const totalDailyCalls = dailyUsageData.reduce((acc, curr) => acc + curr.calls, 0) const handleExport = () => { if (!user) { @@ -304,46 +386,73 @@ export default function UsagePage() {
-
- - - - - - - - - - - - [`${val} requests`, "Consumo"]} - /> - - - -
+ {isChartLoading ? ( +
+ +
+ ) : totalDailyCalls > 0 ? ( +
+ + + + + + + + + + + + [`${val} requests`, "Consumo"]} + /> + + + +
+ ) : consumedUnits > 0 ? ( +
+ +

+ Desglose diario no disponible para este período +

+

+ Se registraron {consumedUnits.toLocaleString()} requests en el período de facturación actual, pero no se encontraron métricas diarias detalladas para el rango seleccionado. +

+
+ ) : ( +
+ +

+ Sin actividad registrada +

+

+ No se registraron llamadas a la API durante los últimos{" "} + {timeRange === "7d" ? "7 días" : timeRange === "30d" ? "30 días" : "90 días"}. +

+
+ )}
From 3337ac71d4df1beb1abf5c8709090c0200be0cab Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Mon, 21 Sep 2026 20:16:27 -0300 Subject: [PATCH 19/24] fix(usage): calculate per-application usage from environment telemetry and handle unavailable state --- app/dashboard/usage/page.tsx | 118 ++++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 37 deletions(-) diff --git a/app/dashboard/usage/page.tsx b/app/dashboard/usage/page.tsx index 38e37a7..02d9eda 100644 --- a/app/dashboard/usage/page.tsx +++ b/app/dashboard/usage/page.tsx @@ -57,6 +57,7 @@ export default function UsagePage() { const period = user?.billingUsage?.period || new Date().toISOString().slice(0, 7) const [dailyUsageData, setDailyUsageData] = useState<{ date: string; calls: number }[]>([]) + const [appUsageData, setAppUsageData] = useState>({}) const [isChartLoading, setIsChartLoading] = useState(false) useEffect(() => { @@ -80,16 +81,27 @@ export default function UsagePage() { const appsData = await appsRes.json() const appsList: any[] = appsData.content || [] const envIds: string[] = [] + const envToAppMap: Record = {} + const initialAppUsage: Record = {} for (const app of appsList) { + if (app.name) { + initialAppUsage[app.name] = 0 + } if (Array.isArray(app.environments)) { for (const env of app.environments) { - if (env.id) envIds.push(env.id) + if (env.id) { + envIds.push(env.id) + if (app.name) { + envToAppMap[env.id] = app.name + } + } } } } const statsByDate: Record = {} + const appUsageCount: Record = { ...initialAppUsage } if (envIds.length > 0) { const statsPromises = envIds.map(async (envId) => { @@ -99,23 +111,28 @@ export default function UsagePage() { { cache: "no-store" } ) if (res.ok) { - return await res.json() + const data = await res.json() + return { envId, data } } } catch { - return [] + return { envId, data: [] } } - return [] + return { envId, data: [] } }) const results = await Promise.allSettled(statsPromises) for (const res of results) { - if (res.status === "fulfilled" && Array.isArray(res.value)) { - for (const stat of res.value) { + if (res.status === "fulfilled" && Array.isArray(res.value.data)) { + const appName = envToAppMap[res.value.envId] + for (const stat of res.value.data) { if (stat.date) { const callsCount = Number(stat.totalBillingUnits || 0) || (Number(stat.dlsAcquireAttempts || 0) + Number(stat.sreTakeAttempts || 0)) statsByDate[stat.date] = (statsByDate[stat.date] || 0) + callsCount + if (appName) { + appUsageCount[appName] = (appUsageCount[appName] || 0) + callsCount + } } } } @@ -136,6 +153,7 @@ export default function UsagePage() { if (isMounted) { setDailyUsageData(points) + setAppUsageData(appUsageCount) } } catch (err) { console.error("Error loading usage chart data:", err) @@ -469,7 +487,7 @@ export default function UsagePage() { - {isAppsLoading ? ( + {isAppsLoading || isChartLoading ? (
@@ -486,40 +504,66 @@ export default function UsagePage() {
) : (
- {applications.map((app) => ( -
-
- - {app.name} - -
- {app.environments.map((env) => ( - - {env} + {applications.map((app) => { + const appCalls = appUsageData[app.name] ?? 0 + const hasUsage = totalDailyCalls > 0 + const isUnavailable = totalDailyCalls === 0 && consumedUnits > 0 + + return ( +
+
+ + {app.name} + +
+ {app.environments.map((env) => ( + + {env} + + ))} +
+
+
+ {isUnavailable ? ( + + No disponible - ))} + ) : hasUsage ? ( +
+ + {appCalls.toLocaleString()} + {" "} + requests + {appCalls > 0 && ( +
+ {((appCalls / totalDailyCalls) * 100).toFixed(1)}% del total +
+ )} +
+ ) : ( + 0 requests + )}
-
- 0 requests -
-
- ))} + ) + })}

- El desglose por aplicación se calcula a partir de los registros de telemetría de tus API keys. + {totalDailyCalls === 0 && consumedUnits > 0 + ? "El desglose por aplicación no está disponible para este período de telemetría." + : "El desglose por aplicación se calcula a partir de los registros de telemetría de tus entornos."}

)} From 76dbaa7efc3515c8ab5403cd6065bb6f68376992 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Mon, 21 Sep 2026 20:24:08 -0300 Subject: [PATCH 20/24] fix(docs): close mobile navigation sheet on route change --- components/docs/docs-header.tsx | 11 +++++++++-- components/docs/docs-sidebar.tsx | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/components/docs/docs-header.tsx b/components/docs/docs-header.tsx index 4a781e5..442f2bd 100644 --- a/components/docs/docs-header.tsx +++ b/components/docs/docs-header.tsx @@ -31,8 +31,14 @@ export function DocsHeader({ onSearchClick }: DocsHeaderProps) { - Caerus - Documentación + setSheetOpen(false)} + className="flex items-center gap-2 hover:opacity-90 transition-opacity" + > + Caerus + Documentación + setSheetOpen(false)} /> diff --git a/components/docs/docs-sidebar.tsx b/components/docs/docs-sidebar.tsx index 0158340..b8ad7a9 100644 --- a/components/docs/docs-sidebar.tsx +++ b/components/docs/docs-sidebar.tsx @@ -11,10 +11,11 @@ import { cn } from "@/lib/utils" interface DocsSidebarProps { onSearchClick?: () => void + onNavigate?: () => void className?: string } -export function DocsSidebar({ onSearchClick, className }: DocsSidebarProps) { +export function DocsSidebar({ onSearchClick, onNavigate, className }: DocsSidebarProps) { const pathname = usePathname() const [filterQuery, setFilterQuery] = React.useState("") @@ -73,6 +74,7 @@ export function DocsSidebar({ onSearchClick, className }: DocsSidebarProps) {
  • Date: Mon, 21 Sep 2026 20:29:10 -0300 Subject: [PATCH 21/24] refactor(pricing): remove hard-coded billing plan limits from preview copy --- components/landing/pricing-preview-section.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/landing/pricing-preview-section.tsx b/components/landing/pricing-preview-section.tsx index 344ee87..5436d5e 100644 --- a/components/landing/pricing-preview-section.tsx +++ b/components/landing/pricing-preview-section.tsx @@ -28,8 +28,8 @@ export function PricingPreviewSection() {

    Caerus funciona bajo un esquema de facturación pensado para desarrolladores. Contás con un{" "} - plan gratuito Developer ($0/mes) con - 50.000 requests mensuales incluidas para prototipar y validar tu producto con costo inicial cero. A medida que tu + plan gratuito Developer para + prototipar y validar tu producto con costo inicial cero. A medida que tu tráfico y tus necesidades de alta disponibilidad aumentan, podés escalar de plan en cualquier momento.

    From 0715617f957551a1e234823a8e308027d38adcff Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Mon, 21 Sep 2026 20:32:02 -0300 Subject: [PATCH 22/24] docs(errors): correct gRPC status mapping for ConflictError to FAILED_PRECONDITION --- app/docs/errors/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/docs/errors/page.tsx b/app/docs/errors/page.tsx index 36b634a..7dd7464 100644 --- a/app/docs/errors/page.tsx +++ b/app/docs/errors/page.tsx @@ -46,7 +46,7 @@ export default function DocsErrorsPage() { ConflictError - ABORTED / 409 + FAILED_PRECONDITION / 409 El recurso ya está retenido por otro usuario o sin stock disponible. From f8d6c9e18f274defda55000b832ce29dacdf6213 Mon Sep 17 00:00:00 2001 From: ManuMar28 Date: Mon, 21 Sep 2026 20:36:56 -0300 Subject: [PATCH 23/24] docs(errors): update idempotency sample to use ValidationError with IDEMPOTENCY_KEY_REQUIRED --- app/docs/errors/page.tsx | 20 ++++++++++---------- components/docs/docs-config.ts | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/docs/errors/page.tsx b/app/docs/errors/page.tsx index 7dd7464..60b4dfa 100644 --- a/app/docs/errors/page.tsx +++ b/app/docs/errors/page.tsx @@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge" const tocItems = [ { id: "catalogo", title: "Catálogo de Excepciones del SDK" }, { id: "conflict-error", title: "ConflictError (409)" }, - { id: "idempotency-error", title: "IdempotencyError" }, + { id: "validation-error", title: "ValidationError (Idempotencia)" }, { id: "not-found-error", title: "NotFoundError (404)" }, { id: "troubleshooting", title: "Troubleshooting de Conexión" }, ] @@ -50,9 +50,9 @@ export default function DocsErrorsPage() { El recurso ya está retenido por otro usuario o sin stock disponible. - IdempotencyError + ValidationError INVALID_ARGUMENT - La plantilla exige clave de idempotencia o se reutilizó una clave con payload diferente. + La plantilla exige clave de idempotencia (reason === 'IDEMPOTENCY_KEY_REQUIRED') o los parámetros son inválidos. NotFoundError @@ -102,19 +102,19 @@ try { />
  • - {/* IdempotencyError */} -
    + {/* ValidationError / Idempotencia */} +

    - Manejo de IdempotencyError + Manejo de ValidationError (Idempotencia)

    - Ocurre cuando una plantilla tiene activada la opción Idempotencia y se envía una solicitud sin idempotencyKey, o cuando se reutiliza una clave existente con parámetros o payload diferente: + Ocurre cuando una plantilla tiene activada la opción Idempotencia y se envía una solicitud sin idempotencyKey (donde error.reason === 'IDEMPOTENCY_KEY_REQUIRED'), o cuando se envían argumentos que no superan las validaciones:

    Date: Mon, 21 Sep 2026 20:40:35 -0300 Subject: [PATCH 24/24] docs(errors): rename NotFoundError to ResourceNotFoundError to match SDK exports --- app/docs/errors/page.tsx | 14 +++++++------- components/docs/docs-config.ts | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/docs/errors/page.tsx b/app/docs/errors/page.tsx index 60b4dfa..04772a5 100644 --- a/app/docs/errors/page.tsx +++ b/app/docs/errors/page.tsx @@ -9,7 +9,7 @@ const tocItems = [ { id: "catalogo", title: "Catálogo de Excepciones del SDK" }, { id: "conflict-error", title: "ConflictError (409)" }, { id: "validation-error", title: "ValidationError (Idempotencia)" }, - { id: "not-found-error", title: "NotFoundError (404)" }, + { id: "resource-not-found-error", title: "ResourceNotFoundError (404)" }, { id: "troubleshooting", title: "Troubleshooting de Conexión" }, ] @@ -55,7 +55,7 @@ export default function DocsErrorsPage() { La plantilla exige clave de idempotencia (reason === 'IDEMPOTENCY_KEY_REQUIRED') o los parámetros son inválidos. - NotFoundError + ResourceNotFoundError NOT_FOUND / 404 El recurso, plantilla o holder especificado no existe o fue borrado lógicamente. @@ -131,10 +131,10 @@ try { />
    - {/* NotFoundError */} -
    + {/* ResourceNotFoundError */} +

    - Manejo de NotFoundError (404) + Manejo de ResourceNotFoundError (404)

    Se produce cuando el recurso, la plantilla asociada o el holderId no existen en el ambiente consultado o fueron eliminados: @@ -143,12 +143,12 @@ try {