"use client"; 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(); const me = useMe(); const [form, setForm] = useState({ name: "", repoUrl: "", branch: "main", port: "", subdomain: "", category: "otras-apps", kind: "frontend", visible: true, targetPort: "", context: "", }); 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 ) => { const { name, value } = e.target; setForm((f) => ({ ...f, [name]: value })); }; const handleCheck = (e: React.ChangeEvent) => { const { name, checked } = e.target; 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; setPhase("deploying"); setRegisterError(null); setVerify(null); setDeployInfo(null); setAttempt(0); const name = form.name.trim().toLowerCase(); setModuleName(name); const payload: Record = { name, repoUrl: form.repoUrl.trim(), branch: form.branch.trim() || "main", category: form.category, kind: form.kind, visible: form.visible, }; if (form.port) payload.port = parseInt(form.port, 10); if (form.subdomain) payload.subdomain = form.subdomain.trim(); if (form.targetPort) payload.targetPort = parseInt(form.targetPort, 10); if (form.kind === "backend" && form.context.trim()) payload.context = form.context.trim(); try { const res = await fetch("/api/modules", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); 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) { 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 (
Integrar Nuevo Módulo

Registra un proyecto Next.js 15 + Bootstrap desde un repositorio Gitea. Se clonará, desplegará automáticamente y quedará disponible en{" "} https://{"{subdominio}"}.devops.norteamericano.cl. Cada push futuro actualiza el módulo vía webhook.

{/* 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
)}
Minúsculas, guiones, empieza con letra.
Debe ser accesible desde el deployer (https://git.norteamericano.cl).
Configuración automática: puerto y subdominio se asignan automáticamente {form.name && ( {" "} → https://{form.subdomain || form.name}.devops.norteamericano.cl {form.port ? ` :${form.port}` : " (puerto auto)"} )} . Se crean los registros en Caddy y DNS.
Opciones avanzadas (puerto / subdominio manual)
https://{form.subdomain || form.name || "subdominio"}.devops.norteamericano.cl
Se asigna automáticamente si se deja vacío.
Puerto que expone el contenedor.
Carpeta con el Dockerfile (vacío = raíz).
Define en qué sección del índice SAM aparece el módulo.
El backend queda interno salvo subdominio explícito.
main = producción, otra rama = desarrollo.
El repositorio debe ser un proyecto Next.js 15 (con{" "} package.json en raíz o en frontend/) y usar{" "} Bootstrap. Si no trae Dockerfile, se genera uno automáticamente. Tras registrar, se verifica que el contenedor corra y la app responda antes de darlo por listo.
Requisitos del repositorio
  • Proyecto Next.js 15 con next build / next start
  • Bootstrap 5 (via CDN o npm) + Boxicons opcional
  • Variable NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl{" "} para consumir el backend
  • Si el repo trae Dockerfile propio, debe declarar{" "} ARG NEXT_PUBLIC_API_URL (se inyecta en el build; si no, el bundle queda apuntando a localhost)
  • Branch por defecto: main (configurable)
); }