vigilar/vigilar/web/templates/visitors/dashboard.html
Aaron D. Lee 23d5bf062a feat(S3): visitors blueprint with profiles, visits, labeling, privacy controls
Add /visitors/ blueprint with REST API endpoints for listing profiles and
visits, labeling (with consent gate), household linking, ignore/unignore,
and cascading forget. Register blueprint in app.py. Add dashboard and profile
detail templates (Bootstrap 5 dark, tab navigation, fetch-based JS). All six
API tests pass (339 total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:01:33 -04:00

351 lines
14 KiB
HTML

{% extends "base.html" %}
{% block title %}Visitors — Vigilar{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-people-fill me-2 text-info"></i>Visitor Recognition</h5>
</div>
<!-- Tab nav -->
<ul class="nav nav-tabs border-secondary mb-3" id="visitors-tabs">
<li class="nav-item">
<button class="nav-link active" data-tab="household">
<i class="bi bi-house-fill me-1"></i>Household
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-tab="known">
<i class="bi bi-person-check-fill me-1"></i>Known
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-tab="unknown">
<i class="bi bi-person-fill-question me-1"></i>Unknown
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-tab="visits">
<i class="bi bi-clock-history me-1"></i>Recent Visits
</button>
</li>
</ul>
<!-- Profiles panel -->
<div id="panel-household" class="tab-panel">
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold">Household Members</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Name</th>
<th>Presence Link</th>
<th>Visits</th>
<th>Last Seen</th>
<th></th>
</tr>
</thead>
<tbody id="tbody-household">
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div id="panel-known" class="tab-panel d-none">
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold">Named Visitors</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Name</th>
<th>Visits</th>
<th>First Seen</th>
<th>Last Seen</th>
<th></th>
</tr>
</thead>
<tbody id="tbody-known">
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div id="panel-unknown" class="tab-panel d-none">
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold">Unidentified Faces</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Profile ID</th>
<th>Visits</th>
<th>First Seen</th>
<th>Last Seen</th>
<th>Ignored</th>
<th></th>
</tr>
</thead>
<tbody id="tbody-unknown">
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div id="panel-visits" class="tab-panel d-none">
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold">Recent Visits</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Profile</th>
<th>Camera</th>
<th>Arrived</th>
<th>Departed</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="tbody-visits">
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Label modal -->
<div class="modal fade" id="label-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark border-secondary">
<div class="modal-header border-secondary">
<h6 class="modal-title"><i class="bi bi-tag-fill me-2"></i>Label Face Profile</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning small py-2">
<i class="bi bi-shield-exclamation me-1"></i>
Labeling stores biometric data. Only label faces with the subject's consent.
</div>
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
id="label-name-input" placeholder="Enter name">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="label-consent-check">
<label class="form-check-label small" for="label-consent-check">
I confirm consent has been obtained
</label>
</div>
</div>
<div class="modal-footer border-secondary">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-primary" id="label-confirm-btn">Save Label</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
'use strict';
let labelTargetId = null;
const labelModal = new bootstrap.Modal(document.getElementById('label-modal'));
function fmtTs(ts) {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString();
}
function fmtDuration(s) {
if (!s) return '—';
if (s < 60) return s.toFixed(0) + 's';
return (s / 60).toFixed(1) + 'm';
}
function profileLink(p) {
const label = p.name || ('Profile #' + p.id);
return `<a href="/visitors/${p.id}" class="text-info text-decoration-none">${label}</a>`;
}
function actionBtns(p) {
const ignore = p.ignored
? `<button class="btn btn-xs btn-outline-secondary btn-unignore" data-id="${p.id}">Unignore</button>`
: `<button class="btn btn-xs btn-outline-warning btn-ignore" data-id="${p.id}">Ignore</button>`;
return `<div class="d-flex gap-1 justify-content-end">
<button class="btn btn-xs btn-outline-info btn-label" data-id="${p.id}">Label</button>
${ignore}
<button class="btn btn-xs btn-outline-danger btn-forget" data-id="${p.id}">Forget</button>
</div>`;
}
function loadHousehold() {
fetch('/visitors/api/profiles')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = (data.profiles || []).filter(p => p.is_household);
const tbody = document.getElementById('tbody-household');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No household members</td></tr>';
return;
}
tbody.innerHTML = rows.map(p => `<tr>
<td>${profileLink(p)}</td>
<td><span class="badge bg-secondary">${p.presence_member || '—'}</span></td>
<td>${p.visit_count}</td>
<td class="text-nowrap">${fmtTs(p.last_seen_at)}</td>
<td>${actionBtns(p)}</td>
</tr>`).join('');
}).catch(() => {});
}
function loadKnown() {
fetch('/visitors/api/profiles?filter=known')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = (data.profiles || []).filter(p => !p.is_household);
const tbody = document.getElementById('tbody-known');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No named visitors</td></tr>';
return;
}
tbody.innerHTML = rows.map(p => `<tr>
<td>${profileLink(p)}</td>
<td>${p.visit_count}</td>
<td class="text-nowrap">${fmtTs(p.first_seen_at)}</td>
<td class="text-nowrap">${fmtTs(p.last_seen_at)}</td>
<td>${actionBtns(p)}</td>
</tr>`).join('');
}).catch(() => {});
}
function loadUnknown() {
fetch('/visitors/api/profiles')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = (data.profiles || []).filter(p => !p.name && !p.is_household);
const tbody = document.getElementById('tbody-unknown');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">No unidentified faces</td></tr>';
return;
}
tbody.innerHTML = rows.map(p => `<tr>
<td>${profileLink(p)}</td>
<td>${p.visit_count}</td>
<td class="text-nowrap">${fmtTs(p.first_seen_at)}</td>
<td class="text-nowrap">${fmtTs(p.last_seen_at)}</td>
<td>${p.ignored ? '<span class="badge bg-secondary">Yes</span>' : '—'}</td>
<td>${actionBtns(p)}</td>
</tr>`).join('');
}).catch(() => {});
}
function loadVisits() {
fetch('/visitors/api/visits?limit=50')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = data.visits || [];
const tbody = document.getElementById('tbody-visits');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No visits recorded</td></tr>';
return;
}
tbody.innerHTML = rows.map(v => `<tr>
<td><a href="/visitors/${v.profile_id}" class="text-info text-decoration-none">#${v.profile_id}</a></td>
<td>${v.camera_id}</td>
<td class="text-nowrap">${fmtTs(v.arrived_at)}</td>
<td class="text-nowrap">${fmtTs(v.departed_at)}</td>
<td>${fmtDuration(v.duration_s)}</td>
</tr>`).join('');
}).catch(() => {});
}
// Tab switching
document.querySelectorAll('[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('d-none'));
btn.classList.add('active');
document.getElementById('panel-' + btn.dataset.tab).classList.remove('d-none');
if (btn.dataset.tab === 'household') loadHousehold();
else if (btn.dataset.tab === 'known') loadKnown();
else if (btn.dataset.tab === 'unknown') loadUnknown();
else if (btn.dataset.tab === 'visits') loadVisits();
});
});
// Action delegation
document.addEventListener('click', e => {
const btn = e.target.closest('button[class*="btn-label"], button[class*="btn-ignore"],' +
'button[class*="btn-unignore"], button[class*="btn-forget"]');
if (!btn) return;
const id = btn.dataset.id;
if (btn.classList.contains('btn-label')) {
labelTargetId = id;
document.getElementById('label-name-input').value = '';
document.getElementById('label-consent-check').checked = false;
labelModal.show();
} else if (btn.classList.contains('btn-ignore')) {
fetch(`/visitors/${id}/ignore`, { method: 'POST' }).then(() => loadAll());
} else if (btn.classList.contains('btn-unignore')) {
fetch(`/visitors/${id}/unignore`, { method: 'POST' }).then(() => loadAll());
} else if (btn.classList.contains('btn-forget')) {
if (confirm('Permanently delete this face profile and all associated data?')) {
fetch(`/visitors/${id}/forget`, { method: 'DELETE' }).then(() => loadAll());
}
}
});
document.getElementById('label-confirm-btn').addEventListener('click', () => {
if (!labelTargetId) return;
const name = document.getElementById('label-name-input').value.trim();
const consent = document.getElementById('label-consent-check').checked;
fetch(`/visitors/${labelTargetId}/label`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, consent }),
}).then(r => {
if (r.ok) { labelModal.hide(); loadAll(); }
else r.json().then(d => alert(d.error || 'Failed to label profile'));
});
});
function loadAll() {
loadHousehold();
loadKnown();
loadUnknown();
loadVisits();
}
loadHousehold();
setInterval(loadAll, 60000);
})();
</script>
{% endblock %}