Files
hobbs-iac/compose/hf-pdocker-01/frigate-notify/app/static/app.js
T
jeremy dddc6734d0 feat: frigate-notify v2 (modular app, CLIP recognition) + clip-service
Splits the single main.py into config/database/models/routes/vision/recognition/
ollama_client/cleanup modules, adds a web UI, and introduces clip-service for
CLIP (ViT-L-14) embeddings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:19:03 -05:00

579 lines
23 KiB
JavaScript

// 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 = '<div class="loading">Loading events</div>';
fetch('/api/events?limit=50')
.then(r => r.json())
.then(events => {
if (!events.length) {
content.innerHTML = `
<h1>Dashboard</h1>
<div class="empty-state">
<p>No events recorded yet.</p>
<p>Events will appear here when Frigate detects objects.</p>
</div>`;
return;
}
content.innerHTML = `
<div class="page-header">
<h1>Dashboard</h1>
</div>
<div class="filters">
<select id="filter-camera" onchange="window._applyFilters()">
<option value="">All Cameras</option>
</select>
<select id="filter-label" onchange="window._applyFilters()">
<option value="">All Labels</option>
</select>
<select id="filter-status" onchange="window._applyFilters()">
<option value="">All Statuses</option>
<option value="known">Known</option>
<option value="unknown">Unknown</option>
<option value="ambiguous">Ambiguous</option>
<option value="unreviewed">Unreviewed</option>
</select>
</div>
<div id="events-list"></div>`;
populateFilterOptions(events);
renderEvents(events);
})
.catch(err => {
content.innerHTML = `<h1>Dashboard</h1><div class="empty-state"><p>Error loading events: ${err.message}</p></div>`;
});
}
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 = '<div class="empty-state"><p>No events match the current filters.</p></div>';
return;
}
container.innerHTML = events.map(event => `
<div class="event-card" data-event-id="${event.id}">
<img src="/api/events/${event.id}/snapshot"
onerror="this.style.display='none'"
alt="${event.label || 'snapshot'}">
<div class="event-info">
<div class="event-header">
<span>
<span class="event-camera">${escapeHtml(event.camera || 'unknown')}</span>
<span class="event-label"> &mdash; ${escapeHtml(event.label || 'unknown')}</span>
<span class="event-score">(${(event.score * 100).toFixed(0)}%)</span>
</span>
<span class="badge badge-${event.match_status || 'unreviewed'}">${event.match_status || 'unreviewed'}</span>
</div>
<div class="event-time">${formatTime(event.created_at)}</div>
${event.ai_description ? `<div class="event-description">${escapeHtml(event.ai_description)}</div>` : ''}
${event.matched_object_name ? `<div class="event-label">Identified as: <strong>${escapeHtml(event.matched_object_name)}</strong></div>` : ''}
<div class="event-actions">
<button class="btn btn-primary btn-sm" onclick="window._tagEvent('${event.id}')">Tag</button>
<button class="btn btn-sm" onclick="window._dismissEvent('${event.id}')">Dismiss</button>
</div>
</div>
</div>
`).join('');
}
// --- Review View ---
function loadReview() {
const content = document.getElementById('main-content');
content.innerHTML = '<div class="loading">Loading review queue</div>';
fetch('/api/events?limit=50&status=unreviewed')
.then(r => r.json())
.then(events => {
if (!events.length) {
content.innerHTML = `
<h1>Review Queue</h1>
<div class="empty-state">
<p>No events to review.</p>
<p>All events have been reviewed or dismissed.</p>
</div>`;
return;
}
content.innerHTML = `
<div class="page-header">
<h1>Review Queue</h1>
<span>${events.length} event${events.length !== 1 ? 's' : ''} to review</span>
</div>
<div id="events-list"></div>`;
renderEvents(events);
})
.catch(err => {
content.innerHTML = `<h1>Review Queue</h1><div class="empty-state"><p>Error loading events: ${err.message}</p></div>`;
});
}
// --- Known Objects View ---
function loadKnown() {
const content = document.getElementById('main-content');
content.innerHTML = '<div class="loading">Loading known objects</div>';
fetch('/api/known-objects')
.then(r => r.json())
.then(objects => {
knownObjectsCache = objects;
if (!objects.length) {
content.innerHTML = `
<h1>Known Objects</h1>
<div class="empty-state">
<p>No known objects registered.</p>
<p>Tag events from the Dashboard to add known objects.</p>
</div>`;
return;
}
content.innerHTML = `
<div class="page-header">
<h1>Known Objects</h1>
<span>${objects.length} object${objects.length !== 1 ? 's' : ''}</span>
</div>
<div class="known-grid">
${objects.map(obj => `
<div class="known-card" data-object-id="${obj.id}">
<h4>${escapeHtml(obj.name)}</h4>
<span class="type-badge">${escapeHtml(obj.type)}</span>
<div class="embedding-count">${obj.embedding_count || 0} embedding${(obj.embedding_count || 0) !== 1 ? 's' : ''}</div>
${obj.samples && obj.samples.length ? `
<div class="known-card-samples">
${obj.samples.map(s => `<img src="${s}" onerror="this.style.display='none'" alt="sample">`).join('')}
</div>
` : ''}
<div class="known-card-actions">
<button class="btn btn-danger btn-sm" onclick="window._deleteKnownObject('${obj.id}', '${escapeHtml(obj.name)}')">Delete</button>
</div>
</div>
`).join('')}
</div>`;
})
.catch(err => {
content.innerHTML = `<h1>Known Objects</h1><div class="empty-state"><p>Error loading objects: ${err.message}</p></div>`;
});
}
// --- Settings View ---
function loadSettings() {
const content = document.getElementById('main-content');
content.innerHTML = '<div class="loading">Loading settings</div>';
fetch('/api/settings')
.then(r => r.json())
.then(settings => {
content.innerHTML = `
<h1>Settings</h1>
<div class="settings-form">
<div class="settings-section">
<h3>Vision Analysis</h3>
<div class="form-group">
<label>Vision Backend</label>
<select id="setting-vision-backend">
<option value="haiku" ${settings.vision_backend === 'haiku' ? 'selected' : ''}>Claude Haiku</option>
<option value="ollama" ${settings.vision_backend === 'ollama' ? 'selected' : ''}>Ollama (Local)</option>
<option value="both" ${settings.vision_backend === 'both' ? 'selected' : ''}>Both (Haiku + Ollama)</option>
</select>
<div class="help-text">Which AI backend to use for snapshot analysis</div>
</div>
</div>
<div class="settings-section">
<h3>Recognition</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="setting-recognition-enabled" ${settings.recognition_enabled ? 'checked' : ''}>
Enable object recognition
</label>
<div class="help-text">Match detected objects against known objects database</div>
</div>
</div>
<div class="settings-section">
<h3>Data Retention</h3>
<div class="form-group">
<label>Event Retention (days)</label>
<input type="number" id="setting-retention-days" value="${settings.event_retention_days || 30}" min="1" max="365">
<div class="help-text">How long to keep event records and snapshots</div>
</div>
</div>
<div class="form-group">
<button class="btn btn-primary" onclick="window._saveSettings()">Save Settings</button>
</div>
</div>`;
})
.catch(err => {
content.innerHTML = `<h1>Settings</h1><div class="empty-state"><p>Error loading settings: ${err.message}</p></div>`;
});
}
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 = `
<div class="stat-item">
<span>Total Events</span>
<span class="stat-value">${stats.total_events || 0}</span>
</div>
<div class="stat-item">
<span>Unreviewed</span>
<span class="stat-value">${stats.unreviewed || 0}</span>
</div>
<div class="stat-item">
<span>Known</span>
<span class="stat-value">${stats.known_matches || 0}</span>
</div>
<div class="stat-item">
<span>Unknown</span>
<span class="stat-value">${stats.unknown_events || 0}</span>
</div>
<div class="stat-item">
<span>Known Objects</span>
<span class="stat-value">${stats.known_objects_count || 0}</span>
</div>`;
})
.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 = `<img src="/api/events/${eventId}/snapshot" onerror="this.style.display='none'" alt="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 = '<option value="">-- Create new --</option>';
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);
}
})();