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 FROM base AS deps
WORKDIR /app 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 COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs USER nextjs
EXPOSE 3000 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"] 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_URL=http://sam-deployer:3010
DEPLOYER_TOKEN=<token> DEPLOYER_TOKEN=<token>
NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl 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> IT_SESSION_SECRET=<openssl rand -hex 24>
# Cuenta de servicio helpdesk (usuario sige real con rol admin) para # Cuenta de servicio helpdesk (usuario sige real con rol admin) para
# administrar claves de correo desde /correo (solo servidor): # administrar claves de correo desde /correo (solo servidor):
@@ -44,9 +44,9 @@ HELPDESK_SERVICE_PASS=<clave>
## Desarrollo ## Desarrollo
```bash ```bash
npm install pnpm install
npm run dev # http://localhost:3000 pnpm dev # http://localhost:3000
npm run lint && npm run build pnpm lint && pnpm typecheck && pnpm test && pnpm build
``` ```
El deploy a producción (.4) es vía push a `main` del repo + rebuild del 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 { NextRequest, NextResponse } from "next/server";
import { assertIT } from "@/lib/correo-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_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || ""; 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 { function validRepoUrl(v: unknown): boolean {
if (typeof v !== "string" || v.length === 0 || v.length > 500) return false; if (typeof v !== "string" || v.length === 0 || v.length > 500) return false;
let u: URL; let u: URL;
+9 -18
View File
@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import SamShell from "@/components/SamShell"; import SamShell from "@/components/SamShell";
import { Bar } from "@/components/ProgressBar";
import { useModules, useStats, useMe } from "@/lib/store" import { useModules, useStats, useMe } from "@/lib/store"
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import {
@@ -23,21 +24,6 @@ import {
const API_URL = const API_URL =
process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl"; 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 { interface HelpdeskMember {
idUsuario: string; idUsuario: string;
nombres?: string; nombres?: string;
@@ -260,9 +246,14 @@ function PagosUsage() {
} }
useEffect(() => { useEffect(() => {
// Carga inicial: fetch async (setState ocurre en callbacks, no sincronico). // Carga inicial diferida a un callback: el setState ocurre fuera
// eslint-disable-next-line react-hooks/set-state-in-effect // del cuerpo del efecto (regla react-hooks/set-state-in-effect).
load(mes, year); // 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
+14 -38
View File
@@ -1,9 +1,8 @@
"use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMemo, type CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useModules, refreshModules } from "@/lib/store"; import { IndexRefresh } from "@/components/IndexRefresh";
import { getPublicModules } from "@/lib/modules-server";
import { import {
assignColorsForModules, assignColorsForModules,
boardHref, boardHref,
@@ -15,24 +14,16 @@ import {
moduleUrl, moduleUrl,
sectionOf, sectionOf,
SECTIONS, SECTIONS,
visibleModules,
type ModuleState, type ModuleState,
} from "@/lib/sam"; } from "@/lib/sam";
export default function Home() { // Índice público como Server Component: HTML completo en el primer
const { data, error } = useModules(); // pintado (SEO + TTFB) y caché de 15s en el servidor. La
const loading = data === null && error === null; // interactividad (revalidar) vive en <IndexRefresh />.
const fetchModules = refreshModules; export default async function Home() {
const { modules, error } = await getPublicModules();
const modules = useMemo( const colorMap = assignColorsForModules(modules);
() => 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 bySection: Record<string, ModuleState[]> = {}; const bySection: Record<string, ModuleState[]> = {};
modules.forEach((m) => { modules.forEach((m) => {
@@ -161,29 +152,19 @@ export default function Home() {
<div className="row" id="modulos"> <div className="row" id="modulos">
<div className="col-lg-1" /> <div className="col-lg-1" />
<div className="col-lg-10 mt-4"> <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 && ( {error && (
<div className="alert alert-danger d-flex align-items-center"> <div className="alert alert-danger d-flex align-items-center">
<i className="bx bx-error-circle me-2 fs-5" /> <i className="bx bx-error-circle me-2 fs-5" />
<div> <div>
<strong>Error:</strong> {error} <strong>Error:</strong> {error}{" "}
<button <Link href="/" className="btn btn-sm btn-outline-danger ms-3">
onClick={fetchModules}
className="btn btn-sm btn-outline-danger ms-3"
>
Reintentar Reintentar
</button> </Link>
</div> </div>
</div> </div>
)} )}
{!loading && !error && ( {!error && (
<div className="row row-cards"> <div className="row row-cards">
{SECTIONS.map((section) => { {SECTIONS.map((section) => {
const list = bySection[section.id] ?? []; const list = bySection[section.id] ?? [];
@@ -213,12 +194,7 @@ export default function Home() {
})} })}
<div className="col-12 mt-4 mb-4 text-center"> <div className="col-12 mt-4 mb-4 text-center">
<small className="text-muted"> <IndexRefresh />
Auto-actualizado cada 15s ·{" "}
<a href="#" onClick={(e) => { e.preventDefault(); fetchModules(); }}>
actualizar ahora
</a>
</small>
</div> </div>
</div> </div>
)} )}
+6 -38
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
import { useMemo, useRef, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import SamShell from "@/components/SamShell"; import SamShell from "@/components/SamShell";
import { useModules, useMe } from "@/lib/store" import { useModules, useMe } from "@/lib/store"
import { itMenu, assignModuleColor } from "@/lib/sam"; import { itMenu, assignModuleColor } from "@/lib/sam";
import type { VerifyCheck, VerifyResult } from "@/lib/verify"; import type { VerifyCheck, VerifyResult } from "@/lib/verify";
import { failedChecks, verifySummary } from "@/lib/verify"; import { failedChecks, verifySummary } from "@/lib/verify";
import { useVerifyPoll, VERIFY_TOTAL } from "@/lib/use-verify-poll";
type Phase = "idle" | "deploying" | "verifying" | "done"; type Phase = "idle" | "deploying" | "verifying" | "done";
@@ -17,11 +18,6 @@ interface DeployInfo {
warnings: string[]; 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" }) { function StepIcon({ state }: { state: "pending" | "run" | "ok" | "fail" }) {
if (state === "run") if (state === "run")
return <span className="spinner-border spinner-border-sm text-primary" />; return <span className="spinner-border spinner-border-sm text-primary" />;
@@ -57,9 +53,8 @@ export default function UploadPage() {
const [moduleName, setModuleName] = useState(""); const [moduleName, setModuleName] = useState("");
const [deployInfo, setDeployInfo] = useState<DeployInfo | null>(null); const [deployInfo, setDeployInfo] = useState<DeployInfo | null>(null);
const [registerError, setRegisterError] = useState<string | null>(null); const [registerError, setRegisterError] = useState<string | null>(null);
const [verify, setVerify] = useState<VerifyResult | null>(null); const { verify, attempt, cancelled, reset: resetVerify, poll: pollVerify } =
const [attempt, setAttempt] = useState(0); useVerifyPoll();
const cancelled = useRef(false);
const handleChange = ( const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
@@ -73,39 +68,12 @@ export default function UploadPage() {
setForm((f) => ({ ...f, [name]: checked })); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
cancelled.current = false; resetVerify();
setPhase("deploying"); setPhase("deploying");
setRegisterError(null); setRegisterError(null);
setVerify(null);
setDeployInfo(null); setDeployInfo(null);
setAttempt(0);
const name = form.name.trim().toLowerCase(); const name = form.name.trim().toLowerCase();
setModuleName(name); setModuleName(name);
@@ -232,7 +200,7 @@ export default function UploadPage() {
Verificación de <code>{moduleName}</code> Verificación de <code>{moduleName}</code>
{phase === "verifying" && ( {phase === "verifying" && (
<small className="text-muted fw-normal ms-2"> <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> </small>
)} )}
</h6> </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"; "use client";
import Link from "next/link"; 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 { useModules, useStats } from "@/lib/store";
import { import {
assignColorsForModules, assignColorsForModules,
@@ -24,11 +25,7 @@ import {
type ContainerStat, type ContainerStat,
} from "@/lib/stats"; } from "@/lib/stats";
import type { ModuleState } from "@/lib/sam"; import type { ModuleState } from "@/lib/sam";
import { import type { ApiControllerInfo } from "@/lib/api-inventory";
API_INVENTORY,
API_TOTAL_CONTROLLERS,
API_TOTAL_ENDPOINTS,
} from "@/lib/api-inventory";
function Donut({ ok, starting, down }: { ok: number; starting: number; down: number }) { function Donut({ ok, starting, down }: { ok: number; starting: number; down: number }) {
const total = ok + starting + down; 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({ function AppCard({
m, m,
stat, stat,
@@ -188,24 +170,39 @@ function AppCard({
const SAM_API_URL = const SAM_API_URL =
process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl"; 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() { function ApiUsage() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const filtered = useMemo(() => { const [data, setData] = useState<InventoryData>(EMPTY_INVENTORY);
const needle = q.trim().toLowerCase(); const [loading, setLoading] = useState(true);
if (!needle) return API_INVENTORY;
return API_INVENTORY.filter( useEffect(() => {
(c) => // Búsqueda con debounce: el inventario vive en el servidor.
c.controller.toLowerCase().includes(needle) || const t = setTimeout(() => {
c.base.toLowerCase().includes(needle) || const params = new URLSearchParams({ q });
c.endpoints.some((e) => e.path.toLowerCase().includes(needle)) if (!expanded) params.set("limit", "12");
); fetch(`/api/inventory?${params}`, { cache: "no-store" })
}, [q]); .then((res) => (res.ok ? res.json() : EMPTY_INVENTORY))
const shown = expanded ? filtered : filtered.slice(0, 12); .then((j: InventoryData) => setData(j))
const getCount = API_INVENTORY.reduce( .catch(() => setData(EMPTY_INVENTORY))
(acc, c) => acc + c.endpoints.filter((e) => e.method === "GET").length, .finally(() => setLoading(false));
0 }, 200);
); return () => clearTimeout(t);
}, [q, expanded]);
const shown = data.controllers;
return ( return (
<> <>
@@ -218,15 +215,15 @@ function ApiUsage() {
<div className="d-flex gap-4 flex-wrap align-items-center mb-3"> <div className="d-flex gap-4 flex-wrap align-items-center mb-3">
<div> <div>
<small className="text-muted d-block">Endpoints reales</small> <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>
<div> <div>
<small className="text-muted d-block">Controladores</small> <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>
<div> <div>
<small className="text-muted d-block">Lecturas (GET)</small> <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> </div>
<a <a
href={`${SAM_API_URL}/swagger`} href={`${SAM_API_URL}/swagger`}
@@ -272,16 +269,19 @@ function ApiUsage() {
})} })}
</tbody> </tbody>
</table> </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> <p className="text-muted small mb-0 mt-2">Sin coincidencias para “{q}”.</p>
)} )}
</div> </div>
{filtered.length > 12 && ( {data.total > 12 && (
<button <button
className="btn btn-sm btn-outline-secondary mt-3" className="btn btn-sm btn-outline-secondary mt-3"
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
> >
{expanded ? "Ver menos" : `Ver todos (${filtered.length})`} {expanded ? "Ver menos" : `Ver todos (${data.total})`}
</button> </button>
)} )}
<small className="text-muted d-block mt-3"> <small className="text-muted d-block mt-3">
+22 -67
View File
@@ -6,6 +6,7 @@
// ============================================================ // ============================================================
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { createStore } from "./store";
export interface CorreoModule { export interface CorreoModule {
module: string; module: string;
@@ -39,49 +40,26 @@ async function fetchCorreo(): Promise<CorreoSnap> {
} }
} }
let snap: CorreoSnap = { data: null, error: null }; const correoStore = createStore(fetchCorreo, {
let started = false; data: null,
const listeners = new Set<() => void>(); error: null,
} as CorreoSnap);
function notify() {
listeners.forEach((l) => l());
}
export function refreshCorreo() { export function refreshCorreo() {
fetchCorreo() correoStore.load();
.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;
} }
export function useCorreoModulos(): CorreoSnap { export function useCorreoModulos(): CorreoSnap {
return useSyncExternalStore(subscribe, snapshot, () => ({ return useSyncExternalStore(
data: null, correoStore.subscribe,
error: null, correoStore.snapshot,
})); () => ({ data: null, error: null })
);
} }
// ============================================================ // ============================================================
// Plantillas del banco único mail.templates (GET/POST/PUT vía // 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 { interface PlantillasSnap {
@@ -103,42 +81,19 @@ async function fetchPlantillas(): Promise<PlantillasSnap> {
} }
} }
let psnap: PlantillasSnap = { data: null, error: null }; const plantillasStore = createStore(fetchPlantillas, {
let pstarted = false; data: null,
const plisteners = new Set<() => void>(); error: null,
} as PlantillasSnap);
function pnotify() {
plisteners.forEach((l) => l());
}
export function refreshPlantillas() { export function refreshPlantillas() {
fetchPlantillas() plantillasStore.load();
.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;
} }
export function useCorreoPlantillas(): PlantillasSnap { export function useCorreoPlantillas(): PlantillasSnap {
return useSyncExternalStore(psubscribe, psnapshot, () => ({ return useSyncExternalStore(
data: null, plantillasStore.subscribe,
error: null, 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); 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 { export interface ApiResponse {
backend: { backend: {
repoUrl: string; repoUrl: string;
+27 -8
View File
@@ -12,11 +12,20 @@ interface ModulesSnap {
error: string | null; 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>, fetcher: () => Promise<T>,
initial: T, initial: T,
ms: number ms?: number
): { subscribe: (cb: () => void) => () => void; snapshot: () => T; load: () => void } { ): ExternalStore<T> {
let snap = initial; let snap = initial;
let started = false; let started = false;
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
@@ -37,13 +46,23 @@ function createStore<T>(
}; };
const subscribe = (cb: () => void) => { const subscribe = (cb: () => void) => {
const arm = () => {
if (ms !== undefined && !timer) timer = setInterval(load, ms);
};
const start = () => { const start = () => {
// No pollear con la pestaña oculta (ahorra requests al deployer). // 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(); load();
if (!timer) timer = setInterval(load, ms); arm();
}; };
const onVisible = () => { const onVisible = () => {
if (ms === undefined) return;
if (document.hidden) { if (document.hidden) {
if (timer) { if (timer) {
clearInterval(timer); clearInterval(timer);
@@ -51,13 +70,13 @@ function createStore<T>(
} }
} else { } else {
load(); load();
if (!timer) timer = setInterval(load, ms); arm();
} }
}; };
if (!started) { if (!started) {
started = true; started = true;
start(); start();
if (typeof document !== "undefined") { if (ms !== undefined && typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisible); document.addEventListener("visibilitychange", onVisible);
} }
} }
@@ -69,7 +88,7 @@ function createStore<T>(
clearInterval(timer); clearInterval(timer);
timer = null; timer = null;
} }
if (typeof document !== "undefined") { if (ms !== undefined && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisible); document.removeEventListener("visibilitychange", onVisible);
} }
started = false; 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 };
}