feat: kind backend/frontend + visible con toggle en gestion

This commit is contained in:
Nurfog committed 2026-09-09 11:27:39 -03:00
1 parent 3d9685adeb
commit 222fdac54d
8 files changed
+171 -12

No files matched your search

+34 -3
View File
@@ -17,8 +17,25 @@ export default function Actualizar() {
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const call = async (name: string, action: "deploy" | "webhook") => { const toggleVisible = async (name: string, current: boolean) => {
setBusy(`${action}:${name}`); 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); setMsg(null);
try { try {
const res = await fetch( const res = await fetch(
@@ -88,7 +105,13 @@ export default function Actualizar() {
<strong>{displayName(m.name)}</strong>{" "} <strong>{displayName(m.name)}</strong>{" "}
<span className={`badge ${env.cls} badge-respaldo`}> <span className={`badge ${env.cls} badge-respaldo`}>
{env.label} {env.label}
</span> </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> <div>
<small className="text-muted"> <small className="text-muted">
{m.lastDeploy {m.lastDeploy
@@ -131,6 +154,14 @@ export default function Actualizar() {
> >
<i className="bx bx-link" /> <i className="bx bx-link" />
</button> </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> </div>
</td> </td>
</tr> </tr>
+35 -1
View File
@@ -18,6 +18,24 @@ export default function Eliminar() {
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: 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 remove = async (name: string) => { const remove = async (name: string) => {
const extra = purge ? " y SE ELIMINARÁ SU CÓDIGO" : ""; const extra = purge ? " y SE ELIMINARÁ SU CÓDIGO" : "";
if ( if (
@@ -99,7 +117,13 @@ export default function Eliminar() {
<strong>{displayName(m.name)}</strong>{" "} <strong>{displayName(m.name)}</strong>{" "}
<span className={`badge ${env.cls} badge-respaldo`}> <span className={`badge ${env.cls} badge-respaldo`}>
{env.label} {env.label}
</span> </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>
)}
</td> </td>
<td> <td>
<code className="small"> <code className="small">
@@ -107,6 +131,15 @@ export default function Eliminar() {
</code> </code>
</td> </td>
<td className="text-end"> <td className="text-end">
<div className="d-flex gap-2 justify-content-end">
<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>
<button <button
className="btn btn-sm btn-outline-danger" className="btn btn-sm btn-outline-danger"
disabled={busy !== null} disabled={busy !== null}
@@ -121,6 +154,7 @@ export default function Eliminar() {
</> </>
)} )}
</button> </button>
</div>
</td> </td>
</tr> </tr>
); );
+2 -2
View File
@@ -15,7 +15,7 @@ import {
memUsed, memUsed,
pct, pct,
shortUptime, shortUptime,
statFor, statForModule,
} from "@/lib/stats"; } from "@/lib/stats";
const API_URL = const API_URL =
@@ -49,7 +49,7 @@ export default function ModuleBoard() {
(m) => m.name.toLowerCase() === name.toLowerCase() (m) => m.name.toLowerCase() === name.toLowerCase()
); );
const functions = getModuleFunctions(name); const functions = getModuleFunctions(name);
const stat = mod ? statFor(stats, `${mod.name}-frontend`) : undefined; const stat = mod ? statForModule(stats, mod.name) : undefined;
const status = mod ? getStatusInfo(mod) : null; const status = mod ? getStatusInfo(mod) : null;
const env = mod ? envOf(mod.branch) : null; const env = mod ? envOf(mod.branch) : null;
const cpu = pct(stat?.cpu); const cpu = pct(stat?.cpu);
+2 -1
View File
@@ -11,6 +11,7 @@ import {
moduleUrl, moduleUrl,
sectionOf, sectionOf,
SECTIONS, SECTIONS,
visibleModules,
type ModuleState, type ModuleState,
} from "@/lib/sam"; } from "@/lib/sam";
@@ -19,7 +20,7 @@ export default function Home() {
const loading = data === null && error === null; const loading = data === null && error === null;
const fetchModules = refreshModules; const fetchModules = refreshModules;
const modules = data?.modules ?? []; const modules = visibleModules(data?.modules ?? []);
const bySection: Record<string, ModuleState[]> = {}; const bySection: Record<string, ModuleState[]> = {};
modules.forEach((m) => { modules.forEach((m) => {
+69 -2
View File
@@ -14,6 +14,9 @@ export default function UploadPage() {
port: "", port: "",
subdomain: "", subdomain: "",
category: "otras-apps", category: "otras-apps",
kind: "frontend",
visible: true,
targetPort: "",
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
@@ -21,7 +24,13 @@ export default function UploadPage() {
const handleChange = ( const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => { ) => {
setForm({ ...form, [e.target.name]: e.target.value }); 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 handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -34,9 +43,12 @@ export default function UploadPage() {
repoUrl: form.repoUrl.trim(), repoUrl: form.repoUrl.trim(),
branch: form.branch.trim() || "main", branch: form.branch.trim() || "main",
category: form.category, category: form.category,
kind: form.kind,
visible: form.visible,
}; };
if (form.port) payload.port = parseInt(form.port, 10); if (form.port) payload.port = parseInt(form.port, 10);
if (form.subdomain) payload.subdomain = form.subdomain.trim(); if (form.subdomain) payload.subdomain = form.subdomain.trim();
if (form.targetPort) payload.targetPort = parseInt(form.targetPort, 10);
try { try {
const res = await fetch("/api/modules", { const res = await fetch("/api/modules", {
@@ -50,7 +62,7 @@ export default function UploadPage() {
ok: true, ok: true,
message: `Módulo "${payload.name}" registrado. Deploy: ${data.webhook ? "webhook creado" : "sin webhook"}${data.deployLog ? " · " + data.deployLog.slice(0, 80) : ""}`, message: `Módulo "${payload.name}" registrado. Deploy: ${data.webhook ? "webhook creado" : "sin webhook"}${data.deployLog ? " · " + data.deployLog.slice(0, 80) : ""}`,
}); });
setForm({ name: "", repoUrl: "", branch: "main", port: "", subdomain: "", category: "otras-apps" }); setForm({ name: "", repoUrl: "", branch: "main", port: "", subdomain: "", category: "otras-apps", kind: "frontend", visible: true, targetPort: "" });
} else { } else {
setResult({ ok: false, message: data.error || `Error HTTP ${res.status}` }); setResult({ ok: false, message: data.error || `Error HTTP ${res.status}` });
} }
@@ -171,6 +183,22 @@ export default function UploadPage() {
/> />
<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>
</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}
className="form-control"
/>
<div className="form-text">Puerto que expone el contenedor.</div>
</div>
</div> </div>
</details> </details>
@@ -195,6 +223,45 @@ export default function UploadPage() {
</div> </div>
</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
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}
/>
<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"> <div className="col-md-6">
<label className="form-label fw-semibold">Rama</label> <label className="form-label fw-semibold">Rama</label>
<input <input
+4 -2
View File
@@ -11,6 +11,7 @@ import {
moduleUrl, moduleUrl,
sectionOf, sectionOf,
SECTIONS, SECTIONS,
visibleModules,
} from "@/lib/sam"; } from "@/lib/sam";
import { import {
healthOf, healthOf,
@@ -18,6 +19,7 @@ import {
pct, pct,
shortUptime, shortUptime,
statFor, statFor,
statForModule,
type ContainerStat, type ContainerStat,
} from "@/lib/stats"; } from "@/lib/stats";
import type { ModuleState } from "@/lib/sam"; import type { ModuleState } from "@/lib/sam";
@@ -166,7 +168,7 @@ export default function TableroView() {
const stats = useStats(); const stats = useStats();
const loading = data === null; const loading = data === null;
const modules = data?.modules ?? []; const modules = visibleModules(data?.modules ?? []);
const ok = modules.filter((m) => healthOf(m) === "ok").length; const ok = modules.filter((m) => healthOf(m) === "ok").length;
const starting = modules.filter((m) => healthOf(m) === "starting").length; const starting = modules.filter((m) => healthOf(m) === "starting").length;
const down = modules.length - ok - starting; const down = modules.length - ok - starting;
@@ -224,7 +226,7 @@ export default function TableroView() {
</div> </div>
<div className="row row-cards"> <div className="row row-cards">
{list.map((m) => ( {list.map((m) => (
<AppCard key={m.name} m={m} stat={statFor(stats, `${m.name}-frontend`)} /> <AppCard key={m.name} m={m} stat={statForModule(stats, m.name)} />
))} ))}
</div> </div>
</div> </div>
+15 -1
View File
@@ -20,10 +20,23 @@ export interface ModuleState {
port: number; port: number;
active: boolean; active: boolean;
category?: string; category?: string;
kind?: string;
visible?: boolean;
targetPort?: number;
lastDeploy?: LastDeploy; lastDeploy?: LastDeploy;
container?: string | null; container?: string | null;
} }
// Solo módulos visibles en índice, tableros y sidebars.
// (Gestión —actualizar/eliminar— muestra todos con su badge.)
export function visibleModules(modules: ModuleState[]): ModuleState[] {
return modules.filter((m) => m.visible !== false);
}
export function isBackend(m: ModuleState): boolean {
return (m.kind || "frontend") === "backend";
}
export interface ApiResponse { export interface ApiResponse {
backend: { backend: {
repoUrl: string; repoUrl: string;
@@ -280,10 +293,11 @@ export function envOf(branch: string) {
// Menú del portal IT: acciones reales de gestión + tableros. // Menú del portal IT: acciones reales de gestión + tableros.
// Las futuras opciones se agregan como entradas de esta lista. // Las futuras opciones se agregan como entradas de esta lista.
export function itMenu(modules: ModuleState[]): MenuItem[] { export function itMenu(modules: ModuleState[]): MenuItem[] {
const vis = visibleModules(modules);
return [ return [
{ label: "Resumen", icon: "bx-grid-alt", href: "/tablero" }, { label: "Resumen", icon: "bx-grid-alt", href: "/tablero" },
{ label: "Aplicaciones", icon: "bx-layer", href: "/" }, { label: "Aplicaciones", icon: "bx-layer", href: "/" },
...modules.map((m) => ({ ...vis.map((m) => ({
label: displayName(m.name), label: displayName(m.name),
icon: getModuleMeta(m.name).icon, icon: getModuleMeta(m.name).icon,
href: boardHref(m).href, href: boardHref(m).href,
+10
View File
@@ -35,6 +35,16 @@ export function statFor(
return stats.find((s) => s.name === containerName); return stats.find((s) => s.name === containerName);
} }
export function statForModule(
stats: ContainerStat[],
moduleName: string
): ContainerStat | undefined {
return (
statFor(stats, `${moduleName}-frontend`) ||
statFor(stats, `${moduleName}-backend`)
);
}
export type Health = "ok" | "starting" | "down"; export type Health = "ok" | "starting" | "down";
export function healthOf(m: ModuleState): Health { export function healthOf(m: ModuleState): Health {