skyview/assets/static/js/modules/ui-manager.js
Ole-Morten Duesund 4a0a993e81 fix: Sanitize all innerHTML dynamic values to prevent XSS
Add centralized escapeHtml() utility and apply it to every dynamic value
inserted via innerHTML/template literals across the frontend. Data from
VRS JSON sources and external CSV files (airline names, countries) flows
through the backend as arbitrary strings that could contain HTML. While
Go's json.Marshal escapes < > &, JavaScript's JSON.parse reverses those
escapes before the values reach innerHTML — enabling script injection.

Affected modules: aircraft-manager, ui-manager, callsign-manager,
map-manager, and the 3D radar labels in app.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:21:56 +01:00

412 lines
No EOL
16 KiB
JavaScript

// UI and table management module
import { escapeHtml } from './html-utils.js';
export class UIManager {
constructor() {
this.aircraftData = new Map();
this.sourcesData = new Map();
this.stats = {};
this.currentView = 'map-view';
this.lastUpdateTime = new Date();
}
initializeViews() {
const viewButtons = document.querySelectorAll('.view-btn');
const views = document.querySelectorAll('.view');
viewButtons.forEach(btn => {
btn.addEventListener('click', () => {
const viewId = btn.id.replace('-btn', '');
this.switchView(viewId);
});
});
}
switchView(viewId) {
// Update buttons
document.querySelectorAll('.view-btn').forEach(btn => btn.classList.remove('active'));
const activeBtn = document.getElementById(`${viewId}-btn`);
if (activeBtn) {
activeBtn.classList.add('active');
}
// Update views (viewId already includes the full view ID like "map-view")
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'));
const activeView = document.getElementById(viewId);
if (activeView) {
activeView.classList.add('active');
} else {
console.warn(`View element not found: ${viewId}`);
return;
}
this.currentView = viewId;
return viewId;
}
updateData(data) {
// Update aircraft data
if (data.aircraft) {
this.aircraftData.clear();
for (const [icao, aircraft] of Object.entries(data.aircraft)) {
this.aircraftData.set(icao, aircraft);
}
}
// Update sources data
if (data.sources) {
this.sourcesData.clear();
data.sources.forEach(source => {
this.sourcesData.set(source.id, source);
});
}
// Update statistics
if (data.stats) {
this.stats = data.stats;
}
this.lastUpdateTime = new Date();
}
updateAircraftTable() {
// Note: This table shows ALL aircraft we're tracking, including those without
// position data. Aircraft without positions will show "No position" in the
// location column but still provide useful info like callsign, altitude, etc.
const tbody = document.getElementById('aircraft-tbody');
if (!tbody) return;
tbody.innerHTML = '';
let filteredData = Array.from(this.aircraftData.values());
// Apply filters
const searchTerm = document.getElementById('search-input')?.value.toLowerCase() || '';
const sourceFilter = document.getElementById('source-filter')?.value || '';
if (searchTerm) {
filteredData = filteredData.filter(aircraft =>
(aircraft.Callsign && aircraft.Callsign.toLowerCase().includes(searchTerm)) ||
(aircraft.ICAO24 && aircraft.ICAO24.toLowerCase().includes(searchTerm)) ||
(aircraft.Squawk && aircraft.Squawk.includes(searchTerm))
);
}
if (sourceFilter) {
filteredData = filteredData.filter(aircraft =>
aircraft.sources && aircraft.sources[sourceFilter]
);
}
// Sort data
const sortBy = document.getElementById('sort-select')?.value || 'distance';
this.sortAircraft(filteredData, sortBy);
// Populate table
filteredData.forEach(aircraft => {
const row = this.createTableRow(aircraft);
tbody.appendChild(row);
});
// Update source filter options
this.updateSourceFilter();
}
createTableRow(aircraft) {
const type = this.getAircraftType(aircraft);
const icao = aircraft.ICAO24 || 'N/A';
const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
const distance = this.calculateDistance(aircraft);
const sources = aircraft.sources ? Object.keys(aircraft.sources).length : 0;
const bestSignal = this.getBestSignalFromSources(aircraft.sources);
const esc = escapeHtml;
const row = document.createElement('tr');
row.innerHTML = `
<td><span class="type-badge ${esc(type)}">${esc(icao)}</span></td>
<td>${esc(aircraft.Callsign) || '-'}</td>
<td>${this.formatSquawk(aircraft)}</td>
<td>${altitude ? `${esc(altitude)} ft` : '-'}</td>
<td>${esc(aircraft.GroundSpeed) || '-'} kt</td>
<td>${distance ? esc(distance.toFixed(1)) : '-'} km</td>
<td>${esc(aircraft.Track) || '-'}°</td>
<td>${esc(sources)}</td>
<td><span class="${esc(this.getSignalClass(bestSignal))}">${bestSignal ? esc(bestSignal.toFixed(1)) : '-'}</span></td>
<td>${esc(this.calculateAge(aircraft).toFixed(0))}s</td>
`;
row.addEventListener('click', () => {
if (aircraft.Latitude && aircraft.Longitude) {
// Trigger event to switch to map and focus on aircraft
const event = new CustomEvent('aircraftSelected', {
detail: { icao, aircraft }
});
document.dispatchEvent(event);
}
});
return row;
}
getAircraftType(aircraft) {
if (aircraft.OnGround) return 'ground';
if (aircraft.Category) {
const cat = aircraft.Category.toLowerCase();
if (cat.includes('military')) return 'military';
if (cat.includes('cargo') || cat.includes('heavy')) return 'cargo';
if (cat.includes('light') || cat.includes('glider')) return 'ga';
}
if (aircraft.Callsign) {
const cs = aircraft.Callsign.toLowerCase();
if (cs.includes('mil') || cs.includes('army') || cs.includes('navy')) return 'military';
if (cs.includes('cargo') || cs.includes('fedex') || cs.includes('ups')) return 'cargo';
}
return 'commercial';
}
calculateAge(aircraft) {
if (!aircraft.last_update) return 0;
const lastUpdate = new Date(aircraft.last_update);
const now = new Date();
const ageMs = now.getTime() - lastUpdate.getTime();
return Math.max(0, ageMs / 1000); // Return age in seconds, minimum 0
}
getBestSignalFromSources(sources) {
if (!sources) return null;
let bestSignal = -999;
for (const [id, data] of Object.entries(sources)) {
if (data.signal_level > bestSignal) {
bestSignal = data.signal_level;
}
}
return bestSignal === -999 ? null : bestSignal;
}
getSignalClass(signal) {
if (!signal) return '';
if (signal > -10) return 'signal-strong';
if (signal > -20) return 'signal-good';
if (signal > -30) return 'signal-weak';
return 'signal-poor';
}
formatSquawk(aircraft) {
if (!aircraft.Squawk) return '-';
const esc = escapeHtml;
// If we have a description, format it nicely
if (aircraft.SquawkDescription) {
// Check if it's an emergency code (contains warning emoji)
if (aircraft.SquawkDescription.includes('⚠️')) {
return `<span class="squawk-emergency" title="${esc(aircraft.SquawkDescription)}">${esc(aircraft.Squawk)}</span>`;
}
// Check if it's a special code (contains special emoji)
else if (aircraft.SquawkDescription.includes('🔸')) {
return `<span class="squawk-special" title="${esc(aircraft.SquawkDescription)}">${esc(aircraft.Squawk)}</span>`;
}
// Check if it's a military code (contains military emoji)
else if (aircraft.SquawkDescription.includes('🔰')) {
return `<span class="squawk-military" title="${esc(aircraft.SquawkDescription)}">${esc(aircraft.Squawk)}</span>`;
}
// Standard codes
else {
return `<span class="squawk-standard" title="${esc(aircraft.SquawkDescription)}">${esc(aircraft.Squawk)}</span>`;
}
}
// No description available, show just the code
return esc(aircraft.Squawk);
}
updateSourceFilter() {
const select = document.getElementById('source-filter');
if (!select) return;
const currentValue = select.value;
// Clear options except "All Sources"
select.innerHTML = '<option value="">All Sources</option>';
// Sort sources alphabetically by name and add options
const sortedSources = Array.from(this.sourcesData.entries()).sort((a, b) =>
a[1].name.localeCompare(b[1].name)
);
for (const [id, source] of sortedSources) {
const option = document.createElement('option');
option.value = id;
option.textContent = source.name;
if (id === currentValue) option.selected = true;
select.appendChild(option);
}
}
sortAircraft(aircraft, sortBy) {
aircraft.sort((a, b) => {
switch (sortBy) {
case 'distance':
return (this.calculateDistance(a) || Infinity) - (this.calculateDistance(b) || Infinity);
case 'altitude':
return (b.Altitude || b.BaroAltitude || 0) - (a.Altitude || a.BaroAltitude || 0);
case 'speed':
return (b.GroundSpeed || 0) - (a.GroundSpeed || 0);
case 'flight':
return (a.Callsign || a.ICAO24 || '').localeCompare(b.Callsign || b.ICAO24 || '');
case 'icao':
return (a.ICAO24 || '').localeCompare(b.ICAO24 || '');
case 'squawk':
return (a.Squawk || '').localeCompare(b.Squawk || '');
case 'signal':
return (this.getBestSignalFromSources(b.sources) || -999) - (this.getBestSignalFromSources(a.sources) || -999);
case 'age':
return (a.Age || 0) - (b.Age || 0);
default:
return 0;
}
});
}
calculateDistance(aircraft) {
if (!aircraft.Latitude || !aircraft.Longitude) return null;
// Use closest source as reference point
let minDistance = Infinity;
for (const [id, srcData] of Object.entries(aircraft.sources || {})) {
if (srcData.distance && srcData.distance < minDistance) {
minDistance = srcData.distance;
}
}
return minDistance === Infinity ? null : minDistance;
}
updateStatistics() {
const totalAircraftEl = document.getElementById('total-aircraft');
const activeSourcesEl = document.getElementById('active-sources');
const activeViewersEl = document.getElementById('active-viewers');
const maxRangeEl = document.getElementById('max-range');
const messagesSecEl = document.getElementById('messages-sec');
const aircraftWithPositionEl = document.getElementById('aircraft-with-position');
const aircraftWithoutPositionEl = document.getElementById('aircraft-without-position');
if (totalAircraftEl) totalAircraftEl.textContent = this.aircraftData.size;
if (activeSourcesEl) {
activeSourcesEl.textContent = Array.from(this.sourcesData.values()).filter(s => s.active).length;
}
if (activeViewersEl) {
activeViewersEl.textContent = this.stats.active_clients || 1;
}
// Update position tracking statistics from backend
if (aircraftWithPositionEl) {
aircraftWithPositionEl.textContent = this.stats.aircraft_with_position || 0;
}
if (aircraftWithoutPositionEl) {
aircraftWithoutPositionEl.textContent = this.stats.aircraft_without_position || 0;
}
// Calculate max range
let maxDistance = 0;
for (const aircraft of this.aircraftData.values()) {
const distance = this.calculateDistance(aircraft);
if (distance && distance > maxDistance) {
maxDistance = distance;
}
}
if (maxRangeEl) maxRangeEl.textContent = `${maxDistance.toFixed(1)} km`;
// Update message rate
const totalMessages = this.stats.total_messages || 0;
if (messagesSecEl) messagesSecEl.textContent = Math.round(totalMessages / 60);
}
updateHeaderInfo() {
const aircraftCountEl = document.getElementById('aircraft-count');
const positionSummaryEl = document.getElementById('position-summary');
const sourcesCountEl = document.getElementById('sources-count');
const activeClientsEl = document.getElementById('active-clients');
if (aircraftCountEl) aircraftCountEl.textContent = `${this.aircraftData.size} aircraft`;
// Update position summary in header
if (positionSummaryEl) {
const positioned = this.stats.aircraft_with_position || 0;
positionSummaryEl.textContent = `${positioned} positioned`;
}
if (sourcesCountEl) sourcesCountEl.textContent = `${this.sourcesData.size} sources`;
// Update active clients count
const clientCount = this.stats.active_clients || 1;
if (activeClientsEl) {
const clientText = clientCount === 1 ? 'viewer' : 'viewers';
activeClientsEl.textContent = `${clientCount} ${clientText}`;
}
this.updateClocks();
}
updateConnectionStatus(status) {
const statusEl = document.getElementById('connection-status');
if (statusEl) {
statusEl.className = `connection-status ${status}`;
statusEl.textContent = status === 'connected' ? 'Connected' : 'Disconnected';
}
}
initializeEventListeners() {
const searchInput = document.getElementById('search-input');
const sortSelect = document.getElementById('sort-select');
const sourceFilter = document.getElementById('source-filter');
if (searchInput) searchInput.addEventListener('input', () => this.updateAircraftTable());
if (sortSelect) sortSelect.addEventListener('change', () => this.updateAircraftTable());
if (sourceFilter) sourceFilter.addEventListener('change', () => this.updateAircraftTable());
}
updateClocks() {
const now = new Date();
this.updateClock('utc', now, true); // UTC clock - use UTC methods
this.updateClock('update', this.lastUpdateTime, false); // Last update clock - use local methods
}
updateClock(prefix, time, useUTC = false) {
// Use appropriate time methods based on whether we want UTC or local time
const hours = useUTC ? time.getUTCHours() : time.getHours();
const minutes = useUTC ? time.getUTCMinutes() : time.getMinutes();
const hourAngle = (hours % 12) * 30 + minutes * 0.5;
const minuteAngle = minutes * 6;
const hourHand = document.getElementById(`${prefix}-hour`);
const minuteHand = document.getElementById(`${prefix}-minute`);
if (hourHand) hourHand.style.transform = `rotate(${hourAngle}deg)`;
if (minuteHand) minuteHand.style.transform = `rotate(${minuteAngle}deg)`;
}
showError(message) {
console.error(message);
// Simple toast notification implementation
const toast = document.createElement('div');
toast.className = 'toast-notification error';
toast.textContent = message;
// Add to page
document.body.appendChild(toast);
// Show toast with animation
setTimeout(() => toast.classList.add('show'), 100);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => document.body.removeChild(toast), 300);
}, 5000);
}
}