mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-28 16:59:55 +02:00
feat: Telegram OSINT map layer, Osiris intel ports, and maritime settings
Add Telegram OSINT with hourly incremental t.me scraping, metro geocoding separate from news centroids, threat-intercept popup UI with inline media, and HTML markers above alert boxes so pins stay clickable. Expose GFW_API_TOKEN in onboarding and Settings Maritime; harden GFW/CCTV/geo fetchers. Port Osiris- derived recon, SCM, entity graph, malware/cyber feeds, sanctions, and submarine cable layers with tests and documentation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Loader2, Minus, Network, Plus, X } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { isEntityGraphEligible, mapEntityToGraphType } from '@/lib/entityGraph';
|
||||
import type { SelectedEntity } from '@/types/dashboard';
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entity: SelectedEntity | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
aircraft: 'text-cyan-300',
|
||||
vessel: 'text-cyan-400',
|
||||
company: 'text-amber-300',
|
||||
person: 'text-violet-300',
|
||||
country: 'text-emerald-300',
|
||||
sanction: 'text-red-300',
|
||||
ip: 'text-orange-300',
|
||||
event: 'text-yellow-300',
|
||||
};
|
||||
|
||||
export default function EntityGraphPanel({ entity, onClose }: Props) {
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [nodes, setNodes] = useState<GraphNode[]>([]);
|
||||
const [links, setLinks] = useState<GraphLink[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadGraph = useCallback(async () => {
|
||||
if (!entity || !isEntityGraphEligible(entity)) return;
|
||||
const type = mapEntityToGraphType(entity.type);
|
||||
if (!type) return;
|
||||
const id = String(entity.name || entity.extra?.callsign || entity.extra?.registration || entity.id);
|
||||
const params = new URLSearchParams({ type, id });
|
||||
if (entity.extra?.registration) params.set('registration', String(entity.extra.registration));
|
||||
if (entity.extra?.icao24) params.set('icao24', String(entity.extra.icao24));
|
||||
if (entity.extra?.model) params.set('model', String(entity.extra.model));
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/entity/expand?${params}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || data.error || 'Expand failed');
|
||||
setNodes(data.nodes || []);
|
||||
setLinks(data.links || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Graph unavailable');
|
||||
setNodes([]);
|
||||
setLinks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entity) loadGraph();
|
||||
else {
|
||||
setNodes([]);
|
||||
setLinks([]);
|
||||
}
|
||||
}, [entity, loadGraph]);
|
||||
|
||||
if (!entity || !isEntityGraphEligible(entity)) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[250] w-80 max-h-[50vh] pointer-events-auto flex flex-col border border-cyan-700/40 bg-black/85 backdrop-blur-sm shadow-[0_0_24px_rgba(34,211,238,0.12)]">
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-cyan-700/30 bg-cyan-950/25 px-3 py-2.5 cursor-pointer hover:bg-cyan-950/40 transition-colors"
|
||||
onClick={() => setIsMinimized((prev) => !prev)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Network size={16} className="text-cyan-400 shrink-0" />
|
||||
<span className="text-[12px] font-mono font-bold tracking-widest text-cyan-400 truncate">
|
||||
ENTITY GRAPH
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="text-cyan-600 hover:text-cyan-300 transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{isMinimized ? (
|
||||
<Plus size={16} className="text-cyan-400" />
|
||||
) : (
|
||||
<Minus size={16} className="text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMinimized && (
|
||||
<div className="px-3 py-2 overflow-y-auto styled-scrollbar flex-1 space-y-2">
|
||||
<div className="text-[10px] font-mono tracking-wider text-cyan-600 truncate">
|
||||
{entity.type.toUpperCase()} · {entity.name || entity.id}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-[11px] font-mono text-cyan-500 tracking-wider">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
RESOLVING…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-500/30 bg-red-950/20 px-2 py-1.5 text-[11px] font-mono text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
{nodes.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className="border border-cyan-900/40 bg-black/50 px-2 py-1.5"
|
||||
>
|
||||
<div className={`text-[9px] font-mono tracking-[0.2em] uppercase opacity-70 ${TYPE_COLORS[n.type] || 'text-cyan-500'}`}>
|
||||
{n.type}
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-cyan-200 leading-snug">{n.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{links.length > 0 && (
|
||||
<div className="border-t border-cyan-900/40 pt-2">
|
||||
<div className="text-[10px] font-mono tracking-[0.2em] text-cyan-600 mb-1">RELATIONSHIPS</div>
|
||||
{links.slice(0, 24).map((l, i) => (
|
||||
<div key={`${l.source}-${l.target}-${i}`} className="text-[10px] font-mono text-cyan-500/90 truncate leading-relaxed">
|
||||
{l.label}: {l.source.split(':').pop()} → {l.target.split(':').pop()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -183,6 +183,7 @@ const LEGEND: LegendCategory[] = [
|
||||
color: 'text-red-400 border-red-500/30',
|
||||
items: [
|
||||
{ svg: triangle('#ffaa00'), label: 'GDELT / LiveUA event (yellow)' },
|
||||
{ svg: dot('#ef4444'), label: 'Telegram OSINT post (red, geolocated)' },
|
||||
{ svg: triangle('#ff0000'), label: 'Violent / Kinetic event (red)' },
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#ffff00" stroke="#ff0000" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>`,
|
||||
|
||||
@@ -158,16 +158,25 @@ import {
|
||||
UavLabels,
|
||||
EarthquakeLabels,
|
||||
ThreatMarkers,
|
||||
TelegramOsintMarkers,
|
||||
} from '@/components/map/MapMarkers';
|
||||
import type { DashboardData, Flight, KiwiSDR, MaplibreViewerProps, Scanner, Ship, SigintSignal } from '@/types/dashboard';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import { useInterpolation } from '@/components/map/hooks/useInterpolation';
|
||||
import { useClusterLabels } from '@/components/map/hooks/useClusterLabels';
|
||||
import { spreadAlertItems } from '@/utils/alertSpread';
|
||||
import {
|
||||
applyTelegramAlertAvoidance,
|
||||
telegramClusterKey,
|
||||
telegramClusterNearNewsAlert,
|
||||
telegramMapPinCoords,
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
|
||||
import { useViewportBounds } from '@/components/map/hooks/useViewportBounds';
|
||||
import { getLiveDataBounds } from '@/lib/liveDataViewport';
|
||||
import { MeasurementLayers } from '@/components/map/layers/MeasurementLayers';
|
||||
import { buildCctvProxyUrl } from '@/lib/cctvProxy';
|
||||
import { sanitizeSubmarineCables } from '@/lib/submarineCables';
|
||||
import { CctvFullscreenModal } from '@/components/MaplibreViewer/CctvFullscreenModal';
|
||||
import { SatellitePopup } from '@/components/MaplibreViewer/popups/SatellitePopup';
|
||||
import { ShipPopup } from '@/components/MaplibreViewer/popups/ShipPopup';
|
||||
@@ -176,6 +185,7 @@ import { CorrelationPopup } from '@/components/MaplibreViewer/popups/Correlation
|
||||
import { WastewaterPopup } from '@/components/MaplibreViewer/popups/WastewaterPopup';
|
||||
import { MilitaryBasePopup } from '@/components/MaplibreViewer/popups/MilitaryBasePopup';
|
||||
import { RegionDossierPanel } from '@/components/MaplibreViewer/popups/RegionDossierPanel';
|
||||
import { TelegramOsintPopup } from '@/components/MaplibreViewer/popups/TelegramOsintPopup';
|
||||
import {
|
||||
buildSentinelTileUrl,
|
||||
hasSentinelCredentials,
|
||||
@@ -294,6 +304,8 @@ const MAP_EXTRA_DATA_KEYS = [
|
||||
'commercial_flights',
|
||||
'correlations',
|
||||
'crowdthreat',
|
||||
'malware_threats',
|
||||
'telegram_osint',
|
||||
'datacenters',
|
||||
'firms_fires',
|
||||
'fishing_activity',
|
||||
@@ -1156,6 +1168,30 @@ const MaplibreViewer = ({
|
||||
const staticUapSightings = activeLayers.uap_sightings ? data?.uap_sightings : undefined;
|
||||
const staticWastewater = activeLayers.wastewater ? data?.wastewater : undefined;
|
||||
const staticCrowdthreat = activeLayers.crowdthreat ? data?.crowdthreat : undefined;
|
||||
const staticMalwareThreats = activeLayers.malware_c2 ? data?.malware_threats?.threats : undefined;
|
||||
const staticTelegramOsintPosts = activeLayers.telegram_osint
|
||||
? data?.telegram_osint?.posts
|
||||
: undefined;
|
||||
|
||||
const [submarineCablesGeoJSON, setSubmarineCablesGeoJSON] = useState<GeoJSON.FeatureCollection | null>(null);
|
||||
useEffect(() => {
|
||||
if (!activeLayers.submarine_cables) {
|
||||
setSubmarineCablesGeoJSON(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetch('/data/submarine-cables.json')
|
||||
.then((r) => r.json())
|
||||
.then((geo) => {
|
||||
if (!cancelled) setSubmarineCablesGeoJSON(sanitizeSubmarineCables(geo));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSubmarineCablesGeoJSON(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeLayers.submarine_cables]);
|
||||
|
||||
const dynamicMapLayers = useDynamicMapLayersWorker(
|
||||
{
|
||||
@@ -1186,6 +1222,7 @@ const MaplibreViewer = ({
|
||||
],
|
||||
{
|
||||
bounds: mapBounds,
|
||||
serverBboxScoped: getLiveDataBounds() !== null,
|
||||
dtSeconds: dtSeconds.current,
|
||||
trackedIcaos: Array.from(trackedIcaoSet),
|
||||
activeLayers: {
|
||||
@@ -1247,6 +1284,8 @@ const MaplibreViewer = ({
|
||||
uapSightings: staticUapSightings,
|
||||
wastewater: staticWastewater,
|
||||
crowdthreat: staticCrowdthreat,
|
||||
malwareThreats: staticMalwareThreats,
|
||||
telegramOsintPosts: staticTelegramOsintPosts,
|
||||
},
|
||||
[
|
||||
staticCctv,
|
||||
@@ -1270,6 +1309,9 @@ const MaplibreViewer = ({
|
||||
staticUapSightings,
|
||||
staticWastewater,
|
||||
staticCrowdthreat,
|
||||
staticMalwareThreats,
|
||||
staticTelegramOsintPosts,
|
||||
mapZoom,
|
||||
],
|
||||
{
|
||||
bounds: mapBounds,
|
||||
@@ -1293,6 +1335,8 @@ const MaplibreViewer = ({
|
||||
uap_sightings: activeLayers.uap_sightings,
|
||||
wastewater: activeLayers.wastewater,
|
||||
crowdthreat: activeLayers.crowdthreat,
|
||||
malware_c2: activeLayers.malware_c2,
|
||||
telegram_osint: activeLayers.telegram_osint,
|
||||
},
|
||||
},
|
||||
[
|
||||
@@ -1316,6 +1360,8 @@ const MaplibreViewer = ({
|
||||
activeLayers.uap_sightings,
|
||||
activeLayers.wastewater,
|
||||
activeLayers.crowdthreat,
|
||||
activeLayers.malware_c2,
|
||||
activeLayers.telegram_osint,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1351,8 +1397,15 @@ const MaplibreViewer = ({
|
||||
uapSightingsGeoJSON,
|
||||
wastewaterGeoJSON,
|
||||
crowdthreatGeoJSON,
|
||||
malwareGeoJSON,
|
||||
telegramOsintGeoJSON,
|
||||
} = staticMapLayers;
|
||||
|
||||
const telegramOsintGeoJSONPlaced = useMemo(
|
||||
() => applyTelegramAlertAvoidance(telegramOsintGeoJSON, data?.news),
|
||||
[telegramOsintGeoJSON, data?.news],
|
||||
);
|
||||
|
||||
// Extract cluster label positions via shared hook
|
||||
const shipClusters = useClusterLabels(mapRef, 'ships-clusters-layer', shipsGeoJSON);
|
||||
const eqClusters = useClusterLabels(mapRef, 'eq-clusters-layer', earthquakesGeoJSON);
|
||||
@@ -1659,6 +1712,9 @@ const MaplibreViewer = ({
|
||||
wastewaterGeoJSON && 'wastewater-dot',
|
||||
wastewaterGeoJSON && 'wastewater-layer',
|
||||
crowdthreatGeoJSON && 'crowdthreat-layer',
|
||||
malwareGeoJSON && 'malware-clusters',
|
||||
malwareGeoJSON && 'malware-layer',
|
||||
submarineCablesGeoJSON && 'submarine-cables-layer',
|
||||
sarAnomaliesGeoJSON && 'sar-anomalies-layer',
|
||||
sarAoisGeoJSON && 'sar-aois-fill',
|
||||
aiIntelGeoJSON && 'ai-intel-clusters',
|
||||
@@ -1731,6 +1787,9 @@ const MaplibreViewer = ({
|
||||
useImperativeSource(mapForHook, 'uap-sightings-source', uapSightingsGeoJSON, 100);
|
||||
useImperativeSource(mapForHook, 'wastewater-source', wastewaterGeoJSON, 100);
|
||||
useImperativeSource(mapForHook, 'crowdthreat-source', crowdthreatGeoJSON, 100);
|
||||
useImperativeSource(mapForHook, 'malware-source', malwareGeoJSON, 100);
|
||||
useImperativeSource(mapForHook, 'telegram-osint-source', telegramOsintGeoJSONPlaced, 100);
|
||||
useImperativeSource(mapForHook, 'submarine-cables-source', submarineCablesGeoJSON, 600);
|
||||
useImperativeSource(mapForHook, 'ships', shipsGeoJSON, 75);
|
||||
useImperativeSource(mapForHook, 'meshtastic-source', meshtasticGeoJSON, 60);
|
||||
useImperativeSource(mapForHook, 'aprs-source', aprsGeoJSON, 60);
|
||||
@@ -1761,7 +1820,7 @@ const MaplibreViewer = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-full w-full z-0 isolate ${selectedEntity && ['region_dossier', 'gdelt', 'liveuamap', 'news'].includes(selectedEntity.type) ? 'map-focus-active' : ''}`}
|
||||
className={`relative h-full w-full z-0 isolate ${selectedEntity && ['region_dossier', 'gdelt', 'liveuamap', 'news', 'telegram_osint'].includes(selectedEntity.type) ? 'map-focus-active' : ''}`}
|
||||
style={pinPlacementMode || sarAoiDropMode ? { cursor: 'crosshair' } : undefined}
|
||||
>
|
||||
<Map
|
||||
@@ -3688,6 +3747,71 @@ const MaplibreViewer = ({
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* Telegram OSINT — one pin per geocoded city; scroll posts in popup */}
|
||||
<Source id="telegram-osint-source" type="geojson" data={EMPTY_FC}>
|
||||
<Layer
|
||||
id="telegram-osint-layer"
|
||||
type="circle"
|
||||
minzoom={4}
|
||||
paint={{
|
||||
'circle-radius': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
4,
|
||||
['case', ['>', ['get', 'post_count'], 1], 14, 11],
|
||||
8,
|
||||
['case', ['>', ['get', 'post_count'], 1], 20, 16],
|
||||
12,
|
||||
['case', ['>', ['get', 'post_count'], 1], 26, 22],
|
||||
],
|
||||
'circle-color': '#ef4444',
|
||||
'circle-stroke-width': 0,
|
||||
'circle-stroke-color': '#fca5a5',
|
||||
'circle-opacity': 0,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* Malware C2 — abuse.ch Feodo + URLhaus */}
|
||||
<Source id="malware-source" type="geojson" data={EMPTY_FC} cluster={true} clusterMaxZoom={6} clusterRadius={35}>
|
||||
<Layer
|
||||
id="malware-clusters"
|
||||
type="circle"
|
||||
filter={['has', 'point_count']}
|
||||
paint={{
|
||||
'circle-radius': ['step', ['get', 'point_count'], 12, 8, 16, 30, 22],
|
||||
'circle-color': 'rgba(255, 61, 61, 0.75)',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ff3d3d',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="malware-layer"
|
||||
type="circle"
|
||||
filter={['!', ['has', 'point_count']]}
|
||||
paint={{
|
||||
'circle-radius': 5,
|
||||
'circle-color': '#ff1744',
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': '#ff8a80',
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* Submarine cables — static TeleGeography GeoJSON */}
|
||||
<Source id="submarine-cables-source" type="geojson" data={EMPTY_FC}>
|
||||
<Layer
|
||||
id="submarine-cables-layer"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#eab308',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 6, 1.2, 10, 2],
|
||||
'line-opacity': 0.75,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* Ships — rendered below flights (water surface level) */}
|
||||
<Source
|
||||
id="ships"
|
||||
@@ -4290,6 +4414,13 @@ const MaplibreViewer = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeLayers.telegram_osint && !isMapInteracting && telegramOsintGeoJSONPlaced?.features?.length ? (
|
||||
<TelegramOsintMarkers
|
||||
features={telegramOsintGeoJSONPlaced.features}
|
||||
onEntityClick={onEntityClick}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Satellite positions — mission-type icons */}
|
||||
{/* satellites: data pushed imperatively */}
|
||||
<Source id="satellites" type="geojson" data={EMPTY_FC}>
|
||||
@@ -5428,6 +5559,66 @@ const MaplibreViewer = ({
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Earthquake popup */}
|
||||
{selectedEntity?.type === 'earthquake' &&
|
||||
(() => {
|
||||
const extra = (selectedEntity.extra || {}) as Record<string, unknown>;
|
||||
const idx = Number(selectedEntity.id);
|
||||
const eq = Number.isFinite(idx)
|
||||
? data?.earthquakes?.[idx]
|
||||
: data?.earthquakes?.find((e) => e.id === String(selectedEntity.id));
|
||||
const lat = typeof eq?.lat === 'number' ? eq.lat : Number(extra.lat);
|
||||
const lng = typeof eq?.lng === 'number' ? eq.lng : Number(extra.lng);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
const mag = eq?.mag ?? Number(extra.mag);
|
||||
const place = eq?.place || String(extra.place || selectedEntity.name || 'Unknown location');
|
||||
const accent = mag >= 6 ? '#ef4444' : mag >= 4.5 ? '#f97316' : '#eab308';
|
||||
return (
|
||||
<Popup
|
||||
longitude={lng}
|
||||
latitude={lat}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
onClose={() => onEntityClick?.(null)}
|
||||
className="threat-popup"
|
||||
maxWidth="280px"
|
||||
>
|
||||
<div className="map-popup bg-[#1a1035] min-w-[200px]" style={{ borderColor: `${accent}66` }}>
|
||||
<div className="map-popup-title pb-1" style={{ color: accent, borderBottom: `1px solid ${accent}33` }}>
|
||||
M{Number.isFinite(mag) ? mag.toFixed(1) : '?'} — EARTHQUAKE
|
||||
</div>
|
||||
<div className="map-popup-row">
|
||||
Location: <span className="text-white">{place}</span>
|
||||
</div>
|
||||
<div className="map-popup-row">
|
||||
Coords:{' '}
|
||||
<span className="text-white font-mono">
|
||||
{lat.toFixed(3)}, {lng.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
{oracleIntel?.found && (
|
||||
<div className="mt-2 pt-2 border-t border-yellow-500/20">
|
||||
<div className="text-[10px] font-mono text-yellow-500/80 tracking-wider mb-1">REGION INTEL</div>
|
||||
<div className="text-[10px] font-mono text-white/70">
|
||||
ORACLE: {oracleIntel.tier}
|
||||
{oracleIntel.avg_sentiment != null && (
|
||||
<span className="text-gray-400">
|
||||
{' '}
|
||||
· SENT {oracleIntel.avg_sentiment > 0 ? '+' : ''}
|
||||
{oracleIntel.avg_sentiment.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1.5 text-[9px] tracking-wider" style={{ color: `${accent}99` }}>
|
||||
SEISMIC — USGS
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Volcano popup */}
|
||||
{selectedEntity?.type === 'volcano' &&
|
||||
(() => {
|
||||
@@ -5521,6 +5712,28 @@ const MaplibreViewer = ({
|
||||
return <FishingDestinationRoute vesselLat={event.lat} vesselLng={event.lng} destination={dest} />;
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
if (selectedEntity?.type !== 'telegram_osint' || !data?.telegram_osint?.posts) return null;
|
||||
const allPosts = data.telegram_osint.posts;
|
||||
const clusterPosts = allPosts.filter((p) => {
|
||||
if (!p.coords || p.coords.length < 2) return false;
|
||||
const key = telegramClusterKey(p.coords[0], p.coords[1]);
|
||||
return key === selectedEntity.id || p.id === selectedEntity.id;
|
||||
});
|
||||
const anchor = clusterPosts[0]?.coords;
|
||||
if (!anchor || anchor.length < 2) return null;
|
||||
const avoidAlert = telegramClusterNearNewsAlert(anchor[0], anchor[1], data?.news);
|
||||
const [pinLat, pinLng] = telegramMapPinCoords(anchor[0], anchor[1], avoidAlert);
|
||||
return (
|
||||
<TelegramOsintPopup
|
||||
posts={clusterPosts}
|
||||
lat={pinLat}
|
||||
lng={pinLng}
|
||||
onClose={() => onEntityClick?.(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
if (selectedEntity?.type !== 'gdelt' || !data?.gdelt) return null;
|
||||
const item = data.gdelt.find(
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { Radio } from 'lucide-react';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import { TELEGRAM_MARKER_OFFSET } from '@/components/map/geoJSONBuilders';
|
||||
import { buildTelegramMediaProxyUrl } from '@/lib/telegramProxy';
|
||||
import type { TelegramOsintPost } from '@/types/dashboard';
|
||||
|
||||
export interface TelegramOsintPopupProps {
|
||||
posts: TelegramOsintPost[];
|
||||
lat: number;
|
||||
lng: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatTime(pubDate?: string) {
|
||||
if (!pubDate) return '';
|
||||
try {
|
||||
return new Date(pubDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function riskTheme(rs: number) {
|
||||
if (rs >= 9) {
|
||||
return {
|
||||
hex: '#ef4444',
|
||||
threatColor: 'text-red-400',
|
||||
borderColor: 'border-red-700',
|
||||
bgHeaderColor: 'bg-red-950/50',
|
||||
bgClass: 'bg-red-950/20 border-red-500/30',
|
||||
titleClass: 'text-cyan-300 font-bold',
|
||||
badgeClass: 'bg-red-500/10 text-red-400 border-red-500/30',
|
||||
};
|
||||
}
|
||||
if (rs >= 7) {
|
||||
return {
|
||||
hex: '#f97316',
|
||||
threatColor: 'text-orange-400',
|
||||
borderColor: 'border-orange-700',
|
||||
bgHeaderColor: 'bg-orange-950/50',
|
||||
bgClass: 'bg-orange-950/20 border-orange-500/30',
|
||||
titleClass: 'text-cyan-300 font-bold',
|
||||
badgeClass: 'bg-orange-500/10 text-orange-400 border-orange-500/30',
|
||||
};
|
||||
}
|
||||
if (rs >= 4) {
|
||||
return {
|
||||
hex: '#eab308',
|
||||
threatColor: 'text-yellow-400',
|
||||
borderColor: 'border-yellow-800',
|
||||
bgHeaderColor: 'bg-yellow-950/50',
|
||||
bgClass: 'bg-yellow-950/20 border-yellow-500/30',
|
||||
titleClass: 'text-cyan-300 font-bold',
|
||||
badgeClass: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
|
||||
};
|
||||
}
|
||||
return {
|
||||
hex: '#22c55e',
|
||||
threatColor: 'text-green-400',
|
||||
borderColor: 'border-green-800',
|
||||
bgHeaderColor: 'bg-green-950/50',
|
||||
bgClass: 'bg-green-950/20 border-green-500/30',
|
||||
titleClass: 'text-cyan-300 font-medium',
|
||||
badgeClass: 'bg-green-500/10 text-green-400 border-green-500/30',
|
||||
};
|
||||
}
|
||||
|
||||
function postHeadline(post: TelegramOsintPost): string {
|
||||
return String(post.title || post.description || 'Telegram intercept').trim();
|
||||
}
|
||||
|
||||
function postDetail(post: TelegramOsintPost): string | null {
|
||||
const title = String(post.title || '').trim();
|
||||
const description = String(post.description || '').trim();
|
||||
if (!description || description === title || description.startsWith(title)) return null;
|
||||
const extra = description.startsWith(title) ? description.slice(title.length).trim() : description;
|
||||
return extra || null;
|
||||
}
|
||||
|
||||
function TelegramPostMedia({ post }: { post: TelegramOsintPost }) {
|
||||
const { t } = useTranslation();
|
||||
const proxyUrl = post.media_url ? buildTelegramMediaProxyUrl(post.media_url) : null;
|
||||
|
||||
let media: React.ReactNode = null;
|
||||
if (post.media_type === 'video' && proxyUrl) {
|
||||
media = (
|
||||
<video
|
||||
src={proxyUrl}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="w-full max-h-52 bg-black"
|
||||
/>
|
||||
);
|
||||
} else if (post.media_type === 'photo' && proxyUrl) {
|
||||
media = (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={proxyUrl} alt="" className="w-full max-h-52 object-contain bg-black" />
|
||||
);
|
||||
} else if (post.embed_url) {
|
||||
media = (
|
||||
<iframe
|
||||
src={post.embed_url}
|
||||
title={t('telegram.embedTitle')}
|
||||
className="w-full"
|
||||
height={240}
|
||||
style={{ border: 'none' }}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-sm border border-cyan-900/40 overflow-hidden bg-black/70">
|
||||
{media}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
|
||||
const { t } = useTranslation();
|
||||
const rs = post.risk_score ?? 1;
|
||||
const theme = riskTheme(rs);
|
||||
const headline = postHeadline(post);
|
||||
const detail = postDetail(post);
|
||||
const isHigh = rs >= 8;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`p-2 rounded-sm border-l-[2px] border-r border-t border-b ${theme.bgClass} flex flex-col gap-1`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[12px] text-[var(--text-secondary)] uppercase tracking-widest">
|
||||
<span className="font-bold flex items-center gap-1 text-white">
|
||||
{isHigh && <span className="text-red-400 mr-1">BREAKING</span>}
|
||||
>_ {post.source || 'TELEGRAM'}
|
||||
</span>
|
||||
<span>[{formatTime(post.published)}]</span>
|
||||
</div>
|
||||
|
||||
<h3 className={`text-[12px] leading-tight ${theme.titleClass}`}>{headline}</h3>
|
||||
|
||||
{detail ? (
|
||||
<p className="text-[11px] text-[var(--text-muted)] leading-relaxed whitespace-pre-wrap">{detail}</p>
|
||||
) : null}
|
||||
|
||||
<TelegramPostMedia post={post} />
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
<span className={`text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${theme.badgeClass}`}>
|
||||
{isHigh ? 'BREAKING' : `LVL: ${rs}/10`}
|
||||
</span>
|
||||
{post.link ? (
|
||||
<a
|
||||
href={post.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] font-mono text-cyan-500 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
{t('telegram.openOriginal')}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function TelegramOsintPopup({ posts, lat, lng, onClose }: TelegramOsintPopupProps) {
|
||||
const { t } = useTranslation();
|
||||
const sortedPosts = useMemo(
|
||||
() =>
|
||||
[...posts].sort(
|
||||
(a, b) =>
|
||||
(b.risk_score ?? 0) - (a.risk_score ?? 0) ||
|
||||
String(b.published || '').localeCompare(String(a.published || '')),
|
||||
),
|
||||
[posts],
|
||||
);
|
||||
|
||||
const maxRisk = sortedPosts[0]?.risk_score ?? 1;
|
||||
const header = riskTheme(maxRisk);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
longitude={lng}
|
||||
latitude={lat}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
onClose={onClose}
|
||||
anchor="bottom"
|
||||
offset={TELEGRAM_MARKER_OFFSET}
|
||||
className="threat-popup"
|
||||
maxWidth="560px"
|
||||
>
|
||||
<div
|
||||
className={`bg-[#080c12] border ${header.borderColor} rounded-lg flex flex-col font-mono overflow-hidden w-[min(520px,92vw)]`}
|
||||
style={{
|
||||
boxShadow: `0 0 60px ${header.hex}33, 0 0 160px ${header.hex}11, inset 0 1px 0 rgba(255,255,255,0.05)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`px-4 py-3 border-b ${header.borderColor}/60 ${header.bgHeaderColor} flex justify-between items-center shrink-0`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio size={16} className={header.threatColor} />
|
||||
<span className={`text-[13px] tracking-[0.25em] font-bold ${header.threatColor}`}>
|
||||
TELEGRAM INTERCEPT
|
||||
</span>
|
||||
{maxRisk >= 8 && (
|
||||
<span className="text-[9px] bg-red-500 text-white px-2 py-0.5 rounded-sm font-bold animate-pulse">
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-[12px] ${header.threatColor} font-bold`}>
|
||||
ALERT LVL: {maxRisk}/10
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-white text-lg leading-none px-1 hover:bg-white/10 rounded transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 border-b border-cyan-900/40 bg-black/40 shrink-0">
|
||||
<div className="text-[11px] text-[var(--text-muted)] uppercase tracking-widest mb-1">
|
||||
{t('telegram.postsAtLocation').replace('{count}', String(sortedPosts.length))}
|
||||
</div>
|
||||
<div className="p-2 bg-black/60 border border-amber-700/40 rounded-sm text-[11px] text-amber-100/90 leading-relaxed relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-[2px] h-full bg-amber-500/80" />
|
||||
<span className="font-bold text-amber-300">>_ SYS.NOTICE: </span>
|
||||
{t('telegram.disclaimer')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto styled-scrollbar flex flex-col gap-2 p-3 max-h-[min(420px,55vh)]">
|
||||
{sortedPosts.map((post) => (
|
||||
<TelegramPostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -321,7 +321,7 @@ function EmissionsEstimateBlock({ flight }: { flight: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number, title?: string) => void }) {
|
||||
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick, onExpandEntityGraph }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number, title?: string) => void, onExpandEntityGraph?: () => void }) {
|
||||
const data = useDataKeys([
|
||||
'news', 'fimi', 'commercial_flights', 'private_flights', 'private_jets',
|
||||
'military_flights', 'tracked_flights', 'ships', 'gdelt', 'liveuamap',
|
||||
@@ -1097,6 +1097,15 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{onExpandEntityGraph && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpandEntityGraph}
|
||||
className="w-full py-1.5 text-[10px] font-mono tracking-wider border border-cyan-700/40 text-cyan-400 hover:bg-cyan-950/30 transition-colors"
|
||||
>
|
||||
INTEL GRAPH →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
@@ -1206,6 +1215,15 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onExpandEntityGraph && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpandEntityGraph}
|
||||
className="w-full py-1.5 text-[10px] font-mono tracking-wider border border-cyan-700/40 text-cyan-400 hover:bg-cyan-950/30 transition-colors"
|
||||
>
|
||||
INTEL GRAPH →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
@@ -38,6 +38,20 @@ const API_GUIDES = [
|
||||
url: 'https://aisstream.io/authenticate',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
name: 'Global Fishing Watch',
|
||||
icon: <Ship size={14} className="text-teal-400" />,
|
||||
required: false,
|
||||
description:
|
||||
'Fishing-vessel activity events for the Fishing Activity map layer. Optional but recommended for maritime OSINT.',
|
||||
steps: [
|
||||
'Create a free account at globalfishingwatch.org',
|
||||
'Open Our APIs and create an API token',
|
||||
'Paste the token into Quick Local Setup above or Settings → API Keys → Maritime',
|
||||
],
|
||||
url: 'https://globalfishingwatch.org/our-apis/',
|
||||
color: 'teal',
|
||||
},
|
||||
];
|
||||
|
||||
const FREE_SOURCES = [
|
||||
@@ -65,6 +79,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
OPENSKY_CLIENT_ID: '',
|
||||
OPENSKY_CLIENT_SECRET: '',
|
||||
AIS_API_KEY: '',
|
||||
GFW_API_TOKEN: '',
|
||||
});
|
||||
const [setupSaving, setSetupSaving] = useState(false);
|
||||
const [setupMsg, setSetupMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
@@ -110,7 +125,12 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
if (!res.ok || data?.ok === false) {
|
||||
throw new Error(data?.detail || 'Could not save API keys.');
|
||||
}
|
||||
setSetupKeys({ OPENSKY_CLIENT_ID: '', OPENSKY_CLIENT_SECRET: '', AIS_API_KEY: '' });
|
||||
setSetupKeys({
|
||||
OPENSKY_CLIENT_ID: '',
|
||||
OPENSKY_CLIENT_SECRET: '',
|
||||
AIS_API_KEY: '',
|
||||
GFW_API_TOKEN: '',
|
||||
});
|
||||
setSetupMsg({ type: 'ok', text: 'Keys saved locally. Restart or refresh feeds to use them.' });
|
||||
} catch (error) {
|
||||
setSetupMsg({
|
||||
@@ -557,8 +577,9 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
OpenSky Network and AIS Stream are the free keys that make ShadowBroker
|
||||
useful immediately: live aircraft and vessel tracking. Paste them below or
|
||||
use Settings later; secrets stay on the local backend.
|
||||
useful immediately: live aircraft and vessel tracking. Global Fishing Watch
|
||||
unlocks the fishing-activity layer. Paste them below or use Settings later;
|
||||
secrets stay on the local backend.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,6 +599,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
['OPENSKY_CLIENT_ID', 'OpenSky Client ID'],
|
||||
['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'],
|
||||
['AIS_API_KEY', 'AIS Stream API Key'],
|
||||
['GFW_API_TOKEN', 'Global Fishing Watch API Token (optional)'],
|
||||
].map(([key, label]) => (
|
||||
<input
|
||||
key={key}
|
||||
@@ -618,9 +640,15 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
<div className="flex items-center gap-2">
|
||||
{api.icon}
|
||||
<span className="text-xs font-mono text-white font-bold">{api.name}</span>
|
||||
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">
|
||||
REQUIRED
|
||||
</span>
|
||||
{api.required ? (
|
||||
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">
|
||||
REQUIRED
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-teal-500/30 text-teal-300 bg-teal-950/20">
|
||||
OPTIONAL
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={api.url}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Loader2, Minus, Plus, Radar, RefreshCw, Search, Shield } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import ReconResults from '@/components/ReconResults';
|
||||
|
||||
type TabId =
|
||||
| 'ip'
|
||||
| 'dns'
|
||||
| 'whois'
|
||||
| 'certs'
|
||||
| 'threats'
|
||||
| 'bgp'
|
||||
| 'sanctions'
|
||||
| 'cve'
|
||||
| 'mac'
|
||||
| 'github'
|
||||
| 'leaks'
|
||||
| 'sweep';
|
||||
|
||||
const TABS: Array<{
|
||||
id: TabId;
|
||||
label: string;
|
||||
param: string;
|
||||
path: string;
|
||||
optional?: boolean;
|
||||
}> = [
|
||||
{ id: 'ip', label: 'IP LOOKUP', param: 'ip', path: 'ip' },
|
||||
{ id: 'dns', label: 'DNS', param: 'domain', path: 'dns' },
|
||||
{ id: 'whois', label: 'WHOIS / RDAP', param: 'domain', path: 'whois' },
|
||||
{ id: 'certs', label: 'CERTS', param: 'domain', path: 'certs' },
|
||||
{ id: 'threats', label: 'THREATS', param: 'query', path: 'threats', optional: true },
|
||||
{ id: 'bgp', label: 'BGP / ASN', param: 'query', path: 'bgp' },
|
||||
{ id: 'sanctions', label: 'OFAC SDN', param: 'query', path: 'sanctions' },
|
||||
{ id: 'cve', label: 'CVE', param: 'cve', path: 'cve' },
|
||||
{ id: 'mac', label: 'MAC', param: 'mac', path: 'mac' },
|
||||
{ id: 'github', label: 'GITHUB', param: 'username', path: 'github' },
|
||||
{ id: 'leaks', label: 'LEAKS', param: 'email', path: 'leaks' },
|
||||
{ id: 'sweep', label: 'IP SWEEP', param: 'ip', path: 'sweep' },
|
||||
];
|
||||
|
||||
export default function ReconPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('ip');
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [results, setResults] = useState<unknown>(null);
|
||||
|
||||
const active = TABS.find((tab) => tab.id === activeTab);
|
||||
|
||||
const runLookup = useCallback(async () => {
|
||||
if (!active || loading) return;
|
||||
if (!active.optional && !query.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
if (activeTab === 'sweep') {
|
||||
const res = await fetch(`${API_BASE}/api/osint/sweep/scan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip: query.trim(), cidr: 24 }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
|
||||
setResults(data);
|
||||
} else {
|
||||
const params = new URLSearchParams();
|
||||
if (query.trim()) params.set(active.param, query.trim());
|
||||
const res = await fetch(`${API_BASE}/api/osint/${active.path}?${params}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
|
||||
setResults(data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Lookup failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [active, activeTab, query, loading]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex-shrink-0 border border-cyan-700/40 bg-black/75 backdrop-blur-sm shadow-[0_0_18px_rgba(34,211,238,0.10)]">
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-cyan-700/30 bg-cyan-950/20 px-3 py-2.5 cursor-pointer hover:bg-cyan-950/40 transition-colors"
|
||||
onClick={() => setIsMinimized((prev) => !prev)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar size={16} className="text-cyan-400" />
|
||||
<span className="text-[12px] font-mono font-bold tracking-widest text-cyan-400">
|
||||
{t('recon.title').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isMinimized ? (
|
||||
<Plus size={16} className="text-cyan-400" />
|
||||
) : (
|
||||
<Minus size={16} className="text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMinimized && (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||
<select
|
||||
value={activeTab}
|
||||
onChange={(e) => {
|
||||
setActiveTab(e.target.value as TabId);
|
||||
setResults(null);
|
||||
setError('');
|
||||
}}
|
||||
className="flex-1 border border-cyan-900/50 bg-black/70 px-2 py-1 text-[11px] font-mono text-cyan-300 tracking-[0.12em] outline-none transition-colors focus:border-cyan-500/60"
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
<option key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
setError('');
|
||||
}}
|
||||
title="Clear"
|
||||
className="text-cyan-600 transition-colors hover:text-cyan-400 p-0.5"
|
||||
>
|
||||
<RefreshCw size={11} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && runLookup()}
|
||||
placeholder={active?.param || 'query'}
|
||||
className="flex-1 border border-cyan-900/50 bg-black/70 px-2 py-1 text-[11px] font-mono text-cyan-300 outline-none transition-colors focus:border-cyan-500/60 placeholder:text-cyan-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runLookup}
|
||||
disabled={loading}
|
||||
className="border border-cyan-600/40 px-2 py-1 text-[10px] font-mono tracking-wider text-cyan-400 transition-colors hover:border-cyan-500/70 disabled:opacity-40 flex items-center gap-1"
|
||||
>
|
||||
{loading ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
||||
RUN
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-mono text-cyan-600 tracking-wider">
|
||||
<Shield size={10} />
|
||||
<span>{t('recon.proxyNote')}</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-500/30 bg-red-950/20 px-2 py-1.5 text-[11px] font-mono text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results != null && <ReconResults tabId={activeTab} results={results} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function fmt(value: unknown): string {
|
||||
if (value == null || value === '') return '—';
|
||||
if (typeof value === 'boolean') return value ? 'YES' : 'NO';
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function DossierShell({ title, children }: { title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="max-h-52 overflow-y-auto styled-scrollbar border border-cyan-900/40 bg-black/60">
|
||||
{title && (
|
||||
<div className="border-b border-cyan-900/40 bg-cyan-950/25 px-2 py-1.5 text-[10px] font-mono font-bold tracking-[0.2em] text-cyan-400">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-2 py-1.5 space-y-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DossierRow({
|
||||
label,
|
||||
value,
|
||||
href,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
href?: string;
|
||||
highlight?: 'red' | 'amber' | 'green' | 'cyan';
|
||||
}) {
|
||||
const tone =
|
||||
highlight === 'red'
|
||||
? 'text-red-300'
|
||||
: highlight === 'amber'
|
||||
? 'text-amber-300'
|
||||
: highlight === 'green'
|
||||
? 'text-green-300'
|
||||
: 'text-cyan-200';
|
||||
|
||||
const content =
|
||||
href && typeof value === 'string' ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${tone} hover:underline inline-flex items-center gap-1 justify-end`}
|
||||
>
|
||||
{value}
|
||||
<ExternalLink size={9} className="shrink-0 opacity-70" />
|
||||
</a>
|
||||
) : (
|
||||
<span className={`${tone} text-right break-all leading-snug`}>{value}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-start gap-3 border-b border-cyan-900/25 py-1 last:border-0">
|
||||
<span className="text-[10px] font-mono text-cyan-600 tracking-wider shrink-0 pt-0.5">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] font-mono min-w-0">{content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="text-[9px] font-mono tracking-[0.22em] text-cyan-500/80 pt-2 pb-0.5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const profile = asRecord(data.profile) || {};
|
||||
const repos = asArray(data.repos) as Array<Record<string, unknown>>;
|
||||
const displayName = fmt(profile.name) !== '—' ? String(profile.name) : fmt(data.username);
|
||||
|
||||
return (
|
||||
<DossierShell title="GITHUB DOSSIER">
|
||||
<DossierRow label="HANDLE" value={`@${fmt(data.username)}`} highlight="cyan" />
|
||||
<DossierRow label="NAME" value={displayName} />
|
||||
{profile.bio ? <DossierRow label="BIO" value={fmt(profile.bio)} /> : null}
|
||||
{profile.location ? <DossierRow label="LOCATION" value={fmt(profile.location)} /> : null}
|
||||
{profile.company ? <DossierRow label="COMPANY" value={fmt(profile.company)} /> : null}
|
||||
<DossierRow label="FOLLOWERS" value={fmt(profile.followers)} />
|
||||
<DossierRow label="PUBLIC REPOS" value={fmt(profile.public_repos)} />
|
||||
{profile.created_at ? (
|
||||
<DossierRow
|
||||
label="MEMBER SINCE"
|
||||
value={new Date(String(profile.created_at)).toLocaleDateString()}
|
||||
/>
|
||||
) : null}
|
||||
{profile.html_url ? (
|
||||
<DossierRow label="PROFILE" value="Open on GitHub" href={String(profile.html_url)} />
|
||||
) : null}
|
||||
{repos.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>RECENT REPOSITORIES</SectionLabel>
|
||||
{repos.slice(0, 6).map((repo) => (
|
||||
<DossierRow
|
||||
key={String(repo.name)}
|
||||
label={String(repo.language || 'REPO')}
|
||||
value={`${repo.name}${repo.stars != null ? ` · ★${repo.stars}` : ''}`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function IpDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const geo = asRecord(data.geo) || {};
|
||||
const rep = asRecord(data.reputation) || {};
|
||||
const sanctions = asRecord(data.sanctions_match);
|
||||
const risk = String(rep.risk_level || 'UNKNOWN');
|
||||
|
||||
return (
|
||||
<DossierShell title="IP DOSSIER">
|
||||
<DossierRow label="TARGET" value={fmt(data.ip)} highlight="cyan" />
|
||||
<DossierRow
|
||||
label="LOCATION"
|
||||
value={[geo.city, geo.region, geo.country].filter(Boolean).join(', ') || '—'}
|
||||
/>
|
||||
<DossierRow label="ISP" value={fmt(geo.isp)} />
|
||||
<DossierRow label="ORG" value={fmt(geo.org)} />
|
||||
<DossierRow label="ASN" value={fmt(geo.as_number)} />
|
||||
<DossierRow
|
||||
label="RISK"
|
||||
value={risk}
|
||||
highlight={risk === 'HIGH' ? 'red' : risk === 'MEDIUM' ? 'amber' : 'green'}
|
||||
/>
|
||||
<DossierRow label="PROXY" value={fmt(rep.is_proxy)} />
|
||||
<DossierRow label="HOSTING" value={fmt(rep.is_hosting)} />
|
||||
{sanctions ? (
|
||||
<DossierRow
|
||||
label="SANCTIONS"
|
||||
value={`${asArray(sanctions.hits).length} OFAC hit(s)`}
|
||||
highlight="red"
|
||||
/>
|
||||
) : null}
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DnsDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const summary = asRecord(data.summary) || {};
|
||||
return (
|
||||
<DossierShell title="DNS DOSSIER">
|
||||
<DossierRow label="DOMAIN" value={fmt(data.domain)} highlight="cyan" />
|
||||
<DossierRow label="A RECORDS" value={asArray(summary.ip_addresses).join(', ') || '—'} />
|
||||
<DossierRow label="MAIL (MX)" value={asArray(summary.mail_servers).join(', ') || '—'} />
|
||||
<DossierRow label="NAMESERVERS" value={asArray(summary.nameservers).join(', ') || '—'} />
|
||||
<DossierRow label="TOTAL RECORDS" value={fmt(summary.total_records)} />
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function WhoisDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const rdap = asRecord(data.rdap) || {};
|
||||
const http = asRecord(data.http) || {};
|
||||
const score = asRecord(data.security_score) || {};
|
||||
const entity = asRecord(asArray(rdap.entities)[0]);
|
||||
|
||||
return (
|
||||
<DossierShell title="WHOIS / RDAP DOSSIER">
|
||||
<DossierRow label="DOMAIN" value={fmt(data.domain)} highlight="cyan" />
|
||||
<DossierRow label="REGISTRAR" value={fmt(entity?.org || entity?.name)} />
|
||||
<DossierRow label="REGISTERED" value={fmt(data.registration)} />
|
||||
<DossierRow label="EXPIRES" value={fmt(data.expiration)} />
|
||||
<DossierRow label="LAST CHANGED" value={fmt(data.last_changed)} />
|
||||
<DossierRow label="HTTP STATUS" value={fmt(http.status)} />
|
||||
<DossierRow
|
||||
label="SECURITY"
|
||||
value={score.grade ? `${score.grade} (${score.score}/${score.max})` : '—'}
|
||||
highlight={score.grade === 'A' ? 'green' : score.grade === 'F' ? 'red' : 'amber'}
|
||||
/>
|
||||
<DossierRow label="NAMESERVERS" value={asArray(rdap.nameservers).slice(0, 4).join(', ') || '—'} />
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CertsDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const subs = asArray(data.subdomains) as string[];
|
||||
const certs = asArray(data.certificates) as Array<Record<string, unknown>>;
|
||||
return (
|
||||
<DossierShell title="CERTIFICATE DOSSIER">
|
||||
<DossierRow label="DOMAIN" value={fmt(data.domain)} highlight="cyan" />
|
||||
<DossierRow label="CERTS FOUND" value={fmt(data.total_found)} />
|
||||
<DossierRow label="SUBDOMAINS" value={subs.length ? `${subs.length} discovered` : '—'} />
|
||||
{subs.slice(0, 5).map((sub) => (
|
||||
<DossierRow key={sub} label="HOST" value={sub} />
|
||||
))}
|
||||
{certs[0] ? (
|
||||
<DossierRow label="LATEST CN" value={fmt(certs[0].common_name)} />
|
||||
) : null}
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SanctionsDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const matches = asArray(data.matches) as Array<Record<string, unknown>>;
|
||||
return (
|
||||
<DossierShell title="SANCTIONS DOSSIER">
|
||||
<DossierRow label="QUERY" value={fmt(data.query)} highlight="cyan" />
|
||||
<DossierRow label="MATCHES" value={fmt(data.total)} highlight={matches.length ? 'red' : 'green'} />
|
||||
<DossierRow label="SOURCE" value={fmt(data.source)} />
|
||||
{matches.slice(0, 8).map((hit, i) => (
|
||||
<DossierRow
|
||||
key={`${hit.id || i}`}
|
||||
label={String(hit.schema || 'ENTITY').toUpperCase()}
|
||||
value={fmt(hit.caption || hit.name || hit.id)}
|
||||
highlight="red"
|
||||
/>
|
||||
))}
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CveDossier({ data }: { data: Record<string, unknown> }) {
|
||||
return (
|
||||
<DossierShell title="CVE DOSSIER">
|
||||
<DossierRow label="CVE" value={fmt(data.id)} highlight="cyan" />
|
||||
{'cvss' in data ? <DossierRow label="CVSS" value={fmt(data.cvss)} /> : null}
|
||||
<div className="pt-1 text-[11px] font-mono text-cyan-200/90 leading-relaxed">
|
||||
{fmt(data.description)}
|
||||
</div>
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaksDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const sources = asArray(data.sources);
|
||||
const found = Boolean(data.found);
|
||||
return (
|
||||
<DossierShell title="BREACH DOSSIER">
|
||||
<DossierRow label="EMAIL" value={fmt(data.email)} highlight="cyan" />
|
||||
<DossierRow
|
||||
label="EXPOSED"
|
||||
value={found ? 'YES' : 'NO'}
|
||||
highlight={found ? 'red' : 'green'}
|
||||
/>
|
||||
{sources.length > 0 ? (
|
||||
<DossierRow label="SOURCES" value={sources.map((s) => fmt(s)).join(', ')} highlight="red" />
|
||||
) : null}
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function MacDossier({ data }: { data: Record<string, unknown> }) {
|
||||
return (
|
||||
<DossierShell title="MAC DOSSIER">
|
||||
<DossierRow label="MAC" value={fmt(data.mac)} highlight="cyan" />
|
||||
<DossierRow label="VENDOR" value={fmt(data.vendor)} />
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreatsDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const otx = asRecord(data.otx) || {};
|
||||
const pulses = asArray(data.pulses) as Array<Record<string, unknown>>;
|
||||
const level = String(data.threat_level || 'LOW');
|
||||
return (
|
||||
<DossierShell title="THREAT INTEL DOSSIER">
|
||||
<DossierRow
|
||||
label="THREAT LEVEL"
|
||||
value={level}
|
||||
highlight={level === 'HIGH' ? 'red' : level === 'MEDIUM' ? 'amber' : 'green'}
|
||||
/>
|
||||
{'pulse_count' in otx ? <DossierRow label="OTX PULSES" value={fmt(otx.pulse_count)} /> : null}
|
||||
{'tor_exit_node' in data ? (
|
||||
<DossierRow label="TOR EXIT" value={fmt(data.tor_exit_node)} highlight="amber" />
|
||||
) : null}
|
||||
{pulses.slice(0, 4).map((pulse, i) => (
|
||||
<div key={i} className="border-b border-cyan-900/25 py-1 last:border-0">
|
||||
<div className="text-[10px] font-mono text-cyan-300 leading-snug">{fmt(pulse.name)}</div>
|
||||
{pulse.adversary ? (
|
||||
<div className="text-[9px] font-mono text-cyan-600 mt-0.5">{fmt(pulse.adversary)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function BgpDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const asn = asRecord(data.asn) || {};
|
||||
const ip = asRecord(data.ip) || {};
|
||||
const prefixes = asRecord(data.prefixes) || {};
|
||||
return (
|
||||
<DossierShell title="BGP DOSSIER">
|
||||
<DossierRow label="QUERY" value={fmt(data.query)} highlight="cyan" />
|
||||
{data.type === 'asn' ? (
|
||||
<>
|
||||
<DossierRow label="ASN" value={fmt(asn.asn)} />
|
||||
<DossierRow label="NAME" value={fmt(asn.name)} />
|
||||
<DossierRow label="COUNTRY" value={fmt(asn.country_code)} />
|
||||
<DossierRow label="PREFIXES V4" value={fmt(prefixes.total_v4)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DossierRow label="PREFIX" value={fmt(ip.prefix)} />
|
||||
<DossierRow label="ASN" value={fmt(ip.asn)} />
|
||||
<DossierRow label="NAME" value={fmt(ip.name)} />
|
||||
</>
|
||||
)}
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SweepDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const summary = asRecord(data.summary) || {};
|
||||
const devices = asArray(data.devices) as Array<Record<string, unknown>>;
|
||||
return (
|
||||
<DossierShell title="SWEEP DOSSIER">
|
||||
<DossierRow label="SCANNED" value={fmt(summary.total_hosts)} />
|
||||
<DossierRow
|
||||
label="RESPONSIVE"
|
||||
value={fmt(summary.total_responsive)}
|
||||
highlight={Number(summary.total_responsive) > 0 ? 'amber' : 'green'}
|
||||
/>
|
||||
<DossierRow label="DURATION" value={data.sweep_time_ms != null ? `${data.sweep_time_ms} ms` : '—'} />
|
||||
{devices.slice(0, 8).map((device) => (
|
||||
<DossierRow
|
||||
key={String(device.ip)}
|
||||
label={fmt(device.ip)}
|
||||
value={[
|
||||
asArray(device.ports).length ? `ports ${asArray(device.ports).join(',')}` : '',
|
||||
asArray(device.vulns).length ? `${asArray(device.vulns).length} vuln(s)` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || 'responsive'}
|
||||
highlight={asArray(device.vulns).length ? 'red' : undefined}
|
||||
/>
|
||||
))}
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericDossier({ data }: { data: Record<string, unknown> }) {
|
||||
const rows = Object.entries(data).filter(([key]) => key !== 'timestamp');
|
||||
return (
|
||||
<DossierShell title="RECON DOSSIER">
|
||||
{rows.slice(0, 12).map(([key, value]) => (
|
||||
<DossierRow
|
||||
key={key}
|
||||
label={key.replace(/_/g, ' ').toUpperCase()}
|
||||
value={
|
||||
typeof value === 'object' && value !== null
|
||||
? Array.isArray(value)
|
||||
? `${value.length} item(s)`
|
||||
: 'See details'
|
||||
: fmt(value)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</DossierShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReconResults({
|
||||
tabId,
|
||||
results,
|
||||
}: {
|
||||
tabId: string;
|
||||
results: unknown;
|
||||
}) {
|
||||
const data = asRecord(results);
|
||||
if (!data) return null;
|
||||
|
||||
switch (tabId) {
|
||||
case 'github':
|
||||
return <GitHubDossier data={data} />;
|
||||
case 'ip':
|
||||
return <IpDossier data={data} />;
|
||||
case 'dns':
|
||||
return <DnsDossier data={data} />;
|
||||
case 'whois':
|
||||
return <WhoisDossier data={data} />;
|
||||
case 'certs':
|
||||
return <CertsDossier data={data} />;
|
||||
case 'sanctions':
|
||||
return <SanctionsDossier data={data} />;
|
||||
case 'cve':
|
||||
return <CveDossier data={data} />;
|
||||
case 'leaks':
|
||||
return <LeaksDossier data={data} />;
|
||||
case 'mac':
|
||||
return <MacDossier data={data} />;
|
||||
case 'threats':
|
||||
return <ThreatsDossier data={data} />;
|
||||
case 'bgp':
|
||||
return <BgpDossier data={data} />;
|
||||
case 'sweep':
|
||||
return <SweepDossier data={data} />;
|
||||
default:
|
||||
return <GenericDossier data={data} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { AlertTriangle, Minus, Plus, RefreshCw, Target } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { useTranslation } from '@/i18n';
|
||||
|
||||
interface Supplier {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string;
|
||||
country: string;
|
||||
category: string;
|
||||
risk_level: string;
|
||||
active_threats: string[];
|
||||
}
|
||||
|
||||
interface ScmPayload {
|
||||
suppliers: Supplier[];
|
||||
critical_count: number;
|
||||
total: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Only evaluate threats when the map layer is enabled. */
|
||||
layerEnabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ScmPanel({ layerEnabled = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [data, setData] = useState<ScmPayload | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!layerEnabled) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/scm-suppliers`);
|
||||
if (res.ok) setData(await res.json());
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [layerEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
if (!layerEnabled) return undefined;
|
||||
const id = setInterval(refresh, 5 * 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [refresh, layerEnabled]);
|
||||
|
||||
const critical = (data?.suppliers || []).filter(
|
||||
(s) => s.risk_level === 'CRITICAL' || s.risk_level === 'HIGH',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex-shrink-0 border border-cyan-700/40 bg-black/75 backdrop-blur-sm shadow-[0_0_18px_rgba(34,211,238,0.10)]">
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-cyan-700/30 bg-cyan-950/20 px-3 py-2.5 cursor-pointer hover:bg-cyan-950/40 transition-colors"
|
||||
onClick={() => setIsMinimized((prev) => !prev)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={16} className="text-cyan-400" />
|
||||
<span className="text-[12px] font-mono font-bold tracking-widest text-cyan-400">
|
||||
{t('scm.title').toUpperCase()}
|
||||
</span>
|
||||
{layerEnabled && critical.length > 0 && (
|
||||
<span className="text-[11px] font-mono px-1.5 py-0.5 bg-red-900/30 border border-red-700/40 text-red-300 tracking-wider">
|
||||
{critical.length} ALERT{critical.length === 1 ? '' : 'S'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
refresh();
|
||||
}}
|
||||
title="Refresh SCM overlay"
|
||||
className="text-cyan-600 transition-colors hover:text-cyan-400 p-0.5"
|
||||
>
|
||||
<RefreshCw size={11} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
{isMinimized ? (
|
||||
<Plus size={16} className="text-cyan-400" />
|
||||
) : (
|
||||
<Minus size={16} className="text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMinimized && (
|
||||
<div className="px-3 py-2 max-h-44 overflow-y-auto styled-scrollbar space-y-1.5">
|
||||
{!layerEnabled ? (
|
||||
<div className="text-[11px] font-mono tracking-wider text-cyan-600/70 py-1">
|
||||
{t('scm.layerOff')}
|
||||
</div>
|
||||
) : critical.length === 0 ? (
|
||||
<div className="text-[11px] font-mono tracking-wider text-cyan-500/80 py-1">
|
||||
{t('scm.allClear')}
|
||||
</div>
|
||||
) : (
|
||||
critical.map((s) => (
|
||||
<div key={s.id} className="border border-red-700/30 bg-red-950/15 px-2 py-1.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-[11px] font-mono font-bold tracking-wide text-red-300 leading-tight">
|
||||
{s.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono tracking-widest text-red-400 shrink-0">
|
||||
{s.risk_level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-cyan-600/80 mt-0.5">
|
||||
{s.city}, {s.country}
|
||||
</div>
|
||||
{s.active_threats.map((threat) => (
|
||||
<div key={threat} className="flex items-center gap-1.5 text-[10px] font-mono text-amber-400/90 mt-1 tracking-wide">
|
||||
<AlertTriangle size={10} className="shrink-0" />
|
||||
{threat}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,6 +110,11 @@ const FRESHNESS_MAP: Record<string, string> = {
|
||||
ai_intel: '',
|
||||
crowdthreat: 'crowdthreat',
|
||||
road_corridor_trends: 'road_corridor_trends',
|
||||
malware_c2: 'malware_threats',
|
||||
submarine_cables: '',
|
||||
scm_suppliers: 'scm_suppliers',
|
||||
cyber_threats: 'cyber_threats',
|
||||
telegram_osint: 'telegram_osint',
|
||||
};
|
||||
|
||||
// POTUS fleet ICAO hex codes for client-side filtering
|
||||
@@ -1187,6 +1192,34 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
count: data?.trains?.length || 0,
|
||||
icon: TrainFront,
|
||||
},
|
||||
{
|
||||
id: 'submarine_cables',
|
||||
name: t('layers.submarineCables'),
|
||||
source: 'TeleGeography (static)',
|
||||
count: null,
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
id: 'malware_c2',
|
||||
name: t('layers.malwareC2'),
|
||||
source: 'abuse.ch',
|
||||
count: data?.malware_threats?.total || 0,
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
id: 'scm_suppliers',
|
||||
name: t('layers.scmSuppliers'),
|
||||
source: 'Tier 1/2 overlay',
|
||||
count: data?.scm_suppliers?.critical_count || 0,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
id: 'cyber_threats',
|
||||
name: t('layers.cyberThreats'),
|
||||
source: 'CISA KEV',
|
||||
count: data?.cyber_threats?.threats?.length || 0,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1275,6 +1308,13 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
count: data?.gdelt?.length || 0,
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
id: 'telegram_osint',
|
||||
name: t('layers.telegramOsint'),
|
||||
source: 't.me public channels',
|
||||
count: data?.telegram_osint?.geolocated || 0,
|
||||
icon: Radio,
|
||||
},
|
||||
{
|
||||
id: 'crowdthreat',
|
||||
name: t('layers.crowdThreat'),
|
||||
@@ -1317,7 +1357,10 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
sections.forEach((s) => {
|
||||
initial[s.label] = false;
|
||||
// Keep high-traffic intel overlays visible on first paint (GDELT, Telegram, etc.)
|
||||
initial[s.label] = s.layers.some((l) =>
|
||||
['global_incidents', 'telegram_osint', 'ukraine_frontline'].includes(l.id),
|
||||
);
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Marker } from 'react-map-gl/maplibre';
|
||||
import type { Earthquake, SelectedEntity, Ship, TrackedFlight, UAV } from '@/types/dashboard';
|
||||
import type { SpreadAlertItem } from '@/utils/alertSpread';
|
||||
import { TELEGRAM_MARKER_OFFSET } from '@/components/map/geoJSONBuilders';
|
||||
|
||||
// Shared monospace label style base
|
||||
const LABEL_BASE: React.CSSProperties = {
|
||||
@@ -473,3 +474,60 @@ export function ThreatMarkers({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Telegram OSINT pins (HTML, above threat alert boxes) --
|
||||
interface TelegramOsintMarkersProps {
|
||||
features: GeoJSON.Feature[];
|
||||
onEntityClick?: (entity: SelectedEntity | null) => void;
|
||||
}
|
||||
|
||||
export function TelegramOsintMarkers({ features, onEntityClick }: TelegramOsintMarkersProps) {
|
||||
if (!features.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{features.map((feature) => {
|
||||
if (feature.geometry?.type !== 'Point') return null;
|
||||
const [lng, lat] = feature.geometry.coordinates as [number, number];
|
||||
const props = feature.properties || {};
|
||||
const id = String(props.id || '');
|
||||
if (!id) return null;
|
||||
const postCount = Number(props.post_count || 1);
|
||||
const size = postCount > 1 ? Math.min(30, 16 + Math.log2(postCount) * 5) : 16;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={`telegram-osint-${id}`}
|
||||
longitude={lng}
|
||||
latitude={lat}
|
||||
anchor="center"
|
||||
offset={TELEGRAM_MARKER_OFFSET}
|
||||
style={{ zIndex: 95 }}
|
||||
onClick={(e) => {
|
||||
e.originalEvent.stopPropagation();
|
||||
onEntityClick?.({
|
||||
id,
|
||||
type: 'telegram_osint',
|
||||
name: String(props.name || 'Telegram OSINT'),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
title={`Telegram OSINT${postCount > 1 ? ` (${postCount} posts)` : ''}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
background: '#ef4444',
|
||||
border: '2.5px solid #fca5a5',
|
||||
boxShadow: '0 0 14px rgba(239, 68, 68, 0.75)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export type DynamicMapLayersDataPayload = DynamicMapLayersPayload;
|
||||
|
||||
export type DynamicMapLayersBuildPayload = {
|
||||
bounds: BoundsTuple;
|
||||
/** When true, /api/live-data/fast already bbox-filtered this payload — skip client cull. */
|
||||
serverBboxScoped?: boolean;
|
||||
dtSeconds: number;
|
||||
trackedIcaos: string[];
|
||||
activeLayers: {
|
||||
@@ -173,6 +175,16 @@ function inView(lat: number, lng: number, bounds: BoundsTuple): boolean {
|
||||
return lng >= bounds[0] && lng <= bounds[2] && lat >= bounds[1] && lat <= bounds[3];
|
||||
}
|
||||
|
||||
function passesViewFilter(
|
||||
lat: number,
|
||||
lng: number,
|
||||
bounds: BoundsTuple,
|
||||
serverBboxScoped: boolean,
|
||||
): boolean {
|
||||
if (serverBboxScoped) return true;
|
||||
return inView(lat, lng, bounds);
|
||||
}
|
||||
|
||||
function cleanLabel(value: unknown): string {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return '';
|
||||
return String(value).trim();
|
||||
@@ -239,6 +251,7 @@ function buildFlightLayerGeoJSONWorker(
|
||||
bounds: BoundsTuple,
|
||||
dtSeconds: number,
|
||||
trackedIcaos: Set<string>,
|
||||
serverBboxScoped: boolean,
|
||||
): FC {
|
||||
if (!flights?.length) return null;
|
||||
const { colorMap, groundedMap, typeLabel, idPrefix, milSpecialMap, useTrackHeading } = config;
|
||||
@@ -248,7 +261,7 @@ function buildFlightLayerGeoJSONWorker(
|
||||
const f = flights[i];
|
||||
if (f.lat == null || f.lng == null) continue;
|
||||
const [iLng, iLat] = interpFlightPosition(f, dtSeconds);
|
||||
if (!inView(iLat, iLng, bounds)) continue;
|
||||
if (!passesViewFilter(iLat, iLng, bounds, serverBboxScoped)) continue;
|
||||
if (f.icao24 && trackedIcaos.has(f.icao24.toLowerCase())) continue;
|
||||
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
@@ -288,6 +301,7 @@ function buildTrackedFlightsGeoJSONWorker(
|
||||
flights: Flight[] | undefined,
|
||||
bounds: BoundsTuple,
|
||||
dtSeconds: number,
|
||||
serverBboxScoped: boolean,
|
||||
): FC {
|
||||
if (!flights?.length) return null;
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
@@ -296,7 +310,7 @@ function buildTrackedFlightsGeoJSONWorker(
|
||||
const f = flights[i];
|
||||
if (f.lat == null || f.lng == null) continue;
|
||||
const [lng, lat] = interpFlightPosition(f, dtSeconds);
|
||||
if (!inView(lat, lng, bounds)) continue;
|
||||
if (!passesViewFilter(lat, lng, bounds, serverBboxScoped)) continue;
|
||||
|
||||
const alertColor = ('alert_color' in f ? f.alert_color : '') || 'white';
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
@@ -334,6 +348,7 @@ function buildShipsGeoJSONWorker(
|
||||
activeLayers: DynamicMapLayersBuildPayload['activeLayers'],
|
||||
bounds: BoundsTuple,
|
||||
dtSeconds: number,
|
||||
serverBboxScoped: boolean,
|
||||
): FC {
|
||||
if (
|
||||
!ships?.length ||
|
||||
@@ -353,7 +368,7 @@ function buildShipsGeoJSONWorker(
|
||||
const s = ships[i];
|
||||
if (s.lat == null || s.lng == null) continue;
|
||||
const [iLng, iLat] = interpShipPosition(s, dtSeconds);
|
||||
if (!inView(iLat, iLng, bounds)) continue;
|
||||
if (!passesViewFilter(iLat, iLng, bounds, serverBboxScoped)) continue;
|
||||
if (s.type === 'carrier') continue;
|
||||
|
||||
const isTrackedYacht = Boolean(s.yacht_alert);
|
||||
@@ -394,6 +409,7 @@ function buildSigintGeoJSONWorker(
|
||||
signals: SigintSignal[] | undefined,
|
||||
source: 'meshtastic' | 'aprs',
|
||||
bounds: BoundsTuple,
|
||||
serverBboxScoped: boolean,
|
||||
): FC {
|
||||
if (!signals?.length) return null;
|
||||
const wanted =
|
||||
@@ -405,7 +421,7 @@ function buildSigintGeoJSONWorker(
|
||||
for (let i = 0; i < signals.length; i += 1) {
|
||||
const sig = signals[i];
|
||||
if (!wanted(sig) || sig.lat == null || sig.lng == null) continue;
|
||||
if (!inView(sig.lat, sig.lng, bounds)) continue;
|
||||
if (!passesViewFilter(sig.lat, sig.lng, bounds, serverBboxScoped)) continue;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
@@ -537,6 +553,7 @@ function applyFilters(activeFilters: Record<string, string[]> | undefined) {
|
||||
function buildDynamicLayers(payload: DynamicMapLayersBuildPayload): DynamicMapLayersResult {
|
||||
const trackedIcaos = new Set(payload.trackedIcaos);
|
||||
const filtered = applyFilters(payload.activeFilters);
|
||||
const serverBboxScoped = Boolean(payload.serverBboxScoped);
|
||||
return {
|
||||
commercialFlightsGeoJSON: payload.activeLayers.flights
|
||||
? buildFlightLayerGeoJSONWorker(
|
||||
@@ -545,6 +562,7 @@ function buildDynamicLayers(payload: DynamicMapLayersBuildPayload): DynamicMapLa
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
trackedIcaos,
|
||||
serverBboxScoped,
|
||||
)
|
||||
: null,
|
||||
privateFlightsGeoJSON: payload.activeLayers.private
|
||||
@@ -554,6 +572,7 @@ function buildDynamicLayers(payload: DynamicMapLayersBuildPayload): DynamicMapLa
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
trackedIcaos,
|
||||
serverBboxScoped,
|
||||
)
|
||||
: null,
|
||||
privateJetsGeoJSON: payload.activeLayers.jets
|
||||
@@ -563,6 +582,7 @@ function buildDynamicLayers(payload: DynamicMapLayersBuildPayload): DynamicMapLa
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
trackedIcaos,
|
||||
serverBboxScoped,
|
||||
)
|
||||
: null,
|
||||
militaryFlightsGeoJSON: payload.activeLayers.military
|
||||
@@ -572,22 +592,29 @@ function buildDynamicLayers(payload: DynamicMapLayersBuildPayload): DynamicMapLa
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
trackedIcaos,
|
||||
serverBboxScoped,
|
||||
)
|
||||
: null,
|
||||
trackedFlightsGeoJSON: payload.activeLayers.tracked
|
||||
? buildTrackedFlightsGeoJSONWorker(filtered.tracked, payload.bounds, payload.dtSeconds)
|
||||
? buildTrackedFlightsGeoJSONWorker(
|
||||
filtered.tracked,
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
serverBboxScoped,
|
||||
)
|
||||
: null,
|
||||
shipsGeoJSON: buildShipsGeoJSONWorker(
|
||||
filtered.ships,
|
||||
payload.activeLayers,
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
serverBboxScoped,
|
||||
),
|
||||
meshtasticGeoJSON: payload.activeLayers.sigint_meshtastic
|
||||
? buildSigintGeoJSONWorker(dynamicData.sigint, 'meshtastic', payload.bounds)
|
||||
? buildSigintGeoJSONWorker(dynamicData.sigint, 'meshtastic', payload.bounds, serverBboxScoped)
|
||||
: null,
|
||||
aprsGeoJSON: payload.activeLayers.sigint_aprs
|
||||
? buildSigintGeoJSONWorker(dynamicData.sigint, 'aprs', payload.bounds)
|
||||
? buildSigintGeoJSONWorker(dynamicData.sigint, 'aprs', payload.bounds, serverBboxScoped)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,8 +165,12 @@ export function buildEarthquakesGeoJSON(earthquakes?: Earthquake[]): FC {
|
||||
properties: {
|
||||
id: i,
|
||||
type: 'earthquake',
|
||||
name: `[M${eq.mag}]\n${eq.place || 'Unknown Location'}`,
|
||||
name: `[M${eq.mag}] ${eq.place || 'Unknown Location'}`,
|
||||
title: eq.title,
|
||||
lat: eq.lat,
|
||||
lng: eq.lng,
|
||||
mag: eq.mag,
|
||||
place: eq.place,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [eq.lng, eq.lat] },
|
||||
};
|
||||
@@ -1566,6 +1570,183 @@ export function buildCrowdThreatGeoJSON(threats?: CrowdThreatItem[], inView?: In
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Telegram OSINT ───────────────────────────────────────────────────────
|
||||
|
||||
/** Group geoparsed posts by city-level coordinates (~1 km grid). */
|
||||
export function telegramClusterKey(lat: number, lng: number): string {
|
||||
return `${lat.toFixed(2)}_${lng.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Small fixed shift (~5 mi NE) only when a threat alert shares the same city grid. */
|
||||
export const TELEGRAM_ALERT_AVOID_METERS = 8_000;
|
||||
export const TELEGRAM_ALERT_AVOID_BEARING = 45;
|
||||
|
||||
/** HTML marker nudge — threat alerts are DOM overlays that cover map canvas dots. */
|
||||
export const TELEGRAM_MARKER_OFFSET: [number, number] = [28, -24];
|
||||
|
||||
export function telegramClusterNearNewsAlert(
|
||||
lat: number,
|
||||
lng: number,
|
||||
news?: Array<{ coords?: [number, number] | null }> | null,
|
||||
): boolean {
|
||||
if (!news?.length) return false;
|
||||
const key = telegramClusterKey(lat, lng);
|
||||
return news.some((item) => {
|
||||
const coords = item.coords;
|
||||
if (!coords || coords.length < 2) return false;
|
||||
return telegramClusterKey(coords[0], coords[1]) === key;
|
||||
});
|
||||
}
|
||||
|
||||
export function telegramMapPinCoords(
|
||||
lat: number,
|
||||
lng: number,
|
||||
avoidAlert: boolean,
|
||||
): [number, number] {
|
||||
if (!avoidAlert) return [lat, lng];
|
||||
return projectPoint(lat, lng, TELEGRAM_ALERT_AVOID_BEARING, TELEGRAM_ALERT_AVOID_METERS);
|
||||
}
|
||||
|
||||
export function applyTelegramAlertAvoidance(
|
||||
geo: FC,
|
||||
news?: Array<{ coords?: [number, number] | null }> | null,
|
||||
): FC {
|
||||
if (!geo?.features?.length) return geo;
|
||||
return {
|
||||
...geo,
|
||||
features: geo.features.map((feature) => {
|
||||
const geometry = feature.geometry;
|
||||
if (!geometry || geometry.type !== 'Point') return feature;
|
||||
const point = geometry.coordinates;
|
||||
if (!point || point.length < 2) return feature;
|
||||
const lng = point[0];
|
||||
const lat = point[1];
|
||||
const avoid = telegramClusterNearNewsAlert(lat, lng, news);
|
||||
if (!avoid) return feature;
|
||||
const [pinLat, pinLng] = telegramMapPinCoords(lat, lng, true);
|
||||
return {
|
||||
...feature,
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [pinLng, pinLat],
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTelegramOsintGeoJSON(
|
||||
payload?: {
|
||||
posts?: Array<{
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
link?: string;
|
||||
source?: string;
|
||||
channel?: string;
|
||||
risk_score?: number;
|
||||
coords?: [number, number] | null;
|
||||
}>;
|
||||
},
|
||||
inView?: InViewFilter,
|
||||
): FC {
|
||||
const posts = payload?.posts;
|
||||
if (!posts?.length) return null;
|
||||
|
||||
const clusters = new Map<
|
||||
string,
|
||||
{
|
||||
lat: number;
|
||||
lng: number;
|
||||
posts: NonNullable<typeof posts>;
|
||||
maxRisk: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const post of posts) {
|
||||
const coords = post.coords;
|
||||
if (!coords || coords.length < 2) continue;
|
||||
const lat = coords[0];
|
||||
const lng = coords[1];
|
||||
if (inView && !inView(lat, lng)) continue;
|
||||
const key = telegramClusterKey(lat, lng);
|
||||
const bucket = clusters.get(key);
|
||||
if (bucket) {
|
||||
bucket.posts.push(post);
|
||||
bucket.maxRisk = Math.max(bucket.maxRisk, post.risk_score ?? 1);
|
||||
} else {
|
||||
clusters.set(key, {
|
||||
lat,
|
||||
lng,
|
||||
posts: [post],
|
||||
maxRisk: post.risk_score ?? 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!clusters.size) return null;
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: Array.from(clusters.entries()).map(([key, cluster]) => {
|
||||
const lead = cluster.posts[0];
|
||||
const count = cluster.posts.length;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: key,
|
||||
type: 'telegram_osint',
|
||||
name:
|
||||
count > 1
|
||||
? `Telegram OSINT (${count} posts)`
|
||||
: lead.title || 'Telegram OSINT',
|
||||
description: lead.description || '',
|
||||
link: lead.link || '',
|
||||
source: lead.source || '',
|
||||
channel: lead.channel || '',
|
||||
risk_score: cluster.maxRisk,
|
||||
post_count: count,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [cluster.lng, cluster.lat],
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Malware C2 / URLhaus ─────────────────────────────────────────────────
|
||||
|
||||
export function buildMalwareGeoJSON(
|
||||
payload?: { threats?: Array<{ id: string; lat: number; lng: number; ip: string; malware: string; threat_type?: string; country?: string }> },
|
||||
inView?: InViewFilter,
|
||||
): FC {
|
||||
const threats = payload?.threats;
|
||||
if (!threats?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: threats
|
||||
.map((t) => {
|
||||
if (t.lat == null || t.lng == null) return null;
|
||||
if (inView && !inView(t.lat, t.lng)) return null;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: t.id,
|
||||
type: 'malware',
|
||||
name: t.malware,
|
||||
ip: t.ip,
|
||||
threat_type: t.threat_type || 'malware',
|
||||
country: t.country || '',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [t.lng, t.lat] },
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GeoJSON.Feature[],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Wastewater colors by alert level ────────────────────────────────────
|
||||
const WW_COLORS = {
|
||||
alert: '#ff3333', // red — elevated pathogen detected
|
||||
|
||||
@@ -48,6 +48,8 @@ const EMPTY_RESULT: StaticMapLayersResult = {
|
||||
uapSightingsGeoJSON: null,
|
||||
wastewaterGeoJSON: null,
|
||||
crowdthreatGeoJSON: null,
|
||||
malwareGeoJSON: null,
|
||||
telegramOsintGeoJSON: null,
|
||||
};
|
||||
|
||||
let worker: Worker | null = null;
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
buildUapSightingsGeoJSON,
|
||||
buildWastewaterGeoJSON,
|
||||
buildCrowdThreatGeoJSON,
|
||||
buildMalwareGeoJSON,
|
||||
buildTelegramOsintGeoJSON,
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
import type {
|
||||
AirQualityStation,
|
||||
@@ -44,6 +46,7 @@ import type {
|
||||
VIIRSChangeNode,
|
||||
Volcano,
|
||||
CrowdThreatItem,
|
||||
MalwareThreat,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
type BoundsTuple = [number, number, number, number];
|
||||
@@ -71,6 +74,17 @@ export type StaticMapLayersDataPayload = {
|
||||
uapSightings?: UAPSighting[];
|
||||
wastewater?: WastewaterPlant[];
|
||||
crowdthreat?: CrowdThreatItem[];
|
||||
malwareThreats?: MalwareThreat[];
|
||||
telegramOsintPosts?: Array<{
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
link?: string;
|
||||
source?: string;
|
||||
channel?: string;
|
||||
risk_score?: number;
|
||||
coords?: [number, number] | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type StaticMapLayersBuildPayload = {
|
||||
@@ -95,6 +109,8 @@ export type StaticMapLayersBuildPayload = {
|
||||
uap_sightings: boolean;
|
||||
wastewater: boolean;
|
||||
crowdthreat: boolean;
|
||||
malware_c2: boolean;
|
||||
telegram_osint: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -119,6 +135,8 @@ export type StaticMapLayersResult = {
|
||||
uapSightingsGeoJSON: FC;
|
||||
wastewaterGeoJSON: FC;
|
||||
crowdthreatGeoJSON: FC;
|
||||
malwareGeoJSON: FC;
|
||||
telegramOsintGeoJSON: FC;
|
||||
};
|
||||
|
||||
type SyncRequest = {
|
||||
@@ -191,6 +209,12 @@ function buildStaticLayers(payload: StaticMapLayersBuildPayload): StaticMapLayer
|
||||
uapSightingsGeoJSON: payload.activeLayers.uap_sightings ? buildUapSightingsGeoJSON(staticData.uapSightings) : null,
|
||||
wastewaterGeoJSON: payload.activeLayers.wastewater ? buildWastewaterGeoJSON(staticData.wastewater) : null,
|
||||
crowdthreatGeoJSON: payload.activeLayers.crowdthreat ? buildCrowdThreatGeoJSON(staticData.crowdthreat, inView) : null,
|
||||
malwareGeoJSON: payload.activeLayers.malware_c2
|
||||
? buildMalwareGeoJSON({ threats: staticData.malwareThreats }, inView)
|
||||
: null,
|
||||
telegramOsintGeoJSON: payload.activeLayers.telegram_osint
|
||||
? buildTelegramOsintGeoJSON({ posts: staticData.telegramOsintPosts })
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user