feat: sidebar de aplicaciones + landing IT por hostname (it.sam)

This commit is contained in:
Nurfog committed 2026-09-08 17:03:52 -03:00
1 parent 3949224d94
commit af638d4154
4 files changed
+357 -338

No files matched your search

+73 -79
View File
@@ -1,7 +1,9 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useModules, refreshModules } from "@/lib/store"; import SamShell from "@/components/SamShell";
import TableroView from "@/components/TableroView";
import { useModules, refreshModules, useSiteSection } from "@/lib/store";
import { import {
displayName, displayName,
envOf, envOf,
@@ -11,16 +13,36 @@ import {
REFERENCE_APPS, REFERENCE_APPS,
sectionOf, sectionOf,
SECTIONS, SECTIONS,
type ApiResponse,
type ModuleState, type ModuleState,
type RefApp,
} from "@/lib/sam"; } from "@/lib/sam";
const sectionMenu = [
...SECTIONS.map((s) => ({ label: s.title, icon: s.icon, href: `/#${s.id}` })),
{ label: "Tablero IT", icon: "bx-pie-chart-alt-2", href: "/tablero" },
];
export default function Home() { export default function Home() {
const { data, error } = useModules(); const { data, error } = useModules();
const site = useSiteSection();
const loading = data === null && error === null; const loading = data === null && error === null;
const fetchModules = refreshModules; const fetchModules = refreshModules;
// it.sam.devops... aterriza directo en el tablero
if (site === "it") {
return (
<SamShell
brand="IT"
subtitle="Tablero de gestión"
menu={sectionMenu}
activeHref="/tablero"
topUser="SAM"
backHref="/"
>
<TableroView />
</SamShell>
);
}
const modules = data?.modules ?? []; const modules = data?.modules ?? [];
const backend = data?.backend; const backend = data?.backend;
@@ -31,42 +53,6 @@ export default function Home() {
bySection[s].push(m); bySection[s].push(m);
}); });
const renderRefCard = (app: RefApp) => (
<div className="col-md-6" key={`ref-${app.name}`}>
<div className="app-card p-2">
<table className="table table-borderless mb-0 align-middle">
<tbody>
<tr>
<td className="ps-3" style={{ width: 56 }}>
<i
className={`bx ${app.icon} bx-sm bx-border text-light ${app.bg}`}
/>
</td>
<td>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
className="text-decoration-none text-dark d-block py-2 stretched-link"
>
{app.title}
</a>
<div className="d-flex align-items-center gap-2">
<small className="text-muted" style={{ fontSize: "0.75rem" }}>
sam.norteamericano.cl
</small>
</div>
</td>
<td className="text-end pe-3" style={{ width: 40 }}>
<i className="bx bx-chevron-right text-muted" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
const renderModuleCard = (m: ModuleState) => { const renderModuleCard = (m: ModuleState) => {
const meta = getModuleMeta(m.name); const meta = getModuleMeta(m.name);
const status = getStatusInfo(m); const status = getStatusInfo(m);
@@ -121,42 +107,55 @@ export default function Home() {
); );
}; };
return ( const renderRefCard = (app: {
<> name: string;
<div className="sidenav"> title: string;
<div className="row text-light login-main-text"> icon: string;
<div className="col-12 text-center"> bg: string;
<div url: string;
className="mx-auto d-flex align-items-center justify-content-center bg-white rounded-3 overflow-hidden" }) => (
style={{ width: 80, height: 80 }} <div className="col-md-6" key={`ref-${app.name}`}>
<div className="app-card p-2">
<table className="table table-borderless mb-0 align-middle">
<tbody>
<tr>
<td className="ps-3" style={{ width: 56 }}>
<i
className={`bx ${app.icon} bx-sm bx-border text-light ${app.bg}`}
/>
</td>
<td>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
className="text-decoration-none text-dark d-block py-2 stretched-link"
> >
<img src="/logo-sam.png" alt="SAM" style={{ width: 64, height: 64, objectFit: "contain" }} /> {app.title}
</div> </a>
</div> <div className="d-flex align-items-center gap-2">
<div className="col-12 text-center mt-3"> <small className="text-muted" style={{ fontSize: "0.75rem" }}>
<h1 className="fw-bold fs-2">Sistema SAM</h1> sam.norteamericano.cl
</div> </small>
<div className="col-12 text-center"> </div>
<h4 className="fw-semibold opacity-75">Aplicaciones</h4> </td>
</div> <td className="text-end pe-3" style={{ width: 40 }}>
<div className="col-12 text-center mt-4 d-flex flex-column gap-2 align-items-center"> <i className="bx bx-chevron-right text-muted" />
<Link href="/tablero" className="btn btn-light btn-sm px-4"> </td>
<i className="bx bx-pie-chart-alt-2 me-1" /> Tablero </tr>
</Link> </tbody>
<Link href="/upload" className="btn btn-outline-light btn-sm px-4"> </table>
<i className="bx bx-plus me-1" /> Nuevo Módulo
</Link>
</div>
<div className="col-12 text-center mt-3">
<small className="opacity-50">Portal de integración</small>
</div>
</div> </div>
</div> </div>
);
<div className="main"> return (
<div className="row" id="modulos"> <SamShell
<div className="col-lg-1" /> brand="SAM"
<div className="col-lg-10 mt-4"> subtitle="Sistema de Administración de Matrículas"
menu={sectionMenu}
topUser="SAM"
>
{loading && ( {loading && (
<div className="text-center py-5"> <div className="text-center py-5">
<div className="spinner-border text-primary" role="status" /> <div className="spinner-border text-primary" role="status" />
@@ -200,7 +199,7 @@ export default function Home() {
const isReview = section.id === "otras-apps"; const isReview = section.id === "otras-apps";
if (!isReview && list.length === 0) return null; if (!isReview && list.length === 0) return null;
return ( return (
<div className="col-12" key={section.id}> <div className="col-12" key={section.id} id={section.id}>
<div className="row row-cards"> <div className="row row-cards">
<div className="col-12"> <div className="col-12">
<div className="alert alert-light section-header mb-3 py-2 px-3 d-flex justify-content-between align-items-center"> <div className="alert alert-light section-header mb-3 py-2 px-3 d-flex justify-content-between align-items-center">
@@ -222,9 +221,7 @@ export default function Home() {
{section.id === "sam" && {section.id === "sam" &&
REFERENCE_APPS.filter( REFERENCE_APPS.filter(
(a) => (a) =>
!list.some( !list.some((m) => m.name.toLowerCase() === a.name)
(m) => m.name.toLowerCase() === a.name
)
).map(renderRefCard)} ).map(renderRefCard)}
{isReview && backend && ( {isReview && backend && (
@@ -296,9 +293,6 @@ export default function Home() {
</div> </div>
</div> </div>
)} )}
</div> </SamShell>
</div>
</div>
</>
); );
} }
+8 -259
View File
@@ -1,275 +1,24 @@
"use client"; "use client";
import Link from "next/link";
import SamShell from "@/components/SamShell"; import SamShell from "@/components/SamShell";
import { useModules, useStats } from "@/lib/store"; import TableroView from "@/components/TableroView";
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 menu = [
const total = ok + starting + down; { label: "Resumen", icon: "bx-grid-alt", href: "#resumen" },
if (total === 0) { label: "Aplicaciones", icon: "bx-layer", href: "/" },
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() { 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 ( return (
<SamShell <SamShell
brand="Tablero" brand="IT"
subtitle="Gestión de aplicaciones" subtitle="Tablero de gestión"
menu={menu} menu={menu}
activeHref="#resumen" activeHref="#resumen"
topUser="SAM" topUser="SAM"
backHref="/" backHref="/"
> >
{loading && ( <TableroView />
<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> </SamShell>
); );
} }
+259
View File
@@ -0,0 +1,259 @@
"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,
} from "@/lib/sam";
import {
healthOf,
memUsed,
pct,
shortUptime,
statFor,
type ContainerStat,
} from "@/lib/stats";
import type { ModuleState } from "@/lib/sam";
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>
);
}
// Vista del tablero (contenido): tarjeta grande de estado global +
// estado y carga por aplicación. Se usa en /tablero y como
// landing de it.sam.devops.norteamericano.cl
export default function TableroView() {
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 core = ["sam-api", "sam-deployer", "sam-postgres", "sam-redis"]
.map((n) => statFor(stats, n))
.filter((s): s is ContainerStat => Boolean(s));
if (loading) {
return (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status" />
</div>
);
}
return (
<>
<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>
</>
);
}
+17
View File
@@ -106,3 +106,20 @@ export function refreshModules() {
export function refreshStats() { export function refreshStats() {
statsStore.load(); statsStore.load();
} }
// Sección del sitio según hostname: it.sam.devops... -> "it".
// useSyncExternalStore evita mismatch de hidratación SSR/cliente.
function siteSnapshot(): string {
const h = window.location.hostname.toLowerCase();
const first = h.split(".")[0];
if (first === "it" && h.endsWith("sam.devops.norteamericano.cl")) return "it";
return "";
}
function subscribeNoop() {
return () => {};
}
export function useSiteSection(): string {
return useSyncExternalStore(subscribeNoop, siteSnapshot, () => "");
}