diff --git a/.gitignore b/.gitignore index 9e4caf0..e36eb0b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ node_modules .vercel .claude/ .env* -.codegraph/ +package-lock.json +pnpm-workspace.yaml diff --git a/app/dashboard/api-keys/page.tsx b/app/dashboard/api-keys/page.tsx index 8b26973..f3a2a4c 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 @@ -379,7 +379,7 @@ export default function ApiKeysPage() { Revocar API Key - ¿Estás seguro que deseas revocar la API Key con prefijo{" "} + ¿Estás seguro de que deseas revocar la API Key con prefijo{" "} {keyToRevoke?.keyPrefix}••••? Esta acción es irreversible y los clientes que usen esta clave ya no podrán autenticarse. 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]/page.tsx b/app/dashboard/applications/[id]/page.tsx index 14b3ddc..d8e0b66 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 @@ -829,7 +829,7 @@ export default function ApplicationDashboard({ Eliminar Plantilla de Recurso - ¿Estás seguro que deseas eliminar la plantilla de recurso{" "} + ¿Estás seguro de que deseas eliminar la plantilla de recurso{" "} {templateToDelete?.name}? Esta acción es irreversible y revocaría el acceso a este recurso en el motor SRE. @@ -857,7 +857,7 @@ export default function ApplicationDashboard({ Revocar API Key - ¿Estás seguro que deseas revocar la API Key con prefijo{" "} + ¿Estás seguro de que deseas revocar la API Key con prefijo{" "} {keyToRevoke?.keyPrefix}••••? Esta acción es irreversible y los clientes que usen esta clave ya no podrán autenticarse. @@ -917,7 +917,7 @@ export default function ApplicationDashboard({ Eliminar Plantilla de Lock - ¿Estás seguro que deseas eliminar la plantilla de lock{" "} + ¿Estás seguro de que deseas eliminar la plantilla de lock{" "} {lockToDelete?.namespace}? Esta acción es irreversible. @@ -967,7 +967,7 @@ export default function ApplicationDashboard({ Eliminar Webhook - ¿Estás seguro que deseas eliminar el webhook con URL{" "} + ¿Estás seguro de que deseas eliminar el webhook con URL{" "} {webhookToDelete?.url}? Esta acción es irreversible. @@ -999,7 +999,7 @@ export default function ApplicationDashboard({ Rotar Secreto de Webhook - ¿Estás seguro que deseas rotar el secreto del webhook con URL{" "} + ¿Estás seguro de que deseas rotar el secreto del webhook con URL{" "} {webhookToRotate?.url}? El secreto anterior se invalidará inmediatamente y las nuevas peticiones se firmarán con el nuevo. 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/app/dashboard/applications/[id]/settings/page.tsx b/app/dashboard/applications/[id]/settings/page.tsx index 6e1108d..ee0c25d 100644 --- a/app/dashboard/applications/[id]/settings/page.tsx +++ b/app/dashboard/applications/[id]/settings/page.tsx @@ -244,17 +244,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" })) @@ -664,7 +663,7 @@ export default function ApplicationSettingsPage({ Eliminar Ambiente - ¿Estás seguro que deseas eliminar el ambiente{" "} + ¿Estás seguro de que deseas eliminar el ambiente{" "} {selectedEnvForDelete?.name}? Esta acción es irreversible y eliminará todos los recursos, locks y API keys asociados a este ambiente. diff --git a/app/dashboard/applications/[id]/team/page.tsx b/app/dashboard/applications/[id]/team/page.tsx index ab72af1..c9b4574 100644 --- a/app/dashboard/applications/[id]/team/page.tsx +++ b/app/dashboard/applications/[id]/team/page.tsx @@ -63,7 +63,7 @@ interface Invitation { const roleLabels: Record = { OWNER: { label: "Propietario", color: "bg-purple-500/20 text-purple-400 border-purple-500/30" }, ADMIN: { label: "Administrador", color: "bg-blue-500/20 text-blue-400 border-blue-500/30" }, - VIEWER: { label: "Visor", color: "bg-gray-500/20 text-gray-400 border-gray-500/30" }, + VIEWER: { label: "Lector", color: "bg-gray-500/20 text-gray-400 border-gray-500/30" }, } export default function TeamPage({ @@ -394,7 +394,7 @@ export default function TeamPage({
- Visor (Solo lectura) + Lector (Solo lectura)
@@ -574,7 +574,7 @@ export default function TeamPage({ Remover Colaborador - ¿Estás seguro que deseas remover a{" "} + ¿Estás seguro de que deseas remover a{" "} {collaboratorToRemove?.email} {" "} @@ -632,7 +632,7 @@ export default function TeamPage({ Administrador - Visor (Solo lectura) + Lector (Solo lectura) diff --git a/app/dashboard/applications/page.tsx b/app/dashboard/applications/page.tsx index 0d56015..3f44e94 100644 --- a/app/dashboard/applications/page.tsx +++ b/app/dashboard/applications/page.tsx @@ -72,7 +72,7 @@ const getRoleBadge = (role: string) => { default: return ( - Visor + Lector ); } @@ -373,7 +373,7 @@ export default function ApplicationsPage() { Eliminar Aplicación - ¿Estás seguro que deseas eliminar la aplicación{" "} + ¿Estás seguro de que deseas eliminar la aplicación{" "} {appToDelete?.name} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 32099e1..54fd412 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -133,14 +133,14 @@ export default async function DashboardPage() { case "ADMIN": return ( - Colaborador (Admin) + Administrador ); case "VIEWER": default: return ( - Colaborador (Viewer) + Lector ); } @@ -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/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 -

-
- -
diff --git a/app/dashboard/usage/page.tsx b/app/dashboard/usage/page.tsx index 1563cce..02d9eda 100644 --- a/app/dashboard/usage/page.tsx +++ b/app/dashboard/usage/page.tsx @@ -1,9 +1,25 @@ "use client" -import { useState } from "react" -import { BarChart3, Activity, Calendar, Download, Filter, TrendingUp, TrendingDown } from "lucide-react" +import { useState, useEffect } from "react" +import Link from "next/link" +import { format, subDays } from "date-fns" +import { + BarChart3, + Activity, + Calendar, + Download, + Filter, + Zap, + ArrowUpRight, + Clock, + AlertTriangle, + AlertCircle, + RefreshCw, +} 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,58 +35,181 @@ 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, error: userError, refreshUser } = useUser() + const { applications, isAppsLoading } = useApps() + + const consumedUnits = user?.billingUsage?.consumedUnits ?? 0 + const includedUnits = + user?.billingUsage?.includedUnits ?? user?.billingPlan?.includedBillingUnits ?? 0 + 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) + + const [dailyUsageData, setDailyUsageData] = useState<{ date: string; calls: number }[]>([]) + const [appUsageData, setAppUsageData] = useState>({}) + 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[] = [] + 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 (app.name) { + envToAppMap[env.id] = app.name + } + } + } + } + } + + const statsByDate: Record = {} + const appUsageCount: Record = { ...initialAppUsage } + + 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) { + const data = await res.json() + return { envId, data } + } + } catch { + return { envId, data: [] } + } + return { envId, data: [] } + }) + + const results = await Promise.allSettled(statsPromises) + for (const res of results) { + 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 + } + } + } + } + } + } - const totalCalls = 0 - const planLimit = 125000 - const usagePercentage = 0 - const isNearLimit = false + // 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, + }) + } - // Generamos datos vacíos de manera dinámica basados en timeRange - 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, - locks: 0 - }) + if (isMounted) { + setDailyUsageData(points) + setAppUsageData(appUsageCount) + } + } catch (err) { + console.error("Error loading usage chart data:", err) + } finally { + if (isMounted) { + setIsChartLoading(false) + } + } } - 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" + loadDailyUsage() + + return () => { + isMounted = false } - } + }, [timeRange]) - const getEventColor = (event: string) => { - if (event.includes("acquired") || event.includes("confirmed") || event.includes("created")) { - return "text-primary" + const totalDailyCalls = dailyUsageData.reduce((acc, curr) => acc + curr.calls, 0) + + const handleExport = () => { + if (!user) { + toast.error("No hay datos de facturación disponibles para exportar") + return } - if (event.includes("expired") || event.includes("timeout")) { - return "text-chart-4" + try { + const rows = [ + ["Concepto", "Valor"], + ["Período", period], + ["Plan", currentPlan?.name || "Sin plan"], + ["Código de Plan", currentPlan?.code || "-"], + ["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 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") } - return "text-muted-foreground" } return ( @@ -78,94 +217,177 @@ 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

-
- {/* 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 */} +
- - API Calls (30d) + + Requests Consumidas + -
- 84.2k - - - +12.3% - -
+ {isUserLoading ? ( + + ) : ( +
+
+ {consumedUnits.toLocaleString()} +
+

+ Período {period} +

+
+ )}
- - Active Locks + + Plan Activo + -
- 12 - - - -2 - -
+ {isUserLoading ? ( + + ) : ( +
+
+ + {currentPlan?.name || "Sin plan"} + + {currentPlan?.code && ( + + {currentPlan.code} + + )} +
+
+ + 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,51 +397,80 @@ 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 -
- - - - - - - - - - - - - - - -
+ {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"}. +

+
+ )}
@@ -229,48 +480,93 @@ export default function UsagePage() { - 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 || isChartLoading ? ( +
+ + +
+ ) : applications.length === 0 ? ( +
+

No tienes aplicaciones registradas todavía.

+

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

+ +
+ ) : ( +
+ {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 + )} +
- )) - )} + ) + })} +

+ {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."} +

+ )} @@ -279,42 +575,36 @@ 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í. +

+ + )}
) } 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..04772a5 --- /dev/null +++ b/app/docs/errors/page.tsx @@ -0,0 +1,198 @@ +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: "validation-error", title: "ValidationError (Idempotencia)" }, + { id: "resource-not-found-error", title: "ResourceNotFoundError (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: +

+ + +
+ + {/* ValidationError / Idempotencia */} +
+

+ Manejo de ValidationError (Idempotencia) +

+

+ 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: +

+ + +
+ + {/* ResourceNotFoundError */} +
+

+ Manejo de ResourceNotFoundError (404) +

+

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

+ + +
+ + {/* 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..a4bf515 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: "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)" }, +] +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/app/settings/billing/page.tsx b/app/settings/billing/page.tsx index 8f56aa7..9668f33 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.
- + Configurar diff --git a/components/dashboard/applications/tabs/manual-control-tab.tsx b/components/dashboard/applications/tabs/manual-control-tab.tsx index 160e162..eb29996 100644 --- a/components/dashboard/applications/tabs/manual-control-tab.tsx +++ b/components/dashboard/applications/tabs/manual-control-tab.tsx @@ -1376,7 +1376,7 @@ export function ManualControlTab({ )} {isViewer && (

- Modo solo lectura: Tu rol de VIEWER no permite ejecutar acciones de control. + Modo solo lectura: Tu rol de Lector no permite ejecutar acciones de control.

)}
diff --git a/components/dashboard/applications/tabs/metrics-tab.tsx b/components/dashboard/applications/tabs/metrics-tab.tsx index a6d9e06..6f92cb0 100644 --- a/components/dashboard/applications/tabs/metrics-tab.tsx +++ b/components/dashboard/applications/tabs/metrics-tab.tsx @@ -281,7 +281,7 @@ export function MetricsTab({ appId, selectedEnv, currentEnvDetails }: MetricsTab key: "totalBillingUnits", label: "Unidades de Facturación", color: "var(--chart-5)", - description: "Cantidad total de unidades de facturación generadas por el uso de la plataforma en este periodo." + description: "Cantidad total de unidades de facturación generadas por el uso de la plataforma en este período." }} data={statsData} /> 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
diff --git a/components/dashboard/lock-form.tsx b/components/dashboard/lock-form.tsx index b89e9cb..eb68595 100644 --- a/components/dashboard/lock-form.tsx +++ b/components/dashboard/lock-form.tsx @@ -1,13 +1,24 @@ "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" import { ArrowLeft, Lock, 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, @@ -56,9 +67,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 +127,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) @@ -117,12 +137,11 @@ export function LockForm({ applicationId, environmentId, lockId, initialData, is } async function handleDelete() { - if (!confirm("¿Estás seguro de que deseas eliminar este lock?")) return setIsSubmitting(true) 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 +154,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 )}
- + + + + + + + ¿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 )}
- + +
+          
+            {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..c66cd0a --- /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, ValidationError, ResourceNotFoundError) y códigos HTTP/gRPC.", + keywords: ["errores", "conflicterror", "validationerror", "idempotencia", "resourcenotfounderror", "troubleshooting", "fallas"], + }, + ], + }, + ], +} diff --git a/components/docs/docs-header.tsx b/components/docs/docs-header.tsx new file mode 100644 index 0000000..442f2bd --- /dev/null +++ b/components/docs/docs-header.tsx @@ -0,0 +1,115 @@ +"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 */} +
+ + + + + + + + setSheetOpen(false)} + className="flex items-center gap-2 hover:opacity-90 transition-opacity" + > + Caerus + Documentación + + + + { + setSheetOpen(false) + onSearchClick() + }} + onNavigate={() => setSheetOpen(false)} + /> + + + + + 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..b8ad7a9 --- /dev/null +++ b/components/docs/docs-sidebar.tsx @@ -0,0 +1,114 @@ +"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 + onNavigate?: () => void + className?: string +} + +export function DocsSidebar({ onSearchClick, onNavigate, 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. +

+ +
+
+ ) +} 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() {

- 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 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 */} diff --git a/components/landing/use-cases-section.tsx b/components/landing/use-cases-section.tsx index eac71a1..5b692f4 100644 --- a/components/landing/use-cases-section.tsx +++ b/components/landing/use-cases-section.tsx @@ -26,7 +26,7 @@ const useCases = [ icon: CreditCard, title: "Operaciones Financieras", description: - "Garantiza que operaciones criticas como pagos o transferencias se ejecuten una sola vez, sin duplicados.", + "Garantiza que operaciones críticas como pagos o transferencias se ejecuten una sola vez, sin duplicados.", example: "Fintech, Bancos, Pasarelas de Pago", }, ] 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