feat: índice como Server Component e inventario server-side

El índice público renderiza en el servidor con revalidación de 15s (isla IndexRefresh para revalidar). Nuevo GET /api/inventory con sesión: el inventario de 2400 líneas ya no viaja al navegador. Dedup: ProgressBar compartido, hook useVerifyPoll, createStore genérico reutilizado por correo. Quick wins: Dockerfile fijado por digest + healthcheck, README sin emails reales, fuera eslint-disable en m/[name].
This commit is contained in:
Nurfog committed 2026-10-08 11:21:05 -03:00
1 parent 8f30b1c1ec
commit 926d41dc51
15 files changed
+331 -241

No files matched your search

+3 -1
View File
@@ -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"]
+4 -4
View File
@@ -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=<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=<openssl rand -hex 24>
# 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=<clave>
## 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
+46
View File
@@ -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,
});
}
+1 -25
View File
@@ -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<boolean> {
);
}
// 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;
+9 -18
View File
@@ -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 (
<div className="progress" style={{ height: 8 }}>
<div
className="progress-bar"
role="progressbar"
style={{ width: `${value}%`, backgroundColor: color }}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
);
}
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
}, []);
+14 -38
View File
@@ -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 <IndexRefresh />.
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<string, ModuleState[]> = {};
modules.forEach((m) => {
@@ -161,29 +152,19 @@ export default function Home() {
<div className="row" id="modulos">
<div className="col-lg-1" />
<div className="col-lg-10 mt-4">
{loading && (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status" />
<p className="mt-2 text-muted">Cargando módulos...</p>
</div>
)}
{error && (
<div className="alert alert-danger d-flex align-items-center">
<i className="bx bx-error-circle me-2 fs-5" />
<div>
<strong>Error:</strong> {error}
<button
onClick={fetchModules}
className="btn btn-sm btn-outline-danger ms-3"
>
<strong>Error:</strong> {error}{" "}
<Link href="/" className="btn btn-sm btn-outline-danger ms-3">
Reintentar
</button>
</Link>
</div>
</div>
)}
{!loading && !error && (
{!error && (
<div className="row row-cards">
{SECTIONS.map((section) => {
const list = bySection[section.id] ?? [];
@@ -213,12 +194,7 @@ export default function Home() {
})}
<div className="col-12 mt-4 mb-4 text-center">
<small className="text-muted">
Auto-actualizado cada 15s ·{" "}
<a href="#" onClick={(e) => { e.preventDefault(); fetchModules(); }}>
actualizar ahora
</a>
</small>
<IndexRefresh />
</div>
</div>
)}
+6 -38
View File
@@ -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 <span className="spinner-border spinner-border-sm text-primary" />;
@@ -57,9 +53,8 @@ export default function UploadPage() {
const [moduleName, setModuleName] = useState("");
const [deployInfo, setDeployInfo] = useState<DeployInfo | null>(null);
const [registerError, setRegisterError] = useState<string | null>(null);
const [verify, setVerify] = useState<VerifyResult | null>(null);
const [attempt, setAttempt] = useState(0);
const cancelled = useRef(false);
const { verify, attempt, cancelled, reset: resetVerify, poll: pollVerify } =
useVerifyPoll();
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
@@ -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 <code>{moduleName}</code>
{phase === "verifying" && (
<small className="text-muted fw-normal ms-2">
comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_ATTEMPTS})
comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_TOTAL})
</small>
)}
</h6>
+34
View File
@@ -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 (
<small className="text-muted">
Auto-actualizado cada {Math.round(intervalMs / 1000)}s ·{" "}
<a
href="#"
onClick={(e) => {
e.preventDefault();
router.refresh();
}}
>
actualizar ahora
</a>
</small>
);
}
+16
View File
@@ -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 (
<div className="progress" style={{ height: 8 }}>
<div
className="progress-bar"
role="progressbar"
style={{ width: `${value}%`, backgroundColor: color }}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
);
}
+42 -42
View File
@@ -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 (
<div className="progress" style={{ height: 8 }}>
<div
className="progress-bar"
role="progressbar"
style={{ width: `${value}%`, backgroundColor: color }}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
);
}
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<InventoryData>(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() {
<div className="d-flex gap-4 flex-wrap align-items-center mb-3">
<div>
<small className="text-muted d-block">Endpoints reales</small>
<span className="fw-bold fs-4">{API_TOTAL_ENDPOINTS}</span>
<span className="fw-bold fs-4">{data.totals.endpoints}</span>
</div>
<div>
<small className="text-muted d-block">Controladores</small>
<span className="fw-bold fs-4">{API_TOTAL_CONTROLLERS}</span>
<span className="fw-bold fs-4">{data.totals.controllers}</span>
</div>
<div>
<small className="text-muted d-block">Lecturas (GET)</small>
<span className="fw-bold fs-4">{getCount}</span>
<span className="fw-bold fs-4">{data.totals.get}</span>
</div>
<a
href={`${SAM_API_URL}/swagger`}
@@ -272,16 +269,19 @@ function ApiUsage() {
})}
</tbody>
</table>
{filtered.length === 0 && (
{loading && (
<p className="text-muted small mb-0 mt-2">Cargando inventario…</p>
)}
{!loading && data.total === 0 && (
<p className="text-muted small mb-0 mt-2">Sin coincidencias para “{q}”.</p>
)}
</div>
{filtered.length > 12 && (
{data.total > 12 && (
<button
className="btn btn-sm btn-outline-secondary mt-3"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? "Ver menos" : `Ver todos (${filtered.length})`}
{expanded ? "Ver menos" : `Ver todos (${data.total})`}
</button>
)}
<small className="text-muted d-block mt-3">
+22 -67
View File
@@ -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<CorreoSnap> {
}
}
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<PlantillasSnap> {
}
}
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 })
);
}
+29
View File
@@ -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<string, string> = { "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" };
}
}
+23
View File
@@ -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;
+27 -8
View File
@@ -12,11 +12,20 @@ interface ModulesSnap {
error: string | null;
}
function createStore<T>(
export interface ExternalStore<T> {
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<T>(
fetcher: () => Promise<T>,
initial: T,
ms: number
): { subscribe: (cb: () => void) => () => void; snapshot: () => T; load: () => void } {
ms?: number
): ExternalStore<T> {
let snap = initial;
let started = false;
let timer: ReturnType<typeof setInterval> | null = null;
@@ -37,13 +46,23 @@ function createStore<T>(
};
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<T>(
}
} 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<T>(
clearInterval(timer);
timer = null;
}
if (typeof document !== "undefined") {
if (ms !== undefined && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisible);
}
started = false;
+55
View File
@@ -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<VerifyResult | null>(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 };
}