feat: color identidad por modulo en indice sam.devops con asignacion autoritaria

This commit is contained in:
Nurfog committed 2026-09-16 16:36:56 -03:00
1 parent f9e0887f10
commit 6fd9e8a615
5 files changed
+304 -8

No files matched your search

+66
View File
@@ -4,9 +4,11 @@ import { useState } from "react";
import SamShell from "@/components/SamShell"; import SamShell from "@/components/SamShell";
import { refreshModules, useModules, useMe } from "@/lib/store" import { refreshModules, useModules, useMe } from "@/lib/store"
import { import {
assignColorsForModules,
displayName, displayName,
envOf, envOf,
getModuleMeta, getModuleMeta,
isValidHexColor,
itMenu, itMenu,
} from "@/lib/sam"; } from "@/lib/sam";
@@ -65,6 +67,47 @@ export default function Actualizar() {
} }
}; };
// Rellena el color de los módulos ya importados que aún no lo tienen
// guardado: lo genera de forma autoritaria y lo persiste en el deployer.
const missingColor = modules.filter((m) => !isValidHexColor(m.color));
const backfillColors = async () => {
if (missingColor.length === 0) return;
setBusy("color:backfill");
setMsg(null);
try {
const assigned = assignColorsForModules(modules);
let ok = 0;
const failed: string[] = [];
for (const m of missingColor) {
try {
const res = await fetch(
`/api/deployer/modules/${encodeURIComponent(m.name)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ color: assigned[m.name] }),
cache: "no-store",
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
ok++;
} catch {
failed.push(m.name);
}
}
refreshModules();
setMsg({
ok: failed.length === 0,
text:
failed.length === 0
? `Colores generados y guardados en ${ok} módulo${ok !== 1 ? "s" : ""}.`
: `Guardados ${ok}, fallaron: ${failed.join(", ")}.`,
});
} finally {
setBusy(null);
}
};
return ( return (
<SamShell <SamShell
brand="IT" brand="IT"
@@ -87,6 +130,29 @@ export default function Actualizar() {
)} )}
<div className="app-card p-3"> <div className="app-card p-3">
{missingColor.length > 0 && (
<div className="alert alert-info py-2 d-flex align-items-center justify-content-between flex-wrap gap-2">
<span className="small">
<i className="bx bx-palette me-1" />
Hay {missingColor.length} módulo{missingColor.length !== 1 ? "s" : ""} sin
color guardado ({missingColor.map((m) => m.name).join(", ")}).
</span>
<button
className="btn btn-sm btn-primary"
disabled={busy !== null}
onClick={backfillColors}
>
{busy === "color:backfill" ? (
<span className="spinner-border spinner-border-sm" />
) : (
<>
<i className="bx bx-palette me-1" />
Generar colores
</>
)}
</button>
</div>
)}
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-hover align-middle mb-0"> <table className="table table-hover align-middle mb-0">
<thead> <thead>
+26
View File
@@ -102,6 +102,32 @@ body {
background-color: #f8faff; background-color: #f8faff;
} }
/* ============================================================
Color identidad por módulo (solo índice sam.devops):
franja + sombra a la izquierda con el color guardado de la
aplicación. Se activa con .module-card y las variables
--module-color / --module-rgb ("r, g, b").
============================================================ */
.app-card.module-card {
border-left: 4px solid var(--module-color, transparent);
box-shadow:
-6px 2px 14px rgba(var(--module-rgb, 0, 40, 93), 0.22),
0 2px 12px rgba(0, 0, 0, 0.08);
}
.app-card.module-card:hover {
transform: translateY(-3px);
background-color: #fff;
box-shadow:
-8px 4px 22px rgba(var(--module-rgb, 0, 40, 93), 0.38),
0 6px 18px rgba(0, 0, 0, 0.1);
}
.app-card .module-icon {
border: none !important;
box-shadow: 0 3px 10px rgba(var(--module-rgb, 0, 40, 93), 0.45);
}
.app-card a { .app-card a {
font-weight: 500; font-weight: 500;
} }
+24 -4
View File
@@ -2,8 +2,10 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMemo, type CSSProperties } from "react";
import { useModules, refreshModules } from "@/lib/store"; import { useModules, refreshModules } from "@/lib/store";
import { import {
assignColorsForModules,
boardHref, boardHref,
displayName, displayName,
envOf, envOf,
@@ -22,7 +24,15 @@ export default function Home() {
const loading = data === null && error === null; const loading = data === null && error === null;
const fetchModules = refreshModules; const fetchModules = refreshModules;
const modules = visibleModules(data?.modules ?? []); const modules = useMemo(
() => visibleModules(data?.modules ?? []),
[data]
);
// Colores efectivos de lo ya importado: el guardado manda y al resto
// se le genera uno sin repetir, así cada tarjeta queda identificada
// aunque el deployer aún no tenga `color` persistido.
const colorMap = useMemo(() => assignColorsForModules(modules), [modules]);
const bySection: Record<string, ModuleState[]> = {}; const bySection: Record<string, ModuleState[]> = {};
modules.forEach((m) => { modules.forEach((m) => {
@@ -32,7 +42,8 @@ export default function Home() {
}); });
const renderModuleCard = (m: ModuleState) => { const renderModuleCard = (m: ModuleState) => {
const meta = getModuleMeta(m.name); // El guardado en BD manda; si aún no tiene, el generado para la lista.
const meta = getModuleMeta(m.name, colorMap[m.name] ?? m.color);
const status = getStatusInfo(m); const status = getStatusInfo(m);
const env = envOf(m.branch); const env = envOf(m.branch);
const url = moduleUrl(m); const url = moduleUrl(m);
@@ -62,13 +73,22 @@ export default function Home() {
); );
return ( return (
<div className="col-md-6" key={m.name}> <div className="col-md-6" key={m.name}>
<div className="app-card p-2"> <div
className="app-card module-card p-2"
style={
{
"--module-color": meta.color,
"--module-rgb": meta.rgb,
} as CSSProperties
}
>
<table className="table table-borderless mb-0 align-middle"> <table className="table table-borderless mb-0 align-middle">
<tbody> <tbody>
<tr> <tr>
<td className="ps-3" style={{ width: 56 }}> <td className="ps-3" style={{ width: 56 }}>
<i <i
className={`bx ${meta.icon} bx-sm bx-border text-light ${meta.bg}`} className={`bx ${meta.icon} bx-sm bx-border module-icon`}
style={{ backgroundColor: meta.color, color: meta.onColor }}
/> />
</td> </td>
<td> <td>
+40 -2
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import SamShell from "@/components/SamShell"; import SamShell from "@/components/SamShell";
import { useModules, useMe } from "@/lib/store" import { useModules, useMe } from "@/lib/store"
import { itMenu } from "@/lib/sam"; import { itMenu, assignModuleColor } from "@/lib/sam";
import type { VerifyCheck, VerifyResult } from "@/lib/verify"; import type { VerifyCheck, VerifyResult } from "@/lib/verify";
import { failedChecks, verifySummary } from "@/lib/verify"; import { failedChecks, verifySummary } from "@/lib/verify";
@@ -110,6 +110,12 @@ export default function UploadPage() {
const name = form.name.trim().toLowerCase(); const name = form.name.trim().toLowerCase();
setModuleName(name); setModuleName(name);
// Color identidad: lo asigna el sistema de forma autoritaria y se
// guarda en el deployer. Pinta la tarjeta del índice sam.devops.
const assignedColor = assignModuleColor(
name,
(data?.modules ?? []).map((m) => m.color)
);
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
name, name,
repoUrl: form.repoUrl.trim(), repoUrl: form.repoUrl.trim(),
@@ -117,6 +123,7 @@ export default function UploadPage() {
category: form.category, category: form.category,
kind: form.kind, kind: form.kind,
visible: form.visible, visible: form.visible,
color: assignedColor,
}; };
if (form.port) payload.port = parseInt(form.port, 10); if (form.port) payload.port = parseInt(form.port, 10);
if (form.subdomain) payload.subdomain = form.subdomain.trim(); if (form.subdomain) payload.subdomain = form.subdomain.trim();
@@ -169,6 +176,16 @@ export default function UploadPage() {
}; };
const busy = phase === "deploying" || phase === "verifying"; const busy = phase === "deploying" || phase === "verifying";
// Vista previa del color que el sistema asignará al integrar.
const previewColor = useMemo(() => {
const n = form.name.trim().toLowerCase();
if (!n) return "#6c757d";
return assignModuleColor(
n,
(data?.modules ?? []).map((m) => m.color)
);
}, [form.name, data]);
const failed = failedChecks(verify); const failed = failedChecks(verify);
const verifyFailed = phase === "done" && verify && !verify.ok; const verifyFailed = phase === "done" && verify && !verify.ok;
@@ -489,6 +506,27 @@ export default function UploadPage() {
</div> </div>
</div> </div>
<div className="col-md-6">
<span className="form-label fw-semibold d-block">
Color asignado
</span>
<div className="d-flex align-items-center gap-2">
<span
className="d-inline-block rounded-circle border"
style={{
width: 28,
height: 28,
backgroundColor: previewColor,
boxShadow: `0 0 10px ${previewColor}`,
}}
/>
<code className="small">{previewColor}</code>
</div>
<div className="form-text">
Lo asigna el sistema al integrar (sombra izquierda en sam.devops).
</div>
</div>
<div className="col-12"> <div className="col-12">
<div className="form-check form-switch"> <div className="form-check form-switch">
<input <input
+148 -2
View File
@@ -23,6 +23,9 @@ export interface ModuleState {
kind?: string; kind?: string;
visible?: boolean; visible?: boolean;
targetPort?: number; targetPort?: number;
// Color identidad asignado al integrar (se guarda en el deployer).
// Si viene vacío, la UI usa el color asignado por nombre.
color?: string | null;
lastDeploy?: LastDeploy; lastDeploy?: LastDeploy;
container?: string | null; container?: string | null;
} }
@@ -60,6 +63,147 @@ export interface MenuItem {
href: string; href: string;
} }
// ============================================================
// Color identidad por módulo (solo índice sam.devops).
// - Al integrar, assignModuleColor() ASIGNA el color de forma
// autoritaria y se guarda en el deployer (campo `color`).
// - Si el módulo ya trae `color`, ese manda; si no, se calcula
// el asignado por nombre (curado + paleta sin repetir + hash).
// ============================================================
export const MODULE_PALETTE = [
"#2563eb", // azul
"#7c3aed", // violeta
"#db2777", // fucsia
"#dc2626", // rojo
"#ea580c", // naranja
"#d97706", // ámbar oscuro (legible en blanco)
"#65a30d", // lima
"#059669", // esmeralda
"#0891b2", // cian oscuro
"#1d4ed8", // azul marino claro
"#0f766e", // teal
"#4d7c0f", // oliva
];
const MODULE_COLORS: Record<string, string> = {
academico: "#2563eb",
profesores: "#d97706",
profesor: "#d97706",
empresas: "#0891b2",
portal: "#00285d",
sam: "#00285d",
ventas: "#059669",
comercial: "#0f766e",
pagos: "#ea580c",
contabilidad: "#dc2626",
finanzas: "#dc2626",
"academico-empresas": "#1d4ed8",
"sam-api": "#7c3aed",
"sam-deployer": "#4d7c0f",
"sam-postgres": "#336791",
"sam-redis": "#b91c1c",
};
function hashName(name: string): number {
let h = 5381;
const s = name.toLowerCase();
for (let i = 0; i < s.length; i++) {
h = ((h << 5) + h + s.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
export function isValidHexColor(v: unknown): v is string {
return typeof v === "string" && /^#[0-9a-fA-F]{6}$/.test(v.trim());
}
export function normalizeHexColor(v: unknown, fallback = "#6c757d"): string {
return isValidHexColor(v) ? (v as string).trim().toLowerCase() : fallback;
}
// Asignación autoritaria del color al integrar.
// 1. Nombre curado conocido -> color fijo.
// 2. Si no, primera paleta libre (evita repetir con los ya usados).
// 3. Si todo está ocupado, hash determinista por nombre.
export function assignModuleColor(name: string, taken: Array<string | null | undefined> = []): string {
const key = (name || "").toLowerCase();
if (MODULE_COLORS[key]) return MODULE_COLORS[key];
for (const fam of Object.keys(MODULE_COLORS)) {
if (fam.length >= 4 && key.includes(fam)) return MODULE_COLORS[fam];
}
if (!key) return "#6c757d";
const used = new Set(
taken
.filter(isValidHexColor)
.map((c) => (c as string).trim().toLowerCase())
);
const free = MODULE_PALETTE.find((c) => !used.has(c));
if (free) return free;
return MODULE_PALETTE[hashName(key) % MODULE_PALETTE.length];
}
// Color efectivo: el guardado en BD manda, si no el asignado.
export function getModuleColor(name: string, stored?: string | null): string {
if (isValidHexColor(stored)) return (stored as string).trim().toLowerCase();
return assignModuleColor(name);
}
// Genera colores para los módulos ya importados que aún no tienen
// `color` en BD, sin repetir con los guardados ni entre sí.
// Devuelve mapa nombre -> color efectivo (guardado o generado).
export function assignColorsForModules<T extends { name: string; color?: string | null }>(
modules: T[]
): Record<string, string> {
const taken: string[] = modules
.map((m) => m.color)
.filter(isValidHexColor)
.map((c) => (c as string).trim().toLowerCase());
// Reserva también los fijos curados para no duplicarlos al generar.
for (const m of modules) {
const key = (m.name || "").toLowerCase();
if (MODULE_COLORS[key] && !taken.includes(MODULE_COLORS[key]))
taken.push(MODULE_COLORS[key]);
}
const result: Record<string, string> = {};
for (const m of modules) {
if (isValidHexColor(m.color)) {
result[m.name] = (m.color as string).trim().toLowerCase();
} else {
const c = assignModuleColor(m.name, taken);
result[m.name] = c;
taken.push(c);
}
}
return result;
}
export function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "").trim();
const full =
h.length === 3
? h
.split("")
.map((c) => c + c)
.join("")
: h;
const n = parseInt(full, 16);
if (Number.isNaN(n)) return [108, 117, 125];
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function rgbOf(hex: string): string {
return hexToRgb(hex).join(", ");
}
// Texto legible sobre el color del módulo (iconos con fondo).
export function onModuleColor(hex: string): string {
const [r, g, b] = hexToRgb(hex);
// Luminancia relativa aproximada.
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.62 ? "#212529" : "#ffffff";
}
const A = ( const A = (
label: string, label: string,
icon: string, icon: string,
@@ -213,8 +357,10 @@ const iconMap: Record<string, { icon: string; bg: string }> = {
default: { icon: "bx-cube", bg: "bg-secondary" }, default: { icon: "bx-cube", bg: "bg-secondary" },
}; };
export function getModuleMeta(name: string) { export function getModuleMeta(name: string, storedColor?: string | null) {
return iconMap[name.toLowerCase()] || iconMap.default; const meta = iconMap[name.toLowerCase()] || iconMap.default;
const color = getModuleColor(name, storedColor);
return { ...meta, color, rgb: rgbOf(color), onColor: onModuleColor(color) };
} }
export function getStatusInfo(m: ModuleState) { export function getStatusInfo(m: ModuleState) {