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:
1 parent
8f30b1c1ec
commit
926d41dc51
15 files changed
+331
-241
No files matched your search
+3
-1
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user