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:
BigBodyCobain
2026-06-08 21:04:08 -06:00
parent b64b9e0962
commit af9b3d08cc
76 changed files with 5769 additions and 218 deletions
@@ -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>
);
}
+1
View File
@@ -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>`,
+214 -1
View File
@@ -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>}
&gt;_ {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">&gt;_ 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>
);
}
+19 -1
View File
@@ -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>
)
+34 -6
View File
@@ -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}
+176
View File
@@ -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>
);
}
+416
View File
@@ -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} />;
}
}
+137
View File
@@ -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>
);
}
+44 -1
View File
@@ -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,
};
}
+182 -1
View File
@@ -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,
};
}