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 = {
|
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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user