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>
354 lines
16 KiB
HTML
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>
|