feat: tablero de gestion con graficas de estado y carga por aplicacion
This commit is contained in:
1 parent
646a3bf628
commit
3949224d94
5 files changed
+459
-56
No files matched your search
@@ -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
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useModules, refreshModules } from "@/lib/store";
|
||||
import {
|
||||
displayName,
|
||||
envOf,
|
||||
@@ -16,61 +16,10 @@ import {
|
||||
type RefApp,
|
||||
} 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() {
|
||||
const { data, error } = useSyncExternalStore(
|
||||
subscribe,
|
||||
snapshot,
|
||||
() => ({ data: null, error: null })
|
||||
);
|
||||
const { data, error } = useModules();
|
||||
const loading = data === null && error === null;
|
||||
const fetchModules = load;
|
||||
const fetchModules = refreshModules;
|
||||
|
||||
const modules = data?.modules ?? [];
|
||||
const backend = data?.backend;
|
||||
@@ -190,8 +139,11 @@ export default function Home() {
|
||||
<div className="col-12 text-center">
|
||||
<h4 className="fw-semibold opacity-75">Aplicaciones</h4>
|
||||
</div>
|
||||
<div className="col-12 text-center mt-4">
|
||||
<Link href="/upload" className="btn btn-light btn-sm px-4">
|
||||
<div className="col-12 text-center mt-4 d-flex flex-column gap-2 align-items-center">
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user