Add Airframes ACARS datalink on plane dossiers and Meshtastic planet scan.

Bulk-ingest Airframes messages on a rate-limited staggered queue with instant cache lookups and priority per-aircraft refresh when opening a dossier; add Meshtastic manual SCAN PLANET control in the SIGINT panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BigBodyCobain
2026-06-24 22:15:50 -06:00
parent a0c79c2044
commit 9ad0a5ffce
13 changed files with 1211 additions and 27 deletions
@@ -0,0 +1,178 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { API_BASE } from '@/lib/api';
type DatalinkMessage = {
id: number;
timestamp?: string;
label?: string;
text?: string;
source_type?: string;
};
const PRIORITY_POLL_MS = 3_000;
const PRIORITY_POLL_MAX_MS = 45_000;
function formatDatalinkTime(value?: string): string {
if (!value) return '--:--';
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value.slice(11, 16) || value;
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
} catch {
return '--:--';
}
}
export default function DatalinkMessagesBlock({
icao24,
registration,
callsign,
}: {
icao24?: string;
registration?: string;
callsign?: string;
}) {
const [loading, setLoading] = useState(true);
const [configured, setConfigured] = useState<boolean | null>(null);
const [messages, setMessages] = useState<DatalinkMessage[]>([]);
const [hint, setHint] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [priorityScanning, setPriorityScanning] = useState(false);
const pollUntilRef = useRef(0);
const buildParams = useCallback(() => {
const params = new URLSearchParams();
if (icao24) params.set('icao24', icao24);
if (registration) params.set('registration', registration);
if (callsign) params.set('callsign', callsign);
return params;
}, [icao24, registration, callsign]);
const fetchDatalink = useCallback(
async (opts?: { showLoading?: boolean }) => {
const params = buildParams();
if ([...params.keys()].length === 0) {
setLoading(false);
return;
}
if (opts?.showLoading) {
setLoading(true);
setLoadError(null);
}
try {
const res = await fetch(`${API_BASE}/api/aviation/datalink/messages?${params.toString()}`, {
cache: 'no-store',
});
if (!res.ok) throw new Error(`datalink ${res.status}`);
const json = await res.json();
setConfigured(Boolean(json.configured));
setMessages(Array.isArray(json.messages) ? json.messages : []);
setHint(typeof json.hint === 'string' ? json.hint : null);
setLoadError(null);
if (json.priority_scan || json.queued_refresh) {
setPriorityScanning(true);
pollUntilRef.current = Date.now() + PRIORITY_POLL_MAX_MS;
}
if (Array.isArray(json.messages) && json.messages.length > 0) {
setPriorityScanning(false);
}
} catch {
if (opts?.showLoading) {
setConfigured(null);
setMessages([]);
setLoadError('Could not reach ACARS cache. Try again in a moment.');
}
} finally {
if (opts?.showLoading) setLoading(false);
}
},
[buildParams],
);
useEffect(() => {
pollUntilRef.current = 0;
setPriorityScanning(false);
void fetchDatalink({ showLoading: true });
}, [fetchDatalink]);
useEffect(() => {
if (!priorityScanning) return;
const intervalId = window.setInterval(() => {
if (Date.now() > pollUntilRef.current) {
setPriorityScanning(false);
return;
}
void fetchDatalink();
}, PRIORITY_POLL_MS);
return () => window.clearInterval(intervalId);
}, [priorityScanning, fetchDatalink]);
if (loading) {
return (
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1">DATALINK (AIRFRAMES)</span>
<span className="text-[10px] font-mono text-[var(--text-muted)]">Loading ACARS cache</span>
</div>
);
}
if (loadError) {
return (
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1">DATALINK (AIRFRAMES)</span>
<p className="text-[10px] font-mono text-amber-400/90 leading-relaxed">{loadError}</p>
</div>
);
}
if (configured === false) {
return (
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1">DATALINK (AIRFRAMES)</span>
<p className="text-[10px] font-mono text-amber-400/90 leading-relaxed">
{hint || 'Add your Airframes API key in Settings → API Keys to enable ACARS datalink on plane dossiers.'}
</p>
</div>
);
}
if (!messages.length) {
return (
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1">DATALINK (AIRFRAMES)</span>
<p className="text-[10px] font-mono text-[var(--text-muted)]">
{priorityScanning
? 'Priority scan queued for this aircraft (~2s)…'
: 'No recent ACARS/VDL messages for this aircraft.'}
</p>
</div>
);
}
return (
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1">DATALINK (AIRFRAMES)</span>
{priorityScanning ? (
<p className="text-[10px] font-mono text-cyan-500/70 mb-1">Refreshing this aircraft</p>
) : null}
<div className="max-h-36 overflow-y-auto space-y-1.5 pr-1">
{messages.map((message) => (
<div key={message.id} className="text-[10px] font-mono leading-snug border border-[var(--border-primary)]/60 bg-black/20 px-2 py-1.5">
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-0.5">
<span>{formatDatalinkTime(message.timestamp)}</span>
{message.label ? <span className="text-orange-400/90">{message.label}</span> : null}
{message.source_type ? <span className="truncate">{message.source_type}</span> : null}
</div>
<div className="text-[var(--text-primary)] whitespace-pre-wrap break-words">{message.text}</div>
</div>
))}
</div>
</div>
);
}
+11
View File
@@ -11,6 +11,7 @@ import { fetchWikipediaSummary } from '@/lib/wikimediaClient';
import type { SelectedEntity, RegionDossier, FimiData } from "@/types/dashboard";
import { useDataKeys } from '@/hooks/useDataStore';
import { API_BASE } from '@/lib/api';
import DatalinkMessagesBlock from '@/components/DatalinkMessagesBlock';
import { lookupShodanHost } from '@/lib/shodanClient';
import type { ShodanHost } from '@/types/shodan';
@@ -909,6 +910,11 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, gt
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
</div>
)}
<DatalinkMessagesBlock
icao24={flight.icao24}
registration={flight.registration}
callsign={flight.callsign}
/>
<EmissionsEstimateBlock flight={flightForEmissions} />
{flight.alert_link && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
@@ -1162,6 +1168,11 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, gt
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
</div>
)}
<DatalinkMessagesBlock
icao24={flight.icao24}
registration={flight.registration}
callsign={flight.callsign}
/>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">ROUTE</span>
<span className="text-cyan-400 text-xs font-bold">{flight.origin_name !== "UNKNOWN" ? `[${flight.origin_name}] → [${flight.dest_name}]` : "UNKNOWN"}</span>
@@ -44,6 +44,7 @@ import {
Radar,
MapPin,
Truck,
RefreshCw,
} from 'lucide-react';
import RoadCorridorLayerControls from '@/components/RoadCorridorLayerControls';
import { API_BASE } from '@/lib/api';
@@ -764,7 +765,10 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
const { theme, toggleTheme, hudColor, cycleHudColor } = useTheme();
const [gibsPlaying, setGibsPlaying] = useState(false);
const [potusEnabled, setPotusEnabled] = useState(true);
const [meshtasticScanning, setMeshtasticScanning] = useState(false);
const [meshtasticScanMessage, setMeshtasticScanMessage] = useState('');
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const meshtasticScanPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// SAR mode chooser — prompts the first time the user enables the SAR
// layer, remembers the choice, and auto-detects server-side Mode B.
@@ -986,6 +990,67 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
return { meshtasticCount: mesh, aprsCount: aprs };
}, [data?.sigint, data?.sigint_totals]);
const stopMeshtasticScanPoll = useCallback(() => {
if (meshtasticScanPollRef.current) {
clearInterval(meshtasticScanPollRef.current);
meshtasticScanPollRef.current = null;
}
}, []);
useEffect(() => () => stopMeshtasticScanPoll(), [stopMeshtasticScanPoll]);
const scanMeshtasticPlanet = useCallback(
async (event: React.MouseEvent) => {
event.stopPropagation();
if (meshtasticScanning) return;
setMeshtasticScanning(true);
setMeshtasticScanMessage('Starting global node scan…');
stopMeshtasticScanPoll();
try {
const res = await fetch(`${API_BASE}/api/sigint/meshtastic/scan`, { method: 'POST' });
const json = await res.json().catch(() => ({}));
if (!res.ok || json.ok === false) {
setMeshtasticScanMessage(
typeof json.status === 'string' ? json.status : 'Meshtastic scan could not start.',
);
setMeshtasticScanning(false);
return;
}
setMeshtasticScanMessage('Scanning planet (~90s)…');
let polls = 0;
meshtasticScanPollRef.current = setInterval(async () => {
polls += 1;
try {
const statusRes = await fetch(`${API_BASE}/api/sigint/meshtastic/status`);
if (!statusRes.ok) return;
const status = await statusRes.json();
if (!status.scan_in_progress) {
stopMeshtasticScanPoll();
setMeshtasticScanning(false);
const count = Number(status.node_count || 0);
setMeshtasticScanMessage(
count > 0 ? `Scan complete — ${count.toLocaleString()} nodes on map.` : 'Scan complete.',
);
} else if (polls >= 40) {
stopMeshtasticScanPoll();
setMeshtasticScanning(false);
setMeshtasticScanMessage('Scan still running in background. Count will update when finished.');
}
} catch {
// keep polling until timeout
}
}, 3000);
} catch {
setMeshtasticScanning(false);
setMeshtasticScanMessage('Meshtastic scan request failed.');
}
},
[meshtasticScanning, stopMeshtasticScanPoll],
);
const cctvCount = Number(data?.cctv_total || data?.cctv?.length || 0);
const satnogsCount = Number(data?.satnogs_total || data?.satnogs_stations?.length || 0);
const tinygsCount = Number(data?.tinygs_total || data?.tinygs_satellites?.length || 0);
@@ -1926,6 +1991,31 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
)}
</div>
</div>
{active && layer.id === 'sigint_meshtastic' && (
<div
className="ml-7 mt-2 flex flex-col gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={scanMeshtasticPlanet}
disabled={meshtasticScanning}
className="inline-flex items-center gap-1.5 self-start px-2 py-1 text-[10px] font-mono tracking-wider border border-cyan-500/40 text-cyan-400 hover:bg-cyan-950/30 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
title="Re-fetch all Meshtastic node positions from the global map API"
>
<RefreshCw
size={11}
className={meshtasticScanning ? 'animate-spin' : ''}
/>
{meshtasticScanning ? 'SCANNING…' : 'SCAN PLANET'}
</button>
{meshtasticScanMessage ? (
<span className="text-[10px] font-mono text-cyan-500/80 leading-snug">
{meshtasticScanMessage}
</span>
) : null}
</div>
)}
{/* GIBS Imagery inline controls */}
{active &&
layer.id === 'gibs_imagery' &&