mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
feat: add military/civilian classification filter to ADS-B history
Add client-side and server-side military aircraft detection using ICAO hex ranges and callsign prefixes (matching live dashboard logic). History table shows MIL/CIV badges with filtering dropdown, and exports respect the classification filter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -197,6 +197,40 @@ def _ensure_history_schema() -> None:
|
|||||||
logger.warning("ADS-B schema check failed: %s", exc)
|
logger.warning("ADS-B schema check failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
MILITARY_ICAO_RANGES = [
|
||||||
|
(0xADF7C0, 0xADFFFF), # US
|
||||||
|
(0xAE0000, 0xAEFFFF), # US
|
||||||
|
(0x3F4000, 0x3F7FFF), # FR
|
||||||
|
(0x43C000, 0x43CFFF), # UK
|
||||||
|
(0x3D0000, 0x3DFFFF), # DE
|
||||||
|
(0x501C00, 0x501FFF), # NATO
|
||||||
|
]
|
||||||
|
|
||||||
|
MILITARY_CALLSIGN_PREFIXES = (
|
||||||
|
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
|
||||||
|
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
|
||||||
|
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
|
||||||
|
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_military_aircraft(icao: str, callsign: str | None) -> bool:
|
||||||
|
"""Return True if the ICAO hex or callsign indicates a military aircraft."""
|
||||||
|
try:
|
||||||
|
hex_val = int(icao, 16)
|
||||||
|
for start, end in MILITARY_ICAO_RANGES:
|
||||||
|
if start <= hex_val <= end:
|
||||||
|
return True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if callsign:
|
||||||
|
upper = callsign.upper().strip()
|
||||||
|
for prefix in MILITARY_CALLSIGN_PREFIXES:
|
||||||
|
if upper.startswith(prefix):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
|
def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
|
||||||
try:
|
try:
|
||||||
parsed = int(value) if value is not None else default
|
parsed = int(value) if value is not None else default
|
||||||
@@ -277,6 +311,7 @@ def _build_export_csv(
|
|||||||
since_minutes: int | None,
|
since_minutes: int | None,
|
||||||
icao: str,
|
icao: str,
|
||||||
search: str,
|
search: str,
|
||||||
|
classification: str,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
snapshots: list[dict[str, Any]],
|
snapshots: list[dict[str, Any]],
|
||||||
sessions: list[dict[str, Any]],
|
sessions: list[dict[str, Any]],
|
||||||
@@ -293,6 +328,8 @@ def _build_export_csv(
|
|||||||
writer.writerow(['ICAO Filter', icao])
|
writer.writerow(['ICAO Filter', icao])
|
||||||
if search:
|
if search:
|
||||||
writer.writerow(['Search Filter', search])
|
writer.writerow(['Search Filter', search])
|
||||||
|
if classification != 'all':
|
||||||
|
writer.writerow(['Classification', classification])
|
||||||
writer.writerow([])
|
writer.writerow([])
|
||||||
|
|
||||||
def write_section(title: str, rows: list[dict[str, Any]], columns: list[str]) -> None:
|
def write_section(title: str, rows: list[dict[str, Any]], columns: list[str]) -> None:
|
||||||
@@ -1356,12 +1393,28 @@ def adsb_history_export():
|
|||||||
scope, since_minutes, start, end = _parse_export_scope(request.args)
|
scope, since_minutes, start, end = _parse_export_scope(request.args)
|
||||||
icao = (request.args.get('icao') or '').strip().upper()
|
icao = (request.args.get('icao') or '').strip().upper()
|
||||||
search = (request.args.get('search') or '').strip()
|
search = (request.args.get('search') or '').strip()
|
||||||
|
classification = str(request.args.get('classification') or 'all').strip().lower()
|
||||||
|
if classification not in {'all', 'military', 'civilian'}:
|
||||||
|
classification = 'all'
|
||||||
pattern = f'%{search}%'
|
pattern = f'%{search}%'
|
||||||
|
|
||||||
snapshots: list[dict[str, Any]] = []
|
snapshots: list[dict[str, Any]] = []
|
||||||
messages: list[dict[str, Any]] = []
|
messages: list[dict[str, Any]] = []
|
||||||
sessions: list[dict[str, Any]] = []
|
sessions: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _filter_by_classification(
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
icao_key: str = 'icao',
|
||||||
|
callsign_key: str = 'callsign',
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if classification == 'all':
|
||||||
|
return rows
|
||||||
|
want_military = classification == 'military'
|
||||||
|
return [
|
||||||
|
r for r in rows
|
||||||
|
if _is_military_aircraft(r.get(icao_key, ''), r.get(callsign_key)) == want_military
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _get_history_connection() as conn:
|
with _get_history_connection() as conn:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
@@ -1393,7 +1446,7 @@ def adsb_history_export():
|
|||||||
snapshot_sql += " WHERE " + " AND ".join(snapshot_where)
|
snapshot_sql += " WHERE " + " AND ".join(snapshot_where)
|
||||||
snapshot_sql += " ORDER BY captured_at DESC"
|
snapshot_sql += " ORDER BY captured_at DESC"
|
||||||
cur.execute(snapshot_sql, tuple(snapshot_params))
|
cur.execute(snapshot_sql, tuple(snapshot_params))
|
||||||
snapshots = cur.fetchall()
|
snapshots = _filter_by_classification(cur.fetchall())
|
||||||
|
|
||||||
if export_type in {'messages', 'all'}:
|
if export_type in {'messages', 'all'}:
|
||||||
message_where: list[str] = []
|
message_where: list[str] = []
|
||||||
@@ -1424,7 +1477,7 @@ def adsb_history_export():
|
|||||||
message_sql += " WHERE " + " AND ".join(message_where)
|
message_sql += " WHERE " + " AND ".join(message_where)
|
||||||
message_sql += " ORDER BY received_at DESC"
|
message_sql += " ORDER BY received_at DESC"
|
||||||
cur.execute(message_sql, tuple(message_params))
|
cur.execute(message_sql, tuple(message_params))
|
||||||
messages = cur.fetchall()
|
messages = _filter_by_classification(cur.fetchall())
|
||||||
|
|
||||||
if export_type in {'sessions', 'all'}:
|
if export_type in {'sessions', 'all'}:
|
||||||
session_where: list[str] = []
|
session_where: list[str] = []
|
||||||
@@ -1465,6 +1518,7 @@ def adsb_history_export():
|
|||||||
'filters': {
|
'filters': {
|
||||||
'icao': icao or None,
|
'icao': icao or None,
|
||||||
'search': search or None,
|
'search': search or None,
|
||||||
|
'classification': classification,
|
||||||
'start': start.isoformat() if start else None,
|
'start': start.isoformat() if start else None,
|
||||||
'end': end.isoformat() if end else None,
|
'end': end.isoformat() if end else None,
|
||||||
},
|
},
|
||||||
@@ -1490,6 +1544,7 @@ def adsb_history_export():
|
|||||||
since_minutes=since_minutes if scope == 'window' else None,
|
since_minutes=since_minutes if scope == 'window' else None,
|
||||||
icao=icao,
|
icao=icao,
|
||||||
search=search,
|
search=search,
|
||||||
|
classification=classification,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
snapshots=snapshots,
|
snapshots=snapshots,
|
||||||
sessions=sessions,
|
sessions=sessions,
|
||||||
|
|||||||
@@ -414,6 +414,37 @@ body {
|
|||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aircraft-row.military {
|
||||||
|
background: rgba(85, 107, 47, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-row.military:hover {
|
||||||
|
background: rgba(85, 107, 47, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mil-badge,
|
||||||
|
.civ-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mil-badge {
|
||||||
|
background: rgba(85, 107, 47, 0.35);
|
||||||
|
color: #a3b86c;
|
||||||
|
border: 1px solid rgba(85, 107, 47, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.civ-badge {
|
||||||
|
background: rgba(74, 158, 255, 0.15);
|
||||||
|
color: var(--text-dim);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,14 @@
|
|||||||
<label for="searchInput">Search</label>
|
<label for="searchInput">Search</label>
|
||||||
<input type="text" id="searchInput" placeholder="ICAO / callsign / registration">
|
<input type="text" id="searchInput" placeholder="ICAO / callsign / registration">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="classSelect">Classification</label>
|
||||||
|
<select id="classSelect">
|
||||||
|
<option value="all">All Aircraft</option>
|
||||||
|
<option value="military">Military</option>
|
||||||
|
<option value="civilian">Civilian</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label for="limitSelect">Limit</label>
|
<label for="limitSelect">Limit</label>
|
||||||
<select id="limitSelect">
|
<select id="limitSelect">
|
||||||
@@ -156,6 +164,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ICAO</th>
|
<th>ICAO</th>
|
||||||
<th>Callsign</th>
|
<th>Callsign</th>
|
||||||
|
<th>Class</th>
|
||||||
<th>Alt</th>
|
<th>Alt</th>
|
||||||
<th>Speed</th>
|
<th>Speed</th>
|
||||||
<th>Last Seen</th>
|
<th>Last Seen</th>
|
||||||
@@ -163,7 +172,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="aircraftTableBody">
|
<tbody id="aircraftTableBody">
|
||||||
<tr class="empty-row">
|
<tr class="empty-row">
|
||||||
<td colspan="5">No aircraft in this window</td>
|
<td colspan="6">No aircraft in this window</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -313,6 +322,39 @@
|
|||||||
const windowSelect = document.getElementById('windowSelect');
|
const windowSelect = document.getElementById('windowSelect');
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
const limitSelect = document.getElementById('limitSelect');
|
const limitSelect = document.getElementById('limitSelect');
|
||||||
|
const classSelect = document.getElementById('classSelect');
|
||||||
|
|
||||||
|
const MILITARY_RANGES = [
|
||||||
|
{ start: 0xADF7C0, end: 0xADFFFF },
|
||||||
|
{ start: 0xAE0000, end: 0xAEFFFF },
|
||||||
|
{ start: 0x3F4000, end: 0x3F7FFF },
|
||||||
|
{ start: 0x43C000, end: 0x43CFFF },
|
||||||
|
{ start: 0x3D0000, end: 0x3DFFFF },
|
||||||
|
{ start: 0x501C00, end: 0x501FFF },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MILITARY_PREFIXES = [
|
||||||
|
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
|
||||||
|
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
|
||||||
|
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
|
||||||
|
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF'
|
||||||
|
];
|
||||||
|
|
||||||
|
function isMilitaryAircraft(icao, callsign) {
|
||||||
|
if (icao) {
|
||||||
|
const hex = parseInt(icao, 16);
|
||||||
|
for (const range of MILITARY_RANGES) {
|
||||||
|
if (hex >= range.start && hex <= range.end) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (callsign) {
|
||||||
|
const upper = callsign.toUpperCase().trim();
|
||||||
|
for (const prefix of MILITARY_PREFIXES) {
|
||||||
|
if (upper.startsWith(prefix)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const HISTORY_WINDOW_STORAGE_KEY = 'adsbHistoryWindowMinutes';
|
const HISTORY_WINDOW_STORAGE_KEY = 'adsbHistoryWindowMinutes';
|
||||||
const VALID_HISTORY_WINDOWS = new Set(['15', '60', '360', '1440', '10080']);
|
const VALID_HISTORY_WINDOWS = new Set(['15', '60', '360', '1440', '10080']);
|
||||||
const historyStatus = document.getElementById('historyStatus');
|
const historyStatus = document.getElementById('historyStatus');
|
||||||
@@ -475,6 +517,7 @@
|
|||||||
const sinceMinutes = windowSelect.value;
|
const sinceMinutes = windowSelect.value;
|
||||||
const search = searchInput.value.trim();
|
const search = searchInput.value.trim();
|
||||||
|
|
||||||
|
const classification = classSelect.value;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
format: exportFormat,
|
format: exportFormat,
|
||||||
type: exportType,
|
type: exportType,
|
||||||
@@ -486,6 +529,9 @@
|
|||||||
if (search) {
|
if (search) {
|
||||||
params.set('search', search);
|
params.set('search', search);
|
||||||
}
|
}
|
||||||
|
if (classification !== 'all') {
|
||||||
|
params.set('classification', classification);
|
||||||
|
}
|
||||||
|
|
||||||
const fallbackName = `adsb_history_${exportType}.${exportFormat}`;
|
const fallbackName = `adsb_history_${exportType}.${exportFormat}`;
|
||||||
const exportUrl = `/adsb/history/export?${params.toString()}`;
|
const exportUrl = `/adsb/history/export?${params.toString()}`;
|
||||||
@@ -604,26 +650,45 @@
|
|||||||
const search = encodeURIComponent(searchInput.value.trim());
|
const search = encodeURIComponent(searchInput.value.trim());
|
||||||
const resp = await fetch(`/adsb/history/aircraft?since_minutes=${sinceMinutes}&limit=${limit}&search=${search}`);
|
const resp = await fetch(`/adsb/history/aircraft?since_minutes=${sinceMinutes}&limit=${limit}&search=${search}`);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="5">History database unavailable</td></tr>';
|
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="6">History database unavailable</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const rows = data.aircraft || [];
|
let rows = data.aircraft || [];
|
||||||
|
|
||||||
|
// Tag each row with military classification
|
||||||
|
rows.forEach(row => {
|
||||||
|
row._military = isMilitaryAircraft(row.icao, row.callsign);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply classification filter
|
||||||
|
const classFilter = classSelect.value;
|
||||||
|
if (classFilter === 'military') {
|
||||||
|
rows = rows.filter(r => r._military);
|
||||||
|
} else if (classFilter === 'civilian') {
|
||||||
|
rows = rows.filter(r => !r._military);
|
||||||
|
}
|
||||||
|
|
||||||
recentAircraft = rows;
|
recentAircraft = rows;
|
||||||
aircraftCount.textContent = rows.length;
|
aircraftCount.textContent = rows.length;
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="5">No aircraft in this window</td></tr>';
|
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="6">No aircraft in this window</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
aircraftTableBody.innerHTML = rows.map(row => `
|
aircraftTableBody.innerHTML = rows.map(row => {
|
||||||
<tr class="aircraft-row" data-icao="${row.icao}">
|
const badge = row._military
|
||||||
|
? '<span class="mil-badge">MIL</span>'
|
||||||
|
: '<span class="civ-badge">CIV</span>';
|
||||||
|
return `
|
||||||
|
<tr class="aircraft-row${row._military ? ' military' : ''}" data-icao="${row.icao}">
|
||||||
<td class="mono">${row.icao}</td>
|
<td class="mono">${row.icao}</td>
|
||||||
<td>${valueOrDash(row.callsign)}</td>
|
<td>${valueOrDash(row.callsign)}</td>
|
||||||
|
<td>${badge}</td>
|
||||||
<td>${valueOrDash(row.altitude)}</td>
|
<td>${valueOrDash(row.altitude)}</td>
|
||||||
<td>${valueOrDash(row.speed)}</td>
|
<td>${valueOrDash(row.speed)}</td>
|
||||||
<td>${formatTime(row.last_seen)}</td>
|
<td>${formatTime(row.last_seen)}</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
|
|
||||||
document.querySelectorAll('.aircraft-row').forEach((row, index) => {
|
document.querySelectorAll('.aircraft-row').forEach((row, index) => {
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
@@ -980,6 +1045,7 @@
|
|||||||
refreshAll();
|
refreshAll();
|
||||||
});
|
});
|
||||||
limitSelect.addEventListener('change', refreshAll);
|
limitSelect.addEventListener('change', refreshAll);
|
||||||
|
classSelect.addEventListener('change', refreshAll);
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
clearTimeout(searchInput._debounce);
|
clearTimeout(searchInput._debounce);
|
||||||
searchInput._debounce = setTimeout(refreshAll, 350);
|
searchInput._debounce = setTimeout(refreshAll, 350);
|
||||||
|
|||||||
Reference in New Issue
Block a user