feat: sidebar IT con acciones reales (agregar/actualizar/eliminar + proxy deployer)

This commit is contained in:
Nurfog committed 2026-09-09 09:54:53 -03:00
1 parent e20e9d6657
commit 23df440b79
5 files changed
+398 -17

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import SamShell from "@/components/SamShell";
import { refreshModules, useModules } from "@/lib/store";
import {
displayName,
envOf,
getModuleMeta,
itMenu,
} from "@/lib/sam";
// Actualizar: redeploy manual + reinstalación de webhook por módulo.
export default function Actualizar() {
const { data, error } = useModules();
const modules = data?.modules ?? [];
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(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);
}
};
return (
<SamShell
brand="IT"
subtitle="Actualizar módulos"
menu={itMenu(modules)}
activeHref="/actualizar"
topUser="SAM"
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">
<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>
<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>
</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>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
// Proxy genérico al deployer: /api/deployer/<path>?<query>
// Métodos: GET, POST, PUT, PATCH, DELETE. Permite exponer
// futuras acciones sin agregar rutas (deploy, webhook, backend...).
async function proxy(req: NextRequest, path: string[]) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (DEPLOYER_TOKEN) headers["X-API-Token"] = DEPLOYER_TOKEN;
try {
const qs = req.nextUrl.search || "";
const init: RequestInit = {
method: req.method,
headers,
cache: "no-store",
};
if (req.method !== "GET" && req.method !== "DELETE") {
try {
init.body = JSON.stringify(await req.json());
} catch {
init.body = undefined;
}
}
const res = await fetch(
`${DEPLOYER_URL}/api/${path.join("/")}${qs}`,
init
);
const text = await res.text();
try {
return NextResponse.json(JSON.parse(text), { status: res.status });
} catch {
return new NextResponse(text, { status: res.status });
}
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Error al conectar con deployer" },
{ status: 502 }
);
}
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
return proxy(req, (await params).slug);
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
return proxy(req, (await params).slug);
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
return proxy(req, (await params).slug);
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
return proxy(req, (await params).slug);
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
return proxy(req, (await params).slug);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import SamShell from "@/components/SamShell";
import { refreshModules, useModules } from "@/lib/store";
import {
displayName,
envOf,
getModuleMeta,
itMenu,
} from "@/lib/sam";
// Eliminar: baja de módulos integrados, con o sin código (purge).
export default function Eliminar() {
const { data, error } = useModules();
const modules = data?.modules ?? [];
const [purge, setPurge] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const remove = async (name: string) => {
const extra = purge ? " y SE ELIMINARÁ SU CÓDIGO" : "";
if (
!window.confirm(`¿Eliminar el módulo "${name}"?${extra}\nEsta acción no se puede deshacer.`)
) {
return;
}
setBusy(name);
setMsg(null);
try {
const res = await fetch(
`/api/deployer/modules/${name}?purge=${purge ? "true" : "false"}`,
{ method: "DELETE", cache: "no-store" }
);
const j = await res.json();
if (!res.ok || !j.ok) throw new Error(j.error || `HTTP ${res.status}`);
setMsg({
ok: true,
text: `Módulo "${name}" eliminado${purge ? " con código" : ""}.`,
});
refreshModules();
} catch (e) {
setMsg({ ok: false, text: e instanceof Error ? e.message : "Error" });
} finally {
setBusy(null);
}
};
return (
<SamShell
brand="IT"
subtitle="Eliminar módulos"
menu={itMenu(modules)}
activeHref="/eliminar"
topUser="SAM"
backHref="/"
>
<div className="alert alert-light section-header mb-3 py-2 px-3">
<i className="bx bx-trash me-1" /> Eliminar 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">
<div className="form-check form-switch mb-3">
<input
className="form-check-input"
type="checkbox"
role="switch"
id="purgeSwitch"
checked={purge}
onChange={(e) => setPurge(e.target.checked)}
/>
<label className="form-check-label" htmlFor="purgeSwitch">
Eliminar también el código <small className="text-muted">(purge)</small>
</label>
</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Módulo</th>
<th>Subdominio</th>
<th className="text-end">Acción</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>
</td>
<td>
<code className="small">
{m.subdomain}.devops.norteamericano.cl
</code>
</td>
<td className="text-end">
<button
className="btn btn-sm btn-outline-danger"
disabled={busy !== null}
onClick={() => remove(m.name)}
>
{busy === m.name ? (
<span className="spinner-border spinner-border-sm" />
) : (
<>
<i className="bx bx-trash me-1" />
Eliminar
</>
)}
</button>
</td>
</tr>
);
})}
{modules.length === 0 && (
<tr>
<td colSpan={3} className="text-center text-muted py-4">
No hay módulos integrados.
</td>
</tr>
)}
</tbody>
</table>
</div>
<p className="text-muted small mt-3 mb-0">
La eliminación baja contenedores y limpia Caddy, DNS, webhook Gitea y
registro. Recuerda recargar el Caddy host si el deployer lo indica.
</p>
</div>
</SamShell>
);
}
+5 -17
View File
@@ -3,32 +3,20 @@
import SamShell from "@/components/SamShell"; import SamShell from "@/components/SamShell";
import TableroView from "@/components/TableroView"; import TableroView from "@/components/TableroView";
import { useModules } from "@/lib/store"; import { useModules } from "@/lib/store";
import { displayName, getModuleMeta } from "@/lib/sam"; import { itMenu } from "@/lib/sam";
// App IT: sidebar con todas sus opciones (resumen, aplicaciones, // App IT: sidebar con todas sus opciones (resumen, aplicaciones,
// tableros por módulo y gestión). Se sirve en /tablero y en // tableros por módulo, agregar, actualizar, eliminar).
// it.sam.devops.norteamericano.cl // Se sirve en /tablero y en it.sam.devops.norteamericano.cl
export default function Tablero() { export default function Tablero() {
const { data } = useModules(); const { data } = useModules();
const modules = data?.modules ?? [];
const menu = [
{ label: "Resumen", icon: "bx-grid-alt", href: "#resumen" },
{ label: "Aplicaciones", icon: "bx-layer", href: "/" },
...modules.map((m) => ({
label: displayName(m.name),
icon: getModuleMeta(m.name).icon,
href: `/m/${m.name}`,
})),
{ label: "Nuevo Módulo", icon: "bx-plus", href: "/upload" },
];
return ( return (
<SamShell <SamShell
brand="IT" brand="IT"
subtitle="Gestión de aplicaciones" subtitle="Gestión de aplicaciones"
menu={menu} menu={itMenu(data?.modules ?? [])}
activeHref="#resumen" activeHref="/tablero"
topUser="SAM" topUser="SAM"
backHref="/" backHref="/"
> >
+17
View File
@@ -260,3 +260,20 @@ export function envOf(branch: string) {
? { label: "Prod", cls: "bg-success" } ? { label: "Prod", cls: "bg-success" }
: { label: "Dev", cls: "bg-warning text-dark" }; : { label: "Dev", cls: "bg-warning text-dark" };
} }
// Menú del portal IT: acciones reales de gestión + tableros.
// Las futuras opciones se agregan como entradas de esta lista.
export function itMenu(modules: ModuleState[]): MenuItem[] {
return [
{ label: "Resumen", icon: "bx-grid-alt", href: "/tablero" },
{ label: "Aplicaciones", icon: "bx-layer", href: "/" },
...modules.map((m) => ({
label: displayName(m.name),
icon: getModuleMeta(m.name).icon,
href: `/m/${m.name}`,
})),
{ label: "Agregar", icon: "bx-plus", href: "/upload" },
{ label: "Actualizar", icon: "bx-refresh", href: "/actualizar" },
{ label: "Eliminar", icon: "bx-trash", href: "/eliminar" },
];
}