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 TableroView from "@/components/TableroView";
|
||||
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,
|
||||
// tableros por módulo y gestión). Se sirve en /tablero y en
|
||||
// it.sam.devops.norteamericano.cl
|
||||
// tableros por módulo, agregar, actualizar, eliminar).
|
||||
// Se sirve en /tablero y en it.sam.devops.norteamericano.cl
|
||||
export default function Tablero() {
|
||||
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 (
|
||||
<SamShell
|
||||
brand="IT"
|
||||
subtitle="Gestión de aplicaciones"
|
||||
menu={menu}
|
||||
activeHref="#resumen"
|
||||
menu={itMenu(data?.modules ?? [])}
|
||||
activeHref="/tablero"
|
||||
topUser="SAM"
|
||||
backHref="/"
|
||||
>
|
||||
|
||||
@@ -260,3 +260,20 @@ export function envOf(branch: string) {
|
||||
? { label: "Prod", cls: "bg-success" }
|
||||
: { 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