templates/doors.html
doors version 2.5 · 8 KB
| 1 | {% extends "base.html" %}{% block title %}Doors - {{ brand }}{% endblock %} |
| 2 | {% block head %}<link rel="stylesheet" href="/x/doors/static/doors.css">{% endblock %} |
| 3 | {% block content %} |
| 4 | <div class="page-head"> |
| 5 | <div><h1>Doors</h1><p>Access-control events linked to your cameras - click ▶ to see who went through</p></div> |
| 6 | <div class="toolbar"> |
| 7 | <input id="dq" placeholder="Search door, person, event…" style="min-width:210px"> |
| 8 | <select id="dkind"><option value="">All events</option> |
| 9 | <option value="denied">Access denied</option><option value="granted">Access granted</option> |
| 10 | <option value="opened">Opened</option><option value="other">Other</option></select> |
| 11 | <select id="ddoor"><option value="">All doors</option></select> |
| 12 | <input type="date" id="ddate"> |
| 13 | <button class="btn secondary" id="dclear" title="Delete the events currently shown by these filters">Clear matching…</button> |
| 14 | </div> |
| 15 | </div> |
| 16 | |
| 17 | <div id="dlist" class="door-list"><p class="muted">Loading…</p></div> |
| 18 | <div style="text-align:center;margin:12px 0"><button class="btn secondary" id="dmore" hidden>Load more</button></div> |
| 19 | |
| 20 | <details class="card door-setup" id="dsetup"> |
| 21 | <summary><h3 style="display:inline">Setup</h3> <span class="muted small">connection · door → camera</span></summary> |
| 22 | |
| 23 | <h4>Connection</h4> |
| 24 | <p class="muted small" id="protegeStatus"></p> |
| 25 | |
| 26 | <h4>Which camera covers which door</h4> |
| 27 | <p class="muted small">"Door name contains" is matched against the door name and the whole event text, so partial names work |
| 28 | (e.g. <code>Front Entry</code>, <code>Warehouse</code>). Add several rows for doors covered by more than one camera.</p> |
| 29 | <div id="dmaps"></div> |
| 30 | <div class="toolbar" style="margin-top:8px"><button class="btn secondary small" id="daddmap">+ Add door</button> |
| 31 | <button class="btn small" id="dsavemaps">Save mapping</button></div> |
| 32 | |
| 33 | <p class="muted small" style="margin-top:14px">Controller address, alerts and history length are in <a href="/settings#custom-doors">Settings → Door access</a>. |
| 34 | Tip: expand any event below to see the exact message received.</p> |
| 35 | </details> |
| 36 | {% endblock %}{% block scripts %}<script> |
| 37 | const $ = (id) => document.getElementById(id); |
| 38 | const esc = JP.esc; |
| 39 | let setup = null, oldest = null; |
| 40 | const fmt = (iso) => new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit' }); |
| 41 | const recLink = (stream, iso) => { |
| 42 | const t = new Date(new Date(iso).getTime() - 10000); // start 10 s before the event |
| 43 | const p = (n) => String(n).padStart(2, '0'); |
| 44 | const at = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`; |
| 45 | return `/recordings?stream=${encodeURIComponent(stream)}&at=${at}`; |
| 46 | }; |
| 47 | |
| 48 | function row(e) { |
| 49 | const cams = e.cameras.length ? e.cameras.map((c, i) => `<a class="btn secondary small" href="${recLink(c, e.time)}">▶ ${esc(e.camera_names[i])}</a>`).join(' ') |
| 50 | : '<span class="muted small">no camera mapped</span>'; |
| 51 | return `<details class="door-row k-${esc(e.kind)}"><summary> |
| 52 | <span class="door-time">${fmt(e.time)}</span> |
| 53 | <span class="door-badge">${esc(e.kind_label)}</span> |
| 54 | <span class="door-name">🚪 ${esc(e.door || 'Unknown door')}</span> |
| 55 | <span class="door-user">${e.user_name ? '👤 ' + esc(e.user_name) : ''}</span> |
| 56 | <span class="door-cams" onclick="event.stopPropagation()">${cams} |
| 57 | <button class="btn secondary small door-del" title="Delete this event" data-id="${e.id}" onclick="deleteDoorEvent(event, ${e.id})">✕</button></span></summary> |
| 58 | <div class="door-raw"><span class="muted small">${esc(e.source || '')} · raw message:</span><pre class="mono">${esc(e.raw || '')}</pre></div></details>`; |
| 59 | } |
| 60 | |
| 61 | async function deleteDoorEvent(ev, id) { |
| 62 | ev.stopPropagation(); ev.preventDefault(); |
| 63 | if (!confirm('Delete this door event?')) return; |
| 64 | try { |
| 65 | await JP.api('/api/x/doors/events/' + id, { method: 'DELETE' }); |
| 66 | load(false); |
| 67 | } catch (e) { JP.toast(e.message, 'error'); } |
| 68 | } |
| 69 | |
| 70 | function currentFilters() { |
| 71 | return { q: $('dq').value, kind: $('dkind').value, door: $('ddoor').value, date: $('ddate').value }; |
| 72 | } |
| 73 | |
| 74 | async function clearMatching() { |
| 75 | const f = currentFilters(); |
| 76 | const anyFilter = f.q || f.kind || f.door || f.date; |
| 77 | let n; |
| 78 | try { |
| 79 | const r = await JP.api('/api/x/doors/delete_matching', { method: 'POST', body: f }); |
| 80 | n = r.count; |
| 81 | } catch (e) { JP.toast(e.message, 'error'); return; } |
| 82 | if (!n) { JP.toast('No matching events to delete', 'muted'); return; } |
| 83 | const what = anyFilter ? `${n} matching event${n === 1 ? '' : 's'}` : `all ${n} door event${n === 1 ? '' : 's'}`; |
| 84 | if (!confirm(`Delete ${what}? This can't be undone.`)) return; |
| 85 | try { |
| 86 | const r = await JP.api('/api/x/doors/delete_matching', { method: 'POST', body: { ...f, confirm: true } }); |
| 87 | JP.toast(`Deleted ${r.deleted} event${r.deleted === 1 ? '' : 's'}`, 'good'); |
| 88 | load(false); |
| 89 | } catch (e) { JP.toast(e.message, 'error'); } |
| 90 | } |
| 91 | |
| 92 | async function load(more) { |
| 93 | const params = new URLSearchParams({ q: $('dq').value, kind: $('dkind').value, door: $('ddoor').value, date: $('ddate').value, limit: 100 }); |
| 94 | if (more && oldest) params.set('before_id', oldest); |
| 95 | const d = await JP.api('/api/x/doors/events?' + params); |
| 96 | setup = d.setup; |
| 97 | if (!more) renderSetup(d); |
| 98 | const html = d.events.map(row).join(''); |
| 99 | if (more) $('dlist').insertAdjacentHTML('beforeend', html); |
| 100 | else $('dlist').innerHTML = html || '<p class="muted">No door events yet — badge through a door and it\'ll appear here within a few seconds.</p>'; |
| 101 | oldest = d.events.length ? d.events[d.events.length - 1].id : oldest; |
| 102 | $('dmore').hidden = d.events.length < 100; |
| 103 | const sel = $('ddoor'), cur = sel.value; |
| 104 | sel.innerHTML = '<option value="">All doors</option>' + d.doors.map((n) => `<option ${n === cur ? 'selected' : ''}>${esc(n)}</option>`).join(''); |
| 105 | } |
| 106 | |
| 107 | function renderSetup(d) { |
| 108 | const s = d.setup; |
| 109 | const p = s.protege || {}; |
| 110 | if (p.last_error) { |
| 111 | $('protegeStatus').innerHTML = `<span class="bad">Not connected: ${esc(p.last_error)}</span> — check the controller settings in <a href="/settings#custom-doors">Settings → Door access</a>.`; |
| 112 | } else if (p.connected) { |
| 113 | $('protegeStatus').innerHTML = `Connected to the ICT Protege WX at <b>${esc(p.host || '')}</b>, polling every ${p.poll_seconds || '?'}s · ${p.events_seen || 0} event(s) seen this run.`; |
| 114 | } else { |
| 115 | $('protegeStatus').innerHTML = `Configured for ICT Protege WX (<b>${esc(p.host || 'no address set')}</b>). Waiting for the first poll…`; |
| 116 | } |
| 117 | if (!document.querySelector('#dmaps .map-row')) { |
| 118 | $('dmaps').innerHTML = ''; |
| 119 | (s.mappings.length ? s.mappings : [{ match: '', camera: '' }]).forEach(addMap); |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | function addMap(m) { |
| 124 | const opts = setup.cameras.map((c) => `<option value="${esc(c.stream)}" ${c.stream === m.camera ? 'selected' : ''}>${esc(c.name)}</option>`).join(''); |
| 125 | $('dmaps').insertAdjacentHTML('beforeend', `<div class="map-row"> |
| 126 | <input class="m-match" placeholder="Door name contains, e.g. Front Entry" value="${esc(m.match || '')}"> |
| 127 | <span class="muted">→</span><select class="m-cam"><option value="">Camera…</option>${opts}</select> |
| 128 | <button class="btn secondary small m-del" title="Remove">✕</button></div>`); |
| 129 | $('dmaps').lastElementChild.querySelector('.m-del').onclick = (e) => e.target.closest('.map-row').remove(); |
| 130 | } |
| 131 | |
| 132 | $('daddmap').onclick = () => addMap({}); |
| 133 | $('dsavemaps').onclick = async () => { |
| 134 | const mappings = [...document.querySelectorAll('#dmaps .map-row')].map((r) => ({ match: r.querySelector('.m-match').value, camera: r.querySelector('.m-cam').value })); |
| 135 | try { const r = await JP.api('/api/x/doors/mappings', { method: 'POST', body: { mappings } }); JP.toast(`Saved ${r.mappings.length} door mapping(s)`, 'good'); } |
| 136 | catch (e) { JP.toast(e.message, 'error'); } |
| 137 | }; |
| 138 | let t; ['dq', 'dkind', 'ddoor', 'ddate'].forEach((id) => $(id).addEventListener('input', () => { clearTimeout(t); t = setTimeout(() => load(false), 250); })); |
| 139 | $('dmore').onclick = () => load(true); |
| 140 | $('dclear').onclick = clearMatching; |
| 141 | load(false); |
| 142 | setInterval(() => { if (!document.hidden && !$('dq').value && !document.activeElement?.closest('.door-setup')) load(false); }, 15000); |
| 143 | </script>{% endblock %} |