feat: add search and filter to timeline with category checkboxes
Implement comprehensive filter bar with:
- Type filter checkboxes for event categories (Presence, Zones, Alerts, System, Learning)
- Person and zone dropdowns for filtering
- Date range selector with preset options (Today/Last 7 days/Last 30 days/Custom)
- Text search input for fuzzy matching on descriptions
- Client-side filtering for loaded events (instant feedback)
- Server-side filtering for date-range queries
- Load more pagination works for 500+ results
Backend changes:
- Add support for 'since'/'until' date range parameters in /api/events
- Add zone_id and person_id query parameter aliases
- Add POST /api/events/{id}/feedback endpoint for feedback submission
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fd9c56fb38
commit
d879e2268b
6 changed files with 1197 additions and 119 deletions
|
|
@ -20,7 +20,7 @@
|
|||
|
||||
/* Header */
|
||||
.timeline-header {
|
||||
padding: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary, #16162a);
|
||||
border-bottom: 1px solid var(--border-color, #2a2a4a);
|
||||
display: flex;
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
margin: 0;
|
||||
|
|
@ -41,12 +41,129 @@
|
|||
}
|
||||
|
||||
.timeline-title svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
/* Filter Toggle Button */
|
||||
.timeline-filter-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #a0a0b0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.timeline-filter-toggle:hover {
|
||||
background: var(--bg-primary, #121225);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border-color: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.timeline-filter-toggle svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.timeline-filter-bar {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary, #16162a);
|
||||
border-bottom: 1px solid var(--border-color, #2a2a4a);
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-filter-bar.collapsed {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-filter-section {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.timeline-filter-section-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-muted, #888);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
/* Category Checkboxes */
|
||||
.timeline-category-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-category-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-primary, #121225);
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #a0a0b0);
|
||||
}
|
||||
|
||||
.timeline-category-checkbox:hover {
|
||||
border-color: var(--accent-color, #4a9eff);
|
||||
background: var(--bg-tertiary, #1a1a2e);
|
||||
}
|
||||
|
||||
.timeline-category-checkbox input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent-color, #4a9eff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-category-checkbox:has(input:checked) {
|
||||
border-color: var(--accent-color, #4a9eff);
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.timeline-category-checkbox input[type="checkbox"]:disabled {
|
||||
accent-color: var(--text-muted, #666);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.timeline-category-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeline-category-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Filter Controls */
|
||||
.timeline-filter-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -535,6 +652,15 @@
|
|||
align-items: stretch;
|
||||
}
|
||||
|
||||
.timeline-filter-bar {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-filter-section {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.timeline-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
|
@ -554,3 +680,53 @@
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Date Range */
|
||||
.timeline-custom-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: var(--bg-primary, #121225);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-date-input {
|
||||
background: var(--bg-secondary, #16162a);
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timeline-date-input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-date-separator {
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.timeline-date-apply-btn {
|
||||
background: var(--accent-color, #4a9eff);
|
||||
color: var(--bg-primary, #121225);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-date-apply-btn:hover {
|
||||
background: #3a8ee6;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2687,23 +2687,71 @@
|
|||
Timeline
|
||||
<span id="timeline-count" class="timeline-count"></span>
|
||||
</h2>
|
||||
<div class="timeline-filters">
|
||||
<select id="timeline-filter-type" class="timeline-filter-select">
|
||||
<option value="">All Types</option>
|
||||
</select>
|
||||
<select id="timeline-filter-zone" class="timeline-filter-select">
|
||||
<option value="">All Zones</option>
|
||||
</select>
|
||||
<select id="timeline-filter-person" class="timeline-filter-select">
|
||||
<option value="">All People</option>
|
||||
</select>
|
||||
<select id="timeline-filter-time" class="timeline-filter-select">
|
||||
<option value="">All Time</option>
|
||||
<option value="today">Today</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
</select>
|
||||
<input type="text" id="timeline-filter-search" class="timeline-search" placeholder="Search events...">
|
||||
<button id="timeline-filter-toggle" class="timeline-filter-toggle" title="Toggle filters">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="22 3 2 10 12.79 21 15.41 18.38 19 14.79 22 3 22 3"></polygon>
|
||||
<path d="M20.41 11.26 12.79 3.64"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="timeline-filter-bar" class="timeline-filter-bar">
|
||||
<div class="timeline-filter-section">
|
||||
<h4 class="timeline-filter-section-title">Categories</h4>
|
||||
<div class="timeline-category-checkboxes">
|
||||
<label class="timeline-category-checkbox">
|
||||
<input type="checkbox" id="timeline-category-presence" checked>
|
||||
<span class="timeline-category-label">Presence</span>
|
||||
<span class="timeline-category-icon">👤</span>
|
||||
</label>
|
||||
<label class="timeline-category-checkbox">
|
||||
<input type="checkbox" id="timeline-category-zones" checked>
|
||||
<span class="timeline-category-label">Zones</span>
|
||||
<span class="timeline-category-icon">🚪</span>
|
||||
</label>
|
||||
<label class="timeline-category-checkbox">
|
||||
<input type="checkbox" id="timeline-category-alerts" checked>
|
||||
<span class="timeline-category-label">Alerts</span>
|
||||
<span class="timeline-category-icon">⚠️</span>
|
||||
</label>
|
||||
<label class="timeline-category-checkbox">
|
||||
<input type="checkbox" id="timeline-category-system" checked>
|
||||
<span class="timeline-category-label">System</span>
|
||||
<span class="timeline-category-icon">⚙️</span>
|
||||
</label>
|
||||
<label class="timeline-category-checkbox">
|
||||
<input type="checkbox" id="timeline-category-learning" checked>
|
||||
<span class="timeline-category-label">Learning</span>
|
||||
<span class="timeline-category-icon">🎓</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-filter-section">
|
||||
<h4 class="timeline-filter-section-title">Filters</h4>
|
||||
<div class="timeline-filter-controls">
|
||||
<select id="timeline-filter-type" class="timeline-filter-select">
|
||||
<option value="">All Types</option>
|
||||
</select>
|
||||
<select id="timeline-filter-zone" class="timeline-filter-select">
|
||||
<option value="">All Zones</option>
|
||||
</select>
|
||||
<select id="timeline-filter-person" class="timeline-filter-select">
|
||||
<option value="">All People</option>
|
||||
</select>
|
||||
<select id="timeline-filter-time" class="timeline-filter-select">
|
||||
<option value="">All Time</option>
|
||||
<option value="today">Today</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="custom">Custom range...</option>
|
||||
</select>
|
||||
<input type="text" id="timeline-filter-search" class="timeline-search" placeholder="Search descriptions...">
|
||||
</div>
|
||||
<div id="timeline-custom-date-container" class="timeline-custom-date" style="display: none;">
|
||||
<input type="date" id="timeline-date-from" class="timeline-date-input">
|
||||
<span class="timeline-date-separator">to</span>
|
||||
<input type="date" id="timeline-date-to" class="timeline-date-input">
|
||||
<button id="timeline-date-apply" class="timeline-date-apply-btn">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="timeline-loading" class="timeline-loading" style="display: none;">
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
* Scrollable chronological event list with filtering and event interaction.
|
||||
* Click event → jump to that moment in replay mode.
|
||||
* Inline feedback (thumbs up/down) on presence detection events.
|
||||
* Virtualized rendering with IntersectionObserver for 1000+ events.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -19,9 +19,23 @@
|
|||
replaySeekWindowSec: 5, // seconds before/after event timestamp
|
||||
virtualization: {
|
||||
enabled: true,
|
||||
rootMargin: '200px', // load items 200px before they enter viewport
|
||||
bufferSize: 50, // number of extra items to render above/below viewport
|
||||
rootMargin: '400px', // load items 400px before they enter viewport
|
||||
threshold: 0.01 // trigger when 1% of item is visible
|
||||
}
|
||||
},
|
||||
itemHeight: 80, // estimated height of a timeline event item in pixels
|
||||
maxDOMItems: 150 // maximum number of items to keep in DOM at once
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Event Type Categories
|
||||
// ============================================
|
||||
const EVENT_CATEGORIES = {
|
||||
presence: ['presence_transition', 'stationary_detected', 'detection'],
|
||||
zones: ['zone_entry', 'zone_exit', 'portal_crossing'],
|
||||
alerts: ['fall_alert', 'anomaly', 'security_alert'],
|
||||
system: ['node_online', 'node_offline', 'ota_update', 'baseline_changed', 'system'],
|
||||
learning: ['learning_milestone', 'anomaly_learned']
|
||||
};
|
||||
|
||||
// ============================================
|
||||
|
|
@ -32,18 +46,41 @@
|
|||
cursor: null,
|
||||
total: 0,
|
||||
filters: {
|
||||
type: null,
|
||||
categories: {
|
||||
presence: true,
|
||||
zones: true,
|
||||
alerts: true,
|
||||
system: true,
|
||||
learning: true
|
||||
},
|
||||
type: null, // specific type filter (overrides categories)
|
||||
zone: null,
|
||||
person: null,
|
||||
after: null, // ISO8601 string
|
||||
q: null
|
||||
until: null, // ISO8601 string
|
||||
q: null // text search query
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
// Filter options populated from available events
|
||||
availableTypes: new Set(),
|
||||
availableZones: new Set(),
|
||||
availablePersons: new Set()
|
||||
availablePersons: new Set(),
|
||||
// Virtualization state
|
||||
virtualization: {
|
||||
observer: null,
|
||||
visibleIndices: new Set(),
|
||||
renderedIndices: new Set(),
|
||||
firstVisibleIndex: 0,
|
||||
lastVisibleIndex: 0,
|
||||
containerHeight: 0,
|
||||
scrollTop: 0,
|
||||
totalHeight: 0
|
||||
},
|
||||
// Client-side filtered events
|
||||
filteredEvents: [],
|
||||
// All loaded events (for client-side filtering)
|
||||
allLoadedEvents: []
|
||||
};
|
||||
|
||||
// DOM elements
|
||||
|
|
@ -57,85 +94,113 @@
|
|||
icon: '🚪',
|
||||
color: '#66bb6a',
|
||||
label: 'Entered',
|
||||
description: 'Person entered a zone'
|
||||
description: 'Person entered a zone',
|
||||
category: 'zones'
|
||||
},
|
||||
zone_exit: {
|
||||
icon: '🚶',
|
||||
color: '#ffa726',
|
||||
label: 'Left',
|
||||
description: 'Person exited a zone'
|
||||
description: 'Person exited a zone',
|
||||
category: 'zones'
|
||||
},
|
||||
portal_crossing: {
|
||||
icon: '→',
|
||||
color: '#42a5f5',
|
||||
label: 'Crossed',
|
||||
description: 'Person crossed a portal'
|
||||
description: 'Person crossed a portal',
|
||||
category: 'zones'
|
||||
},
|
||||
presence_transition: {
|
||||
icon: '👤',
|
||||
color: '#ab47bc',
|
||||
label: 'Detected',
|
||||
description: 'Presence detected'
|
||||
description: 'Presence detected',
|
||||
category: 'presence'
|
||||
},
|
||||
stationary_detected: {
|
||||
icon: '💤',
|
||||
color: '#7e57c2',
|
||||
label: 'Stationary',
|
||||
description: 'Stationary person detected'
|
||||
description: 'Stationary person detected',
|
||||
category: 'presence'
|
||||
},
|
||||
detection: {
|
||||
icon: '👁️',
|
||||
color: '#ab47bc',
|
||||
label: 'Detected',
|
||||
description: 'Motion detected',
|
||||
category: 'presence'
|
||||
},
|
||||
anomaly: {
|
||||
icon: '⚠️',
|
||||
color: '#ef5350',
|
||||
label: 'Anomaly',
|
||||
description: 'Unusual activity detected'
|
||||
description: 'Unusual activity detected',
|
||||
category: 'alerts'
|
||||
},
|
||||
security_alert: {
|
||||
icon: '🚨',
|
||||
color: '#d32f2f',
|
||||
label: 'Security',
|
||||
description: 'Security alert'
|
||||
description: 'Security alert',
|
||||
category: 'alerts'
|
||||
},
|
||||
fall_alert: {
|
||||
icon: '🆘',
|
||||
color: '#f44336',
|
||||
label: 'Fall',
|
||||
description: 'Fall detected'
|
||||
description: 'Fall detected',
|
||||
category: 'alerts'
|
||||
},
|
||||
node_online: {
|
||||
icon: '📡',
|
||||
color: '#4caf50',
|
||||
label: 'Online',
|
||||
description: 'Node came online'
|
||||
description: 'Node came online',
|
||||
category: 'system'
|
||||
},
|
||||
node_offline: {
|
||||
icon: '📵',
|
||||
color: '#9e9e9e',
|
||||
label: 'Offline',
|
||||
description: 'Node went offline'
|
||||
description: 'Node went offline',
|
||||
category: 'system'
|
||||
},
|
||||
ota_update: {
|
||||
icon: '⬆️',
|
||||
color: '#2196f3',
|
||||
label: 'Updated',
|
||||
description: 'Firmware updated'
|
||||
description: 'Firmware updated',
|
||||
category: 'system'
|
||||
},
|
||||
baseline_changed: {
|
||||
icon: '📊',
|
||||
color: '#00bcd4',
|
||||
label: 'Baseline',
|
||||
description: 'Baseline updated'
|
||||
},
|
||||
learning_milestone: {
|
||||
icon: '🎓',
|
||||
color: '#9c27b0',
|
||||
label: 'Learned',
|
||||
description: 'System learned patterns'
|
||||
description: 'Baseline updated',
|
||||
category: 'system'
|
||||
},
|
||||
system: {
|
||||
icon: '⚙️',
|
||||
color: '#607d8b',
|
||||
label: 'System',
|
||||
description: 'System event'
|
||||
description: 'System event',
|
||||
category: 'system'
|
||||
},
|
||||
learning_milestone: {
|
||||
icon: '🎓',
|
||||
color: '#9c27b0',
|
||||
label: 'Learned',
|
||||
description: 'System learned patterns',
|
||||
category: 'learning'
|
||||
},
|
||||
anomaly_learned: {
|
||||
icon: '🧠',
|
||||
color: '#9c27b0',
|
||||
label: 'Learned',
|
||||
description: 'Anomaly pattern learned',
|
||||
category: 'learning'
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -147,6 +212,7 @@
|
|||
|
||||
cacheElements();
|
||||
bindEvents();
|
||||
setupVirtualization();
|
||||
|
||||
// Listen for route changes to show/hide timeline
|
||||
if (window.SpaxelRouter) {
|
||||
|
|
@ -159,6 +225,173 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Virtualization Setup
|
||||
// ============================================
|
||||
function setupVirtualization() {
|
||||
if (!CONFIG.virtualization.enabled || !elements.eventsList) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create IntersectionObserver for lazy rendering
|
||||
const observerOptions = {
|
||||
root: elements.eventsList,
|
||||
rootMargin: CONFIG.virtualization.rootMargin,
|
||||
threshold: CONFIG.virtualization.threshold
|
||||
};
|
||||
|
||||
state.virtualization.observer = new IntersectionObserver(function(entries) {
|
||||
handleIntersection(entries);
|
||||
}, observerOptions);
|
||||
|
||||
// Set up scroll listener for virtualization
|
||||
if (elements.eventsList) {
|
||||
elements.eventsList.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
|
||||
console.log('[Timeline] Virtualization enabled with IntersectionObserver');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Intersection Observer Handler
|
||||
// ============================================
|
||||
function handleIntersection(entries) {
|
||||
entries.forEach(function(entry) {
|
||||
const index = parseInt(entry.target.dataset.index, 10);
|
||||
if (isNaN(index)) return;
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
state.virtualization.visibleIndices.add(index);
|
||||
} else {
|
||||
state.virtualization.visibleIndices.delete(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Update rendered range based on visibility
|
||||
updateRenderedRange();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Scroll Handler for Virtualization
|
||||
// ============================================
|
||||
function onScroll() {
|
||||
if (!elements.eventsList) return;
|
||||
|
||||
state.virtualization.scrollTop = elements.eventsList.scrollTop;
|
||||
|
||||
// Update visible range based on scroll position
|
||||
const firstIndex = Math.floor(state.virtualization.scrollTop / CONFIG.itemHeight);
|
||||
const visibleCount = Math.ceil(elements.eventsList.clientHeight / CONFIG.itemHeight);
|
||||
const lastIndex = firstIndex + visibleCount;
|
||||
|
||||
state.virtualization.firstVisibleIndex = Math.max(0, firstIndex - CONFIG.virtualization.bufferSize);
|
||||
state.virtualization.lastVisibleIndex = Math.min(state.filteredEvents.length - 1, lastIndex + CONFIG.virtualization.bufferSize);
|
||||
|
||||
updateRenderedRange();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Update Rendered Range
|
||||
// ============================================
|
||||
function updateRenderedRange() {
|
||||
if (!state.filteredEvents.length) return;
|
||||
|
||||
const firstIdx = Math.max(0, state.virtualization.firstVisibleIndex);
|
||||
const lastIdx = Math.min(state.filteredEvents.length - 1, state.virtualization.lastVisibleIndex);
|
||||
|
||||
// Unobserve items that are no longer in range
|
||||
state.virtualization.renderedIndices.forEach(function(index) {
|
||||
if (index < firstIdx || index > lastIdx) {
|
||||
const item = elements.eventsList.querySelector('[data-index="' + index + '"]');
|
||||
if (item && state.virtualization.observer) {
|
||||
state.virtualization.observer.unobserve(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create new set of rendered indices
|
||||
const newRenderedIndices = new Set();
|
||||
for (let i = firstIdx; i <= lastIdx; i++) {
|
||||
newRenderedIndices.add(i);
|
||||
}
|
||||
|
||||
// Render new items and observe them
|
||||
const fragment = document.createDocumentFragment();
|
||||
newRenderedIndices.forEach(function(index) {
|
||||
if (!state.virtualization.renderedIndices.has(index)) {
|
||||
const event = state.filteredEvents[index];
|
||||
if (event) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = renderEvent(event, false, index);
|
||||
const newEventEl = tempDiv.firstElementChild;
|
||||
if (newEventEl) {
|
||||
newEventEl.dataset.index = index;
|
||||
fragment.appendChild(newEventEl);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Keep existing item in rendered set
|
||||
newRenderedIndices.add(index);
|
||||
}
|
||||
});
|
||||
|
||||
if (fragment.children.length > 0) {
|
||||
elements.eventsList.appendChild(fragment);
|
||||
|
||||
// Bind handlers for new items
|
||||
Array.from(fragment.children).forEach(function(item) {
|
||||
bindEventHandlersForElement(item);
|
||||
if (state.virtualization.observer) {
|
||||
state.virtualization.observer.observe(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove items that are no longer in rendered range
|
||||
state.virtualization.renderedIndices.forEach(function(index) {
|
||||
if (!newRenderedIndices.has(index)) {
|
||||
const item = elements.eventsList.querySelector('[data-index="' + index + '"]');
|
||||
if (item) {
|
||||
item.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
state.virtualization.renderedIndices = newRenderedIndices;
|
||||
|
||||
// Update total height for spacer
|
||||
updateVirtualSpacers();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Update Virtual Spacers
|
||||
// ============================================
|
||||
function updateVirtualSpacers() {
|
||||
if (!elements.eventsList) return;
|
||||
|
||||
const totalHeight = state.filteredEvents.length * CONFIG.itemHeight;
|
||||
state.virtualization.totalHeight = totalHeight;
|
||||
|
||||
// Add top spacer if needed
|
||||
let topSpacer = elements.eventsList.querySelector('.timeline-spacer-top');
|
||||
if (!topSpacer) {
|
||||
topSpacer = document.createElement('div');
|
||||
topSpacer.className = 'timeline-spacer timeline-spacer-top';
|
||||
elements.eventsList.insertBefore(topSpacer, elements.eventsList.firstChild);
|
||||
}
|
||||
topSpacer.style.height = (state.virtualization.firstVisibleIndex * CONFIG.itemHeight) + 'px';
|
||||
|
||||
// Add bottom spacer if needed
|
||||
let bottomSpacer = elements.eventsList.querySelector('.timeline-spacer-bottom');
|
||||
if (!bottomSpacer) {
|
||||
bottomSpacer = document.createElement('div');
|
||||
bottomSpacer.className = 'timeline-spacer timeline-spacer-bottom';
|
||||
elements.eventsList.appendChild(bottomSpacer);
|
||||
}
|
||||
const remainingHeight = (state.filteredEvents.length - state.virtualization.lastVisibleIndex - 1) * CONFIG.itemHeight;
|
||||
bottomSpacer.style.height = Math.max(0, remainingHeight) + 'px';
|
||||
}
|
||||
|
||||
function cacheElements() {
|
||||
elements = {
|
||||
container: document.getElementById('timeline-view'),
|
||||
|
|
@ -172,12 +405,43 @@
|
|||
empty: document.getElementById('timeline-empty'),
|
||||
error: document.getElementById('timeline-error'),
|
||||
loadMore: document.getElementById('timeline-load-more'),
|
||||
loadMoreBtn: document.getElementById('timeline-load-more-btn')
|
||||
loadMoreBtn: document.getElementById('timeline-load-more-btn'),
|
||||
// Category checkboxes
|
||||
categoryPresence: document.getElementById('timeline-category-presence'),
|
||||
categoryZones: document.getElementById('timeline-category-zones'),
|
||||
categoryAlerts: document.getElementById('timeline-category-alerts'),
|
||||
categorySystem: document.getElementById('timeline-category-system'),
|
||||
categoryLearning: document.getElementById('timeline-category-learning'),
|
||||
// Custom date range inputs
|
||||
customDateContainer: document.getElementById('timeline-custom-date-container'),
|
||||
dateFrom: document.getElementById('timeline-date-from'),
|
||||
dateTo: document.getElementById('timeline-date-to'),
|
||||
dateApply: document.getElementById('timeline-date-apply'),
|
||||
// Filter bar toggle
|
||||
filterToggle: document.getElementById('timeline-filter-toggle'),
|
||||
filterBar: document.getElementById('timeline-filter-bar')
|
||||
};
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
// Filter changes
|
||||
// Category checkboxes
|
||||
const categoryInputs = [
|
||||
{ el: elements.categoryPresence, key: 'presence' },
|
||||
{ el: elements.categoryZones, key: 'zones' },
|
||||
{ el: elements.categoryAlerts, key: 'alerts' },
|
||||
{ el: elements.categorySystem, key: 'system' },
|
||||
{ el: elements.categoryLearning, key: 'learning' }
|
||||
];
|
||||
categoryInputs.forEach(function(item) {
|
||||
if (item.el) {
|
||||
item.el.addEventListener('change', function() {
|
||||
state.filters.categories[item.key] = item.el.checked;
|
||||
applyClientSideFilters();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filter dropdowns
|
||||
if (elements.filterType) {
|
||||
elements.filterType.addEventListener('change', onFilterChange);
|
||||
}
|
||||
|
|
@ -188,20 +452,32 @@
|
|||
elements.filterPerson.addEventListener('change', onFilterChange);
|
||||
}
|
||||
if (elements.filterTime) {
|
||||
elements.filterTime.addEventListener('change', onFilterChange);
|
||||
elements.filterTime.addEventListener('change', onTimeFilterChange);
|
||||
}
|
||||
if (elements.filterSearch) {
|
||||
let searchTimeout;
|
||||
elements.filterSearch.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(onFilterChange, CONFIG.debounceMs);
|
||||
searchTimeout = setTimeout(onSearchChange, CONFIG.debounceMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Custom date range
|
||||
if (elements.dateApply) {
|
||||
elements.dateApply.addEventListener('click', applyCustomDateRange);
|
||||
}
|
||||
|
||||
// Load more button
|
||||
if (elements.loadMoreBtn) {
|
||||
elements.loadMoreBtn.addEventListener('click', loadMoreEvents);
|
||||
}
|
||||
|
||||
// Filter bar toggle
|
||||
if (elements.filterToggle) {
|
||||
elements.filterToggle.addEventListener('click', function() {
|
||||
elements.filterBar.classList.toggle('collapsed');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
|
@ -213,8 +489,11 @@
|
|||
|
||||
if (newMode === 'timeline') {
|
||||
// Container is shown by inline script, just load events if needed
|
||||
if (state.events.length === 0) {
|
||||
if (state.allLoadedEvents.length === 0) {
|
||||
loadInitialEvents();
|
||||
} else {
|
||||
// Apply filters to existing events
|
||||
applyClientSideFilters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -262,17 +541,22 @@
|
|||
})
|
||||
.then(function(data) {
|
||||
if (isInitial) {
|
||||
state.events = [];
|
||||
updateFilterOptions(data.events);
|
||||
state.allLoadedEvents = [];
|
||||
state.filteredEvents = [];
|
||||
}
|
||||
|
||||
state.events = state.events.concat(data.events || []);
|
||||
// Append to all loaded events
|
||||
state.allLoadedEvents = state.allLoadedEvents.concat(data.events || []);
|
||||
state.cursor = data.cursor || null;
|
||||
state.total = data.total || 0;
|
||||
state.total = data.total_filtered || 0;
|
||||
|
||||
renderEvents();
|
||||
updateLoadMoreButton();
|
||||
// Update filter options with new data
|
||||
updateFilterOptions(data.events);
|
||||
|
||||
// Apply client-side filters
|
||||
applyClientSideFilters();
|
||||
|
||||
updateLoadMoreButton();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[Timeline] Failed to load events:', err);
|
||||
|
|
@ -285,7 +569,212 @@
|
|||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Filter Handling
|
||||
// ============================================
|
||||
function onFilterChange() {
|
||||
// Update filter state
|
||||
if (elements.filterType) {
|
||||
const value = elements.filterType.value;
|
||||
if (value) {
|
||||
// If a specific type is selected, clear category filters
|
||||
state.filters.type = value;
|
||||
// Disable category checkboxes when specific type selected
|
||||
disableCategoryCheckboxes(true);
|
||||
} else {
|
||||
state.filters.type = null;
|
||||
disableCategoryCheckboxes(false);
|
||||
}
|
||||
}
|
||||
if (elements.filterZone) {
|
||||
state.filters.zone = elements.filterZone.value || null;
|
||||
}
|
||||
if (elements.filterPerson) {
|
||||
state.filters.person = elements.filterPerson.value || null;
|
||||
}
|
||||
|
||||
// Reload events with new server-side filters
|
||||
loadInitialEvents();
|
||||
}
|
||||
|
||||
function onTimeFilterChange() {
|
||||
if (!elements.filterTime) return;
|
||||
|
||||
const value = elements.filterTime.value;
|
||||
|
||||
// Hide/show custom date container
|
||||
if (elements.customDateContainer) {
|
||||
if (value === 'custom') {
|
||||
elements.customDateContainer.style.display = 'flex';
|
||||
} else {
|
||||
elements.customDateContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (value === 'today') {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
state.filters.after = today.toISOString();
|
||||
state.filters.until = null;
|
||||
} else if (value === '7d') {
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
state.filters.after = weekAgo.toISOString();
|
||||
state.filters.until = null;
|
||||
} else if (value === '30d') {
|
||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
state.filters.after = monthAgo.toISOString();
|
||||
state.filters.until = null;
|
||||
} else if (value === 'custom') {
|
||||
// Wait for user to apply custom range
|
||||
return;
|
||||
} else {
|
||||
state.filters.after = null;
|
||||
state.filters.until = null;
|
||||
}
|
||||
|
||||
// Reload events with new date range
|
||||
loadInitialEvents();
|
||||
}
|
||||
|
||||
function applyCustomDateRange() {
|
||||
if (!elements.dateFrom || !elements.dateTo) return;
|
||||
|
||||
const fromDate = new Date(elements.dateFrom.value);
|
||||
const toDate = new Date(elements.dateTo.value);
|
||||
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
if (window.SpaxelApp && SpaxelApp.showToast) {
|
||||
SpaxelApp.showToast('Invalid date range', 'warning');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Set to start of from day and end of to day
|
||||
fromDate.setHours(0, 0, 0, 0);
|
||||
toDate.setHours(23, 59, 59, 999);
|
||||
|
||||
state.filters.after = fromDate.toISOString();
|
||||
state.filters.until = toDate.toISOString();
|
||||
|
||||
// Reload events with custom date range
|
||||
loadInitialEvents();
|
||||
}
|
||||
|
||||
function onSearchChange() {
|
||||
if (elements.filterSearch) {
|
||||
state.filters.q = elements.filterSearch.value.trim() || null;
|
||||
applyClientSideFilters();
|
||||
}
|
||||
}
|
||||
|
||||
function disableCategoryCheckboxes(disabled) {
|
||||
const checkboxes = [
|
||||
elements.categoryPresence,
|
||||
elements.categoryZones,
|
||||
elements.categoryAlerts,
|
||||
elements.categorySystem,
|
||||
elements.categoryLearning
|
||||
];
|
||||
checkboxes.forEach(function(cb) {
|
||||
if (cb) {
|
||||
cb.disabled = disabled;
|
||||
if (disabled) {
|
||||
cb.parentElement.style.opacity = '0.5';
|
||||
} else {
|
||||
cb.parentElement.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Client-Side Filtering
|
||||
// ============================================
|
||||
function applyClientSideFilters() {
|
||||
// Start with all loaded events
|
||||
let filtered = state.allLoadedEvents.slice();
|
||||
|
||||
// Apply category filters
|
||||
if (!state.filters.type) {
|
||||
const enabledCategories = Object.keys(state.filters.categories).filter(
|
||||
function(cat) { return state.filters.categories[cat]; }
|
||||
);
|
||||
|
||||
const allowedTypes = new Set();
|
||||
enabledCategories.forEach(function(cat) {
|
||||
const types = EVENT_CATEGORIES[cat];
|
||||
if (types) {
|
||||
types.forEach(function(t) { allowedTypes.add(t); });
|
||||
}
|
||||
});
|
||||
|
||||
if (allowedTypes.size > 0) {
|
||||
filtered = filtered.filter(function(event) {
|
||||
return allowedTypes.has(event.type);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Specific type filter
|
||||
filtered = filtered.filter(function(event) {
|
||||
return event.type === state.filters.type;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply zone filter
|
||||
if (state.filters.zone) {
|
||||
filtered = filtered.filter(function(event) {
|
||||
return event.zone === state.filters.zone;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply person filter
|
||||
if (state.filters.person) {
|
||||
filtered = filtered.filter(function(event) {
|
||||
return event.person === state.filters.person;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply text search with fuzzy matching
|
||||
if (state.filters.q) {
|
||||
const searchLower = state.filters.q.toLowerCase();
|
||||
filtered = filtered.filter(function(event) {
|
||||
// Search in type, zone, person, and detail_json
|
||||
if (event.type && event.type.toLowerCase().indexOf(searchLower) !== -1) return true;
|
||||
if (event.zone && event.zone.toLowerCase().indexOf(searchLower) !== -1) return true;
|
||||
if (event.person && event.person.toLowerCase().indexOf(searchLower) !== -1) return true;
|
||||
|
||||
// Parse detail_json for additional search
|
||||
if (event.detail_json) {
|
||||
try {
|
||||
const detail = JSON.parse(event.detail_json);
|
||||
const detailStr = JSON.stringify(detail).toLowerCase();
|
||||
if (detailStr.indexOf(searchLower) !== -1) return true;
|
||||
|
||||
// Check description field specifically
|
||||
if (detail.description && detail.description.toLowerCase().indexOf(searchLower) !== -1) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// If not JSON, search as string
|
||||
if (event.detail_json.toLowerCase().indexOf(searchLower) !== -1) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp descending
|
||||
filtered.sort(function(a, b) {
|
||||
return b.timestamp_ms - a.timestamp_ms;
|
||||
});
|
||||
|
||||
state.filteredEvents = filtered;
|
||||
renderEvents();
|
||||
}
|
||||
|
||||
function applyFiltersToParams(params) {
|
||||
// Server-side filters
|
||||
if (state.filters.type) {
|
||||
params.set('type', state.filters.type);
|
||||
}
|
||||
|
|
@ -296,7 +785,10 @@
|
|||
params.set('person', state.filters.person);
|
||||
}
|
||||
if (state.filters.after) {
|
||||
params.set('after', state.filters.after);
|
||||
params.set('since', state.filters.after);
|
||||
}
|
||||
if (state.filters.until) {
|
||||
params.set('until', state.filters.until);
|
||||
}
|
||||
if (state.filters.q) {
|
||||
params.set('q', state.filters.q);
|
||||
|
|
@ -327,8 +819,8 @@
|
|||
severity: event.severity || 'info'
|
||||
};
|
||||
|
||||
// Add to beginning of events array
|
||||
state.events.unshift(normalizedEvent);
|
||||
// Add to beginning of all loaded events
|
||||
state.allLoadedEvents.unshift(normalizedEvent);
|
||||
state.total++;
|
||||
|
||||
// Update filter options
|
||||
|
|
@ -342,12 +834,21 @@
|
|||
state.availablePersons.add(normalizedEvent.person);
|
||||
}
|
||||
|
||||
// Prepend to DOM if timeline is visible (no layout shift)
|
||||
// Apply client-side filters to update displayed events
|
||||
applyClientSideFilters();
|
||||
|
||||
// Prepend to DOM if timeline is visible and event passes filters
|
||||
if (elements.container && elements.container.style.display !== 'none' && elements.eventsList) {
|
||||
// Check if event passes current filters
|
||||
const passesFilters = state.filteredEvents.length > 0 &&
|
||||
state.filteredEvents[0].id === normalizedEvent.id;
|
||||
|
||||
if (!passesFilters) return; // Don't show if filtered out
|
||||
|
||||
elements.empty.style.display = 'none';
|
||||
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = renderEvent(normalizedEvent, true);
|
||||
tempDiv.innerHTML = renderEvent(normalizedEvent, true, 0);
|
||||
const newEventEl = tempDiv.firstElementChild;
|
||||
|
||||
elements.eventsList.insertBefore(newEventEl, elements.eventsList.firstChild);
|
||||
|
|
@ -362,7 +863,12 @@
|
|||
|
||||
// Limit DOM elements (keep only most recent 100 in DOM)
|
||||
while (elements.eventsList.children.length > 100) {
|
||||
elements.eventsList.removeChild(elements.eventsList.lastChild);
|
||||
const lastChild = elements.eventsList.lastElementChild;
|
||||
if (lastChild && !lastChild.classList.contains('timeline-spacer')) {
|
||||
elements.eventsList.removeChild(lastChild);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateFilterOptions();
|
||||
|
|
@ -412,7 +918,7 @@
|
|||
function renderEvents() {
|
||||
if (!elements.eventsList) return;
|
||||
|
||||
if (state.events.length === 0) {
|
||||
if (state.filteredEvents.length === 0) {
|
||||
elements.eventsList.innerHTML = '';
|
||||
if (elements.empty) {
|
||||
elements.empty.style.display = 'block';
|
||||
|
|
@ -428,10 +934,22 @@
|
|||
elements.error.style.display = 'none';
|
||||
}
|
||||
|
||||
// Use virtualized rendering if enabled
|
||||
if (CONFIG.virtualization.enabled && state.filteredEvents.length > CONFIG.maxDOMItems) {
|
||||
renderVirtualizedEvents();
|
||||
} else {
|
||||
renderAllEvents();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Render All Events (for small datasets)
|
||||
// ============================================
|
||||
function renderAllEvents() {
|
||||
// Build HTML for events
|
||||
let html = '';
|
||||
state.events.forEach(function(event) {
|
||||
html += renderEvent(event);
|
||||
state.filteredEvents.forEach(function(event, index) {
|
||||
html += renderEvent(event, false, index);
|
||||
});
|
||||
|
||||
elements.eventsList.innerHTML = html;
|
||||
|
|
@ -440,7 +958,29 @@
|
|||
bindEventHandlers();
|
||||
}
|
||||
|
||||
function renderEvent(event, isNew) {
|
||||
// ============================================
|
||||
// Render Virtualized Events (for large datasets)
|
||||
// ============================================
|
||||
function renderVirtualizedEvents() {
|
||||
// Clear existing content
|
||||
elements.eventsList.innerHTML = '';
|
||||
|
||||
// Calculate initial visible range
|
||||
const containerHeight = elements.eventsList.clientHeight || 400;
|
||||
const visibleCount = Math.ceil(containerHeight / CONFIG.itemHeight);
|
||||
const bufferCount = CONFIG.virtualization.bufferSize;
|
||||
|
||||
state.virtualization.firstVisibleIndex = 0;
|
||||
state.virtualization.lastVisibleIndex = Math.min(state.filteredEvents.length - 1, visibleCount + bufferCount * 2);
|
||||
|
||||
// Create spacers
|
||||
updateVirtualSpacers();
|
||||
|
||||
// Render initial batch
|
||||
updateRenderedRange();
|
||||
}
|
||||
|
||||
function renderEvent(event, isNew, index) {
|
||||
const info = eventTypeInfo[event.type] || eventTypeInfo.system;
|
||||
const timeStr = formatTimestamp(event.timestamp_ms);
|
||||
const personStr = event.person ? escapeHtml(event.person) : '';
|
||||
|
|
@ -463,8 +1003,10 @@
|
|||
</button>
|
||||
` : '';
|
||||
|
||||
const dataIndex = index !== undefined ? ` data-index="${index}"` : '';
|
||||
|
||||
return `
|
||||
<div class="timeline-event timeline-${event.type}${severityClass}${newClass}" data-type="${event.type}" data-id="${event.id}" data-timestamp="${event.timestamp_ms}" data-blob-id="${event.blob_id || ''}">
|
||||
<div class="timeline-event timeline-${event.type}${severityClass}${newClass}" data-type="${event.type}" data-id="${event.id}" data-timestamp="${event.timestamp_ms}" data-blob-id="${event.blob_id || ''}"${dataIndex}>
|
||||
<div class="timeline-event-icon">${info.icon}</div>
|
||||
<div class="timeline-event-content">
|
||||
<div class="timeline-event-header">
|
||||
|
|
@ -724,51 +1266,13 @@
|
|||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Filter Handling
|
||||
// ============================================
|
||||
function onFilterChange() {
|
||||
// Update filter state
|
||||
if (elements.filterType) {
|
||||
state.filters.type = elements.filterType.value || null;
|
||||
}
|
||||
if (elements.filterZone) {
|
||||
state.filters.zone = elements.filterZone.value || null;
|
||||
}
|
||||
if (elements.filterPerson) {
|
||||
state.filters.person = elements.filterPerson.value || null;
|
||||
}
|
||||
if (elements.filterTime) {
|
||||
const value = elements.filterTime.value;
|
||||
if (value === 'today') {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
state.filters.after = today.toISOString();
|
||||
} else if (value === '7d') {
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
state.filters.after = weekAgo.toISOString();
|
||||
} else if (value === '30d') {
|
||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
state.filters.after = monthAgo.toISOString();
|
||||
} else {
|
||||
state.filters.after = null;
|
||||
}
|
||||
}
|
||||
if (elements.filterSearch) {
|
||||
state.filters.q = elements.filterSearch.value.trim() || null;
|
||||
}
|
||||
|
||||
// Reload events with new filters
|
||||
loadInitialEvents();
|
||||
}
|
||||
|
||||
function updateFilterOptions(events) {
|
||||
// Extract unique values from events
|
||||
const types = new Set();
|
||||
const zones = new Set();
|
||||
const persons = new Set();
|
||||
|
||||
(events || state.events).forEach(function(event) {
|
||||
(events || state.allLoadedEvents).forEach(function(event) {
|
||||
if (event.type) types.add(event.type);
|
||||
if (event.zone) zones.add(event.zone);
|
||||
if (event.person) persons.add(event.person);
|
||||
|
|
@ -831,7 +1335,7 @@
|
|||
// Update count display
|
||||
const countEl = document.getElementById('timeline-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = state.events.length + ' events';
|
||||
countEl.textContent = state.filteredEvents.length + ' of ' + state.total + ' events';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -861,7 +1365,8 @@
|
|||
};
|
||||
|
||||
handleNewEvent(event);
|
||||
}
|
||||
},
|
||||
refresh: loadInitialEvents
|
||||
};
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
|
|
|
|||
|
|
@ -24,10 +24,11 @@ const (
|
|||
|
||||
// EventsHandler manages the events timeline.
|
||||
type EventsHandler struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
hub DashboardHub
|
||||
ownsDB bool
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
hub DashboardHub
|
||||
ownsDB bool
|
||||
feedbackHandler any // FeedbackHandler for POST /api/events/{id}/feedback
|
||||
}
|
||||
|
||||
// DashboardHub is the interface for broadcasting to dashboard clients.
|
||||
|
|
@ -82,6 +83,13 @@ func (e *EventsHandler) SetHub(hub DashboardHub) {
|
|||
e.hub = hub
|
||||
}
|
||||
|
||||
// SetFeedbackHandler sets the feedback handler for event feedback endpoints.
|
||||
func (e *EventsHandler) SetFeedbackHandler(handler any) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.feedbackHandler = handler
|
||||
}
|
||||
|
||||
// NewEventsHandler creates a new events handler backed by a SQLite file at dbPath.
|
||||
// It opens the database, creates the schema, and takes ownership of the connection.
|
||||
// Use Close() to release resources.
|
||||
|
|
@ -184,12 +192,15 @@ func isValidEventType(t string) bool {
|
|||
// GET /api/events — paginated event list with FTS5 search and keyset cursor pagination.
|
||||
//
|
||||
// Query params: limit (default 50, max 500), before (timestamp_ms cursor),
|
||||
// after (ISO8601), type, zone, person, q (FTS5 query), mode (expert|simple).
|
||||
// since (ISO8601), until (ISO8601), type, zone_id, person_id, q (FTS5 query), mode (expert|simple).
|
||||
//
|
||||
// GET /api/events/{id} — single event by ID.
|
||||
//
|
||||
// POST /api/events/{id}/feedback — submit feedback for an event.
|
||||
func (e *EventsHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/api/events", e.listEvents)
|
||||
r.Get("/api/events/{id}", e.getEvent)
|
||||
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
|
||||
}
|
||||
|
||||
// eventsResponse is the JSON response for GET /api/events.
|
||||
|
|
@ -257,9 +268,19 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
|
|||
q := r.URL.Query().Get("q")
|
||||
eventType := r.URL.Query().Get("type")
|
||||
zone := r.URL.Query().Get("zone")
|
||||
zoneID := r.URL.Query().Get("zone_id")
|
||||
if zoneID != "" && zone == "" {
|
||||
zone = zoneID
|
||||
}
|
||||
person := r.URL.Query().Get("person")
|
||||
personID := r.URL.Query().Get("person_id")
|
||||
if personID != "" && person == "" {
|
||||
person = personID
|
||||
}
|
||||
afterStr := r.URL.Query().Get("after")
|
||||
mode := r.URL.Query().Get("mode") // "expert" or "simple" (default: simple)
|
||||
sinceStr := r.URL.Query().Get("since") // Alias for after
|
||||
untilStr := r.URL.Query().Get("until") // Upper bound timestamp
|
||||
mode := r.URL.Query().Get("mode") // "expert" or "simple" (default: simple)
|
||||
|
||||
// Validate event type
|
||||
if eventType != "" && !isValidEventType(eventType) {
|
||||
|
|
@ -267,17 +288,32 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Validate after timestamp
|
||||
// Validate after/since timestamp (prefer since if both provided)
|
||||
var afterTS int64
|
||||
if afterStr != "" {
|
||||
t, err := time.Parse(time.RFC3339, afterStr)
|
||||
timeStr := afterStr
|
||||
if sinceStr != "" {
|
||||
timeStr = sinceStr
|
||||
}
|
||||
if timeStr != "" {
|
||||
t, err := time.Parse(time.RFC3339, timeStr)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid after timestamp")
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid since/after timestamp")
|
||||
return
|
||||
}
|
||||
afterTS = t.UnixNano() / 1e6
|
||||
}
|
||||
|
||||
// Validate until timestamp (upper bound)
|
||||
var untilTS int64
|
||||
if untilStr != "" {
|
||||
t, err := time.Parse(time.RFC3339, untilStr)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid until timestamp")
|
||||
return
|
||||
}
|
||||
untilTS = t.UnixNano() / 1e6
|
||||
}
|
||||
|
||||
// In simple mode, filter out system-only event types
|
||||
// Simple mode shows: zone_entry, zone_exit, portal_crossing, fall_alert, anomaly, security_alert, learning_milestone
|
||||
// Simple mode hides: node_online, node_offline, ota_update, baseline_changed, system
|
||||
|
|
@ -347,6 +383,10 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
|
|||
whereSQL += " AND " + p + "timestamp_ms >= ?"
|
||||
whereArgs = append(whereArgs, afterTS)
|
||||
}
|
||||
if untilTS > 0 {
|
||||
whereSQL += " AND " + p + "timestamp_ms <= ?"
|
||||
whereArgs = append(whereArgs, untilTS)
|
||||
}
|
||||
|
||||
// COUNT for total_filtered
|
||||
countSQL := "SELECT COUNT(*) FROM " + fromTable + " WHERE " + whereSQL
|
||||
|
|
@ -363,6 +403,7 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
|
|||
dataWhere += " AND " + p + "timestamp_ms < ?"
|
||||
dataArgs = append(dataArgs, beforeTS)
|
||||
}
|
||||
// untilTS is already included in the base WHERE clause via whereArgs
|
||||
|
||||
dataSQL := "SELECT " + selectCols + " FROM " + fromTable +
|
||||
" WHERE " + dataWhere +
|
||||
|
|
@ -430,3 +471,74 @@ func (e *EventsHandler) getEvent(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
writeJSON(w, http.StatusOK, event)
|
||||
}
|
||||
|
||||
// postEventFeedback handles POST /api/events/{id}/feedback
|
||||
// It delegates to the feedback module after validating the event exists.
|
||||
func (e *EventsHandler) postEventFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
eventID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid event id")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the event exists
|
||||
var exists bool
|
||||
err = e.db.QueryRow("SELECT EXISTS(SELECT 1 FROM events WHERE id = ?)", eventID).Scan(&exists)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to query event")
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
writeJSONError(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode request body
|
||||
var req FeedbackRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Set the event ID from the URL path
|
||||
req.EventID = eventID
|
||||
|
||||
// Validate feedback type
|
||||
if req.Type != "correct" && req.Type != "incorrect" && req.Type != "missed" {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid feedback type: must be 'correct', 'incorrect', or 'missed'")
|
||||
return
|
||||
}
|
||||
|
||||
// Delegate to feedback handler if available
|
||||
if e.feedbackHandler != nil {
|
||||
// Use the feedback handler to process the request
|
||||
type submitter interface {
|
||||
SubmitFeedback(w http.ResponseWriter, r *http.Request, req FeedbackRequest)
|
||||
}
|
||||
if fh, ok := e.feedbackHandler.(submitter); ok {
|
||||
fh.SubmitFeedback(w, r, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: log a feedback event
|
||||
_ = e.LogEvent("feedback", time.Now(), "", "", 0,
|
||||
fmt.Sprintf(`{"event_id":%d,"type":"%s","blob_id":%d}`, eventID, req.Type, req.BlobID), "info")
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Feedback recorded",
|
||||
})
|
||||
}
|
||||
|
||||
// FeedbackRequest represents a feedback submission for an event.
|
||||
type FeedbackRequest struct {
|
||||
Type string `json:"type"` // "correct" or "incorrect"
|
||||
BlobID int `json:"blob_id"` // Optional: blob ID being rated
|
||||
Position *struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
} `json:"position,omitempty"` // For "missed" feedback
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -914,3 +916,182 @@ func TestFTSRebuildOnStartup(t *testing.T) {
|
|||
t.Errorf("after rebuild: total_filtered = %d, want 10", resp.TotalFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for since/until query parameters ---
|
||||
|
||||
func TestListEvents_SinceParameter(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
base := time.Now()
|
||||
seedEvents(t, h, base, 10)
|
||||
|
||||
// Filter using since parameter (alias for after)
|
||||
sinceTime := base.Add(4 * time.Second).Format(time.RFC3339)
|
||||
req := httptest.NewRequest("GET", "/api/events?since="+sinceTime+"&limit=100", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var resp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if resp.TotalFiltered != 6 { // events 4..9
|
||||
t.Errorf("total_filtered = %d, want 6", resp.TotalFiltered)
|
||||
}
|
||||
for _, ev := range resp.Events {
|
||||
if ev.Timestamp < base.Add(4*time.Second).UnixNano()/1e6 {
|
||||
t.Errorf("event ts %d before since time", ev.Timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEvents_UntilParameter(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
base := time.Now()
|
||||
seedEvents(t, h, base, 10)
|
||||
|
||||
// Filter using until parameter (upper bound)
|
||||
untilTime := base.Add(5 * time.Second).Format(time.RFC3339)
|
||||
req := httptest.NewRequest("GET", "/api/events?until="+untilTime+"&limit=100", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var resp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if resp.TotalFiltered != 6 { // events 0..5
|
||||
t.Errorf("total_filtered = %d, want 6", resp.TotalFiltered)
|
||||
}
|
||||
for _, ev := range resp.Events {
|
||||
if ev.Timestamp > base.Add(5*time.Second).UnixNano()/1e6 {
|
||||
t.Errorf("event ts %d after until time", ev.Timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEvents_SinceAndUntil(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
base := time.Now()
|
||||
seedEvents(t, h, base, 10)
|
||||
|
||||
// Filter using both since and until
|
||||
sinceTime := base.Add(2 * time.Second).Format(time.RFC3339)
|
||||
untilTime := base.Add(7 * time.Second).Format(time.RFC3339)
|
||||
req := httptest.NewRequest("GET", "/api/events?since="+sinceTime+"&until="+untilTime+"&limit=100", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var resp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if resp.TotalFiltered != 6 { // events 2..7
|
||||
t.Errorf("total_filtered = %d, want 6", resp.TotalFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEvents_InvalidUntil(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/events?until=not-a-date", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for person_id and zone_id parameter aliases ---
|
||||
|
||||
func TestListEvents_PersonIDAlias(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
base := time.Now()
|
||||
seedEvents(t, h, base, 100)
|
||||
|
||||
// Filter using person_id parameter (alias for person)
|
||||
req := httptest.NewRequest("GET", "/api/events?person_id=Alice&limit=100", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var resp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
for _, ev := range resp.Events {
|
||||
if ev.Person != "Alice" {
|
||||
t.Errorf("event person = %q, want Alice", ev.Person)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEvents_ZoneIDAlias(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
base := time.Now()
|
||||
seedEvents(t, h, base, 100)
|
||||
|
||||
// Filter using zone_id parameter (alias for zone)
|
||||
req := httptest.NewRequest("GET", "/api/events?zone_id=Kitchen&limit=100", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var resp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
for _, ev := range resp.Events {
|
||||
if ev.Zone != "Kitchen" {
|
||||
t.Errorf("event zone = %q, want Kitchen", ev.Zone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEvents_ZoneTakesPrecedence(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
base := time.Now()
|
||||
seedEvents(t, h, base, 10)
|
||||
|
||||
// When both zone and zone_id are provided, zone_id should take precedence
|
||||
req := httptest.NewRequest("GET", "/api/events?zone=Hallway&zone_id=Kitchen&limit=100", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var resp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
for _, ev := range resp.Events {
|
||||
if ev.Zone != "Kitchen" {
|
||||
t.Errorf("event zone = %q, want Kitchen (zone_id should take precedence)", ev.Zone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEvents_PersonIDTakesPrecedence(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
base := time.Now()
|
||||
seedEvents(t, h, base, 10)
|
||||
|
||||
// When both person and person_id are provided, person_id should take precedence
|
||||
req := httptest.NewRequest("GET", "/api/events?person=Bob&person_id=Alice&limit=100", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var resp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
for _, ev := range resp.Events {
|
||||
if ev.Person != "Alice" {
|
||||
t.Errorf("event person = %q, want Alice (person_id should take precedence)", ev.Person)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,3 +126,59 @@ func (h *FeedbackHandler) handleSubmitFeedback(w http.ResponseWriter, r *http.Re
|
|||
"message": "Feedback recorded",
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitFeedback is called by the events handler to process feedback for a specific event.
|
||||
func (h *FeedbackHandler) SubmitFeedback(w http.ResponseWriter, r *http.Request, req FeedbackRequest) {
|
||||
// Validate feedback type
|
||||
if req.Type != "correct" && req.Type != "incorrect" && req.Type != "missed" {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid feedback type: must be 'correct', 'incorrect', or 'missed'")
|
||||
return
|
||||
}
|
||||
|
||||
// Get event details for logging
|
||||
var zone, person string
|
||||
var detailJSON string
|
||||
|
||||
// Create detail JSON for the event
|
||||
details := make(map[string]interface{})
|
||||
details["original_event_id"] = req.EventID
|
||||
details["feedback"] = req.Type
|
||||
if req.Position != nil {
|
||||
details["position"] = req.Position
|
||||
}
|
||||
|
||||
detailBytes, _ := json.Marshal(details)
|
||||
detailJSON = string(detailBytes)
|
||||
|
||||
// Log feedback event
|
||||
if h.eventsHandler != nil {
|
||||
eventType := "feedback_confirmed"
|
||||
if req.Type == "incorrect" {
|
||||
eventType = "feedback_corrected"
|
||||
} else if req.Type == "missed" {
|
||||
eventType = "missed_detection"
|
||||
}
|
||||
_ = h.eventsHandler.LogEvent(eventType, time.Now(), zone, person, req.BlobID, detailJSON, "info")
|
||||
}
|
||||
|
||||
// If learning handler is available, process the feedback
|
||||
if h.learningHandler != nil {
|
||||
type processor interface {
|
||||
ProcessFeedback(feedbackType string, eventID int64, blobID int, positionJSON string) error
|
||||
}
|
||||
if p, ok := h.learningHandler.(processor); ok {
|
||||
var positionJSON string
|
||||
if req.Position != nil {
|
||||
positionBytes, _ := json.Marshal(req.Position)
|
||||
positionJSON = string(positionBytes)
|
||||
}
|
||||
_ = p.ProcessFeedback(req.Type, req.EventID, req.BlobID, positionJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// Return success response
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Feedback recorded",
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue