feat: verificación post-importación con aviso detallado de fallas

This commit is contained in:
Nurfog committed 2026-09-10 12:17:37 -03:00
1 parent 33d2cc2bcb
commit 16940d5f99
3 files changed
+493 -26

No files matched your search

+183
View File
@@ -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<string, string> {
const h: Record<string, string> = { "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 });
}
+260 -26
View File
@@ -1,9 +1,42 @@
"use client"; "use client";
import { useState } from "react"; import { useRef, useState } from "react";
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 } from "@/lib/sam"; 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 <span className="spinner-border spinner-border-sm text-primary" />;
if (state === "ok") return <i className="bx bx-check-circle text-success fs-5" />;
if (state === "fail") return <i className="bx bx-x-circle text-danger fs-5" />;
return <i className="bx bx-circle text-muted fs-5" />;
}
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() { export default function UploadPage() {
const { data } = useModules(); const { data } = useModules();
@@ -20,8 +53,13 @@ export default function UploadPage() {
targetPort: "", targetPort: "",
context: "", context: "",
}); });
const [loading, setLoading] = useState(false); const [phase, setPhase] = useState<Phase>("idle");
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); 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 handleChange = ( const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
@@ -35,13 +73,45 @@ 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();
setLoading(true); cancelled.current = false;
setResult(null); setPhase("deploying");
setRegisterError(null);
setVerify(null);
setDeployInfo(null);
setAttempt(0);
const name = form.name.trim().toLowerCase();
setModuleName(name);
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
name: form.name.trim().toLowerCase(), name,
repoUrl: form.repoUrl.trim(), repoUrl: form.repoUrl.trim(),
branch: form.branch.trim() || "main", branch: form.branch.trim() || "main",
category: form.category, category: form.category,
@@ -59,26 +129,49 @@ export default function UploadPage() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const data = await res.json(); const data = await res.json().catch(() => ({}));
if (res.ok) {
const warns: string[] = Array.isArray(data.dbCheck?.warnings) // Falla de registro (4xx/5xx): el módulo no quedó importado, no hay qué verificar.
? data.dbCheck.warnings.slice(0, 4) if (!res.ok && res.status !== 207) {
: []; setRegisterError(
setResult({ (data.error as string) ||
ok: true, (Array.isArray(data.dbCheck?.warnings) && data.dbCheck.warnings.length
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) : ""}`, ? `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: "" }); 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}` }); // 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) { } catch (err) {
setResult({ ok: false, message: err instanceof Error ? err.message : "Error de red" }); if (!cancelled.current) {
} finally { setRegisterError(err instanceof Error ? err.message : "Error de red");
setLoading(false); setPhase("done");
}
} }
}; };
const busy = phase === "deploying" || phase === "verifying";
const failed = failedChecks(verify);
const verifyFailed = phase === "done" && verify && !verify.ok;
return ( return (
<SamShell <SamShell
brand="IT" brand="IT"
@@ -101,10 +194,136 @@ export default function UploadPage() {
actualiza el módulo vía webhook. actualiza el módulo vía webhook.
</p> </p>
{result && ( {/* Aviso directo a quien sube: falla de registro (no se importó) */}
<div className={`alert ${result.ok ? "alert-success" : "alert-danger"}`}> {registerError && (
<i className={`bx ${result.ok ? "bx-check-circle" : "bx-error-circle"} me-2`} /> <div className="alert alert-danger">
{result.message} <i className="bx bx-error-circle me-2" />
<strong>{me?.email ? `${me.email}, la importación falló` : "La importación falló"}</strong>
{moduleName && <> al importar <strong>{moduleName}</strong></>}: {registerError}
<div className="small mt-2 mb-0">
El módulo <strong>no</strong> quedó registrado. Corrige el repositorio
(URL, rama, <code>package.json</code>, config de datos) e inténtalo de nuevo.
</div>
</div>
)}
{/* Progreso de verificación post-importación */}
{(phase === "verifying" || (verify && phase === "done")) && (
<div className="app-card p-3 mb-3 border">
<h6 className="fw-semibold mb-1">
<i className="bx bx-check-shield me-1" />
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})
</small>
)}
</h6>
{verify && (
<small className="text-muted d-block mb-2">{verifySummary(verify)}</small>
)}
<ul className="list-group list-group-flush">
<li className="list-group-item d-flex gap-2 align-items-start px-0">
<StepIcon state={deployInfo ? "ok" : "run"} />
<span>
<strong>Registro y deploy</strong>
<small className="text-muted d-block">
{deployInfo
? `registrado (HTTP ${deployInfo.httpStatus})${deployInfo.webhook ? " · webhook creado" : " · sin webhook"}`
: "registrando módulo en el deployer…"}
</small>
</span>
</li>
{[
{ 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 (
<li key={s.id} className="list-group-item d-flex gap-2 align-items-start px-0">
<StepIcon state={checkState(check, phase)} />
<span>
<strong>{s.label}</strong>
<small className="text-muted d-block">
{check ? check.detail : "pendiente…"}
</small>
</span>
</li>
);
})}
</ul>
{phase === "verifying" && (
<p className="text-muted small mb-0 mt-2">
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 <Link href="/tablero">Tablero</Link> y{" "}
<Link href="/actualizar">Actualizar</Link>.
</p>
)}
</div>
)}
{/* Aviso directo a quien sube: verificación fallida */}
{verifyFailed && (
<div className="alert alert-danger">
<i className="bx bx-error-circle me-2" />
<strong>
{me?.email ? `${me.email}, la importación de "${moduleName}" falló` : `La importación de "${moduleName}" falló`}
</strong>{" "}
en: {failed.map((c) => c.label).join(", ")}.
<ul className="mb-2 mt-2">
{failed.map((c) => (
<li key={c.id}>
<strong>{c.label}:</strong> {c.detail}
</li>
))}
</ul>
{deployInfo && deployInfo.warnings.length > 0 && (
<div className="small mb-2">
<strong>Advertencias de config de datos:</strong>
<ul className="mb-0">
{deployInfo.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
{(verify.logsTail || deployInfo?.deployLog) && (
<details className="small mb-2">
<summary style={{ cursor: "pointer" }}>Ver logs del deploy</summary>
<pre className="bg-dark text-light p-2 mt-1 mb-0 small" style={{ whiteSpace: "pre-wrap", maxHeight: 240, overflow: "auto" }}>
{(verify.logsTail || deployInfo?.deployLog || "").slice(-3000)}
</pre>
</details>
)}
<div className="small mb-0">
El módulo quedó registrado pero <strong>no está funcionando</strong>. Revisa el
Dockerfile/puerto (<code>NEXT_PUBLIC_API_URL</code>, puerto interno 3000),
corrige el repo y reintenta desde <Link href="/actualizar">Actualizar</Link> o
elimínalo en <Link href="/eliminar">Eliminar</Link>. Su estado también es visible
en el <Link href={`/m/${moduleName}`}>tablero del módulo</Link>.
</div>
</div>
)}
{/* Éxito verificado */}
{phase === "done" && verify?.ok && (
<div className="alert alert-success">
<i className="bx bx-check-circle me-2" />
Módulo <strong>{moduleName}</strong> importado y <strong>verificado</strong>:
contenedor {verify.containerStatus || "running"} + HTTP {verify.httpStatus}
{verify.publicUrl ? <> + <code>{verify.publicUrl}</code></> : null}.
<span className="ms-2">
<a href={verify.publicUrl || `https://${moduleName}.devops.norteamericano.cl/`} target="_blank" rel="noopener noreferrer" className="btn btn-sm btn-success ms-1">
<i className="bx bx-window-open me-1" /> Abrir aplicación
</a>
<Link href={`/m/${moduleName}`} className="btn btn-sm btn-outline-success ms-1">
Ver tablero
</Link>
</span>
</div> </div>
)} )}
@@ -121,6 +340,7 @@ export default function UploadPage() {
placeholder="ej: ventas" placeholder="ej: ventas"
pattern="^[a-z][a-z0-9-]+$" pattern="^[a-z][a-z0-9-]+$"
required required
disabled={busy}
className="form-control" className="form-control"
/> />
<div className="form-text">Minúsculas, guiones, empieza con letra.</div> <div className="form-text">Minúsculas, guiones, empieza con letra.</div>
@@ -136,6 +356,7 @@ export default function UploadPage() {
onChange={handleChange} onChange={handleChange}
placeholder="https://git.norteamericano.cl/user/mi-proyecto.git" placeholder="https://git.norteamericano.cl/user/mi-proyecto.git"
required required
disabled={busy}
className="form-control" className="form-control"
/> />
<div className="form-text">Debe ser accesible desde el deployer (https://git.norteamericano.cl).</div> <div className="form-text">Debe ser accesible desde el deployer (https://git.norteamericano.cl).</div>
@@ -170,6 +391,7 @@ export default function UploadPage() {
value={form.subdomain} value={form.subdomain}
onChange={handleChange} onChange={handleChange}
placeholder="ej: ventas (default: nombre)" placeholder="ej: ventas (default: nombre)"
disabled={busy}
className="form-control" className="form-control"
/> />
<div className="form-text">https://{form.subdomain || form.name || "subdominio"}.devops.norteamericano.cl</div> <div className="form-text">https://{form.subdomain || form.name || "subdominio"}.devops.norteamericano.cl</div>
@@ -186,6 +408,7 @@ export default function UploadPage() {
placeholder="auto (3011+)" placeholder="auto (3011+)"
min={3000} min={3000}
max={4000} max={4000}
disabled={busy}
className="form-control" className="form-control"
/> />
<div className="form-text">Se asigna automáticamente si se deja vacío.</div> <div className="form-text">Se asigna automáticamente si se deja vacío.</div>
@@ -202,6 +425,7 @@ export default function UploadPage() {
placeholder="3000 frontend / 5000 backend" placeholder="3000 frontend / 5000 backend"
min={1} min={1}
max={65535} max={65535}
disabled={busy}
className="form-control" className="form-control"
/> />
<div className="form-text">Puerto que expone el contenedor.</div> <div className="form-text">Puerto que expone el contenedor.</div>
@@ -215,6 +439,7 @@ export default function UploadPage() {
value={form.context} value={form.context}
onChange={handleChange} onChange={handleChange}
placeholder="ej: backend-dotnet" placeholder="ej: backend-dotnet"
disabled={busy}
className="form-control" className="form-control"
/> />
<div className="form-text">Carpeta con el Dockerfile (vacío = raíz).</div> <div className="form-text">Carpeta con el Dockerfile (vacío = raíz).</div>
@@ -231,6 +456,7 @@ export default function UploadPage() {
value={form.category} value={form.category}
onChange={handleChange} onChange={handleChange}
required required
disabled={busy}
className="form-select" className="form-select"
> >
<option value="pagina-web">Página Web</option> <option value="pagina-web">Página Web</option>
@@ -252,6 +478,7 @@ export default function UploadPage() {
value={form.kind} value={form.kind}
onChange={handleChange} onChange={handleChange}
required required
disabled={busy}
className="form-select" className="form-select"
> >
<option value="frontend">Frontend (Next.js)</option> <option value="frontend">Frontend (Next.js)</option>
@@ -272,6 +499,7 @@ export default function UploadPage() {
name="visible" name="visible"
checked={form.visible} checked={form.visible}
onChange={handleCheck} onChange={handleCheck}
disabled={busy}
/> />
<label className="form-check-label" htmlFor="visibleSwitch"> <label className="form-check-label" htmlFor="visibleSwitch">
Visible en el portal{" "} Visible en el portal{" "}
@@ -289,6 +517,7 @@ export default function UploadPage() {
value={form.branch} value={form.branch}
onChange={handleChange} onChange={handleChange}
placeholder="main" placeholder="main"
disabled={busy}
className="form-control" className="form-control"
/> />
<div className="form-text"> <div className="form-text">
@@ -302,15 +531,20 @@ export default function UploadPage() {
El repositorio debe ser un proyecto <strong>Next.js 15</strong> (con{" "} El repositorio debe ser un proyecto <strong>Next.js 15</strong> (con{" "}
<code>package.json</code> en raíz o en <code>frontend/</code>) y usar{" "} <code>package.json</code> en raíz o en <code>frontend/</code>) y usar{" "}
<strong>Bootstrap</strong>. Si no trae <code>Dockerfile</code>, se genera uno automáticamente. <strong>Bootstrap</strong>. Si no trae <code>Dockerfile</code>, se genera uno automáticamente.
Tras registrar, se verifica que el contenedor corra y la app responda antes de darlo por listo.
</div> </div>
</div> </div>
<div className="col-12 mt-3 d-flex gap-2"> <div className="col-12 mt-3 d-flex gap-2">
<button type="submit" disabled={loading} className="btn btn-primary px-4"> <button type="submit" disabled={busy} className="btn btn-primary px-4">
{loading ? ( {phase === "deploying" ? (
<> <>
<span className="spinner-border spinner-border-sm me-2" /> Desplegando... <span className="spinner-border spinner-border-sm me-2" /> Desplegando...
</> </>
) : phase === "verifying" ? (
<>
<span className="spinner-border spinner-border-sm me-2" /> Verificando funcionamiento...
</>
) : ( ) : (
<> <>
<i className="bx bx-rocket me-1" /> Registrar y Desplegar <i className="bx bx-rocket me-1" /> Registrar y Desplegar
+50
View File
@@ -0,0 +1,50 @@
// ============================================================
// Verificación post-importación: tipos compartidos cliente/servidor.
// El deployer expone GET /api/modules/:name/verify con este contrato;
// si el deployer es antiguo (sin endpoint), la ruta del portal
// /api/modules/[name]/verify reconstruye un resultado equivalente.
// ============================================================
export interface VerifyCheck {
id: string;
label: string;
ok: boolean;
detail: string;
}
export interface VerifyResult {
ok: boolean;
module: string;
container: string | null;
containerStatus: string | null;
httpStatus: number | null;
checks: VerifyCheck[];
logsTail: string;
time: string;
/** Origen del resultado: deployer directo o reconstruido por el portal. */
source?: "deployer" | "portal";
/** URL pública verificada por el portal (si aplica). */
publicUrl?: string | null;
publicStatus?: number | null;
}
export const VERIFY_STEP_META: { id: string; label: string; icon: string }[] = [
{ id: "register", label: "Registro y deploy", icon: "bx-rocket" },
{ id: "container", label: "Contenedor en ejecución", icon: "bx-cube" },
{ id: "logs", label: "Logs sin errores", icon: "bx-terminal" },
{ id: "http-root", label: "Aplicación responde", icon: "bx-globe" },
{ id: "http-extra", label: "Contenido válido", icon: "bx-check-shield" },
{ id: "caddy", label: "Ruta pública", icon: "bx-link" },
];
export function failedChecks(v: VerifyResult | null | undefined): VerifyCheck[] {
if (!v) return [];
return v.checks.filter((c) => !c.ok);
}
/** Resumen de una línea para el aviso a quien importa. */
export function verifySummary(v: VerifyResult): string {
if (v.ok) return `"${v.module}" verificado: contenedor ${v.containerStatus || "running"} + HTTP ${v.httpStatus}`;
const failed = failedChecks(v).map((c) => c.label).join(", ");
return `"${v.module}" FALLÓ verificación en: ${failed || "paso desconocido"}`;
}