583 lines
24 KiB
TypeScript
583 lines
24 KiB
TypeScript
"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 <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() {
|
|
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<Phase>("idle");
|
|
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 = (
|
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
|
) => {
|
|
const { name, value } = e.target;
|
|
setForm((f) => ({ ...f, [name]: value }));
|
|
};
|
|
|
|
const handleCheck = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
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<string, unknown> = {
|
|
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 (
|
|
<SamShell
|
|
brand="IT"
|
|
subtitle="Agregar módulo"
|
|
menu={itMenu(data?.modules ?? [])}
|
|
activeHref="/upload"
|
|
topUser={me?.email || "SAM"}
|
|
allowLogout
|
|
backHref="/"
|
|
>
|
|
<div className="alert alert-light section-header mb-3 py-2 px-3">
|
|
<i className="bx bx-cloud-upload me-1" /> Integrar Nuevo Módulo
|
|
</div>
|
|
|
|
<div className="app-card p-4">
|
|
<p className="text-muted mb-4">
|
|
Registra un proyecto <strong>Next.js 15 + Bootstrap</strong> desde un repositorio
|
|
Gitea. Se clonará, desplegará automáticamente y quedará disponible en{" "}
|
|
<code>https://{"{subdominio}"}.devops.norteamericano.cl</code>. Cada push futuro
|
|
actualiza el módulo vía webhook.
|
|
</p>
|
|
|
|
{/* Aviso directo a quien sube: falla de registro (no se importó) */}
|
|
{registerError && (
|
|
<div className="alert alert-danger">
|
|
<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>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="row g-3">
|
|
<div className="col-md-6">
|
|
<label className="form-label fw-semibold">
|
|
Nombre del módulo <span className="text-danger">*</span>
|
|
</label>
|
|
<input
|
|
name="name"
|
|
value={form.name}
|
|
onChange={handleChange}
|
|
placeholder="ej: ventas"
|
|
pattern="^[a-z][a-z0-9-]+$"
|
|
required
|
|
disabled={busy}
|
|
className="form-control"
|
|
/>
|
|
<div className="form-text">Minúsculas, guiones, empieza con letra.</div>
|
|
</div>
|
|
|
|
<div className="col-12">
|
|
<label className="form-label fw-semibold">
|
|
URL del repositorio Gitea <span className="text-danger">*</span>
|
|
</label>
|
|
<input
|
|
name="repoUrl"
|
|
value={form.repoUrl}
|
|
onChange={handleChange}
|
|
placeholder="https://git.norteamericano.cl/user/mi-proyecto.git"
|
|
required
|
|
disabled={busy}
|
|
className="form-control"
|
|
/>
|
|
<div className="form-text">Debe ser accesible desde el deployer (https://git.norteamericano.cl).</div>
|
|
</div>
|
|
|
|
<div className="col-12">
|
|
<div className="alert alert-light border py-2 small">
|
|
<i className="bx bx-cog me-1" />
|
|
<strong>Configuración automática:</strong> puerto y subdominio se asignan automáticamente
|
|
{form.name && (
|
|
<span>
|
|
{" "}
|
|
→ <code>https://{form.subdomain || form.name}.devops.norteamericano.cl</code>
|
|
{form.port ? ` :${form.port}` : " (puerto auto)"}
|
|
</span>
|
|
)}
|
|
. Se crean los registros en Caddy y DNS.
|
|
</div>
|
|
</div>
|
|
|
|
<details className="col-12">
|
|
<summary className="small text-muted" style={{ cursor: "pointer" }}>
|
|
Opciones avanzadas (puerto / subdominio manual)
|
|
</summary>
|
|
<div className="row g-3 mt-1">
|
|
<div className="col-md-6">
|
|
<label className="form-label fw-semibold">
|
|
Subdominio <small className="text-muted">(opcional)</small>
|
|
</label>
|
|
<input
|
|
name="subdomain"
|
|
value={form.subdomain}
|
|
onChange={handleChange}
|
|
placeholder="ej: ventas (default: nombre)"
|
|
disabled={busy}
|
|
className="form-control"
|
|
/>
|
|
<div className="form-text">https://{form.subdomain || form.name || "subdominio"}.devops.norteamericano.cl</div>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label fw-semibold">
|
|
Puerto <small className="text-muted">(opcional)</small>
|
|
</label>
|
|
<input
|
|
name="port"
|
|
type="number"
|
|
value={form.port}
|
|
onChange={handleChange}
|
|
placeholder="auto (3011+)"
|
|
min={3000}
|
|
max={4000}
|
|
disabled={busy}
|
|
className="form-control"
|
|
/>
|
|
<div className="form-text">Se asigna automáticamente si se deja vacío.</div>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label fw-semibold">
|
|
Puerto interno <small className="text-muted">(backend)</small>
|
|
</label>
|
|
<input
|
|
name="targetPort"
|
|
type="number"
|
|
value={form.targetPort}
|
|
onChange={handleChange}
|
|
placeholder="3000 frontend / 5000 backend"
|
|
min={1}
|
|
max={65535}
|
|
disabled={busy}
|
|
className="form-control"
|
|
/>
|
|
<div className="form-text">Puerto que expone el contenedor.</div>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label fw-semibold">
|
|
Subdirectorio <small className="text-muted">(backend monorepo)</small>
|
|
</label>
|
|
<input
|
|
name="context"
|
|
value={form.context}
|
|
onChange={handleChange}
|
|
placeholder="ej: backend-dotnet"
|
|
disabled={busy}
|
|
className="form-control"
|
|
/>
|
|
<div className="form-text">Carpeta con el Dockerfile (vacío = raíz).</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<div className="col-md-6">
|
|
<label className="form-label fw-semibold">
|
|
¿A qué corresponde? <span className="text-danger">*</span>
|
|
</label>
|
|
<select
|
|
name="category"
|
|
value={form.category}
|
|
onChange={handleChange}
|
|
required
|
|
disabled={busy}
|
|
className="form-select"
|
|
>
|
|
<option value="pagina-web">Página Web</option>
|
|
<option value="sam">SAM</option>
|
|
<option value="profesores">Profesores</option>
|
|
<option value="otras-apps">Otras Aplicaciones</option>
|
|
</select>
|
|
<div className="form-text">
|
|
Define en qué sección del índice SAM aparece el módulo.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-md-6">
|
|
<label className="form-label fw-semibold">
|
|
Tipo <span className="text-danger">*</span>
|
|
</label>
|
|
<select
|
|
name="kind"
|
|
value={form.kind}
|
|
onChange={handleChange}
|
|
required
|
|
disabled={busy}
|
|
className="form-select"
|
|
>
|
|
<option value="frontend">Frontend (Next.js)</option>
|
|
<option value="backend">Backend (Dockerfile propio)</option>
|
|
</select>
|
|
<div className="form-text">
|
|
El backend queda interno salvo subdominio explícito.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-12">
|
|
<div className="form-check form-switch">
|
|
<input
|
|
className="form-check-input"
|
|
type="checkbox"
|
|
role="switch"
|
|
id="visibleSwitch"
|
|
name="visible"
|
|
checked={form.visible}
|
|
onChange={handleCheck}
|
|
disabled={busy}
|
|
/>
|
|
<label className="form-check-label" htmlFor="visibleSwitch">
|
|
Visible en el portal{" "}
|
|
<small className="text-muted">
|
|
(si no, solo se gestiona desde IT)
|
|
</small>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-md-6">
|
|
<label className="form-label fw-semibold">Rama</label>
|
|
<input
|
|
name="branch"
|
|
value={form.branch}
|
|
onChange={handleChange}
|
|
placeholder="main"
|
|
disabled={busy}
|
|
className="form-control"
|
|
/>
|
|
<div className="form-text">
|
|
main = producción, otra rama = desarrollo.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-12 mt-2">
|
|
<div className="alert alert-info py-2 small mb-0">
|
|
<i className="bx bx-info-circle me-1" />
|
|
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{" "}
|
|
<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 className="col-12 mt-3 d-flex gap-2">
|
|
<button type="submit" disabled={busy} className="btn btn-primary px-4">
|
|
{phase === "deploying" ? (
|
|
<>
|
|
<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
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="app-card p-3 mt-3">
|
|
<h6 className="fw-semibold mb-2">
|
|
<i className="bx bx-book-open me-1" /> Requisitos del repositorio
|
|
</h6>
|
|
<ul className="small text-muted mb-0">
|
|
<li>
|
|
Proyecto Next.js 15 con <code>next build</code> / <code>next start</code>
|
|
</li>
|
|
<li>Bootstrap 5 (via CDN o npm) + Boxicons opcional</li>
|
|
<li>
|
|
Variable <code>NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl</code>{" "}
|
|
para consumir el backend
|
|
</li>
|
|
<li>
|
|
Si el repo trae <code>Dockerfile</code> propio, debe declarar{" "}
|
|
<code>ARG NEXT_PUBLIC_API_URL</code> (se inyecta en el build; si no,
|
|
el bundle queda apuntando a localhost)
|
|
</li>
|
|
<li>Branch por defecto: main (configurable)</li>
|
|
</ul>
|
|
</div>
|
|
</SamShell>
|
|
);
|
|
}
|