From 3949224d946128322a0048b6edcbea28a163ef2e Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 8 Sep 2026 16:56:11 -0300 Subject: [PATCH] feat: tablero de gestion con graficas de estado y carga por aplicacion --- src/app/api/stats/route.ts | 24 ++++ src/app/page.tsx | 64 ++------- src/app/tablero/page.tsx | 275 +++++++++++++++++++++++++++++++++++++ src/lib/stats.ts | 44 ++++++ src/lib/store.ts | 108 +++++++++++++++ 5 files changed, 459 insertions(+), 56 deletions(-) create mode 100644 src/app/api/stats/route.ts create mode 100644 src/app/tablero/page.tsx create mode 100644 src/lib/stats.ts create mode 100644 src/lib/store.ts diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts new file mode 100644 index 0000000..40006fe --- /dev/null +++ b/src/app/api/stats/route.ts @@ -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 = { + "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 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 05292ab..23b388a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() {

Aplicaciones

-
- +
+ + Tablero + + Nuevo Módulo
diff --git a/src/app/tablero/page.tsx b/src/app/tablero/page.tsx new file mode 100644 index 0000000..02ef63c --- /dev/null +++ b/src/app/tablero/page.tsx @@ -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

Sin módulos integrados.

; + 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 ( +
+ + + {segs.map((s, i) => { + if (s.v === 0) return null; + const frac = s.v / total; + const el = ( + + ); + acc += frac; + return el; + })} + + {total} + + + módulos + + +
    +
  • + + Activos {ok} +
  • +
  • + + Iniciando {starting} +
  • +
  • + + Detenidos {down} +
  • +
+
+ ); +} + +function Bar({ value, color }: { value: number; color: string }) { + return ( +
+
+
+ ); +} + +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 ( +
+
+
+ +
+ {displayName(m.name)} +
+ + {shortUptime(stat?.status ?? m.container)} · :{m.port} + {" "} + {env.label} +
+
+
+
+
+ CPU + {stat?.cpu ?? "—"} +
+ +
+
+
+ MEM {memUsed(stat)} + {stat?.memPerc ?? "—"} +
+ +
+
+ + Tablero + + + + +
+
+
+ ); +} + +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 ( + + {loading && ( +
+
+
+ )} + + {!loading && ( + <> +
+
+
+
+ + Estado de todos los módulos +
+
+ +
+
+ Producción (main) +
{prod}
+
+
+ Desarrollo (otras ramas) +
{modules.length - prod}
+
+
+
+
+
+
+ + {SECTIONS.map((section) => { + const list = modules.filter( + (m) => sectionOf(m.name, m.category) === section.id + ); + if (list.length === 0) return null; + return ( +
+
+ {section.title} +
+
+ {list.map((m) => ( + + ))} +
+
+ ); + })} + + {core.length > 0 && ( + <> +
+ Servicios base +
+
+ {core.map((s) => ( +
+
+ {s.name} +
+ {shortUptime(s.status)} · {s.pids} proc +
+
+ CPU {s.cpu} + MEM {s.memPerc} +
+ +
+ +
+
+
+ ))} +
+ + )} + +

+ Auto-actualizado cada 30s · Carga de trabajo en tiempo real (docker stats) +

+ + )} + + ); +} diff --git a/src/lib/stats.ts b/src/lib/stats.ts new file mode 100644 index 0000000..3bd6a72 --- /dev/null +++ b/src/lib/stats.ts @@ -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"; +} diff --git a/src/lib/store.ts b/src/lib/store.ts new file mode 100644 index 0000000..a49b4e2 --- /dev/null +++ b/src/lib/store.ts @@ -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( + fetcher: () => Promise, + initial: T, + ms: number +): { subscribe: (cb: () => void) => () => void; snapshot: () => T; load: () => void } { + let snap = initial; + let started = false; + let timer: ReturnType | 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 { + 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 { + 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(); +}