Volver a la wiki

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:

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

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


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:

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


Flujo de usuario (beta org)

  1. Admin activa módulo en org:

    org.extra_modules.add(integrity_module)
  2. 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.
  3. 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).
  4. Usuario clicks plan_updated sobre hallazgo:

    • JS POST /api/racks/integrity/ack con datos.
    • API crea IntegrityAck, silencia 3 días, retorna gate.
    • Página recarga (o se refresca parcialmente).
  5. Gate #202:

    • Dashboard monitorea signal_pct.
    • Si > 85% → feature es “confiable”.
    • Entonces se activa en Plan 4+ para clientes.

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


Testing

Pruebas de UI en tests/racks/test_integrity_ui.py (esperado):


Véase también

Subir