diff --git a/Dockerfile b/Dockerfile index 91f38f2..390b159 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine AS base +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS base FROM base AS deps WORKDIR /app @@ -22,4 +22,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/me').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" CMD ["node", "server.js"] diff --git a/README.md b/README.md index abf8a0e..399a266 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ clave en texto plano o SHA256) + allowlist `IT_USERS` (solo esos correos entran) DEPLOYER_URL=http://sam-deployer:3010 DEPLOYER_TOKEN= NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl -IT_USERS=cgaray@norteamericano.cl,fmena@norteamericano.cl,jallende@norteamericano.cl +IT_USERS=usuario1@norteamericano.cl,usuario2@norteamericano.cl IT_SESSION_SECRET= # Cuenta de servicio helpdesk (usuario sige real con rol admin) para # administrar claves de correo desde /correo (solo servidor): @@ -44,9 +44,9 @@ HELPDESK_SERVICE_PASS= ## Desarrollo ```bash -npm install -npm run dev # http://localhost:3000 -npm run lint && npm run build +pnpm install +pnpm dev # http://localhost:3000 +pnpm lint && pnpm typecheck && pnpm test && pnpm build ``` El deploy a producción (.4) es vía push a `main` del repo + rebuild del diff --git a/src/app/api/inventory/route.ts b/src/app/api/inventory/route.ts new file mode 100644 index 0000000..4bbc7cb --- /dev/null +++ b/src/app/api/inventory/route.ts @@ -0,0 +1,46 @@ +// Inventario de SAM.API filtrado server-side: el archivo completo +// (~2400 líneas) no viaja al navegador. Solo lo consume /tablero, +// por lo que exige sesión IT igual que /api/stats. +import { NextRequest, NextResponse } from "next/server"; +import { assertIT } from "@/lib/correo-server"; +import { + API_INVENTORY, + API_TOTAL_CONTROLLERS, + API_TOTAL_ENDPOINTS, + type ApiControllerInfo, +} from "@/lib/api-inventory"; + +const GET_COUNT = API_INVENTORY.reduce( + (acc, c) => acc + c.endpoints.filter((e) => e.method === "GET").length, + 0 +); + +function matches(c: ApiControllerInfo, needle: string): boolean { + return ( + c.controller.toLowerCase().includes(needle) || + c.base.toLowerCase().includes(needle) || + c.endpoints.some((e) => e.path.toLowerCase().includes(needle)) + ); +} + +export async function GET(req: NextRequest) { + try { + await assertIT(req); + } catch { + return NextResponse.json({ error: "Requiere sesión IT" }, { status: 401 }); + } + const q = (req.nextUrl.searchParams.get("q") || "").trim().toLowerCase().slice(0, 64); + const rawLimit = Number(req.nextUrl.searchParams.get("limit") || "12"); + const limit = + Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 100) : 0; + const filtered = q ? API_INVENTORY.filter((c) => matches(c, q)) : API_INVENTORY; + return NextResponse.json({ + totals: { + endpoints: API_TOTAL_ENDPOINTS, + controllers: API_TOTAL_CONTROLLERS, + get: GET_COUNT, + }, + total: filtered.length, + controllers: limit > 0 ? filtered.slice(0, limit) : filtered, + }); +} diff --git a/src/app/api/modules/route.ts b/src/app/api/modules/route.ts index dae333c..2e36028 100644 --- a/src/app/api/modules/route.ts +++ b/src/app/api/modules/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { assertIT } from "@/lib/correo-server"; -import type { ModuleState } from "@/lib/sam"; +import { sanitizeForPublic, type ModuleState } from "@/lib/sam"; const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010"; const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || ""; @@ -21,30 +21,6 @@ function isIT(req: NextRequest): Promise { ); } -// Vista pública: solo módulos visibles y sin datos sensibles -// (repoUrl, logs de deploy). La vista completa queda para sesión IT. -function sanitizeForPublic(modules: ModuleState[]): ModuleState[] { - return modules - .filter((m) => m.visible !== false) - .map((m) => ({ - name: m.name, - repoUrl: "", - branch: m.branch, - subdomain: m.subdomain, - port: m.port, - active: m.active, - category: m.category, - kind: m.kind, - visible: m.visible, - targetPort: m.targetPort, - color: m.color, - lastDeploy: m.lastDeploy - ? { sha: m.lastDeploy.sha, time: m.lastDeploy.time, ok: m.lastDeploy.ok, log: "" } - : undefined, - container: m.container, - })); -} - function validRepoUrl(v: unknown): boolean { if (typeof v !== "string" || v.length === 0 || v.length > 500) return false; let u: URL; diff --git a/src/app/m/[name]/page.tsx b/src/app/m/[name]/page.tsx index 2c0d22c..d5650fd 100644 --- a/src/app/m/[name]/page.tsx +++ b/src/app/m/[name]/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import SamShell from "@/components/SamShell"; +import { Bar } from "@/components/ProgressBar"; import { useModules, useStats, useMe } from "@/lib/store" import { useParams } from "next/navigation"; import { @@ -23,21 +24,6 @@ import { const API_URL = process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl"; -function Bar({ value, color }: { value: number; color: string }) { - return ( -
-
-
- ); -} - interface HelpdeskMember { idUsuario: string; nombres?: string; @@ -260,9 +246,14 @@ function PagosUsage() { } useEffect(() => { - // Carga inicial: fetch async (setState ocurre en callbacks, no sincronico). - // eslint-disable-next-line react-hooks/set-state-in-effect - load(mes, year); + // Carga inicial diferida a un callback: el setState ocurre fuera + // del cuerpo del efecto (regla react-hooks/set-state-in-effect). + // Se ejecuta una sola vez a propósito: los cambios de mes/año + // posteriores van por el submit del formulario, no por re-render. + const t = setTimeout(() => { + load(mes, year); + }, 0); + return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/app/page.tsx b/src/app/page.tsx index 77e1cb7..3ecd4f8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,8 @@ -"use client"; - import Image from "next/image"; import Link from "next/link"; -import { useMemo, type CSSProperties } from "react"; -import { useModules, refreshModules } from "@/lib/store"; +import type { CSSProperties } from "react"; +import { IndexRefresh } from "@/components/IndexRefresh"; +import { getPublicModules } from "@/lib/modules-server"; import { assignColorsForModules, boardHref, @@ -15,24 +14,16 @@ import { moduleUrl, sectionOf, SECTIONS, - visibleModules, type ModuleState, } from "@/lib/sam"; -export default function Home() { - const { data, error } = useModules(); - const loading = data === null && error === null; - const fetchModules = refreshModules; +// Índice público como Server Component: HTML completo en el primer +// pintado (SEO + TTFB) y caché de 15s en el servidor. La +// interactividad (revalidar) vive en . +export default async function Home() { + const { modules, error } = await getPublicModules(); - const modules = useMemo( - () => visibleModules(data?.modules ?? []), - [data] - ); - - // Colores efectivos de lo ya importado: el guardado manda y al resto - // se le genera uno sin repetir, así cada tarjeta queda identificada - // aunque el deployer aún no tenga `color` persistido. - const colorMap = useMemo(() => assignColorsForModules(modules), [modules]); + const colorMap = assignColorsForModules(modules); const bySection: Record = {}; modules.forEach((m) => { @@ -161,29 +152,19 @@ export default function Home() {
- {loading && ( -
-
-

Cargando módulos...

-
- )} - {error && (
- Error: {error} - +
)} - {!loading && !error && ( + {!error && (
{SECTIONS.map((section) => { const list = bySection[section.id] ?? []; @@ -213,12 +194,7 @@ export default function Home() { })}
- - Auto-actualizado cada 15s ·{" "} - { e.preventDefault(); fetchModules(); }}> - actualizar ahora - - +
)} diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index 2233d80..530a698 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -1,12 +1,13 @@ "use client"; -import { useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import SamShell from "@/components/SamShell"; import { useModules, useMe } from "@/lib/store" import { itMenu, assignModuleColor } from "@/lib/sam"; import type { VerifyCheck, VerifyResult } from "@/lib/verify"; import { failedChecks, verifySummary } from "@/lib/verify"; +import { useVerifyPoll, VERIFY_TOTAL } from "@/lib/use-verify-poll"; type Phase = "idle" | "deploying" | "verifying" | "done"; @@ -17,11 +18,6 @@ interface DeployInfo { warnings: string[]; } -const VERIFY_ATTEMPTS = 15; -const VERIFY_DELAY_MS = 8000; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - function StepIcon({ state }: { state: "pending" | "run" | "ok" | "fail" }) { if (state === "run") return ; @@ -57,9 +53,8 @@ export default function UploadPage() { const [moduleName, setModuleName] = useState(""); const [deployInfo, setDeployInfo] = useState(null); const [registerError, setRegisterError] = useState(null); - const [verify, setVerify] = useState(null); - const [attempt, setAttempt] = useState(0); - const cancelled = useRef(false); + const { verify, attempt, cancelled, reset: resetVerify, poll: pollVerify } = + useVerifyPoll(); const handleChange = ( e: React.ChangeEvent @@ -73,39 +68,12 @@ export default function UploadPage() { setForm((f) => ({ ...f, [name]: checked })); }; - const pollVerify = async (name: string, first: VerifyResult | null) => { - let current = first; - if (current) setVerify(current); - for (let i = 0; i < VERIFY_ATTEMPTS; i++) { - if (cancelled.current) return; - // Si el primer resultado ya es ok, igual se confirma contra la URL pública - try { - const res = await fetch(`/api/modules/${encodeURIComponent(name)}/verify`, { - cache: "no-store", - }); - if (res.ok) { - current = (await res.json()) as VerifyResult; - if (!cancelled.current) { - setVerify(current); - setAttempt(i + 1); - } - if (current.ok) return; - } - } catch { - /* reintento best-effort (Caddy/DNS pueden tardar) */ - } - if (i < VERIFY_ATTEMPTS - 1) await sleep(VERIFY_DELAY_MS); - } - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - cancelled.current = false; + resetVerify(); setPhase("deploying"); setRegisterError(null); - setVerify(null); setDeployInfo(null); - setAttempt(0); const name = form.name.trim().toLowerCase(); setModuleName(name); @@ -232,7 +200,7 @@ export default function UploadPage() { Verificación de {moduleName} {phase === "verifying" && ( - comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_ATTEMPTS}) + comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_TOTAL}) )} diff --git a/src/components/IndexRefresh.tsx b/src/components/IndexRefresh.tsx new file mode 100644 index 0000000..4a3447c --- /dev/null +++ b/src/components/IndexRefresh.tsx @@ -0,0 +1,34 @@ +// Isla cliente del índice público: revalidación manual y automática. +// El contenido lo renderiza el Server Component (con caché de 15s). +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export function IndexRefresh({ intervalMs = 15000 }: { intervalMs?: number }) { + const router = useRouter(); + + useEffect(() => { + if (!intervalMs) return; + const tick = () => { + if (!document.hidden) router.refresh(); + }; + const t = setInterval(tick, intervalMs); + return () => clearInterval(t); + }, [router, intervalMs]); + + return ( + + Auto-actualizado cada {Math.round(intervalMs / 1000)}s ·{" "} + { + e.preventDefault(); + router.refresh(); + }} + > + actualizar ahora + + + ); +} diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 0000000..adf6320 --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,16 @@ +// Barra de progreso fina para CPU/MEM. Compartida entre el tablero IT +// y el tablero por módulo (antes duplicada en ambos). +export function Bar({ value, color }: { value: number; color: string }) { + return ( +
+
+
+ ); +} diff --git a/src/components/TableroView.tsx b/src/components/TableroView.tsx index 32e9c13..deecf89 100644 --- a/src/components/TableroView.tsx +++ b/src/components/TableroView.tsx @@ -1,7 +1,8 @@ "use client"; import Link from "next/link"; -import { useMemo, useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import { Bar } from "@/components/ProgressBar"; import { useModules, useStats } from "@/lib/store"; import { assignColorsForModules, @@ -24,11 +25,7 @@ import { type ContainerStat, } from "@/lib/stats"; import type { ModuleState } from "@/lib/sam"; -import { - API_INVENTORY, - API_TOTAL_CONTROLLERS, - API_TOTAL_ENDPOINTS, -} from "@/lib/api-inventory"; +import type { ApiControllerInfo } from "@/lib/api-inventory"; function Donut({ ok, starting, down }: { ok: number; starting: number; down: number }) { const total = ok + starting + down; @@ -92,21 +89,6 @@ function Donut({ ok, starting, down }: { ok: number; starting: number; down: num ); } -function Bar({ value, color }: { value: number; color: string }) { - return ( -
-
-
- ); -} - function AppCard({ m, stat, @@ -188,24 +170,39 @@ function AppCard({ const SAM_API_URL = process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl"; +interface InventoryData { + totals: { endpoints: number; controllers: number; get: number }; + total: number; + controllers: ApiControllerInfo[]; +} + +const EMPTY_INVENTORY: InventoryData = { + totals: { endpoints: 0, controllers: 0, get: 0 }, + total: 0, + controllers: [], +}; + function ApiUsage() { const [q, setQ] = useState(""); const [expanded, setExpanded] = useState(false); - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase(); - if (!needle) return API_INVENTORY; - return API_INVENTORY.filter( - (c) => - c.controller.toLowerCase().includes(needle) || - c.base.toLowerCase().includes(needle) || - c.endpoints.some((e) => e.path.toLowerCase().includes(needle)) - ); - }, [q]); - const shown = expanded ? filtered : filtered.slice(0, 12); - const getCount = API_INVENTORY.reduce( - (acc, c) => acc + c.endpoints.filter((e) => e.method === "GET").length, - 0 - ); + const [data, setData] = useState(EMPTY_INVENTORY); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Búsqueda con debounce: el inventario vive en el servidor. + const t = setTimeout(() => { + const params = new URLSearchParams({ q }); + if (!expanded) params.set("limit", "12"); + fetch(`/api/inventory?${params}`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : EMPTY_INVENTORY)) + .then((j: InventoryData) => setData(j)) + .catch(() => setData(EMPTY_INVENTORY)) + .finally(() => setLoading(false)); + }, 200); + return () => clearTimeout(t); + }, [q, expanded]); + + const shown = data.controllers; return ( <> @@ -218,15 +215,15 @@ function ApiUsage() {
Endpoints reales - {API_TOTAL_ENDPOINTS} + {data.totals.endpoints}
Controladores - {API_TOTAL_CONTROLLERS} + {data.totals.controllers}
Lecturas (GET) - {getCount} + {data.totals.get}
- {filtered.length === 0 && ( + {loading && ( +

Cargando inventario…

+ )} + {!loading && data.total === 0 && (

Sin coincidencias para “{q}”.

)}
- {filtered.length > 12 && ( + {data.total > 12 && ( )} diff --git a/src/lib/correo.ts b/src/lib/correo.ts index 2961c4c..496a8a7 100644 --- a/src/lib/correo.ts +++ b/src/lib/correo.ts @@ -6,6 +6,7 @@ // ============================================================ import { useSyncExternalStore } from "react"; +import { createStore } from "./store"; export interface CorreoModule { module: string; @@ -39,49 +40,26 @@ async function fetchCorreo(): Promise { } } -let snap: CorreoSnap = { data: null, error: null }; -let started = false; -const listeners = new Set<() => void>(); - -function notify() { - listeners.forEach((l) => l()); -} +const correoStore = createStore(fetchCorreo, { + data: null, + error: null, +} as CorreoSnap); export function refreshCorreo() { - fetchCorreo() - .then((next) => { - snap = next; - }) - .catch(() => {}) - .finally(() => notify()); -} - -function subscribe(cb: () => void) { - if (!started) { - started = true; - refreshCorreo(); - } - listeners.add(cb); - return () => { - listeners.delete(cb); - if (listeners.size === 0) started = false; - }; -} - -function snapshot(): CorreoSnap { - return snap; + correoStore.load(); } export function useCorreoModulos(): CorreoSnap { - return useSyncExternalStore(subscribe, snapshot, () => ({ - data: null, - error: null, - })); + return useSyncExternalStore( + correoStore.subscribe, + correoStore.snapshot, + () => ({ data: null, error: null }) + ); } // ============================================================ // Plantillas del banco único mail.templates (GET/POST/PUT vía -// /api/correo/plantillas). Mismo patrón de store que módulos. +// /api/correo/plantillas). Mismo store compartido que módulos. // ============================================================ interface PlantillasSnap { @@ -103,42 +81,19 @@ async function fetchPlantillas(): Promise { } } -let psnap: PlantillasSnap = { data: null, error: null }; -let pstarted = false; -const plisteners = new Set<() => void>(); - -function pnotify() { - plisteners.forEach((l) => l()); -} +const plantillasStore = createStore(fetchPlantillas, { + data: null, + error: null, +} as PlantillasSnap); export function refreshPlantillas() { - fetchPlantillas() - .then((next) => { - psnap = next; - }) - .catch(() => {}) - .finally(() => pnotify()); -} - -function psubscribe(cb: () => void) { - if (!pstarted) { - pstarted = true; - refreshPlantillas(); - } - plisteners.add(cb); - return () => { - plisteners.delete(cb); - if (plisteners.size === 0) pstarted = false; - }; -} - -function psnapshot(): PlantillasSnap { - return psnap; + plantillasStore.load(); } export function useCorreoPlantillas(): PlantillasSnap { - return useSyncExternalStore(psubscribe, psnapshot, () => ({ - data: null, - error: null, - })); + return useSyncExternalStore( + plantillasStore.subscribe, + plantillasStore.snapshot, + () => ({ data: null, error: null }) + ); } diff --git a/src/lib/modules-server.ts b/src/lib/modules-server.ts new file mode 100644 index 0000000..664275f --- /dev/null +++ b/src/lib/modules-server.ts @@ -0,0 +1,29 @@ +// Acceso server-side a los módulos del deployer para Server Components. +// Solo importa este módulo desde componentes de servidor o route handlers: +// contiene el token del deployer (nunca sale al navegador). +import { sanitizeForPublic, type ModuleState } from "./sam"; + +const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010"; +const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || ""; + +export const MODULES_REVALIDATE_S = 15; + +export async function getPublicModules(): Promise<{ + modules: ModuleState[]; + error: string | null; +}> { + try { + const headers: Record = { "Content-Type": "application/json" }; + if (DEPLOYER_TOKEN) headers["X-API-Token"] = DEPLOYER_TOKEN; + const res = await fetch(`${DEPLOYER_URL}/api/modules`, { + headers, + next: { revalidate: MODULES_REVALIDATE_S }, + }); + if (!res.ok) return { modules: [], error: `Deployer respondió ${res.status}` }; + const data = await res.json().catch(() => ({})); + const list: ModuleState[] = Array.isArray(data.modules) ? data.modules : []; + return { modules: sanitizeForPublic(list), error: null }; + } catch { + return { modules: [], error: "No se pudo contactar al deployer" }; + } +} diff --git a/src/lib/sam.ts b/src/lib/sam.ts index 99c366c..1978f75 100644 --- a/src/lib/sam.ts +++ b/src/lib/sam.ts @@ -36,6 +36,29 @@ export function visibleModules(modules: ModuleState[]): ModuleState[] { return modules.filter((m) => m.visible !== false); } +// Vista pública del índice: solo módulos visibles y sin datos +// sensibles (repoUrl, logs de deploy). La comparten la ruta +// /api/modules y el Server Component del índice. +export function sanitizeForPublic(modules: ModuleState[]): ModuleState[] { + return visibleModules(modules).map((m) => ({ + name: m.name, + repoUrl: "", + branch: m.branch, + subdomain: m.subdomain, + port: m.port, + active: m.active, + category: m.category, + kind: m.kind, + visible: m.visible, + targetPort: m.targetPort, + color: m.color, + lastDeploy: m.lastDeploy + ? { sha: m.lastDeploy.sha, time: m.lastDeploy.time, ok: m.lastDeploy.ok, log: "" } + : undefined, + container: m.container, + })); +} + export interface ApiResponse { backend: { repoUrl: string; diff --git a/src/lib/store.ts b/src/lib/store.ts index 71a4156..65f7d44 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -12,11 +12,20 @@ interface ModulesSnap { error: string | null; } -function createStore( +export interface ExternalStore { + subscribe: (cb: () => void) => () => void; + snapshot: () => T; + load: () => void; +} + +// Store externo genérico (useSyncExternalStore): primera carga + +// auto-refresh opcional con `ms`. Sin `ms` solo carga manual vía load(). +// Sin setState en efectos (regla react-hooks). +export function createStore( fetcher: () => Promise, initial: T, - ms: number -): { subscribe: (cb: () => void) => () => void; snapshot: () => T; load: () => void } { + ms?: number +): ExternalStore { let snap = initial; let started = false; let timer: ReturnType | null = null; @@ -37,13 +46,23 @@ function createStore( }; const subscribe = (cb: () => void) => { + const arm = () => { + if (ms !== undefined && !timer) timer = setInterval(load, ms); + }; const start = () => { // No pollear con la pestaña oculta (ahorra requests al deployer). - if (typeof document !== "undefined" && document.hidden) return; + // Los stores manuales (sin ms) cargan igual: es un solo fetch. + if ( + ms !== undefined && + typeof document !== "undefined" && + document.hidden + ) + return; load(); - if (!timer) timer = setInterval(load, ms); + arm(); }; const onVisible = () => { + if (ms === undefined) return; if (document.hidden) { if (timer) { clearInterval(timer); @@ -51,13 +70,13 @@ function createStore( } } else { load(); - if (!timer) timer = setInterval(load, ms); + arm(); } }; if (!started) { started = true; start(); - if (typeof document !== "undefined") { + if (ms !== undefined && typeof document !== "undefined") { document.addEventListener("visibilitychange", onVisible); } } @@ -69,7 +88,7 @@ function createStore( clearInterval(timer); timer = null; } - if (typeof document !== "undefined") { + if (ms !== undefined && typeof document !== "undefined") { document.removeEventListener("visibilitychange", onVisible); } started = false; diff --git a/src/lib/use-verify-poll.ts b/src/lib/use-verify-poll.ts new file mode 100644 index 0000000..a679041 --- /dev/null +++ b/src/lib/use-verify-poll.ts @@ -0,0 +1,55 @@ +// Polling de verificación post-deploy: consulta +// /api/modules/[name]/verify hasta que ok o se acaban los intentos. +// Best-effort (Caddy/DNS pueden tardar en propagar). +import { useRef, useState } from "react"; +import type { VerifyResult } from "./verify"; + +const VERIFY_ATTEMPTS = 15; +const VERIFY_DELAY_MS = 8000; + +export const VERIFY_TOTAL = VERIFY_ATTEMPTS; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export function useVerifyPoll() { + const [verify, setVerify] = useState(null); + const [attempt, setAttempt] = useState(0); + const cancelled = useRef(false); + + const reset = () => { + cancelled.current = false; + setVerify(null); + setAttempt(0); + }; + + const cancel = () => { + cancelled.current = true; + }; + + const poll = async (name: string, first: VerifyResult | null) => { + let current = first; + if (current) setVerify(current); + for (let i = 0; i < VERIFY_ATTEMPTS; i++) { + if (cancelled.current) return; + // Si el primer resultado ya es ok, igual se confirma contra la URL pública + try { + const res = await fetch(`/api/modules/${encodeURIComponent(name)}/verify`, { + cache: "no-store", + }); + if (res.ok) { + current = (await res.json()) as VerifyResult; + if (!cancelled.current) { + setVerify(current); + setAttempt(i + 1); + } + if (current.ok) return; + } + } catch { + /* reintento best-effort (Caddy/DNS pueden tardar) */ + } + if (i < VERIFY_ATTEMPTS - 1) await sleep(VERIFY_DELAY_MS); + } + }; + + return { verify, attempt, cancelled, reset, cancel, poll }; +}