feat: Improve aircraft legend clarity and icon differentiation - resolves #13 #36
26 changed files with 829 additions and 591 deletions
|
|
@ -29,5 +29,6 @@ import "embed"
|
||||||
// external file deployment or complicated asset management.
|
// external file deployment or complicated asset management.
|
||||||
//
|
//
|
||||||
// Updated to include database.html for database status page
|
// Updated to include database.html for database status page
|
||||||
|
//
|
||||||
//go:embed static
|
//go:embed static
|
||||||
var Static embed.FS
|
var Static embed.FS
|
||||||
|
|
|
||||||
|
|
@ -262,21 +262,28 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-icon {
|
.legend-icon {
|
||||||
width: 16px;
|
width: 24px;
|
||||||
height: 16px;
|
height: 24px;
|
||||||
border-radius: 2px;
|
display: inline-flex;
|
||||||
border: 1px solid #ffffff;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-icon.light { background: #00bfff; } /* Sky blue for light aircraft */
|
.legend-icon svg {
|
||||||
.legend-icon.medium { background: #00ff88; } /* Green for medium aircraft */
|
width: 100%;
|
||||||
.legend-icon.large { background: #ff8c00; } /* Orange for large aircraft */
|
height: 100%;
|
||||||
.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.light svg { color: #00bfff; } /* Sky blue for light aircraft */
|
||||||
.legend-icon.military { background: #ff4444; } /* Red-orange for military */
|
.legend-icon.medium svg { color: #00ff88; } /* Green for medium aircraft */
|
||||||
.legend-icon.ga { background: #ffff00; } /* Yellow for general aviation */
|
.legend-icon.large svg { color: #ff8c00; } /* Orange for large aircraft */
|
||||||
.legend-icon.ground { background: #888888; } /* Gray for ground vehicles */
|
.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 {
|
.table-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
26
assets/static/icons/heavy.svg
Normal file
26
assets/static/icons/heavy.svg
Normal 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 |
24
assets/static/icons/large.svg
Normal file
24
assets/static/icons/large.svg
Normal 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 |
18
assets/static/icons/light.svg
Normal file
18
assets/static/icons/light.svg
Normal 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 |
21
assets/static/icons/medium.svg
Normal file
21
assets/static/icons/medium.svg
Normal 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 |
|
|
@ -112,37 +112,175 @@
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<h4>ADS-B Categories</h4>
|
<h4>ADS-B Categories</h4>
|
||||||
<div class="legend-item">
|
<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 < 7000kg</span>
|
<span>Light < 7000kg</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<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>
|
<span>Medium 7000-34000kg</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<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>
|
<span>Large 34000-136000kg</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<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>
|
<span>High Vortex Large</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<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 > 136000kg</span>
|
<span>Heavy > 136000kg</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<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>
|
<span>Rotorcraft</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<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>
|
<span>Glider/Ultralight</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<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>
|
<span>Surface Vehicle</span>
|
||||||
</div>
|
</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>
|
<h4>Sources</h4>
|
||||||
<div id="sources-legend"></div>
|
<div id="sources-legend"></div>
|
||||||
|
|
|
||||||
|
|
@ -331,60 +331,48 @@ export class AircraftManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
getAircraftIconType(aircraft) {
|
getAircraftIconType(aircraft) {
|
||||||
// For icon selection, we still need basic categories
|
// For icon selection, determine which SVG shape to use based on category
|
||||||
// This determines which SVG shape to use
|
|
||||||
if (aircraft.OnGround) return 'ground';
|
if (aircraft.OnGround) return 'ground';
|
||||||
|
|
||||||
if (aircraft.Category) {
|
if (aircraft.Category) {
|
||||||
const cat = aircraft.Category.toLowerCase();
|
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('helicopter') || cat.includes('rotorcraft')) return 'helicopter';
|
||||||
if (cat.includes('military') || cat.includes('fighter') || cat.includes('bomber')) return 'military';
|
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('glider') || cat.includes('ultralight')) return 'ga';
|
||||||
if (cat.includes('light') || 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
|
// Default to medium icon for unknown aircraft
|
||||||
return 'commercial';
|
return 'medium';
|
||||||
}
|
}
|
||||||
|
|
||||||
getAircraftColor(type, aircraft) {
|
getAircraftColor(type, aircraft) {
|
||||||
// Special colors for specific types
|
// Color mapping based on aircraft type/size
|
||||||
if (type === 'military') return '#ff4444';
|
switch (type) {
|
||||||
if (type === 'helicopter') return '#ff00ff';
|
case 'military': return '#ff4444'; // Red-orange for military
|
||||||
if (type === 'ground') return '#888888';
|
case 'helicopter': return '#ff00ff'; // Magenta for helicopters
|
||||||
if (type === 'ga') return '#ffff00';
|
case 'ground': return '#888888'; // Gray for ground vehicles
|
||||||
|
case 'ga': return '#ffff00'; // Yellow for general aviation
|
||||||
// For commercial and cargo types, use weight-based colors
|
case 'light': return '#00bfff'; // Sky blue for light aircraft
|
||||||
if (aircraft && aircraft.Category) {
|
case 'medium': return '#00ff88'; // Green for medium aircraft
|
||||||
const cat = aircraft.Category.toLowerCase();
|
case 'large': return '#ff8c00'; // Orange for large aircraft
|
||||||
|
case 'heavy': return '#ff0000'; // Red for heavy aircraft
|
||||||
// Check for specific weight ranges in the category string
|
default: return '#00ff88'; // Default green for unknown
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to green for unknown commercial aircraft
|
|
||||||
return '#00ff88';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,28 +19,28 @@ import (
|
||||||
|
|
||||||
// Shared configuration structures (should match main skyview)
|
// Shared configuration structures (should match main skyview)
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `json:"server"`
|
Server ServerConfig `json:"server"`
|
||||||
Sources []SourceConfig `json:"sources"`
|
Sources []SourceConfig `json:"sources"`
|
||||||
Settings Settings `json:"settings"`
|
Settings Settings `json:"settings"`
|
||||||
Database *database.Config `json:"database,omitempty"`
|
Database *database.Config `json:"database,omitempty"`
|
||||||
Callsign *CallsignConfig `json:"callsign,omitempty"`
|
Callsign *CallsignConfig `json:"callsign,omitempty"`
|
||||||
Origin OriginConfig `json:"origin"`
|
Origin OriginConfig `json:"origin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CallsignConfig struct {
|
type CallsignConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CacheHours int `json:"cache_hours"`
|
CacheHours int `json:"cache_hours"`
|
||||||
PrivacyMode bool `json:"privacy_mode"`
|
PrivacyMode bool `json:"privacy_mode"`
|
||||||
Sources map[string]CallsignSourceConfig `json:"sources"`
|
Sources map[string]CallsignSourceConfig `json:"sources"`
|
||||||
ExternalAPIs map[string]ExternalAPIConfig `json:"external_apis,omitempty"`
|
ExternalAPIs map[string]ExternalAPIConfig `json:"external_apis,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CallsignSourceConfig struct {
|
type CallsignSourceConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
License string `json:"license"`
|
License string `json:"license"`
|
||||||
RequiresConsent bool `json:"requires_consent,omitempty"`
|
RequiresConsent bool `json:"requires_consent,omitempty"`
|
||||||
UserAcceptsTerms bool `json:"user_accepts_terms,omitempty"`
|
UserAcceptsTerms bool `json:"user_accepts_terms,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExternalAPIConfig struct {
|
type ExternalAPIConfig struct {
|
||||||
|
|
@ -51,14 +51,14 @@ type ExternalAPIConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OriginConfig struct {
|
type OriginConfig struct {
|
||||||
Latitude float64 `json:"latitude"`
|
Latitude float64 `json:"latitude"`
|
||||||
Longitude float64 `json:"longitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceConfig struct {
|
type SourceConfig struct {
|
||||||
|
|
@ -152,7 +152,7 @@ OPTIONS:
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Configuration loading failed: %v", err)
|
log.Fatalf("Configuration loading failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize database connection using shared config
|
// Initialize database connection using shared config
|
||||||
db, err := initDatabaseFromConfig(config, *dbPath)
|
db, err := initDatabaseFromConfig(config, *dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -215,7 +215,7 @@ func initDatabase(dbPath string) (*database.Database, error) {
|
||||||
// cmdInit initializes an empty database
|
// cmdInit initializes an empty database
|
||||||
func cmdInit(db *database.Database, force bool) error {
|
func cmdInit(db *database.Database, force bool) error {
|
||||||
dbPath := db.GetConfig().Path
|
dbPath := db.GetConfig().Path
|
||||||
|
|
||||||
// Check if database already exists and has data
|
// Check if database already exists and has data
|
||||||
if !force {
|
if !force {
|
||||||
if stats, err := db.GetHistoryManager().GetStatistics(); err == nil {
|
if stats, err := db.GetHistoryManager().GetStatistics(); err == nil {
|
||||||
|
|
@ -271,19 +271,19 @@ func cmdList(db *database.Database) error {
|
||||||
func cmdStatus(db *database.Database) error {
|
func cmdStatus(db *database.Database) error {
|
||||||
fmt.Println("SkyView Database Status")
|
fmt.Println("SkyView Database Status")
|
||||||
fmt.Println("======================")
|
fmt.Println("======================")
|
||||||
|
|
||||||
dbPath := db.GetConfig().Path
|
dbPath := db.GetConfig().Path
|
||||||
fmt.Printf("Database: %s\n", dbPath)
|
fmt.Printf("Database: %s\n", dbPath)
|
||||||
|
|
||||||
// Check if file exists and get size
|
// Check if file exists and get size
|
||||||
if stat, err := os.Stat(dbPath); err == nil {
|
if stat, err := os.Stat(dbPath); err == nil {
|
||||||
fmt.Printf("Size: %.2f MB\n", float64(stat.Size())/(1024*1024))
|
fmt.Printf("Size: %.2f MB\n", float64(stat.Size())/(1024*1024))
|
||||||
fmt.Printf("Modified: %s\n", stat.ModTime().Format(time.RFC3339))
|
fmt.Printf("Modified: %s\n", stat.ModTime().Format(time.RFC3339))
|
||||||
|
|
||||||
// Add database optimization stats
|
// Add database optimization stats
|
||||||
optimizer := database.NewOptimizationManager(db, db.GetConfig())
|
optimizer := database.NewOptimizationManager(db, db.GetConfig())
|
||||||
if stats, err := optimizer.GetOptimizationStats(); err == nil {
|
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)
|
stats.Efficiency, stats.UsedPages, stats.FreePages)
|
||||||
if stats.AutoVacuumEnabled {
|
if stats.AutoVacuumEnabled {
|
||||||
fmt.Printf("Auto-VACUUM: Enabled\n")
|
fmt.Printf("Auto-VACUUM: Enabled\n")
|
||||||
|
|
@ -320,7 +320,7 @@ func cmdStatus(db *database.Database) error {
|
||||||
var airportCount, airlineCount int
|
var airportCount, airlineCount int
|
||||||
db.GetConnection().QueryRow(`SELECT COUNT(*) FROM airports`).Scan(&airportCount)
|
db.GetConnection().QueryRow(`SELECT COUNT(*) FROM airports`).Scan(&airportCount)
|
||||||
db.GetConnection().QueryRow(`SELECT COUNT(*) FROM airlines`).Scan(&airlineCount)
|
db.GetConnection().QueryRow(`SELECT COUNT(*) FROM airlines`).Scan(&airlineCount)
|
||||||
|
|
||||||
// Get data source update information
|
// Get data source update information
|
||||||
var lastUpdate time.Time
|
var lastUpdate time.Time
|
||||||
var updateCount int
|
var updateCount int
|
||||||
|
|
@ -329,7 +329,7 @@ func cmdStatus(db *database.Database) error {
|
||||||
FROM data_sources
|
FROM data_sources
|
||||||
WHERE imported_at IS NOT NULL
|
WHERE imported_at IS NOT NULL
|
||||||
`).Scan(&updateCount, &lastUpdate)
|
`).Scan(&updateCount, &lastUpdate)
|
||||||
|
|
||||||
fmt.Printf("📊 Database Statistics:\n")
|
fmt.Printf("📊 Database Statistics:\n")
|
||||||
fmt.Printf(" Reference Data:\n")
|
fmt.Printf(" Reference Data:\n")
|
||||||
if airportCount > 0 {
|
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(" • Last Updated: %s\n", lastUpdate.Format("2006-01-02 15:04:05"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" Flight History:\n")
|
fmt.Printf(" Flight History:\n")
|
||||||
if totalRecords, ok := stats["total_records"].(int); ok {
|
if totalRecords, ok := stats["total_records"].(int); ok {
|
||||||
fmt.Printf(" • Aircraft Records: %d\n", totalRecords)
|
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 hasOldest && hasNewest && oldestRecord != nil && newestRecord != nil {
|
||||||
if oldest, ok := oldestRecord.(time.Time); ok {
|
if oldest, ok := oldestRecord.(time.Time); ok {
|
||||||
if newest, ok := newestRecord.(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"),
|
oldest.Format("2006-01-02"),
|
||||||
newest.Format("2006-01-02"))
|
newest.Format("2006-01-02"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show airport data sample if available
|
// Show airport data sample if available
|
||||||
if airportCount > 0 {
|
if airportCount > 0 {
|
||||||
var sampleAirports []string
|
var sampleAirports []string
|
||||||
|
|
@ -398,7 +398,7 @@ func cmdStatus(db *database.Database) error {
|
||||||
// cmdUpdate updates data from specified sources (or safe sources by default)
|
// cmdUpdate updates data from specified sources (or safe sources by default)
|
||||||
func cmdUpdate(db *database.Database, sources []string, force bool) error {
|
func cmdUpdate(db *database.Database, sources []string, force bool) error {
|
||||||
availableSources := database.GetAvailableDataSources()
|
availableSources := database.GetAvailableDataSources()
|
||||||
|
|
||||||
// If no sources specified, use safe (non-consent-required) sources
|
// If no sources specified, use safe (non-consent-required) sources
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
log.Println("Updating from safe data sources...")
|
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, " ", "")))
|
sources = append(sources, strings.ToLower(strings.ReplaceAll(source.Name, " ", "")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
log.Println("No safe data sources available for automatic update")
|
log.Println("No safe data sources available for automatic update")
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -416,7 +416,7 @@ func cmdUpdate(db *database.Database, sources []string, force bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
loader := database.NewDataLoader(db.GetConnection())
|
loader := database.NewDataLoader(db.GetConnection())
|
||||||
|
|
||||||
for _, sourceName := range sources {
|
for _, sourceName := range sources {
|
||||||
// Find matching source
|
// Find matching source
|
||||||
var matchedSource *database.DataSource
|
var matchedSource *database.DataSource
|
||||||
|
|
@ -426,13 +426,13 @@ func cmdUpdate(db *database.Database, sources []string, force bool) error {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if matchedSource == nil {
|
if matchedSource == nil {
|
||||||
log.Printf("⚠️ Unknown source: %s", sourceName)
|
log.Printf("⚠️ Unknown source: %s", sourceName)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for consent requirement
|
// Check for consent requirement
|
||||||
if matchedSource.RequiresConsent && !force {
|
if matchedSource.RequiresConsent && !force {
|
||||||
log.Printf("Skipping %s: requires license acceptance (%s)", matchedSource.Name, matchedSource.License)
|
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)
|
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)
|
log.Printf("Loading %s...", matchedSource.Name)
|
||||||
|
|
||||||
result, err := loader.LoadDataSource(*matchedSource)
|
result, err := loader.LoadDataSource(*matchedSource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to load %s: %v", matchedSource.Name, err)
|
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)
|
log.Printf("Loaded %s: %d records in %v", matchedSource.Name, result.RecordsNew, result.Duration)
|
||||||
|
|
||||||
if len(result.Errors) > 0 {
|
if len(result.Errors) > 0 {
|
||||||
log.Printf(" %d errors occurred during import (first few):", len(result.Errors))
|
log.Printf(" %d errors occurred during import (first few):", len(result.Errors))
|
||||||
for i, errMsg := range result.Errors {
|
for i, errMsg := range result.Errors {
|
||||||
if i >= 3 { break }
|
if i >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
log.Printf(" %s", errMsg)
|
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
|
// cmdImport imports data from a specific source with interactive license acceptance
|
||||||
func cmdImport(db *database.Database, sourceName string, force bool) error {
|
func cmdImport(db *database.Database, sourceName string, force bool) error {
|
||||||
availableSources := database.GetAvailableDataSources()
|
availableSources := database.GetAvailableDataSources()
|
||||||
|
|
||||||
var matchedSource *database.DataSource
|
var matchedSource *database.DataSource
|
||||||
for _, available := range availableSources {
|
for _, available := range availableSources {
|
||||||
if strings.EqualFold(strings.ReplaceAll(available.Name, " ", ""), sourceName) {
|
if strings.EqualFold(strings.ReplaceAll(available.Name, " ", ""), sourceName) {
|
||||||
|
|
@ -479,7 +481,7 @@ func cmdImport(db *database.Database, sourceName string, force bool) error {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if matchedSource == nil {
|
if matchedSource == nil {
|
||||||
return fmt.Errorf("unknown data source: %s", sourceName)
|
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.Printf(" URL: %s\n", matchedSource.URL)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("By importing this data, you agree to comply with the %s license terms.\n", matchedSource.License)
|
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?") {
|
if !askForConfirmation("Do you accept the license terms?") {
|
||||||
fmt.Println("Import cancelled.")
|
fmt.Println("Import cancelled.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
matchedSource.UserAcceptedLicense = true
|
matchedSource.UserAcceptedLicense = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("📥 Importing %s...\n", matchedSource.Name)
|
fmt.Printf("📥 Importing %s...\n", matchedSource.Name)
|
||||||
|
|
||||||
loader := database.NewDataLoader(db.GetConnection())
|
loader := database.NewDataLoader(db.GetConnection())
|
||||||
result, err := loader.LoadDataSource(*matchedSource)
|
result, err := loader.LoadDataSource(*matchedSource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -550,10 +552,10 @@ func cmdReset(db *database.Database, force bool) error {
|
||||||
// askForConfirmation asks the user for yes/no confirmation
|
// askForConfirmation asks the user for yes/no confirmation
|
||||||
func askForConfirmation(question string) bool {
|
func askForConfirmation(question string) bool {
|
||||||
fmt.Printf("%s (y/N): ", question)
|
fmt.Printf("%s (y/N): ", question)
|
||||||
|
|
||||||
var response string
|
var response string
|
||||||
fmt.Scanln(&response)
|
fmt.Scanln(&response)
|
||||||
|
|
||||||
response = strings.ToLower(strings.TrimSpace(response))
|
response = strings.ToLower(strings.TrimSpace(response))
|
||||||
return response == "y" || response == "yes"
|
return response == "y" || response == "yes"
|
||||||
}
|
}
|
||||||
|
|
@ -564,30 +566,30 @@ func loadConfig(configPath string) (*Config, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err)
|
return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var config Config
|
var config Config
|
||||||
if err := json.Unmarshal(data, &config); err != nil {
|
if err := json.Unmarshal(data, &config); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
|
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &config, nil
|
return &config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initDatabaseFromConfig initializes database using shared configuration
|
// initDatabaseFromConfig initializes database using shared configuration
|
||||||
func initDatabaseFromConfig(config *Config, dbPathOverride string) (*database.Database, error) {
|
func initDatabaseFromConfig(config *Config, dbPathOverride string) (*database.Database, error) {
|
||||||
var dbConfig *database.Config
|
var dbConfig *database.Config
|
||||||
|
|
||||||
if config.Database != nil {
|
if config.Database != nil {
|
||||||
dbConfig = config.Database
|
dbConfig = config.Database
|
||||||
} else {
|
} else {
|
||||||
dbConfig = database.DefaultConfig()
|
dbConfig = database.DefaultConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow command-line override of database path
|
// Allow command-line override of database path
|
||||||
if dbPathOverride != "" {
|
if dbPathOverride != "" {
|
||||||
dbConfig.Path = dbPathOverride
|
dbConfig.Path = dbPathOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve database path if empty
|
// Resolve database path if empty
|
||||||
if dbConfig.Path == "" {
|
if dbConfig.Path == "" {
|
||||||
resolvedPath, err := database.ResolveDatabasePath(dbConfig.Path)
|
resolvedPath, err := database.ResolveDatabasePath(dbConfig.Path)
|
||||||
|
|
@ -596,18 +598,18 @@ func initDatabaseFromConfig(config *Config, dbPathOverride string) (*database.Da
|
||||||
}
|
}
|
||||||
dbConfig.Path = resolvedPath
|
dbConfig.Path = resolvedPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and initialize database
|
// Create and initialize database
|
||||||
db, err := database.NewDatabase(dbConfig)
|
db, err := database.NewDatabase(dbConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create database: %w", err)
|
return nil, fmt.Errorf("failed to create database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Initialize(); err != nil {
|
if err := db.Initialize(); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -615,23 +617,23 @@ func initDatabaseFromConfig(config *Config, dbPathOverride string) (*database.Da
|
||||||
func cmdOptimize(db *database.Database, force bool) error {
|
func cmdOptimize(db *database.Database, force bool) error {
|
||||||
fmt.Println("Database Storage Optimization")
|
fmt.Println("Database Storage Optimization")
|
||||||
fmt.Println("============================")
|
fmt.Println("============================")
|
||||||
|
|
||||||
// We need to get the database path from the config
|
// We need to get the database path from the config
|
||||||
// For now, let's create a simple optimization manager
|
// For now, let's create a simple optimization manager
|
||||||
config := &database.Config{
|
config := &database.Config{
|
||||||
Path: "./dev-skyview.db", // Default path - this should be configurable
|
Path: "./dev-skyview.db", // Default path - this should be configurable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create optimization manager
|
// Create optimization manager
|
||||||
optimizer := database.NewOptimizationManager(db, config)
|
optimizer := database.NewOptimizationManager(db, config)
|
||||||
|
|
||||||
// Get current stats
|
// Get current stats
|
||||||
fmt.Println("📊 Current Database Statistics:")
|
fmt.Println("📊 Current Database Statistics:")
|
||||||
stats, err := optimizer.GetOptimizationStats()
|
stats, err := optimizer.GetOptimizationStats()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get database stats: %w", err)
|
return fmt.Errorf("failed to get database stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" • Size: %.1f MB\n", float64(stats.DatabaseSize)/(1024*1024))
|
fmt.Printf(" • Size: %.1f MB\n", float64(stats.DatabaseSize)/(1024*1024))
|
||||||
fmt.Printf(" • Page Size: %d bytes\n", stats.PageSize)
|
fmt.Printf(" • Page Size: %d bytes\n", stats.PageSize)
|
||||||
fmt.Printf(" • Total Pages: %d\n", stats.PageCount)
|
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(" • Free Pages: %d\n", stats.FreePages)
|
||||||
fmt.Printf(" • Efficiency: %.1f%%\n", stats.Efficiency)
|
fmt.Printf(" • Efficiency: %.1f%%\n", stats.Efficiency)
|
||||||
fmt.Printf(" • Auto VACUUM: %v\n", stats.AutoVacuumEnabled)
|
fmt.Printf(" • Auto VACUUM: %v\n", stats.AutoVacuumEnabled)
|
||||||
|
|
||||||
// Check if optimization is needed
|
// Check if optimization is needed
|
||||||
needsOptimization := stats.FreePages > 0 || stats.Efficiency < 95.0
|
needsOptimization := stats.FreePages > 0 || stats.Efficiency < 95.0
|
||||||
|
|
||||||
if !needsOptimization && !force {
|
if !needsOptimization && !force {
|
||||||
fmt.Println("✅ Database is already well optimized!")
|
fmt.Println("✅ Database is already well optimized!")
|
||||||
fmt.Println(" Use --force to run optimization anyway")
|
fmt.Println(" Use --force to run optimization anyway")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform optimizations
|
// Perform optimizations
|
||||||
if force && !needsOptimization {
|
if force && !needsOptimization {
|
||||||
fmt.Println("\n🔧 Force optimization requested:")
|
fmt.Println("\n🔧 Force optimization requested:")
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("\n🔧 Applying Optimizations:")
|
fmt.Println("\n🔧 Applying Optimizations:")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := optimizer.VacuumDatabase(); err != nil {
|
if err := optimizer.VacuumDatabase(); err != nil {
|
||||||
return fmt.Errorf("VACUUM failed: %w", err)
|
return fmt.Errorf("VACUUM failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := optimizer.OptimizeDatabase(); err != nil {
|
if err := optimizer.OptimizeDatabase(); err != nil {
|
||||||
return fmt.Errorf("optimization failed: %w", err)
|
return fmt.Errorf("optimization failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show final stats
|
// Show final stats
|
||||||
fmt.Println("\n📈 Final Statistics:")
|
fmt.Println("\n📈 Final Statistics:")
|
||||||
finalStats, err := optimizer.GetOptimizationStats()
|
finalStats, err := optimizer.GetOptimizationStats()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get final stats: %w", err)
|
return fmt.Errorf("failed to get final stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" • Size: %.1f MB\n", float64(finalStats.DatabaseSize)/(1024*1024))
|
fmt.Printf(" • Size: %.1f MB\n", float64(finalStats.DatabaseSize)/(1024*1024))
|
||||||
fmt.Printf(" • Efficiency: %.1f%%\n", finalStats.Efficiency)
|
fmt.Printf(" • Efficiency: %.1f%%\n", finalStats.Efficiency)
|
||||||
fmt.Printf(" • Free Pages: %d\n", finalStats.FreePages)
|
fmt.Printf(" • Free Pages: %d\n", finalStats.FreePages)
|
||||||
|
|
||||||
if stats.DatabaseSize > finalStats.DatabaseSize {
|
if stats.DatabaseSize > finalStats.DatabaseSize {
|
||||||
saved := stats.DatabaseSize - finalStats.DatabaseSize
|
saved := stats.DatabaseSize - finalStats.DatabaseSize
|
||||||
fmt.Printf(" • Space Saved: %.1f MB\n", float64(saved)/(1024*1024))
|
fmt.Printf(" • Space Saved: %.1f MB\n", float64(saved)/(1024*1024))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("\n✅ Database optimization completed!")
|
fmt.Println("\n✅ Database optimization completed!")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@ import (
|
||||||
type ExternalAPIClient struct {
|
type ExternalAPIClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
maxRetries int
|
maxRetries int
|
||||||
userAgent string
|
userAgent string
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
lastRequest time.Time
|
lastRequest time.Time
|
||||||
minInterval time.Duration
|
minInterval time.Duration
|
||||||
|
|
@ -32,28 +32,28 @@ type APIClientConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenSkyFlightInfo struct {
|
type OpenSkyFlightInfo struct {
|
||||||
ICAO string `json:"icao"`
|
ICAO string `json:"icao"`
|
||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
Origin string `json:"origin"`
|
Origin string `json:"origin"`
|
||||||
Destination string `json:"destination"`
|
Destination string `json:"destination"`
|
||||||
FirstSeen time.Time `json:"first_seen"`
|
FirstSeen time.Time `json:"first_seen"`
|
||||||
LastSeen time.Time `json:"last_seen"`
|
LastSeen time.Time `json:"last_seen"`
|
||||||
AircraftType string `json:"aircraft_type"`
|
AircraftType string `json:"aircraft_type"`
|
||||||
Registration string `json:"registration"`
|
Registration string `json:"registration"`
|
||||||
FlightNumber string `json:"flight_number"`
|
FlightNumber string `json:"flight_number"`
|
||||||
Airline string `json:"airline"`
|
Airline string `json:"airline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIError struct {
|
type APIError struct {
|
||||||
Operation string
|
Operation string
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Message string
|
Message string
|
||||||
Retryable bool
|
Retryable bool
|
||||||
RetryAfter time.Duration
|
RetryAfter time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
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)
|
e.Operation, e.Message, e.StatusCode, e.Retryable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ func NewExternalAPIClient(config APIClientConfig) *ExternalAPIClient {
|
||||||
if config.MinInterval == 0 {
|
if config.MinInterval == 0 {
|
||||||
config.MinInterval = 1 * time.Second // Default rate limit
|
config.MinInterval = 1 * time.Second // Default rate limit
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ExternalAPIClient{
|
return &ExternalAPIClient{
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: config.Timeout,
|
Timeout: config.Timeout,
|
||||||
|
|
@ -85,7 +85,7 @@ func NewExternalAPIClient(config APIClientConfig) *ExternalAPIClient {
|
||||||
func (c *ExternalAPIClient) enforceRateLimit() {
|
func (c *ExternalAPIClient) enforceRateLimit() {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
elapsed := time.Since(c.lastRequest)
|
elapsed := time.Since(c.lastRequest)
|
||||||
if elapsed < c.minInterval {
|
if elapsed < c.minInterval {
|
||||||
time.Sleep(c.minInterval - elapsed)
|
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) {
|
func (c *ExternalAPIClient) makeRequest(ctx context.Context, url string) (*http.Response, error) {
|
||||||
c.enforceRateLimit()
|
c.enforceRateLimit()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", c.userAgent)
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
// Exponential backoff
|
// Exponential backoff
|
||||||
|
|
@ -117,16 +117,16 @@ func (c *ExternalAPIClient) makeRequest(ctx context.Context, url string) (*http.
|
||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, lastErr = c.httpClient.Do(req)
|
resp, lastErr = c.httpClient.Do(req)
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for retryable status codes
|
// Check for retryable status codes
|
||||||
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
|
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
// Handle rate limiting
|
// Handle rate limiting
|
||||||
if resp.StatusCode == 429 {
|
if resp.StatusCode == 429 {
|
||||||
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
|
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
|
||||||
|
|
@ -140,15 +140,15 @@ func (c *ExternalAPIClient) makeRequest(ctx context.Context, url string) (*http.
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success or non-retryable error
|
// Success or non-retryable error
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,14 +156,14 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
|
||||||
if icao == "" {
|
if icao == "" {
|
||||||
return nil, fmt.Errorf("empty ICAO code")
|
return nil, fmt.Errorf("empty ICAO code")
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSky Network API endpoint for flight information
|
// OpenSky Network API endpoint for flight information
|
||||||
apiURL := fmt.Sprintf("https://opensky-network.org/api/flights/aircraft?icao24=%s&begin=%d&end=%d",
|
apiURL := fmt.Sprintf("https://opensky-network.org/api/flights/aircraft?icao24=%s&begin=%d&end=%d",
|
||||||
icao,
|
icao,
|
||||||
time.Now().Add(-24*time.Hour).Unix(),
|
time.Now().Add(-24*time.Hour).Unix(),
|
||||||
time.Now().Unix(),
|
time.Now().Unix(),
|
||||||
)
|
)
|
||||||
|
|
||||||
resp, err := c.makeRequest(ctx, apiURL)
|
resp, err := c.makeRequest(ctx, apiURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &APIError{
|
return nil, &APIError{
|
||||||
|
|
@ -173,7 +173,7 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return nil, &APIError{
|
return nil, &APIError{
|
||||||
|
|
@ -183,7 +183,7 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
|
||||||
Retryable: resp.StatusCode >= 500 || resp.StatusCode == 429,
|
Retryable: resp.StatusCode >= 500 || resp.StatusCode == 429,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var flights [][]interface{}
|
var flights [][]interface{}
|
||||||
decoder := json.NewDecoder(resp.Body)
|
decoder := json.NewDecoder(resp.Body)
|
||||||
if err := decoder.Decode(&flights); err != nil {
|
if err := decoder.Decode(&flights); err != nil {
|
||||||
|
|
@ -193,11 +193,11 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
|
||||||
Retryable: false,
|
Retryable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(flights) == 0 {
|
if len(flights) == 0 {
|
||||||
return nil, nil // No flight information available
|
return nil, nil // No flight information available
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the most recent flight
|
// Parse the most recent flight
|
||||||
flight := flights[0]
|
flight := flights[0]
|
||||||
if len(flight) < 10 {
|
if len(flight) < 10 {
|
||||||
|
|
@ -207,11 +207,11 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
|
||||||
Retryable: false,
|
Retryable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info := &OpenSkyFlightInfo{
|
info := &OpenSkyFlightInfo{
|
||||||
ICAO: icao,
|
ICAO: icao,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse fields based on OpenSky API documentation
|
// Parse fields based on OpenSky API documentation
|
||||||
if callsign, ok := flight[1].(string); ok {
|
if callsign, ok := flight[1].(string); ok {
|
||||||
info.Callsign = callsign
|
info.Callsign = callsign
|
||||||
|
|
@ -228,7 +228,7 @@ func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao s
|
||||||
if destination, ok := flight[5].(string); ok {
|
if destination, ok := flight[5].(string); ok {
|
||||||
info.Destination = destination
|
info.Destination = destination
|
||||||
}
|
}
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,10 +236,10 @@ func (c *ExternalAPIClient) GetAircraftInfoFromOpenSky(ctx context.Context, icao
|
||||||
if icao == "" {
|
if icao == "" {
|
||||||
return nil, fmt.Errorf("empty ICAO code")
|
return nil, fmt.Errorf("empty ICAO code")
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSky Network metadata API
|
// OpenSky Network metadata API
|
||||||
apiURL := fmt.Sprintf("https://opensky-network.org/api/metadata/aircraft/icao/%s", icao)
|
apiURL := fmt.Sprintf("https://opensky-network.org/api/metadata/aircraft/icao/%s", icao)
|
||||||
|
|
||||||
resp, err := c.makeRequest(ctx, apiURL)
|
resp, err := c.makeRequest(ctx, apiURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &APIError{
|
return nil, &APIError{
|
||||||
|
|
@ -249,11 +249,11 @@ func (c *ExternalAPIClient) GetAircraftInfoFromOpenSky(ctx context.Context, icao
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
return nil, nil // Aircraft not found
|
return nil, nil // Aircraft not found
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return nil, &APIError{
|
return nil, &APIError{
|
||||||
|
|
@ -263,7 +263,7 @@ func (c *ExternalAPIClient) GetAircraftInfoFromOpenSky(ctx context.Context, icao
|
||||||
Retryable: resp.StatusCode >= 500 || resp.StatusCode == 429,
|
Retryable: resp.StatusCode >= 500 || resp.StatusCode == 429,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var aircraft map[string]interface{}
|
var aircraft map[string]interface{}
|
||||||
decoder := json.NewDecoder(resp.Body)
|
decoder := json.NewDecoder(resp.Body)
|
||||||
if err := decoder.Decode(&aircraft); err != nil {
|
if err := decoder.Decode(&aircraft); err != nil {
|
||||||
|
|
@ -273,7 +273,7 @@ func (c *ExternalAPIClient) GetAircraftInfoFromOpenSky(ctx context.Context, icao
|
||||||
Retryable: false,
|
Retryable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return aircraft, nil
|
return aircraft, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,7 +282,7 @@ func (c *ExternalAPIClient) EnhanceCallsignWithExternalData(ctx context.Context,
|
||||||
enhancement["callsign"] = callsign
|
enhancement["callsign"] = callsign
|
||||||
enhancement["icao"] = icao
|
enhancement["icao"] = icao
|
||||||
enhancement["enhanced"] = false
|
enhancement["enhanced"] = false
|
||||||
|
|
||||||
// Try to get flight information from OpenSky
|
// Try to get flight information from OpenSky
|
||||||
if flightInfo, err := c.GetFlightInfoFromOpenSky(ctx, icao); err == nil && flightInfo != nil {
|
if flightInfo, err := c.GetFlightInfoFromOpenSky(ctx, icao); err == nil && flightInfo != nil {
|
||||||
enhancement["flight_info"] = map[string]interface{}{
|
enhancement["flight_info"] = map[string]interface{}{
|
||||||
|
|
@ -295,53 +295,53 @@ func (c *ExternalAPIClient) EnhanceCallsignWithExternalData(ctx context.Context,
|
||||||
}
|
}
|
||||||
enhancement["enhanced"] = true
|
enhancement["enhanced"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get aircraft metadata
|
// Try to get aircraft metadata
|
||||||
if aircraftInfo, err := c.GetAircraftInfoFromOpenSky(ctx, icao); err == nil && aircraftInfo != nil {
|
if aircraftInfo, err := c.GetAircraftInfoFromOpenSky(ctx, icao); err == nil && aircraftInfo != nil {
|
||||||
enhancement["aircraft_info"] = aircraftInfo
|
enhancement["aircraft_info"] = aircraftInfo
|
||||||
enhancement["enhanced"] = true
|
enhancement["enhanced"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return enhancement, nil
|
return enhancement, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ExternalAPIClient) BatchEnhanceCallsigns(ctx context.Context, callsigns map[string]string) (map[string]map[string]interface{}, error) {
|
func (c *ExternalAPIClient) BatchEnhanceCallsigns(ctx context.Context, callsigns map[string]string) (map[string]map[string]interface{}, error) {
|
||||||
results := make(map[string]map[string]interface{})
|
results := make(map[string]map[string]interface{})
|
||||||
|
|
||||||
for callsign, icao := range callsigns {
|
for callsign, icao := range callsigns {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return results, ctx.Err()
|
return results, ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
enhanced, err := c.EnhanceCallsignWithExternalData(ctx, callsign, icao)
|
enhanced, err := c.EnhanceCallsignWithExternalData(ctx, callsign, icao)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Log error but continue with other callsigns
|
// Log error but continue with other callsigns
|
||||||
fmt.Printf("Warning: failed to enhance callsign %s (ICAO: %s): %v\n", callsign, icao, err)
|
fmt.Printf("Warning: failed to enhance callsign %s (ICAO: %s): %v\n", callsign, icao, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
results[callsign] = enhanced
|
results[callsign] = enhanced
|
||||||
}
|
}
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ExternalAPIClient) TestConnection(ctx context.Context) error {
|
func (c *ExternalAPIClient) TestConnection(ctx context.Context) error {
|
||||||
// Test with a simple API call
|
// Test with a simple API call
|
||||||
testURL := "https://opensky-network.org/api/states?time=0&lamin=0&lomin=0&lamax=1&lomax=1"
|
testURL := "https://opensky-network.org/api/states?time=0&lamin=0&lomin=0&lamax=1&lomax=1"
|
||||||
|
|
||||||
resp, err := c.makeRequest(ctx, testURL)
|
resp, err := c.makeRequest(ctx, testURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connection test failed: %w", err)
|
return fmt.Errorf("connection test failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("connection test returned status %d", resp.StatusCode)
|
return fmt.Errorf("connection test returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,24 +349,24 @@ func parseRetryAfter(header string) time.Duration {
|
||||||
if header == "" {
|
if header == "" {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as seconds
|
// Try parsing as seconds
|
||||||
if seconds, err := time.ParseDuration(header + "s"); err == nil {
|
if seconds, err := time.ParseDuration(header + "s"); err == nil {
|
||||||
return seconds
|
return seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as HTTP date
|
// Try parsing as HTTP date
|
||||||
if t, err := http.ParseTime(header); err == nil {
|
if t, err := http.ParseTime(header); err == nil {
|
||||||
return time.Until(t)
|
return time.Until(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthCheck provides information about the client's health
|
// HealthCheck provides information about the client's health
|
||||||
func (c *ExternalAPIClient) HealthCheck(ctx context.Context) map[string]interface{} {
|
func (c *ExternalAPIClient) HealthCheck(ctx context.Context) map[string]interface{} {
|
||||||
health := make(map[string]interface{})
|
health := make(map[string]interface{})
|
||||||
|
|
||||||
// Test connection
|
// Test connection
|
||||||
if err := c.TestConnection(ctx); err != nil {
|
if err := c.TestConnection(ctx); err != nil {
|
||||||
health["status"] = "unhealthy"
|
health["status"] = "unhealthy"
|
||||||
|
|
@ -374,16 +374,16 @@ func (c *ExternalAPIClient) HealthCheck(ctx context.Context) map[string]interfac
|
||||||
} else {
|
} else {
|
||||||
health["status"] = "healthy"
|
health["status"] = "healthy"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add configuration info
|
// Add configuration info
|
||||||
health["timeout"] = c.timeout.String()
|
health["timeout"] = c.timeout.String()
|
||||||
health["max_retries"] = c.maxRetries
|
health["max_retries"] = c.maxRetries
|
||||||
health["min_interval"] = c.minInterval.String()
|
health["min_interval"] = c.minInterval.String()
|
||||||
health["user_agent"] = c.userAgent
|
health["user_agent"] = c.userAgent
|
||||||
|
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
health["last_request"] = c.lastRequest
|
health["last_request"] = c.lastRequest
|
||||||
c.mutex.RUnlock()
|
c.mutex.RUnlock()
|
||||||
|
|
||||||
return health
|
return health
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,35 +19,35 @@ import (
|
||||||
|
|
||||||
// Database represents the main database connection and operations
|
// Database represents the main database connection and operations
|
||||||
type Database struct {
|
type Database struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
config *Config
|
config *Config
|
||||||
migrator *Migrator
|
migrator *Migrator
|
||||||
callsign *CallsignManager
|
callsign *CallsignManager
|
||||||
history *HistoryManager
|
history *HistoryManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds database configuration options
|
// Config holds database configuration options
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Database file path (auto-resolved if empty)
|
// Database file path (auto-resolved if empty)
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
|
||||||
// Data retention settings
|
// 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"`
|
BackupOnUpgrade bool `json:"backup_on_upgrade"`
|
||||||
|
|
||||||
// Connection settings
|
// Connection settings
|
||||||
MaxOpenConns int `json:"max_open_conns"` // Default: 10
|
MaxOpenConns int `json:"max_open_conns"` // Default: 10
|
||||||
MaxIdleConns int `json:"max_idle_conns"` // Default: 5
|
MaxIdleConns int `json:"max_idle_conns"` // Default: 5
|
||||||
ConnMaxLifetime time.Duration `json:"conn_max_lifetime"` // Default: 1 hour
|
ConnMaxLifetime time.Duration `json:"conn_max_lifetime"` // Default: 1 hour
|
||||||
|
|
||||||
// Maintenance settings
|
// 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
|
CleanupInterval time.Duration `json:"cleanup_interval"` // Default: 1 hour
|
||||||
|
|
||||||
// Compression settings
|
// Compression settings
|
||||||
EnableCompression bool `json:"enable_compression"` // Enable automatic compression
|
EnableCompression bool `json:"enable_compression"` // Enable automatic compression
|
||||||
CompressionLevel int `json:"compression_level"` // Compression level (1-9, default: 6)
|
CompressionLevel int `json:"compression_level"` // Compression level (1-9, default: 6)
|
||||||
PageSize int `json:"page_size"` // SQLite page size (default: 4096)
|
PageSize int `json:"page_size"` // SQLite page size (default: 4096)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AircraftHistoryRecord represents a stored aircraft position update
|
// AircraftHistoryRecord represents a stored aircraft position update
|
||||||
|
|
@ -93,18 +93,18 @@ type AirlineRecord struct {
|
||||||
|
|
||||||
// AirportRecord represents embedded airport data from OpenFlights
|
// AirportRecord represents embedded airport data from OpenFlights
|
||||||
type AirportRecord struct {
|
type AirportRecord struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
IATA string `json:"iata"`
|
IATA string `json:"iata"`
|
||||||
ICAO string `json:"icao"`
|
ICAO string `json:"icao"`
|
||||||
Latitude float64 `json:"latitude"`
|
Latitude float64 `json:"latitude"`
|
||||||
Longitude float64 `json:"longitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
Altitude int `json:"altitude"`
|
Altitude int `json:"altitude"`
|
||||||
TimezoneOffset float64 `json:"timezone_offset"`
|
TimezoneOffset float64 `json:"timezone_offset"`
|
||||||
DST string `json:"dst"`
|
DST string `json:"dst"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseError represents database operation errors
|
// DatabaseError represents database operation errors
|
||||||
|
|
@ -131,7 +131,7 @@ func NewDatabase(config *Config) (*Database, error) {
|
||||||
if config == nil {
|
if config == nil {
|
||||||
config = DefaultConfig()
|
config = DefaultConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve database path
|
// Resolve database path
|
||||||
dbPath, err := ResolveDatabasePath(config.Path)
|
dbPath, err := ResolveDatabasePath(config.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -142,7 +142,7 @@ func NewDatabase(config *Config) (*Database, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
config.Path = dbPath
|
config.Path = dbPath
|
||||||
|
|
||||||
// Open database connection
|
// Open database connection
|
||||||
conn, err := sql.Open("sqlite3", buildConnectionString(dbPath))
|
conn, err := sql.Open("sqlite3", buildConnectionString(dbPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -152,12 +152,12 @@ func NewDatabase(config *Config) (*Database, error) {
|
||||||
Retryable: true,
|
Retryable: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure connection pool
|
// Configure connection pool
|
||||||
conn.SetMaxOpenConns(config.MaxOpenConns)
|
conn.SetMaxOpenConns(config.MaxOpenConns)
|
||||||
conn.SetMaxIdleConns(config.MaxIdleConns)
|
conn.SetMaxIdleConns(config.MaxIdleConns)
|
||||||
conn.SetConnMaxLifetime(config.ConnMaxLifetime)
|
conn.SetConnMaxLifetime(config.ConnMaxLifetime)
|
||||||
|
|
||||||
// Test connection
|
// Test connection
|
||||||
if err := conn.Ping(); err != nil {
|
if err := conn.Ping(); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
|
@ -167,17 +167,17 @@ func NewDatabase(config *Config) (*Database, error) {
|
||||||
Retryable: true,
|
Retryable: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db := &Database{
|
db := &Database{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
config: config,
|
config: config,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize components
|
// Initialize components
|
||||||
db.migrator = NewMigrator(conn)
|
db.migrator = NewMigrator(conn)
|
||||||
db.callsign = NewCallsignManager(conn)
|
db.callsign = NewCallsignManager(conn)
|
||||||
db.history = NewHistoryManager(conn, config.MaxHistoryDays)
|
db.history = NewHistoryManager(conn, config.MaxHistoryDays)
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,7 +191,7 @@ func (db *Database) Initialize() error {
|
||||||
Retryable: false,
|
Retryable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load embedded OpenFlights data if not already loaded
|
// Load embedded OpenFlights data if not already loaded
|
||||||
if err := db.callsign.LoadEmbeddedData(); err != nil {
|
if err := db.callsign.LoadEmbeddedData(); err != nil {
|
||||||
return &DatabaseError{
|
return &DatabaseError{
|
||||||
|
|
@ -200,7 +200,7 @@ func (db *Database) Initialize() error {
|
||||||
Retryable: false,
|
Retryable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,7 +240,6 @@ func (db *Database) Health() error {
|
||||||
return db.conn.Ping()
|
return db.conn.Ping()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// DefaultConfig returns the default database configuration
|
// DefaultConfig returns the default database configuration
|
||||||
func DefaultConfig() *Config {
|
func DefaultConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
|
|
@ -258,4 +257,4 @@ func DefaultConfig() *Config {
|
||||||
// buildConnectionString creates SQLite connection string with optimizations
|
// buildConnectionString creates SQLite connection string with optimizations
|
||||||
func buildConnectionString(path string) string {
|
func buildConnectionString(path string) string {
|
||||||
return fmt.Sprintf("%s?_journal_mode=WAL&_synchronous=NORMAL&_cache_size=-64000&_temp_store=MEMORY&_foreign_keys=ON", path)
|
return fmt.Sprintf("%s?_journal_mode=WAL&_synchronous=NORMAL&_cache_size=-64000&_temp_store=MEMORY&_foreign_keys=ON", path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,4 +164,4 @@ func TestDatabasePragmas(t *testing.T) {
|
||||||
if journalMode != "wal" {
|
if journalMode != "wal" {
|
||||||
t.Errorf("Expected WAL journal mode, got: %s", journalMode)
|
t.Errorf("Expected WAL journal mode, got: %s", journalMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,32 +37,32 @@ type DataSource struct {
|
||||||
|
|
||||||
// LoadResult contains the results of a data loading operation
|
// LoadResult contains the results of a data loading operation
|
||||||
type LoadResult struct {
|
type LoadResult struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
RecordsTotal int `json:"records_total"`
|
RecordsTotal int `json:"records_total"`
|
||||||
RecordsNew int `json:"records_new"`
|
RecordsNew int `json:"records_new"`
|
||||||
RecordsError int `json:"records_error"`
|
RecordsError int `json:"records_error"`
|
||||||
Duration time.Duration `json:"duration"`
|
Duration time.Duration `json:"duration"`
|
||||||
Errors []string `json:"errors,omitempty"`
|
Errors []string `json:"errors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDataLoader creates a new data loader with HTTP client
|
// NewDataLoader creates a new data loader with HTTP client
|
||||||
func NewDataLoader(conn *sql.DB) *DataLoader {
|
func NewDataLoader(conn *sql.DB) *DataLoader {
|
||||||
// Check for insecure TLS environment variable
|
// Check for insecure TLS environment variable
|
||||||
insecureTLS := os.Getenv("SKYVIEW_INSECURE_TLS") == "1"
|
insecureTLS := os.Getenv("SKYVIEW_INSECURE_TLS") == "1"
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
MaxIdleConns: 10,
|
MaxIdleConns: 10,
|
||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
DisableCompression: false,
|
DisableCompression: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow insecure certificates if requested
|
// Allow insecure certificates if requested
|
||||||
if insecureTLS {
|
if insecureTLS {
|
||||||
transport.TLSClientConfig = &tls.Config{
|
transport.TLSClientConfig = &tls.Config{
|
||||||
InsecureSkipVerify: true,
|
InsecureSkipVerify: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &DataLoader{
|
return &DataLoader{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
|
|
@ -85,7 +85,7 @@ func GetAvailableDataSources() []DataSource {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "OpenFlights Airports",
|
Name: "OpenFlights Airports",
|
||||||
License: "AGPL-3.0",
|
License: "AGPL-3.0",
|
||||||
URL: "https://raw.githubusercontent.com/jpatokal/openflights/master/data/airports.dat",
|
URL: "https://raw.githubusercontent.com/jpatokal/openflights/master/data/airports.dat",
|
||||||
RequiresConsent: false, // Runtime data consumption doesn't require explicit consent
|
RequiresConsent: false, // Runtime data consumption doesn't require explicit consent
|
||||||
Format: "openflights",
|
Format: "openflights",
|
||||||
|
|
@ -111,23 +111,23 @@ func (dl *DataLoader) LoadDataSource(source DataSource) (*LoadResult, error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
result.Duration = time.Since(startTime)
|
result.Duration = time.Since(startTime)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Check license acceptance if required
|
// Check license acceptance if required
|
||||||
if source.RequiresConsent && !source.UserAcceptedLicense {
|
if source.RequiresConsent && !source.UserAcceptedLicense {
|
||||||
return nil, fmt.Errorf("user has not accepted license for source: %s (%s)", source.Name, source.License)
|
return nil, fmt.Errorf("user has not accepted license for source: %s (%s)", source.Name, source.License)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download data
|
// Download data
|
||||||
resp, err := dl.client.Get(source.URL)
|
resp, err := dl.client.Get(source.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to download data from %s: %v", source.URL, err)
|
return nil, fmt.Errorf("failed to download data from %s: %v", source.URL, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("HTTP error downloading data: %s", resp.Status)
|
return nil, fmt.Errorf("HTTP error downloading data: %s", resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and load data based on format
|
// Parse and load data based on format
|
||||||
switch source.Format {
|
switch source.Format {
|
||||||
case "openflights":
|
case "openflights":
|
||||||
|
|
@ -137,10 +137,10 @@ func (dl *DataLoader) LoadDataSource(source DataSource) (*LoadResult, error) {
|
||||||
return dl.loadOpenFlightsAirports(resp.Body, source, result)
|
return dl.loadOpenFlightsAirports(resp.Body, source, result)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("unknown OpenFlights data type: %s", source.Name)
|
return nil, fmt.Errorf("unknown OpenFlights data type: %s", source.Name)
|
||||||
|
|
||||||
case "ourairports":
|
case "ourairports":
|
||||||
return dl.loadOurAirports(resp.Body, source, result)
|
return dl.loadOurAirports(resp.Body, source, result)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported data format: %s", source.Format)
|
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)
|
return nil, fmt.Errorf("failed to begin transaction: %v", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Record data source
|
// Record data source
|
||||||
if err := dl.recordDataSource(tx, source); err != nil {
|
if err := dl.recordDataSource(tx, source); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing data from this source
|
// Clear existing data from this source
|
||||||
_, err = tx.Exec(`DELETE FROM airlines WHERE data_source = ?`, source.Name)
|
_, err = tx.Exec(`DELETE FROM airlines WHERE data_source = ?`, source.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to clear existing airline data: %v", err)
|
return nil, fmt.Errorf("failed to clear existing airline data: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
csvReader := csv.NewReader(reader)
|
csvReader := csv.NewReader(reader)
|
||||||
csvReader.FieldsPerRecord = -1 // Variable number of fields
|
csvReader.FieldsPerRecord = -1 // Variable number of fields
|
||||||
|
|
||||||
insertStmt, err := tx.Prepare(`
|
insertStmt, err := tx.Prepare(`
|
||||||
INSERT OR REPLACE INTO airlines (id, name, alias, iata_code, icao_code, callsign, country, active, data_source)
|
INSERT OR REPLACE INTO airlines (id, name, alias, iata_code, icao_code, callsign, country, active, data_source)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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)
|
return nil, fmt.Errorf("failed to prepare insert statement: %v", err)
|
||||||
}
|
}
|
||||||
defer insertStmt.Close()
|
defer insertStmt.Close()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
record, err := csvReader.Read()
|
record, err := csvReader.Read()
|
||||||
if err == io.EOF {
|
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))
|
result.Errors = append(result.Errors, fmt.Sprintf("CSV parse error: %v", err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(record) < 7 {
|
if len(record) < 7 {
|
||||||
result.RecordsError++
|
result.RecordsError++
|
||||||
result.Errors = append(result.Errors, "insufficient fields in record")
|
result.Errors = append(result.Errors, "insufficient fields in record")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result.RecordsTotal++
|
result.RecordsTotal++
|
||||||
|
|
||||||
// Parse OpenFlights airline format:
|
// Parse OpenFlights airline format:
|
||||||
// ID, Name, Alias, IATA, ICAO, Callsign, Country, Active
|
// ID, Name, Alias, IATA, ICAO, Callsign, Country, Active
|
||||||
id, _ := strconv.Atoi(record[0])
|
id, _ := strconv.Atoi(record[0])
|
||||||
|
|
@ -206,29 +206,37 @@ func (dl *DataLoader) loadOpenFlightsAirlines(reader io.Reader, source DataSourc
|
||||||
callsign := strings.Trim(record[5], `"`)
|
callsign := strings.Trim(record[5], `"`)
|
||||||
country := strings.Trim(record[6], `"`)
|
country := strings.Trim(record[6], `"`)
|
||||||
active := len(record) > 7 && strings.Trim(record[7], `"`) == "Y"
|
active := len(record) > 7 && strings.Trim(record[7], `"`) == "Y"
|
||||||
|
|
||||||
// Convert \N to empty strings
|
// Convert \N to empty strings
|
||||||
if alias == "\\N" { alias = "" }
|
if alias == "\\N" {
|
||||||
if iata == "\\N" { iata = "" }
|
alias = ""
|
||||||
if icao == "\\N" { icao = "" }
|
}
|
||||||
if callsign == "\\N" { callsign = "" }
|
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)
|
_, err = insertStmt.Exec(id, name, alias, iata, icao, callsign, country, active, source.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.RecordsError++
|
result.RecordsError++
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("insert error for airline %s: %v", name, err))
|
result.Errors = append(result.Errors, fmt.Sprintf("insert error for airline %s: %v", name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result.RecordsNew++
|
result.RecordsNew++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update record count
|
// Update record count
|
||||||
_, err = tx.Exec(`UPDATE data_sources SET record_count = ? WHERE name = ?`, result.RecordsNew, source.Name)
|
_, err = tx.Exec(`UPDATE data_sources SET record_count = ? WHERE name = ?`, result.RecordsNew, source.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to update record count: %v", err)
|
return nil, fmt.Errorf("failed to update record count: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, tx.Commit()
|
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)
|
return nil, fmt.Errorf("failed to begin transaction: %v", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Record data source
|
// Record data source
|
||||||
if err := dl.recordDataSource(tx, source); err != nil {
|
if err := dl.recordDataSource(tx, source); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing data from this source
|
// Clear existing data from this source
|
||||||
_, err = tx.Exec(`DELETE FROM airports WHERE data_source = ?`, source.Name)
|
_, err = tx.Exec(`DELETE FROM airports WHERE data_source = ?`, source.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to clear existing airport data: %v", err)
|
return nil, fmt.Errorf("failed to clear existing airport data: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
csvReader := csv.NewReader(reader)
|
csvReader := csv.NewReader(reader)
|
||||||
csvReader.FieldsPerRecord = -1
|
csvReader.FieldsPerRecord = -1
|
||||||
|
|
||||||
insertStmt, err := tx.Prepare(`
|
insertStmt, err := tx.Prepare(`
|
||||||
INSERT OR REPLACE INTO airports (id, name, city, country, iata_code, icao_code, latitude, longitude,
|
INSERT OR REPLACE INTO airports (id, name, city, country, iata_code, icao_code, latitude, longitude,
|
||||||
elevation_ft, timezone_offset, dst_type, timezone, data_source)
|
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)
|
return nil, fmt.Errorf("failed to prepare insert statement: %v", err)
|
||||||
}
|
}
|
||||||
defer insertStmt.Close()
|
defer insertStmt.Close()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
record, err := csvReader.Read()
|
record, err := csvReader.Read()
|
||||||
if err == io.EOF {
|
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))
|
result.Errors = append(result.Errors, fmt.Sprintf("CSV parse error: %v", err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(record) < 12 {
|
if len(record) < 12 {
|
||||||
result.RecordsError++
|
result.RecordsError++
|
||||||
result.Errors = append(result.Errors, "insufficient fields in airport record")
|
result.Errors = append(result.Errors, "insufficient fields in airport record")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result.RecordsTotal++
|
result.RecordsTotal++
|
||||||
|
|
||||||
// Parse OpenFlights airport format
|
// Parse OpenFlights airport format
|
||||||
id, _ := strconv.Atoi(record[0])
|
id, _ := strconv.Atoi(record[0])
|
||||||
name := strings.Trim(record[1], `"`)
|
name := strings.Trim(record[1], `"`)
|
||||||
|
|
@ -296,29 +304,37 @@ func (dl *DataLoader) loadOpenFlightsAirports(reader io.Reader, source DataSourc
|
||||||
tzOffset, _ := strconv.ParseFloat(record[9], 64)
|
tzOffset, _ := strconv.ParseFloat(record[9], 64)
|
||||||
dst := strings.Trim(record[10], `"`)
|
dst := strings.Trim(record[10], `"`)
|
||||||
timezone := strings.Trim(record[11], `"`)
|
timezone := strings.Trim(record[11], `"`)
|
||||||
|
|
||||||
// Convert \N to empty strings
|
// Convert \N to empty strings
|
||||||
if iata == "\\N" { iata = "" }
|
if iata == "\\N" {
|
||||||
if icao == "\\N" { icao = "" }
|
iata = ""
|
||||||
if dst == "\\N" { dst = "" }
|
}
|
||||||
if timezone == "\\N" { timezone = "" }
|
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)
|
_, err = insertStmt.Exec(id, name, city, country, iata, icao, lat, lon, alt, tzOffset, dst, timezone, source.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.RecordsError++
|
result.RecordsError++
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("insert error for airport %s: %v", name, err))
|
result.Errors = append(result.Errors, fmt.Sprintf("insert error for airport %s: %v", name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result.RecordsNew++
|
result.RecordsNew++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update record count
|
// Update record count
|
||||||
_, err = tx.Exec(`UPDATE data_sources SET record_count = ? WHERE name = ?`, result.RecordsNew, source.Name)
|
_, err = tx.Exec(`UPDATE data_sources SET record_count = ? WHERE name = ?`, result.RecordsNew, source.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to update record count: %v", err)
|
return nil, fmt.Errorf("failed to update record count: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, tx.Commit()
|
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)
|
return nil, fmt.Errorf("failed to begin transaction: %v", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
csvReader := csv.NewReader(reader)
|
csvReader := csv.NewReader(reader)
|
||||||
|
|
||||||
// Read header row
|
// Read header row
|
||||||
headers, err := csvReader.Read()
|
headers, err := csvReader.Read()
|
||||||
if err != nil {
|
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)}
|
result.Errors = []string{fmt.Sprintf("Failed to read CSV header: %v", err)}
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create header index map for easier field access
|
// Create header index map for easier field access
|
||||||
headerIndex := make(map[string]int)
|
headerIndex := make(map[string]int)
|
||||||
for i, header := range headers {
|
for i, header := range headers {
|
||||||
headerIndex[strings.TrimSpace(header)] = i
|
headerIndex[strings.TrimSpace(header)] = i
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare statement for airports
|
// Prepare statement for airports
|
||||||
stmt, err := tx.Prepare(`
|
stmt, err := tx.Prepare(`
|
||||||
INSERT OR REPLACE INTO airports (
|
INSERT OR REPLACE INTO airports (
|
||||||
|
|
@ -362,7 +378,7 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
|
|
||||||
// Process each row
|
// Process each row
|
||||||
for {
|
for {
|
||||||
record, err := csvReader.Read()
|
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))
|
result.Errors = append(result.Errors, fmt.Sprintf("CSV read error: %v", err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip rows with insufficient fields
|
// Skip rows with insufficient fields
|
||||||
if len(record) < len(headerIndex) {
|
if len(record) < len(headerIndex) {
|
||||||
result.RecordsError++
|
result.RecordsError++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract fields using header index
|
// Extract fields using header index
|
||||||
sourceID := getFieldByHeader(record, headerIndex, "id")
|
sourceID := getFieldByHeader(record, headerIndex, "id")
|
||||||
ident := getFieldByHeader(record, headerIndex, "ident")
|
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")
|
homeLink := getFieldByHeader(record, headerIndex, "home_link")
|
||||||
wikipediaLink := getFieldByHeader(record, headerIndex, "wikipedia_link")
|
wikipediaLink := getFieldByHeader(record, headerIndex, "wikipedia_link")
|
||||||
keywords := getFieldByHeader(record, headerIndex, "keywords")
|
keywords := getFieldByHeader(record, headerIndex, "keywords")
|
||||||
|
|
||||||
// Parse coordinates
|
// Parse coordinates
|
||||||
var latitude, longitude float64
|
var latitude, longitude float64
|
||||||
if latStr := getFieldByHeader(record, headerIndex, "latitude_deg"); latStr != "" {
|
if latStr := getFieldByHeader(record, headerIndex, "latitude_deg"); latStr != "" {
|
||||||
|
|
@ -407,7 +423,7 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
|
||||||
longitude = lng
|
longitude = lng
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse elevation
|
// Parse elevation
|
||||||
var elevation int
|
var elevation int
|
||||||
if elevStr := getFieldByHeader(record, headerIndex, "elevation_ft"); elevStr != "" {
|
if elevStr := getFieldByHeader(record, headerIndex, "elevation_ft"); elevStr != "" {
|
||||||
|
|
@ -415,10 +431,10 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
|
||||||
elevation = elev
|
elevation = elev
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse scheduled service
|
// Parse scheduled service
|
||||||
scheduledService := getFieldByHeader(record, headerIndex, "scheduled_service") == "yes"
|
scheduledService := getFieldByHeader(record, headerIndex, "scheduled_service") == "yes"
|
||||||
|
|
||||||
// Insert airport record
|
// Insert airport record
|
||||||
_, err = stmt.Exec(
|
_, err = stmt.Exec(
|
||||||
sourceID, name, ident, airportType, icaoCode, iataCode,
|
sourceID, name, ident, airportType, icaoCode, iataCode,
|
||||||
|
|
@ -432,7 +448,7 @@ func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, resul
|
||||||
result.RecordsNew++
|
result.RecordsNew++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update data source tracking
|
// Update data source tracking
|
||||||
_, err = tx.Exec(`
|
_, err = tx.Exec(`
|
||||||
INSERT OR REPLACE INTO data_sources (name, license, url, imported_at, record_count, user_accepted_license)
|
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 {
|
if err != nil {
|
||||||
return result, fmt.Errorf("failed to update data source tracking: %v", err)
|
return result, fmt.Errorf("failed to update data source tracking: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, tx.Commit()
|
return result, tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -460,13 +476,13 @@ func (dl *DataLoader) GetLoadedDataSources() ([]DataSource, error) {
|
||||||
FROM data_sources
|
FROM data_sources
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := dl.conn.Query(query)
|
rows, err := dl.conn.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var sources []DataSource
|
var sources []DataSource
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var source DataSource
|
var source DataSource
|
||||||
|
|
@ -482,7 +498,7 @@ func (dl *DataLoader) GetLoadedDataSources() ([]DataSource, error) {
|
||||||
}
|
}
|
||||||
sources = append(sources, source)
|
sources = append(sources, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sources, rows.Err()
|
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)
|
(name, license, url, version, user_accepted_license)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`, source.Name, source.License, source.URL, source.Version, source.UserAcceptedLicense)
|
`, source.Name, source.License, source.URL, source.Version, source.UserAcceptedLicense)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ClearDataSource removes all data from a specific source
|
// ClearDataSource removes all data from a specific source
|
||||||
func (dl *DataLoader) ClearDataSource(sourceName string) error {
|
func (dl *DataLoader) ClearDataSource(sourceName string) error {
|
||||||
tx, err := dl.conn.Begin()
|
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)
|
return fmt.Errorf("failed to begin transaction: %v", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Clear from all tables
|
// Clear from all tables
|
||||||
_, err = tx.Exec(`DELETE FROM airlines WHERE data_source = ?`, sourceName)
|
_, err = tx.Exec(`DELETE FROM airlines WHERE data_source = ?`, sourceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to clear airlines: %v", err)
|
return fmt.Errorf("failed to clear airlines: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(`DELETE FROM airports WHERE data_source = ?`, sourceName)
|
_, err = tx.Exec(`DELETE FROM airports WHERE data_source = ?`, sourceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to clear airports: %v", err)
|
return fmt.Errorf("failed to clear airports: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(`DELETE FROM data_sources WHERE name = ?`, sourceName)
|
_, err = tx.Exec(`DELETE FROM data_sources WHERE name = ?`, sourceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to clear data source record: %v", err)
|
return fmt.Errorf("failed to clear data source record: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,9 @@ func TestDataLoader_LoadOpenFlightsAirlines(t *testing.T) {
|
||||||
result, err := loader.LoadDataSource(source)
|
result, err := loader.LoadDataSource(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Network issues in tests are acceptable
|
// Network issues in tests are acceptable
|
||||||
if strings.Contains(err.Error(), "connection") ||
|
if strings.Contains(err.Error(), "connection") ||
|
||||||
strings.Contains(err.Error(), "timeout") ||
|
strings.Contains(err.Error(), "timeout") ||
|
||||||
strings.Contains(err.Error(), "no such host") {
|
strings.Contains(err.Error(), "no such host") {
|
||||||
t.Skipf("Skipping network test due to connectivity issue: %v", err)
|
t.Skipf("Skipping network test due to connectivity issue: %v", err)
|
||||||
}
|
}
|
||||||
t.Fatal("LoadDataSource failed:", err)
|
t.Fatal("LoadDataSource failed:", err)
|
||||||
|
|
@ -45,7 +45,7 @@ func TestDataLoader_LoadOpenFlightsAirlines(t *testing.T) {
|
||||||
t.Fatal("Expected load result, got nil")
|
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)
|
result.RecordsTotal, result.RecordsNew, result.RecordsError, result.Duration)
|
||||||
|
|
||||||
// Verify some data was processed
|
// Verify some data was processed
|
||||||
|
|
@ -72,16 +72,16 @@ func TestDataLoader_LoadOurAirports(t *testing.T) {
|
||||||
result, err := loader.LoadDataSource(source)
|
result, err := loader.LoadDataSource(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Network issues in tests are acceptable
|
// Network issues in tests are acceptable
|
||||||
if strings.Contains(err.Error(), "connection") ||
|
if strings.Contains(err.Error(), "connection") ||
|
||||||
strings.Contains(err.Error(), "timeout") ||
|
strings.Contains(err.Error(), "timeout") ||
|
||||||
strings.Contains(err.Error(), "no such host") {
|
strings.Contains(err.Error(), "no such host") {
|
||||||
t.Skipf("Skipping network test due to connectivity issue: %v", err)
|
t.Skipf("Skipping network test due to connectivity issue: %v", err)
|
||||||
}
|
}
|
||||||
t.Fatal("LoadDataSource failed:", err)
|
t.Fatal("LoadDataSource failed:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != nil {
|
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)
|
result.RecordsTotal, result.RecordsNew, result.RecordsError, result.Duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,4 +174,4 @@ func TestLoadResult_Struct(t *testing.T) {
|
||||||
if len(result.Errors) != 2 {
|
if len(result.Errors) != 2 {
|
||||||
t.Error("Errors field not preserved")
|
t.Error("Errors field not preserved")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
type CallsignManager struct {
|
type CallsignManager struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
|
|
||||||
// Compiled regex patterns for callsign parsing
|
// Compiled regex patterns for callsign parsing
|
||||||
airlinePattern *regexp.Regexp
|
airlinePattern *regexp.Regexp
|
||||||
flightPattern *regexp.Regexp
|
flightPattern *regexp.Regexp
|
||||||
|
|
@ -42,14 +42,14 @@ func (cm *CallsignManager) ParseCallsign(callsign string) *CallsignParseResult {
|
||||||
ParsedTime: time.Now(),
|
ParsedTime: time.Now(),
|
||||||
IsValid: false,
|
IsValid: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
if callsign == "" {
|
if callsign == "" {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean and normalize the callsign
|
// Clean and normalize the callsign
|
||||||
normalized := strings.TrimSpace(strings.ToUpper(callsign))
|
normalized := strings.TrimSpace(strings.ToUpper(callsign))
|
||||||
|
|
||||||
// Try airline pattern first (most common for commercial flights)
|
// Try airline pattern first (most common for commercial flights)
|
||||||
if matches := cm.airlinePattern.FindStringSubmatch(normalized); len(matches) == 3 {
|
if matches := cm.airlinePattern.FindStringSubmatch(normalized); len(matches) == 3 {
|
||||||
result.AirlineCode = matches[1]
|
result.AirlineCode = matches[1]
|
||||||
|
|
@ -57,7 +57,7 @@ func (cm *CallsignManager) ParseCallsign(callsign string) *CallsignParseResult {
|
||||||
result.IsValid = true
|
result.IsValid = true
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to general flight pattern
|
// Fall back to general flight pattern
|
||||||
if matches := cm.flightPattern.FindStringSubmatch(normalized); len(matches) == 3 {
|
if matches := cm.flightPattern.FindStringSubmatch(normalized); len(matches) == 3 {
|
||||||
result.AirlineCode = matches[1]
|
result.AirlineCode = matches[1]
|
||||||
|
|
@ -65,24 +65,24 @@ func (cm *CallsignManager) ParseCallsign(callsign string) *CallsignParseResult {
|
||||||
result.IsValid = true
|
result.IsValid = true
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, error) {
|
func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, error) {
|
||||||
cm.mutex.RLock()
|
cm.mutex.RLock()
|
||||||
defer cm.mutex.RUnlock()
|
defer cm.mutex.RUnlock()
|
||||||
|
|
||||||
if callsign == "" {
|
if callsign == "" {
|
||||||
return nil, fmt.Errorf("empty callsign")
|
return nil, fmt.Errorf("empty callsign")
|
||||||
}
|
}
|
||||||
|
|
||||||
// First check the cache
|
// First check the cache
|
||||||
cached, err := cm.getCallsignFromCache(callsign)
|
cached, err := cm.getCallsignFromCache(callsign)
|
||||||
if err == nil && cached != nil {
|
if err == nil && cached != nil {
|
||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the callsign
|
// Parse the callsign
|
||||||
parsed := cm.ParseCallsign(callsign)
|
parsed := cm.ParseCallsign(callsign)
|
||||||
if !parsed.IsValid {
|
if !parsed.IsValid {
|
||||||
|
|
@ -91,13 +91,13 @@ func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, erro
|
||||||
IsValid: false,
|
IsValid: false,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up airline information
|
// Look up airline information
|
||||||
airline, err := cm.getAirlineByCode(parsed.AirlineCode)
|
airline, err := cm.getAirlineByCode(parsed.AirlineCode)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("failed to lookup airline %s: %w", parsed.AirlineCode, err)
|
return nil, fmt.Errorf("failed to lookup airline %s: %w", parsed.AirlineCode, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the result
|
// Build the result
|
||||||
info := &CallsignInfo{
|
info := &CallsignInfo{
|
||||||
OriginalCallsign: callsign,
|
OriginalCallsign: callsign,
|
||||||
|
|
@ -106,7 +106,7 @@ func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, erro
|
||||||
IsValid: true,
|
IsValid: true,
|
||||||
LastUpdated: time.Now(),
|
LastUpdated: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if airline != nil {
|
if airline != nil {
|
||||||
info.AirlineName = airline.Name
|
info.AirlineName = airline.Name
|
||||||
info.AirlineCountry = airline.Country
|
info.AirlineCountry = airline.Country
|
||||||
|
|
@ -114,7 +114,7 @@ func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, erro
|
||||||
} else {
|
} else {
|
||||||
info.DisplayName = fmt.Sprintf("%s %s", parsed.AirlineCode, parsed.FlightNumber)
|
info.DisplayName = fmt.Sprintf("%s %s", parsed.AirlineCode, parsed.FlightNumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result (fire and forget)
|
// Cache the result (fire and forget)
|
||||||
go func() {
|
go func() {
|
||||||
if err := cm.cacheCallsignInfo(info); err != nil {
|
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)
|
fmt.Printf("Warning: failed to cache callsign info for %s: %v\n", callsign, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,10 +133,10 @@ func (cm *CallsignManager) getCallsignFromCache(callsign string) (*CallsignInfo,
|
||||||
FROM callsign_cache
|
FROM callsign_cache
|
||||||
WHERE callsign = ? AND expires_at > datetime('now')
|
WHERE callsign = ? AND expires_at > datetime('now')
|
||||||
`
|
`
|
||||||
|
|
||||||
var info CallsignInfo
|
var info CallsignInfo
|
||||||
var cacheExpires time.Time
|
var cacheExpires time.Time
|
||||||
|
|
||||||
err := cm.db.QueryRow(query, callsign).Scan(
|
err := cm.db.QueryRow(query, callsign).Scan(
|
||||||
&info.OriginalCallsign,
|
&info.OriginalCallsign,
|
||||||
&info.AirlineCode,
|
&info.AirlineCode,
|
||||||
|
|
@ -148,25 +148,25 @@ func (cm *CallsignManager) getCallsignFromCache(callsign string) (*CallsignInfo,
|
||||||
&info.LastUpdated,
|
&info.LastUpdated,
|
||||||
&cacheExpires,
|
&cacheExpires,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &info, nil
|
return &info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *CallsignManager) cacheCallsignInfo(info *CallsignInfo) error {
|
func (cm *CallsignManager) cacheCallsignInfo(info *CallsignInfo) error {
|
||||||
// Cache for 24 hours by default
|
// Cache for 24 hours by default
|
||||||
cacheExpires := time.Now().Add(24 * time.Hour)
|
cacheExpires := time.Now().Add(24 * time.Hour)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT OR REPLACE INTO callsign_cache
|
INSERT OR REPLACE INTO callsign_cache
|
||||||
(callsign, airline_icao, flight_number, airline_name,
|
(callsign, airline_icao, flight_number, airline_name,
|
||||||
airline_country, cached_at, expires_at)
|
airline_country, cached_at, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := cm.db.Exec(query,
|
_, err := cm.db.Exec(query,
|
||||||
info.OriginalCallsign,
|
info.OriginalCallsign,
|
||||||
info.AirlineCode,
|
info.AirlineCode,
|
||||||
|
|
@ -176,7 +176,7 @@ func (cm *CallsignManager) cacheCallsignInfo(info *CallsignInfo) error {
|
||||||
info.LastUpdated,
|
info.LastUpdated,
|
||||||
cacheExpires,
|
cacheExpires,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +190,7 @@ func (cm *CallsignManager) getAirlineByCode(code string) (*AirlineRecord, error)
|
||||||
name
|
name
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
var airline AirlineRecord
|
var airline AirlineRecord
|
||||||
err := cm.db.QueryRow(query, code, code, code).Scan(
|
err := cm.db.QueryRow(query, code, code, code).Scan(
|
||||||
&airline.ICAOCode,
|
&airline.ICAOCode,
|
||||||
|
|
@ -199,31 +199,31 @@ func (cm *CallsignManager) getAirlineByCode(code string) (*AirlineRecord, error)
|
||||||
&airline.Country,
|
&airline.Country,
|
||||||
&airline.Active,
|
&airline.Active,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &airline, nil
|
return &airline, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *CallsignManager) GetAirlinesByCountry(country string) ([]AirlineRecord, error) {
|
func (cm *CallsignManager) GetAirlinesByCountry(country string) ([]AirlineRecord, error) {
|
||||||
cm.mutex.RLock()
|
cm.mutex.RLock()
|
||||||
defer cm.mutex.RUnlock()
|
defer cm.mutex.RUnlock()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT icao_code, iata_code, name, country, active
|
SELECT icao_code, iata_code, name, country, active
|
||||||
FROM airlines
|
FROM airlines
|
||||||
WHERE country = ? AND active = 1
|
WHERE country = ? AND active = 1
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := cm.db.Query(query, country)
|
rows, err := cm.db.Query(query, country)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var airlines []AirlineRecord
|
var airlines []AirlineRecord
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var airline AirlineRecord
|
var airline AirlineRecord
|
||||||
|
|
@ -239,14 +239,14 @@ func (cm *CallsignManager) GetAirlinesByCountry(country string) ([]AirlineRecord
|
||||||
}
|
}
|
||||||
airlines = append(airlines, airline)
|
airlines = append(airlines, airline)
|
||||||
}
|
}
|
||||||
|
|
||||||
return airlines, rows.Err()
|
return airlines, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *CallsignManager) SearchAirlines(query string) ([]AirlineRecord, error) {
|
func (cm *CallsignManager) SearchAirlines(query string) ([]AirlineRecord, error) {
|
||||||
cm.mutex.RLock()
|
cm.mutex.RLock()
|
||||||
defer cm.mutex.RUnlock()
|
defer cm.mutex.RUnlock()
|
||||||
|
|
||||||
searchQuery := `
|
searchQuery := `
|
||||||
SELECT icao_code, iata_code, name, country, active
|
SELECT icao_code, iata_code, name, country, active
|
||||||
FROM airlines
|
FROM airlines
|
||||||
|
|
@ -265,11 +265,11 @@ func (cm *CallsignManager) SearchAirlines(query string) ([]AirlineRecord, error)
|
||||||
name
|
name
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
`
|
`
|
||||||
|
|
||||||
searchTerm := "%" + strings.ToUpper(query) + "%"
|
searchTerm := "%" + strings.ToUpper(query) + "%"
|
||||||
exactTerm := strings.ToUpper(query)
|
exactTerm := strings.ToUpper(query)
|
||||||
|
|
||||||
rows, err := cm.db.Query(searchQuery,
|
rows, err := cm.db.Query(searchQuery,
|
||||||
searchTerm, searchTerm, searchTerm, searchTerm,
|
searchTerm, searchTerm, searchTerm, searchTerm,
|
||||||
exactTerm, exactTerm, exactTerm,
|
exactTerm, exactTerm, exactTerm,
|
||||||
)
|
)
|
||||||
|
|
@ -277,7 +277,7 @@ func (cm *CallsignManager) SearchAirlines(query string) ([]AirlineRecord, error)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var airlines []AirlineRecord
|
var airlines []AirlineRecord
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var airline AirlineRecord
|
var airline AirlineRecord
|
||||||
|
|
@ -293,14 +293,14 @@ func (cm *CallsignManager) SearchAirlines(query string) ([]AirlineRecord, error)
|
||||||
}
|
}
|
||||||
airlines = append(airlines, airline)
|
airlines = append(airlines, airline)
|
||||||
}
|
}
|
||||||
|
|
||||||
return airlines, rows.Err()
|
return airlines, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *CallsignManager) ClearExpiredCache() error {
|
func (cm *CallsignManager) ClearExpiredCache() error {
|
||||||
cm.mutex.Lock()
|
cm.mutex.Lock()
|
||||||
defer cm.mutex.Unlock()
|
defer cm.mutex.Unlock()
|
||||||
|
|
||||||
query := `DELETE FROM callsign_cache WHERE expires_at <= datetime('now')`
|
query := `DELETE FROM callsign_cache WHERE expires_at <= datetime('now')`
|
||||||
_, err := cm.db.Exec(query)
|
_, err := cm.db.Exec(query)
|
||||||
return err
|
return err
|
||||||
|
|
@ -309,9 +309,9 @@ func (cm *CallsignManager) ClearExpiredCache() error {
|
||||||
func (cm *CallsignManager) GetCacheStats() (map[string]interface{}, error) {
|
func (cm *CallsignManager) GetCacheStats() (map[string]interface{}, error) {
|
||||||
cm.mutex.RLock()
|
cm.mutex.RLock()
|
||||||
defer cm.mutex.RUnlock()
|
defer cm.mutex.RUnlock()
|
||||||
|
|
||||||
stats := make(map[string]interface{})
|
stats := make(map[string]interface{})
|
||||||
|
|
||||||
// Total cached entries
|
// Total cached entries
|
||||||
var totalCached int
|
var totalCached int
|
||||||
err := cm.db.QueryRow(`SELECT COUNT(*) FROM callsign_cache`).Scan(&totalCached)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
stats["total_cached"] = totalCached
|
stats["total_cached"] = totalCached
|
||||||
|
|
||||||
// Valid (non-expired) entries
|
// Valid (non-expired) entries
|
||||||
var validCached int
|
var validCached int
|
||||||
err = cm.db.QueryRow(`SELECT COUNT(*) FROM callsign_cache WHERE expires_at > datetime('now')`).Scan(&validCached)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
stats["valid_cached"] = validCached
|
stats["valid_cached"] = validCached
|
||||||
|
|
||||||
// Expired entries
|
// Expired entries
|
||||||
stats["expired_cached"] = totalCached - validCached
|
stats["expired_cached"] = totalCached - validCached
|
||||||
|
|
||||||
// Total airlines in database
|
// Total airlines in database
|
||||||
var totalAirlines int
|
var totalAirlines int
|
||||||
err = cm.db.QueryRow(`SELECT COUNT(*) FROM airlines WHERE active = 1`).Scan(&totalAirlines)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
stats["total_airlines"] = totalAirlines
|
stats["total_airlines"] = totalAirlines
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,14 +349,14 @@ func (cm *CallsignManager) LoadEmbeddedData() error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
// Data already loaded
|
// Data already loaded
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For now, we'll implement this as a placeholder
|
// For now, we'll implement this as a placeholder
|
||||||
// In a full implementation, this would load embedded airline data
|
// In a full implementation, this would load embedded airline data
|
||||||
// from embedded files or resources
|
// from embedded files or resources
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,15 @@ func TestCallsignManager_ParseCallsign(t *testing.T) {
|
||||||
manager := NewCallsignManager(db.GetConnection())
|
manager := NewCallsignManager(db.GetConnection())
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
callsign string
|
callsign string
|
||||||
expectedValid bool
|
expectedValid bool
|
||||||
expectedAirline string
|
expectedAirline string
|
||||||
expectedFlight string
|
expectedFlight string
|
||||||
}{
|
}{
|
||||||
{"UAL123", true, "UAL", "123"},
|
{"UAL123", true, "UAL", "123"},
|
||||||
{"BA4567", true, "BA", "4567"},
|
{"BA4567", true, "BA", "4567"},
|
||||||
{"AFR89", true, "AFR", "89"},
|
{"AFR89", true, "AFR", "89"},
|
||||||
{"N123AB", false, "", ""}, // Aircraft registration, not callsign
|
{"N123AB", false, "", ""}, // Aircraft registration, not callsign
|
||||||
{"INVALID", false, "", ""}, // No numbers
|
{"INVALID", false, "", ""}, // No numbers
|
||||||
{"123", false, "", ""}, // Only numbers
|
{"123", false, "", ""}, // Only numbers
|
||||||
{"A", false, "", ""}, // Too short
|
{"A", false, "", ""}, // Too short
|
||||||
|
|
@ -39,15 +39,15 @@ func TestCallsignManager_ParseCallsign(t *testing.T) {
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
result := manager.ParseCallsign(tc.callsign)
|
result := manager.ParseCallsign(tc.callsign)
|
||||||
if result.IsValid != tc.expectedValid {
|
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)
|
tc.callsign, tc.expectedValid, result.IsValid)
|
||||||
}
|
}
|
||||||
if result.IsValid && result.AirlineCode != tc.expectedAirline {
|
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)
|
tc.callsign, tc.expectedAirline, result.AirlineCode)
|
||||||
}
|
}
|
||||||
if result.IsValid && result.FlightNumber != tc.expectedFlight {
|
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)
|
tc.callsign, tc.expectedFlight, result.FlightNumber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +101,7 @@ func TestCallsignManager_GetCallsignInfo_InvalidCallsign(t *testing.T) {
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
manager := NewCallsignManager(db.GetConnection())
|
manager := NewCallsignManager(db.GetConnection())
|
||||||
|
|
||||||
// Test with invalid callsign format
|
// Test with invalid callsign format
|
||||||
info, err := manager.GetCallsignInfo("INVALID")
|
info, err := manager.GetCallsignInfo("INVALID")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -129,7 +129,7 @@ func TestCallsignManager_GetCallsignInfo_EmptyCallsign(t *testing.T) {
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
manager := NewCallsignManager(db.GetConnection())
|
manager := NewCallsignManager(db.GetConnection())
|
||||||
|
|
||||||
// Test with empty callsign
|
// Test with empty callsign
|
||||||
info, err := manager.GetCallsignInfo("")
|
info, err := manager.GetCallsignInfo("")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -162,7 +162,7 @@ func TestCallsignManager_GetCacheStats(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error("GetCacheStats should not error:", err)
|
t.Error("GetCacheStats should not error:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stats == nil {
|
if stats == nil {
|
||||||
t.Error("Expected cache stats, got nil")
|
t.Error("Expected cache stats, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -265,4 +265,4 @@ func TestCallsignParseResult_Struct(t *testing.T) {
|
||||||
if !result.IsValid {
|
if !result.IsValid {
|
||||||
t.Error("IsValid field not preserved")
|
t.Error("IsValid field not preserved")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
type HistoryManager struct {
|
type HistoryManager struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
maxHistoryDays int
|
maxHistoryDays int
|
||||||
cleanupTicker *time.Ticker
|
cleanupTicker *time.Ticker
|
||||||
|
|
@ -23,11 +23,11 @@ func NewHistoryManager(db *sql.DB, maxHistoryDays int) *HistoryManager {
|
||||||
maxHistoryDays: maxHistoryDays,
|
maxHistoryDays: maxHistoryDays,
|
||||||
stopCleanup: make(chan bool),
|
stopCleanup: make(chan bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start periodic cleanup (every hour)
|
// Start periodic cleanup (every hour)
|
||||||
hm.cleanupTicker = time.NewTicker(1 * time.Hour)
|
hm.cleanupTicker = time.NewTicker(1 * time.Hour)
|
||||||
go hm.periodicCleanup()
|
go hm.periodicCleanup()
|
||||||
|
|
||||||
return hm
|
return hm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,14 +56,14 @@ func (hm *HistoryManager) periodicCleanup() {
|
||||||
func (hm *HistoryManager) RecordAircraft(record *AircraftHistoryRecord) error {
|
func (hm *HistoryManager) RecordAircraft(record *AircraftHistoryRecord) error {
|
||||||
hm.mutex.Lock()
|
hm.mutex.Lock()
|
||||||
defer hm.mutex.Unlock()
|
defer hm.mutex.Unlock()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO aircraft_history
|
INSERT INTO aircraft_history
|
||||||
(icao, callsign, squawk, latitude, longitude, altitude,
|
(icao, callsign, squawk, latitude, longitude, altitude,
|
||||||
vertical_rate, speed, track, source_id, signal_strength, timestamp)
|
vertical_rate, speed, track, source_id, signal_strength, timestamp)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := hm.db.Exec(query,
|
_, err := hm.db.Exec(query,
|
||||||
record.ICAO,
|
record.ICAO,
|
||||||
record.Callsign,
|
record.Callsign,
|
||||||
|
|
@ -78,7 +78,7 @@ func (hm *HistoryManager) RecordAircraft(record *AircraftHistoryRecord) error {
|
||||||
record.SignalStrength,
|
record.SignalStrength,
|
||||||
record.Timestamp,
|
record.Timestamp,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,16 +86,16 @@ func (hm *HistoryManager) RecordAircraftBatch(records []AircraftHistoryRecord) e
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
hm.mutex.Lock()
|
hm.mutex.Lock()
|
||||||
defer hm.mutex.Unlock()
|
defer hm.mutex.Unlock()
|
||||||
|
|
||||||
tx, err := hm.db.Begin()
|
tx, err := hm.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
stmt, err := tx.Prepare(`
|
stmt, err := tx.Prepare(`
|
||||||
INSERT INTO aircraft_history
|
INSERT INTO aircraft_history
|
||||||
(icao, callsign, squawk, latitude, longitude, altitude,
|
(icao, callsign, squawk, latitude, longitude, altitude,
|
||||||
|
|
@ -106,7 +106,7 @@ func (hm *HistoryManager) RecordAircraftBatch(records []AircraftHistoryRecord) e
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
|
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
_, err := stmt.Exec(
|
_, err := stmt.Exec(
|
||||||
record.ICAO,
|
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 fmt.Errorf("failed to insert record for ICAO %s: %w", record.ICAO, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hm *HistoryManager) GetAircraftHistory(icao string, hours int) ([]AircraftHistoryRecord, error) {
|
func (hm *HistoryManager) GetAircraftHistory(icao string, hours int) ([]AircraftHistoryRecord, error) {
|
||||||
hm.mutex.RLock()
|
hm.mutex.RLock()
|
||||||
defer hm.mutex.RUnlock()
|
defer hm.mutex.RUnlock()
|
||||||
|
|
||||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT icao, callsign, squawk, latitude, longitude, altitude,
|
SELECT icao, callsign, squawk, latitude, longitude, altitude,
|
||||||
vertical_rate, speed, track, source_id, signal_strength, timestamp
|
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
|
ORDER BY timestamp DESC
|
||||||
LIMIT 1000
|
LIMIT 1000
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := hm.db.Query(query, icao, since)
|
rows, err := hm.db.Query(query, icao, since)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var records []AircraftHistoryRecord
|
var records []AircraftHistoryRecord
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var record AircraftHistoryRecord
|
var record AircraftHistoryRecord
|
||||||
|
|
@ -173,16 +173,16 @@ func (hm *HistoryManager) GetAircraftHistory(icao string, hours int) ([]Aircraft
|
||||||
}
|
}
|
||||||
records = append(records, record)
|
records = append(records, record)
|
||||||
}
|
}
|
||||||
|
|
||||||
return records, rows.Err()
|
return records, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hm *HistoryManager) GetAircraftTrack(icao string, hours int) ([]TrackPoint, error) {
|
func (hm *HistoryManager) GetAircraftTrack(icao string, hours int) ([]TrackPoint, error) {
|
||||||
hm.mutex.RLock()
|
hm.mutex.RLock()
|
||||||
defer hm.mutex.RUnlock()
|
defer hm.mutex.RUnlock()
|
||||||
|
|
||||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT latitude, longitude, altitude, timestamp
|
SELECT latitude, longitude, altitude, timestamp
|
||||||
FROM aircraft_history
|
FROM aircraft_history
|
||||||
|
|
@ -191,13 +191,13 @@ func (hm *HistoryManager) GetAircraftTrack(icao string, hours int) ([]TrackPoint
|
||||||
ORDER BY timestamp ASC
|
ORDER BY timestamp ASC
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := hm.db.Query(query, icao, since)
|
rows, err := hm.db.Query(query, icao, since)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var track []TrackPoint
|
var track []TrackPoint
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var point TrackPoint
|
var point TrackPoint
|
||||||
|
|
@ -212,16 +212,16 @@ func (hm *HistoryManager) GetAircraftTrack(icao string, hours int) ([]TrackPoint
|
||||||
}
|
}
|
||||||
track = append(track, point)
|
track = append(track, point)
|
||||||
}
|
}
|
||||||
|
|
||||||
return track, rows.Err()
|
return track, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hm *HistoryManager) GetRecentAircraft(hours int, limit int) ([]string, error) {
|
func (hm *HistoryManager) GetRecentAircraft(hours int, limit int) ([]string, error) {
|
||||||
hm.mutex.RLock()
|
hm.mutex.RLock()
|
||||||
defer hm.mutex.RUnlock()
|
defer hm.mutex.RUnlock()
|
||||||
|
|
||||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT DISTINCT icao
|
SELECT DISTINCT icao
|
||||||
FROM aircraft_history
|
FROM aircraft_history
|
||||||
|
|
@ -229,13 +229,13 @@ func (hm *HistoryManager) GetRecentAircraft(hours int, limit int) ([]string, err
|
||||||
ORDER BY MAX(timestamp) DESC
|
ORDER BY MAX(timestamp) DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := hm.db.Query(query, since, limit)
|
rows, err := hm.db.Query(query, since, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var icaos []string
|
var icaos []string
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var icao string
|
var icao string
|
||||||
|
|
@ -245,20 +245,20 @@ func (hm *HistoryManager) GetRecentAircraft(hours int, limit int) ([]string, err
|
||||||
}
|
}
|
||||||
icaos = append(icaos, icao)
|
icaos = append(icaos, icao)
|
||||||
}
|
}
|
||||||
|
|
||||||
return icaos, rows.Err()
|
return icaos, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hm *HistoryManager) GetAircraftLastSeen(icao string) (time.Time, error) {
|
func (hm *HistoryManager) GetAircraftLastSeen(icao string) (time.Time, error) {
|
||||||
hm.mutex.RLock()
|
hm.mutex.RLock()
|
||||||
defer hm.mutex.RUnlock()
|
defer hm.mutex.RUnlock()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT MAX(timestamp)
|
SELECT MAX(timestamp)
|
||||||
FROM aircraft_history
|
FROM aircraft_history
|
||||||
WHERE icao = ?
|
WHERE icao = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
var lastSeen time.Time
|
var lastSeen time.Time
|
||||||
err := hm.db.QueryRow(query, icao).Scan(&lastSeen)
|
err := hm.db.QueryRow(query, icao).Scan(&lastSeen)
|
||||||
return lastSeen, err
|
return lastSeen, err
|
||||||
|
|
@ -267,24 +267,24 @@ func (hm *HistoryManager) GetAircraftLastSeen(icao string) (time.Time, error) {
|
||||||
func (hm *HistoryManager) CleanupOldHistory() error {
|
func (hm *HistoryManager) CleanupOldHistory() error {
|
||||||
hm.mutex.Lock()
|
hm.mutex.Lock()
|
||||||
defer hm.mutex.Unlock()
|
defer hm.mutex.Unlock()
|
||||||
|
|
||||||
if hm.maxHistoryDays <= 0 {
|
if hm.maxHistoryDays <= 0 {
|
||||||
return nil // No cleanup if maxHistoryDays is 0 or negative
|
return nil // No cleanup if maxHistoryDays is 0 or negative
|
||||||
}
|
}
|
||||||
|
|
||||||
cutoff := time.Now().AddDate(0, 0, -hm.maxHistoryDays)
|
cutoff := time.Now().AddDate(0, 0, -hm.maxHistoryDays)
|
||||||
|
|
||||||
query := `DELETE FROM aircraft_history WHERE timestamp < ?`
|
query := `DELETE FROM aircraft_history WHERE timestamp < ?`
|
||||||
result, err := hm.db.Exec(query, cutoff)
|
result, err := hm.db.Exec(query, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
rowsAffected, err := result.RowsAffected()
|
rowsAffected, err := result.RowsAffected()
|
||||||
if err == nil && rowsAffected > 0 {
|
if err == nil && rowsAffected > 0 {
|
||||||
fmt.Printf("Cleaned up %d old aircraft history records\n", rowsAffected)
|
fmt.Printf("Cleaned up %d old aircraft history records\n", rowsAffected)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,9 +295,9 @@ func (hm *HistoryManager) GetStatistics() (map[string]interface{}, error) {
|
||||||
func (hm *HistoryManager) GetHistoryStats() (map[string]interface{}, error) {
|
func (hm *HistoryManager) GetHistoryStats() (map[string]interface{}, error) {
|
||||||
hm.mutex.RLock()
|
hm.mutex.RLock()
|
||||||
defer hm.mutex.RUnlock()
|
defer hm.mutex.RUnlock()
|
||||||
|
|
||||||
stats := make(map[string]interface{})
|
stats := make(map[string]interface{})
|
||||||
|
|
||||||
// Total records
|
// Total records
|
||||||
var totalRecords int
|
var totalRecords int
|
||||||
err := hm.db.QueryRow(`SELECT COUNT(*) FROM aircraft_history`).Scan(&totalRecords)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
stats["total_records"] = totalRecords
|
stats["total_records"] = totalRecords
|
||||||
|
|
||||||
// Unique aircraft
|
// Unique aircraft
|
||||||
var uniqueAircraft int
|
var uniqueAircraft int
|
||||||
err = hm.db.QueryRow(`SELECT COUNT(DISTINCT icao) FROM aircraft_history`).Scan(&uniqueAircraft)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
stats["unique_aircraft"] = uniqueAircraft
|
stats["unique_aircraft"] = uniqueAircraft
|
||||||
|
|
||||||
// Recent records (last 24 hours)
|
// Recent records (last 24 hours)
|
||||||
var recentRecords int
|
var recentRecords int
|
||||||
since := time.Now().Add(-24 * time.Hour)
|
since := time.Now().Add(-24 * time.Hour)
|
||||||
|
|
@ -322,7 +322,7 @@ func (hm *HistoryManager) GetHistoryStats() (map[string]interface{}, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stats["recent_records_24h"] = recentRecords
|
stats["recent_records_24h"] = recentRecords
|
||||||
|
|
||||||
// Oldest and newest record timestamps (only if records exist)
|
// Oldest and newest record timestamps (only if records exist)
|
||||||
if totalRecords > 0 {
|
if totalRecords > 0 {
|
||||||
var oldestTimestamp, newestTimestamp time.Time
|
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)
|
stats["history_days"] = int(time.Since(oldestTimestamp).Hours() / 24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{}, error) {
|
func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{}, error) {
|
||||||
hm.mutex.RLock()
|
hm.mutex.RLock()
|
||||||
defer hm.mutex.RUnlock()
|
defer hm.mutex.RUnlock()
|
||||||
|
|
||||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||||
|
|
||||||
summary := make(map[string]interface{})
|
summary := make(map[string]interface{})
|
||||||
|
|
||||||
// Aircraft count in time period
|
// Aircraft count in time period
|
||||||
var aircraftCount int
|
var aircraftCount int
|
||||||
err := hm.db.QueryRow(`
|
err := hm.db.QueryRow(`
|
||||||
|
|
@ -356,7 +356,7 @@ func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{},
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
summary["aircraft_count"] = aircraftCount
|
summary["aircraft_count"] = aircraftCount
|
||||||
|
|
||||||
// Message count in time period
|
// Message count in time period
|
||||||
var messageCount int
|
var messageCount int
|
||||||
err = hm.db.QueryRow(`
|
err = hm.db.QueryRow(`
|
||||||
|
|
@ -368,7 +368,7 @@ func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{},
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
summary["message_count"] = messageCount
|
summary["message_count"] = messageCount
|
||||||
|
|
||||||
// Most active sources
|
// Most active sources
|
||||||
query := `
|
query := `
|
||||||
SELECT source_id, COUNT(*) as count
|
SELECT source_id, COUNT(*) as count
|
||||||
|
|
@ -378,13 +378,13 @@ func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{},
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := hm.db.Query(query, since)
|
rows, err := hm.db.Query(query, since)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
sources := make([]map[string]interface{}, 0)
|
sources := make([]map[string]interface{}, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sourceID string
|
var sourceID string
|
||||||
|
|
@ -399,7 +399,7 @@ func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
summary["top_sources"] = sources
|
summary["top_sources"] = sources
|
||||||
|
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,4 +408,4 @@ type TrackPoint struct {
|
||||||
Longitude float64 `json:"longitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
Altitude *int `json:"altitude,omitempty"`
|
Altitude *int `json:"altitude,omitempty"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -192,17 +192,17 @@ func GetMigrations() []Migration {
|
||||||
},
|
},
|
||||||
// Future migrations will be added here
|
// Future migrations will be added here
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate checksums
|
// Calculate checksums
|
||||||
for i := range migrations {
|
for i := range migrations {
|
||||||
migrations[i].Checksum = calculateChecksum(migrations[i].Up)
|
migrations[i].Checksum = calculateChecksum(migrations[i].Up)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by version
|
// Sort by version
|
||||||
sort.Slice(migrations, func(i, j int) bool {
|
sort.Slice(migrations, func(i, j int) bool {
|
||||||
return migrations[i].Version < migrations[j].Version
|
return migrations[i].Version < migrations[j].Version
|
||||||
})
|
})
|
||||||
|
|
||||||
return migrations
|
return migrations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,19 +212,19 @@ func (m *Migrator) MigrateToLatest() error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get current version: %v", err)
|
return fmt.Errorf("failed to get current version: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
migrations := GetMigrations()
|
migrations := GetMigrations()
|
||||||
|
|
||||||
for _, migration := range migrations {
|
for _, migration := range migrations {
|
||||||
if migration.Version <= currentVersion {
|
if migration.Version <= currentVersion {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.applyMigration(migration); err != nil {
|
if err := m.applyMigration(migration); err != nil {
|
||||||
return fmt.Errorf("failed to apply migration %d: %v", migration.Version, err)
|
return fmt.Errorf("failed to apply migration %d: %v", migration.Version, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,20 +234,20 @@ func (m *Migrator) MigrateTo(targetVersion int) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get current version: %v", err)
|
return fmt.Errorf("failed to get current version: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if targetVersion == currentVersion {
|
if targetVersion == currentVersion {
|
||||||
return nil // Already at target version
|
return nil // Already at target version
|
||||||
}
|
}
|
||||||
|
|
||||||
migrations := GetMigrations()
|
migrations := GetMigrations()
|
||||||
|
|
||||||
if targetVersion > currentVersion {
|
if targetVersion > currentVersion {
|
||||||
// Forward migration
|
// Forward migration
|
||||||
for _, migration := range migrations {
|
for _, migration := range migrations {
|
||||||
if migration.Version <= currentVersion || migration.Version > targetVersion {
|
if migration.Version <= currentVersion || migration.Version > targetVersion {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.applyMigration(migration); err != nil {
|
if err := m.applyMigration(migration); err != nil {
|
||||||
return fmt.Errorf("failed to apply migration %d: %v", migration.Version, err)
|
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 {
|
sort.Slice(migrations, func(i, j int) bool {
|
||||||
return migrations[i].Version > migrations[j].Version
|
return migrations[i].Version > migrations[j].Version
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, migration := range migrations {
|
for _, migration := range migrations {
|
||||||
if migration.Version > currentVersion || migration.Version <= targetVersion {
|
if migration.Version > currentVersion || migration.Version <= targetVersion {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.rollbackMigration(migration); err != nil {
|
if err := m.rollbackMigration(migration); err != nil {
|
||||||
return fmt.Errorf("failed to rollback migration %d: %v", migration.Version, err)
|
return fmt.Errorf("failed to rollback migration %d: %v", migration.Version, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,19 +279,19 @@ func (m *Migrator) GetAppliedMigrations() ([]MigrationRecord, error) {
|
||||||
if err := m.ensureSchemaInfoTable(); err != nil {
|
if err := m.ensureSchemaInfoTable(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT version, description, applied_at, checksum
|
SELECT version, description, applied_at, checksum
|
||||||
FROM schema_info
|
FROM schema_info
|
||||||
ORDER BY version
|
ORDER BY version
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := m.conn.Query(query)
|
rows, err := m.conn.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query applied migrations: %v", err)
|
return nil, fmt.Errorf("failed to query applied migrations: %v", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var migrations []MigrationRecord
|
var migrations []MigrationRecord
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var migration MigrationRecord
|
var migration MigrationRecord
|
||||||
|
|
@ -306,7 +306,7 @@ func (m *Migrator) GetAppliedMigrations() ([]MigrationRecord, error) {
|
||||||
}
|
}
|
||||||
migrations = append(migrations, migration)
|
migrations = append(migrations, migration)
|
||||||
}
|
}
|
||||||
|
|
||||||
return migrations, nil
|
return migrations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -315,13 +315,13 @@ func (m *Migrator) getCurrentVersion() (int, error) {
|
||||||
if err := m.ensureSchemaInfoTable(); err != nil {
|
if err := m.ensureSchemaInfoTable(); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var version int
|
var version int
|
||||||
err := m.conn.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_info`).Scan(&version)
|
err := m.conn.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_info`).Scan(&version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to get current version: %v", err)
|
return 0, fmt.Errorf("failed to get current version: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return version, nil
|
return version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,13 +332,13 @@ func (m *Migrator) applyMigration(migration Migration) error {
|
||||||
return fmt.Errorf("failed to begin transaction: %v", err)
|
return fmt.Errorf("failed to begin transaction: %v", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Warn about data loss
|
// Warn about data loss
|
||||||
if migration.DataLoss {
|
if migration.DataLoss {
|
||||||
// In a real application, this would show a warning to the user
|
// In a real application, this would show a warning to the user
|
||||||
// For now, we'll just log it
|
// For now, we'll just log it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute migration SQL
|
// Execute migration SQL
|
||||||
statements := strings.Split(migration.Up, ";")
|
statements := strings.Split(migration.Up, ";")
|
||||||
for _, stmt := range statements {
|
for _, stmt := range statements {
|
||||||
|
|
@ -346,22 +346,22 @@ func (m *Migrator) applyMigration(migration Migration) error {
|
||||||
if stmt == "" {
|
if stmt == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(stmt); err != nil {
|
if _, err := tx.Exec(stmt); err != nil {
|
||||||
return fmt.Errorf("failed to execute migration statement: %v", err)
|
return fmt.Errorf("failed to execute migration statement: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record migration
|
// Record migration
|
||||||
_, err = tx.Exec(`
|
_, err = tx.Exec(`
|
||||||
INSERT INTO schema_info (version, description, checksum)
|
INSERT INTO schema_info (version, description, checksum)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
`, migration.Version, migration.Description, migration.Checksum)
|
`, migration.Version, migration.Description, migration.Checksum)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to record migration: %v", err)
|
return fmt.Errorf("failed to record migration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,13 +370,13 @@ func (m *Migrator) rollbackMigration(migration Migration) error {
|
||||||
if migration.Down == "" {
|
if migration.Down == "" {
|
||||||
return fmt.Errorf("migration %d has no rollback script", migration.Version)
|
return fmt.Errorf("migration %d has no rollback script", migration.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := m.conn.Begin()
|
tx, err := m.conn.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to begin transaction: %v", err)
|
return fmt.Errorf("failed to begin transaction: %v", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Execute rollback SQL
|
// Execute rollback SQL
|
||||||
statements := strings.Split(migration.Down, ";")
|
statements := strings.Split(migration.Down, ";")
|
||||||
for _, stmt := range statements {
|
for _, stmt := range statements {
|
||||||
|
|
@ -384,18 +384,18 @@ func (m *Migrator) rollbackMigration(migration Migration) error {
|
||||||
if stmt == "" {
|
if stmt == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(stmt); err != nil {
|
if _, err := tx.Exec(stmt); err != nil {
|
||||||
return fmt.Errorf("failed to execute rollback statement: %v", err)
|
return fmt.Errorf("failed to execute rollback statement: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove migration record
|
// Remove migration record
|
||||||
_, err = tx.Exec(`DELETE FROM schema_info WHERE version = ?`, migration.Version)
|
_, err = tx.Exec(`DELETE FROM schema_info WHERE version = ?`, migration.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove migration record: %v", err)
|
return fmt.Errorf("failed to remove migration record: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -416,4 +416,4 @@ func (m *Migrator) ensureSchemaInfoTable() error {
|
||||||
func calculateChecksum(content string) string {
|
func calculateChecksum(content string) string {
|
||||||
// Simple checksum - in production, use a proper hash function
|
// Simple checksum - in production, use a proper hash function
|
||||||
return fmt.Sprintf("%x", len(content))
|
return fmt.Sprintf("%x", len(content))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ func NewOptimizationManager(db *Database, config *Config) *OptimizationManager {
|
||||||
// PerformMaintenance runs database maintenance tasks including VACUUM
|
// PerformMaintenance runs database maintenance tasks including VACUUM
|
||||||
func (om *OptimizationManager) PerformMaintenance() error {
|
func (om *OptimizationManager) PerformMaintenance() error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Check if VACUUM is needed
|
// Check if VACUUM is needed
|
||||||
if om.config.VacuumInterval > 0 && now.Sub(om.lastVacuum) >= om.config.VacuumInterval {
|
if om.config.VacuumInterval > 0 && now.Sub(om.lastVacuum) >= om.config.VacuumInterval {
|
||||||
if err := om.VacuumDatabase(); err != nil {
|
if err := om.VacuumDatabase(); err != nil {
|
||||||
|
|
@ -32,7 +32,7 @@ func (om *OptimizationManager) PerformMaintenance() error {
|
||||||
}
|
}
|
||||||
om.lastVacuum = now
|
om.lastVacuum = now
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,37 +42,37 @@ func (om *OptimizationManager) VacuumDatabase() error {
|
||||||
if conn == nil {
|
if conn == nil {
|
||||||
return fmt.Errorf("database connection not available")
|
return fmt.Errorf("database connection not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// Get size before VACUUM
|
// Get size before VACUUM
|
||||||
sizeBefore, err := om.getDatabaseSize()
|
sizeBefore, err := om.getDatabaseSize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get database size: %w", err)
|
return fmt.Errorf("failed to get database size: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform VACUUM
|
// Perform VACUUM
|
||||||
if _, err := conn.Exec("VACUUM"); err != nil {
|
if _, err := conn.Exec("VACUUM"); err != nil {
|
||||||
return fmt.Errorf("VACUUM operation failed: %w", err)
|
return fmt.Errorf("VACUUM operation failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get size after VACUUM
|
// Get size after VACUUM
|
||||||
sizeAfter, err := om.getDatabaseSize()
|
sizeAfter, err := om.getDatabaseSize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get database size after VACUUM: %w", err)
|
return fmt.Errorf("failed to get database size after VACUUM: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
savedBytes := sizeBefore - sizeAfter
|
savedBytes := sizeBefore - sizeAfter
|
||||||
savedPercent := float64(savedBytes) / float64(sizeBefore) * 100
|
savedPercent := float64(savedBytes) / float64(sizeBefore) * 100
|
||||||
|
|
||||||
fmt.Printf("VACUUM completed in %v: %.1f MB → %.1f MB (saved %.1f MB, %.1f%%)\n",
|
fmt.Printf("VACUUM completed in %v: %.1f MB → %.1f MB (saved %.1f MB, %.1f%%)\n",
|
||||||
duration,
|
duration,
|
||||||
float64(sizeBefore)/(1024*1024),
|
float64(sizeBefore)/(1024*1024),
|
||||||
float64(sizeAfter)/(1024*1024),
|
float64(sizeAfter)/(1024*1024),
|
||||||
float64(savedBytes)/(1024*1024),
|
float64(savedBytes)/(1024*1024),
|
||||||
savedPercent)
|
savedPercent)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,13 +82,13 @@ func (om *OptimizationManager) OptimizeDatabase() error {
|
||||||
if conn == nil {
|
if conn == nil {
|
||||||
return fmt.Errorf("database connection not available")
|
return fmt.Errorf("database connection not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Optimizing database for storage efficiency...")
|
fmt.Println("Optimizing database for storage efficiency...")
|
||||||
|
|
||||||
// Apply storage-friendly pragmas
|
// Apply storage-friendly pragmas
|
||||||
optimizations := []struct{
|
optimizations := []struct {
|
||||||
name string
|
name string
|
||||||
query string
|
query string
|
||||||
description string
|
description string
|
||||||
}{
|
}{
|
||||||
{"Auto VACUUM", "PRAGMA auto_vacuum = INCREMENTAL", "Enable incremental auto-vacuum"},
|
{"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"},
|
{"Optimize", "PRAGMA optimize", "Update SQLite query planner statistics"},
|
||||||
{"Analyze", "ANALYZE", "Update table statistics for better query plans"},
|
{"Analyze", "ANALYZE", "Update table statistics for better query plans"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range optimizations {
|
for _, opt := range optimizations {
|
||||||
if _, err := conn.Exec(opt.query); err != nil {
|
if _, err := conn.Exec(opt.query); err != nil {
|
||||||
fmt.Printf("Warning: %s failed: %v\n", opt.name, err)
|
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)
|
fmt.Printf("✓ %s: %s\n", opt.name, opt.description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,83 +114,83 @@ func (om *OptimizationManager) OptimizePageSize(pageSize int) error {
|
||||||
if conn == nil {
|
if conn == nil {
|
||||||
return fmt.Errorf("database connection not available")
|
return fmt.Errorf("database connection not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check current page size
|
// Check current page size
|
||||||
var currentPageSize int
|
var currentPageSize int
|
||||||
if err := conn.QueryRow("PRAGMA page_size").Scan(¤tPageSize); err != nil {
|
if err := conn.QueryRow("PRAGMA page_size").Scan(¤tPageSize); err != nil {
|
||||||
return fmt.Errorf("failed to get current page size: %w", err)
|
return fmt.Errorf("failed to get current page size: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentPageSize == pageSize {
|
if currentPageSize == pageSize {
|
||||||
fmt.Printf("Page size already optimal: %d bytes\n", pageSize)
|
fmt.Printf("Page size already optimal: %d bytes\n", pageSize)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Optimizing page size: %d → %d bytes (requires VACUUM)\n", currentPageSize, pageSize)
|
fmt.Printf("Optimizing page size: %d → %d bytes (requires VACUUM)\n", currentPageSize, pageSize)
|
||||||
|
|
||||||
// Set new page size
|
// Set new page size
|
||||||
query := fmt.Sprintf("PRAGMA page_size = %d", pageSize)
|
query := fmt.Sprintf("PRAGMA page_size = %d", pageSize)
|
||||||
if _, err := conn.Exec(query); err != nil {
|
if _, err := conn.Exec(query); err != nil {
|
||||||
return fmt.Errorf("failed to set page size: %w", err)
|
return fmt.Errorf("failed to set page size: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// VACUUM to apply the new page size
|
// VACUUM to apply the new page size
|
||||||
if err := om.VacuumDatabase(); err != nil {
|
if err := om.VacuumDatabase(); err != nil {
|
||||||
return fmt.Errorf("failed to apply page size change: %w", err)
|
return fmt.Errorf("failed to apply page size change: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOptimizationStats returns current database optimization statistics
|
// GetOptimizationStats returns current database optimization statistics
|
||||||
func (om *OptimizationManager) GetOptimizationStats() (*OptimizationStats, error) {
|
func (om *OptimizationManager) GetOptimizationStats() (*OptimizationStats, error) {
|
||||||
stats := &OptimizationStats{}
|
stats := &OptimizationStats{}
|
||||||
|
|
||||||
// Get database size
|
// Get database size
|
||||||
size, err := om.getDatabaseSize()
|
size, err := om.getDatabaseSize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stats.DatabaseSize = size
|
stats.DatabaseSize = size
|
||||||
|
|
||||||
// Get page statistics
|
// Get page statistics
|
||||||
conn := om.db.GetConnection()
|
conn := om.db.GetConnection()
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
var pageSize, pageCount, freelistCount int
|
var pageSize, pageCount, freelistCount int
|
||||||
conn.QueryRow("PRAGMA page_size").Scan(&pageSize)
|
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)
|
conn.QueryRow("PRAGMA freelist_count").Scan(&freelistCount)
|
||||||
|
|
||||||
stats.PageSize = pageSize
|
stats.PageSize = pageSize
|
||||||
stats.PageCount = pageCount
|
stats.PageCount = pageCount
|
||||||
stats.FreePages = freelistCount
|
stats.FreePages = freelistCount
|
||||||
stats.UsedPages = pageCount - freelistCount
|
stats.UsedPages = pageCount - freelistCount
|
||||||
|
|
||||||
if pageCount > 0 {
|
if pageCount > 0 {
|
||||||
stats.Efficiency = float64(stats.UsedPages) / float64(pageCount) * 100
|
stats.Efficiency = float64(stats.UsedPages) / float64(pageCount) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check auto vacuum setting
|
// Check auto vacuum setting
|
||||||
var autoVacuum int
|
var autoVacuum int
|
||||||
conn.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum)
|
conn.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum)
|
||||||
stats.AutoVacuumEnabled = autoVacuum > 0
|
stats.AutoVacuumEnabled = autoVacuum > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.LastVacuum = om.lastVacuum
|
stats.LastVacuum = om.lastVacuum
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OptimizationStats holds database storage optimization statistics
|
// OptimizationStats holds database storage optimization statistics
|
||||||
type OptimizationStats struct {
|
type OptimizationStats struct {
|
||||||
DatabaseSize int64 `json:"database_size"`
|
DatabaseSize int64 `json:"database_size"`
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
PageCount int `json:"page_count"`
|
PageCount int `json:"page_count"`
|
||||||
UsedPages int `json:"used_pages"`
|
UsedPages int `json:"used_pages"`
|
||||||
FreePages int `json:"free_pages"`
|
FreePages int `json:"free_pages"`
|
||||||
Efficiency float64 `json:"efficiency_percent"`
|
Efficiency float64 `json:"efficiency_percent"`
|
||||||
AutoVacuumEnabled bool `json:"auto_vacuum_enabled"`
|
AutoVacuumEnabled bool `json:"auto_vacuum_enabled"`
|
||||||
LastVacuum time.Time `json:"last_vacuum"`
|
LastVacuum time.Time `json:"last_vacuum"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDatabaseSize returns the current database file size in bytes
|
// getDatabaseSize returns the current database file size in bytes
|
||||||
|
|
@ -198,11 +198,11 @@ func (om *OptimizationManager) getDatabaseSize() (int64, error) {
|
||||||
if om.config.Path == "" {
|
if om.config.Path == "" {
|
||||||
return 0, fmt.Errorf("database path not configured")
|
return 0, fmt.Errorf("database path not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
stat, err := os.Stat(om.config.Path)
|
stat, err := os.Stat(om.config.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to stat database file: %w", err)
|
return 0, fmt.Errorf("failed to stat database file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stat.Size(), nil
|
return stat.Size(), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,13 +221,13 @@ func TestOptimizationManager_InvalidPath(t *testing.T) {
|
||||||
func TestOptimizationStats_JSON(t *testing.T) {
|
func TestOptimizationStats_JSON(t *testing.T) {
|
||||||
stats := &OptimizationStats{
|
stats := &OptimizationStats{
|
||||||
DatabaseSize: 1024000,
|
DatabaseSize: 1024000,
|
||||||
PageSize: 4096,
|
PageSize: 4096,
|
||||||
PageCount: 250,
|
PageCount: 250,
|
||||||
UsedPages: 200,
|
UsedPages: 200,
|
||||||
FreePages: 50,
|
FreePages: 50,
|
||||||
Efficiency: 80.0,
|
Efficiency: 80.0,
|
||||||
AutoVacuumEnabled: true,
|
AutoVacuumEnabled: true,
|
||||||
LastVacuum: time.Now(),
|
LastVacuum: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that all fields are accessible
|
// Test that all fields are accessible
|
||||||
|
|
@ -286,9 +286,9 @@ func TestOptimizationManager_WithRealData(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare efficiency
|
// Compare efficiency
|
||||||
t.Logf("Optimization results: %.2f%% → %.2f%% efficiency",
|
t.Logf("Optimization results: %.2f%% → %.2f%% efficiency",
|
||||||
statsBefore.Efficiency, statsAfter.Efficiency)
|
statsBefore.Efficiency, statsAfter.Efficiency)
|
||||||
|
|
||||||
// After optimization, we should have auto-vacuum enabled
|
// After optimization, we should have auto-vacuum enabled
|
||||||
if !statsAfter.AutoVacuumEnabled {
|
if !statsAfter.AutoVacuumEnabled {
|
||||||
t.Error("Auto-vacuum should be enabled after optimization")
|
t.Error("Auto-vacuum should be enabled after optimization")
|
||||||
|
|
@ -304,4 +304,4 @@ func TestOptimizationManager_WithRealData(t *testing.T) {
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
t.Error("Data lost during optimization")
|
t.Error("Data lost during optimization")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,17 @@ func ResolveDatabasePath(configPath string) (string, error) {
|
||||||
}
|
}
|
||||||
return configPath, nil
|
return configPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try system location first (for services)
|
// Try system location first (for services)
|
||||||
if systemPath, err := trySystemPath(); err == nil {
|
if systemPath, err := trySystemPath(); err == nil {
|
||||||
return systemPath, nil
|
return systemPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try user data directory
|
// Try user data directory
|
||||||
if userPath, err := tryUserPath(); err == nil {
|
if userPath, err := tryUserPath(); err == nil {
|
||||||
return userPath, nil
|
return userPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to current directory
|
// Fallback to current directory
|
||||||
return tryCurrentDirPath()
|
return tryCurrentDirPath()
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ func ResolveDatabasePath(configPath string) (string, error) {
|
||||||
// trySystemPath attempts to use system-wide database location
|
// trySystemPath attempts to use system-wide database location
|
||||||
func trySystemPath() (string, error) {
|
func trySystemPath() (string, error) {
|
||||||
var systemDir string
|
var systemDir string
|
||||||
|
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "linux":
|
case "linux":
|
||||||
systemDir = "/var/lib/skyview"
|
systemDir = "/var/lib/skyview"
|
||||||
|
|
@ -46,26 +46,26 @@ func trySystemPath() (string, error) {
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("system path not supported on %s", runtime.GOOS)
|
return "", fmt.Errorf("system path not supported on %s", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if directory exists and is writable
|
// Check if directory exists and is writable
|
||||||
if err := ensureDirExists(systemDir); err != nil {
|
if err := ensureDirExists(systemDir); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
dbPath := filepath.Join(systemDir, "skyview.db")
|
dbPath := filepath.Join(systemDir, "skyview.db")
|
||||||
|
|
||||||
// Test write permissions
|
// Test write permissions
|
||||||
if err := testWritePermissions(dbPath); err != nil {
|
if err := testWritePermissions(dbPath); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbPath, nil
|
return dbPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryUserPath attempts to use user data directory
|
// tryUserPath attempts to use user data directory
|
||||||
func tryUserPath() (string, error) {
|
func tryUserPath() (string, error) {
|
||||||
var userDataDir string
|
var userDataDir string
|
||||||
|
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "linux":
|
case "linux":
|
||||||
if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" {
|
if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" {
|
||||||
|
|
@ -91,20 +91,20 @@ func tryUserPath() (string, error) {
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("user path not supported on %s", runtime.GOOS)
|
return "", fmt.Errorf("user path not supported on %s", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
skyviewDir := filepath.Join(userDataDir, "skyview")
|
skyviewDir := filepath.Join(userDataDir, "skyview")
|
||||||
|
|
||||||
if err := ensureDirExists(skyviewDir); err != nil {
|
if err := ensureDirExists(skyviewDir); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
dbPath := filepath.Join(skyviewDir, "skyview.db")
|
dbPath := filepath.Join(skyviewDir, "skyview.db")
|
||||||
|
|
||||||
// Test write permissions
|
// Test write permissions
|
||||||
if err := testWritePermissions(dbPath); err != nil {
|
if err := testWritePermissions(dbPath); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbPath, nil
|
return dbPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,14 +114,14 @@ func tryCurrentDirPath() (string, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("cannot get current directory: %v", err)
|
return "", fmt.Errorf("cannot get current directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dbPath := filepath.Join(currentDir, "skyview.db")
|
dbPath := filepath.Join(currentDir, "skyview.db")
|
||||||
|
|
||||||
// Test write permissions
|
// Test write permissions
|
||||||
if err := testWritePermissions(dbPath); err != nil {
|
if err := testWritePermissions(dbPath); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbPath, nil
|
return dbPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,23 +134,23 @@ func ensureDirExists(dir string) error {
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return fmt.Errorf("cannot access directory %s: %v", dir, err)
|
return fmt.Errorf("cannot access directory %s: %v", dir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// testWritePermissions verifies write access to the database path
|
// testWritePermissions verifies write access to the database path
|
||||||
func testWritePermissions(dbPath string) error {
|
func testWritePermissions(dbPath string) error {
|
||||||
dir := filepath.Dir(dbPath)
|
dir := filepath.Dir(dbPath)
|
||||||
|
|
||||||
// Check directory write permissions
|
// Check directory write permissions
|
||||||
testFile := filepath.Join(dir, ".skyview_write_test")
|
testFile := filepath.Join(dir, ".skyview_write_test")
|
||||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||||
return fmt.Errorf("no write permission to directory %s: %v", dir, err)
|
return fmt.Errorf("no write permission to directory %s: %v", dir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up test file
|
// Clean up test file
|
||||||
os.Remove(testFile)
|
os.Remove(testFile)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,4 +171,4 @@ func IsSystemPath(dbPath string) bool {
|
||||||
return programData != "" && filepath.HasPrefix(dbPath, filepath.Join(programData, "skyview"))
|
return programData != "" && filepath.HasPrefix(dbPath, filepath.Join(programData, "skyview"))
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,4 @@ func setupTestDatabase(t *testing.T) (*Database, func()) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return db, cleanup
|
return db, cleanup
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,7 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
|
||||||
|
|
||||||
state.LastUpdate = timestamp
|
state.LastUpdate = timestamp
|
||||||
state.TotalMessages++
|
state.TotalMessages++
|
||||||
|
|
||||||
// Persist to database if available and aircraft has position
|
// Persist to database if available and aircraft has position
|
||||||
if m.db != nil && aircraft.Latitude != 0 && aircraft.Longitude != 0 {
|
if m.db != nil && aircraft.Latitude != 0 && aircraft.Longitude != 0 {
|
||||||
m.saveAircraftToDatabase(aircraft, sourceID, signal, timestamp)
|
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) {
|
func (m *Merger) saveAircraftToDatabase(aircraft *modes.Aircraft, sourceID string, signal float64, timestamp time.Time) {
|
||||||
// Convert ICAO24 to hex string
|
// Convert ICAO24 to hex string
|
||||||
icaoHex := fmt.Sprintf("%06X", aircraft.ICAO24)
|
icaoHex := fmt.Sprintf("%06X", aircraft.ICAO24)
|
||||||
|
|
||||||
// Prepare database record
|
// Prepare database record
|
||||||
record := database.AircraftHistoryRecord{
|
record := database.AircraftHistoryRecord{
|
||||||
ICAO: icaoHex,
|
ICAO: icaoHex,
|
||||||
|
|
@ -1087,7 +1087,7 @@ func (m *Merger) saveAircraftToDatabase(aircraft *modes.Aircraft, sourceID strin
|
||||||
SourceID: sourceID,
|
SourceID: sourceID,
|
||||||
SignalStrength: &signal,
|
SignalStrength: &signal,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add optional fields if available
|
// Add optional fields if available
|
||||||
if aircraft.Altitude > 0 {
|
if aircraft.Altitude > 0 {
|
||||||
record.Altitude = &aircraft.Altitude
|
record.Altitude = &aircraft.Altitude
|
||||||
|
|
@ -1107,7 +1107,7 @@ func (m *Merger) saveAircraftToDatabase(aircraft *modes.Aircraft, sourceID strin
|
||||||
if aircraft.Callsign != "" {
|
if aircraft.Callsign != "" {
|
||||||
record.Callsign = &aircraft.Callsign
|
record.Callsign = &aircraft.Callsign
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to database (non-blocking to avoid slowing down real-time processing)
|
// Save to database (non-blocking to avoid slowing down real-time processing)
|
||||||
go func() {
|
go func() {
|
||||||
if err := m.db.GetHistoryManager().RecordAircraft(&record); err != nil {
|
if err := m.db.GetHistoryManager().RecordAircraft(&record); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,13 @@ type OriginConfig struct {
|
||||||
// - Concurrent broadcast system for WebSocket clients
|
// - Concurrent broadcast system for WebSocket clients
|
||||||
// - CORS support for cross-origin web applications
|
// - CORS support for cross-origin web applications
|
||||||
type Server struct {
|
type Server struct {
|
||||||
host string // Bind address for HTTP server
|
host string // Bind address for HTTP server
|
||||||
port int // TCP port for HTTP server
|
port int // TCP port for HTTP server
|
||||||
merger *merger.Merger // Data source for aircraft information
|
merger *merger.Merger // Data source for aircraft information
|
||||||
database *database.Database // Optional database for persistence
|
database *database.Database // Optional database for persistence
|
||||||
staticFiles embed.FS // Embedded static web assets
|
staticFiles embed.FS // Embedded static web assets
|
||||||
server *http.Server // HTTP server instance
|
server *http.Server // HTTP server instance
|
||||||
origin OriginConfig // Geographic reference point
|
origin OriginConfig // Geographic reference point
|
||||||
|
|
||||||
// WebSocket management
|
// WebSocket management
|
||||||
wsClients map[*websocket.Conn]bool // Active WebSocket client connections
|
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{})
|
response := make(map[string]interface{})
|
||||||
|
|
||||||
// Get database path and size information
|
// Get database path and size information
|
||||||
dbConfig := s.database.GetConfig()
|
dbConfig := s.database.GetConfig()
|
||||||
dbPath := dbConfig.Path
|
dbPath := dbConfig.Path
|
||||||
response["path"] = dbPath
|
response["path"] = dbPath
|
||||||
|
|
||||||
// Get file size and modification time
|
// Get file size and modification time
|
||||||
if stat, err := os.Stat(dbPath); err == nil {
|
if stat, err := os.Stat(dbPath); err == nil {
|
||||||
response["size_bytes"] = stat.Size()
|
response["size_bytes"] = stat.Size()
|
||||||
response["size_mb"] = float64(stat.Size()) / (1024 * 1024)
|
response["size_mb"] = float64(stat.Size()) / (1024 * 1024)
|
||||||
response["modified"] = stat.ModTime().Unix()
|
response["modified"] = stat.ModTime().Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get optimization statistics
|
// Get optimization statistics
|
||||||
optimizer := database.NewOptimizationManager(s.database, dbConfig)
|
optimizer := database.NewOptimizationManager(s.database, dbConfig)
|
||||||
if optimizationStats, err := optimizer.GetOptimizationStats(); err == nil {
|
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()
|
response["last_vacuum"] = optimizationStats.LastVacuum.Unix()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get history statistics
|
// Get history statistics
|
||||||
historyStats, err := s.database.GetHistoryManager().GetStatistics()
|
historyStats, err := s.database.GetHistoryManager().GetStatistics()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error getting history statistics: %v", err)
|
log.Printf("Error getting history statistics: %v", err)
|
||||||
historyStats = make(map[string]interface{})
|
historyStats = make(map[string]interface{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get callsign statistics if available
|
// Get callsign statistics if available
|
||||||
callsignStats := make(map[string]interface{})
|
callsignStats := make(map[string]interface{})
|
||||||
if callsignManager := s.database.GetCallsignManager(); callsignManager != nil {
|
if callsignManager := s.database.GetCallsignManager(); callsignManager != nil {
|
||||||
|
|
@ -963,23 +963,23 @@ func (s *Server) handleGetDatabaseStatus(w http.ResponseWriter, r *http.Request)
|
||||||
callsignStats = stats
|
callsignStats = stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get record counts for reference data
|
// Get record counts for reference data
|
||||||
var airportCount, airlineCount int
|
var airportCount, airlineCount int
|
||||||
s.database.GetConnection().QueryRow(`SELECT COUNT(*) FROM airports`).Scan(&airportCount)
|
s.database.GetConnection().QueryRow(`SELECT COUNT(*) FROM airports`).Scan(&airportCount)
|
||||||
s.database.GetConnection().QueryRow(`SELECT COUNT(*) FROM airlines`).Scan(&airlineCount)
|
s.database.GetConnection().QueryRow(`SELECT COUNT(*) FROM airlines`).Scan(&airlineCount)
|
||||||
|
|
||||||
referenceData := make(map[string]interface{})
|
referenceData := make(map[string]interface{})
|
||||||
referenceData["airports"] = airportCount
|
referenceData["airports"] = airportCount
|
||||||
referenceData["airlines"] = airlineCount
|
referenceData["airlines"] = airlineCount
|
||||||
|
|
||||||
response["database_available"] = true
|
response["database_available"] = true
|
||||||
response["path"] = dbPath
|
response["path"] = dbPath
|
||||||
response["reference_data"] = referenceData
|
response["reference_data"] = referenceData
|
||||||
response["history"] = historyStats
|
response["history"] = historyStats
|
||||||
response["callsign"] = callsignStats
|
response["callsign"] = callsignStats
|
||||||
response["timestamp"] = time.Now().Unix()
|
response["timestamp"] = time.Now().Unix()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
@ -993,20 +993,20 @@ func (s *Server) handleGetDataSources(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Create data loader instance
|
// Create data loader instance
|
||||||
loader := database.NewDataLoader(s.database.GetConnection())
|
loader := database.NewDataLoader(s.database.GetConnection())
|
||||||
|
|
||||||
availableSources := database.GetAvailableDataSources()
|
availableSources := database.GetAvailableDataSources()
|
||||||
loadedSources, err := loader.GetLoadedDataSources()
|
loadedSources, err := loader.GetLoadedDataSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error getting loaded data sources: %v", err)
|
log.Printf("Error getting loaded data sources: %v", err)
|
||||||
loadedSources = []database.DataSource{}
|
loadedSources = []database.DataSource{}
|
||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"available": availableSources,
|
"available": availableSources,
|
||||||
"loaded": loadedSources,
|
"loaded": loadedSources,
|
||||||
"timestamp": time.Now().Unix(),
|
"timestamp": time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
@ -1021,7 +1021,7 @@ func (s *Server) handleGetCallsignInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
// Extract callsign from URL parameters
|
// Extract callsign from URL parameters
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
callsign := vars["callsign"]
|
callsign := vars["callsign"]
|
||||||
|
|
||||||
if callsign == "" {
|
if callsign == "" {
|
||||||
http.Error(w, "Callsign parameter required", http.StatusBadRequest)
|
http.Error(w, "Callsign parameter required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -1036,7 +1036,7 @@ func (s *Server) handleGetCallsignInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"callsign": callsignInfo,
|
"callsign": callsignInfo,
|
||||||
"timestamp": time.Now().Unix(),
|
"timestamp": time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1070,12 +1070,12 @@ func (s *Server) debugEmbeddedFiles() {
|
||||||
func (s *Server) handleDatabasePage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDatabasePage(w http.ResponseWriter, r *http.Request) {
|
||||||
// Debug embedded files first
|
// Debug embedded files first
|
||||||
s.debugEmbeddedFiles()
|
s.debugEmbeddedFiles()
|
||||||
|
|
||||||
// Try to read the database HTML file from embedded assets
|
// Try to read the database HTML file from embedded assets
|
||||||
data, err := s.staticFiles.ReadFile("static/database.html")
|
data, err := s.staticFiles.ReadFile("static/database.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error reading database.html: %v", err)
|
log.Printf("Error reading database.html: %v", err)
|
||||||
|
|
||||||
// Fallback: serve a simple HTML page with API calls
|
// Fallback: serve a simple HTML page with API calls
|
||||||
fallbackHTML := `<!DOCTYPE html>
|
fallbackHTML := `<!DOCTYPE html>
|
||||||
<html><head><title>Database Status - SkyView</title></head>
|
<html><head><title>Database Status - SkyView</title></head>
|
||||||
|
|
@ -1091,7 +1091,7 @@ fetch('/api/database/status')
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body></html>`
|
</body></html>`
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
w.Write([]byte(fallbackHTML))
|
w.Write([]byte(fallbackHTML))
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,10 @@ func NewDatabase() *Database {
|
||||||
db := &Database{
|
db := &Database{
|
||||||
codes: make(map[string]*CodeInfo),
|
codes: make(map[string]*CodeInfo),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize with standard transponder codes
|
// Initialize with standard transponder codes
|
||||||
db.loadStandardCodes()
|
db.loadStandardCodes()
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ func (db *Database) loadStandardCodes() {
|
||||||
Priority: 90,
|
Priority: 90,
|
||||||
Notes: "General emergency situation requiring immediate attention",
|
Notes: "General emergency situation requiring immediate attention",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Standard VFR/IFR Codes
|
// Standard VFR/IFR Codes
|
||||||
{
|
{
|
||||||
Code: "1200",
|
Code: "1200",
|
||||||
|
|
@ -149,7 +149,7 @@ func (db *Database) loadStandardCodes() {
|
||||||
Priority: 5,
|
Priority: 5,
|
||||||
Notes: "Transponder operating but no specific code assigned",
|
Notes: "Transponder operating but no specific code assigned",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Special Purpose Codes
|
// Special Purpose Codes
|
||||||
{
|
{
|
||||||
Code: "1255",
|
Code: "1255",
|
||||||
|
|
@ -175,7 +175,7 @@ func (db *Database) loadStandardCodes() {
|
||||||
Priority: 35,
|
Priority: 35,
|
||||||
Notes: "Military interceptor aircraft",
|
Notes: "Military interceptor aircraft",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Military Ranges
|
// Military Ranges
|
||||||
{
|
{
|
||||||
Code: "4000",
|
Code: "4000",
|
||||||
|
|
@ -193,7 +193,7 @@ func (db *Database) loadStandardCodes() {
|
||||||
Priority: 12,
|
Priority: 12,
|
||||||
Notes: "Military interceptor operations (0100-0777 range)",
|
Notes: "Military interceptor operations (0100-0777 range)",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Additional Common Codes
|
// Additional Common Codes
|
||||||
{
|
{
|
||||||
Code: "1201",
|
Code: "1201",
|
||||||
|
|
@ -219,7 +219,7 @@ func (db *Database) loadStandardCodes() {
|
||||||
Priority: 8,
|
Priority: 8,
|
||||||
Notes: "VFR flight above 12,500 feet requiring transponder",
|
Notes: "VFR flight above 12,500 feet requiring transponder",
|
||||||
},
|
},
|
||||||
|
|
||||||
// European Specific
|
// European Specific
|
||||||
{
|
{
|
||||||
Code: "7001",
|
Code: "7001",
|
||||||
|
|
@ -246,7 +246,7 @@ func (db *Database) loadStandardCodes() {
|
||||||
Notes: "General Air Traffic operating in Other Air Traffic area",
|
Notes: "General Air Traffic operating in Other Air Traffic area",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all codes to the database
|
// Add all codes to the database
|
||||||
for _, code := range codes {
|
for _, code := range codes {
|
||||||
db.codes[code.Code] = code
|
db.codes[code.Code] = code
|
||||||
|
|
@ -254,7 +254,7 @@ func (db *Database) loadStandardCodes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup returns information about a given transponder code
|
// Lookup returns information about a given transponder code
|
||||||
//
|
//
|
||||||
// The method accepts both 4-digit strings and integers, automatically
|
// 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.
|
// 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)
|
// - []*CodeInfo: Slice of all emergency codes, sorted by priority (highest first)
|
||||||
func (db *Database) GetEmergencyCodes() []*CodeInfo {
|
func (db *Database) GetEmergencyCodes() []*CodeInfo {
|
||||||
var emergencyCodes []*CodeInfo
|
var emergencyCodes []*CodeInfo
|
||||||
|
|
||||||
for _, info := range db.codes {
|
for _, info := range db.codes {
|
||||||
if info.Type == Emergency {
|
if info.Type == Emergency {
|
||||||
emergencyCodes = append(emergencyCodes, info)
|
emergencyCodes = append(emergencyCodes, info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by priority (highest first)
|
// Sort by priority (highest first)
|
||||||
for i := 0; i < len(emergencyCodes); i++ {
|
for i := 0; i < len(emergencyCodes); i++ {
|
||||||
for j := i + 1; j < len(emergencyCodes); j++ {
|
for j := i + 1; j < len(emergencyCodes); j++ {
|
||||||
|
|
@ -323,7 +323,7 @@ func (db *Database) GetEmergencyCodes() []*CodeInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return emergencyCodes
|
return emergencyCodes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,7 +379,7 @@ func (db *Database) FormatSquawkWithDescription(code string) string {
|
||||||
if info == nil {
|
if info == nil {
|
||||||
return code // Return just the code if no description available
|
return code // Return just the code if no description available
|
||||||
}
|
}
|
||||||
|
|
||||||
switch info.Type {
|
switch info.Type {
|
||||||
case Emergency:
|
case Emergency:
|
||||||
return fmt.Sprintf("%s (⚠️ EMERGENCY - %s)", code, info.Description)
|
return fmt.Sprintf("%s (⚠️ EMERGENCY - %s)", code, info.Description)
|
||||||
|
|
@ -390,4 +390,4 @@ func (db *Database) FormatSquawkWithDescription(code string) string {
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("%s (%s)", code, info.Description)
|
return fmt.Sprintf("%s (%s)", code, info.Description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ func TestNewDatabase(t *testing.T) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
t.Fatal("NewDatabase() returned nil")
|
t.Fatal("NewDatabase() returned nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(db.codes) == 0 {
|
if len(db.codes) == 0 {
|
||||||
t.Error("Database should contain pre-loaded codes")
|
t.Error("Database should contain pre-loaded codes")
|
||||||
}
|
}
|
||||||
|
|
@ -17,20 +17,20 @@ func TestNewDatabase(t *testing.T) {
|
||||||
|
|
||||||
func TestEmergencyCodes(t *testing.T) {
|
func TestEmergencyCodes(t *testing.T) {
|
||||||
db := NewDatabase()
|
db := NewDatabase()
|
||||||
|
|
||||||
emergencyCodes := []string{"7500", "7600", "7700"}
|
emergencyCodes := []string{"7500", "7600", "7700"}
|
||||||
|
|
||||||
for _, code := range emergencyCodes {
|
for _, code := range emergencyCodes {
|
||||||
info := db.Lookup(code)
|
info := db.Lookup(code)
|
||||||
if info == nil {
|
if info == nil {
|
||||||
t.Errorf("Emergency code %s not found", code)
|
t.Errorf("Emergency code %s not found", code)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.Type != Emergency {
|
if info.Type != Emergency {
|
||||||
t.Errorf("Code %s should be Emergency type, got %s", code, info.Type)
|
t.Errorf("Code %s should be Emergency type, got %s", code, info.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !db.IsEmergencyCode(code) {
|
if !db.IsEmergencyCode(code) {
|
||||||
t.Errorf("IsEmergencyCode(%s) should return true", code)
|
t.Errorf("IsEmergencyCode(%s) should return true", code)
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +39,7 @@ func TestEmergencyCodes(t *testing.T) {
|
||||||
|
|
||||||
func TestStandardCodes(t *testing.T) {
|
func TestStandardCodes(t *testing.T) {
|
||||||
db := NewDatabase()
|
db := NewDatabase()
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
code string
|
code string
|
||||||
description string
|
description string
|
||||||
|
|
@ -48,16 +48,16 @@ func TestStandardCodes(t *testing.T) {
|
||||||
{"7000", "VFR - Visual Flight Rules"},
|
{"7000", "VFR - Visual Flight Rules"},
|
||||||
{"1000", "Mode A/C Not Assigned"},
|
{"1000", "Mode A/C Not Assigned"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
info := db.Lookup(tc.code)
|
info := db.Lookup(tc.code)
|
||||||
if info == nil {
|
if info == nil {
|
||||||
t.Errorf("Standard code %s not found", tc.code)
|
t.Errorf("Standard code %s not found", tc.code)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.Description != tc.description {
|
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)
|
tc.code, tc.description, info.Description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -65,17 +65,17 @@ func TestStandardCodes(t *testing.T) {
|
||||||
|
|
||||||
func TestLookupInt(t *testing.T) {
|
func TestLookupInt(t *testing.T) {
|
||||||
db := NewDatabase()
|
db := NewDatabase()
|
||||||
|
|
||||||
// Test integer lookup
|
// Test integer lookup
|
||||||
info := db.LookupInt(7700)
|
info := db.LookupInt(7700)
|
||||||
if info == nil {
|
if info == nil {
|
||||||
t.Fatal("LookupInt(7700) returned nil")
|
t.Fatal("LookupInt(7700) returned nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.Code != "7700" {
|
if info.Code != "7700" {
|
||||||
t.Errorf("Expected code '7700', got '%s'", info.Code)
|
t.Errorf("Expected code '7700', got '%s'", info.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.Type != Emergency {
|
if info.Type != Emergency {
|
||||||
t.Errorf("Code 7700 should be Emergency type, got %s", info.Type)
|
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) {
|
func TestLookupHex(t *testing.T) {
|
||||||
db := NewDatabase()
|
db := NewDatabase()
|
||||||
|
|
||||||
// 7700 in octal is 3840 in decimal, which is F00 in hex
|
// 7700 in octal is 3840 in decimal, which is F00 in hex
|
||||||
// However, squawk codes are transmitted differently in different formats
|
// However, squawk codes are transmitted differently in different formats
|
||||||
// For now, test with a simple hex conversion
|
// For now, test with a simple hex conversion
|
||||||
|
|
||||||
// Test invalid hex
|
// Test invalid hex
|
||||||
info := db.LookupHex("INVALID")
|
info := db.LookupHex("INVALID")
|
||||||
if info != nil {
|
if info != nil {
|
||||||
|
|
@ -97,7 +97,7 @@ func TestLookupHex(t *testing.T) {
|
||||||
|
|
||||||
func TestFormatSquawkWithDescription(t *testing.T) {
|
func TestFormatSquawkWithDescription(t *testing.T) {
|
||||||
db := NewDatabase()
|
db := NewDatabase()
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
code string
|
code string
|
||||||
expected string
|
expected string
|
||||||
|
|
@ -108,7 +108,7 @@ func TestFormatSquawkWithDescription(t *testing.T) {
|
||||||
{"0000", "0000 (🔰 No Transponder/Military)"},
|
{"0000", "0000 (🔰 No Transponder/Military)"},
|
||||||
{"9999", "9999"}, // Unknown code should return just the code
|
{"9999", "9999"}, // Unknown code should return just the code
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
result := db.FormatSquawkWithDescription(tc.code)
|
result := db.FormatSquawkWithDescription(tc.code)
|
||||||
if result != tc.expected {
|
if result != tc.expected {
|
||||||
|
|
@ -120,12 +120,12 @@ func TestFormatSquawkWithDescription(t *testing.T) {
|
||||||
|
|
||||||
func TestGetEmergencyCodes(t *testing.T) {
|
func TestGetEmergencyCodes(t *testing.T) {
|
||||||
db := NewDatabase()
|
db := NewDatabase()
|
||||||
|
|
||||||
emergencyCodes := db.GetEmergencyCodes()
|
emergencyCodes := db.GetEmergencyCodes()
|
||||||
if len(emergencyCodes) != 3 {
|
if len(emergencyCodes) != 3 {
|
||||||
t.Errorf("Expected 3 emergency codes, got %d", len(emergencyCodes))
|
t.Errorf("Expected 3 emergency codes, got %d", len(emergencyCodes))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that they're sorted by priority (highest first)
|
// Check that they're sorted by priority (highest first)
|
||||||
for i := 1; i < len(emergencyCodes); i++ {
|
for i := 1; i < len(emergencyCodes); i++ {
|
||||||
if emergencyCodes[i-1].Priority < emergencyCodes[i].Priority {
|
if emergencyCodes[i-1].Priority < emergencyCodes[i].Priority {
|
||||||
|
|
@ -136,7 +136,7 @@ func TestGetEmergencyCodes(t *testing.T) {
|
||||||
|
|
||||||
func TestAddCustomCode(t *testing.T) {
|
func TestAddCustomCode(t *testing.T) {
|
||||||
db := NewDatabase()
|
db := NewDatabase()
|
||||||
|
|
||||||
customCode := &CodeInfo{
|
customCode := &CodeInfo{
|
||||||
Code: "1234",
|
Code: "1234",
|
||||||
Description: "Test Custom Code",
|
Description: "Test Custom Code",
|
||||||
|
|
@ -145,14 +145,14 @@ func TestAddCustomCode(t *testing.T) {
|
||||||
Priority: 50,
|
Priority: 50,
|
||||||
Notes: "This is a test custom code",
|
Notes: "This is a test custom code",
|
||||||
}
|
}
|
||||||
|
|
||||||
db.AddCustomCode(customCode)
|
db.AddCustomCode(customCode)
|
||||||
|
|
||||||
info := db.Lookup("1234")
|
info := db.Lookup("1234")
|
||||||
if info == nil {
|
if info == nil {
|
||||||
t.Fatal("Custom code not found after adding")
|
t.Fatal("Custom code not found after adding")
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.Description != "Test Custom Code" {
|
if info.Description != "Test Custom Code" {
|
||||||
t.Errorf("Custom code description mismatch: expected %q, got %q",
|
t.Errorf("Custom code description mismatch: expected %q, got %q",
|
||||||
"Test Custom Code", info.Description)
|
"Test Custom Code", info.Description)
|
||||||
|
|
@ -170,7 +170,7 @@ func TestCodeTypeString(t *testing.T) {
|
||||||
{Military, "Military"},
|
{Military, "Military"},
|
||||||
{Special, "Special"},
|
{Special, "Special"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
result := tc.codeType.String()
|
result := tc.codeType.String()
|
||||||
if result != tc.expected {
|
if result != tc.expected {
|
||||||
|
|
@ -181,16 +181,16 @@ func TestCodeTypeString(t *testing.T) {
|
||||||
|
|
||||||
func TestGetAllCodes(t *testing.T) {
|
func TestGetAllCodes(t *testing.T) {
|
||||||
db := NewDatabase()
|
db := NewDatabase()
|
||||||
|
|
||||||
allCodes := db.GetAllCodes()
|
allCodes := db.GetAllCodes()
|
||||||
if len(allCodes) == 0 {
|
if len(allCodes) == 0 {
|
||||||
t.Error("GetAllCodes() should return non-empty slice")
|
t.Error("GetAllCodes() should return non-empty slice")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify we can find known codes in the result
|
// Verify we can find known codes in the result
|
||||||
found7700 := false
|
found7700 := false
|
||||||
found1200 := false
|
found1200 := false
|
||||||
|
|
||||||
for _, code := range allCodes {
|
for _, code := range allCodes {
|
||||||
if code.Code == "7700" {
|
if code.Code == "7700" {
|
||||||
found7700 = true
|
found7700 = true
|
||||||
|
|
@ -199,11 +199,11 @@ func TestGetAllCodes(t *testing.T) {
|
||||||
found1200 = true
|
found1200 = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found7700 {
|
if !found7700 {
|
||||||
t.Error("Emergency code 7700 not found in GetAllCodes() result")
|
t.Error("Emergency code 7700 not found in GetAllCodes() result")
|
||||||
}
|
}
|
||||||
if !found1200 {
|
if !found1200 {
|
||||||
t.Error("Standard code 1200 not found in GetAllCodes() result")
|
t.Error("Standard code 1200 not found in GetAllCodes() result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue