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 = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
async headers() { 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 [ return [
{ {
source: "/:path*", source: "/:path*",
@@ -10,6 +29,7 @@ const nextConfig: NextConfig = {
{ key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "DENY" }, { 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); setMsg(null);
try { try {
const res = await fetch( 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 SamShell from "@/components/SamShell";
import TableroView from "@/components/TableroView"; 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"; import { itMenu } from "@/lib/sam";
// App IT: sidebar con todas sus opciones (resumen, aplicaciones, // App IT: sidebar con todas sus opciones (resumen, aplicaciones,
// tableros por módulo, agregar, actualizar, eliminar). // tableros por módulo, agregar, actualizar, eliminar).
// Se sirve en /tablero y en it.sam.devops.norteamericano.cl // Se sirve en /tablero y en it.sam.devops.norteamericano.cl
export default function Tablero() { // Server Component: módulos y stats con caché (15s/30s); la página
const { data } = useModules(); // revalida y el cliente refresca la vista sin polling directo.
const me = useMe(); export default async function Tablero() {
const sess = await readSession((await cookies()).get(COOKIE)?.value);
const [{ modules }, stats] = await Promise.all([
getPublicModules(),
getContainerStats(),
]);
return ( return (
<SamShell <SamShell
brand="IT" brand="IT"
subtitle="Gestión de aplicaciones" subtitle="Gestión de aplicaciones"
menu={itMenu(data?.modules ?? [])} menu={itMenu(modules)}
activeHref="/tablero" activeHref="/tablero"
topUser={me?.email || "SAM"} topUser={sess?.email || "SAM"}
allowLogout allowLogout
backHref="/" backHref="/"
> >
<TableroView /> <TableroView modules={modules} stats={stats} />
</SamShell> </SamShell>
); );
} }
+2
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import type { MenuItem } from "@/lib/sam"; import type { MenuItem } from "@/lib/sam";
import { clearMe } from "@/lib/store";
interface SamShellProps { interface SamShellProps {
brand: string; brand: string;
@@ -39,6 +40,7 @@ export default function SamShell({
try { try {
await fetch("/api/auth", { method: "DELETE" }); await fetch("/api/auth", { method: "DELETE" });
} finally { } finally {
clearMe();
router.push("/login"); router.push("/login");
} }
}; };
+17 -17
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import Link from "next/link"; 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 { Bar } from "@/components/ProgressBar";
import { useModules, useStats } from "@/lib/store"; import { IndexRefresh } from "@/components/IndexRefresh";
import { import {
assignColorsForModules, assignColorsForModules,
displayName, displayName,
@@ -13,8 +13,8 @@ import {
moduleUrl, moduleUrl,
sectionOf, sectionOf,
SECTIONS, SECTIONS,
visibleModules,
} from "@/lib/sam"; } from "@/lib/sam";
import type { ModuleState } from "@/lib/sam";
import { import {
healthOf, healthOf,
memUsed, memUsed,
@@ -24,7 +24,6 @@ import {
statForModule, statForModule,
type ContainerStat, type ContainerStat,
} from "@/lib/stats"; } from "@/lib/stats";
import type { ModuleState } from "@/lib/sam";
import type { ApiControllerInfo } from "@/lib/api-inventory"; import type { ApiControllerInfo } from "@/lib/api-inventory";
function Donut({ ok, starting, down }: { ok: number; starting: number; down: number }) { 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 + // Vista del tablero (contenido): tarjeta grande de estado global +
// estado y carga por aplicación + uso real de la API. // estado y carga por aplicación + uso real de la API.
// Se usa en /tablero y como landing de it.sam.devops.norteamericano.cl // Se usa en /tablero y como landing de it.sam.devops.norteamericano.cl
export default function TableroView() { // Recibe datos del Server Component (sin polling en el cliente;
const { data } = useModules(); // la página revalida cada 30s e <IndexRefresh /> refresca la vista).
const stats = useStats(); export default function TableroView({
const loading = data === null; modules,
stats,
const modules = useMemo( }: {
() => visibleModules(data?.modules ?? []), modules: ModuleState[];
[data] stats: ContainerStat[];
); }) {
// Mismo color que en el índice: guardado manda, al resto se le genera // Mismo color que en el índice: guardado manda, al resto se le genera
// uno sin repetir para lo ya importado. // 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 ok = modules.filter((m) => healthOf(m) === "ok").length;
const starting = modules.filter((m) => healthOf(m) === "starting").length; const starting = modules.filter((m) => healthOf(m) === "starting").length;
const down = modules.length - ok - starting; const down = modules.length - ok - starting;
@@ -322,10 +321,10 @@ export default function TableroView() {
.map((n) => statFor(stats, n)) .map((n) => statFor(stats, n))
.filter((s): s is ContainerStat => Boolean(s)); .filter((s): s is ContainerStat => Boolean(s));
if (loading) { if (modules.length === 0) {
return ( return (
<div className="text-center py-5"> <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> </div>
); );
} }
@@ -411,7 +410,8 @@ export default function TableroView() {
<ApiUsage /> <ApiUsage />
<p className="text-muted small mt-4 mb-4 text-center"> <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> </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; subscribe: (cb: () => void) => () => void;
snapshot: () => T; snapshot: () => T;
load: () => void; load: () => void;
reset: (value: T) => void;
} }
// Store externo genérico (useSyncExternalStore): primera carga + // Store externo genérico (useSyncExternalStore): primera carga +
// auto-refresh opcional con `ms`. Sin `ms` solo carga manual vía load(). // 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). // Sin setState en efectos (regla react-hooks).
export function createStore<T>( export function createStore<T>(
fetcher: () => Promise<T>, fetcher: () => Promise<T>,
initial: T, initial: T,
ms?: number ms?: number,
equal: (a: T, b: T) => boolean = (a, b) => a === b
): ExternalStore<T> { ): ExternalStore<T> {
let snap = initial; let snap = initial;
let started = false; let started = false;
@@ -38,9 +41,11 @@ export function createStore<T>(
const load = async () => { const load = async () => {
try { try {
const next = await fetcher(); const next = await fetcher();
if (next !== snap) snap = next; if (equal(next, snap)) return;
snap = next;
} catch { } catch {
// el error lo modela cada fetcher dentro de su snapshot // el error lo modela cada fetcher dentro de su snapshot
return;
} }
notify(); 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> { async function fetchModules(): Promise<ModulesSnap> {
@@ -123,8 +133,17 @@ async function fetchStats(): Promise<ContainerStat[]> {
} }
} }
const modulesStore = createStore(fetchModules, { data: null, error: null }, 15000); function stableEqual<T>(a: T, b: T): boolean {
const statsStore = createStore(fetchStats, [] as ContainerStat[], 30000); 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() { export function useModules() {
return useSyncExternalStore( return useSyncExternalStore(
@@ -166,3 +185,9 @@ const meStore = createStore(fetchMe, null as { email: string } | null, 60000);
export function useMe() { export function useMe() {
return useSyncExternalStore(meStore.subscribe, meStore.snapshot, () => null); 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);
}