feat: tablero como Server Component, CSP y stores sin re-render

La página /tablero lee sesión y trae módulos (15s) + stats (30s) en el servidor; TableroView recibe props y el navegador ya no pollea al deployer. CSP endurecido compatible con estáticas (connect/form/img acotados, sin exfiltración externa). Logout limpia el usuario cacheado, los stores comparan contenido antes de notificar y formato en actualizar.
This commit is contained in:
Nurfog committed 2026-10-08 11:32:52 -03:00
1 parent 926d41dc51
commit ab6a5a148c
7 files changed
+111 -32

No files matched your search

+20
View File
@@ -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 },
],
},
];
+2 -1
View File
@@ -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(
+15 -9
View File
@@ -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 (
<SamShell
brand="IT"
subtitle="Gestión de aplicaciones"
menu={itMenu(data?.modules ?? [])}
menu={itMenu(modules)}
activeHref="/tablero"
topUser={me?.email || "SAM"}
topUser={sess?.email || "SAM"}
allowLogout
backHref="/"
>
<TableroView />
<TableroView modules={modules} stats={stats} />
</SamShell>
);
}
+2
View File
@@ -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");
}
};
+17 -17
View File
@@ -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 <IndexRefresh /> 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 (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status" />
<p className="text-muted mb-0">Sin módulos integrados.</p>
</div>
);
}
@@ -411,7 +410,8 @@ export default function TableroView() {
<ApiUsage />
<p className="text-muted small mt-4 mb-4 text-center">
Auto-actualizado cada 30s · Carga de trabajo en tiempo real (docker stats)
<IndexRefresh intervalMs={30000} /> · Carga de trabajo en tiempo real
(docker stats)
</p>
</>
);
+25
View File
@@ -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<ContainerStat[]> {
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,
next: { revalidate: STATS_REVALIDATE_S },
});
if (!res.ok) return [];
const j = await res.json().catch(() => ({}));
return Array.isArray(j.stats) ? j.stats : [];
} catch {
return [];
}
}
+30 -5
View File
@@ -16,15 +16,18 @@ export interface ExternalStore<T> {
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<T>(
fetcher: () => Promise<T>,
initial: T,
ms?: number
ms?: number,
equal: (a: T, b: T) => boolean = (a, b) => a === b
): ExternalStore<T> {
let snap = initial;
let started = false;
@@ -38,9 +41,11 @@ export function createStore<T>(
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<T>(
};
};
return { subscribe, snapshot: () => snap, load };
const reset = (value: T) => {
snap = value;
notify();
};
return { subscribe, snapshot: () => snap, load, reset };
}
async function fetchModules(): Promise<ModulesSnap> {
@@ -123,8 +133,17 @@ async function fetchStats(): Promise<ContainerStat[]> {
}
}
const modulesStore = createStore(fetchModules, { data: null, error: null }, 15000);
const statsStore = createStore(fetchStats, [] as ContainerStat[], 30000);
function stableEqual<T>(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);
}