Files
sam-portal/src/app/actualizar/page.tsx
T

252 lines
8.8 KiB
TypeScript

"use client";
import { useState } from "react";
import SamShell from "@/components/SamShell";
import { refreshModules, useModules, useMe } from "@/lib/store"
import {
assignColorsForModules,
displayName,
envOf,
getModuleMeta,
isValidHexColor,
itMenu,
} from "@/lib/sam";
// Actualizar: redeploy manual + reinstalación de webhook por módulo.
export default function Actualizar() {
const { data, error } = useModules();
const me = useMe();
const modules = data?.modules ?? [];
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const toggleVisible = async (name: string, current: boolean) => {
setBusy(`visible:${name}`);
try {
const res = await fetch(`/api/deployer/modules/${name}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ visible: !current }),
cache: "no-store",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
refreshModules();
} catch (e) {
setMsg({ ok: false, text: e instanceof Error ? e.message : "Error" });
} finally {
setBusy(null);
}
};
const call = async (name: string, action: "deploy" | "webhook") => { setBusy(`${action}:${name}`);
setMsg(null);
try {
const res = await fetch(
action === "deploy"
? `/api/deployer/modules/${name}/deploy`
: `/api/deployer/modules/${name}/webhook`,
{ method: "POST", cache: "no-store" }
);
const j = await res.json();
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
setMsg({
ok: true,
text:
action === "deploy"
? `"${name}" actualizado (sha ${(j.sha || "").slice(0, 7)})`
: `"${name}": webhook ${j.created ? `creado #${j.created}` : j.updated ? `actualizado #${j.updated}` : "ok"}`,
});
refreshModules();
} catch (e) {
setMsg({
ok: false,
text: e instanceof Error ? e.message : "Error",
});
} finally {
setBusy(null);
}
};
// Rellena el color de los módulos ya importados que aún no lo tienen
// guardado: lo genera de forma autoritaria y lo persiste en el deployer.
const missingColor = modules.filter((m) => !isValidHexColor(m.color));
const backfillColors = async () => {
if (missingColor.length === 0) return;
setBusy("color:backfill");
setMsg(null);
try {
const assigned = assignColorsForModules(modules);
let ok = 0;
const failed: string[] = [];
for (const m of missingColor) {
try {
const res = await fetch(
`/api/deployer/modules/${encodeURIComponent(m.name)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ color: assigned[m.name] }),
cache: "no-store",
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
ok++;
} catch {
failed.push(m.name);
}
}
refreshModules();
setMsg({
ok: failed.length === 0,
text:
failed.length === 0
? `Colores generados y guardados en ${ok} módulo${ok !== 1 ? "s" : ""}.`
: `Guardados ${ok}, fallaron: ${failed.join(", ")}.`,
});
} finally {
setBusy(null);
}
};
return (
<SamShell
brand="IT"
subtitle="Actualizar módulos"
menu={itMenu(modules)}
activeHref="/actualizar"
topUser={me?.email || "SAM"}
allowLogout
backHref="/"
>
<div className="alert alert-light section-header mb-3 py-2 px-3">
<i className="bx bx-refresh me-1" /> Actualizar módulos
</div>
{error && <div className="alert alert-danger">{error}</div>}
{msg && (
<div className={`alert ${msg.ok ? "alert-success" : "alert-danger"}`}>
{msg.text}
</div>
)}
<div className="app-card p-3">
{missingColor.length > 0 && (
<div className="alert alert-info py-2 d-flex align-items-center justify-content-between flex-wrap gap-2">
<span className="small">
<i className="bx bx-palette me-1" />
Hay {missingColor.length} módulo{missingColor.length !== 1 ? "s" : ""} sin
color guardado ({missingColor.map((m) => m.name).join(", ")}).
</span>
<button
className="btn btn-sm btn-primary"
disabled={busy !== null}
onClick={backfillColors}
>
{busy === "color:backfill" ? (
<span className="spinner-border spinner-border-sm" />
) : (
<>
<i className="bx bx-palette me-1" />
Generar colores
</>
)}
</button>
</div>
)}
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Módulo</th>
<th>Rama</th>
<th>Último deploy</th>
<th className="text-end">Acciones</th>
</tr>
</thead>
<tbody>
{modules.map((m) => {
const env = envOf(m.branch);
return (
<tr key={m.name}>
<td>
<i className={`bx ${getModuleMeta(m.name).icon} me-2`} />
<strong>{displayName(m.name)}</strong>{" "}
<span className={`badge ${env.cls} badge-respaldo`}>
{env.label}
</span>{" "}
{m.kind === "backend" && (
<span className="badge bg-dark badge-respaldo">backend</span>
)}{" "}
{m.visible === false && (
<span className="badge bg-secondary badge-respaldo">oculto</span>
)}
<div>
<small className="text-muted">
{m.lastDeploy
? `${(m.lastDeploy.sha || "").slice(0, 7)} · ${m.lastDeploy.ok ? "ok" : "falló"}`
: "sin deploys"}
</small>
</div>
</td>
<td>
<code className="small">{m.branch}</code>
</td>
<td>
<small className="text-muted">
{m.lastDeploy?.time
? new Date(m.lastDeploy.time).toLocaleString("es-CL")
: "—"}
</small>
</td>
<td className="text-end">
<div className="d-flex gap-2 justify-content-end">
<button
className="btn btn-sm btn-primary"
disabled={busy !== null}
onClick={() => call(m.name, "deploy")}
>
{busy === `deploy:${m.name}` ? (
<span className="spinner-border spinner-border-sm" />
) : (
<>
<i className="bx bx-refresh me-1" />
Actualizar
</>
)}
</button>
<button
className="btn btn-sm btn-outline-secondary"
disabled={busy !== null}
onClick={() => call(m.name, "webhook")}
title="Reinstalar webhook Gitea"
>
<i className="bx bx-link" />
</button>
<button
className="btn btn-sm btn-outline-secondary"
disabled={busy !== null}
onClick={() => toggleVisible(m.name, m.visible !== false)}
title={m.visible === false ? "Mostrar en portal" : "Ocultar del portal"}
>
<i className={`bx ${m.visible === false ? "bx-eye" : "bx-eye-off"}`} />
</button>
</div>
</td>
</tr>
);
})}
{modules.length === 0 && (
<tr>
<td colSpan={4} className="text-center text-muted py-4">
No hay módulos integrados.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</SamShell>
);
}