Vista: /integrity/ (página del radar interno)
Descripción
La página /integrity/ renderiza la Barra de Integridad F2: el radar interno que muestra estado del plano vs. realidad, histórico 30 días, y cola de reconciliación con veredictos.
Ubicación:
- Vista:
racks/views.py::integrity_view() - Ruta:
racks/urls.py→path("integrity/", views.integrity_view, name="integrity") - Template:
templates/racks/integrity.html - CSS:
static/css/pages/integrity.css - JS:
static/js/pages/integrity.js
Versión: v1.60.0 (2026-07-17)
Gateado: Módulo integrity requerido (middleware).
Vista (Django)
Código
@login_required(login_url="/login")
def integrity_view(request):
"""Barra de Integridad (F2) — radar interno plano↔realidad.
Gateada por el módulo "integrity" (middleware, prefijo /integrity/): solo
la ve una org con el módulo en extra_modules. Los datos los trae el JS de
la página desde /api/racks/integrity/overview.
"""
from core.utils.permissions import has_permission
org = request.user.organization if hasattr(request.user, "organization") else None
can_edit = bool(org) and has_permission(request.user, "racks", "edit")
return render(request, "racks/integrity.html", {"can_edit": can_edit})
Autenticación / Autorización
- Usuario:
@login_required→ 302 redirige a/loginsi no autenticado. - Organización: Derivada de
request.user.organization(FK en User). - Módulo: Gateado a nivel middleware (prefijo
/integrity/) → 403 antes de esta vista. - Contexto:
can_editindica si el usuario tieneracks:edit.
Parámetros de template
{
"can_edit": bool, # True si usuario tiene racks:edit
}
Template (HTML)
Ubicación: templates/racks/integrity.html
Estructura:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Integrity Radar" %} — CreaRack Pro</title>
<!-- Tokens CSS centrales -->
<link rel="stylesheet" href="{% static 'css/tokens.css' %}">
<link rel="stylesheet" href="{% static 'css/pages/integrity.css' %}">
</head>
<body>
<nav>{% ifmodule "integrity" %} ... Menú {% endif %}</nav>
<main class="integrity-page">
<!-- Header -->
<div class="integrity-header">
<h1>{% trans "Integrity Radar" %}</h1>
<p class="integrity-subtitle">
{% trans "Plan vs. reality. Signal calibration for the gate." %}
</p>
</div>
<!-- Resumen (4 cards) -->
<section class="integrity-summary" id="summary-cards">
<div class="integrity-card">
<div class="integrity-card-label">{% trans "Fidelity" %}</div>
<div class="integrity-card-value">—</div>
</div>
<!-- ... -->
</section>
<!-- Histórico (sparkline de 30 días) -->
<section class="integrity-block">
<h2>{% trans "30-Day History" %}</h2>
<div class="integrity-history" id="history"></div>
</section>
<!-- Tabla por rack -->
<section class="integrity-block">
<h2>{% trans "By Rack" %}</h2>
<table class="integrity-table" id="rack-table"></table>
</section>
<!-- Cola de reconciliación -->
<section class="integrity-block">
<h2>{% trans "Reconciliation Queue" %}</h2>
<p class="integrity-hint">
{% trans "Findings with no verdict yet. Click one of 3 buttons to record your judgment." %}
</p>
<div id="findings-queue"></div>
</section>
<!-- Vivos sin plano -->
<section class="integrity-block">
<h2>{% trans "Undocumented (Alive, Not in Plan)" %}</h2>
<div id="undocumented-queue"></div>
</section>
</main>
<script type="module">
import { IntegrityRadar } from "{% static 'js/pages/integrity.js' %}";
new IntegrityRadar({
apiBase: "{{ request.build_absolute_uri '/' }}api/racks/integrity",
canEdit: {{ can_edit | lower }}, // "true" or "false"
}).init();
</script>
</body>
</html>
Directivas de template
{% ifmodule "integrity" %}— Solo renderiza el menú si el módulo está activo (fallback por seguridad, aunque el middleware ya lo gatea).{{ can_edit | lower }}— Pasa boolean JS.
CSS (integrity.css)
Filosofía: Solo tokens centrales, nada hardcodeado. Grid responsive, respeta tema.
Estructura
/* Container */
.integrity-page {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px 64px;
}
/* Header */
.integrity-header h1 {
font-family: var(--font-heading);
color: var(--text-primary);
margin: 0 0 6px;
}
.integrity-subtitle {
color: var(--text-secondary);
margin: 0 0 20px;
max-width: 70ch;
}
/* Cards (fidelity, coverage, etc.) */
.integrity-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 26px;
}
.integrity-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 14px 16px;
}
.integrity-card-label {
color: var(--text-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.integrity-card-value {
color: var(--text-primary);
font-size: 26px;
font-weight: 700;
margin-top: 4px;
}
/* Table */
.integrity-table {
width: 100%;
border-collapse: collapse;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.integrity-table th, .integrity-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 13.5px;
}
.integrity-table th {
color: var(--text-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Finding cards */
.integrity-finding {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 10px;
}
.integrity-kind {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--border-color);
color: var(--text-muted);
}
.integrity-kind.k-stale {
color: var(--warning);
border-color: var(--warning);
}
.integrity-kind.k-conflict {
color: var(--danger);
border-color: var(--danger);
}
.integrity-kind.k-undoc {
color: var(--accent);
border-color: var(--accent);
}
/* Actions (buttons) */
.integrity-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
Tokens usados:
--font-heading,--text-primary,--text-secondary,--text-muted--card-bg,--border-color--success,--warning,--danger,--accent
Responsive: grid-template-columns: repeat(auto-fit, minmax(...)) → adapta automáticamente a móvil.
JavaScript (integrity.js)
Patrón: ES module, escHtml() estático, t() en runtime, sin frameworks.
Clase principal
export class IntegrityRadar {
constructor(options) {
this.apiBase = options.apiBase; // e.g., "/api/racks/integrity"
this.canEdit = options.canEdit; // boolean
}
async init() {
// 1. Fetch data
const data = await this.fetchOverview();
// 2. Render sections
this.renderSummary(data.result.summary);
this.renderHistory(data.history);
this.renderRackTable(data.result.racks);
this.renderFindings(data.result.racks);
this.renderUndocumented(data.result.undocumented);
this.renderGate(data.gate);
// 3. Attach event listeners
if (this.canEdit) {
this.attachVerdictListeners();
}
}
async fetchOverview() {
const resp = await fetch(`${this.apiBase}/overview`);
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
return resp.json();
}
renderSummary(summary) {
const html = `
<div class="integrity-card">
<div class="integrity-card-label">${ escHtml(t('Fidelity')) }</div>
<div class="integrity-card-value">${ summary.fidelity_pct ?? '—' }%</div>
</div>
...
`;
document.getElementById("summary-cards").innerHTML = html;
}
renderHistory(history) {
const html = history.map(day => `
<div class="integrity-day">
<b>${ escHtml(day.date) }</b>
<div>${ day.fidelity_pct ?? '—' }%</div>
</div>
`).join("");
document.getElementById("history").innerHTML = html;
}
renderFindings(racks) {
let html = "";
for (const rack of racks) {
for (const finding of rack.findings) {
// Si hay ack, solo mostrar como info (no repetir en cola)
if (finding.ack) continue;
html += `
<div class="integrity-finding" data-finding-id="${ finding.device_id }">
<div class="integrity-finding-head">
<strong>${ escHtml(finding.name) }</strong>
<span class="integrity-kind k-${ finding.classification }">
${ escHtml(t(finding.classification)) }
</span>
</div>
<div class="integrity-reason">${ escHtml(finding.reason) }</div>
<div class="integrity-evidence">
${ JSON.stringify(finding.evidence) }
</div>
<div class="integrity-actions">
<button class="btn-text" data-verdict="plan_updated">
${ escHtml(t("Plan updated")) }
</button>
<button class="btn-text" data-verdict="real_change">
${ escHtml(t("Real change")) }
</button>
<button class="btn-text" data-verdict="false_alarm">
${ escHtml(t("False alarm")) }
</button>
</div>
</div>
`;
}
}
document.getElementById("findings-queue").innerHTML = html ||
`<div class="integrity-empty">${ escHtml(t("No findings.")) }</div>`;
}
attachVerdictListeners() {
document.addEventListener("click", async (e) => {
if (!e.target.classList.contains("btn-text")) return;
const finding = e.target.closest(".integrity-finding");
const deviceId = parseInt(finding.dataset.findingId);
const verdict = e.target.dataset.verdict;
const resp = await fetch(`${this.apiBase}/ack`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: finding.dataset.kind,
verdict: verdict,
device_id: deviceId,
note: prompt(t("Note (optional)")) || "",
}),
});
if (resp.ok) {
// Refresh page or just update UI
location.reload();
} else {
alert(t("Error") + ": " + (await resp.text()));
}
});
}
}
// Utilidades
function escHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function t(key) {
// Runtime i18n: busca en window.__i18n__ o retorna key
return (window.__i18n__ || {})[key] || key;
}
Notas de seguridad
- escHtml(): Previene XSS. Todo texto dinámico pasa por aquí.
- Sin eval(): No ejecuta código dinámico. Template strings safe.
- t(): I18n sin compilación — tokens buscan en objeto global.
Flujo de usuario (beta org)
-
Admin activa módulo en org:
org.extra_modules.add(integrity_module) -
Usuario de racks:edit navega a
/integrity/:- Vista carga, template renderiza HTML base.
- JS: fetch
/api/racks/integrity/overview. - API retorna cómputo + histórico + gate.
-
Usuario ve:
- Cards: Fidelity 99.2%, Coverage 99.5%, Undocumented 3.
- Histórico: Línea 30 días.
- Tabla por rack: Cada uno su fidelity.
- Cola: Hallazgos sin veredicto (stale, conflict, undoc).
-
Usuario clicks
plan_updatedsobre hallazgo:- JS POST
/api/racks/integrity/ackcon datos. - API crea IntegrityAck, silencia 3 días, retorna gate.
- Página recarga (o se refresca parcialmente).
- JS POST
-
Gate #202:
- Dashboard monitorea
signal_pct. - Si > 85% → feature es “confiable”.
- Entonces se activa en Plan 4+ para clientes.
- Dashboard monitorea
Integraciones
Menú (ifmodule)
En el layout principal de CreaRack:
<nav class="sidebar">
<a href="/racks/editor">Racks Editor</a>
<a href="/monitoring/cns">CNS</a>
{% ifmodule "integrity" %}
<a href="/integrity/" class="active">Integrity Radar</a>
{% endif %}
</nav>
Permisos
- Ver página: Miembro org + módulo activo.
- Hacer clicks: Además,
racks:edit.
Testing
Pruebas de UI en tests/racks/test_integrity_ui.py (esperado):
- GET
/integrity/sin módulo → 403. - GET
/integrity/con módulo, user no edit → botones deshabilitados (HTML pero sin JS listeners). - GET
/integrity/con módulo + edit → botones activos. - POST
/ackdesde JS → validación, creación IntegrityAck, refresh UI.
Véase también
- [[feature—racks—integridad-f2-radar-interno]]
- [[entity—racks—endpoint—integrity-overview-ack]]
- [[entity—racks—service—integrity-motor]]
- [[entity—core—module—integrity]]
- [[concept—ux—design-patterns]]