Merge pull request 'feat: Improve aircraft legend clarity and icon differentiation - resolves #13' (#36) from feature/improve-aircraft-legend-issue-13 into main

Reviewed-on: #36
This commit is contained in:
Ole-Morten Duesund 2025-09-01 10:06:30 +02:00
commit 6efd673507
26 changed files with 829 additions and 591 deletions

View file

@ -29,5 +29,6 @@ import "embed"
// external file deployment or complicated asset management.
//
// Updated to include database.html for database status page
//
//go:embed static
var Static embed.FS

View file

@ -262,21 +262,28 @@ body {
}
.legend-icon {
width: 16px;
height: 16px;
border-radius: 2px;
border: 1px solid #ffffff;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 8px;
}
.legend-icon.light { background: #00bfff; } /* Sky blue for light aircraft */
.legend-icon.medium { background: #00ff88; } /* Green for medium aircraft */
.legend-icon.large { background: #ff8c00; } /* Orange for large aircraft */
.legend-icon.high-vortex { background: #ff4500; } /* Red-orange for high vortex large */
.legend-icon.heavy { background: #ff0000; } /* Red for heavy aircraft */
.legend-icon.helicopter { background: #ff00ff; } /* Magenta for helicopters */
.legend-icon.military { background: #ff4444; } /* Red-orange for military */
.legend-icon.ga { background: #ffff00; } /* Yellow for general aviation */
.legend-icon.ground { background: #888888; } /* Gray for ground vehicles */
.legend-icon svg {
width: 100%;
height: 100%;
}
.legend-icon.light svg { color: #00bfff; } /* Sky blue for light aircraft */
.legend-icon.medium svg { color: #00ff88; } /* Green for medium aircraft */
.legend-icon.large svg { color: #ff8c00; } /* Orange for large aircraft */
.legend-icon.high-vortex svg { color: #ff4500; } /* Red-orange for high vortex large */
.legend-icon.heavy svg { color: #ff0000; } /* Red for heavy aircraft */
.legend-icon.helicopter svg { color: #ff00ff; } /* Magenta for helicopters */
.legend-icon.military svg { color: #ff4444; } /* Red-orange for military */
.legend-icon.ga svg { color: #ffff00; } /* Yellow for general aviation */
.legend-icon.ground svg { color: #888888; } /* Gray for ground vehicles */
.table-controls {
display: flex;

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(18,18)">
<!-- Heavy aircraft (jumbo jet like A380 or 747) -->
<!-- Fuselage (very wide) -->
<path d="M0,-16 L-2.5,-14 L-2.5,9 L-1.5,12 L0,13 L1.5,12 L2.5,9 L2.5,-14 Z" fill="currentColor"/>
<!-- Main wings (very long and wide) -->
<path d="M-2.5,0.5 L-16,4 L-16,6.5 L-2.5,3.5 Z" fill="currentColor"/>
<path d="M2.5,0.5 L16,4 L16,6.5 L2.5,3.5 Z" fill="currentColor"/>
<!-- Wing tips (large winglets) -->
<path d="M-16,4 L-17.5,2.5 L-17.5,5 L-16,6.5 Z" fill="currentColor"/>
<path d="M16,4 L17.5,2.5 L17.5,5 L16,6.5 Z" fill="currentColor"/>
<!-- Tail wings (large) -->
<path d="M-1.2,10 L-7,11.8 L-7,13 L-1.2,11.5 Z" fill="currentColor"/>
<path d="M1.2,10 L7,11.8 L7,13 L1.2,11.5 Z" fill="currentColor"/>
<!-- Vertical stabilizer (very tall) -->
<path d="M0,8 L-0.8,8 L-3,13.5 L0,13.5 L3,13.5 L0.8,8 Z" fill="currentColor"/>
<!-- Nose (large) -->
<ellipse cx="0" cy="-15" rx="2.5" ry="3" fill="currentColor"/>
<!-- Engine nacelles (very large, 4 engines) -->
<ellipse cx="-6" cy="3" rx="1.5" ry="3" fill="currentColor"/>
<ellipse cx="6" cy="3" rx="1.5" ry="3" fill="currentColor"/>
<ellipse cx="-4" cy="4" rx="1" ry="2" fill="currentColor"/>
<ellipse cx="4" cy="4" rx="1" ry="2" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="34" height="34" viewBox="0 0 34 34" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(17,17)">
<!-- Large aircraft (wide-body airliner) -->
<!-- Fuselage (wider than commercial) -->
<path d="M0,-15 L-2,-13 L-2,8 L-1.2,11 L0,12 L1.2,11 L2,8 L2,-13 Z" fill="currentColor"/>
<!-- Main wings (longer, wider) -->
<path d="M-2,0 L-14,3.5 L-14,5.5 L-2,3 Z" fill="currentColor"/>
<path d="M2,0 L14,3.5 L14,5.5 L2,3 Z" fill="currentColor"/>
<!-- Wing tips (winglets) -->
<path d="M-14,3.5 L-15,2.5 L-15,4 L-14,5 Z" fill="currentColor"/>
<path d="M14,3.5 L15,2.5 L15,4 L14,5 Z" fill="currentColor"/>
<!-- Tail wings -->
<path d="M-1,9 L-6,10.5 L-6,11.5 L-1,10.5 Z" fill="currentColor"/>
<path d="M1,9 L6,10.5 L6,11.5 L1,10.5 Z" fill="currentColor"/>
<!-- Vertical stabilizer (taller) -->
<path d="M0,7 L-0.6,7 L-2.5,12 L0,12 L2.5,12 L0.6,7 Z" fill="currentColor"/>
<!-- Nose -->
<ellipse cx="0" cy="-14" rx="2" ry="2.5" fill="currentColor"/>
<!-- Engine nacelles (larger) -->
<ellipse cx="-5" cy="2.5" rx="1.2" ry="2.5" fill="currentColor"/>
<ellipse cx="5" cy="2.5" rx="1.2" ry="2.5" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="28" height="28" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(14,14)">
<!-- Light aircraft (smaller, simpler design) -->
<!-- Fuselage -->
<path d="M0,-10 L-1,-9 L-1,5 L-0.5,7 L0,8 L0.5,7 L1,5 L1,-9 Z" fill="currentColor"/>
<!-- Main wings -->
<path d="M-1,-1 L-8,1 L-8,2.5 L-1,1 Z" fill="currentColor"/>
<path d="M1,-1 L8,1 L8,2.5 L1,1 Z" fill="currentColor"/>
<!-- Tail wings -->
<path d="M-0.5,5 L-3,6 L-3,6.5 L-0.5,6 Z" fill="currentColor"/>
<path d="M0.5,5 L3,6 L3,6.5 L0.5,6 Z" fill="currentColor"/>
<!-- Vertical stabilizer -->
<path d="M0,4 L-0.3,4 L-1,7 L0,7 L1,7 L0.3,4 Z" fill="currentColor"/>
<!-- Nose -->
<ellipse cx="0" cy="-9.5" rx="1" ry="1.5" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 835 B

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="30" height="30" viewBox="0 0 30 30" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(15,15)">
<!-- Medium aircraft (medium size, more detailed) -->
<!-- Fuselage -->
<path d="M0,-12 L-1.2,-11 L-1.2,6 L-0.8,8 L0,9 L0.8,8 L1.2,6 L1.2,-11 Z" fill="currentColor"/>
<!-- Main wings -->
<path d="M-1.2,-0.5 L-10,2 L-10,3.5 L-1.2,2 Z" fill="currentColor"/>
<path d="M1.2,-0.5 L10,2 L10,3.5 L1.2,2 Z" fill="currentColor"/>
<!-- Tail wings -->
<path d="M-0.7,6.5 L-4,7.5 L-4,8.2 L-0.7,7.5 Z" fill="currentColor"/>
<path d="M0.7,6.5 L4,7.5 L4,8.2 L0.7,7.5 Z" fill="currentColor"/>
<!-- Vertical stabilizer -->
<path d="M0,5 L-0.4,5 L-1.5,9 L0,9 L1.5,9 L0.4,5 Z" fill="currentColor"/>
<!-- Nose -->
<ellipse cx="0" cy="-11.5" rx="1.2" ry="1.8" fill="currentColor"/>
<!-- Engine nacelles -->
<ellipse cx="-3" cy="1.5" rx="0.8" ry="1.5" fill="currentColor"/>
<ellipse cx="3" cy="1.5" rx="0.8" ry="1.5" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -112,37 +112,175 @@
<div class="legend">
<h4>ADS-B Categories</h4>
<div class="legend-item">
<span class="legend-icon light"></span>
<span class="legend-icon light">
<svg width="24" height="24" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(14,14)">
<path d="M0,-10 L-1,-9 L-1,5 L-0.5,7 L0,8 L0.5,7 L1,5 L1,-9 Z" fill="currentColor"/>
<path d="M-1,-1 L-8,1 L-8,2.5 L-1,1 Z" fill="currentColor"/>
<path d="M1,-1 L8,1 L8,2.5 L1,1 Z" fill="currentColor"/>
<path d="M-0.5,5 L-3,6 L-3,6.5 L-0.5,6 Z" fill="currentColor"/>
<path d="M0.5,5 L3,6 L3,6.5 L0.5,6 Z" fill="currentColor"/>
<path d="M0,4 L-0.3,4 L-1,7 L0,7 L1,7 L0.3,4 Z" fill="currentColor"/>
<ellipse cx="0" cy="-9.5" rx="1" ry="1.5" fill="currentColor"/>
</g>
</svg>
</span>
<span>Light &lt; 7000kg</span>
</div>
<div class="legend-item">
<span class="legend-icon medium"></span>
<span class="legend-icon medium">
<svg width="24" height="24" viewBox="0 0 30 30" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(15,15)">
<path d="M0,-12 L-1.2,-11 L-1.2,6 L-0.8,8 L0,9 L0.8,8 L1.2,6 L1.2,-11 Z" fill="currentColor"/>
<path d="M-1.2,-0.5 L-10,2 L-10,3.5 L-1.2,2 Z" fill="currentColor"/>
<path d="M1.2,-0.5 L10,2 L10,3.5 L1.2,2 Z" fill="currentColor"/>
<path d="M-0.7,6.5 L-4,7.5 L-4,8.2 L-0.7,7.5 Z" fill="currentColor"/>
<path d="M0.7,6.5 L4,7.5 L4,8.2 L0.7,7.5 Z" fill="currentColor"/>
<path d="M0,5 L-0.4,5 L-1.5,9 L0,9 L1.5,9 L0.4,5 Z" fill="currentColor"/>
<ellipse cx="0" cy="-11.5" rx="1.2" ry="1.8" fill="currentColor"/>
<ellipse cx="-3" cy="1.5" rx="0.8" ry="1.5" fill="currentColor"/>
<ellipse cx="3" cy="1.5" rx="0.8" ry="1.5" fill="currentColor"/>
</g>
</svg>
</span>
<span>Medium 7000-34000kg</span>
</div>
<div class="legend-item">
<span class="legend-icon large"></span>
<span class="legend-icon large">
<svg width="24" height="24" viewBox="0 0 34 34" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(17,17)">
<path d="M0,-15 L-2,-13 L-2,8 L-1.2,11 L0,12 L1.2,11 L2,8 L2,-13 Z" fill="currentColor"/>
<path d="M-2,0 L-14,3.5 L-14,5.5 L-2,3 Z" fill="currentColor"/>
<path d="M2,0 L14,3.5 L14,5.5 L2,3 Z" fill="currentColor"/>
<path d="M-14,3.5 L-15,2.5 L-15,4 L-14,5 Z" fill="currentColor"/>
<path d="M14,3.5 L15,2.5 L15,4 L14,5 Z" fill="currentColor"/>
<path d="M-1,9 L-6,10.5 L-6,11.5 L-1,10.5 Z" fill="currentColor"/>
<path d="M1,9 L6,10.5 L6,11.5 L1,10.5 Z" fill="currentColor"/>
<path d="M0,7 L-0.6,7 L-2.5,12 L0,12 L2.5,12 L0.6,7 Z" fill="currentColor"/>
<ellipse cx="0" cy="-14" rx="2" ry="2.5" fill="currentColor"/>
<ellipse cx="-5" cy="2.5" rx="1.2" ry="2.5" fill="currentColor"/>
<ellipse cx="5" cy="2.5" rx="1.2" ry="2.5" fill="currentColor"/>
</g>
</svg>
</span>
<span>Large 34000-136000kg</span>
</div>
<div class="legend-item">
<span class="legend-icon high-vortex"></span>
<span class="legend-icon high-vortex">
<svg width="24" height="24" viewBox="0 0 34 34" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(17,17)">
<path d="M0,-15 L-2,-13 L-2,8 L-1.2,11 L0,12 L1.2,11 L2,8 L2,-13 Z" fill="currentColor"/>
<path d="M-2,0 L-14,3.5 L-14,5.5 L-2,3 Z" fill="currentColor"/>
<path d="M2,0 L14,3.5 L14,5.5 L2,3 Z" fill="currentColor"/>
<path d="M-14,3.5 L-15,2.5 L-15,4 L-14,5 Z" fill="currentColor"/>
<path d="M14,3.5 L15,2.5 L15,4 L14,5 Z" fill="currentColor"/>
<path d="M-1,9 L-6,10.5 L-6,11.5 L-1,10.5 Z" fill="currentColor"/>
<path d="M1,9 L6,10.5 L6,11.5 L1,10.5 Z" fill="currentColor"/>
<path d="M0,7 L-0.6,7 L-2.5,12 L0,12 L2.5,12 L0.6,7 Z" fill="currentColor"/>
<ellipse cx="0" cy="-14" rx="2" ry="2.5" fill="currentColor"/>
<ellipse cx="-5" cy="2.5" rx="1.2" ry="2.5" fill="currentColor"/>
<ellipse cx="5" cy="2.5" rx="1.2" ry="2.5" fill="currentColor"/>
</g>
</svg>
</span>
<span>High Vortex Large</span>
</div>
<div class="legend-item">
<span class="legend-icon heavy"></span>
<span class="legend-icon heavy">
<svg width="24" height="24" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(18,18)">
<path d="M0,-16 L-2.5,-14 L-2.5,9 L-1.5,12 L0,13 L1.5,12 L2.5,9 L2.5,-14 Z" fill="currentColor"/>
<path d="M-2.5,0.5 L-16,4 L-16,6.5 L-2.5,3.5 Z" fill="currentColor"/>
<path d="M2.5,0.5 L16,4 L16,6.5 L2.5,3.5 Z" fill="currentColor"/>
<path d="M-16,4 L-17.5,2.5 L-17.5,5 L-16,6.5 Z" fill="currentColor"/>
<path d="M16,4 L17.5,2.5 L17.5,5 L16,6.5 Z" fill="currentColor"/>
<path d="M-1.2,10 L-7,11.8 L-7,13 L-1.2,11.5 Z" fill="currentColor"/>
<path d="M1.2,10 L7,11.8 L7,13 L1.2,11.5 Z" fill="currentColor"/>
<path d="M0,8 L-0.8,8 L-3,13.5 L0,13.5 L3,13.5 L0.8,8 Z" fill="currentColor"/>
<ellipse cx="0" cy="-15" rx="2.5" ry="3" fill="currentColor"/>
<ellipse cx="-6" cy="3" rx="1.5" ry="3" fill="currentColor"/>
<ellipse cx="6" cy="3" rx="1.5" ry="3" fill="currentColor"/>
<ellipse cx="-4" cy="4" rx="1" ry="2" fill="currentColor"/>
<ellipse cx="4" cy="4" rx="1" ry="2" fill="currentColor"/>
</g>
</svg>
</span>
<span>Heavy &gt; 136000kg</span>
</div>
<div class="legend-item">
<span class="legend-icon helicopter"></span>
<span class="legend-icon helicopter">
<svg width="24" height="24" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(16,16)">
<!-- Main rotor disc -->
<ellipse cx="0" cy="-2" rx="11" ry="1" fill="currentColor" opacity="0.2"/>
<path d="M-11,-2 L11,-2" stroke="currentColor" stroke-width="0.5" opacity="0.4"/>
<!-- Main fuselage -->
<path d="M0,-8 C-3,-8 -4,-6 -4,-3 L-4,4 C-4,6 -3,7 -1,7 L1,7 C3,7 4,6 4,4 L4,-3 C4,-6 3,-8 0,-8 Z" fill="currentColor"/>
<!-- Cockpit windscreen -->
<path d="M0,-8 C-2,-8 -3,-7 -3,-5 L-3,-3 L3,-3 L3,-5 C3,-7 2,-8 0,-8 Z" fill="currentColor" opacity="0.7"/>
<!-- Tail boom -->
<rect x="-1" y="6" width="2" height="8" fill="currentColor"/>
<!-- Tail rotor -->
<ellipse cx="0" cy="13" rx="1" ry="3" fill="currentColor"/>
<path d="M-3,13 L3,13" stroke="currentColor" stroke-width="0.8"/>
<!-- Landing skids -->
<path d="M-3,7 L-3,9 L-1,9" stroke="currentColor" stroke-width="1" fill="none"/>
<path d="M3,7 L3,9 L1,9" stroke="currentColor" stroke-width="1" fill="none"/>
<!-- Rotor hub -->
<circle cx="0" cy="-2" r="1" fill="currentColor"/>
</g>
</svg>
</span>
<span>Rotorcraft</span>
</div>
<div class="legend-item">
<span class="legend-icon ga"></span>
<span class="legend-icon ga">
<svg width="24" height="24" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(14,14)">
<path d="M0,-8 L-0.8,-7.5 L-0.8,4 L-0.4,5.5 L0,6 L0.4,5.5 L0.8,4 L0.8,-7.5 Z" fill="currentColor"/>
<path d="M-0.8,-0.5 L-6,1 L-6,2 L-0.8,1 Z" fill="currentColor"/>
<path d="M0.8,-0.5 L6,1 L6,2 L0.8,1 Z" fill="currentColor"/>
<path d="M-0.4,4 L-2.5,4.8 L-2.5,5.2 L-0.4,4.8 Z" fill="currentColor"/>
<path d="M0.4,4 L2.5,4.8 L2.5,5.2 L0.4,4.8 Z" fill="currentColor"/>
<path d="M0,3 L-0.2,3 L-0.8,6 L0,6 L0.8,6 L0.2,3 Z" fill="currentColor"/>
<ellipse cx="0" cy="-7.5" rx="0.8" ry="1" fill="currentColor"/>
</g>
</svg>
</span>
<span>Glider/Ultralight</span>
</div>
<div class="legend-item">
<span class="legend-icon ground"></span>
<span class="legend-icon ground">
<svg width="24" height="24" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(16,16)">
<path d="M-6,-2 L6,-2 L8,0 L8,2 L-8,2 L-8,0 Z" fill="currentColor"/>
<ellipse cx="-4" cy="4" rx="2" ry="2" fill="currentColor"/>
<ellipse cx="4" cy="4" rx="2" ry="2" fill="currentColor"/>
<path d="M-2,-2 L2,-2 L2,-6 L-2,-6 Z" fill="currentColor"/>
<path d="M-1,-6 L1,-6 L1,-8 L-1,-8 Z" fill="currentColor"/>
</g>
</svg>
</span>
<span>Surface Vehicle</span>
</div>
<div class="legend-item">
<span class="legend-icon military">
<svg width="24" height="24" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(16,16)">
<path d="M0,-14 L-1.2,-12 L-1.2,6 L-0.8,9 L0,10 L0.8,9 L1.2,6 L1.2,-12 Z" fill="currentColor"/>
<path d="M-1.2,-1 L-11,2 L-11,3.5 L-1.2,2 Z" fill="currentColor"/>
<path d="M1.2,-1 L11,2 L11,3.5 L1.2,2 Z" fill="currentColor"/>
<path d="M-0.7,7 L-4.5,8 L-4.5,8.8 L-0.7,8 Z" fill="currentColor"/>
<path d="M0.7,7 L4.5,8 L4.5,8.8 L0.7,8 Z" fill="currentColor"/>
<path d="M0,5.5 L-0.4,5.5 L-1.8,10 L0,10 L1.8,10 L0.4,5.5 Z" fill="currentColor"/>
<ellipse cx="0" cy="-13" rx="1.2" ry="2" fill="currentColor"/>
<ellipse cx="-3.5" cy="1.8" rx="0.8" ry="1.8" fill="currentColor"/>
<ellipse cx="3.5" cy="1.8" rx="0.8" ry="1.8" fill="currentColor"/>
</g>
</svg>
</span>
<span>Military</span>
</div>
<h4>Sources</h4>
<div id="sources-legend"></div>

View file

@ -331,60 +331,48 @@ export class AircraftManager {
}
getAircraftIconType(aircraft) {
// For icon selection, we still need basic categories
// This determines which SVG shape to use
// For icon selection, determine which SVG shape to use based on category
if (aircraft.OnGround) return 'ground';
if (aircraft.Category) {
const cat = aircraft.Category.toLowerCase();
// Map to basic icon types for visual representation
// Specialized aircraft types
if (cat.includes('helicopter') || cat.includes('rotorcraft')) return 'helicopter';
if (cat.includes('military') || cat.includes('fighter') || cat.includes('bomber')) return 'military';
if (cat.includes('cargo') || cat.includes('heavy') || cat.includes('super')) return 'cargo';
if (cat.includes('light') || cat.includes('glider') || cat.includes('ultralight')) return 'ga';
if (cat.includes('glider') || cat.includes('ultralight')) return 'ga';
// Weight-based categories with specific icons
if (cat.includes('light') && cat.includes('7000')) return 'light';
if (cat.includes('medium') && cat.includes('7000-34000')) return 'medium';
if (cat.includes('large') && cat.includes('34000-136000')) return 'large';
if (cat.includes('heavy') && cat.includes('136000')) return 'heavy';
if (cat.includes('high vortex')) return 'large'; // Use large icon for high vortex
// Fallback category matching
if (cat.includes('heavy') || cat.includes('super')) return 'heavy';
if (cat.includes('large')) return 'large';
if (cat.includes('medium')) return 'medium';
if (cat.includes('light')) return 'light';
}
// Default commercial icon for everything else
return 'commercial';
// Default to medium icon for unknown aircraft
return 'medium';
}
getAircraftColor(type, aircraft) {
// Special colors for specific types
if (type === 'military') return '#ff4444';
if (type === 'helicopter') return '#ff00ff';
if (type === 'ground') return '#888888';
if (type === 'ga') return '#ffff00';
// For commercial and cargo types, use weight-based colors
if (aircraft && aircraft.Category) {
const cat = aircraft.Category.toLowerCase();
// Check for specific weight ranges in the category string
// Light aircraft (< 7000kg) - Sky blue
if (cat.includes('light')) {
return '#00bfff';
}
// Medium aircraft (7000-34000kg) - Green
if (cat.includes('medium')) {
return '#00ff88';
}
// High Vortex Large - Red-orange (special wake turbulence category)
if (cat.includes('high vortex')) {
return '#ff4500';
}
// Large aircraft (34000-136000kg) - Orange
if (cat.includes('large')) {
return '#ff8c00';
}
// Heavy aircraft (> 136000kg) - Red
if (cat.includes('heavy') || cat.includes('super')) {
return '#ff0000';
}
// Color mapping based on aircraft type/size
switch (type) {
case 'military': return '#ff4444'; // Red-orange for military
case 'helicopter': return '#ff00ff'; // Magenta for helicopters
case 'ground': return '#888888'; // Gray for ground vehicles
case 'ga': return '#ffff00'; // Yellow for general aviation
case 'light': return '#00bfff'; // Sky blue for light aircraft
case 'medium': return '#00ff88'; // Green for medium aircraft
case 'large': return '#ff8c00'; // Orange for large aircraft
case 'heavy': return '#ff0000'; // Red for heavy aircraft
default: return '#00ff88'; // Default green for unknown
}
// Default to green for unknown commercial aircraft
return '#00ff88';
}

View file

@ -19,28 +19,28 @@ import (
// Shared configuration structures (should match main skyview)
type Config struct {
Server ServerConfig `json:"server"`
Sources []SourceConfig `json:"sources"`
Settings Settings `json:"settings"`
Database *database.Config `json:"database,omitempty"`
Callsign *CallsignConfig `json:"callsign,omitempty"`
Origin OriginConfig `json:"origin"`
Server ServerConfig `json:"server"`
Sources []SourceConfig `json:"sources"`
Settings Settings `json:"settings"`
Database *database.Config `json:"database,omitempty"`
Callsign *CallsignConfig `json:"callsign,omitempty"`
Origin OriginConfig `json:"origin"`
}
type CallsignConfig struct {
Enabled bool `json:"enabled"`
CacheHours int `json:"cache_hours"`
PrivacyMode bool `json:"privacy_mode"`
Sources map[string]CallsignSourceConfig `json:"sources"`
ExternalAPIs map[string]ExternalAPIConfig `json:"external_apis,omitempty"`
Enabled bool `json:"enabled"`
CacheHours int `json:"cache_hours"`
PrivacyMode bool `json:"privacy_mode"`
Sources map[string]CallsignSourceConfig `json:"sources"`
ExternalAPIs map[string]ExternalAPIConfig `json:"external_apis,omitempty"`
}
type CallsignSourceConfig struct {
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
License string `json:"license"`
RequiresConsent bool `json:"requires_consent,omitempty"`
UserAcceptsTerms bool `json:"user_accepts_terms,omitempty"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
License string `json:"license"`
RequiresConsent bool `json:"requires_consent,omitempty"`
UserAcceptsTerms bool `json:"user_accepts_terms,omitempty"`
}
type ExternalAPIConfig struct {
@ -51,14 +51,14 @@ type ExternalAPIConfig struct {
}
type OriginConfig struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Name string `json:"name,omitempty"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Name string `json:"name,omitempty"`
}
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Host string `json:"host"`
Port int `json:"port"`
}
type SourceConfig struct {
@ -152,7 +152,7 @@ OPTIONS:
if err != nil {
log.Fatalf("Configuration loading failed: %v", err)
}
// Initialize database connection using shared config
db, err := initDatabaseFromConfig(config, *dbPath)
if err != nil {
@ -215,7 +215,7 @@ func initDatabase(dbPath string) (*database.Database, error) {
// cmdInit initializes an empty database
func cmdInit(db *database.Database, force bool) error {
dbPath := db.GetConfig().Path
// Check if database already exists and has data
if !force {
if stats, err := db.GetHistoryManager().GetStatistics(); err == nil {
@ -271,19 +271,19 @@ func cmdList(db *database.Database) error {
func cmdStatus(db *database.Database) error {
fmt.Println("SkyView Database Status")
fmt.Println("======================")
dbPath := db.GetConfig().Path
fmt.Printf("Database: %s\n", dbPath)
// Check if file exists and get size
if stat, err := os.Stat(dbPath); err == nil {
fmt.Printf("Size: %.2f MB\n", float64(stat.Size())/(1024*1024))
fmt.Printf("Modified: %s\n", stat.ModTime().Format(time.RFC3339))
// Add database optimization stats
optimizer := database.NewOptimizationManager(db, db.GetConfig())
if stats, err := optimizer.GetOptimizationStats(); err == nil {
fmt.Printf("Efficiency: %.1f%% (%d used pages, %d free pages)\n",
fmt.Printf("Efficiency: %.1f%% (%d used pages, %d free pages)\n",
stats.Efficiency, stats.UsedPages, stats.FreePages)
if stats.AutoVacuumEnabled {
fmt.Printf("Auto-VACUUM: Enabled\n")
@ -320,7 +320,7 @@ func cmdStatus(db *database.Database) error {
var airportCount, airlineCount int
db.GetConnection().QueryRow(`SELECT COUNT(*) FROM airports`).Scan(&airportCount)
db.GetConnection().QueryRow(`SELECT COUNT(*) FROM airlines`).Scan(&airlineCount)
// Get data source update information
var lastUpdate time.Time
var updateCount int
@ -329,7 +329,7 @@ func cmdStatus(db *database.Database) error {
FROM data_sources
WHERE imported_at IS NOT NULL
`).Scan(&updateCount, &lastUpdate)
fmt.Printf("📊 Database Statistics:\n")
fmt.Printf(" Reference Data:\n")
if airportCount > 0 {
@ -344,7 +344,7 @@ func cmdStatus(db *database.Database) error {
fmt.Printf(" • Last Updated: %s\n", lastUpdate.Format("2006-01-02 15:04:05"))
}
}
fmt.Printf(" Flight History:\n")
if totalRecords, ok := stats["total_records"].(int); ok {
fmt.Printf(" • Aircraft Records: %d\n", totalRecords)
@ -361,13 +361,13 @@ func cmdStatus(db *database.Database) error {
if hasOldest && hasNewest && oldestRecord != nil && newestRecord != nil {
if oldest, ok := oldestRecord.(time.Time); ok {
if newest, ok := newestRecord.(time.Time); ok {
fmt.Printf(" • Flight Data Range: %s to %s\n",
fmt.Printf(" • Flight Data Range: %s to %s\n",
oldest.Format("2006-01-02"),
newest.Format("2006-01-02"))
}
}
}
// Show airport data sample if available
if airportCount > 0 {
var sampleAirports []string
@ -398,7 +398,7 @@ func cmdStatus(db *database.Database) error {
// cmdUpdate updates data from specified sources (or safe sources by default)
func cmdUpdate(db *database.Database, sources []string, force bool) error {
availableSources := database.GetAvailableDataSources()
// If no sources specified, use safe (non-consent-required) sources
if len(sources) == 0 {
log.Println("Updating from safe data sources...")
@ -407,7 +407,7 @@ func cmdUpdate(db *database.Database, sources []string, force bool) error {
sources = append(sources, strings.ToLower(strings.ReplaceAll(source.Name, " ", "")))
}
}
if len(sources) == 0 {
log.Println("No safe data sources available for automatic update")
return nil
@ -416,7 +416,7 @@ func cmdUpdate(db *database.Database, sources []string, force bool) error {
}
loader := database.NewDataLoader(db.GetConnection())
for _, sourceName := range sources {
// Find matching source
var matchedSource *database.DataSource
@ -426,13 +426,13 @@ func cmdUpdate(db *database.Database, sources []string, force bool) error {
break
}
}
if matchedSource == nil {
log.Printf("⚠️ Unknown source: %s", sourceName)
continue
}
// Check for consent requirement
// Check for consent requirement
if matchedSource.RequiresConsent && !force {
log.Printf("Skipping %s: requires license acceptance (%s)", matchedSource.Name, matchedSource.License)
log.Printf("Use --force to accept license terms, or 'skyview-data import %s' for interactive acceptance", sourceName)
@ -446,7 +446,7 @@ func cmdUpdate(db *database.Database, sources []string, force bool) error {
}
log.Printf("Loading %s...", matchedSource.Name)
result, err := loader.LoadDataSource(*matchedSource)
if err != nil {
log.Printf("Failed to load %s: %v", matchedSource.Name, err)
@ -454,11 +454,13 @@ func cmdUpdate(db *database.Database, sources []string, force bool) error {
}
log.Printf("Loaded %s: %d records in %v", matchedSource.Name, result.RecordsNew, result.Duration)
if len(result.Errors) > 0 {
log.Printf(" %d errors occurred during import (first few):", len(result.Errors))
for i, errMsg := range result.Errors {
if i >= 3 { break }
if i >= 3 {
break
}
log.Printf(" %s", errMsg)
}
}
@ -471,7 +473,7 @@ func cmdUpdate(db *database.Database, sources []string, force bool) error {
// cmdImport imports data from a specific source with interactive license acceptance
func cmdImport(db *database.Database, sourceName string, force bool) error {
availableSources := database.GetAvailableDataSources()
var matchedSource *database.DataSource
for _, available := range availableSources {
if strings.EqualFold(strings.ReplaceAll(available.Name, " ", ""), sourceName) {
@ -479,7 +481,7 @@ func cmdImport(db *database.Database, sourceName string, force bool) error {
break
}
}
if matchedSource == nil {
return fmt.Errorf("unknown data source: %s", sourceName)
}
@ -491,17 +493,17 @@ func cmdImport(db *database.Database, sourceName string, force bool) error {
fmt.Printf(" URL: %s\n", matchedSource.URL)
fmt.Println()
fmt.Printf("By importing this data, you agree to comply with the %s license terms.\n", matchedSource.License)
if !askForConfirmation("Do you accept the license terms?") {
fmt.Println("Import cancelled.")
return nil
}
matchedSource.UserAcceptedLicense = true
}
fmt.Printf("📥 Importing %s...\n", matchedSource.Name)
loader := database.NewDataLoader(db.GetConnection())
result, err := loader.LoadDataSource(*matchedSource)
if err != nil {
@ -550,10 +552,10 @@ func cmdReset(db *database.Database, force bool) error {
// askForConfirmation asks the user for yes/no confirmation
func askForConfirmation(question string) bool {
fmt.Printf("%s (y/N): ", question)
var response string
fmt.Scanln(&response)
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
}
@ -564,30 +566,30 @@ func loadConfig(configPath string) (*Config, error) {
if err != nil {
return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err)
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
}
return &config, nil
}
// initDatabaseFromConfig initializes database using shared configuration
func initDatabaseFromConfig(config *Config, dbPathOverride string) (*database.Database, error) {
var dbConfig *database.Config
if config.Database != nil {
dbConfig = config.Database
} else {
dbConfig = database.DefaultConfig()
}
// Allow command-line override of database path
if dbPathOverride != "" {
dbConfig.Path = dbPathOverride
}
// Resolve database path if empty
if dbConfig.Path == "" {
resolvedPath, err := database.ResolveDatabasePath(dbConfig.Path)
@ -596,18 +598,18 @@ func initDatabaseFromConfig(config *Config, dbPathOverride string) (*database.Da
}
dbConfig.Path = resolvedPath
}
// Create and initialize database
db, err := database.NewDatabase(dbConfig)
if err != nil {
return nil, fmt.Errorf("failed to create database: %w", err)
}
if err := db.Initialize(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
return db, nil
}
@ -615,23 +617,23 @@ func initDatabaseFromConfig(config *Config, dbPathOverride string) (*database.Da
func cmdOptimize(db *database.Database, force bool) error {
fmt.Println("Database Storage Optimization")
fmt.Println("============================")
// We need to get the database path from the config
// For now, let's create a simple optimization manager
config := &database.Config{
Path: "./dev-skyview.db", // Default path - this should be configurable
}
// Create optimization manager
optimizer := database.NewOptimizationManager(db, config)
// Get current stats
fmt.Println("📊 Current Database Statistics:")
stats, err := optimizer.GetOptimizationStats()
if err != nil {
return fmt.Errorf("failed to get database stats: %w", err)
}
fmt.Printf(" • Size: %.1f MB\n", float64(stats.DatabaseSize)/(1024*1024))
fmt.Printf(" • Page Size: %d bytes\n", stats.PageSize)
fmt.Printf(" • Total Pages: %d\n", stats.PageCount)
@ -639,48 +641,47 @@ func cmdOptimize(db *database.Database, force bool) error {
fmt.Printf(" • Free Pages: %d\n", stats.FreePages)
fmt.Printf(" • Efficiency: %.1f%%\n", stats.Efficiency)
fmt.Printf(" • Auto VACUUM: %v\n", stats.AutoVacuumEnabled)
// Check if optimization is needed
needsOptimization := stats.FreePages > 0 || stats.Efficiency < 95.0
if !needsOptimization && !force {
fmt.Println("✅ Database is already well optimized!")
fmt.Println(" Use --force to run optimization anyway")
return nil
}
// Perform optimizations
if force && !needsOptimization {
fmt.Println("\n🔧 Force optimization requested:")
} else {
fmt.Println("\n🔧 Applying Optimizations:")
}
if err := optimizer.VacuumDatabase(); err != nil {
return fmt.Errorf("VACUUM failed: %w", err)
}
if err := optimizer.OptimizeDatabase(); err != nil {
return fmt.Errorf("optimization failed: %w", err)
}
// Show final stats
fmt.Println("\n📈 Final Statistics:")
finalStats, err := optimizer.GetOptimizationStats()
if err != nil {
return fmt.Errorf("failed to get final stats: %w", err)
}
fmt.Printf(" • Size: %.1f MB\n", float64(finalStats.DatabaseSize)/(1024*1024))
fmt.Printf(" • Efficiency: %.1f%%\n", finalStats.Efficiency)
fmt.Printf(" • Free Pages: %d\n", finalStats.FreePages)
if stats.DatabaseSize > finalStats.DatabaseSize {
saved := stats.DatabaseSize - finalStats.DatabaseSize
fmt.Printf(" • Space Saved: %.1f MB\n", float64(saved)/(1024*1024))
}
fmt.Println("\n✅ Database optimization completed!")
return nil
}

View file

@ -13,12 +13,12 @@ import (
type ExternalAPIClient struct {
httpClient *http.Client
mutex sync.RWMutex
// Configuration
timeout time.Duration
maxRetries int
userAgent string
// Rate limiting
lastRequest time.Time
minInterval time.Duration
@ -32,28 +32,28 @@ type APIClientConfig struct {
}
type OpenSkyFlightInfo struct {
ICAO string `json:"icao"`
Callsign string `json:"callsign"`
Origin string `json:"origin"`
Destination string `json:"destination"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
AircraftType string `json:"aircraft_type"`
Registration string `json:"registration"`
FlightNumber string `json:"flight_number"`
Airline string `json:"airline"`
ICAO string `json:"icao"`
Callsign string `json:"callsign"`
Origin string `json:"origin"`
Destination string `json:"destination"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
AircraftType string `json:"aircraft_type"`
Registration string `json:"registration"`
FlightNumber string `json:"flight_number"`
Airline string `json:"airline"`
}
type APIError struct {
Operation string
StatusCode int
Message string
Retryable bool
RetryAfter time.Duration
Operation string
StatusCode int
Message string
Retryable bool
RetryAfter time.Duration
}
func (e *APIError) Error() string {
return fmt.Sprintf("API error in %s: %s (status: %d, retryable: %v)",
return fmt.Sprintf("API error in %s: %s (status: %d, retryable: %v)",
e.Operation, e.Message, e.StatusCode, e.Retryable)
}
@ -70,7 +70,7 @@ func NewExternalAPIClient(config APIClientConfig) *ExternalAPIClient {
if config.MinInterval == 0 {
config.MinInterval = 1 * time.Second // Default rate limit
}
return &ExternalAPIClient{
httpClient: &http.Client{
Timeout: config.Timeout,
@ -85,7 +85,7 @@ func NewExternalAPIClient(config APIClientConfig) *ExternalAPIClient {
func (c *ExternalAPIClient) enforceRateLimit() {
c.mutex.Lock()
defer c.mutex.Unlock()
elapsed := time.Since(c.lastRequest)
if elapsed < c.minInterval {
time.Sleep(c.minInterval - elapsed)
@ -95,18 +95,18 @@ func (c *ExternalAPIClient) enforceRateLimit() {
func (c *ExternalAPIClient) makeRequest(ctx context.Context, url string) (*http.Response, error) {
c.enforceRateLimit()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json")
var resp *http.Response
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff
@ -117,16 +117,16 @@ func (c *ExternalAPIClient) makeRequest(ctx context.Context, url string) (*http.
case <-time.After(backoff):
}
}
resp, lastErr = c.httpClient.Do(req)
if lastErr != nil {
continue
}
// Check for retryable status codes
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
resp.Body.Close()
// Handle rate limiting
if resp.StatusCode == 429 {
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
@ -140,15 +140,15 @@ func (c *ExternalAPIClient) makeRequest(ctx context.Context, url string) (*http.
}
continue
}
// Success or non-retryable error
break
}
if lastErr != nil {
return nil, lastErr
}
return resp, nil
}
@ -156,14 +156,14 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
if icao == "" {
return nil, fmt.Errorf("empty ICAO code")
}
// OpenSky Network API endpoint for flight information
apiURL := fmt.Sprintf("https://opensky-network.org/api/flights/aircraft?icao24=%s&begin=%d&end=%d",
icao,
time.Now().Add(-24*time.Hour).Unix(),
time.Now().Unix(),
)
resp, err := c.makeRequest(ctx, apiURL)
if err != nil {
return nil, &APIError{
@ -173,7 +173,7 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, &APIError{
@ -183,7 +183,7 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
Retryable: resp.StatusCode >= 500 || resp.StatusCode == 429,
}
}
var flights [][]interface{}
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(&flights); err != nil {
@ -193,11 +193,11 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
Retryable: false,
}
}
if len(flights) == 0 {
return nil, nil // No flight information available
}
// Parse the most recent flight
flight := flights[0]
if len(flight) < 10 {
@ -207,11 +207,11 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
Retryable: false,
}
}
info := &OpenSkyFlightInfo{
ICAO: icao,
}
// Parse fields based on OpenSky API documentation
if callsign, ok := flight[1].(string); ok {
info.Callsign = callsign
@ -228,7 +228,7 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
if destination, ok := flight[5].(string); ok {
info.Destination = destination
}
return info, nil
}
@ -236,10 +236,10 @@ func (c *ExternalAPIClient) GetAircraftInfoFromOpenSky(ctx context.Context, icao
if icao == "" {
return nil, fmt.Errorf("empty ICAO code")
}
// OpenSky Network metadata API
apiURL := fmt.Sprintf("https://opensky-network.org/api/metadata/aircraft/icao/%s", icao)
resp, err := c.makeRequest(ctx, apiURL)
if err != nil {
return nil, &APIError{
@ -249,11 +249,11 @@ func (c *ExternalAPIClient) GetAircraftInfoFromOpenSky(ctx context.Context, icao
}
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil // Aircraft not found
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, &APIError{
@ -263,7 +263,7 @@ func (c *ExternalAPIClient) GetAircraftInfoFromOpenSky(ctx context.Context, icao
Retryable: resp.StatusCode >= 500 || resp.StatusCode == 429,
}
}
var aircraft map[string]interface{}
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(&aircraft); err != nil {
@ -273,7 +273,7 @@ func (c *ExternalAPIClient) GetAircraftInfoFromOpenSky(ctx context.Context, icao
Retryable: false,
}
}
return aircraft, nil
}
@ -282,7 +282,7 @@ func (c *ExternalAPIClient) EnhanceCallsignWithExternalData(ctx context.Context,
enhancement["callsign"] = callsign
enhancement["icao"] = icao
enhancement["enhanced"] = false
// Try to get flight information from OpenSky
if flightInfo, err := c.GetFlightInfoFromOpenSky(ctx, icao); err == nil && flightInfo != nil {
enhancement["flight_info"] = map[string]interface{}{
@ -295,53 +295,53 @@ func (c *ExternalAPIClient) EnhanceCallsignWithExternalData(ctx context.Context,
}
enhancement["enhanced"] = true
}
// Try to get aircraft metadata
if aircraftInfo, err := c.GetAircraftInfoFromOpenSky(ctx, icao); err == nil && aircraftInfo != nil {
enhancement["aircraft_info"] = aircraftInfo
enhancement["enhanced"] = true
}
return enhancement, nil
}
func (c *ExternalAPIClient) BatchEnhanceCallsigns(ctx context.Context, callsigns map[string]string) (map[string]map[string]interface{}, error) {
results := make(map[string]map[string]interface{})
for callsign, icao := range callsigns {
select {
case <-ctx.Done():
return results, ctx.Err()
default:
}
enhanced, err := c.EnhanceCallsignWithExternalData(ctx, callsign, icao)
if err != nil {
// Log error but continue with other callsigns
fmt.Printf("Warning: failed to enhance callsign %s (ICAO: %s): %v\n", callsign, icao, err)
continue
}
results[callsign] = enhanced
}
return results, nil
}
func (c *ExternalAPIClient) TestConnection(ctx context.Context) error {
// Test with a simple API call
testURL := "https://opensky-network.org/api/states?time=0&lamin=0&lomin=0&lamax=1&lomax=1"
resp, err := c.makeRequest(ctx, testURL)
if err != nil {
return fmt.Errorf("connection test failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("connection test returned status %d", resp.StatusCode)
}
return nil
}
@ -349,24 +349,24 @@ func parseRetryAfter(header string) time.Duration {
if header == "" {
return 0
}
// Try parsing as seconds
if seconds, err := time.ParseDuration(header + "s"); err == nil {
return seconds
}
// Try parsing as HTTP date
if t, err := http.ParseTime(header); err == nil {
return time.Until(t)
}
return 0
}
// HealthCheck provides information about the client's health
func (c *ExternalAPIClient) HealthCheck(ctx context.Context) map[string]interface{} {
health := make(map[string]interface{})
// Test connection
if err := c.TestConnection(ctx); err != nil {
health["status"] = "unhealthy"
@ -374,16 +374,16 @@ func (c *ExternalAPIClient) HealthCheck(ctx context.Context) map[string]interfac
} else {
health["status"] = "healthy"
}
// Add configuration info
health["timeout"] = c.timeout.String()
health["max_retries"] = c.maxRetries
health["min_interval"] = c.minInterval.String()
health["user_agent"] = c.userAgent
c.mutex.RLock()
health["last_request"] = c.lastRequest
c.mutex.RUnlock()
return health
}
}

View file

@ -19,35 +19,35 @@ import (
// Database represents the main database connection and operations
type Database struct {
conn *sql.DB
config *Config
migrator *Migrator
callsign *CallsignManager
history *HistoryManager
conn *sql.DB
config *Config
migrator *Migrator
callsign *CallsignManager
history *HistoryManager
}
// Config holds database configuration options
type Config struct {
// Database file path (auto-resolved if empty)
Path string `json:"path"`
// Data retention settings
MaxHistoryDays int `json:"max_history_days"` // 0 = unlimited
MaxHistoryDays int `json:"max_history_days"` // 0 = unlimited
BackupOnUpgrade bool `json:"backup_on_upgrade"`
// Connection settings
MaxOpenConns int `json:"max_open_conns"` // Default: 10
MaxIdleConns int `json:"max_idle_conns"` // Default: 5
ConnMaxLifetime time.Duration `json:"conn_max_lifetime"` // Default: 1 hour
// Maintenance settings
VacuumInterval time.Duration `json:"vacuum_interval"` // Default: 24 hours
VacuumInterval time.Duration `json:"vacuum_interval"` // Default: 24 hours
CleanupInterval time.Duration `json:"cleanup_interval"` // Default: 1 hour
// Compression settings
EnableCompression bool `json:"enable_compression"` // Enable automatic compression
CompressionLevel int `json:"compression_level"` // Compression level (1-9, default: 6)
PageSize int `json:"page_size"` // SQLite page size (default: 4096)
EnableCompression bool `json:"enable_compression"` // Enable automatic compression
CompressionLevel int `json:"compression_level"` // Compression level (1-9, default: 6)
PageSize int `json:"page_size"` // SQLite page size (default: 4096)
}
// AircraftHistoryRecord represents a stored aircraft position update
@ -93,18 +93,18 @@ type AirlineRecord struct {
// AirportRecord represents embedded airport data from OpenFlights
type AirportRecord struct {
ID int `json:"id"`
Name string `json:"name"`
City string `json:"city"`
Country string `json:"country"`
IATA string `json:"iata"`
ICAO string `json:"icao"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude int `json:"altitude"`
ID int `json:"id"`
Name string `json:"name"`
City string `json:"city"`
Country string `json:"country"`
IATA string `json:"iata"`
ICAO string `json:"icao"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude int `json:"altitude"`
TimezoneOffset float64 `json:"timezone_offset"`
DST string `json:"dst"`
Timezone string `json:"timezone"`
DST string `json:"dst"`
Timezone string `json:"timezone"`
}
// DatabaseError represents database operation errors
@ -131,7 +131,7 @@ func NewDatabase(config *Config) (*Database, error) {
if config == nil {
config = DefaultConfig()
}
// Resolve database path
dbPath, err := ResolveDatabasePath(config.Path)
if err != nil {
@ -142,7 +142,7 @@ func NewDatabase(config *Config) (*Database, error) {
}
}
config.Path = dbPath
// Open database connection
conn, err := sql.Open("sqlite3", buildConnectionString(dbPath))
if err != nil {
@ -152,12 +152,12 @@ func NewDatabase(config *Config) (*Database, error) {
Retryable: true,
}
}
// Configure connection pool
conn.SetMaxOpenConns(config.MaxOpenConns)
conn.SetMaxIdleConns(config.MaxIdleConns)
conn.SetConnMaxLifetime(config.ConnMaxLifetime)
// Test connection
if err := conn.Ping(); err != nil {
conn.Close()
@ -167,17 +167,17 @@ func NewDatabase(config *Config) (*Database, error) {
Retryable: true,
}
}
db := &Database{
conn: conn,
config: config,
}
// Initialize components
db.migrator = NewMigrator(conn)
db.callsign = NewCallsignManager(conn)
db.history = NewHistoryManager(conn, config.MaxHistoryDays)
return db, nil
}
@ -191,7 +191,7 @@ func (db *Database) Initialize() error {
Retryable: false,
}
}
// Load embedded OpenFlights data if not already loaded
if err := db.callsign.LoadEmbeddedData(); err != nil {
return &DatabaseError{
@ -200,7 +200,7 @@ func (db *Database) Initialize() error {
Retryable: false,
}
}
return nil
}
@ -240,7 +240,6 @@ func (db *Database) Health() error {
return db.conn.Ping()
}
// DefaultConfig returns the default database configuration
func DefaultConfig() *Config {
return &Config{
@ -258,4 +257,4 @@ func DefaultConfig() *Config {
// buildConnectionString creates SQLite connection string with optimizations
func buildConnectionString(path string) string {
return fmt.Sprintf("%s?_journal_mode=WAL&_synchronous=NORMAL&_cache_size=-64000&_temp_store=MEMORY&_foreign_keys=ON", path)
}
}

View file

@ -164,4 +164,4 @@ func TestDatabasePragmas(t *testing.T) {
if journalMode != "wal" {
t.Errorf("Expected WAL journal mode, got: %s", journalMode)
}
}
}

View file

@ -37,32 +37,32 @@ type DataSource struct {
// LoadResult contains the results of a data loading operation
type LoadResult struct {
Source string `json:"source"`
RecordsTotal int `json:"records_total"`
RecordsNew int `json:"records_new"`
RecordsError int `json:"records_error"`
Source string `json:"source"`
RecordsTotal int `json:"records_total"`
RecordsNew int `json:"records_new"`
RecordsError int `json:"records_error"`
Duration time.Duration `json:"duration"`
Errors []string `json:"errors,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// NewDataLoader creates a new data loader with HTTP client
func NewDataLoader(conn *sql.DB) *DataLoader {
// Check for insecure TLS environment variable
insecureTLS := os.Getenv("SKYVIEW_INSECURE_TLS") == "1"
transport := &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
DisableCompression: false,
}
// Allow insecure certificates if requested
if insecureTLS {
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
return &DataLoader{
conn: conn,
client: &http.Client{
@ -85,7 +85,7 @@ func GetAvailableDataSources() []DataSource {
},
{
Name: "OpenFlights Airports",
License: "AGPL-3.0",
License: "AGPL-3.0",
URL: "https://raw.githubusercontent.com/jpatokal/openflights/master/data/airports.dat",
RequiresConsent: false, // Runtime data consumption doesn't require explicit consent
Format: "openflights",
@ -111,23 +111,23 @@ func (dl *DataLoader) LoadDataSource(source DataSource) (*LoadResult, error) {
defer func() {
result.Duration = time.Since(startTime)
}()
// Check license acceptance if required
if source.RequiresConsent && !source.UserAcceptedLicense {
return nil, fmt.Errorf("user has not accepted license for source: %s (%s)", source.Name, source.License)
}
// Download data
resp, err := dl.client.Get(source.URL)
if err != nil {
return nil, fmt.Errorf("failed to download data from %s: %v", source.URL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP error downloading data: %s", resp.Status)
}
// Parse and load data based on format
switch source.Format {
case "openflights":
@ -137,10 +137,10 @@ func (dl *DataLoader) LoadDataSource(source DataSource) (*LoadResult, error) {
return dl.loadOpenFlightsAirports(resp.Body, source, result)
}
return nil, fmt.Errorf("unknown OpenFlights data type: %s", source.Name)
case "ourairports":
return dl.loadOurAirports(resp.Body, source, result)
default:
return nil, fmt.Errorf("unsupported data format: %s", source.Format)
}
@ -153,21 +153,21 @@ func (dl *DataLoader) loadOpenFlightsAirlines(reader io.Reader, source DataSourc
return nil, fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Record data source
if err := dl.recordDataSource(tx, source); err != nil {
return nil, err
}
// Clear existing data from this source
_, err = tx.Exec(`DELETE FROM airlines WHERE data_source = ?`, source.Name)
if err != nil {
return nil, fmt.Errorf("failed to clear existing airline data: %v", err)
}
csvReader := csv.NewReader(reader)
csvReader.FieldsPerRecord = -1 // Variable number of fields
insertStmt, err := tx.Prepare(`
INSERT OR REPLACE INTO airlines (id, name, alias, iata_code, icao_code, callsign, country, active, data_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@ -176,7 +176,7 @@ func (dl *DataLoader) loadOpenFlightsAirlines(reader io.Reader, source DataSourc
return nil, fmt.Errorf("failed to prepare insert statement: %v", err)
}
defer insertStmt.Close()
for {
record, err := csvReader.Read()
if err == io.EOF {
@ -187,15 +187,15 @@ func (dl *DataLoader) loadOpenFlightsAirlines(reader io.Reader, source DataSourc
result.Errors = append(result.Errors, fmt.Sprintf("CSV parse error: %v", err))
continue
}
if len(record) < 7 {
result.RecordsError++
result.Errors = append(result.Errors, "insufficient fields in record")
continue
}
result.RecordsTotal++
// Parse OpenFlights airline format:
// ID, Name, Alias, IATA, ICAO, Callsign, Country, Active
id, _ := strconv.Atoi(record[0])
@ -206,29 +206,37 @@ func (dl *DataLoader) loadOpenFlightsAirlines(reader io.Reader, source DataSourc
callsign := strings.Trim(record[5], `"`)
country := strings.Trim(record[6], `"`)
active := len(record) > 7 && strings.Trim(record[7], `"`) == "Y"
// Convert \N to empty strings
if alias == "\\N" { alias = "" }
if iata == "\\N" { iata = "" }
if icao == "\\N" { icao = "" }
if callsign == "\\N" { callsign = "" }
if alias == "\\N" {
alias = ""
}
if iata == "\\N" {
iata = ""
}
if icao == "\\N" {
icao = ""
}
if callsign == "\\N" {
callsign = ""
}
_, err = insertStmt.Exec(id, name, alias, iata, icao, callsign, country, active, source.Name)
if err != nil {
result.RecordsError++
result.Errors = append(result.Errors, fmt.Sprintf("insert error for airline %s: %v", name, err))
continue
}
result.RecordsNew++
}
// Update record count
_, err = tx.Exec(`UPDATE data_sources SET record_count = ? WHERE name = ?`, result.RecordsNew, source.Name)
if err != nil {
return nil, fmt.Errorf("failed to update record count: %v", err)
}
return result, tx.Commit()
}
@ -239,21 +247,21 @@ func (dl *DataLoader) loadOpenFlightsAirports(reader io.Reader, source DataSourc
return nil, fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Record data source
if err := dl.recordDataSource(tx, source); err != nil {
return nil, err
}
// Clear existing data from this source
_, err = tx.Exec(`DELETE FROM airports WHERE data_source = ?`, source.Name)
if err != nil {
return nil, fmt.Errorf("failed to clear existing airport data: %v", err)
}
csvReader := csv.NewReader(reader)
csvReader.FieldsPerRecord = -1
insertStmt, err := tx.Prepare(`
INSERT OR REPLACE INTO airports (id, name, city, country, iata_code, icao_code, latitude, longitude,
elevation_ft, timezone_offset, dst_type, timezone, data_source)
@ -263,7 +271,7 @@ func (dl *DataLoader) loadOpenFlightsAirports(reader io.Reader, source DataSourc
return nil, fmt.Errorf("failed to prepare insert statement: %v", err)
}
defer insertStmt.Close()
for {
record, err := csvReader.Read()
if err == io.EOF {
@ -274,15 +282,15 @@ func (dl *DataLoader) loadOpenFlightsAirports(reader io.Reader, source DataSourc
result.Errors = append(result.Errors, fmt.Sprintf("CSV parse error: %v", err))
continue
}
if len(record) < 12 {
result.RecordsError++
result.Errors = append(result.Errors, "insufficient fields in airport record")
continue
}
result.RecordsTotal++
// Parse OpenFlights airport format
id, _ := strconv.Atoi(record[0])
name := strings.Trim(record[1], `"`)
@ -296,29 +304,37 @@ func (dl *DataLoader) loadOpenFlightsAirports(reader io.Reader, source DataSourc
tzOffset, _ := strconv.ParseFloat(record[9], 64)
dst := strings.Trim(record[10], `"`)
timezone := strings.Trim(record[11], `"`)
// Convert \N to empty strings
if iata == "\\N" { iata = "" }
if icao == "\\N" { icao = "" }
if dst == "\\N" { dst = "" }
if timezone == "\\N" { timezone = "" }
if iata == "\\N" {
iata = ""
}
if icao == "\\N" {
icao = ""
}
if dst == "\\N" {
dst = ""
}
if timezone == "\\N" {
timezone = ""
}
_, err = insertStmt.Exec(id, name, city, country, iata, icao, lat, lon, alt, tzOffset, dst, timezone, source.Name)
if err != nil {
result.RecordsError++
result.Errors = append(result.Errors, fmt.Sprintf("insert error for airport %s: %v", name, err))
continue
}
result.RecordsNew++
}
// Update record count
_, err = tx.Exec(`UPDATE data_sources SET record_count = ? WHERE name = ?`, result.RecordsNew, source.Name)
if err != nil {
return nil, fmt.Errorf("failed to update record count: %v", err)
}
return result, tx.Commit()
}
@ -330,9 +346,9 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
return nil, fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
csvReader := csv.NewReader(reader)
// Read header row
headers, err := csvReader.Read()
if err != nil {
@ -340,13 +356,13 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
result.Errors = []string{fmt.Sprintf("Failed to read CSV header: %v", err)}
return result, err
}
// Create header index map for easier field access
headerIndex := make(map[string]int)
for i, header := range headers {
headerIndex[strings.TrimSpace(header)] = i
}
// Prepare statement for airports
stmt, err := tx.Prepare(`
INSERT OR REPLACE INTO airports (
@ -362,7 +378,7 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
return result, err
}
defer stmt.Close()
// Process each row
for {
record, err := csvReader.Read()
@ -374,13 +390,13 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
result.Errors = append(result.Errors, fmt.Sprintf("CSV read error: %v", err))
continue
}
// Skip rows with insufficient fields
if len(record) < len(headerIndex) {
result.RecordsError++
continue
}
// Extract fields using header index
sourceID := getFieldByHeader(record, headerIndex, "id")
ident := getFieldByHeader(record, headerIndex, "ident")
@ -394,7 +410,7 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
homeLink := getFieldByHeader(record, headerIndex, "home_link")
wikipediaLink := getFieldByHeader(record, headerIndex, "wikipedia_link")
keywords := getFieldByHeader(record, headerIndex, "keywords")
// Parse coordinates
var latitude, longitude float64
if latStr := getFieldByHeader(record, headerIndex, "latitude_deg"); latStr != "" {
@ -407,7 +423,7 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
longitude = lng
}
}
// Parse elevation
var elevation int
if elevStr := getFieldByHeader(record, headerIndex, "elevation_ft"); elevStr != "" {
@ -415,10 +431,10 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
elevation = elev
}
}
// Parse scheduled service
scheduledService := getFieldByHeader(record, headerIndex, "scheduled_service") == "yes"
// Insert airport record
_, err = stmt.Exec(
sourceID, name, ident, airportType, icaoCode, iataCode,
@ -432,7 +448,7 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
result.RecordsNew++
}
}
// Update data source tracking
_, err = tx.Exec(`
INSERT OR REPLACE INTO data_sources (name, license, url, imported_at, record_count, user_accepted_license)
@ -441,7 +457,7 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
if err != nil {
return result, fmt.Errorf("failed to update data source tracking: %v", err)
}
return result, tx.Commit()
}
@ -460,13 +476,13 @@ func (dl *DataLoader) GetLoadedDataSources() ([]DataSource, error) {
FROM data_sources
ORDER BY name
`
rows, err := dl.conn.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var sources []DataSource
for rows.Next() {
var source DataSource
@ -482,7 +498,7 @@ func (dl *DataLoader) GetLoadedDataSources() ([]DataSource, error) {
}
sources = append(sources, source)
}
return sources, rows.Err()
}
@ -493,11 +509,10 @@ func (dl *DataLoader) recordDataSource(tx *sql.Tx, source DataSource) error {
(name, license, url, version, user_accepted_license)
VALUES (?, ?, ?, ?, ?)
`, source.Name, source.License, source.URL, source.Version, source.UserAcceptedLicense)
return err
}
// ClearDataSource removes all data from a specific source
func (dl *DataLoader) ClearDataSource(sourceName string) error {
tx, err := dl.conn.Begin()
@ -505,22 +520,22 @@ func (dl *DataLoader) ClearDataSource(sourceName string) error {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Clear from all tables
_, err = tx.Exec(`DELETE FROM airlines WHERE data_source = ?`, sourceName)
if err != nil {
return fmt.Errorf("failed to clear airlines: %v", err)
}
_, err = tx.Exec(`DELETE FROM airports WHERE data_source = ?`, sourceName)
if err != nil {
return fmt.Errorf("failed to clear airports: %v", err)
}
_, err = tx.Exec(`DELETE FROM data_sources WHERE name = ?`, sourceName)
if err != nil {
return fmt.Errorf("failed to clear data source record: %v", err)
}
return tx.Commit()
}
}

View file

@ -33,9 +33,9 @@ func TestDataLoader_LoadOpenFlightsAirlines(t *testing.T) {
result, err := loader.LoadDataSource(source)
if err != nil {
// Network issues in tests are acceptable
if strings.Contains(err.Error(), "connection") ||
strings.Contains(err.Error(), "timeout") ||
strings.Contains(err.Error(), "no such host") {
if strings.Contains(err.Error(), "connection") ||
strings.Contains(err.Error(), "timeout") ||
strings.Contains(err.Error(), "no such host") {
t.Skipf("Skipping network test due to connectivity issue: %v", err)
}
t.Fatal("LoadDataSource failed:", err)
@ -45,7 +45,7 @@ func TestDataLoader_LoadOpenFlightsAirlines(t *testing.T) {
t.Fatal("Expected load result, got nil")
}
t.Logf("Loaded airlines: Total=%d, New=%d, Errors=%d, Duration=%v",
t.Logf("Loaded airlines: Total=%d, New=%d, Errors=%d, Duration=%v",
result.RecordsTotal, result.RecordsNew, result.RecordsError, result.Duration)
// Verify some data was processed
@ -72,16 +72,16 @@ func TestDataLoader_LoadOurAirports(t *testing.T) {
result, err := loader.LoadDataSource(source)
if err != nil {
// Network issues in tests are acceptable
if strings.Contains(err.Error(), "connection") ||
strings.Contains(err.Error(), "timeout") ||
strings.Contains(err.Error(), "no such host") {
if strings.Contains(err.Error(), "connection") ||
strings.Contains(err.Error(), "timeout") ||
strings.Contains(err.Error(), "no such host") {
t.Skipf("Skipping network test due to connectivity issue: %v", err)
}
t.Fatal("LoadDataSource failed:", err)
}
if result != nil {
t.Logf("Loaded airports: Total=%d, New=%d, Errors=%d, Duration=%v",
t.Logf("Loaded airports: Total=%d, New=%d, Errors=%d, Duration=%v",
result.RecordsTotal, result.RecordsNew, result.RecordsError, result.Duration)
}
}
@ -174,4 +174,4 @@ func TestLoadResult_Struct(t *testing.T) {
if len(result.Errors) != 2 {
t.Error("Errors field not preserved")
}
}
}

View file

@ -12,7 +12,7 @@ import (
type CallsignManager struct {
db *sql.DB
mutex sync.RWMutex
// Compiled regex patterns for callsign parsing
airlinePattern *regexp.Regexp
flightPattern *regexp.Regexp
@ -42,14 +42,14 @@ func (cm *CallsignManager) ParseCallsign(callsign string) *CallsignParseResult {
ParsedTime: time.Now(),
IsValid: false,
}
if callsign == "" {
return result
}
// Clean and normalize the callsign
normalized := strings.TrimSpace(strings.ToUpper(callsign))
// Try airline pattern first (most common for commercial flights)
if matches := cm.airlinePattern.FindStringSubmatch(normalized); len(matches) == 3 {
result.AirlineCode = matches[1]
@ -57,7 +57,7 @@ func (cm *CallsignManager) ParseCallsign(callsign string) *CallsignParseResult {
result.IsValid = true
return result
}
// Fall back to general flight pattern
if matches := cm.flightPattern.FindStringSubmatch(normalized); len(matches) == 3 {
result.AirlineCode = matches[1]
@ -65,24 +65,24 @@ func (cm *CallsignManager) ParseCallsign(callsign string) *CallsignParseResult {
result.IsValid = true
return result
}
return result
}
func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, error) {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
if callsign == "" {
return nil, fmt.Errorf("empty callsign")
}
// First check the cache
cached, err := cm.getCallsignFromCache(callsign)
if err == nil && cached != nil {
return cached, nil
}
// Parse the callsign
parsed := cm.ParseCallsign(callsign)
if !parsed.IsValid {
@ -91,13 +91,13 @@ func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, erro
IsValid: false,
}, nil
}
// Look up airline information
airline, err := cm.getAirlineByCode(parsed.AirlineCode)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("failed to lookup airline %s: %w", parsed.AirlineCode, err)
}
// Build the result
info := &CallsignInfo{
OriginalCallsign: callsign,
@ -106,7 +106,7 @@ func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, erro
IsValid: true,
LastUpdated: time.Now(),
}
if airline != nil {
info.AirlineName = airline.Name
info.AirlineCountry = airline.Country
@ -114,7 +114,7 @@ func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, erro
} else {
info.DisplayName = fmt.Sprintf("%s %s", parsed.AirlineCode, parsed.FlightNumber)
}
// Cache the result (fire and forget)
go func() {
if err := cm.cacheCallsignInfo(info); err != nil {
@ -122,7 +122,7 @@ func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, erro
fmt.Printf("Warning: failed to cache callsign info for %s: %v\n", callsign, err)
}
}()
return info, nil
}
@ -133,10 +133,10 @@ func (cm *CallsignManager) getCallsignFromCache(callsign string) (*CallsignInfo,
FROM callsign_cache
WHERE callsign = ? AND expires_at > datetime('now')
`
var info CallsignInfo
var cacheExpires time.Time
err := cm.db.QueryRow(query, callsign).Scan(
&info.OriginalCallsign,
&info.AirlineCode,
@ -148,25 +148,25 @@ func (cm *CallsignManager) getCallsignFromCache(callsign string) (*CallsignInfo,
&info.LastUpdated,
&cacheExpires,
)
if err != nil {
return nil, err
}
return &info, nil
}
func (cm *CallsignManager) cacheCallsignInfo(info *CallsignInfo) error {
// Cache for 24 hours by default
cacheExpires := time.Now().Add(24 * time.Hour)
query := `
INSERT OR REPLACE INTO callsign_cache
(callsign, airline_icao, flight_number, airline_name,
airline_country, cached_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
_, err := cm.db.Exec(query,
info.OriginalCallsign,
info.AirlineCode,
@ -176,7 +176,7 @@ func (cm *CallsignManager) cacheCallsignInfo(info *CallsignInfo) error {
info.LastUpdated,
cacheExpires,
)
return err
}
@ -190,7 +190,7 @@ func (cm *CallsignManager) getAirlineByCode(code string) (*AirlineRecord, error)
name
LIMIT 1
`
var airline AirlineRecord
err := cm.db.QueryRow(query, code, code, code).Scan(
&airline.ICAOCode,
@ -199,31 +199,31 @@ func (cm *CallsignManager) getAirlineByCode(code string) (*AirlineRecord, error)
&airline.Country,
&airline.Active,
)
if err != nil {
return nil, err
}
return &airline, nil
}
func (cm *CallsignManager) GetAirlinesByCountry(country string) ([]AirlineRecord, error) {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
query := `
SELECT icao_code, iata_code, name, country, active
FROM airlines
WHERE country = ? AND active = 1
ORDER BY name
`
rows, err := cm.db.Query(query, country)
if err != nil {
return nil, err
}
defer rows.Close()
var airlines []AirlineRecord
for rows.Next() {
var airline AirlineRecord
@ -239,14 +239,14 @@ func (cm *CallsignManager) GetAirlinesByCountry(country string) ([]AirlineRecord
}
airlines = append(airlines, airline)
}
return airlines, rows.Err()
}
func (cm *CallsignManager) SearchAirlines(query string) ([]AirlineRecord, error) {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
searchQuery := `
SELECT icao_code, iata_code, name, country, active
FROM airlines
@ -265,11 +265,11 @@ func (cm *CallsignManager) SearchAirlines(query string) ([]AirlineRecord, error)
name
LIMIT 50
`
searchTerm := "%" + strings.ToUpper(query) + "%"
exactTerm := strings.ToUpper(query)
rows, err := cm.db.Query(searchQuery,
rows, err := cm.db.Query(searchQuery,
searchTerm, searchTerm, searchTerm, searchTerm,
exactTerm, exactTerm, exactTerm,
)
@ -277,7 +277,7 @@ func (cm *CallsignManager) SearchAirlines(query string) ([]AirlineRecord, error)
return nil, err
}
defer rows.Close()
var airlines []AirlineRecord
for rows.Next() {
var airline AirlineRecord
@ -293,14 +293,14 @@ func (cm *CallsignManager) SearchAirlines(query string) ([]AirlineRecord, error)
}
airlines = append(airlines, airline)
}
return airlines, rows.Err()
}
func (cm *CallsignManager) ClearExpiredCache() error {
cm.mutex.Lock()
defer cm.mutex.Unlock()
query := `DELETE FROM callsign_cache WHERE expires_at <= datetime('now')`
_, err := cm.db.Exec(query)
return err
@ -309,9 +309,9 @@ func (cm *CallsignManager) ClearExpiredCache() error {
func (cm *CallsignManager) GetCacheStats() (map[string]interface{}, error) {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
stats := make(map[string]interface{})
// Total cached entries
var totalCached int
err := cm.db.QueryRow(`SELECT COUNT(*) FROM callsign_cache`).Scan(&totalCached)
@ -319,7 +319,7 @@ func (cm *CallsignManager) GetCacheStats() (map[string]interface{}, error) {
return nil, err
}
stats["total_cached"] = totalCached
// Valid (non-expired) entries
var validCached int
err = cm.db.QueryRow(`SELECT COUNT(*) FROM callsign_cache WHERE expires_at > datetime('now')`).Scan(&validCached)
@ -327,10 +327,10 @@ func (cm *CallsignManager) GetCacheStats() (map[string]interface{}, error) {
return nil, err
}
stats["valid_cached"] = validCached
// Expired entries
stats["expired_cached"] = totalCached - validCached
// Total airlines in database
var totalAirlines int
err = cm.db.QueryRow(`SELECT COUNT(*) FROM airlines WHERE active = 1`).Scan(&totalAirlines)
@ -338,7 +338,7 @@ func (cm *CallsignManager) GetCacheStats() (map[string]interface{}, error) {
return nil, err
}
stats["total_airlines"] = totalAirlines
return stats, nil
}
@ -349,14 +349,14 @@ func (cm *CallsignManager) LoadEmbeddedData() error {
if err != nil {
return err
}
if count > 0 {
// Data already loaded
return nil
}
// For now, we'll implement this as a placeholder
// In a full implementation, this would load embedded airline data
// from embedded files or resources
return nil
}
}

View file

@ -21,15 +21,15 @@ func TestCallsignManager_ParseCallsign(t *testing.T) {
manager := NewCallsignManager(db.GetConnection())
testCases := []struct {
callsign string
expectedValid bool
expectedAirline string
expectedFlight string
callsign string
expectedValid bool
expectedAirline string
expectedFlight string
}{
{"UAL123", true, "UAL", "123"},
{"BA4567", true, "BA", "4567"},
{"AFR89", true, "AFR", "89"},
{"N123AB", false, "", ""}, // Aircraft registration, not callsign
{"N123AB", false, "", ""}, // Aircraft registration, not callsign
{"INVALID", false, "", ""}, // No numbers
{"123", false, "", ""}, // Only numbers
{"A", false, "", ""}, // Too short
@ -39,15 +39,15 @@ func TestCallsignManager_ParseCallsign(t *testing.T) {
for _, tc := range testCases {
result := manager.ParseCallsign(tc.callsign)
if result.IsValid != tc.expectedValid {
t.Errorf("ParseCallsign(%s): expected valid=%v, got %v",
t.Errorf("ParseCallsign(%s): expected valid=%v, got %v",
tc.callsign, tc.expectedValid, result.IsValid)
}
if result.IsValid && result.AirlineCode != tc.expectedAirline {
t.Errorf("ParseCallsign(%s): expected airline=%s, got %s",
t.Errorf("ParseCallsign(%s): expected airline=%s, got %s",
tc.callsign, tc.expectedAirline, result.AirlineCode)
}
if result.IsValid && result.FlightNumber != tc.expectedFlight {
t.Errorf("ParseCallsign(%s): expected flight=%s, got %s",
t.Errorf("ParseCallsign(%s): expected flight=%s, got %s",
tc.callsign, tc.expectedFlight, result.FlightNumber)
}
}
@ -101,7 +101,7 @@ func TestCallsignManager_GetCallsignInfo_InvalidCallsign(t *testing.T) {
defer cleanup()
manager := NewCallsignManager(db.GetConnection())
// Test with invalid callsign format
info, err := manager.GetCallsignInfo("INVALID")
if err != nil {
@ -129,7 +129,7 @@ func TestCallsignManager_GetCallsignInfo_EmptyCallsign(t *testing.T) {
defer cleanup()
manager := NewCallsignManager(db.GetConnection())
// Test with empty callsign
info, err := manager.GetCallsignInfo("")
if err == nil {
@ -162,7 +162,7 @@ func TestCallsignManager_GetCacheStats(t *testing.T) {
if err != nil {
t.Error("GetCacheStats should not error:", err)
}
if stats == nil {
t.Error("Expected cache stats, got nil")
}
@ -265,4 +265,4 @@ func TestCallsignParseResult_Struct(t *testing.T) {
if !result.IsValid {
t.Error("IsValid field not preserved")
}
}
}

View file

@ -10,7 +10,7 @@ import (
type HistoryManager struct {
db *sql.DB
mutex sync.RWMutex
// Configuration
maxHistoryDays int
cleanupTicker *time.Ticker
@ -23,11 +23,11 @@ func NewHistoryManager(db *sql.DB, maxHistoryDays int) *HistoryManager {
maxHistoryDays: maxHistoryDays,
stopCleanup: make(chan bool),
}
// Start periodic cleanup (every hour)
hm.cleanupTicker = time.NewTicker(1 * time.Hour)
go hm.periodicCleanup()
return hm
}
@ -56,14 +56,14 @@ func (hm *HistoryManager) periodicCleanup() {
func (hm *HistoryManager) RecordAircraft(record *AircraftHistoryRecord) error {
hm.mutex.Lock()
defer hm.mutex.Unlock()
query := `
INSERT INTO aircraft_history
(icao, callsign, squawk, latitude, longitude, altitude,
vertical_rate, speed, track, source_id, signal_strength, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := hm.db.Exec(query,
record.ICAO,
record.Callsign,
@ -78,7 +78,7 @@ func (hm *HistoryManager) RecordAircraft(record *AircraftHistoryRecord) error {
record.SignalStrength,
record.Timestamp,
)
return err
}
@ -86,16 +86,16 @@ func (hm *HistoryManager) RecordAircraftBatch(records []AircraftHistoryRecord) e
if len(records) == 0 {
return nil
}
hm.mutex.Lock()
defer hm.mutex.Unlock()
tx, err := hm.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO aircraft_history
(icao, callsign, squawk, latitude, longitude, altitude,
@ -106,7 +106,7 @@ func (hm *HistoryManager) RecordAircraftBatch(records []AircraftHistoryRecord) e
return err
}
defer stmt.Close()
for _, record := range records {
_, err := stmt.Exec(
record.ICAO,
@ -126,16 +126,16 @@ func (hm *HistoryManager) RecordAircraftBatch(records []AircraftHistoryRecord) e
return fmt.Errorf("failed to insert record for ICAO %s: %w", record.ICAO, err)
}
}
return tx.Commit()
}
func (hm *HistoryManager) GetAircraftHistory(icao string, hours int) ([]AircraftHistoryRecord, error) {
hm.mutex.RLock()
defer hm.mutex.RUnlock()
since := time.Now().Add(-time.Duration(hours) * time.Hour)
query := `
SELECT icao, callsign, squawk, latitude, longitude, altitude,
vertical_rate, speed, track, source_id, signal_strength, timestamp
@ -144,13 +144,13 @@ func (hm *HistoryManager) GetAircraftHistory(icao string, hours int) ([]Aircraft
ORDER BY timestamp DESC
LIMIT 1000
`
rows, err := hm.db.Query(query, icao, since)
if err != nil {
return nil, err
}
defer rows.Close()
var records []AircraftHistoryRecord
for rows.Next() {
var record AircraftHistoryRecord
@ -173,16 +173,16 @@ func (hm *HistoryManager) GetAircraftHistory(icao string, hours int) ([]Aircraft
}
records = append(records, record)
}
return records, rows.Err()
}
func (hm *HistoryManager) GetAircraftTrack(icao string, hours int) ([]TrackPoint, error) {
hm.mutex.RLock()
defer hm.mutex.RUnlock()
since := time.Now().Add(-time.Duration(hours) * time.Hour)
query := `
SELECT latitude, longitude, altitude, timestamp
FROM aircraft_history
@ -191,13 +191,13 @@ func (hm *HistoryManager) GetAircraftTrack(icao string, hours int) ([]TrackPoint
ORDER BY timestamp ASC
LIMIT 500
`
rows, err := hm.db.Query(query, icao, since)
if err != nil {
return nil, err
}
defer rows.Close()
var track []TrackPoint
for rows.Next() {
var point TrackPoint
@ -212,16 +212,16 @@ func (hm *HistoryManager) GetAircraftTrack(icao string, hours int) ([]TrackPoint
}
track = append(track, point)
}
return track, rows.Err()
}
func (hm *HistoryManager) GetRecentAircraft(hours int, limit int) ([]string, error) {
hm.mutex.RLock()
defer hm.mutex.RUnlock()
since := time.Now().Add(-time.Duration(hours) * time.Hour)
query := `
SELECT DISTINCT icao
FROM aircraft_history
@ -229,13 +229,13 @@ func (hm *HistoryManager) GetRecentAircraft(hours int, limit int) ([]string, err
ORDER BY MAX(timestamp) DESC
LIMIT ?
`
rows, err := hm.db.Query(query, since, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var icaos []string
for rows.Next() {
var icao string
@ -245,20 +245,20 @@ func (hm *HistoryManager) GetRecentAircraft(hours int, limit int) ([]string, err
}
icaos = append(icaos, icao)
}
return icaos, rows.Err()
}
func (hm *HistoryManager) GetAircraftLastSeen(icao string) (time.Time, error) {
hm.mutex.RLock()
defer hm.mutex.RUnlock()
query := `
SELECT MAX(timestamp)
FROM aircraft_history
WHERE icao = ?
`
var lastSeen time.Time
err := hm.db.QueryRow(query, icao).Scan(&lastSeen)
return lastSeen, err
@ -267,24 +267,24 @@ func (hm *HistoryManager) GetAircraftLastSeen(icao string) (time.Time, error) {
func (hm *HistoryManager) CleanupOldHistory() error {
hm.mutex.Lock()
defer hm.mutex.Unlock()
if hm.maxHistoryDays <= 0 {
return nil // No cleanup if maxHistoryDays is 0 or negative
}
cutoff := time.Now().AddDate(0, 0, -hm.maxHistoryDays)
query := `DELETE FROM aircraft_history WHERE timestamp < ?`
result, err := hm.db.Exec(query, cutoff)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err == nil && rowsAffected > 0 {
fmt.Printf("Cleaned up %d old aircraft history records\n", rowsAffected)
}
return nil
}
@ -295,9 +295,9 @@ func (hm *HistoryManager) GetStatistics() (map[string]interface{}, error) {
func (hm *HistoryManager) GetHistoryStats() (map[string]interface{}, error) {
hm.mutex.RLock()
defer hm.mutex.RUnlock()
stats := make(map[string]interface{})
// Total records
var totalRecords int
err := hm.db.QueryRow(`SELECT COUNT(*) FROM aircraft_history`).Scan(&totalRecords)
@ -305,7 +305,7 @@ func (hm *HistoryManager) GetHistoryStats() (map[string]interface{}, error) {
return nil, err
}
stats["total_records"] = totalRecords
// Unique aircraft
var uniqueAircraft int
err = hm.db.QueryRow(`SELECT COUNT(DISTINCT icao) FROM aircraft_history`).Scan(&uniqueAircraft)
@ -313,7 +313,7 @@ func (hm *HistoryManager) GetHistoryStats() (map[string]interface{}, error) {
return nil, err
}
stats["unique_aircraft"] = uniqueAircraft
// Recent records (last 24 hours)
var recentRecords int
since := time.Now().Add(-24 * time.Hour)
@ -322,7 +322,7 @@ func (hm *HistoryManager) GetHistoryStats() (map[string]interface{}, error) {
return nil, err
}
stats["recent_records_24h"] = recentRecords
// Oldest and newest record timestamps (only if records exist)
if totalRecords > 0 {
var oldestTimestamp, newestTimestamp time.Time
@ -333,18 +333,18 @@ func (hm *HistoryManager) GetHistoryStats() (map[string]interface{}, error) {
stats["history_days"] = int(time.Since(oldestTimestamp).Hours() / 24)
}
}
return stats, nil
}
func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{}, error) {
hm.mutex.RLock()
defer hm.mutex.RUnlock()
since := time.Now().Add(-time.Duration(hours) * time.Hour)
summary := make(map[string]interface{})
// Aircraft count in time period
var aircraftCount int
err := hm.db.QueryRow(`
@ -356,7 +356,7 @@ func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{},
return nil, err
}
summary["aircraft_count"] = aircraftCount
// Message count in time period
var messageCount int
err = hm.db.QueryRow(`
@ -368,7 +368,7 @@ func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{},
return nil, err
}
summary["message_count"] = messageCount
// Most active sources
query := `
SELECT source_id, COUNT(*) as count
@ -378,13 +378,13 @@ func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{},
ORDER BY count DESC
LIMIT 5
`
rows, err := hm.db.Query(query, since)
if err != nil {
return nil, err
}
defer rows.Close()
sources := make([]map[string]interface{}, 0)
for rows.Next() {
var sourceID string
@ -399,7 +399,7 @@ func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{},
})
}
summary["top_sources"] = sources
return summary, nil
}
@ -408,4 +408,4 @@ type TrackPoint struct {
Longitude float64 `json:"longitude"`
Altitude *int `json:"altitude,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
}

View file

@ -192,17 +192,17 @@ func GetMigrations() []Migration {
},
// Future migrations will be added here
}
// Calculate checksums
for i := range migrations {
migrations[i].Checksum = calculateChecksum(migrations[i].Up)
}
// Sort by version
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].Version < migrations[j].Version
})
return migrations
}
@ -212,19 +212,19 @@ func (m *Migrator) MigrateToLatest() error {
if err != nil {
return fmt.Errorf("failed to get current version: %v", err)
}
migrations := GetMigrations()
for _, migration := range migrations {
if migration.Version <= currentVersion {
continue
}
if err := m.applyMigration(migration); err != nil {
return fmt.Errorf("failed to apply migration %d: %v", migration.Version, err)
}
}
return nil
}
@ -234,20 +234,20 @@ func (m *Migrator) MigrateTo(targetVersion int) error {
if err != nil {
return fmt.Errorf("failed to get current version: %v", err)
}
if targetVersion == currentVersion {
return nil // Already at target version
}
migrations := GetMigrations()
if targetVersion > currentVersion {
// Forward migration
for _, migration := range migrations {
if migration.Version <= currentVersion || migration.Version > targetVersion {
continue
}
if err := m.applyMigration(migration); err != nil {
return fmt.Errorf("failed to apply migration %d: %v", migration.Version, err)
}
@ -258,18 +258,18 @@ func (m *Migrator) MigrateTo(targetVersion int) error {
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].Version > migrations[j].Version
})
for _, migration := range migrations {
if migration.Version > currentVersion || migration.Version <= targetVersion {
continue
}
if err := m.rollbackMigration(migration); err != nil {
return fmt.Errorf("failed to rollback migration %d: %v", migration.Version, err)
}
}
}
return nil
}
@ -279,19 +279,19 @@ func (m *Migrator) GetAppliedMigrations() ([]MigrationRecord, error) {
if err := m.ensureSchemaInfoTable(); err != nil {
return nil, err
}
query := `
SELECT version, description, applied_at, checksum
FROM schema_info
ORDER BY version
`
rows, err := m.conn.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query applied migrations: %v", err)
}
defer rows.Close()
var migrations []MigrationRecord
for rows.Next() {
var migration MigrationRecord
@ -306,7 +306,7 @@ func (m *Migrator) GetAppliedMigrations() ([]MigrationRecord, error) {
}
migrations = append(migrations, migration)
}
return migrations, nil
}
@ -315,13 +315,13 @@ func (m *Migrator) getCurrentVersion() (int, error) {
if err := m.ensureSchemaInfoTable(); err != nil {
return 0, err
}
var version int
err := m.conn.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_info`).Scan(&version)
if err != nil {
return 0, fmt.Errorf("failed to get current version: %v", err)
}
return version, nil
}
@ -332,13 +332,13 @@ func (m *Migrator) applyMigration(migration Migration) error {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Warn about data loss
if migration.DataLoss {
// In a real application, this would show a warning to the user
// For now, we'll just log it
}
// Execute migration SQL
statements := strings.Split(migration.Up, ";")
for _, stmt := range statements {
@ -346,22 +346,22 @@ func (m *Migrator) applyMigration(migration Migration) error {
if stmt == "" {
continue
}
if _, err := tx.Exec(stmt); err != nil {
return fmt.Errorf("failed to execute migration statement: %v", err)
}
}
// Record migration
_, err = tx.Exec(`
INSERT INTO schema_info (version, description, checksum)
VALUES (?, ?, ?)
`, migration.Version, migration.Description, migration.Checksum)
if err != nil {
return fmt.Errorf("failed to record migration: %v", err)
}
return tx.Commit()
}
@ -370,13 +370,13 @@ func (m *Migrator) rollbackMigration(migration Migration) error {
if migration.Down == "" {
return fmt.Errorf("migration %d has no rollback script", migration.Version)
}
tx, err := m.conn.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Execute rollback SQL
statements := strings.Split(migration.Down, ";")
for _, stmt := range statements {
@ -384,18 +384,18 @@ func (m *Migrator) rollbackMigration(migration Migration) error {
if stmt == "" {
continue
}
if _, err := tx.Exec(stmt); err != nil {
return fmt.Errorf("failed to execute rollback statement: %v", err)
}
}
// Remove migration record
_, err = tx.Exec(`DELETE FROM schema_info WHERE version = ?`, migration.Version)
if err != nil {
return fmt.Errorf("failed to remove migration record: %v", err)
}
return tx.Commit()
}
@ -416,4 +416,4 @@ func (m *Migrator) ensureSchemaInfoTable() error {
func calculateChecksum(content string) string {
// Simple checksum - in production, use a proper hash function
return fmt.Sprintf("%x", len(content))
}
}

View file

@ -24,7 +24,7 @@ func NewOptimizationManager(db *Database, config *Config) *OptimizationManager {
// PerformMaintenance runs database maintenance tasks including VACUUM
func (om *OptimizationManager) PerformMaintenance() error {
now := time.Now()
// Check if VACUUM is needed
if om.config.VacuumInterval > 0 && now.Sub(om.lastVacuum) >= om.config.VacuumInterval {
if err := om.VacuumDatabase(); err != nil {
@ -32,7 +32,7 @@ func (om *OptimizationManager) PerformMaintenance() error {
}
om.lastVacuum = now
}
return nil
}
@ -42,37 +42,37 @@ func (om *OptimizationManager) VacuumDatabase() error {
if conn == nil {
return fmt.Errorf("database connection not available")
}
start := time.Now()
// Get size before VACUUM
sizeBefore, err := om.getDatabaseSize()
if err != nil {
return fmt.Errorf("failed to get database size: %w", err)
}
// Perform VACUUM
if _, err := conn.Exec("VACUUM"); err != nil {
return fmt.Errorf("VACUUM operation failed: %w", err)
}
// Get size after VACUUM
sizeAfter, err := om.getDatabaseSize()
if err != nil {
return fmt.Errorf("failed to get database size after VACUUM: %w", err)
}
duration := time.Since(start)
savedBytes := sizeBefore - sizeAfter
savedPercent := float64(savedBytes) / float64(sizeBefore) * 100
fmt.Printf("VACUUM completed in %v: %.1f MB → %.1f MB (saved %.1f MB, %.1f%%)\n",
duration,
float64(sizeBefore)/(1024*1024),
float64(sizeAfter)/(1024*1024),
float64(savedBytes)/(1024*1024),
savedPercent)
return nil
}
@ -82,13 +82,13 @@ func (om *OptimizationManager) OptimizeDatabase() error {
if conn == nil {
return fmt.Errorf("database connection not available")
}
fmt.Println("Optimizing database for storage efficiency...")
// Apply storage-friendly pragmas
optimizations := []struct{
name string
query string
optimizations := []struct {
name string
query string
description string
}{
{"Auto VACUUM", "PRAGMA auto_vacuum = INCREMENTAL", "Enable incremental auto-vacuum"},
@ -96,7 +96,7 @@ func (om *OptimizationManager) OptimizeDatabase() error {
{"Optimize", "PRAGMA optimize", "Update SQLite query planner statistics"},
{"Analyze", "ANALYZE", "Update table statistics for better query plans"},
}
for _, opt := range optimizations {
if _, err := conn.Exec(opt.query); err != nil {
fmt.Printf("Warning: %s failed: %v\n", opt.name, err)
@ -104,7 +104,7 @@ func (om *OptimizationManager) OptimizeDatabase() error {
fmt.Printf("✓ %s: %s\n", opt.name, opt.description)
}
}
return nil
}
@ -114,83 +114,83 @@ func (om *OptimizationManager) OptimizePageSize(pageSize int) error {
if conn == nil {
return fmt.Errorf("database connection not available")
}
// Check current page size
var currentPageSize int
if err := conn.QueryRow("PRAGMA page_size").Scan(&currentPageSize); err != nil {
return fmt.Errorf("failed to get current page size: %w", err)
}
if currentPageSize == pageSize {
fmt.Printf("Page size already optimal: %d bytes\n", pageSize)
return nil
}
fmt.Printf("Optimizing page size: %d → %d bytes (requires VACUUM)\n", currentPageSize, pageSize)
// Set new page size
query := fmt.Sprintf("PRAGMA page_size = %d", pageSize)
if _, err := conn.Exec(query); err != nil {
return fmt.Errorf("failed to set page size: %w", err)
}
// VACUUM to apply the new page size
if err := om.VacuumDatabase(); err != nil {
return fmt.Errorf("failed to apply page size change: %w", err)
}
return nil
}
// GetOptimizationStats returns current database optimization statistics
func (om *OptimizationManager) GetOptimizationStats() (*OptimizationStats, error) {
stats := &OptimizationStats{}
// Get database size
size, err := om.getDatabaseSize()
if err != nil {
return nil, err
}
stats.DatabaseSize = size
// Get page statistics
conn := om.db.GetConnection()
if conn != nil {
var pageSize, pageCount, freelistCount int
conn.QueryRow("PRAGMA page_size").Scan(&pageSize)
conn.QueryRow("PRAGMA page_count").Scan(&pageCount)
conn.QueryRow("PRAGMA page_count").Scan(&pageCount)
conn.QueryRow("PRAGMA freelist_count").Scan(&freelistCount)
stats.PageSize = pageSize
stats.PageCount = pageCount
stats.FreePages = freelistCount
stats.UsedPages = pageCount - freelistCount
if pageCount > 0 {
stats.Efficiency = float64(stats.UsedPages) / float64(pageCount) * 100
}
// Check auto vacuum setting
var autoVacuum int
conn.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum)
stats.AutoVacuumEnabled = autoVacuum > 0
}
stats.LastVacuum = om.lastVacuum
return stats, nil
}
// OptimizationStats holds database storage optimization statistics
type OptimizationStats struct {
DatabaseSize int64 `json:"database_size"`
PageSize int `json:"page_size"`
PageCount int `json:"page_count"`
UsedPages int `json:"used_pages"`
FreePages int `json:"free_pages"`
Efficiency float64 `json:"efficiency_percent"`
AutoVacuumEnabled bool `json:"auto_vacuum_enabled"`
LastVacuum time.Time `json:"last_vacuum"`
PageSize int `json:"page_size"`
PageCount int `json:"page_count"`
UsedPages int `json:"used_pages"`
FreePages int `json:"free_pages"`
Efficiency float64 `json:"efficiency_percent"`
AutoVacuumEnabled bool `json:"auto_vacuum_enabled"`
LastVacuum time.Time `json:"last_vacuum"`
}
// getDatabaseSize returns the current database file size in bytes
@ -198,11 +198,11 @@ func (om *OptimizationManager) getDatabaseSize() (int64, error) {
if om.config.Path == "" {
return 0, fmt.Errorf("database path not configured")
}
stat, err := os.Stat(om.config.Path)
if err != nil {
return 0, fmt.Errorf("failed to stat database file: %w", err)
}
return stat.Size(), nil
}
}

View file

@ -221,13 +221,13 @@ func TestOptimizationManager_InvalidPath(t *testing.T) {
func TestOptimizationStats_JSON(t *testing.T) {
stats := &OptimizationStats{
DatabaseSize: 1024000,
PageSize: 4096,
PageCount: 250,
UsedPages: 200,
FreePages: 50,
Efficiency: 80.0,
PageSize: 4096,
PageCount: 250,
UsedPages: 200,
FreePages: 50,
Efficiency: 80.0,
AutoVacuumEnabled: true,
LastVacuum: time.Now(),
LastVacuum: time.Now(),
}
// Test that all fields are accessible
@ -286,9 +286,9 @@ func TestOptimizationManager_WithRealData(t *testing.T) {
}
// Compare efficiency
t.Logf("Optimization results: %.2f%% → %.2f%% efficiency",
t.Logf("Optimization results: %.2f%% → %.2f%% efficiency",
statsBefore.Efficiency, statsAfter.Efficiency)
// After optimization, we should have auto-vacuum enabled
if !statsAfter.AutoVacuumEnabled {
t.Error("Auto-vacuum should be enabled after optimization")
@ -304,4 +304,4 @@ func TestOptimizationManager_WithRealData(t *testing.T) {
if count == 0 {
t.Error("Data lost during optimization")
}
}
}

View file

@ -17,17 +17,17 @@ func ResolveDatabasePath(configPath string) (string, error) {
}
return configPath, nil
}
// Try system location first (for services)
if systemPath, err := trySystemPath(); err == nil {
return systemPath, nil
}
// Try user data directory
if userPath, err := tryUserPath(); err == nil {
return userPath, nil
}
// Fallback to current directory
return tryCurrentDirPath()
}
@ -35,7 +35,7 @@ func ResolveDatabasePath(configPath string) (string, error) {
// trySystemPath attempts to use system-wide database location
func trySystemPath() (string, error) {
var systemDir string
switch runtime.GOOS {
case "linux":
systemDir = "/var/lib/skyview"
@ -46,26 +46,26 @@ func trySystemPath() (string, error) {
default:
return "", fmt.Errorf("system path not supported on %s", runtime.GOOS)
}
// Check if directory exists and is writable
if err := ensureDirExists(systemDir); err != nil {
return "", err
}
dbPath := filepath.Join(systemDir, "skyview.db")
// Test write permissions
if err := testWritePermissions(dbPath); err != nil {
return "", err
}
return dbPath, nil
}
// tryUserPath attempts to use user data directory
func tryUserPath() (string, error) {
var userDataDir string
switch runtime.GOOS {
case "linux":
if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" {
@ -91,20 +91,20 @@ func tryUserPath() (string, error) {
default:
return "", fmt.Errorf("user path not supported on %s", runtime.GOOS)
}
skyviewDir := filepath.Join(userDataDir, "skyview")
if err := ensureDirExists(skyviewDir); err != nil {
return "", err
}
dbPath := filepath.Join(skyviewDir, "skyview.db")
// Test write permissions
if err := testWritePermissions(dbPath); err != nil {
return "", err
}
return dbPath, nil
}
@ -114,14 +114,14 @@ func tryCurrentDirPath() (string, error) {
if err != nil {
return "", fmt.Errorf("cannot get current directory: %v", err)
}
dbPath := filepath.Join(currentDir, "skyview.db")
// Test write permissions
if err := testWritePermissions(dbPath); err != nil {
return "", err
}
return dbPath, nil
}
@ -134,23 +134,23 @@ func ensureDirExists(dir string) error {
} else if err != nil {
return fmt.Errorf("cannot access directory %s: %v", dir, err)
}
return nil
}
// testWritePermissions verifies write access to the database path
func testWritePermissions(dbPath string) error {
dir := filepath.Dir(dbPath)
// Check directory write permissions
testFile := filepath.Join(dir, ".skyview_write_test")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
return fmt.Errorf("no write permission to directory %s: %v", dir, err)
}
// Clean up test file
os.Remove(testFile)
return nil
}
@ -171,4 +171,4 @@ func IsSystemPath(dbPath string) bool {
return programData != "" && filepath.HasPrefix(dbPath, filepath.Join(programData, "skyview"))
}
return false
}
}

View file

@ -33,4 +33,4 @@ func setupTestDatabase(t *testing.T) (*Database, func()) {
}
return db, cleanup
}
}

View file

@ -448,7 +448,7 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
state.LastUpdate = timestamp
state.TotalMessages++
// Persist to database if available and aircraft has position
if m.db != nil && aircraft.Latitude != 0 && aircraft.Longitude != 0 {
m.saveAircraftToDatabase(aircraft, sourceID, signal, timestamp)
@ -1077,7 +1077,7 @@ func (m *Merger) validatePosition(aircraft *modes.Aircraft, state *AircraftState
func (m *Merger) saveAircraftToDatabase(aircraft *modes.Aircraft, sourceID string, signal float64, timestamp time.Time) {
// Convert ICAO24 to hex string
icaoHex := fmt.Sprintf("%06X", aircraft.ICAO24)
// Prepare database record
record := database.AircraftHistoryRecord{
ICAO: icaoHex,
@ -1087,7 +1087,7 @@ func (m *Merger) saveAircraftToDatabase(aircraft *modes.Aircraft, sourceID strin
SourceID: sourceID,
SignalStrength: &signal,
}
// Add optional fields if available
if aircraft.Altitude > 0 {
record.Altitude = &aircraft.Altitude
@ -1107,7 +1107,7 @@ func (m *Merger) saveAircraftToDatabase(aircraft *modes.Aircraft, sourceID strin
if aircraft.Callsign != "" {
record.Callsign = &aircraft.Callsign
}
// Save to database (non-blocking to avoid slowing down real-time processing)
go func() {
if err := m.db.GetHistoryManager().RecordAircraft(&record); err != nil {

View file

@ -55,13 +55,13 @@ type OriginConfig struct {
// - Concurrent broadcast system for WebSocket clients
// - CORS support for cross-origin web applications
type Server struct {
host string // Bind address for HTTP server
port int // TCP port for HTTP server
merger *merger.Merger // Data source for aircraft information
host string // Bind address for HTTP server
port int // TCP port for HTTP server
merger *merger.Merger // Data source for aircraft information
database *database.Database // Optional database for persistence
staticFiles embed.FS // Embedded static web assets
server *http.Server // HTTP server instance
origin OriginConfig // Geographic reference point
staticFiles embed.FS // Embedded static web assets
server *http.Server // HTTP server instance
origin OriginConfig // Geographic reference point
// WebSocket management
wsClients map[*websocket.Conn]bool // Active WebSocket client connections
@ -919,19 +919,19 @@ func (s *Server) handleGetDatabaseStatus(w http.ResponseWriter, r *http.Request)
}
response := make(map[string]interface{})
// Get database path and size information
dbConfig := s.database.GetConfig()
dbPath := dbConfig.Path
response["path"] = dbPath
// Get file size and modification time
if stat, err := os.Stat(dbPath); err == nil {
response["size_bytes"] = stat.Size()
response["size_mb"] = float64(stat.Size()) / (1024 * 1024)
response["modified"] = stat.ModTime().Unix()
}
// Get optimization statistics
optimizer := database.NewOptimizationManager(s.database, dbConfig)
if optimizationStats, err := optimizer.GetOptimizationStats(); err == nil {
@ -945,14 +945,14 @@ func (s *Server) handleGetDatabaseStatus(w http.ResponseWriter, r *http.Request)
response["last_vacuum"] = optimizationStats.LastVacuum.Unix()
}
}
// Get history statistics
historyStats, err := s.database.GetHistoryManager().GetStatistics()
if err != nil {
log.Printf("Error getting history statistics: %v", err)
historyStats = make(map[string]interface{})
}
// Get callsign statistics if available
callsignStats := make(map[string]interface{})
if callsignManager := s.database.GetCallsignManager(); callsignManager != nil {
@ -963,23 +963,23 @@ func (s *Server) handleGetDatabaseStatus(w http.ResponseWriter, r *http.Request)
callsignStats = stats
}
}
// Get record counts for reference data
var airportCount, airlineCount int
s.database.GetConnection().QueryRow(`SELECT COUNT(*) FROM airports`).Scan(&airportCount)
s.database.GetConnection().QueryRow(`SELECT COUNT(*) FROM airlines`).Scan(&airlineCount)
referenceData := make(map[string]interface{})
referenceData["airports"] = airportCount
referenceData["airlines"] = airlineCount
response["database_available"] = true
response["path"] = dbPath
response["reference_data"] = referenceData
response["history"] = historyStats
response["callsign"] = callsignStats
response["timestamp"] = time.Now().Unix()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
@ -993,20 +993,20 @@ func (s *Server) handleGetDataSources(w http.ResponseWriter, r *http.Request) {
// Create data loader instance
loader := database.NewDataLoader(s.database.GetConnection())
availableSources := database.GetAvailableDataSources()
loadedSources, err := loader.GetLoadedDataSources()
if err != nil {
log.Printf("Error getting loaded data sources: %v", err)
loadedSources = []database.DataSource{}
}
response := map[string]interface{}{
"available": availableSources,
"loaded": loadedSources,
"timestamp": time.Now().Unix(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
@ -1021,7 +1021,7 @@ func (s *Server) handleGetCallsignInfo(w http.ResponseWriter, r *http.Request) {
// Extract callsign from URL parameters
vars := mux.Vars(r)
callsign := vars["callsign"]
if callsign == "" {
http.Error(w, "Callsign parameter required", http.StatusBadRequest)
return
@ -1036,7 +1036,7 @@ func (s *Server) handleGetCallsignInfo(w http.ResponseWriter, r *http.Request) {
}
response := map[string]interface{}{
"callsign": callsignInfo,
"callsign": callsignInfo,
"timestamp": time.Now().Unix(),
}
@ -1070,12 +1070,12 @@ func (s *Server) debugEmbeddedFiles() {
func (s *Server) handleDatabasePage(w http.ResponseWriter, r *http.Request) {
// Debug embedded files first
s.debugEmbeddedFiles()
// Try to read the database HTML file from embedded assets
data, err := s.staticFiles.ReadFile("static/database.html")
if err != nil {
log.Printf("Error reading database.html: %v", err)
// Fallback: serve a simple HTML page with API calls
fallbackHTML := `<!DOCTYPE html>
<html><head><title>Database Status - SkyView</title></head>
@ -1091,7 +1091,7 @@ fetch('/api/database/status')
});
</script>
</body></html>`
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(fallbackHTML))
return

View file

@ -72,10 +72,10 @@ func NewDatabase() *Database {
db := &Database{
codes: make(map[string]*CodeInfo),
}
// Initialize with standard transponder codes
db.loadStandardCodes()
return db
}
@ -107,7 +107,7 @@ func (db *Database) loadStandardCodes() {
Priority: 90,
Notes: "General emergency situation requiring immediate attention",
},
// Standard VFR/IFR Codes
{
Code: "1200",
@ -149,7 +149,7 @@ func (db *Database) loadStandardCodes() {
Priority: 5,
Notes: "Transponder operating but no specific code assigned",
},
// Special Purpose Codes
{
Code: "1255",
@ -175,7 +175,7 @@ func (db *Database) loadStandardCodes() {
Priority: 35,
Notes: "Military interceptor aircraft",
},
// Military Ranges
{
Code: "4000",
@ -193,7 +193,7 @@ func (db *Database) loadStandardCodes() {
Priority: 12,
Notes: "Military interceptor operations (0100-0777 range)",
},
// Additional Common Codes
{
Code: "1201",
@ -219,7 +219,7 @@ func (db *Database) loadStandardCodes() {
Priority: 8,
Notes: "VFR flight above 12,500 feet requiring transponder",
},
// European Specific
{
Code: "7001",
@ -246,7 +246,7 @@ func (db *Database) loadStandardCodes() {
Notes: "General Air Traffic operating in Other Air Traffic area",
},
}
// Add all codes to the database
for _, code := range codes {
db.codes[code.Code] = code
@ -254,7 +254,7 @@ func (db *Database) loadStandardCodes() {
}
// Lookup returns information about a given transponder code
//
//
// The method accepts both 4-digit strings and integers, automatically
// formatting them as needed. Returns nil if the code is not found in the database.
//
@ -308,13 +308,13 @@ func (db *Database) LookupHex(hexCode string) *CodeInfo {
// - []*CodeInfo: Slice of all emergency codes, sorted by priority (highest first)
func (db *Database) GetEmergencyCodes() []*CodeInfo {
var emergencyCodes []*CodeInfo
for _, info := range db.codes {
if info.Type == Emergency {
emergencyCodes = append(emergencyCodes, info)
}
}
// Sort by priority (highest first)
for i := 0; i < len(emergencyCodes); i++ {
for j := i + 1; j < len(emergencyCodes); j++ {
@ -323,7 +323,7 @@ func (db *Database) GetEmergencyCodes() []*CodeInfo {
}
}
}
return emergencyCodes
}
@ -379,7 +379,7 @@ func (db *Database) FormatSquawkWithDescription(code string) string {
if info == nil {
return code // Return just the code if no description available
}
switch info.Type {
case Emergency:
return fmt.Sprintf("%s (⚠️ EMERGENCY - %s)", code, info.Description)
@ -390,4 +390,4 @@ func (db *Database) FormatSquawkWithDescription(code string) string {
default:
return fmt.Sprintf("%s (%s)", code, info.Description)
}
}
}

View file

@ -9,7 +9,7 @@ func TestNewDatabase(t *testing.T) {
if db == nil {
t.Fatal("NewDatabase() returned nil")
}
if len(db.codes) == 0 {
t.Error("Database should contain pre-loaded codes")
}
@ -17,20 +17,20 @@ func TestNewDatabase(t *testing.T) {
func TestEmergencyCodes(t *testing.T) {
db := NewDatabase()
emergencyCodes := []string{"7500", "7600", "7700"}
for _, code := range emergencyCodes {
info := db.Lookup(code)
if info == nil {
t.Errorf("Emergency code %s not found", code)
continue
}
if info.Type != Emergency {
t.Errorf("Code %s should be Emergency type, got %s", code, info.Type)
}
if !db.IsEmergencyCode(code) {
t.Errorf("IsEmergencyCode(%s) should return true", code)
}
@ -39,7 +39,7 @@ func TestEmergencyCodes(t *testing.T) {
func TestStandardCodes(t *testing.T) {
db := NewDatabase()
testCases := []struct {
code string
description string
@ -48,16 +48,16 @@ func TestStandardCodes(t *testing.T) {
{"7000", "VFR - Visual Flight Rules"},
{"1000", "Mode A/C Not Assigned"},
}
for _, tc := range testCases {
info := db.Lookup(tc.code)
if info == nil {
t.Errorf("Standard code %s not found", tc.code)
continue
}
if info.Description != tc.description {
t.Errorf("Code %s: expected description %q, got %q",
t.Errorf("Code %s: expected description %q, got %q",
tc.code, tc.description, info.Description)
}
}
@ -65,17 +65,17 @@ func TestStandardCodes(t *testing.T) {
func TestLookupInt(t *testing.T) {
db := NewDatabase()
// Test integer lookup
info := db.LookupInt(7700)
if info == nil {
t.Fatal("LookupInt(7700) returned nil")
}
if info.Code != "7700" {
t.Errorf("Expected code '7700', got '%s'", info.Code)
}
if info.Type != Emergency {
t.Errorf("Code 7700 should be Emergency type, got %s", info.Type)
}
@ -83,11 +83,11 @@ func TestLookupInt(t *testing.T) {
func TestLookupHex(t *testing.T) {
db := NewDatabase()
// 7700 in octal is 3840 in decimal, which is F00 in hex
// However, squawk codes are transmitted differently in different formats
// For now, test with a simple hex conversion
// Test invalid hex
info := db.LookupHex("INVALID")
if info != nil {
@ -97,7 +97,7 @@ func TestLookupHex(t *testing.T) {
func TestFormatSquawkWithDescription(t *testing.T) {
db := NewDatabase()
testCases := []struct {
code string
expected string
@ -108,7 +108,7 @@ func TestFormatSquawkWithDescription(t *testing.T) {
{"0000", "0000 (🔰 No Transponder/Military)"},
{"9999", "9999"}, // Unknown code should return just the code
}
for _, tc := range testCases {
result := db.FormatSquawkWithDescription(tc.code)
if result != tc.expected {
@ -120,12 +120,12 @@ func TestFormatSquawkWithDescription(t *testing.T) {
func TestGetEmergencyCodes(t *testing.T) {
db := NewDatabase()
emergencyCodes := db.GetEmergencyCodes()
if len(emergencyCodes) != 3 {
t.Errorf("Expected 3 emergency codes, got %d", len(emergencyCodes))
}
// Check that they're sorted by priority (highest first)
for i := 1; i < len(emergencyCodes); i++ {
if emergencyCodes[i-1].Priority < emergencyCodes[i].Priority {
@ -136,7 +136,7 @@ func TestGetEmergencyCodes(t *testing.T) {
func TestAddCustomCode(t *testing.T) {
db := NewDatabase()
customCode := &CodeInfo{
Code: "1234",
Description: "Test Custom Code",
@ -145,14 +145,14 @@ func TestAddCustomCode(t *testing.T) {
Priority: 50,
Notes: "This is a test custom code",
}
db.AddCustomCode(customCode)
info := db.Lookup("1234")
if info == nil {
t.Fatal("Custom code not found after adding")
}
if info.Description != "Test Custom Code" {
t.Errorf("Custom code description mismatch: expected %q, got %q",
"Test Custom Code", info.Description)
@ -170,7 +170,7 @@ func TestCodeTypeString(t *testing.T) {
{Military, "Military"},
{Special, "Special"},
}
for _, tc := range testCases {
result := tc.codeType.String()
if result != tc.expected {
@ -181,16 +181,16 @@ func TestCodeTypeString(t *testing.T) {
func TestGetAllCodes(t *testing.T) {
db := NewDatabase()
allCodes := db.GetAllCodes()
if len(allCodes) == 0 {
t.Error("GetAllCodes() should return non-empty slice")
}
// Verify we can find known codes in the result
found7700 := false
found1200 := false
for _, code := range allCodes {
if code.Code == "7700" {
found7700 = true
@ -199,11 +199,11 @@ func TestGetAllCodes(t *testing.T) {
found1200 = true
}
}
if !found7700 {
t.Error("Emergency code 7700 not found in GetAllCodes() result")
}
if !found1200 {
t.Error("Standard code 1200 not found in GetAllCodes() result")
}
}
}