diff --git a/next.config.ts b/next.config.ts
index 7d3b4f2..889e105 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -3,6 +3,25 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
async headers() {
+ // CSP de endurecimiento compatible con páginas estáticas.
+ // Sin nonces a propósito: los nonces exigen render dinámico en
+ // todas las páginas (rompería el caché del índice) y style-src
+ // estricto bloquearía los `style` inline de la UI. `connect-src`,
+ // `form-action` e `img-src` acotan el impacto de un XSS
+ // (sin exfiltración a orígenes externos).
+ const csp = [
+ "default-src 'self'",
+ "script-src 'self' 'unsafe-inline'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob:",
+ "font-src 'self' data:",
+ "connect-src 'self'",
+ "object-src 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ "frame-ancestors 'none'",
+ "upgrade-insecure-requests",
+ ].join("; ");
return [
{
source: "/:path*",
@@ -10,6 +29,7 @@ const nextConfig: NextConfig = {
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "DENY" },
+ { key: "Content-Security-Policy", value: csp },
],
},
];
diff --git a/src/app/actualizar/page.tsx b/src/app/actualizar/page.tsx
index 58cce48..5c906e4 100644
--- a/src/app/actualizar/page.tsx
+++ b/src/app/actualizar/page.tsx
@@ -38,7 +38,8 @@ export default function Actualizar() {
}
};
- const call = async (name: string, action: "deploy" | "webhook") => { setBusy(`${action}:${name}`);
+ const call = async (name: string, action: "deploy" | "webhook") => {
+ setBusy(`${action}:${name}`);
setMsg(null);
try {
const res = await fetch(
diff --git a/src/app/tablero/page.tsx b/src/app/tablero/page.tsx
index d63aed0..e5b85cb 100644
--- a/src/app/tablero/page.tsx
+++ b/src/app/tablero/page.tsx
@@ -1,28 +1,34 @@
-"use client";
-
+import { cookies } from "next/headers";
import SamShell from "@/components/SamShell";
import TableroView from "@/components/TableroView";
-import { useModules, useMe } from "@/lib/store"
+import { COOKIE, readSession } from "@/lib/auth";
+import { getPublicModules } from "@/lib/modules-server";
+import { getContainerStats } from "@/lib/stats-server";
import { itMenu } from "@/lib/sam";
// App IT: sidebar con todas sus opciones (resumen, aplicaciones,
// tableros por módulo, agregar, actualizar, eliminar).
// Se sirve en /tablero y en it.sam.devops.norteamericano.cl
-export default function Tablero() {
- const { data } = useModules();
- const me = useMe();
+// Server Component: módulos y stats con caché (15s/30s); la página
+// revalida y el cliente refresca la vista sin polling directo.
+export default async function Tablero() {
+ const sess = await readSession((await cookies()).get(COOKIE)?.value);
+ const [{ modules }, stats] = await Promise.all([
+ getPublicModules(),
+ getContainerStats(),
+ ]);
return (
-
+
);
}
diff --git a/src/components/SamShell.tsx b/src/components/SamShell.tsx
index 20f16b5..93d4a21 100644
--- a/src/components/SamShell.tsx
+++ b/src/components/SamShell.tsx
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { MenuItem } from "@/lib/sam";
+import { clearMe } from "@/lib/store";
interface SamShellProps {
brand: string;
@@ -39,6 +40,7 @@ export default function SamShell({
try {
await fetch("/api/auth", { method: "DELETE" });
} finally {
+ clearMe();
router.push("/login");
}
};
diff --git a/src/components/TableroView.tsx b/src/components/TableroView.tsx
index deecf89..9ec4ea4 100644
--- a/src/components/TableroView.tsx
+++ b/src/components/TableroView.tsx
@@ -1,9 +1,9 @@
"use client";
import Link from "next/link";
-import { useEffect, useMemo, useState, type CSSProperties } from "react";
+import { useEffect, useState, type CSSProperties } from "react";
import { Bar } from "@/components/ProgressBar";
-import { useModules, useStats } from "@/lib/store";
+import { IndexRefresh } from "@/components/IndexRefresh";
import {
assignColorsForModules,
displayName,
@@ -13,8 +13,8 @@ import {
moduleUrl,
sectionOf,
SECTIONS,
- visibleModules,
} from "@/lib/sam";
+import type { ModuleState } from "@/lib/sam";
import {
healthOf,
memUsed,
@@ -24,7 +24,6 @@ import {
statForModule,
type ContainerStat,
} from "@/lib/stats";
-import type { ModuleState } from "@/lib/sam";
import type { ApiControllerInfo } from "@/lib/api-inventory";
function Donut({ ok, starting, down }: { ok: number; starting: number; down: number }) {
@@ -299,18 +298,18 @@ function ApiUsage() {
// Vista del tablero (contenido): tarjeta grande de estado global +
// estado y carga por aplicación + uso real de la API.
// 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 = useMemo(
- () => visibleModules(data?.modules ?? []),
- [data]
- );
+// Recibe datos del Server Component (sin polling en el cliente;
+// la página revalida cada 30s e refresca la vista).
+export default function TableroView({
+ modules,
+ stats,
+}: {
+ modules: ModuleState[];
+ stats: ContainerStat[];
+}) {
// Mismo color que en el índice: guardado manda, al resto se le genera
// uno sin repetir para lo ya importado.
- const colorMap = useMemo(() => assignColorsForModules(modules), [modules]);
+ const colorMap = assignColorsForModules(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;
@@ -322,10 +321,10 @@ export default function TableroView() {
.map((n) => statFor(stats, n))
.filter((s): s is ContainerStat => Boolean(s));
- if (loading) {
+ if (modules.length === 0) {
return (
-
+
Sin módulos integrados.
);
}
@@ -411,7 +410,8 @@ export default function TableroView() {
- Auto-actualizado cada 30s · Carga de trabajo en tiempo real (docker stats)
+ · Carga de trabajo en tiempo real
+ (docker stats)
>
);
diff --git a/src/lib/stats-server.ts b/src/lib/stats-server.ts
new file mode 100644
index 0000000..07152d5
--- /dev/null
+++ b/src/lib/stats-server.ts
@@ -0,0 +1,25 @@
+// Acceso server-side a docker stats del deployer para Server Components.
+// Solo importa este módulo desde componentes de servidor o route handlers:
+// contiene el token del deployer (nunca sale al navegador).
+import type { ContainerStat } from "./stats";
+
+const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
+const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
+
+export const STATS_REVALIDATE_S = 30;
+
+export async function getContainerStats(): Promise {
+ 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,
+ next: { revalidate: STATS_REVALIDATE_S },
+ });
+ if (!res.ok) return [];
+ const j = await res.json().catch(() => ({}));
+ return Array.isArray(j.stats) ? j.stats : [];
+ } catch {
+ return [];
+ }
+}
diff --git a/src/lib/store.ts b/src/lib/store.ts
index 65f7d44..9afbf6c 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -16,15 +16,18 @@ export interface ExternalStore {
subscribe: (cb: () => void) => () => void;
snapshot: () => T;
load: () => void;
+ reset: (value: T) => void;
}
// Store externo genérico (useSyncExternalStore): primera carga +
// auto-refresh opcional con `ms`. Sin `ms` solo carga manual vía load().
+// `equal` evita notificar (y re-renderizar) cuando el contenido no cambió.
// Sin setState en efectos (regla react-hooks).
export function createStore(
fetcher: () => Promise,
initial: T,
- ms?: number
+ ms?: number,
+ equal: (a: T, b: T) => boolean = (a, b) => a === b
): ExternalStore {
let snap = initial;
let started = false;
@@ -38,9 +41,11 @@ export function createStore(
const load = async () => {
try {
const next = await fetcher();
- if (next !== snap) snap = next;
+ if (equal(next, snap)) return;
+ snap = next;
} catch {
// el error lo modela cada fetcher dentro de su snapshot
+ return;
}
notify();
};
@@ -96,7 +101,12 @@ export function createStore(
};
};
- return { subscribe, snapshot: () => snap, load };
+ const reset = (value: T) => {
+ snap = value;
+ notify();
+ };
+
+ return { subscribe, snapshot: () => snap, load, reset };
}
async function fetchModules(): Promise {
@@ -123,8 +133,17 @@ async function fetchStats(): Promise {
}
}
-const modulesStore = createStore(fetchModules, { data: null, error: null }, 15000);
-const statsStore = createStore(fetchStats, [] as ContainerStat[], 30000);
+function stableEqual(a: T, b: T): boolean {
+ if (a === b) return true;
+ try {
+ return JSON.stringify(a) === JSON.stringify(b);
+ } catch {
+ return false;
+ }
+}
+
+const modulesStore = createStore(fetchModules, { data: null, error: null }, 15000, stableEqual);
+const statsStore = createStore(fetchStats, [] as ContainerStat[], 30000, stableEqual);
export function useModules() {
return useSyncExternalStore(
@@ -166,3 +185,9 @@ const meStore = createStore(fetchMe, null as { email: string } | null, 60000);
export function useMe() {
return useSyncExternalStore(meStore.subscribe, meStore.snapshot, () => null);
}
+
+// Limpia la sesión cacheada al cerrar sesión (si no, el topbar
+// conserva el usuario hasta el próximo poll de 60s).
+export function clearMe() {
+ meStore.reset(null);
+}