diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..a744b98 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,578 @@ +// 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.status || 'unreviewed'} +
+
${formatTime(event.timestamp)}
+ ${event.description ? `
${escapeHtml(event.description)}
` : ''} + ${event.identified_as ? `
Identified as: ${escapeHtml(event.identified_as)}
` : ''} +
+ + +
+
+
+ `).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 || 0} +
+
+ Unknown + ${stats.unknown || 0} +
+
+ Known Objects + ${stats.known_objects || 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); + } + +})(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..bb607a1 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,61 @@ + + + + + + Frigate Alerts + + + + +
+
+
+ + + + + + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..fbc281d --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,534 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #1a1a2e; + color: #e0e0e0; + display: flex; + min-height: 100vh; +} + +/* Sidebar */ +#sidebar { + width: 220px; + background: #16213e; + padding: 20px 0; + position: fixed; + height: 100vh; + overflow-y: auto; +} + +.sidebar-header { + padding: 0 20px 20px; + border-bottom: 1px solid #0f3460; +} + +.sidebar-header h2 { + color: #00b4d8; + font-size: 1.2em; +} + +.sidebar-section { + padding: 10px 0; +} + +.sidebar-item { + display: block; + padding: 10px 20px; + color: #a0a0a0; + text-decoration: none; + transition: all 0.2s; +} + +.sidebar-item:hover { + background: #0f3460; + color: #fff; +} + +.sidebar-item.active { + background: #0f3460; + color: #00b4d8; + border-left: 3px solid #00b4d8; +} + +.sidebar-stats { + padding: 20px; + font-size: 0.85em; + color: #888; +} + +/* Main content */ +#content { + margin-left: 220px; + flex: 1; + padding: 20px; +} + +/* Page headers */ +h1 { + margin-bottom: 20px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +/* Filters */ +.filters { + display: flex; + gap: 10px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.filters select, +.filters input { + padding: 6px 10px; + background: #16213e; + border: 1px solid #0f3460; + color: #e0e0e0; + border-radius: 4px; +} + +.filters select:focus, +.filters input:focus { + outline: none; + border-color: #00b4d8; +} + +/* Event cards */ +.event-card { + background: #16213e; + border-radius: 8px; + padding: 15px; + margin-bottom: 15px; + display: flex; + gap: 15px; + transition: border-color 0.2s; + border: 1px solid transparent; +} + +.event-card:hover { + border-color: #0f3460; +} + +.event-card img { + width: 160px; + height: 120px; + object-fit: cover; + border-radius: 4px; + background: #1a1a2e; +} + +.event-info { + flex: 1; +} + +.event-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.event-camera { + font-weight: bold; + color: #00b4d8; +} + +.event-label { + color: #a0a0a0; +} + +.event-score { + color: #888; + font-size: 0.85em; +} + +.event-description { + color: #ccc; + margin: 8px 0; + font-size: 0.9em; + line-height: 1.4; +} + +.event-time { + color: #666; + font-size: 0.8em; + margin-bottom: 6px; +} + +.event-actions { + margin-top: 10px; + display: flex; + gap: 8px; +} + +/* Status badges */ +.badge { + padding: 3px 10px; + border-radius: 12px; + font-size: 0.8em; + font-weight: bold; +} + +.badge-known { + background: #2d6a4f; + color: #95d5b2; +} + +.badge-unknown { + background: #9a4c1a; + color: #f4a261; +} + +.badge-ambiguous { + background: #7c6f09; + color: #ffd166; +} + +.badge-unreviewed { + background: #444; + color: #aaa; +} + +/* Buttons */ +.btn { + padding: 6px 14px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.85em; + transition: all 0.2s; + color: #ccc; + background: #2a2a4a; +} + +.btn:hover { + background: #3a3a5a; + color: #fff; +} + +.btn-primary { + background: #0f3460; + color: #fff; +} + +.btn-primary:hover { + background: #00b4d8; +} + +.btn-danger { + background: #7c2929; + color: #fff; +} + +.btn-danger:hover { + background: #c0392b; +} + +.btn-sm { + padding: 4px 10px; + font-size: 0.8em; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal.hidden { + display: none; +} + +.modal-content { + background: #16213e; + border-radius: 8px; + padding: 25px; + width: 450px; + max-width: 90vw; +} + +.modal-content h3 { + margin-bottom: 15px; + color: #00b4d8; +} + +.modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 15px; +} + +#tag-modal-snapshot { + margin-bottom: 15px; +} + +#tag-modal-snapshot img { + width: 100%; + max-height: 200px; + object-fit: cover; + border-radius: 4px; +} + +/* Form elements */ +.form-group { + margin-bottom: 12px; +} + +.form-group label { + display: block; + margin-bottom: 4px; + color: #a0a0a0; + font-size: 0.9em; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 8px 10px; + background: #1a1a2e; + border: 1px solid #0f3460; + color: #e0e0e0; + border-radius: 4px; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #00b4d8; +} + +.form-group input[type="checkbox"] { + width: auto; + margin-right: 8px; +} + +.form-group .checkbox-label { + display: flex; + align-items: center; + cursor: pointer; +} + +.form-group .help-text { + font-size: 0.8em; + color: #666; + margin-top: 4px; +} + +/* Known objects grid */ +.known-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 15px; +} + +.known-card { + background: #16213e; + border-radius: 8px; + padding: 15px; + border: 1px solid transparent; + transition: border-color 0.2s; +} + +.known-card:hover { + border-color: #0f3460; +} + +.known-card h4 { + color: #00b4d8; + margin-bottom: 5px; +} + +.known-card .type-badge { + font-size: 0.8em; + color: #a0a0a0; + text-transform: capitalize; +} + +.known-card .embedding-count { + font-size: 0.8em; + color: #666; + margin-top: 6px; +} + +.known-card .known-card-actions { + margin-top: 10px; + display: flex; + gap: 8px; +} + +.known-card .known-card-samples { + display: flex; + gap: 5px; + margin-top: 10px; + overflow-x: auto; +} + +.known-card .known-card-samples img { + width: 60px; + height: 60px; + object-fit: cover; + border-radius: 4px; + background: #1a1a2e; +} + +/* Settings */ +.settings-form { + max-width: 600px; +} + +.settings-form .form-group { + margin-bottom: 15px; +} + +.settings-section { + margin-bottom: 30px; +} + +.settings-section h3 { + color: #00b4d8; + margin-bottom: 15px; + padding-bottom: 8px; + border-bottom: 1px solid #0f3460; +} + +/* Stats in sidebar */ +.stat-item { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.stat-value { + color: #00b4d8; + font-weight: bold; +} + +/* Empty state */ +.empty-state { + text-align: center; + color: #666; + padding: 60px 20px; +} + +.empty-state p { + margin-top: 10px; + font-size: 0.9em; +} + +/* Loading spinner */ +.loading { + text-align: center; + padding: 40px; + color: #666; +} + +.loading::after { + content: ''; + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid #0f3460; + border-top-color: #00b4d8; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-left: 10px; + vertical-align: middle; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Toast notifications */ +.toast { + position: fixed; + bottom: 20px; + right: 20px; + padding: 12px 20px; + border-radius: 6px; + color: #fff; + font-size: 0.9em; + z-index: 2000; + animation: slideIn 0.3s ease-out; +} + +.toast-success { + background: #2d6a4f; +} + +.toast-error { + background: #7c2929; +} + +@keyframes slideIn { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Responsive */ +@media (max-width: 768px) { + #sidebar { + width: 100%; + height: auto; + position: relative; + } + + .sidebar-section { + display: flex; + flex-wrap: wrap; + } + + .sidebar-item { + padding: 8px 15px; + } + + .sidebar-stats { + display: none; + } + + #content { + margin-left: 0; + } + + body { + flex-direction: column; + } + + .event-card { + flex-direction: column; + } + + .event-card img { + width: 100%; + height: auto; + } + + .known-grid { + grid-template-columns: 1fr; + } +}