feat: kind backend/frontend + visible con toggle en gestion
This commit is contained in:
1 parent
3d9685adeb
commit
222fdac54d
8 files changed
+171
-12
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user