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";
|
"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>
|
||||||
|
|||||||
@@ -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