feat: tablero de gestion con graficas de estado y carga por aplicacion

This commit is contained in:
Nurfog committed 2026-09-08 16:56:11 -03:00
1 parent 646a3bf628
commit 3949224d94
5 files changed
+459 -56

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
export async function GET() {
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (DEPLOYER_TOKEN) headers["X-API-Token"] = DEPLOYER_TOKEN;
const res = await fetch(`${DEPLOYER_URL}/api/stats`, {
headers,
cache: "no-store",
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Error al obtener stats" },
{ status: 502 }
);
}
}
+8 -56
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useSyncExternalStore } from "react";
import Link from "next/link"; import Link from "next/link";
import { useModules, refreshModules } from "@/lib/store";
import { import {
displayName, displayName,
envOf, envOf,
@@ -16,61 +16,10 @@ import {
type RefApp, type RefApp,
} from "@/lib/sam"; } from "@/lib/sam";
// Store externo: evita setState en efectos (regla react-hooks)
// y auto-refresca cada 15s. Los clics llaman a load() directo.
let cache: ApiResponse | null = null;
let loadError: string | null = null;
let started = false;
let snap: { data: ApiResponse | null; error: string | null } = {
data: null,
error: null,
};
const listeners = new Set<() => void>();
function notify() {
listeners.forEach((l) => l());
}
async function load() {
try {
const res = await fetch("/api/modules", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
cache = await res.json();
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : "Error al cargar módulos";
}
// Referencia estable: solo cambia si cambian los datos (evita loops)
if (snap.data !== cache || snap.error !== loadError) {
snap = { data: cache, error: loadError };
}
notify();
}
function subscribe(cb: () => void) {
if (!started) {
started = true;
load();
setInterval(load, 15000);
}
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
function snapshot(): { data: ApiResponse | null; error: string | null } {
return snap;
}
export default function Home() { export default function Home() {
const { data, error } = useSyncExternalStore( const { data, error } = useModules();
subscribe,
snapshot,
() => ({ data: null, error: null })
);
const loading = data === null && error === null; const loading = data === null && error === null;
const fetchModules = load; const fetchModules = refreshModules;
const modules = data?.modules ?? []; const modules = data?.modules ?? [];
const backend = data?.backend; const backend = data?.backend;
@@ -190,8 +139,11 @@ export default function Home() {
<div className="col-12 text-center"> <div className="col-12 text-center">
<h4 className="fw-semibold opacity-75">Aplicaciones</h4> <h4 className="fw-semibold opacity-75">Aplicaciones</h4>
</div> </div>
<div className="col-12 text-center mt-4"> <div className="col-12 text-center mt-4 d-flex flex-column gap-2 align-items-center">
<Link href="/upload" className="btn btn-light btn-sm px-4"> <Link href="/tablero" className="btn btn-light btn-sm px-4">
<i className="bx bx-pie-chart-alt-2 me-1" /> Tablero
</Link>
<Link href="/upload" className="btn btn-outline-light btn-sm px-4">
<i className="bx bx-plus me-1" /> Nuevo Módulo <i className="bx bx-plus me-1" /> Nuevo Módulo
</Link> </Link>
</div> </div>
+275
View File
@@ -0,0 +1,275 @@
"use client";
import Link from "next/link";
import SamShell from "@/components/SamShell";
import { useModules, useStats } from "@/lib/store";
import {
displayName,
envOf,
getModuleMeta,
moduleUrl,
sectionOf,
SECTIONS,
type ApiResponse,
type ModuleState,
} from "@/lib/sam";
import {
healthOf,
memUsed,
pct,
shortUptime,
statFor,
type ContainerStat,
} from "@/lib/stats";
function Donut({ ok, starting, down }: { ok: number; starting: number; down: number }) {
const total = ok + starting + down;
if (total === 0)
return <p className="text-muted mb-0">Sin módulos integrados.</p>;
const R = 54;
const C = 2 * Math.PI * R;
const segs = [
{ v: ok, color: "#198754" },
{ v: starting, color: "#ffc107" },
{ v: down, color: "#dc3545" },
];
let acc = 0;
return (
<div className="d-flex align-items-center gap-4 flex-wrap">
<svg width="140" height="140" viewBox="0 0 140 140">
<circle cx="70" cy="70" r={R} fill="none" stroke="#e9ecef" strokeWidth="16" />
{segs.map((s, i) => {
if (s.v === 0) return null;
const frac = s.v / total;
const el = (
<circle
key={i}
cx="70"
cy="70"
r={R}
fill="none"
stroke={s.color}
strokeWidth="16"
strokeDasharray={`${frac * C} ${C}`}
strokeDashoffset={-acc * C}
transform="rotate(-90 70 70)"
strokeLinecap="butt"
/>
);
acc += frac;
return el;
})}
<text x="70" y="66" textAnchor="middle" fontSize="26" fontWeight="700" fill="#00285d">
{total}
</text>
<text x="70" y="86" textAnchor="middle" fontSize="12" fill="#6c757d">
módulos
</text>
</svg>
<ul className="list-unstyled mb-0">
<li className="mb-1">
<span className="status-dot status-healthy me-2" />
Activos <strong>{ok}</strong>
</li>
<li className="mb-1">
<span className="status-dot status-starting me-2" />
Iniciando <strong>{starting}</strong>
</li>
<li>
<span className="status-dot status-unhealthy me-2" />
Detenidos <strong>{down}</strong>
</li>
</ul>
</div>
);
}
function Bar({ value, color }: { value: number; color: string }) {
return (
<div className="progress" style={{ height: 8 }}>
<div
className="progress-bar"
role="progressbar"
style={{ width: `${value}%`, backgroundColor: color }}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
);
}
function AppCard({ m, stat }: { m: ModuleState; stat?: ContainerStat }) {
const meta = getModuleMeta(m.name);
const env = envOf(m.branch);
const cpu = pct(stat?.cpu);
const mem = pct(stat?.memPerc);
return (
<div className="col-md-6 col-xl-4">
<div className="app-card p-3 h-100">
<div className="d-flex align-items-center gap-2 mb-2">
<i className={`bx ${meta.icon} bx-border text-light ${meta.bg}`} />
<div>
<strong>{displayName(m.name)}</strong>
<div>
<small className="text-muted">
{shortUptime(stat?.status ?? m.container)} · :{m.port}
</small>{" "}
<span className={`badge ${env.cls} badge-respaldo`}>{env.label}</span>
</div>
</div>
</div>
<div className="mb-2">
<div className="d-flex justify-content-between">
<small className="text-muted">CPU</small>
<small className="fw-semibold">{stat?.cpu ?? "—"}</small>
</div>
<Bar value={cpu} color="#0d6efd" />
</div>
<div className="mb-2">
<div className="d-flex justify-content-between">
<small className="text-muted">MEM {memUsed(stat)}</small>
<small className="fw-semibold">{stat?.memPerc ?? "—"}</small>
</div>
<Bar value={mem} color="#6f42c1" />
</div>
<div className="d-flex gap-2 mt-2">
<Link href={`/m/${m.name}`} className="btn btn-sm btn-outline-primary flex-fill">
<i className="bx bx-grid-alt me-1" /> Tablero
</Link>
<a
href={moduleUrl(m)}
target="_blank"
rel="noopener noreferrer"
className="btn btn-sm btn-outline-secondary"
>
<i className="bx bx-window-open" />
</a>
</div>
</div>
</div>
);
}
export default function Tablero() {
const { data } = useModules();
const stats = useStats();
const loading = data === null;
const modules = data?.modules ?? [];
const ok = modules.filter((m) => healthOf(m) === "ok").length;
const starting = modules.filter((m) => healthOf(m) === "starting").length;
const down = modules.length - ok - starting;
const prod = modules.filter(
(m) => m.branch.toLowerCase() === "main" || m.branch.toLowerCase() === "master"
).length;
const menu = [
{ label: "Resumen", icon: "bx-grid-alt", href: "#resumen" },
...modules.map((m) => ({
label: displayName(m.name),
icon: getModuleMeta(m.name).icon,
href: `/m/${m.name}`,
})),
];
const core = ["sam-api", "sam-deployer", "sam-postgres", "sam-redis"]
.map((n) => statFor(stats, n))
.filter((s): s is ContainerStat => Boolean(s));
return (
<SamShell
brand="Tablero"
subtitle="Gestión de aplicaciones"
menu={menu}
activeHref="#resumen"
topUser="SAM"
backHref="/"
>
{loading && (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status" />
</div>
)}
{!loading && (
<>
<div className="row row-cards" id="resumen">
<div className="col-12">
<div className="app-card p-4">
<h6 className="fw-bold mb-3">
<i className="bx bx-pie-chart-alt-2 me-2" />
Estado de todos los módulos
</h6>
<div className="d-flex gap-5 flex-wrap align-items-center">
<Donut ok={ok} starting={starting} down={down} />
<div>
<div className="mb-1">
<small className="text-muted">Producción (main)</small>
<div className="fw-bold fs-5">{prod}</div>
</div>
<div>
<small className="text-muted">Desarrollo (otras ramas)</small>
<div className="fw-bold fs-5">{modules.length - prod}</div>
</div>
</div>
</div>
</div>
</div>
</div>
{SECTIONS.map((section) => {
const list = modules.filter(
(m) => sectionOf(m.name, m.category) === section.id
);
if (list.length === 0) return null;
return (
<div key={section.id}>
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
<i className={`bx ${section.icon} me-1`} /> {section.title}
</div>
<div className="row row-cards">
{list.map((m) => (
<AppCard key={m.name} m={m} stat={statFor(stats, `${m.name}-frontend`)} />
))}
</div>
</div>
);
})}
{core.length > 0 && (
<>
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
<i className="bx bx-server me-1" /> Servicios base
</div>
<div className="row row-cards">
{core.map((s) => (
<div className="col-md-6 col-xl-3" key={s.name}>
<div className="app-card p-3">
<strong className="small">{s.name}</strong>
<div className="small text-muted mb-2">
{shortUptime(s.status)} · {s.pids} proc
</div>
<div className="d-flex justify-content-between">
<small className="text-muted">CPU {s.cpu}</small>
<small className="text-muted">MEM {s.memPerc}</small>
</div>
<Bar value={pct(s.cpu)} color="#0d6efd" />
<div className="mt-1">
<Bar value={pct(s.memPerc)} color="#6f42c1" />
</div>
</div>
</div>
))}
</div>
</>
)}
<p className="text-muted small mt-4 mb-4 text-center">
Auto-actualizado cada 30s · Carga de trabajo en tiempo real (docker stats)
</p>
</>
)}
</SamShell>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { ModuleState } from "./sam";
export interface ContainerStat {
name: string;
container: string;
cpu: string;
memUsage: string;
memPerc: string;
netIO: string;
blockIO: string;
pids: string;
status: string | null;
}
export function pct(s: string | undefined): number {
const v = parseFloat((s || "").replace("%", ""));
if (isNaN(v)) return 0;
return Math.min(100, Math.max(0, v));
}
export function shortUptime(status: string | null | undefined): string {
if (!status) return "—";
return status.split("(")[0].trim().replace(/^Up\s+/i, "");
}
export function memUsed(stat: ContainerStat | undefined): string {
if (!stat) return "—";
return stat.memUsage.split("/")[0].trim();
}
export function statFor(
stats: ContainerStat[],
containerName: string
): ContainerStat | undefined {
return stats.find((s) => s.name === containerName);
}
export type Health = "ok" | "starting" | "down";
export function healthOf(m: ModuleState): Health {
if (!m.container) return "down";
if (m.container.includes("starting")) return "starting";
return "ok";
}
+108
View File
@@ -0,0 +1,108 @@
// ============================================================
// Stores externos (useSyncExternalStore): datos del deployer
// con auto-refresh, sin setState en efectos (regla react-hooks).
// ============================================================
import { useSyncExternalStore } from "react";
import type { ApiResponse } from "./sam";
import type { ContainerStat } from "./stats";
interface ModulesSnap {
data: ApiResponse | null;
error: string | null;
}
function createStore<T>(
fetcher: () => Promise<T>,
initial: T,
ms: number
): { subscribe: (cb: () => void) => () => void; snapshot: () => T; load: () => void } {
let snap = initial;
let started = false;
let timer: ReturnType<typeof setInterval> | null = null;
const listeners = new Set<() => void>();
const notify = () => {
listeners.forEach((l) => l());
};
const load = async () => {
try {
const next = await fetcher();
if (next !== snap) snap = next;
} catch {
// el error lo modela cada fetcher dentro de su snapshot
}
notify();
};
const subscribe = (cb: () => void) => {
if (!started) {
started = true;
load();
timer = setInterval(load, ms);
}
listeners.add(cb);
return () => {
listeners.delete(cb);
if (listeners.size === 0 && timer) {
clearInterval(timer);
timer = null;
started = false;
}
};
};
return { subscribe, snapshot: () => snap, load };
}
async function fetchModules(): Promise<ModulesSnap> {
try {
const res = await fetch("/api/modules", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { data: await res.json(), error: null };
} catch (e) {
return {
data: null,
error: e instanceof Error ? e.message : "Error al cargar módulos",
};
}
}
async function fetchStats(): Promise<ContainerStat[]> {
try {
const res = await fetch("/api/stats", { cache: "no-store" });
if (!res.ok) return [];
const j = await res.json();
return Array.isArray(j.stats) ? j.stats : [];
} catch {
return [];
}
}
const modulesStore = createStore(fetchModules, { data: null, error: null }, 15000);
const statsStore = createStore(fetchStats, [] as ContainerStat[], 30000);
export function useModules() {
return useSyncExternalStore(
modulesStore.subscribe,
modulesStore.snapshot,
() => ({ data: null, error: null })
);
}
export function useStats() {
return useSyncExternalStore(
statsStore.subscribe,
statsStore.snapshot,
() => [] as ContainerStat[]
);
}
export function refreshModules() {
modulesStore.load();
}
export function refreshStats() {
statsStore.load();
}