feat: index SAM con tableros por modulo (SamShell Academico + sidebar de funciones API)
This commit is contained in:
1 parent
6d30e1c274
commit
d30374729c
8 files changed
+719
-86
No files matched your search
+67
-84
@@ -1,85 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
displayName,
|
||||
getModuleMeta,
|
||||
getStatusInfo,
|
||||
moduleUrl,
|
||||
type ApiResponse,
|
||||
} from "@/lib/sam";
|
||||
|
||||
interface ModuleState {
|
||||
name: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
subdomain: string;
|
||||
port: number;
|
||||
active: boolean;
|
||||
lastDeploy?: {
|
||||
sha: string;
|
||||
time: string;
|
||||
ok: boolean;
|
||||
log: string;
|
||||
// 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;
|
||||
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";
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
if (!started) {
|
||||
started = true;
|
||||
load();
|
||||
setInterval(load, 15000);
|
||||
}
|
||||
listeners.add(cb);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
};
|
||||
container?: string | null;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
backend: {
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
container: string | null;
|
||||
lastDeploy: ModuleState["lastDeploy"] | null;
|
||||
};
|
||||
modules: ModuleState[];
|
||||
}
|
||||
|
||||
const iconMap: Record<string, { icon: string; bg: string }> = {
|
||||
ventas: { icon: "bx-money", bg: "bg-success" },
|
||||
comercial: { icon: "bx-money", bg: "bg-success" },
|
||||
finanzas: { icon: "bx-detail", bg: "bg-danger" },
|
||||
contabilidad: { icon: "bx-detail", bg: "bg-danger" },
|
||||
academico: { icon: "bx-user", bg: "bg-primary" },
|
||||
"academico-empresas": { icon: "bx-briefcase", bg: "bg-primary" },
|
||||
profesor: { icon: "bx-pencil", bg: "bg-warning text-dark" },
|
||||
profesoras: { icon: "bx-pencil", bg: "bg-warning text-dark" },
|
||||
empresas: { icon: "bx-building", bg: "bg-info" },
|
||||
pagos: { icon: "bx-credit-card", bg: "bg-success" },
|
||||
default: { icon: "bx-cube", bg: "bg-secondary" },
|
||||
};
|
||||
|
||||
function getModuleMeta(name: string) {
|
||||
const key = name.toLowerCase();
|
||||
return iconMap[key] || iconMap.default;
|
||||
}
|
||||
|
||||
function getStatusInfo(m: ModuleState) {
|
||||
if (!m.container) return { label: "Detenido", cls: "status-unhealthy" };
|
||||
if (m.container.includes("healthy")) return { label: "Activo", cls: "status-healthy" };
|
||||
if (m.container.includes("starting")) return { label: "Iniciando", cls: "status-starting" };
|
||||
return { label: "Activo", cls: "status-healthy" };
|
||||
function snapshot(): { data: ApiResponse | null; error: string | null } {
|
||||
return { data: cache, error: loadError };
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [data, setData] = useState<ApiResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchModules = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/modules", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Error al cargar módulos");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchModules();
|
||||
const id = setInterval(fetchModules, 15000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const { data, error } = useSyncExternalStore(
|
||||
subscribe,
|
||||
snapshot,
|
||||
() => ({ data: null, error: null })
|
||||
);
|
||||
const loading = data === null && error === null;
|
||||
const fetchModules = load;
|
||||
|
||||
const modules = data?.modules ?? [];
|
||||
const backend = data?.backend;
|
||||
@@ -90,10 +67,10 @@ export default function Home() {
|
||||
<div className="row text-light login-main-text">
|
||||
<div className="col-12 text-center">
|
||||
<div
|
||||
className="mx-auto d-flex align-items-center justify-content-center bg-white rounded-3"
|
||||
className="mx-auto d-flex align-items-center justify-content-center bg-white rounded-3 overflow-hidden"
|
||||
style={{ width: 80, height: 80 }}
|
||||
>
|
||||
<i className="bx bx-layer text-primary" style={{ fontSize: 48 }} />
|
||||
<img src="/logo-sam.png" alt="SAM" style={{ width: 64, height: 64, objectFit: "contain" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 text-center mt-3">
|
||||
@@ -168,9 +145,8 @@ export default function Home() {
|
||||
modules.map((m) => {
|
||||
const meta = getModuleMeta(m.name);
|
||||
const status = getStatusInfo(m);
|
||||
const url = `https://${m.subdomain}.devops.norteamericano.cl`;
|
||||
const displayName =
|
||||
m.name.charAt(0).toUpperCase() + m.name.slice(1).replace(/-/g, " ");
|
||||
const url = moduleUrl(m);
|
||||
const name = displayName(m.name);
|
||||
return (
|
||||
<div className="col-md-6" key={m.name}>
|
||||
<div className="app-card p-2">
|
||||
@@ -183,14 +159,12 @@ export default function Home() {
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Link
|
||||
href={`/m/${m.name}`}
|
||||
className="text-decoration-none text-dark d-block py-2 stretched-link"
|
||||
>
|
||||
{displayName}
|
||||
</a>
|
||||
{name}
|
||||
</Link>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span className={`status-dot ${status.cls}`} />
|
||||
<small className="text-muted" style={{ fontSize: "0.75rem" }}>
|
||||
@@ -199,7 +173,16 @@ export default function Home() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-end pe-3" style={{ width: 40 }}>
|
||||
<i className="bx bx-chevron-right text-muted" />
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Abrir ${url}`}
|
||||
className="position-relative"
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<i className="bx bx-window-open text-muted" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
Reference in new issue
Block a user