lakehouse/mcp-server/biometric_dashboard.html
root 87b034f5f9 phase 1.6: ops dashboard + consent_versions allowlist + subject timeline tool
Closes the afternoon's "all four" wave (per J's request to do all the
items in one pass instead of pick-one-of-options):

(1) Live demo on WORKER-100 — full lifecycle exercised end-to-end
    against the running gateway. 3 audit rows landed in correct
    order (consent_grant → biometric_collection →
    consent_withdrawal), chain_verified=true, photo on disk at
    data/biometric/uploads/WORKER-100/1778011967957907731_027b6bb1.jpg
    (180 bytes JFIF). retention_until=2026-06-04 (30d from
    withdrawal per consent template v1 §2).

(2) GET /biometric/stats — read-only aggregate over all subjects.
    Returns counts by biometric.status + subject.status, photo
    count, oldest_active_retention_until, and the last 20
    state-change events (consent_grant / collection / withdrawal /
    erasure — validator_lookup and other noise filtered out).
    Walks per-subject audit logs via the existing writer; cheap
    for 100 subjects, would want an event-stream index at 100k.
    Legal-tier auth (same posture as /audit). 4 unit tests.

(3) /biometric/dashboard mcp-server frontend. Auto-refreshes
    /biometric/stats every 15s, neo-brutalist tile layout for
    the per-status counts + retention horizon block + recent
    events table with kind badges + event-kind breakdown pills.
    sessionStorage-backed token; logout button clears state.
    DOM-built throughout (textContent + createElement) — never
    innerHTML on audit-row values, since trace_id et al. could
    in theory carry operator-supplied strings.

(4) consent_versions allowlist. BiometricEndpointState gains
    `allowed_consent_versions: Option<Arc<HashSet<String>>>`,
    loaded at startup from /etc/lakehouse/consent_versions.json
    (override via LH_CONSENT_VERSIONS_FILE). process_consent
    refuses unknown hashes with HTTP 400 consent_version_unknown
    when configured. Resolution semantics:
      - Missing file → permissive (v1 compat, warn-log)
      - Parse error → permissive (error-log; broken config
        silently going strict would be worse)
      - Empty array → strict, refuse all (deliberate freeze
        mode for "counsel hasn't signed v1 yet")
      - Populated → strict, lowercase-normalized comparison
    5 unit tests (known/unknown/case/empty/none-permissive).
    Example template at ops/consent_versions.example.json with
    a counsel-tier deployment note.

(5) scripts/staffing/subject_timeline.sh — operator one-shot
    pretty-print of any subject's full BIPA lifecycle. Curls
    /audit/subject/{id} with legal token; renders manifest
    summary + on-disk photo state + chronological audit chain
    with kind badges + chain verification status. Smoke-tested
    on WORKER-100 (3 rows verified).

(6) STATE_OF_PLAY.md refresh. New section "afternoon wave"
    captures all four commits (76cb5ac, 7f0f500, 68d226c, this
    one) + the live demo evidence + the v1 endpoint matrix +
    UI/CLI inventory + the production-cutover blocking set
    (counsel calendar only — eng substrate is done).

Verified live post-restart:
- /audit/health + /biometric/health both 200
- /biometric/stats returns 100 subjects, 2 withdrawn (WORKER-2 from
  earlier scrum + WORKER-100 from today's demo), 1 photo on record,
  6 recent state-change events
- /biometric/intake + /biometric/withdraw + /biometric/dashboard
  all 200 on mcp-server :3700
- subject_timeline.sh on WORKER-100: chain_verified=true,
  chain_root=a47563ff937d50de…
- 88/88 catalogd lib tests + 55/55 biometric_endpoint tests green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:27:52 -05:00

354 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lakehouse — Biometric Ops Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
a{color:#58a6ff;text-decoration:none}
a:hover{color:#79c0ff}
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
.bar .actions{display:flex;align-items:center;gap:10px}
.bar .gen{font-size:11px;color:#7d8590;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
.bar button{background:#21262d;color:#c9d1d9;border:none;padding:6px 12px;font-size:12px;font-family:inherit;cursor:pointer;border-radius:0}
.bar button:hover{background:#30363d}
.wrap{max-width:1100px;margin:0 auto;padding:24px 20px 60px}
.screen{display:none}
.screen.active{display:block}
h2{font-size:18px;color:#e6edf3;font-weight:600;margin:24px 0 10px;letter-spacing:-0.3px}
h2:first-child{margin-top:0}
.lede{color:#7d8590;font-size:13px;margin-bottom:16px}
.card{background:#0d1117;border:1px solid #171d27;padding:18px;margin-bottom:14px}
label{display:block;margin-bottom:12px;color:#7d8590;font-size:12px;text-transform:uppercase;letter-spacing:0.5px}
input[type=password]{width:100%;background:#0d1117;border:1px solid #2d333b;color:#e6edf3;padding:10px 12px;font-family:inherit;font-size:14px;border-radius:0;margin-top:6px;transition:border-color .15s}
input[type=password]:focus{outline:none;border-color:#58a6ff}
button.primary{background:#1f6feb;color:#fff;border:none;padding:10px 20px;font-family:inherit;font-size:14px;font-weight:500;cursor:pointer;border-radius:0;transition:background .15s}
button.primary:hover:not(:disabled){background:#388bfd}
button.primary:disabled{background:#21262d;color:#545d68;cursor:not-allowed}
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-bottom:18px}
.tile{background:#0d1117;border:1px solid #171d27;padding:18px;border-left:3px solid #2d333b}
.tile.given{border-left-color:#3fb950}
.tile.withdrawn{border-left-color:#d29922}
.tile.expired{border-left-color:#da3633}
.tile.erased{border-left-color:#8b949e}
.tile.never{border-left-color:#58a6ff}
.tile.pending{border-left-color:#a371f7}
.tile.photos{border-left-color:#f85149}
.tile-label{font-size:11px;color:#7d8590;text-transform:uppercase;letter-spacing:0.6px;margin-bottom:6px}
.tile-value{font-size:24px;color:#e6edf3;font-weight:600;letter-spacing:-0.5px;font-feature-settings:'tnum';font-variant-numeric:tabular-nums}
.tile-sub{font-size:11px;color:#545d68;margin-top:4px}
.row-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@media (max-width:760px){ .row-grid{grid-template-columns:1fr} }
.kv{display:grid;grid-template-columns:200px 1fr;gap:6px;margin:4px 0;font-size:13px}
.kv .k{color:#7d8590;text-transform:uppercase;font-size:11px;letter-spacing:0.5px;padding-top:2px}
.kv .v{color:#e6edf3;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px}
.table{width:100%;border-collapse:collapse;margin-top:8px;font-size:12px}
.table th{text-align:left;padding:8px 10px;background:#0a0c10;color:#7d8590;text-transform:uppercase;letter-spacing:0.4px;font-size:11px;font-weight:500;border-bottom:1px solid #1f242c}
.table td{padding:8px 10px;border-bottom:1px solid #161b22;color:#c9d1d9;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;vertical-align:top}
.table tr:hover td{background:#0a0c10}
.kind{display:inline-block;padding:2px 8px;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;font-family:'Inter',sans-serif}
.kind.consent_grant{background:#0c2510;color:#7ee787;border:1px solid #2ea04338}
.kind.collection{background:#251c0c;color:#ffd166;border:1px solid #d2992238}
.kind.withdrawal{background:#251c0c;color:#f0883e;border:1px solid #f0883e38}
.kind.erasure{background:#250d0d;color:#ff7b72;border:1px solid #f8514938}
.kind.full{background:#250d0d;color:#ffa198;border:1px solid #f8514955}
.error{background:#3a0f0f;border:1px solid #f85149;color:#ffa198;padding:12px 16px;margin:12px 0;font-size:13px;display:none}
.error.show{display:block}
.foot{margin-top:32px;padding-top:16px;border-top:1px solid #171d27;font-size:11px;color:#545d68;text-transform:uppercase;letter-spacing:0.5px}
.foot a{color:#58a6ff}
.empty{padding:24px;text-align:center;color:#545d68;font-size:13px;font-style:italic}
.kb-pill{display:inline-block;margin-right:18px;font-size:13px}
.kb-pill .kb-k{color:#7d8590}
.kb-pill .kb-v{color:#e6edf3;font-weight:600}
</style>
</head>
<body>
<div class="bar">
<h1>⚡ Biometric Ops Dashboard</h1>
<span class="actions">
<span class="gen" id="gen-at"></span>
<button id="refresh">Refresh</button>
<button id="logout">Logout</button>
</span>
</div>
<div class="wrap">
<!-- Token gate -->
<div id="screen-token" class="screen active">
<h2>Operator authentication</h2>
<p class="lede">Read-only ops dashboard over <code>/biometric/stats</code>. Aggregate counts + recent state-change events. Auth via legal-tier token (sessionStorage; clears on tab close).</p>
<div class="card">
<label for="token-input">Legal audit token</label>
<input type="password" id="token-input" placeholder="44-char token from /etc/lakehouse/legal_audit.token" autocomplete="off">
<div style="margin-top:14px"><button id="token-submit" class="primary">Continue →</button></div>
</div>
<div class="error" id="token-error"></div>
</div>
<!-- Dashboard -->
<div id="screen-dash" class="screen">
<h2 style="margin-top:0">Biometric consent state — by subject</h2>
<div class="tiles">
<div class="tile never"><div class="tile-label">Never collected</div><div class="tile-value" id="t-never"></div><div class="tile-sub">no biometric data on record</div></div>
<div class="tile pending"><div class="tile-label">Pending</div><div class="tile-value" id="t-pending"></div><div class="tile-sub">consent in flight</div></div>
<div class="tile given"><div class="tile-label">Given</div><div class="tile-value" id="t-given"></div><div class="tile-sub">active biometric data</div></div>
<div class="tile withdrawn"><div class="tile-label">Withdrawn</div><div class="tile-value" id="t-withdrawn"></div><div class="tile-sub">awaiting destruction (≤30d)</div></div>
<div class="tile expired"><div class="tile-label">Expired</div><div class="tile-value" id="t-expired"></div><div class="tile-sub">retention window passed</div></div>
<div class="tile photos"><div class="tile-label">Photos on disk</div><div class="tile-value" id="t-photos"></div><div class="tile-sub">quarantined uploads</div></div>
</div>
<div class="row-grid">
<div>
<h2>Subject lifecycle status</h2>
<div class="card">
<div class="kv">
<span class="k">Total subjects</span><span class="v" id="x-total"></span>
<span class="k">Active</span><span class="v" id="x-active"></span>
<span class="k">Pending consent</span><span class="v" id="x-pending-consent"></span>
<span class="k">Withdrawn</span><span class="v" id="x-withdrawn"></span>
<span class="k">Retention expired</span><span class="v" id="x-retexp"></span>
<span class="k">Erased</span><span class="v" id="x-erased"></span>
</div>
</div>
</div>
<div>
<h2>Retention horizon</h2>
<div class="card">
<div class="kv">
<span class="k">Oldest active retention</span><span class="v" id="x-oldest"></span>
<span class="k">Days until earliest expiry</span><span class="v" id="x-days"></span>
<span class="k">Sweep schedule</span><span class="v">daily 03:00 UTC</span>
<span class="k">Sweep unit</span><span class="v">lakehouse-retention-sweep.timer</span>
</div>
</div>
</div>
</div>
<h2>Recent state-change events <span style="color:#7d8590;font-size:12px;font-weight:400">(last 20 across all subjects, newest first)</span></h2>
<div class="card" style="padding:0">
<table class="table">
<thead>
<tr>
<th>Timestamp</th>
<th>Candidate</th>
<th>Kind</th>
<th>Result</th>
<th>Trace ID</th>
</tr>
</thead>
<tbody id="events-tbody">
<tr><td colspan="5" class="empty">Loading…</td></tr>
</tbody>
</table>
</div>
<h2>Event-kind breakdown <span style="color:#7d8590;font-size:12px;font-weight:400">(across recent events shown above)</span></h2>
<div class="card">
<div id="kind-breakdown"></div>
</div>
<div class="error" id="dash-error"></div>
</div>
</div>
<div class="foot">
<a href="/biometric/intake">Intake UI</a>
· <a href="/biometric/withdraw">Withdraw UI</a>
· <a href="https://git.agentview.dev/profit/lakehouse/src/branch/main/docs/PHASE_1_6_BIPA_GATES.md">BIPA Gates</a>
· <a href="https://git.agentview.dev/profit/lakehouse/src/branch/main/docs/runbooks/BIPA_DESTRUCTION_RUNBOOK.md">Destruction runbook</a>
</div>
<script>
const GATEWAY = (function(){
const p = new URLSearchParams(location.search);
return p.get('gw') || 'http://localhost:3100';
})();
const state = { token: null, refreshTimer: null };
const REFRESH_MS = 15000;
function show(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
function err(id, msg){ const e=document.getElementById(id); e.textContent=msg; e.classList.add('show'); }
function clearErr(id){ document.getElementById(id).classList.remove('show'); }
function fmtDate(iso){
if (!iso) return '—';
try { return new Date(iso).toISOString().replace('T',' ').replace(/\.\d+Z$/, 'Z'); }
catch(e){ return iso; }
}
// Build a TD element with text content. textContent prevents any HTML
// in the value from being interpreted — important because trace_id and
// (in theory) other audit-row fields could contain operator-supplied
// strings. The prior version used innerHTML string concatenation; that
// was an XSS vector if a maliciously-crafted X-Lakehouse-Trace-Id
// landed in the audit log.
function td(value) {
const el = document.createElement('td');
el.textContent = value == null ? '—' : String(value);
return el;
}
function kindBadge(kind) {
const map = {
'biometric_consent_grant': 'consent_grant',
'biometric_collection': 'collection',
'biometric_consent_withdrawal': 'withdrawal',
'biometric_erasure': 'erasure',
'full_erasure': 'full',
};
const cls = map[kind] || '';
const span = document.createElement('span');
span.className = 'kind ' + cls;
span.textContent = String(kind || '').replace(/^biometric_/, '');
const cell = document.createElement('td');
cell.appendChild(span);
return cell;
}
(function init(){
const saved = sessionStorage.getItem('lh_legal_token');
if (saved) {
state.token = saved;
document.getElementById('token-input').value = '••••••••';
show('screen-dash');
refresh();
state.refreshTimer = setInterval(refresh, REFRESH_MS);
}
})();
document.getElementById('token-submit').addEventListener('click', () => {
clearErr('token-error');
const v = document.getElementById('token-input').value.trim();
const tok = v === '••••••••' ? state.token : v;
if (!tok || tok.length < 32) { err('token-error', 'Token must be ≥32 characters.'); return; }
state.token = tok;
sessionStorage.setItem('lh_legal_token', tok);
show('screen-dash');
refresh();
state.refreshTimer = setInterval(refresh, REFRESH_MS);
});
document.getElementById('refresh').addEventListener('click', refresh);
document.getElementById('logout').addEventListener('click', () => {
sessionStorage.removeItem('lh_legal_token');
state.token = null;
if (state.refreshTimer) { clearInterval(state.refreshTimer); state.refreshTimer = null; }
document.getElementById('token-input').value = '';
show('screen-token');
});
async function refresh(){
clearErr('dash-error');
try {
const r = await fetch(`${GATEWAY}/biometric/stats`, {
headers: { 'X-Lakehouse-Legal-Token': state.token },
});
if (!r.ok) {
const body = await r.text();
err('dash-error', `Stats fetch failed: HTTP ${r.status} ${body.substring(0,200)}`);
return;
}
const s = await r.json();
render(s);
} catch (e) {
err('dash-error', `Network error: ${e.message}`);
}
}
function render(s) {
document.getElementById('gen-at').textContent = 'as of ' + fmtDate(s.generated_at);
document.getElementById('t-never').textContent = s.biometric_status.never_collected;
document.getElementById('t-pending').textContent = s.biometric_status.pending;
document.getElementById('t-given').textContent = s.biometric_status.given;
document.getElementById('t-withdrawn').textContent = s.biometric_status.withdrawn;
document.getElementById('t-expired').textContent = s.biometric_status.expired;
document.getElementById('t-photos').textContent = s.photos_on_record;
document.getElementById('x-total').textContent = s.total_subjects;
document.getElementById('x-active').textContent = s.subject_status.active;
document.getElementById('x-pending-consent').textContent = s.subject_status.pending_consent;
document.getElementById('x-withdrawn').textContent = s.subject_status.withdrawn;
document.getElementById('x-retexp').textContent = s.subject_status.retention_expired;
document.getElementById('x-erased').textContent = s.subject_status.erased;
document.getElementById('x-oldest').textContent = fmtDate(s.oldest_active_retention_until);
document.getElementById('x-days').textContent =
s.upcoming_destruction_window_days != null ? s.upcoming_destruction_window_days + ' days' : '—';
// Build events table via DOM nodes (textContent on each cell) — never
// innerHTML with audit-row values.
const tbody = document.getElementById('events-tbody');
tbody.replaceChildren();
if (!s.recent_events.length) {
const tr = document.createElement('tr');
const tdEmpty = document.createElement('td');
tdEmpty.colSpan = 5;
tdEmpty.className = 'empty';
tdEmpty.textContent = 'No state-change events recorded yet. Run an intake or withdrawal to see audit chain entries here.';
tr.appendChild(tdEmpty);
tbody.appendChild(tr);
} else {
for (const e of s.recent_events) {
const tr = document.createElement('tr');
tr.appendChild(td(fmtDate(e.ts)));
tr.appendChild(td(e.candidate_id));
tr.appendChild(kindBadge(e.kind));
tr.appendChild(td(e.result));
tr.appendChild(td(e.trace_id || '—'));
tbody.appendChild(tr);
}
}
// Same DOM-build for the kind breakdown.
const kb = document.getElementById('kind-breakdown');
kb.replaceChildren();
const counts = s.recent_event_counts || {};
const keys = Object.keys(counts);
if (!keys.length) {
const span = document.createElement('span');
span.style.color = '#7d8590';
span.style.fontSize = '12px';
span.style.fontStyle = 'italic';
span.textContent = 'No events yet.';
kb.appendChild(span);
} else {
for (const k of keys.sort()) {
const wrap = document.createElement('span');
wrap.className = 'kb-pill';
const kEl = document.createElement('span');
kEl.className = 'kb-k';
kEl.textContent = k;
const sep = document.createTextNode(': ');
const vEl = document.createElement('strong');
vEl.className = 'kb-v';
vEl.textContent = counts[k];
wrap.append(kEl, sep, vEl);
kb.appendChild(wrap);
}
}
}
</script>
</body>
</html>