diff --git a/static/css/components/activity-timeline.css b/static/css/components/activity-timeline.css new file mode 100644 index 0000000..fa1bf4c --- /dev/null +++ b/static/css/components/activity-timeline.css @@ -0,0 +1,696 @@ +/** + * Activity Timeline Component + * Reusable, configuration-driven timeline visualization + * Supports visual modes: compact, enriched, summary + */ + +/* ============================================ + CSS VARIABLES (with fallbacks) + ============================================ */ +.activity-timeline { + --timeline-bg: var(--bg-card, #1a1a1a); + --timeline-border: var(--border-color, #333); + --timeline-bg-secondary: var(--bg-secondary, #252525); + --timeline-bg-elevated: var(--bg-elevated, #2a2a2a); + --timeline-text-primary: var(--text-primary, #fff); + --timeline-text-secondary: var(--text-secondary, #888); + --timeline-text-dim: var(--text-dim, #666); + --timeline-accent: var(--accent-cyan, #4a9eff); + --timeline-status-new: var(--signal-new, #3b82f6); + --timeline-status-baseline: var(--signal-baseline, #6b7280); + --timeline-status-burst: var(--signal-burst, #f59e0b); + --timeline-status-flagged: var(--signal-emergency, #ef4444); + --timeline-status-gone: var(--text-dim, #666); +} + +/* ============================================ + TIMELINE CONTAINER + ============================================ */ +.activity-timeline { + background: var(--timeline-bg); + border: 1px solid var(--timeline-border); + border-radius: 6px; + font-family: 'JetBrains Mono', 'SF Mono', 'Consolas', monospace; + font-size: 11px; +} + +.activity-timeline.collapsed .activity-timeline-body { + display: none; +} + +.activity-timeline.collapsed .activity-timeline-header { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 10px; +} + +.activity-timeline.collapsed .activity-timeline-collapse-icon { + transform: rotate(-90deg); +} + +/* ============================================ + HEADER + ============================================ */ +.activity-timeline-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + cursor: pointer; + user-select: none; + transition: background 0.15s ease; +} + +.activity-timeline-header:hover { + background: rgba(255, 255, 255, 0.02); +} + +.activity-timeline-collapse-icon { + margin-right: 8px; + font-size: 10px; + transition: transform 0.2s ease; + color: var(--timeline-text-dim); +} + +.activity-timeline-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--timeline-text-secondary); +} + +.activity-timeline-header-stats { + display: flex; + gap: 12px; + font-size: 10px; + color: var(--timeline-text-dim); +} + +.activity-timeline-header-stat { + display: flex; + align-items: center; + gap: 4px; +} + +.activity-timeline-header-stat .stat-value { + color: var(--timeline-text-primary); + font-weight: 500; +} + +/* ============================================ + BODY + ============================================ */ +.activity-timeline-body { + padding: 0 12px 12px 12px; + border-top: 1px solid var(--timeline-border); +} + +/* ============================================ + CONTROLS + ============================================ */ +.activity-timeline-controls { + display: flex; + gap: 6px; + align-items: center; + padding: 8px 0; + flex-wrap: wrap; +} + +.activity-timeline-btn { + background: var(--timeline-bg-secondary); + border: 1px solid var(--timeline-border); + color: var(--timeline-text-secondary); + font-size: 9px; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; +} + +.activity-timeline-btn:hover { + background: var(--timeline-bg-elevated); + color: var(--timeline-text-primary); +} + +.activity-timeline-btn.active { + background: var(--timeline-accent); + color: #000; + border-color: var(--timeline-accent); +} + +.activity-timeline-window { + display: flex; + align-items: center; + gap: 4px; + font-size: 9px; + color: var(--timeline-text-dim); + margin-left: auto; +} + +.activity-timeline-window-select { + background: var(--timeline-bg-secondary); + border: 1px solid var(--timeline-border); + color: var(--timeline-text-primary); + font-size: 9px; + padding: 3px 6px; + border-radius: 3px; + font-family: inherit; +} + +/* ============================================ + TIME AXIS + ============================================ */ +.activity-timeline-axis { + display: flex; + justify-content: space-between; + padding: 0 50px 0 140px; + margin-bottom: 6px; + font-size: 9px; + color: var(--timeline-text-dim); +} + +.activity-timeline-axis-label { + position: relative; +} + +.activity-timeline-axis-label::before { + content: ''; + position: absolute; + top: -4px; + left: 50%; + width: 1px; + height: 4px; + background: var(--timeline-border); +} + +/* ============================================ + LANES CONTAINER + ============================================ */ +.activity-timeline-lanes { + display: flex; + flex-direction: column; + gap: 3px; + max-height: 180px; + overflow-y: auto; + margin-top: 6px; +} + +.activity-timeline-lanes::-webkit-scrollbar { + width: 6px; +} + +.activity-timeline-lanes::-webkit-scrollbar-track { + background: var(--timeline-bg-secondary); + border-radius: 3px; +} + +.activity-timeline-lanes::-webkit-scrollbar-thumb { + background: var(--timeline-border); + border-radius: 3px; +} + +.activity-timeline-lanes::-webkit-scrollbar-thumb:hover { + background: var(--timeline-text-dim); +} + +/* ============================================ + INDIVIDUAL LANE + ============================================ */ +.activity-timeline-lane { + display: flex; + align-items: stretch; + min-height: 32px; + background: var(--timeline-bg-secondary); + border-radius: 3px; + overflow: hidden; + cursor: pointer; + transition: background 0.15s ease; +} + +.activity-timeline-lane:hover { + background: var(--timeline-bg-elevated); +} + +.activity-timeline-lane.expanded { + min-height: auto; +} + +.activity-timeline-lane.baseline { + opacity: 0.5; +} + +.activity-timeline-lane.baseline:hover { + opacity: 0.8; +} + +/* Status indicator strip */ +.activity-timeline-status { + width: 4px; + min-width: 4px; + flex-shrink: 0; +} + +.activity-timeline-status[data-status="new"] { + background: var(--timeline-status-new); +} + +.activity-timeline-status[data-status="baseline"] { + background: var(--timeline-status-baseline); +} + +.activity-timeline-status[data-status="burst"] { + background: var(--timeline-status-burst); +} + +.activity-timeline-status[data-status="flagged"] { + background: var(--timeline-status-flagged); +} + +.activity-timeline-status[data-status="gone"] { + background: var(--timeline-status-gone); +} + +/* Label section */ +.activity-timeline-label { + width: 130px; + min-width: 130px; + padding: 6px 8px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1px; + border-right: 1px solid var(--timeline-border); + overflow: hidden; +} + +.activity-timeline-id { + color: var(--timeline-text-primary); + font-size: 11px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +.activity-timeline-name { + color: var(--timeline-text-dim); + font-size: 9px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +/* ============================================ + TRACK (where bars are drawn) + ============================================ */ +.activity-timeline-track { + flex: 1; + position: relative; + height: 100%; + min-height: 32px; + padding: 4px 8px; +} + +.activity-timeline-track-bg { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; +} + +/* ============================================ + SIGNAL BARS + ============================================ */ +.activity-timeline-bar { + position: absolute; + top: 50%; + transform: translateY(-50%); + height: 14px; + min-width: 2px; + border-radius: 2px; + transition: opacity 0.15s ease; +} + +/* Strength variants */ +.activity-timeline-bar[data-strength="1"] { height: 5px; } +.activity-timeline-bar[data-strength="2"] { height: 9px; } +.activity-timeline-bar[data-strength="3"] { height: 13px; } +.activity-timeline-bar[data-strength="4"] { height: 17px; } +.activity-timeline-bar[data-strength="5"] { height: 21px; } + +/* Status colors */ +.activity-timeline-bar[data-status="new"], +.activity-timeline-bar[data-status="repeated"] { + background: var(--timeline-status-new); + box-shadow: 0 0 4px rgba(59, 130, 246, 0.3); +} + +.activity-timeline-bar[data-status="baseline"] { + background: var(--timeline-status-baseline); +} + +.activity-timeline-bar[data-status="burst"] { + background: var(--timeline-status-burst); + box-shadow: 0 0 5px rgba(245, 158, 11, 0.4); +} + +.activity-timeline-bar[data-status="flagged"] { + background: var(--timeline-status-flagged); + box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); + animation: timeline-flagged-pulse 2s ease-in-out infinite; +} + +@keyframes timeline-flagged-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.activity-timeline-lane:hover .activity-timeline-bar { + opacity: 0.9; +} + +/* ============================================ + EXPANDED VIEW (tick marks) + ============================================ */ +.activity-timeline-ticks { + display: none; + position: relative; + height: 24px; + margin-top: 4px; + border-top: 1px solid var(--timeline-border); + padding-top: 4px; +} + +.activity-timeline-lane.expanded .activity-timeline-ticks { + display: block; +} + +.activity-timeline-tick { + position: absolute; + bottom: 0; + width: 1px; + background: var(--timeline-accent); +} + +.activity-timeline-tick[data-strength="1"] { height: 4px; } +.activity-timeline-tick[data-strength="2"] { height: 8px; } +.activity-timeline-tick[data-strength="3"] { height: 12px; } +.activity-timeline-tick[data-strength="4"] { height: 16px; } +.activity-timeline-tick[data-strength="5"] { height: 20px; } + +/* ============================================ + STATS COLUMN + ============================================ */ +.activity-timeline-stats { + width: 45px; + min-width: 45px; + padding: 4px 6px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; + font-size: 9px; + color: var(--timeline-text-dim); + border-left: 1px solid var(--timeline-border); +} + +.activity-timeline-stat-count { + color: var(--timeline-text-primary); + font-weight: 500; +} + +.activity-timeline-stat-label { + font-size: 8px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* ============================================ + ANNOTATIONS + ============================================ */ +.activity-timeline-annotations { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--timeline-border); + max-height: 80px; + overflow-y: auto; +} + +.activity-timeline-annotation { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + font-size: 10px; + color: var(--timeline-text-secondary); + background: var(--timeline-bg-secondary); + border-radius: 3px; + margin-bottom: 4px; +} + +.activity-timeline-annotation-icon { + font-size: 10px; + width: 14px; + text-align: center; +} + +.activity-timeline-annotation[data-type="new"] { + border-left: 2px solid var(--timeline-status-new); +} + +.activity-timeline-annotation[data-type="burst"] { + border-left: 2px solid var(--timeline-status-burst); +} + +.activity-timeline-annotation[data-type="pattern"] { + border-left: 2px solid var(--timeline-accent); +} + +.activity-timeline-annotation[data-type="flagged"] { + border-left: 2px solid var(--timeline-status-flagged); + color: var(--timeline-status-flagged); +} + +.activity-timeline-annotation[data-type="gone"] { + border-left: 2px solid var(--timeline-status-gone); +} + +/* ============================================ + TOOLTIP + ============================================ */ +.activity-timeline-tooltip { + position: fixed; + z-index: 10000; + background: var(--timeline-bg-elevated); + border: 1px solid var(--timeline-border); + border-radius: 4px; + padding: 8px 10px; + font-size: 10px; + color: var(--timeline-text-primary); + pointer-events: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + max-width: 240px; + font-family: 'JetBrains Mono', monospace; +} + +.activity-timeline-tooltip-header { + font-weight: 600; + margin-bottom: 4px; + color: var(--timeline-accent); +} + +.activity-timeline-tooltip-row { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--timeline-text-secondary); + line-height: 1.5; +} + +.activity-timeline-tooltip-row span:last-child { + color: var(--timeline-text-primary); +} + +/* ============================================ + LEGEND + ============================================ */ +.activity-timeline-legend { + display: flex; + gap: 12px; + padding-top: 8px; + margin-top: 8px; + border-top: 1px solid var(--timeline-border); + font-size: 9px; + color: var(--timeline-text-dim); +} + +.activity-timeline-legend-item { + display: flex; + align-items: center; + gap: 4px; +} + +.activity-timeline-legend-dot { + width: 6px; + height: 6px; + border-radius: 2px; +} + +.activity-timeline-legend-dot.new { background: var(--timeline-status-new); } +.activity-timeline-legend-dot.baseline { background: var(--timeline-status-baseline); } +.activity-timeline-legend-dot.burst { background: var(--timeline-status-burst); } +.activity-timeline-legend-dot.flagged { background: var(--timeline-status-flagged); } + +/* ============================================ + EMPTY STATE + ============================================ */ +.activity-timeline-empty { + text-align: center; + padding: 24px 16px; + color: var(--timeline-text-dim); + font-size: 11px; +} + +.activity-timeline-empty-icon { + font-size: 20px; + margin-bottom: 8px; + opacity: 0.4; +} + +/* More indicator */ +.activity-timeline-more { + text-align: center; + padding: 8px; + font-size: 10px; + color: var(--timeline-text-dim); +} + +/* ============================================ + VISUAL MODE: COMPACT + ============================================ */ +.activity-timeline--compact .activity-timeline-lanes { + max-height: 140px; +} + +.activity-timeline--compact .activity-timeline-lane { + min-height: 26px; +} + +.activity-timeline--compact .activity-timeline-label { + width: 100px; + min-width: 100px; + padding: 4px 6px; +} + +.activity-timeline--compact .activity-timeline-id { + display: none; +} + +.activity-timeline--compact .activity-timeline-name { + font-size: 10px; + color: var(--timeline-text-secondary); +} + +.activity-timeline--compact .activity-timeline-track { + min-height: 26px; +} + +.activity-timeline--compact .activity-timeline-bar { + height: 10px !important; +} + +.activity-timeline--compact .activity-timeline-bar[data-strength="1"] { height: 4px !important; } +.activity-timeline--compact .activity-timeline-bar[data-strength="2"] { height: 6px !important; } +.activity-timeline--compact .activity-timeline-bar[data-strength="3"] { height: 8px !important; } +.activity-timeline--compact .activity-timeline-bar[data-strength="4"] { height: 10px !important; } +.activity-timeline--compact .activity-timeline-bar[data-strength="5"] { height: 12px !important; } + +.activity-timeline--compact .activity-timeline-stats { + width: 30px; + min-width: 30px; +} + +.activity-timeline--compact .activity-timeline-stat-label { + display: none; +} + +.activity-timeline--compact .activity-timeline-legend { + display: none; +} + +.activity-timeline--compact .activity-timeline-axis { + padding-left: 110px; + padding-right: 40px; +} + +/* ============================================ + VISUAL MODE: SUMMARY + ============================================ */ +.activity-timeline--summary .activity-timeline-lanes { + max-height: 100px; +} + +.activity-timeline--summary .activity-timeline-lane { + min-height: 20px; +} + +.activity-timeline--summary .activity-timeline-label { + width: 80px; + min-width: 80px; + padding: 3px 6px; +} + +.activity-timeline--summary .activity-timeline-id, +.activity-timeline--summary .activity-timeline-name { + font-size: 9px; +} + +.activity-timeline--summary .activity-timeline-status { + width: 3px; + min-width: 3px; +} + +.activity-timeline--summary .activity-timeline-track { + min-height: 20px; +} + +.activity-timeline--summary .activity-timeline-bar { + height: 8px !important; + border-radius: 1px; +} + +.activity-timeline--summary .activity-timeline-stats { + display: none; +} + +.activity-timeline--summary .activity-timeline-ticks { + display: none !important; +} + +.activity-timeline--summary .activity-timeline-annotations { + display: none; +} + +.activity-timeline--summary .activity-timeline-legend { + display: none; +} + +.activity-timeline--summary .activity-timeline-axis { + padding-left: 90px; + padding-right: 10px; + font-size: 8px; +} + +/* ============================================ + BACKWARD COMPATIBILITY NOTE + The old signal-timeline.css is still loaded + for existing TSCM code that uses those classes. + New code should use activity-timeline classes. + ============================================ */ diff --git a/static/js/components/activity-timeline.js b/static/js/components/activity-timeline.js new file mode 100644 index 0000000..c0eeeb7 --- /dev/null +++ b/static/js/components/activity-timeline.js @@ -0,0 +1,1233 @@ +/** + * Activity Timeline Component + * Reusable, configuration-driven timeline visualization for time-based metadata + * Supports multiple modes: TSCM, Listening Post, Bluetooth, WiFi, Monitoring + */ + +const ActivityTimeline = (function() { + 'use strict'; + + // Default configuration + const defaults = { + // Identity + title: 'Activity Timeline', + mode: 'generic', + + // Display options + visualMode: 'enriched', // 'compact' | 'enriched' | 'summary' + collapsed: true, + showAnnotations: true, + showLegend: true, + + // Time configuration + timeWindows: { + '5m': 5 * 60 * 1000, + '15m': 15 * 60 * 1000, + '30m': 30 * 60 * 1000, + '1h': 60 * 60 * 1000, + '2h': 2 * 60 * 60 * 1000, + '4h': 4 * 60 * 60 * 1000, + '8h': 8 * 60 * 60 * 1000 + }, + defaultWindow: '30m', + availableWindows: ['5m', '15m', '30m', '1h', '2h'], + + // Filter configuration + filters: { + hideBaseline: { enabled: true, label: 'Hide Known', default: false }, + showOnlyNew: { enabled: true, label: 'New Only', default: false }, + showOnlyBurst: { enabled: true, label: 'Bursts', default: false } + }, + customFilters: [], + + // Limits + maxItems: 100, + maxDisplayedLanes: 15, + burstThreshold: 5, + burstWindow: 60 * 1000, + updateInterval: 5000, + barMinWidth: 2, + + // Callbacks + onItemClick: null, + onItemFlag: null, + onExport: null, + + // Label generator (can be overridden) + labelGenerator: null + }; + + // Instances registry for multi-instance support + const instances = new Map(); + + /** + * Create a new timeline instance + */ + function create(containerId, options = {}) { + const container = document.getElementById(containerId); + if (!container) { + console.error(`ActivityTimeline: Container '${containerId}' not found`); + return null; + } + + // Merge options with defaults + const config = mergeConfig(defaults, options); + + // Create instance state + const state = { + containerId: containerId, + config: config, + items: new Map(), + annotations: [], + filterState: initFilterState(config), + timeWindow: config.defaultWindow, + tooltip: null, + updateTimer: null, + element: null + }; + + // Store instance + instances.set(containerId, state); + + // Build DOM + state.element = buildTimeline(container, state); + + // Setup interactions + setupEventListeners(state); + + // Create tooltip + createTooltip(state); + + // Start update cycle + startUpdateTimer(state); + + // Initial render + render(state); + + // Return public API bound to this instance + return createPublicAPI(containerId); + } + + /** + * Merge user config with defaults + */ + function mergeConfig(defaults, options) { + const config = { ...defaults }; + + for (const key of Object.keys(options)) { + if (typeof options[key] === 'object' && !Array.isArray(options[key]) && options[key] !== null) { + config[key] = { ...defaults[key], ...options[key] }; + } else { + config[key] = options[key]; + } + } + + return config; + } + + /** + * Initialize filter state from config + */ + function initFilterState(config) { + const state = {}; + for (const [key, filter] of Object.entries(config.filters)) { + if (filter.enabled) { + state[key] = filter.default || false; + } + } + for (const filter of config.customFilters) { + state[filter.key] = filter.default || false; + } + return state; + } + + /** + * Create item data structure + */ + function createItem(id, label, options = {}) { + return { + id: id, + label: label, + type: options.type || 'generic', + events: [], + firstSeen: null, + lastSeen: null, + status: 'new', + pattern: null, + flagged: false, + eventCount: 0, + tags: options.tags || [], + metadata: options.metadata || {} + }; + } + + /** + * Generate label for an item (can be overridden via config) + */ + function generateLabel(id, state) { + if (state.config.labelGenerator) { + return state.config.labelGenerator(id); + } + return categorizeById(id, state.config.mode); + } + + /** + * Default categorization by mode + */ + function categorizeById(id, mode) { + // RF frequency categorization + if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') { + const f = parseFloat(id); + if (!isNaN(f)) { + if (f >= 2400 && f <= 2500) return 'Wi-Fi 2.4GHz'; + if (f >= 5150 && f <= 5850) return 'Wi-Fi 5GHz'; + if (f >= 433 && f <= 434) return '433MHz ISM'; + if (f >= 868 && f <= 869) return '868MHz ISM'; + if (f >= 902 && f <= 928) return '915MHz ISM'; + if (f >= 315 && f <= 316) return '315MHz'; + if (f >= 2402 && f <= 2480) return 'Bluetooth'; + if (f >= 144 && f <= 148) return 'VHF Ham'; + if (f >= 420 && f <= 450) return 'UHF Ham'; + return `${f.toFixed(3)} MHz`; + } + } + + // Bluetooth mode - MAC address + if (mode === 'bluetooth') { + if (/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(id)) { + return id.substring(0, 8) + '...'; + } + } + + // WiFi mode - SSID + if (mode === 'wifi') { + if (id.length > 20) { + return id.substring(0, 17) + '...'; + } + } + + return id; + } + + /** + * Add or update an event + */ + function addEvent(containerId, eventData) { + const state = instances.get(containerId); + if (!state) return null; + + const now = Date.now(); + const id = eventData.id; + const label = eventData.label || generateLabel(id, state); + + let item = state.items.get(id); + + if (!item) { + item = createItem(id, label, { + type: eventData.type, + tags: eventData.tags, + metadata: eventData.metadata + }); + item.firstSeen = now; + state.items.set(id, item); + + addAnnotation(state, 'new', `New: ${item.label}`, now); + } + + // Add event + item.events.push({ + timestamp: now, + strength: Math.min(5, Math.max(1, eventData.strength || 3)), + duration: eventData.duration || 1000 + }); + + item.lastSeen = now; + item.eventCount++; + + // Update status + updateItemStatus(item, state.config); + + // Detect patterns + detectPatterns(item, state); + + // Prune old events + const windowMs = state.config.timeWindows['8h'] || state.config.timeWindows['2h']; + item.events = item.events.filter(e => now - e.timestamp < windowMs); + + // Prune items if over limit + if (state.items.size > state.config.maxItems) { + pruneOldItems(state); + } + + return item; + } + + /** + * Bulk import events + */ + function importEvents(containerId, events) { + for (const event of events) { + addEvent(containerId, event); + } + } + + /** + * Remove oldest/least active items + */ + function pruneOldItems(state) { + const items = Array.from(state.items.entries()); + items.sort((a, b) => { + if (a[1].flagged && !b[1].flagged) return 1; + if (!a[1].flagged && b[1].flagged) return -1; + return a[1].lastSeen - b[1].lastSeen; + }); + + const toRemove = items.length - state.config.maxItems; + for (let i = 0; i < toRemove; i++) { + if (!items[i][1].flagged) { + state.items.delete(items[i][0]); + } + } + } + + /** + * Update item status based on activity + */ + function updateItemStatus(item, config) { + const now = Date.now(); + const recentEvents = item.events.filter( + e => now - e.timestamp < config.burstWindow + ); + + if (recentEvents.length >= config.burstThreshold) { + if (item.status !== 'burst') { + item.status = 'burst'; + } + } else if (item.eventCount >= 20) { + item.status = 'baseline'; + } else if (now - item.firstSeen < 5 * 60 * 1000) { + item.status = 'new'; + } + + if (item.flagged) { + item.status = 'flagged'; + } + } + + /** + * Detect repeating patterns + */ + function detectPatterns(item, state) { + if (item.events.length < 4) return; + + const intervals = []; + for (let i = 1; i < item.events.length; i++) { + intervals.push(item.events[i].timestamp - item.events[i-1].timestamp); + } + + if (intervals.length >= 3) { + const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length; + const tolerance = avgInterval * 0.1; + const consistent = intervals.filter( + i => Math.abs(i - avgInterval) <= tolerance + ).length; + + if (consistent >= intervals.length * 0.7) { + const seconds = Math.round(avgInterval / 1000); + if (seconds >= 1 && seconds <= 3600) { + const patternStr = seconds < 60 + ? `${seconds}s interval` + : `${Math.round(seconds/60)}m interval`; + + if (item.pattern !== patternStr) { + item.pattern = patternStr; + addAnnotation(state, 'pattern', `Pattern: ${patternStr} - ${item.label}`, Date.now()); + } + } + } + } + } + + /** + * Add annotation + */ + function addAnnotation(state, type, message, timestamp) { + state.annotations.unshift({ + type: type, + message: message, + timestamp: timestamp + }); + + if (state.annotations.length > 20) { + state.annotations.pop(); + } + } + + /** + * Toggle flag on item + */ + function toggleFlag(containerId, id) { + const state = instances.get(containerId); + if (!state) return; + + const item = state.items.get(id); + if (item) { + item.flagged = !item.flagged; + item.status = item.flagged ? 'flagged' : 'new'; + addAnnotation(state, + 'flagged', + item.flagged ? `Flagged: ${item.label}` : `Unflagged: ${item.label}`, + Date.now() + ); + + if (state.config.onItemFlag) { + state.config.onItemFlag(item); + } + + render(state); + } + } + + /** + * Mark item as inactive + */ + function markInactive(containerId, id) { + const state = instances.get(containerId); + if (!state) return; + + const item = state.items.get(id); + if (item && item.status !== 'gone') { + item.status = 'gone'; + addAnnotation(state, 'gone', `Inactive: ${item.label}`, Date.now()); + } + } + + /** + * Build timeline DOM + */ + function buildTimeline(container, state) { + const config = state.config; + const timeline = document.createElement('div'); + timeline.className = `activity-timeline activity-timeline--${config.visualMode}` + + (config.collapsed ? ' collapsed' : ''); + timeline.id = `activityTimeline-${state.containerId}`; + timeline.dataset.mode = config.mode; + + // Build filter buttons HTML + const filterButtonsHtml = buildFilterButtons(config); + + // Build window options HTML + const windowOptionsHtml = config.availableWindows.map(w => + `` + ).join(''); + + // Build legend HTML + const legendHtml = config.showLegend ? ` +
+
+
+ New +
+
+
+ Baseline +
+
+
+ Burst +
+
+
+ Flagged +
+
+ ` : ''; + + timeline.innerHTML = ` +
+
+ + ${config.title} +
+
+
+ 0 + total +
+
+ 0 + new +
+
+ 0 + burst +
+
+
+
+
+ ${filterButtonsHtml} +
+ Window: + +
+
+
+
+
+
+
No activity recorded
+
Events will appear as they are detected
+
+
+ + ${legendHtml} +
+ `; + + container.appendChild(timeline); + return timeline; + } + + /** + * Build filter buttons HTML + */ + function buildFilterButtons(config) { + let html = ''; + + for (const [key, filter] of Object.entries(config.filters)) { + if (filter.enabled) { + html += ``; + } + } + + for (const filter of config.customFilters) { + html += ``; + } + + return html; + } + + /** + * Format window label + */ + function formatWindowLabel(window) { + const labels = { + '5m': '5 min', + '15m': '15 min', + '30m': '30 min', + '1h': '1 hour', + '2h': '2 hours', + '4h': '4 hours', + '8h': '8 hours' + }; + return labels[window] || window; + } + + /** + * Setup event listeners + */ + function setupEventListeners(state) { + const timeline = state.element; + + // Collapse toggle + const header = timeline.querySelector('.activity-timeline-header'); + if (header) { + header.addEventListener('click', (e) => { + if (e.target.closest('button') || e.target.closest('select')) return; + timeline.classList.toggle('collapsed'); + }); + } + + // Filter buttons + timeline.querySelectorAll('.activity-timeline-btn[data-filter]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const filter = btn.dataset.filter; + state.filterState[filter] = !state.filterState[filter]; + btn.classList.toggle('active', state.filterState[filter]); + render(state); + }); + }); + + // Time window selector + const windowSelect = timeline.querySelector('.activity-timeline-window-select'); + if (windowSelect) { + windowSelect.addEventListener('click', (e) => e.stopPropagation()); + windowSelect.addEventListener('change', (e) => { + state.timeWindow = e.target.value; + render(state); + }); + } + + // Lane interactions + timeline.addEventListener('click', (e) => { + const lane = e.target.closest('.activity-timeline-lane'); + if (lane && !e.target.closest('button')) { + lane.classList.toggle('expanded'); + + if (state.config.onItemClick) { + const id = lane.dataset.id; + const item = state.items.get(id); + if (item) { + state.config.onItemClick(item); + } + } + } + }); + + // Right-click to flag + timeline.addEventListener('contextmenu', (e) => { + const lane = e.target.closest('.activity-timeline-lane'); + if (lane) { + e.preventDefault(); + const id = lane.dataset.id; + toggleFlag(state.containerId, id); + } + }); + } + + /** + * Create tooltip element + */ + function createTooltip(state) { + if (state.tooltip) return; + + state.tooltip = document.createElement('div'); + state.tooltip.className = 'activity-timeline-tooltip'; + state.tooltip.style.display = 'none'; + document.body.appendChild(state.tooltip); + } + + /** + * Show tooltip + */ + function showTooltip(e, item, state) { + if (!state.tooltip) return; + + const lastSeenStr = formatTimeAgo(item.lastSeen); + + state.tooltip.innerHTML = ` +
${item.label}
+
+ ID: + ${item.id} +
+
+ First seen: + ${formatTime(item.firstSeen)} +
+
+ Last seen: + ${lastSeenStr} +
+
+ Events: + ${item.eventCount} +
+ ${item.pattern ? ` +
+ Pattern: + ${item.pattern} +
+ ` : ''} +
+ Status: + ${item.status} +
+ ${item.tags.length > 0 ? ` +
+ Tags: + ${item.tags.join(', ')} +
+ ` : ''} + `; + + state.tooltip.style.display = 'block'; + state.tooltip.style.left = (e.clientX + 10) + 'px'; + state.tooltip.style.top = (e.clientY + 10) + 'px'; + } + + /** + * Hide tooltip + */ + function hideTooltip(state) { + if (state.tooltip) { + state.tooltip.style.display = 'none'; + } + } + + /** + * Start update timer + */ + function startUpdateTimer(state) { + if (state.updateTimer) { + clearInterval(state.updateTimer); + } + state.updateTimer = setInterval(() => { + render(state); + }, state.config.updateInterval); + } + + /** + * Stop update timer + */ + function stopUpdateTimer(state) { + if (state.updateTimer) { + clearInterval(state.updateTimer); + state.updateTimer = null; + } + } + + /** + * Render the timeline + */ + function render(state) { + const lanesContainer = state.element.querySelector('.activity-timeline-lanes'); + const axisContainer = state.element.querySelector('.activity-timeline-axis'); + const annotationsContainer = state.element.querySelector('.activity-timeline-annotations'); + + if (!lanesContainer) return; + + const now = Date.now(); + const windowMs = state.config.timeWindows[state.timeWindow]; + const startTime = now - windowMs; + + // Render time axis + renderAxis(axisContainer, startTime, now, windowMs); + + // Get filtered items + let items = Array.from(state.items.values()); + + // Apply standard filters + if (state.filterState.hideBaseline) { + items = items.filter(s => s.status !== 'baseline'); + } + if (state.filterState.showOnlyNew) { + items = items.filter(s => s.status === 'new'); + } + if (state.filterState.showOnlyBurst) { + items = items.filter(s => s.status === 'burst'); + } + + // Apply custom filters + for (const filter of state.config.customFilters) { + if (state.filterState[filter.key] && filter.predicate) { + items = items.filter(filter.predicate); + } + } + + // Sort by priority and recency + const statusPriority = { flagged: 0, burst: 1, new: 2, baseline: 3, gone: 4 }; + items.sort((a, b) => { + const priorityDiff = statusPriority[a.status] - statusPriority[b.status]; + if (priorityDiff !== 0) return priorityDiff; + return b.lastSeen - a.lastSeen; + }); + + // Render lanes + const totalItems = items.length; + const displayedItems = items.slice(0, state.config.maxDisplayedLanes); + const hiddenCount = totalItems - displayedItems.length; + + if (items.length === 0) { + lanesContainer.innerHTML = ` +
+
+
No activity recorded
+
Events will appear as they are detected
+
+ `; + } else { + let html = displayedItems.map(item => + renderLane(item, startTime, now, windowMs, state) + ).join(''); + + if (hiddenCount > 0) { + html += ` +
+ +${hiddenCount} more (scroll or adjust filters) +
+ `; + } + + lanesContainer.innerHTML = html; + + // Add tooltip listeners + lanesContainer.querySelectorAll('.activity-timeline-lane').forEach(lane => { + const id = lane.dataset.id; + const item = state.items.get(id); + + lane.addEventListener('mouseenter', (e) => showTooltip(e, item, state)); + lane.addEventListener('mousemove', (e) => showTooltip(e, item, state)); + lane.addEventListener('mouseleave', () => hideTooltip(state)); + }); + } + + // Update header stats + const allItems = Array.from(state.items.values()); + const statTotal = state.element.querySelector('[data-stat="total"]'); + const statNew = state.element.querySelector('[data-stat="new"]'); + const statBurst = state.element.querySelector('[data-stat="burst"]'); + if (statTotal) statTotal.textContent = allItems.length; + if (statNew) statNew.textContent = allItems.filter(s => s.status === 'new').length; + if (statBurst) statBurst.textContent = allItems.filter(s => s.status === 'burst').length; + + // Render annotations + if (state.config.showAnnotations) { + renderAnnotations(annotationsContainer, state); + } + } + + /** + * Render time axis + */ + function renderAxis(container, startTime, endTime, windowMs) { + if (!container) return; + + const labels = []; + const steps = 6; + for (let i = 0; i <= steps; i++) { + const time = startTime + (windowMs * i / steps); + const label = i === steps ? 'Now' : formatTimeShort(time); + labels.push(`${label}`); + } + + container.innerHTML = labels.join(''); + } + + /** + * Render a single lane + */ + function renderLane(item, startTime, endTime, windowMs, state) { + const isBaseline = item.status === 'baseline'; + const visualMode = state.config.visualMode; + + // Get events within time window + const visibleEvents = item.events.filter( + e => e.timestamp >= startTime && e.timestamp <= endTime + ); + + // Generate bars + const barsHtml = aggregateAndRenderBars(visibleEvents, startTime, windowMs, state.config); + + // Generate ticks for expanded view + const ticksHtml = visibleEvents.map(event => { + const position = ((event.timestamp - startTime) / windowMs) * 100; + return `
`; + }).join(''); + + const recentCount = visibleEvents.length; + + // Compact mode: minimal info + if (visualMode === 'compact') { + return ` +
+
+
+ ${item.label} +
+
+
${barsHtml}
+
+
+ ${recentCount} +
+
+ `; + } + + // Enriched mode: full info + return ` +
+
+
+ ${item.id} + ${item.label} +
+
+
${barsHtml}
+
${ticksHtml}
+
+
+ ${recentCount} + events +
+
+ `; + } + + /** + * Aggregate events into bars + */ + function aggregateAndRenderBars(events, startTime, windowMs, config) { + if (events.length === 0) return ''; + + const bars = []; + let currentBar = null; + const minGap = windowMs / 100; + + events.sort((a, b) => a.timestamp - b.timestamp); + + for (const event of events) { + if (!currentBar) { + currentBar = { + start: event.timestamp, + end: event.timestamp + event.duration, + maxStrength: event.strength, + count: 1 + }; + } else if (event.timestamp - currentBar.end <= minGap) { + currentBar.end = Math.max(currentBar.end, event.timestamp + event.duration); + currentBar.maxStrength = Math.max(currentBar.maxStrength, event.strength); + currentBar.count++; + } else { + bars.push(currentBar); + currentBar = { + start: event.timestamp, + end: event.timestamp + event.duration, + maxStrength: event.strength, + count: 1 + }; + } + } + if (currentBar) bars.push(currentBar); + + return bars.map(bar => { + const left = ((bar.start - startTime) / windowMs) * 100; + const width = Math.max( + config.barMinWidth / 8, + ((bar.end - bar.start) / windowMs) * 100 + ); + const status = bar.count >= config.burstThreshold ? 'burst' : + bar.count > 1 ? 'repeated' : 'new'; + + return `
`; + }).join(''); + } + + /** + * Render annotations + */ + function renderAnnotations(container, state) { + if (!container) return; + + const recentAnnotations = state.annotations.slice(0, 5); + + if (recentAnnotations.length === 0) { + container.style.display = 'none'; + return; + } + + container.style.display = 'block'; + + const iconMap = { + new: '●', + burst: '◆', + pattern: '↻', + flagged: '⚑', + gone: '○' + }; + + container.innerHTML = recentAnnotations.map(ann => { + const icon = iconMap[ann.type] || '•'; + return ` +
+ ${icon} + ${ann.message} + ${formatTimeAgo(ann.timestamp)} +
+ `; + }).join(''); + } + + /** + * Format time + */ + function formatTime(timestamp) { + return new Date(timestamp).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + } + + /** + * Format short time for axis + */ + function formatTimeShort(timestamp) { + return new Date(timestamp).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + } + + /** + * Format time ago + */ + function formatTimeAgo(timestamp) { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; + } + + /** + * Clear all data + */ + function clear(containerId) { + const state = instances.get(containerId); + if (!state) return; + + state.items.clear(); + state.annotations = []; + render(state); + } + + /** + * Export data + */ + function exportData(containerId) { + const state = instances.get(containerId); + if (!state) return null; + + const items = Array.from(state.items.values()).map(s => ({ + id: s.id, + label: s.label, + type: s.type, + status: s.status, + pattern: s.pattern, + firstSeen: new Date(s.firstSeen).toISOString(), + lastSeen: new Date(s.lastSeen).toISOString(), + eventCount: s.eventCount, + flagged: s.flagged, + tags: s.tags + })); + + return { + exportTime: new Date().toISOString(), + mode: state.config.mode, + timeWindow: state.timeWindow, + items: items, + annotations: state.annotations.map(a => ({ + ...a, + timestamp: new Date(a.timestamp).toISOString() + })) + }; + } + + /** + * Get stats + */ + function getStats(containerId) { + const state = instances.get(containerId); + if (!state) return null; + + const items = Array.from(state.items.values()); + return { + total: items.length, + new: items.filter(s => s.status === 'new').length, + baseline: items.filter(s => s.status === 'baseline').length, + burst: items.filter(s => s.status === 'burst').length, + flagged: items.filter(s => s.flagged).length, + withPattern: items.filter(s => s.pattern).length + }; + } + + /** + * Destroy instance + */ + function destroy(containerId) { + const state = instances.get(containerId); + if (!state) return; + + stopUpdateTimer(state); + + if (state.tooltip) { + state.tooltip.remove(); + state.tooltip = null; + } + + if (state.element) { + state.element.remove(); + } + + instances.delete(containerId); + } + + /** + * Create public API for an instance + */ + function createPublicAPI(containerId) { + return { + // Data management + addEvent: (eventData) => addEvent(containerId, eventData), + importEvents: (events) => importEvents(containerId, events), + toggleFlag: (id) => toggleFlag(containerId, id), + markInactive: (id) => markInactive(containerId, id), + clear: () => clear(containerId), + + // Rendering + render: () => { + const state = instances.get(containerId); + if (state) render(state); + }, + + // Data access + getItems: () => { + const state = instances.get(containerId); + return state ? Array.from(state.items.values()) : []; + }, + getAnnotations: () => { + const state = instances.get(containerId); + return state ? state.annotations : []; + }, + getStats: () => getStats(containerId), + exportData: () => exportData(containerId), + + // Configuration + setTimeWindow: (window) => { + const state = instances.get(containerId); + if (state && state.config.timeWindows[window]) { + state.timeWindow = window; + render(state); + } + }, + setFilter: (filter, value) => { + const state = instances.get(containerId); + if (state && state.filterState.hasOwnProperty(filter)) { + state.filterState[filter] = value; + render(state); + } + }, + + // Lifecycle + destroy: () => destroy(containerId) + }; + } + + // Global API + return { + create: create, + + // Convenience methods for single-instance use + addEvent: (containerId, eventData) => addEvent(containerId, eventData), + clear: (containerId) => clear(containerId), + getStats: (containerId) => getStats(containerId), + exportData: (containerId) => exportData(containerId), + destroy: (containerId) => destroy(containerId), + + // Instance access + getInstance: (containerId) => instances.get(containerId), + getInstances: () => instances + }; +})(); + +// Backwards compatibility alias +window.ActivityTimeline = ActivityTimeline; + +// Legacy SignalTimeline compatibility wrapper +window.SignalTimeline = (function() { + 'use strict'; + + let legacyInstance = null; + const LEGACY_CONTAINER = 'signalTimelineContainer'; + + return { + create: function(containerId, options = {}) { + // Map old options to new format + const newOptions = { + title: 'Signal Activity Timeline', + mode: 'tscm', + visualMode: 'enriched', + collapsed: options.collapsed !== false, + showAnnotations: true, + showLegend: true + }; + + legacyInstance = ActivityTimeline.create(containerId, newOptions); + return legacyInstance ? document.getElementById(`activityTimeline-${containerId}`) : null; + }, + + destroy: function() { + if (legacyInstance) { + legacyInstance.destroy(); + legacyInstance = null; + } + }, + + addEvent: function(frequency, strength = 3, duration = 1000, name = null) { + if (legacyInstance) { + return legacyInstance.addEvent({ + id: String(frequency), + label: name, + strength: strength, + duration: duration, + type: 'rf' + }); + } + return null; + }, + + flagSignal: function(frequency) { + if (legacyInstance) { + legacyInstance.toggleFlag(String(frequency)); + } + }, + + markGone: function(frequency) { + if (legacyInstance) { + legacyInstance.markInactive(String(frequency)); + } + }, + + clear: function() { + if (legacyInstance) { + legacyInstance.clear(); + } + }, + + render: function() { + if (legacyInstance) { + legacyInstance.render(); + } + }, + + getSignals: function() { + return legacyInstance ? legacyInstance.getItems() : []; + }, + + getAnnotations: function() { + return legacyInstance ? legacyInstance.getAnnotations() : []; + }, + + getStats: function() { + return legacyInstance ? legacyInstance.getStats() : null; + }, + + exportData: function() { + return legacyInstance ? legacyInstance.exportData() : null; + }, + + setTimeWindow: function(window) { + if (legacyInstance) { + legacyInstance.setTimeWindow(window); + } + }, + + setFilter: function(filter, value) { + if (legacyInstance) { + legacyInstance.setFilter(filter, value); + } + } + }; +})(); diff --git a/static/js/components/timeline-adapters/bluetooth-adapter.js b/static/js/components/timeline-adapters/bluetooth-adapter.js new file mode 100644 index 0000000..f0263d1 --- /dev/null +++ b/static/js/components/timeline-adapters/bluetooth-adapter.js @@ -0,0 +1,288 @@ +/** + * Bluetooth Timeline Adapter + * Normalizes Bluetooth device data for the Activity Timeline component + * Used by: Bluetooth mode, TSCM (Bluetooth detections) + */ + +const BluetoothTimelineAdapter = (function() { + 'use strict'; + + /** + * RSSI to strength category mapping for Bluetooth + * Bluetooth RSSI typically ranges from -30 (very close) to -100 (far) + */ + const RSSI_THRESHOLDS = { + VERY_STRONG: -45, // 5 - device likely within 1m + STRONG: -60, // 4 - device likely within 3m + MODERATE: -75, // 3 - device likely within 10m + WEAK: -90, // 2 - device at edge of range + MINIMAL: -100 // 1 - barely detectable + }; + + /** + * Known device type patterns + */ + const DEVICE_PATTERNS = { + // Apple devices + AIRPODS: /airpods/i, + IPHONE: /iphone/i, + IPAD: /ipad/i, + MACBOOK: /macbook|mac\s*pro|imac/i, + APPLE_WATCH: /apple\s*watch/i, + AIRTAG: /airtag/i, + + // Trackers + TILE: /tile/i, + CHIPOLO: /chipolo/i, + SAMSUNG_TAG: /smarttag|galaxy\s*tag/i, + + // Audio + HEADPHONES: /headphone|earphone|earbud|bose|sony|beats|jabra|sennheiser/i, + SPEAKER: /speaker|soundbar|echo|homepod|sonos/i, + + // Wearables + FITBIT: /fitbit/i, + GARMIN: /garmin/i, + SMARTWATCH: /watch|band|mi\s*band|galaxy\s*fit/i, + + // Input devices + KEYBOARD: /keyboard/i, + MOUSE: /mouse|trackpad|magic/i, + CONTROLLER: /controller|gamepad|xbox|playstation|dualshock/i, + + // Vehicles + CAR: /car\s*kit|handsfree|obd|vehicle|toyota|honda|ford|bmw|mercedes/i + }; + + /** + * Convert RSSI to strength category + */ + function rssiToStrength(rssi) { + if (rssi === null || rssi === undefined) return 3; + + const r = parseFloat(rssi); + if (isNaN(r)) return 3; + + if (r > RSSI_THRESHOLDS.VERY_STRONG) return 5; + if (r > RSSI_THRESHOLDS.STRONG) return 4; + if (r > RSSI_THRESHOLDS.MODERATE) return 3; + if (r > RSSI_THRESHOLDS.WEAK) return 2; + return 1; + } + + /** + * Classify device type from name + */ + function classifyDevice(name) { + if (!name) return { type: 'unknown', category: 'device' }; + + for (const [pattern, regex] of Object.entries(DEVICE_PATTERNS)) { + if (regex.test(name)) { + return { + type: pattern.toLowerCase(), + category: getCategoryForType(pattern) + }; + } + } + + return { type: 'unknown', category: 'device' }; + } + + /** + * Get category for device type + */ + function getCategoryForType(type) { + const categories = { + AIRPODS: 'audio', + IPHONE: 'phone', + IPAD: 'tablet', + MACBOOK: 'computer', + APPLE_WATCH: 'wearable', + AIRTAG: 'tracker', + TILE: 'tracker', + CHIPOLO: 'tracker', + SAMSUNG_TAG: 'tracker', + HEADPHONES: 'audio', + SPEAKER: 'audio', + FITBIT: 'wearable', + GARMIN: 'wearable', + SMARTWATCH: 'wearable', + KEYBOARD: 'input', + MOUSE: 'input', + CONTROLLER: 'input', + CAR: 'vehicle' + }; + return categories[type] || 'device'; + } + + /** + * Format MAC address for display (truncated) + */ + function formatMac(mac, full = false) { + if (!mac) return 'Unknown'; + if (full) return mac.toUpperCase(); + return mac.substring(0, 8).toUpperCase() + '...'; + } + + /** + * Determine if device is a tracker type + */ + function isTracker(device) { + if (device.is_tracker) return true; + + const name = device.name || ''; + return /airtag|tile|chipolo|smarttag|tracker/i.test(name); + } + + /** + * Normalize a Bluetooth device detection for the timeline + */ + function normalizeDevice(device) { + const mac = device.mac || device.address || device.id; + const name = device.name || device.device_name || formatMac(mac); + const classification = classifyDevice(name); + + const tags = [device.type || 'ble']; + tags.push(classification.category); + + if (isTracker(device)) tags.push('tracker'); + if (device.is_beacon) tags.push('beacon'); + if (device.is_connectable) tags.push('connectable'); + if (device.manufacturer) tags.push('identified'); + + return { + id: mac, + label: name, + strength: rssiToStrength(device.rssi), + duration: device.scan_duration || device.duration || 1000, + type: classification.type, + tags: tags, + metadata: { + mac: mac, + rssi: device.rssi, + device_type: device.type, + manufacturer: device.manufacturer, + services: device.services, + is_tracker: isTracker(device), + classification: classification + } + }; + } + + /** + * Normalize for TSCM context (includes threat assessment) + */ + function normalizeTscmDevice(device) { + const normalized = normalizeDevice(device); + + // Add TSCM-specific tags + if (device.is_new) normalized.tags.push('new'); + if (device.threat_level) normalized.tags.push(`threat-${device.threat_level}`); + if (device.baseline_known === false) normalized.tags.push('unknown'); + + normalized.metadata.threat_level = device.threat_level; + normalized.metadata.first_seen = device.first_seen; + normalized.metadata.appearance_count = device.appearance_count; + + return normalized; + } + + /** + * Batch normalize multiple devices + */ + function normalizeDevices(devices, context = 'scan') { + const normalizer = context === 'tscm' ? normalizeTscmDevice : normalizeDevice; + return devices.map(normalizer); + } + + /** + * Create timeline configuration for Bluetooth mode + */ + function getBluetoothConfig() { + return { + title: 'Device Activity', + mode: 'bluetooth', + visualMode: 'enriched', + collapsed: false, + showAnnotations: true, + showLegend: true, + defaultWindow: '15m', + availableWindows: ['5m', '15m', '30m', '1h'], + filters: { + hideBaseline: { enabled: true, label: 'Hide Known', default: false }, + showOnlyNew: { enabled: true, label: 'New Only', default: false }, + showOnlyBurst: { enabled: false, label: 'Bursts', default: false } + }, + customFilters: [ + { + key: 'showOnlyTrackers', + label: 'Trackers Only', + default: false, + predicate: (item) => item.tags.includes('tracker') + }, + { + key: 'hideWearables', + label: 'Hide Wearables', + default: false, + predicate: (item) => !item.tags.includes('wearable') + } + ], + maxItems: 75, + maxDisplayedLanes: 12, + labelGenerator: (id) => formatMac(id) + }; + } + + /** + * Create compact timeline configuration (for sidebar use) + */ + function getCompactConfig() { + return { + title: 'BT Devices', + mode: 'bluetooth', + visualMode: 'compact', + collapsed: false, + showAnnotations: false, + showLegend: false, + defaultWindow: '15m', + availableWindows: ['5m', '15m', '30m'], + filters: { + hideBaseline: { enabled: false }, + showOnlyNew: { enabled: true, label: 'New', default: false }, + showOnlyBurst: { enabled: false } + }, + customFilters: [], + maxItems: 30, + maxDisplayedLanes: 8 + }; + } + + // Public API + return { + // Normalization + normalizeDevice: normalizeDevice, + normalizeTscmDevice: normalizeTscmDevice, + normalizeDevices: normalizeDevices, + + // Utilities + rssiToStrength: rssiToStrength, + classifyDevice: classifyDevice, + formatMac: formatMac, + isTracker: isTracker, + + // Configuration presets + getBluetoothConfig: getBluetoothConfig, + getCompactConfig: getCompactConfig, + + // Constants + RSSI_THRESHOLDS: RSSI_THRESHOLDS, + DEVICE_PATTERNS: DEVICE_PATTERNS + }; +})(); + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = BluetoothTimelineAdapter; +} + +window.BluetoothTimelineAdapter = BluetoothTimelineAdapter; diff --git a/static/js/components/timeline-adapters/rf-adapter.js b/static/js/components/timeline-adapters/rf-adapter.js new file mode 100644 index 0000000..972d16c --- /dev/null +++ b/static/js/components/timeline-adapters/rf-adapter.js @@ -0,0 +1,241 @@ +/** + * RF Signal Timeline Adapter + * Normalizes RF signal data for the Activity Timeline component + * Used by: Listening Post, TSCM + */ + +const RFTimelineAdapter = (function() { + 'use strict'; + + /** + * RSSI to strength category mapping + * Uses confidence-safe thresholds + */ + const RSSI_THRESHOLDS = { + VERY_STRONG: -40, // 5 - indicates likely nearby source + STRONG: -55, // 4 - probable close proximity + MODERATE: -70, // 3 - likely in proximity + WEAK: -85, // 2 - potentially distant or obstructed + MINIMAL: -100 // 1 - may be ambient noise or distant source + }; + + /** + * Frequency band categorization + */ + const FREQUENCY_BANDS = [ + { min: 2400, max: 2500, label: 'Wi-Fi 2.4GHz', type: 'wifi' }, + { min: 5150, max: 5850, label: 'Wi-Fi 5GHz', type: 'wifi' }, + { min: 5925, max: 7125, label: 'Wi-Fi 6E', type: 'wifi' }, + { min: 2402, max: 2480, label: 'Bluetooth', type: 'bluetooth' }, + { min: 433, max: 434, label: '433MHz ISM', type: 'ism' }, + { min: 868, max: 869, label: '868MHz ISM', type: 'ism' }, + { min: 902, max: 928, label: '915MHz ISM', type: 'ism' }, + { min: 315, max: 316, label: '315MHz', type: 'keyfob' }, + { min: 144, max: 148, label: 'VHF Ham', type: 'amateur' }, + { min: 420, max: 450, label: 'UHF Ham', type: 'amateur' }, + { min: 462.5625, max: 467.7125, label: 'FRS/GMRS', type: 'personal' }, + { min: 151, max: 159, label: 'VHF Business', type: 'commercial' }, + { min: 450, max: 470, label: 'UHF Business', type: 'commercial' }, + { min: 88, max: 108, label: 'FM Broadcast', type: 'broadcast' }, + { min: 118, max: 137, label: 'Airband', type: 'aviation' }, + { min: 156, max: 162, label: 'Marine VHF', type: 'marine' } + ]; + + /** + * Convert RSSI (dBm) to strength category (1-5) + */ + function rssiToStrength(rssi) { + if (rssi === null || rssi === undefined) return 3; + + const r = parseFloat(rssi); + if (isNaN(r)) return 3; + + if (r > RSSI_THRESHOLDS.VERY_STRONG) return 5; + if (r > RSSI_THRESHOLDS.STRONG) return 4; + if (r > RSSI_THRESHOLDS.MODERATE) return 3; + if (r > RSSI_THRESHOLDS.WEAK) return 2; + return 1; + } + + /** + * Categorize frequency into human-readable band name + */ + function categorizeFrequency(freqMHz) { + const f = parseFloat(freqMHz); + if (isNaN(f)) return { label: String(freqMHz), type: 'unknown' }; + + for (const band of FREQUENCY_BANDS) { + if (f >= band.min && f <= band.max) { + return { label: band.label, type: band.type }; + } + } + + // Generic labeling by range + if (f < 30) return { label: `${f.toFixed(3)} MHz HF`, type: 'hf' }; + if (f < 300) return { label: `${f.toFixed(3)} MHz VHF`, type: 'vhf' }; + if (f < 3000) return { label: `${f.toFixed(3)} MHz UHF`, type: 'uhf' }; + return { label: `${f.toFixed(3)} MHz`, type: 'unknown' }; + } + + /** + * Normalize a scanner signal detection for the timeline + */ + function normalizeSignal(signalData) { + const freq = signalData.frequency || signalData.freq; + const category = categorizeFrequency(freq); + + return { + id: String(freq), + label: signalData.name || category.label, + strength: rssiToStrength(signalData.rssi || signalData.signal_strength), + duration: signalData.duration || 1000, + type: category.type, + tags: buildTags(signalData, category), + metadata: { + frequency: freq, + rssi: signalData.rssi, + modulation: signalData.modulation, + bandwidth: signalData.bandwidth + } + }; + } + + /** + * Normalize a TSCM RF detection + */ + function normalizeTscmSignal(detection) { + const freq = detection.frequency; + const category = categorizeFrequency(freq); + + const tags = buildTags(detection, category); + + // Add TSCM-specific tags + if (detection.is_new) tags.push('new'); + if (detection.baseline_deviation) tags.push('deviation'); + if (detection.threat_level) tags.push(`threat-${detection.threat_level}`); + + return { + id: String(freq), + label: detection.name || category.label, + strength: rssiToStrength(detection.rssi), + duration: detection.duration || 1000, + type: category.type, + tags: tags, + metadata: { + frequency: freq, + rssi: detection.rssi, + threat_level: detection.threat_level, + source: detection.source + } + }; + } + + /** + * Build tags array from signal data + */ + function buildTags(data, category) { + const tags = []; + + if (category.type) tags.push(category.type); + + if (data.modulation) { + tags.push(data.modulation.toLowerCase()); + } + + if (data.is_burst) tags.push('burst'); + if (data.is_continuous) tags.push('continuous'); + if (data.is_periodic) tags.push('periodic'); + + return tags; + } + + /** + * Batch normalize multiple signals + */ + function normalizeSignals(signals, type = 'scanner') { + const normalizer = type === 'tscm' ? normalizeTscmSignal : normalizeSignal; + return signals.map(normalizer); + } + + /** + * Create timeline configuration for Listening Post mode + */ + function getListeningPostConfig() { + return { + title: 'Signal Activity', + mode: 'listening-post', + visualMode: 'enriched', + collapsed: false, + showAnnotations: true, + showLegend: true, + defaultWindow: '15m', + availableWindows: ['5m', '15m', '30m', '1h'], + filters: { + hideBaseline: { enabled: true, label: 'Hide Known', default: false }, + showOnlyNew: { enabled: true, label: 'New Only', default: false }, + showOnlyBurst: { enabled: true, label: 'Bursts', default: false } + }, + customFilters: [ + { + key: 'hideIsm', + label: 'Hide ISM', + default: false, + predicate: (item) => !item.tags.includes('ism') + } + ], + maxItems: 50, + maxDisplayedLanes: 12 + }; + } + + /** + * Create timeline configuration for TSCM mode + */ + function getTscmConfig() { + return { + title: 'Signal Activity Timeline', + mode: 'tscm', + visualMode: 'enriched', + collapsed: true, + showAnnotations: true, + showLegend: true, + defaultWindow: '30m', + availableWindows: ['5m', '15m', '30m', '1h', '2h'], + filters: { + hideBaseline: { enabled: true, label: 'Hide Known', default: false }, + showOnlyNew: { enabled: true, label: 'New Only', default: false }, + showOnlyBurst: { enabled: true, label: 'Bursts', default: false } + }, + customFilters: [], + maxItems: 100, + maxDisplayedLanes: 15 + }; + } + + // Public API + return { + // Normalization + normalizeSignal: normalizeSignal, + normalizeTscmSignal: normalizeTscmSignal, + normalizeSignals: normalizeSignals, + + // Utilities + rssiToStrength: rssiToStrength, + categorizeFrequency: categorizeFrequency, + + // Configuration presets + getListeningPostConfig: getListeningPostConfig, + getTscmConfig: getTscmConfig, + + // Constants + RSSI_THRESHOLDS: RSSI_THRESHOLDS, + FREQUENCY_BANDS: FREQUENCY_BANDS + }; +})(); + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = RFTimelineAdapter; +} + +window.RFTimelineAdapter = RFTimelineAdapter; diff --git a/static/js/components/timeline-adapters/wifi-adapter.js b/static/js/components/timeline-adapters/wifi-adapter.js new file mode 100644 index 0000000..2acd96d --- /dev/null +++ b/static/js/components/timeline-adapters/wifi-adapter.js @@ -0,0 +1,319 @@ +/** + * WiFi Timeline Adapter + * Normalizes WiFi network data for the Activity Timeline component + * Used by: WiFi mode, TSCM (WiFi detections) + */ + +const WiFiTimelineAdapter = (function() { + 'use strict'; + + /** + * RSSI to strength category mapping for WiFi + */ + const RSSI_THRESHOLDS = { + EXCELLENT: -50, // 5 - excellent signal + GOOD: -60, // 4 - good signal + FAIR: -70, // 3 - fair signal + WEAK: -80, // 2 - weak signal + POOR: -90 // 1 - very weak + }; + + /** + * WiFi channel to frequency band mapping + */ + const CHANNEL_BANDS = { + // 2.4 GHz (channels 1-14) + '2.4GHz': { min: 1, max: 14 }, + // 5 GHz (channels 32-177) + '5GHz': { min: 32, max: 177 }, + // 6 GHz (channels 1-233, WiFi 6E) + '6GHz': { min: 1, max: 233, is6e: true } + }; + + /** + * Security type classifications + */ + const SECURITY_TYPES = { + OPEN: 'open', + WEP: 'wep', + WPA: 'wpa', + WPA2: 'wpa2', + WPA3: 'wpa3', + ENTERPRISE: 'enterprise' + }; + + /** + * Convert RSSI to strength category + */ + function rssiToStrength(rssi) { + if (rssi === null || rssi === undefined) return 3; + + const r = parseFloat(rssi); + if (isNaN(r)) return 3; + + if (r > RSSI_THRESHOLDS.EXCELLENT) return 5; + if (r > RSSI_THRESHOLDS.GOOD) return 4; + if (r > RSSI_THRESHOLDS.FAIR) return 3; + if (r > RSSI_THRESHOLDS.WEAK) return 2; + return 1; + } + + /** + * Determine frequency band from channel + */ + function getBandFromChannel(channel, frequency) { + if (frequency) { + const f = parseFloat(frequency); + if (f >= 5925) return '6GHz'; + if (f >= 5000) return '5GHz'; + if (f >= 2400) return '2.4GHz'; + } + + const ch = parseInt(channel); + if (isNaN(ch)) return 'unknown'; + + // This is simplified - in practice 6GHz also uses channels 1+ + // but typically reported with frequency + if (ch <= 14) return '2.4GHz'; + if (ch >= 32 && ch <= 177) return '5GHz'; + + return 'unknown'; + } + + /** + * Classify security type + */ + function classifySecurity(network) { + const security = (network.security || network.encryption || '').toLowerCase(); + const auth = (network.auth || '').toLowerCase(); + + if (!security || security === 'none' || security === 'open') { + return SECURITY_TYPES.OPEN; + } + if (security.includes('wep')) return SECURITY_TYPES.WEP; + if (security.includes('wpa3')) return SECURITY_TYPES.WPA3; + if (security.includes('wpa2') || security.includes('rsn')) { + if (auth.includes('eap') || auth.includes('802.1x') || auth.includes('enterprise')) { + return SECURITY_TYPES.ENTERPRISE; + } + return SECURITY_TYPES.WPA2; + } + if (security.includes('wpa')) return SECURITY_TYPES.WPA; + + return 'unknown'; + } + + /** + * Truncate SSID for display + */ + function formatSsid(ssid, maxLength = 20) { + if (!ssid) return '[Hidden]'; + if (ssid.length <= maxLength) return ssid; + return ssid.substring(0, maxLength - 3) + '...'; + } + + /** + * Identify potentially interesting network characteristics + */ + function identifyCharacteristics(network) { + const characteristics = []; + const ssid = (network.ssid || '').toLowerCase(); + + // Hidden network + if (!network.ssid || network.is_hidden) { + characteristics.push('hidden'); + } + + // Open network + if (classifySecurity(network) === SECURITY_TYPES.OPEN) { + characteristics.push('open'); + } + + // Weak security + if (classifySecurity(network) === SECURITY_TYPES.WEP) { + characteristics.push('weak-security'); + } + + // Potential hotspot + if (/hotspot|mobile|tether|android|iphone/i.test(ssid)) { + characteristics.push('hotspot'); + } + + // Guest network + if (/guest|visitor|public/i.test(ssid)) { + characteristics.push('guest'); + } + + // IoT device + if (/ring|nest|ecobee|smartthings|wyze|arlo|hue|lifx/i.test(ssid)) { + characteristics.push('iot'); + } + + return characteristics; + } + + /** + * Normalize a WiFi network detection for the timeline + */ + function normalizeNetwork(network) { + const ssid = network.ssid || network.essid || ''; + const bssid = network.bssid || network.mac || ''; + const band = getBandFromChannel(network.channel, network.frequency); + const security = classifySecurity(network); + const characteristics = identifyCharacteristics(network); + + const tags = [band, security, ...characteristics]; + + return { + id: bssid || ssid, + label: formatSsid(ssid) || formatMac(bssid), + strength: rssiToStrength(network.rssi || network.signal), + duration: network.duration || 1000, + type: 'wifi', + tags: tags.filter(Boolean), + metadata: { + ssid: ssid, + bssid: bssid, + channel: network.channel, + frequency: network.frequency, + rssi: network.rssi || network.signal, + security: security, + band: band, + characteristics: characteristics + } + }; + } + + /** + * Normalize for TSCM context + */ + function normalizeTscmNetwork(network) { + const normalized = normalizeNetwork(network); + + // Add TSCM-specific tags + if (network.is_new) normalized.tags.push('new'); + if (network.threat_level) normalized.tags.push(`threat-${network.threat_level}`); + if (network.is_rogue) normalized.tags.push('rogue'); + if (network.is_deauth_target) normalized.tags.push('targeted'); + + normalized.metadata.threat_level = network.threat_level; + normalized.metadata.first_seen = network.first_seen; + normalized.metadata.client_count = network.client_count; + + return normalized; + } + + /** + * Format MAC/BSSID for display + */ + function formatMac(mac) { + if (!mac) return 'Unknown'; + return mac.toUpperCase(); + } + + /** + * Batch normalize multiple networks + */ + function normalizeNetworks(networks, context = 'scan') { + const normalizer = context === 'tscm' ? normalizeTscmNetwork : normalizeNetwork; + return networks.map(normalizer); + } + + /** + * Create timeline configuration for WiFi mode + */ + function getWiFiConfig() { + return { + title: 'Network Activity', + mode: 'wifi', + visualMode: 'enriched', + collapsed: false, + showAnnotations: true, + showLegend: true, + defaultWindow: '15m', + availableWindows: ['5m', '15m', '30m', '1h'], + filters: { + hideBaseline: { enabled: true, label: 'Hide Known', default: false }, + showOnlyNew: { enabled: true, label: 'New Only', default: false }, + showOnlyBurst: { enabled: false, label: 'Bursts', default: false } + }, + customFilters: [ + { + key: 'showOnlyOpen', + label: 'Open Only', + default: false, + predicate: (item) => item.tags.includes('open') + }, + { + key: 'hideHidden', + label: 'Hide Hidden', + default: false, + predicate: (item) => !item.tags.includes('hidden') + }, + { + key: 'show5GHz', + label: '5GHz Only', + default: false, + predicate: (item) => item.tags.includes('5GHz') + } + ], + maxItems: 100, + maxDisplayedLanes: 15, + labelGenerator: (id) => formatSsid(id) + }; + } + + /** + * Create compact configuration for sidebar + */ + function getCompactConfig() { + return { + title: 'Networks', + mode: 'wifi', + visualMode: 'compact', + collapsed: false, + showAnnotations: false, + showLegend: false, + defaultWindow: '15m', + availableWindows: ['5m', '15m', '30m'], + filters: { + hideBaseline: { enabled: false }, + showOnlyNew: { enabled: true, label: 'New', default: false }, + showOnlyBurst: { enabled: false } + }, + customFilters: [], + maxItems: 30, + maxDisplayedLanes: 8 + }; + } + + // Public API + return { + // Normalization + normalizeNetwork: normalizeNetwork, + normalizeTscmNetwork: normalizeTscmNetwork, + normalizeNetworks: normalizeNetworks, + + // Utilities + rssiToStrength: rssiToStrength, + getBandFromChannel: getBandFromChannel, + classifySecurity: classifySecurity, + formatSsid: formatSsid, + identifyCharacteristics: identifyCharacteristics, + + // Configuration presets + getWiFiConfig: getWiFiConfig, + getCompactConfig: getCompactConfig, + + // Constants + RSSI_THRESHOLDS: RSSI_THRESHOLDS, + SECURITY_TYPES: SECURITY_TYPES + }; +})(); + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = WiFiTimelineAdapter; +} + +window.WiFiTimelineAdapter = WiFiTimelineAdapter; diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 5f9f588..fb6d37b 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -691,6 +691,25 @@ function addSignalHit(data) { const hitCount = document.getElementById('scannerHitCount'); if (hitCount) hitCount.textContent = `${tbody.children.length} signals found`; + + // Feed to activity timeline if available + if (typeof addTimelineEvent === 'function') { + const normalized = typeof RFTimelineAdapter !== 'undefined' + ? RFTimelineAdapter.normalizeSignal({ + frequency: data.frequency, + rssi: data.rssi || data.signal_strength, + duration: data.duration || 2000, + modulation: data.modulation + }) + : { + id: String(data.frequency), + label: `${data.frequency.toFixed(3)} MHz`, + strength: 3, + duration: 2000, + type: 'rf' + }; + addTimelineEvent('listening', normalized); + } } function clearScannerLog() { @@ -700,6 +719,12 @@ function clearScannerLog() { scannerCycles = 0; recentSignalHits.clear(); + // Clear the timeline if available + const timeline = typeof getTimeline === 'function' ? getTimeline('listening') : null; + if (timeline) { + timeline.clear(); + } + const signalCount = document.getElementById('scannerSignalCount'); if (signalCount) signalCount.textContent = '0'; diff --git a/templates/index.html b/templates/index.html index 030fbe4..0374446 100644 --- a/templates/index.html +++ b/templates/index.html @@ -18,6 +18,7 @@ + @@ -790,6 +791,10 @@
Waiting for client probe requests...
+ +
+
+
@@ -870,6 +875,10 @@ FindMy-compatible devices...
+ +
+
+
@@ -1345,6 +1354,11 @@
Ready
+ + +
+
+
@@ -1577,9 +1591,93 @@ + + + +