// Frigate Alerts - Web UI (function () { 'use strict'; let currentView = 'dashboard'; let currentTagEventId = null; let refreshTimer = null; let knownObjectsCache = []; // --- Initialization --- document.addEventListener('DOMContentLoaded', () => { setupNavigation(); setupTagModal(); loadView('dashboard'); loadStats(); }); // --- Navigation --- function setupNavigation() { document.querySelectorAll('.sidebar-item').forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); const view = item.dataset.view; loadView(view); }); }); } function setActiveNav(view) { document.querySelectorAll('.sidebar-item').forEach(item => { item.classList.toggle('active', item.dataset.view === view); }); } // --- View Router --- function loadView(view) { currentView = view; setActiveNav(view); clearAutoRefresh(); switch (view) { case 'dashboard': loadDashboard(); startAutoRefresh(); break; case 'review': loadReview(); startAutoRefresh(); break; case 'known': loadKnown(); break; case 'settings': loadSettings(); break; } } // --- Auto Refresh --- function startAutoRefresh() { refreshTimer = setInterval(() => { if (currentView === 'dashboard') { loadDashboard(); } else if (currentView === 'review') { loadReview(); } loadStats(); }, 30000); } function clearAutoRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } } // --- Dashboard View --- function loadDashboard() { const content = document.getElementById('main-content'); content.innerHTML = '
Loading events
'; fetch('/api/events?limit=50') .then(r => r.json()) .then(events => { if (!events.length) { content.innerHTML = `

Dashboard

No events recorded yet.

Events will appear here when Frigate detects objects.

`; return; } content.innerHTML = `
`; populateFilterOptions(events); renderEvents(events); }) .catch(err => { content.innerHTML = `

Dashboard

Error loading events: ${err.message}

`; }); } function populateFilterOptions(events) { const cameras = [...new Set(events.map(e => e.camera).filter(Boolean))].sort(); const labels = [...new Set(events.map(e => e.label).filter(Boolean))].sort(); const cameraSelect = document.getElementById('filter-camera'); const labelSelect = document.getElementById('filter-label'); if (cameraSelect) { cameras.forEach(c => { const opt = document.createElement('option'); opt.value = c; opt.textContent = c; cameraSelect.appendChild(opt); }); } if (labelSelect) { labels.forEach(l => { const opt = document.createElement('option'); opt.value = l; opt.textContent = l; labelSelect.appendChild(opt); }); } } // Expose filter function globally for inline onchange handlers window._applyFilters = function () { const camera = document.getElementById('filter-camera')?.value || ''; const label = document.getElementById('filter-label')?.value || ''; const status = document.getElementById('filter-status')?.value || ''; let url = '/api/events?limit=50'; if (camera) url += `&camera=${encodeURIComponent(camera)}`; if (label) url += `&label=${encodeURIComponent(label)}`; if (status) url += `&status=${encodeURIComponent(status)}`; fetch(url) .then(r => r.json()) .then(events => renderEvents(events)) .catch(err => console.error('Filter error:', err)); }; function renderEvents(events) { const container = document.getElementById('events-list'); if (!container) return; if (!events.length) { container.innerHTML = '

No events match the current filters.

'; return; } container.innerHTML = events.map(event => `
${event.label || 'snapshot'}
${escapeHtml(event.camera || 'unknown')} — ${escapeHtml(event.label || 'unknown')} (${(event.score * 100).toFixed(0)}%) ${event.match_status || 'unreviewed'}
${formatTime(event.created_at)}
${event.ai_description ? `
${escapeHtml(event.ai_description)}
` : ''} ${event.matched_object_name ? `
Identified as: ${escapeHtml(event.matched_object_name)}
` : ''}
`).join(''); } // --- Review View --- function loadReview() { const content = document.getElementById('main-content'); content.innerHTML = '
Loading review queue
'; fetch('/api/events?limit=50&status=unreviewed') .then(r => r.json()) .then(events => { if (!events.length) { content.innerHTML = `

Review Queue

No events to review.

All events have been reviewed or dismissed.

`; return; } content.innerHTML = `
`; renderEvents(events); }) .catch(err => { content.innerHTML = `

Review Queue

Error loading events: ${err.message}

`; }); } // --- Known Objects View --- function loadKnown() { const content = document.getElementById('main-content'); content.innerHTML = '
Loading known objects
'; fetch('/api/known-objects') .then(r => r.json()) .then(objects => { knownObjectsCache = objects; if (!objects.length) { content.innerHTML = `

Known Objects

No known objects registered.

Tag events from the Dashboard to add known objects.

`; return; } content.innerHTML = `
${objects.map(obj => `

${escapeHtml(obj.name)}

${escapeHtml(obj.type)}
${obj.embedding_count || 0} embedding${(obj.embedding_count || 0) !== 1 ? 's' : ''}
${obj.samples && obj.samples.length ? `
${obj.samples.map(s => `sample`).join('')}
` : ''}
`).join('')}
`; }) .catch(err => { content.innerHTML = `

Known Objects

Error loading objects: ${err.message}

`; }); } // --- Settings View --- function loadSettings() { const content = document.getElementById('main-content'); content.innerHTML = '
Loading settings
'; fetch('/api/settings') .then(r => r.json()) .then(settings => { content.innerHTML = `

Settings

Vision Analysis

Which AI backend to use for snapshot analysis

Recognition

Match detected objects against known objects database

Data Retention

How long to keep event records and snapshots
`; }) .catch(err => { content.innerHTML = `

Settings

Error loading settings: ${err.message}

`; }); } window._saveSettings = function () { const settings = { vision_backend: document.getElementById('setting-vision-backend').value, recognition_enabled: document.getElementById('setting-recognition-enabled').checked, event_retention_days: parseInt(document.getElementById('setting-retention-days').value, 10) }; fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }) .then(r => { if (!r.ok) throw new Error('Failed to save settings'); return r.json(); }) .then(() => showToast('Settings saved', 'success')) .catch(err => showToast('Error: ' + err.message, 'error')); }; // --- Stats --- function loadStats() { fetch('/api/stats') .then(r => r.json()) .then(stats => { const el = document.getElementById('stats-summary'); if (!el) return; el.innerHTML = `
Total Events ${stats.total_events || 0}
Unreviewed ${stats.unreviewed || 0}
Known ${stats.known_matches || 0}
Unknown ${stats.unknown_events || 0}
Known Objects ${stats.known_objects_count || 0}
`; }) .catch(() => { // Stats are non-critical, silently fail }); } // --- Tag Modal --- function setupTagModal() { document.getElementById('tag-cancel').addEventListener('click', closeTagModal); document.getElementById('tag-save').addEventListener('click', saveTag); document.getElementById('tag-existing-object').addEventListener('change', (e) => { const newFields = document.getElementById('tag-new-fields'); newFields.style.display = e.target.value ? 'none' : 'block'; }); // Close modal on backdrop click document.getElementById('tag-modal').addEventListener('click', (e) => { if (e.target === document.getElementById('tag-modal')) { closeTagModal(); } }); // Close modal on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeTagModal(); }); } window._tagEvent = function (eventId) { currentTagEventId = eventId; // Show snapshot in modal const snapshotContainer = document.getElementById('tag-modal-snapshot'); snapshotContainer.innerHTML = `snapshot`; // Load known objects for dropdown fetch('/api/known-objects') .then(r => r.json()) .then(objects => { knownObjectsCache = objects; const select = document.getElementById('tag-existing-object'); select.innerHTML = ''; objects.forEach(obj => { const opt = document.createElement('option'); opt.value = obj.id; opt.textContent = `${obj.name} (${obj.type})`; select.appendChild(opt); }); }) .catch(() => { }); // Reset form document.getElementById('tag-name').value = ''; document.getElementById('tag-type').value = 'person'; document.getElementById('tag-new-fields').style.display = 'block'; document.getElementById('tag-existing-object').value = ''; document.getElementById('tag-modal').classList.remove('hidden'); }; function closeTagModal() { document.getElementById('tag-modal').classList.add('hidden'); currentTagEventId = null; } function saveTag() { if (!currentTagEventId) return; const existingId = document.getElementById('tag-existing-object').value; let body; if (existingId) { body = { known_object_id: existingId }; } else { const name = document.getElementById('tag-name').value.trim(); const type = document.getElementById('tag-type').value; if (!name) { showToast('Please enter a name', 'error'); return; } body = { name: name, type: type }; } fetch(`/api/events/${currentTagEventId}/tag`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(r => { if (!r.ok) throw new Error('Failed to tag event'); return r.json(); }) .then(() => { showToast('Event tagged successfully', 'success'); closeTagModal(); loadView(currentView); loadStats(); }) .catch(err => showToast('Error: ' + err.message, 'error')); } // --- Event Actions --- window._dismissEvent = function (eventId) { fetch(`/api/events/${eventId}/dismiss`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) .then(r => { if (!r.ok) throw new Error('Failed to dismiss event'); return r.json(); }) .then(() => { showToast('Event dismissed', 'success'); // Remove card from DOM for immediate feedback const card = document.querySelector(`.event-card[data-event-id="${eventId}"]`); if (card) { card.style.opacity = '0'; card.style.transition = 'opacity 0.3s'; setTimeout(() => card.remove(), 300); } loadStats(); }) .catch(err => showToast('Error: ' + err.message, 'error')); }; // --- Known Object Actions --- window._deleteKnownObject = function (objectId, name) { if (!confirm(`Delete known object "${name}"? This cannot be undone.`)) return; fetch(`/api/known-objects/${objectId}`, { method: 'DELETE' }) .then(r => { if (!r.ok) throw new Error('Failed to delete object'); return r.json(); }) .then(() => { showToast(`Deleted "${name}"`, 'success'); loadKnown(); loadStats(); }) .catch(err => showToast('Error: ' + err.message, 'error')); }; // --- Utilities --- function escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function formatTime(timestamp) { if (!timestamp) return ''; const date = new Date(timestamp); const now = new Date(); const diffMs = now - date; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return 'Just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function showToast(message, type) { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; setTimeout(() => toast.remove(), 300); }, 3000); } })();