diff --git a/src/app/api/modules/[name]/verify/route.ts b/src/app/api/modules/[name]/verify/route.ts new file mode 100644 index 0000000..cff1379 --- /dev/null +++ b/src/app/api/modules/[name]/verify/route.ts @@ -0,0 +1,183 @@ +import { NextResponse } from "next/server"; +import type { VerifyCheck, VerifyResult } from "@/lib/verify"; + +const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010"; +const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || ""; + +function deployerHeaders(): Record { + const h: Record = { "Content-Type": "application/json" }; + if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN; + return h; +} + +async function fetchWithTimeout( + url: string, + ms: number +): Promise<{ status: number; body: string; contentType: string }> { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), ms); + try { + const res = await fetch(url, { signal: ctrl.signal, redirect: "follow", cache: "no-store" }); + const body = await res.text().catch(() => ""); + return { + status: res.status, + body: body.slice(0, 20000), + contentType: res.headers.get("content-type") || "", + }; + } finally { + clearTimeout(t); + } +} + +// GET /api/modules/[name]/verify — verificación post-importación. +// 1. Intenta el endpoint nuevo del deployer (contenedor + logs + HTTP local). +// 2. Si el deployer es antiguo, reconstruye el resultado con /api/modules + /api/stats. +// 3. Siempre suma el chequeo de URL pública (Caddy + DNS + TLS) para avisar +// a quien importa con el paso exacto que falló. +export async function GET( + _req: Request, + { params }: { params: Promise<{ name: string }> } +) { + const name = (await params).name; + let base: VerifyResult | null = null; + + // --- 1. Deployer nuevo: GET /api/modules/:name/verify --- + try { + const res = await fetch( + `${DEPLOYER_URL}/api/modules/${encodeURIComponent(name)}/verify`, + { headers: deployerHeaders(), cache: "no-store" } + ); + if (res.ok) { + const v = (await res.json()) as VerifyResult; + if (v && Array.isArray(v.checks)) { + base = { ...v, source: "deployer" }; + } + } + } catch { + base = null; + } + + // --- 2. Fallback: reconstruir desde /api/modules + /api/stats --- + let mod: { + name: string; + subdomain: string; + port: number; + kind?: string; + container?: string | null; + lastDeploy?: { ok: boolean; log: string } | null; + } | null = null; + if (!base) { + try { + const res = await fetch(`${DEPLOYER_URL}/api/modules`, { + headers: deployerHeaders(), + cache: "no-store", + }); + if (res.ok) { + const data = await res.json(); + const list = Array.isArray(data.modules) ? data.modules : []; + mod = list.find( + (m: { name: string }) => m.name.toLowerCase() === name.toLowerCase() + ) || null; + } + } catch { + mod = null; + } + if (!mod) { + return NextResponse.json( + { error: `Módulo "${name}" no encontrado en el deployer` }, + { status: 404 } + ); + } + const checks: VerifyCheck[] = []; + if (mod.container) { + const starting = mod.container.includes("starting"); + checks.push({ + id: "container", + label: "Contenedor creado y en ejecución", + ok: !starting, + detail: starting + ? `"${mod.container}" aún iniciando — reintenta en unos segundos` + : `contenedor: ${mod.container}`, + }); + } else { + checks.push({ + id: "container", + label: "Contenedor creado y en ejecución", + ok: false, + detail: "el deployer no reporta contenedor (¿falló el build? revisa el último deploy)", + }); + } + const log = mod.lastDeploy?.log || ""; + const fatal = log.match(/Failed to compile|Application error|EADDRINUSE|Cannot find module|Verificación post-deploy falló.*/i); + checks.push({ + id: "logs", + label: "Logs sin errores", + ok: !fatal, + detail: fatal + ? `el deployLog indica: ${fatal[0].slice(0, 120)}` + : "deployLog sin patrones fatales (deployer antiguo: sin logs de runtime)", + }); + checks.push({ + id: "http-root", + label: "Aplicación responde HTTP 200", + ok: false, + detail: "deployer antiguo sin verificación HTTP: se valida solo la URL pública abajo", + }); + checks.push({ + id: "http-extra", + label: "Contenido válido", + ok: false, + detail: "omitido: el deployer no expuso verificación HTTP", + }); + base = { + ok: false, + module: mod.name, + container: null, + containerStatus: mod.container || null, + httpStatus: null, + checks, + logsTail: log.slice(-2000), + time: new Date().toISOString(), + source: "portal", + }; + } + + // --- 3. Chequeo de URL pública (siempre, portal) --- + const subdomain = + mod?.subdomain || + (base.checks.find((c) => c.id === "caddy")?.detail.match(/https:\/\/([a-z0-9-]+)\./)?.[1] as string) || + name.toLowerCase(); + const publicUrl = `https://${subdomain}.devops.norteamericano.cl/`; + let publicStatus: number | null = null; + const caddyCheck = base.checks.find((c) => c.id === "caddy"); + const wantsRoute = !caddyCheck || !/sin subdominio|no requiere/i.test(caddyCheck.detail); + if (wantsRoute) { + try { + const r = await fetchWithTimeout(publicUrl, 15000); + publicStatus = r.status; + const broken = /Application error|Something went wrong/i.test(r.body); + const okPublic = r.status >= 200 && r.status < 400 && !broken; + base.checks.push({ + id: "public-http", + label: "URL pública accesible", + ok: okPublic, + detail: okPublic + ? `${publicUrl} → ${r.status} (${r.contentType || "ok"})` + : broken + ? `${publicUrl} responde ${r.status} pero el HTML indica error de runtime` + : `${publicUrl} → ${r.status} (¿Caddy/DNS aún propagando? reintenta)`, + }); + if (!okPublic) base.ok = false; + } catch (e) { + base.checks.push({ + id: "public-http", + label: "URL pública accesible", + ok: false, + detail: `${publicUrl} no responde (${e instanceof Error ? e.message : "error de red"} — ¿Caddy/DNS aún propagando?)`, + }); + base.ok = false; + } + } + + return NextResponse.json({ ...base, publicUrl: wantsRoute ? publicUrl : null, publicStatus }); +} diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index 354fd3c..5d662d9 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -1,9 +1,42 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; +import Link from "next/link"; import SamShell from "@/components/SamShell"; import { useModules, useMe } from "@/lib/store" import { itMenu } from "@/lib/sam"; +import type { VerifyCheck, VerifyResult } from "@/lib/verify"; +import { failedChecks, verifySummary } from "@/lib/verify"; + +type Phase = "idle" | "deploying" | "verifying" | "done"; + +interface DeployInfo { + httpStatus: number; + deployLog: string; + webhook: boolean; + 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 ; + if (state === "ok") return ; + if (state === "fail") return ; + return ; +} + +function checkState( + check: VerifyCheck | undefined, + phase: Phase +): "pending" | "run" | "ok" | "fail" { + if (!check) return phase === "verifying" ? "run" : "pending"; + return check.ok ? "ok" : "fail"; +} export default function UploadPage() { const { data } = useModules(); @@ -20,8 +53,13 @@ export default function UploadPage() { targetPort: "", context: "", }); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); + const [phase, setPhase] = useState("idle"); + const [moduleName, setModuleName] = useState(""); + const [deployInfo, setDeployInfo] = useState(null); + const [registerError, setRegisterError] = useState(null); + const [verify, setVerify] = useState(null); + const [attempt, setAttempt] = useState(0); + const cancelled = useRef(false); const handleChange = ( e: React.ChangeEvent @@ -35,13 +73,45 @@ 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(); - setLoading(true); - setResult(null); + cancelled.current = false; + setPhase("deploying"); + setRegisterError(null); + setVerify(null); + setDeployInfo(null); + setAttempt(0); + + const name = form.name.trim().toLowerCase(); + setModuleName(name); const payload: Record = { - name: form.name.trim().toLowerCase(), + name, repoUrl: form.repoUrl.trim(), branch: form.branch.trim() || "main", category: form.category, @@ -59,26 +129,49 @@ export default function UploadPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - const data = await res.json(); - if (res.ok) { - const warns: string[] = Array.isArray(data.dbCheck?.warnings) - ? data.dbCheck.warnings.slice(0, 4) - : []; - setResult({ - ok: true, - message: `Módulo "${payload.name}" registrado. Deploy: ${data.webhook ? "webhook creado" : "sin webhook"}${data.deployLog ? " · " + data.deployLog.slice(0, 80) : ""}${warns.length ? " · DB: " + warns.join(" | ").slice(0, 200) : ""}`, - }); - setForm({ name: "", repoUrl: "", branch: "main", port: "", subdomain: "", category: "otras-apps", kind: "frontend", visible: true, targetPort: "", context: "" }); - } else { - setResult({ ok: false, message: data.error || `Error HTTP ${res.status}` }); + const data = await res.json().catch(() => ({})); + + // Falla de registro (4xx/5xx): el módulo no quedó importado, no hay qué verificar. + if (!res.ok && res.status !== 207) { + setRegisterError( + (data.error as string) || + (Array.isArray(data.dbCheck?.warnings) && data.dbCheck.warnings.length + ? `Config de datos inválida: ${data.dbCheck.warnings.join(" ; ")}` + : `Error HTTP ${res.status}`) + ); + setPhase("done"); + return; } + + setDeployInfo({ + httpStatus: res.status, + deployLog: typeof data.deployLog === "string" ? data.deployLog : "", + webhook: Boolean(data.webhook), + warnings: Array.isArray(data.dbCheck?.warnings) ? data.dbCheck.warnings.slice(0, 5) : [], + }); + setForm({ name: "", repoUrl: "", branch: "main", port: "", subdomain: "", category: "otras-apps", kind: "frontend", visible: true, targetPort: "", context: "" }); + + // 201 o 207: el módulo quedó registrado → verificar contenedor + funcionamiento + // avisando a quien lo sube con el paso exacto si algo falla. + setPhase("verifying"); + const first: VerifyResult | null = + data.verify && Array.isArray(data.verify.checks) + ? { ...data.verify, source: "deployer" } + : null; + await pollVerify(name, first); + if (!cancelled.current) setPhase("done"); } catch (err) { - setResult({ ok: false, message: err instanceof Error ? err.message : "Error de red" }); - } finally { - setLoading(false); + if (!cancelled.current) { + setRegisterError(err instanceof Error ? err.message : "Error de red"); + setPhase("done"); + } } }; + const busy = phase === "deploying" || phase === "verifying"; + const failed = failedChecks(verify); + const verifyFailed = phase === "done" && verify && !verify.ok; + return ( - {result && ( -
- - {result.message} + {/* Aviso directo a quien sube: falla de registro (no se importó) */} + {registerError && ( +
+ + {me?.email ? `${me.email}, la importación falló` : "La importación falló"} + {moduleName && <> al importar {moduleName}}: {registerError} +
+ El módulo no quedó registrado. Corrige el repositorio + (URL, rama, package.json, config de datos) e inténtalo de nuevo. +
+
+ )} + + {/* Progreso de verificación post-importación */} + {(phase === "verifying" || (verify && phase === "done")) && ( +
+
+ + Verificación de {moduleName} + {phase === "verifying" && ( + + comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_ATTEMPTS}) + + )} +
+ {verify && ( + {verifySummary(verify)} + )} +
    +
  • + + + Registro y deploy + + {deployInfo + ? `registrado (HTTP ${deployInfo.httpStatus})${deployInfo.webhook ? " · webhook creado" : " · sin webhook"}` + : "registrando módulo en el deployer…"} + + +
  • + {[ + { id: "container", label: "Contenedor creado y en ejecución" }, + { id: "logs", label: "Logs sin errores fatales" }, + { id: "http-root", label: "Aplicación responde HTTP" }, + { id: "http-extra", label: "Contenido válido (checks extra)" }, + { id: "caddy", label: "Ruta pública (Caddy)" }, + { id: "public-http", label: "URL pública accesible" }, + ].map((s) => { + const check = verify?.checks.find((c) => c.id === s.id); + return ( +
  • + + + {s.label} + + {check ? check.detail : "pendiente…"} + + +
  • + ); + })} +
+ {phase === "verifying" && ( +

+ Puedes seguir en esta página: si algún paso falla se te avisará aquí mismo + con el detalle. El estado también queda en Tablero y{" "} + Actualizar. +

+ )} +
+ )} + + {/* Aviso directo a quien sube: verificación fallida */} + {verifyFailed && ( +
+ + + {me?.email ? `${me.email}, la importación de "${moduleName}" falló` : `La importación de "${moduleName}" falló`} + {" "} + en: {failed.map((c) => c.label).join(", ")}. +
    + {failed.map((c) => ( +
  • + {c.label}: {c.detail} +
  • + ))} +
+ {deployInfo && deployInfo.warnings.length > 0 && ( +
+ Advertencias de config de datos: +
    + {deployInfo.warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+
+ )} + {(verify.logsTail || deployInfo?.deployLog) && ( +
+ Ver logs del deploy +
+                  {(verify.logsTail || deployInfo?.deployLog || "").slice(-3000)}
+                
+
+ )} +
+ El módulo quedó registrado pero no está funcionando. Revisa el + Dockerfile/puerto (NEXT_PUBLIC_API_URL, puerto interno 3000), + corrige el repo y reintenta desde Actualizar o + elimínalo en Eliminar. Su estado también es visible + en el tablero del módulo. +
+
+ )} + + {/* Éxito verificado */} + {phase === "done" && verify?.ok && ( +
+ + Módulo {moduleName} importado y verificado: + contenedor {verify.containerStatus || "running"} + HTTP {verify.httpStatus} + {verify.publicUrl ? <> + {verify.publicUrl} : null}. + + + Abrir aplicación + + + Ver tablero + +
)} @@ -121,6 +340,7 @@ export default function UploadPage() { placeholder="ej: ventas" pattern="^[a-z][a-z0-9-]+$" required + disabled={busy} className="form-control" />
Minúsculas, guiones, empieza con letra.
@@ -136,6 +356,7 @@ export default function UploadPage() { onChange={handleChange} placeholder="https://git.norteamericano.cl/user/mi-proyecto.git" required + disabled={busy} className="form-control" />
Debe ser accesible desde el deployer (https://git.norteamericano.cl).
@@ -170,6 +391,7 @@ export default function UploadPage() { value={form.subdomain} onChange={handleChange} placeholder="ej: ventas (default: nombre)" + disabled={busy} className="form-control" />
https://{form.subdomain || form.name || "subdominio"}.devops.norteamericano.cl
@@ -186,6 +408,7 @@ export default function UploadPage() { placeholder="auto (3011+)" min={3000} max={4000} + disabled={busy} className="form-control" />
Se asigna automáticamente si se deja vacío.
@@ -202,6 +425,7 @@ export default function UploadPage() { placeholder="3000 frontend / 5000 backend" min={1} max={65535} + disabled={busy} className="form-control" />
Puerto que expone el contenedor.
@@ -215,6 +439,7 @@ export default function UploadPage() { value={form.context} onChange={handleChange} placeholder="ej: backend-dotnet" + disabled={busy} className="form-control" />
Carpeta con el Dockerfile (vacío = raíz).
@@ -231,6 +456,7 @@ export default function UploadPage() { value={form.category} onChange={handleChange} required + disabled={busy} className="form-select" > @@ -252,6 +478,7 @@ export default function UploadPage() { value={form.kind} onChange={handleChange} required + disabled={busy} className="form-select" > @@ -272,6 +499,7 @@ export default function UploadPage() { name="visible" checked={form.visible} onChange={handleCheck} + disabled={busy} />
-