feat: color identidad por modulo en indice sam.devops con asignacion autoritaria
This commit is contained in:
1 parent
f9e0887f10
commit
6fd9e8a615
5 files changed
+304
-8
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user