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:
1 parent
926d41dc51
commit
ab6a5a148c
7 files changed
+111
-32
No files matched your search
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user