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); +}