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
|
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"]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { 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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
"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
@@ -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 })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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);
|
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
@@ -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;
|
||||||
|
|||||||
@@ -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