mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-04 03:17:57 +02:00
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:
@@ -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,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' &&
|
||||
|
||||
Reference in New Issue
Block a user