localeye.cam

← Back

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>
37const $ = (id) => document.getElementById(id);
38const esc = JP.esc;
39let setup = null, oldest = null;
40const fmt = (iso) => new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit' });
41const 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
48function 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
61async 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
70function currentFilters() {
71 return { q: $('dq').value, kind: $('dkind').value, door: $('ddoor').value, date: $('ddate').value };
72}
73
74async 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
92async 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
107function 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
123function 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};
138let 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;
141load(false);
142setInterval(() => { if (!document.hidden && !$('dq').value && !document.activeElement?.closest('.door-setup')) load(false); }, 15000);
143</script>{% endblock %}