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>
412 lines
No EOL
16 KiB
JavaScript
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);
|
|
}
|
|
} |