feat: sidebar IT con acciones reales (agregar/actualizar/eliminar + proxy deployer)
This commit is contained in:
1 parent
e20e9d6657
commit
23df440b79
5 files changed
+398
-17
No files matched your search
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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="/"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in new issue
Block a user