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
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { VIEWPORT_COMMITTED_EVENT } from '@/components/map/hooks/useViewportBounds';
import { setLiveDataBounds } from '@/lib/liveDataViewport';
describe('viewport fast refetch wiring', () => {
beforeEach(() => {
vi.useFakeTimers();
setLiveDataBounds({ south: 10, west: 20, north: 12, east: 22 });
});
afterEach(() => {
setLiveDataBounds(null);
vi.useRealTimers();
vi.restoreAllMocks();
});
it('VIEWPORT_COMMITTED_EVENT is a stable custom event name', () => {
expect(VIEWPORT_COMMITTED_EVENT).toBe('shadowbroker:viewport-committed');
const handler = vi.fn();
window.addEventListener(VIEWPORT_COMMITTED_EVENT, handler);
window.dispatchEvent(new CustomEvent(VIEWPORT_COMMITTED_EVENT));
expect(handler).toHaveBeenCalledTimes(1);
window.removeEventListener(VIEWPORT_COMMITTED_EVENT, handler);
});
});
@@ -0,0 +1,61 @@
import { sanitizeSubmarineCables } from '@/lib/submarineCables';
describe('sanitizeSubmarineCables', () => {
it('removes synthetic corridor overlays', () => {
const out = sanitizeSubmarineCables({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'SEA-ME-WE Corridor' },
geometry: {
type: 'LineString',
coordinates: [
[-5, 51],
[73, 17],
],
},
},
{
type: 'Feature',
properties: { name: 'FEA' },
geometry: {
type: 'LineString',
coordinates: [
[32, 30],
[33, 29],
],
},
},
],
});
expect(out.features).toHaveLength(1);
expect(out.features[0].properties?.name).toBe('FEA');
});
it('splits trans-ocean jumps into separate segments', () => {
const out = sanitizeSubmarineCables({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'Test Pacific' },
geometry: {
type: 'LineString',
coordinates: [
[-120, 35],
[-125, 36],
[100, 13],
[101, 12],
],
},
},
],
});
const geom = out.features[0].geometry;
expect(geom?.type).toBe('MultiLineString');
if (geom?.type === 'MultiLineString') {
expect(geom.coordinates).toHaveLength(2);
}
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import {
applyTelegramAlertAvoidance,
buildTelegramOsintGeoJSON,
telegramClusterKey,
telegramClusterNearNewsAlert,
telegramMapPinCoords,
TELEGRAM_ALERT_AVOID_METERS,
} from '@/components/map/geoJSONBuilders';
describe('telegramMapPinCoords', () => {
it('stays on the geocoded city when no threat alert overlaps', () => {
const [lat, lng] = telegramMapPinCoords(31.046, 34.851, false);
expect(lat).toBe(31.046);
expect(lng).toBe(34.851);
});
it('nudges ~5 mi northeast only when avoiding an alert', () => {
const [lat, lng] = telegramMapPinCoords(31.046, 34.851, true);
expect(lat).toBeGreaterThan(31.046);
expect(lng).toBeGreaterThan(34.851);
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat - 31.046);
const meters = 6371000 * dLat;
expect(meters).toBeGreaterThan(4_000);
expect(meters).toBeLessThan(TELEGRAM_ALERT_AVOID_METERS + 2_000);
});
});
describe('telegramClusterNearNewsAlert', () => {
it('detects news on the same city grid', () => {
const news = [{ coords: [31.046, 34.851] as [number, number] }];
expect(telegramClusterNearNewsAlert(31.049, 34.849, news)).toBe(true);
expect(telegramClusterNearNewsAlert(50.45, 30.52, news)).toBe(false);
});
});
describe('telegramClusterKey', () => {
it('groups nearby coordinates to the same city bucket', () => {
expect(telegramClusterKey(50.451, 30.521)).toBe(telegramClusterKey(50.449, 30.519));
});
});
describe('buildTelegramOsintGeoJSON', () => {
it('places the dot on the geocoded city by default', () => {
const geo = buildTelegramOsintGeoJSON({
posts: [
{
id: 'tg-1',
title: 'Strike near Kyiv',
coords: [50.45, 30.52],
},
],
});
const feature = geo?.features[0];
expect(feature).toBeTruthy();
const [lng, lat] = feature!.geometry!.coordinates as [number, number];
expect(lat).toBeCloseTo(50.45, 2);
expect(lng).toBeCloseTo(30.52, 2);
});
it('merges posts in the same city into one pin', () => {
const geo = buildTelegramOsintGeoJSON({
posts: [
{ id: 'a', title: 'Post A', coords: [50.45, 30.52] },
{ id: 'b', title: 'Post B', coords: [50.451, 30.521] },
{ id: 'c', title: 'Post C', coords: [48.0, 37.8] },
],
});
expect(geo?.features).toHaveLength(2);
const kyiv = geo?.features.find((f) => f.properties?.post_count === 2);
expect(kyiv).toBeTruthy();
expect(kyiv?.properties?.id).toBe(telegramClusterKey(50.45, 30.52));
});
});
describe('applyTelegramAlertAvoidance', () => {
it('offsets only clusters that share a grid cell with a news alert', () => {
const geo = buildTelegramOsintGeoJSON({
posts: [
{ id: 'il', title: 'Israel post', coords: [31.046, 34.851] },
{ id: 'ua', title: 'Kyiv post', coords: [50.45, 30.52] },
],
});
const placed = applyTelegramAlertAvoidance(geo, [{ coords: [31.046, 34.851] }]);
const israel = placed?.features.find((f) => f.properties?.id === telegramClusterKey(31.046, 34.851));
const kyiv = placed?.features.find((f) => f.properties?.id === telegramClusterKey(50.45, 30.52));
const [ilLng, ilLat] = israel!.geometry!.coordinates as [number, number];
const [uaLng, uaLat] = kyiv!.geometry!.coordinates as [number, number];
expect(ilLat).toBeGreaterThan(31.046);
expect(uaLat).toBeCloseTo(50.45, 2);
expect(uaLng).toBeCloseTo(30.52, 2);
});
});
@@ -5,6 +5,10 @@ import {
coarsenViewBounds,
expandBoundsToRadius,
} from '@/lib/viewportPrivacy';
import {
liveDataBoundsKey,
setLiveDataBounds,
} from '@/lib/liveDataViewport';
describe('viewport privacy helper', () => {
it('coarsens narrow bounds outward without clipping the original view', () => {
@@ -45,6 +49,14 @@ describe('viewport privacy helper', () => {
expect(b).toBe(a);
});
it('liveDataBoundsKey matches quantized fetch params and clears for world view', () => {
setLiveDataBounds({ south: 33.6, west: -84.5, north: 33.8, east: -84.2 });
expect(liveDataBoundsKey()).toBe('33,-85,34,-84');
setLiveDataBounds(null);
expect(liveDataBoundsKey()).toBeNull();
});
it('expands bounds to a fixed preload radius around the current view center', () => {
const original = {
south: 39.55,
+29 -1
View File
@@ -21,6 +21,10 @@ import InfonetTerminal from '@/components/InfonetTerminal';
import { leaveWormhole, fetchWormholeState } from '@/mesh/wormholeClient';
import { teardownWormholeOnClose } from '@/lib/wormholeTeardown';
import ShodanPanel from '@/components/ShodanPanel';
import ReconPanel from '@/components/ReconPanel';
import ScmPanel from '@/components/ScmPanel';
import EntityGraphPanel from '@/components/EntityGraphPanel';
import { isEntityGraphEligible } from '@/lib/entityGraph';
import AIIntelPanel from '@/components/AIIntelPanel';
import GlobalTicker from '@/components/GlobalTicker';
import ErrorBoundary from '@/components/ErrorBoundary';
@@ -71,6 +75,10 @@ export default function Dashboard() {
useDataPolling();
const { mouseCoords, locationLabel, handleMouseCoords } = useReverseGeocode();
const [selectedEntity, setSelectedEntity] = useState<SelectedEntity | null>(null);
const [showEntityGraph, setShowEntityGraph] = useState(false);
useEffect(() => {
setShowEntityGraph(false);
}, [selectedEntity]);
const [trackedSdr, setTrackedSdr] = useState<KiwiSDR | null>(null);
const [trackedScanner, setTrackedScanner] = useState<Scanner | null>(null);
const { regionDossier, regionDossierLoading, handleMapRightClick } = useRegionDossier(
@@ -186,6 +194,11 @@ export default function Dashboard() {
sentinel_hub: false,
viirs_nightlights: false,
road_corridor_trends: false,
malware_c2: false,
submarine_cables: false,
scm_suppliers: false,
cyber_threats: false,
telegram_osint: true,
// Hazards — no fire, rest ON
earthquakes: true,
firms: false,
@@ -636,7 +649,15 @@ export default function Dashboard() {
</div>
)}
{/* 4. AI INTEL (Below Shodan) */}
{/* 4. RECON + SCM */}
{secondaryBootReady && (
<div className="contents" style={{ direction: 'ltr' }}>
<ReconPanel />
<ScmPanel layerEnabled={activeLayers.scm_suppliers} />
</div>
)}
{/* 5. AI INTEL */}
{secondaryBootReady && (
<div className="contents" style={{ direction: 'ltr' }}>
<AIIntelPanel
@@ -748,6 +769,9 @@ export default function Dashboard() {
selectedEntity={selectedEntity}
regionDossier={regionDossier}
regionDossierLoading={regionDossierLoading}
onExpandEntityGraph={() => {
if (isEntityGraphEligible(selectedEntity)) setShowEntityGraph(true);
}}
onArticleClick={(idx, lat, lng, title) => {
if (lat !== undefined && lng !== undefined) {
setFlyToLocation({ lat, lng, ts: Date.now() });
@@ -989,6 +1013,10 @@ export default function Dashboard() {
onSettingsClick={() => setSettingsOpen(true)}
/>
{showEntityGraph && selectedEntity && isEntityGraphEligible(selectedEntity) && (
<EntityGraphPanel entity={selectedEntity} onClose={() => setShowEntityGraph(false)} />
)}
{/* INFONET TERMINAL */}
<InfonetTerminal
isOpen={infonetOpen}
@@ -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,
};
}
+62 -6
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef } from "react";
import { API_BASE } from "@/lib/api";
import { mergeData, setBackendStatus as setStoreBackendStatus } from "./useDataStore";
import { appendLiveDataBoundsParams } from "@/lib/liveDataViewport";
import { appendLiveDataBoundsParams, liveDataBoundsKey } from "@/lib/liveDataViewport";
import { VIEWPORT_COMMITTED_EVENT } from "@/components/map/hooks/useViewportBounds";
export type BackendStatus = 'connecting' | 'connected' | 'disconnected';
@@ -83,6 +84,10 @@ function hasMeaningfulFastData(json: FastDataProbe): boolean {
*/
export const LAYER_TOGGLE_EVENT = 'sb:layer-toggle';
/** Debounce rapid pans; min gap keeps viewport refetches under the 120/min rate limit. */
const VIEWPORT_FAST_REFETCH_DEBOUNCE_MS = 400;
const VIEWPORT_FAST_REFETCH_MIN_INTERVAL_MS = 2500;
/**
* Polls the backend for fast and slow data tiers.
*
@@ -94,6 +99,9 @@ export const LAYER_TOGGLE_EVENT = 'sb:layer-toggle';
* infrastructure. World-zoomed views skip bbox params entirely and hit
* the shared ETag cache exactly like the pre-#288 behaviour.
*
* Viewport commits trigger a debounced fast-tier refetch so regional pans
* refill aircraft/ships without waiting for the 15s poll cadence.
*
* The AIS stream viewport POST (/api/viewport) is still handled separately
* by useViewportBounds to limit upstream AIS ingestion.
*/
@@ -110,8 +118,12 @@ export function useDataPolling() {
let fetchedStartupFastPayload = false;
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
let viewportDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const fastAbortRef = { current: null as AbortController | null };
const slowAbortRef = { current: null as AbortController | null };
const fastFetchGenRef = { current: 0 };
let lastViewportFetchKey: string | null = null;
let lastViewportFetchAt = 0;
const fetchCriticalBootstrap = async () => {
try {
@@ -138,6 +150,13 @@ export function useDataPolling() {
}
};
const abortInFlightFastFetch = () => {
if (fastAbortRef.current) {
fastAbortRef.current.abort();
fastAbortRef.current = null;
}
};
const fetchFastData = async () => {
if (fastTimerId) {
clearTimeout(fastTimerId);
@@ -145,9 +164,12 @@ export function useDataPolling() {
}
// Skip fetch when Time Machine snapshot mode is active
if (_pollingPaused) { scheduleNext('fast'); return; }
if (fastAbortRef.current) return;
abortInFlightFastFetch();
const controller = new AbortController();
fastAbortRef.current = controller;
const fetchGen = ++fastFetchGenRef.current;
try {
const useStartupPayload = !fetchedStartupFastPayload && !fastEtag.current;
const headers: Record<string, string> = {};
@@ -159,9 +181,10 @@ export function useDataPolling() {
headers,
signal: controller.signal,
});
if (fetchGen !== fastFetchGenRef.current) return;
if (res.status === 304) {
setStoreBackendStatus('connected');
scheduleNext('fast');
scheduleNext('fast', fetchGen);
return;
}
if (res.ok) {
@@ -171,6 +194,7 @@ export function useDataPolling() {
fastEtag.current = useStartupPayload ? null : res.headers.get('etag') || null;
if (useStartupPayload) fetchedStartupFastPayload = true;
const json = await res.json();
if (fetchGen !== fastFetchGenRef.current) return;
mergeData(json);
if (hasMeaningfulFastData(json)) hasData = true;
}
@@ -189,7 +213,7 @@ export function useDataPolling() {
fastAbortRef.current = null;
}
}
scheduleNext('fast');
scheduleNext('fast', fetchGen);
};
const fetchSlowData = async () => {
@@ -231,8 +255,9 @@ export function useDataPolling() {
};
// Adaptive polling: retry every 3s during startup, back off to normal cadence once data arrives
const scheduleNext = (tier: 'fast' | 'slow') => {
const scheduleNext = (tier: 'fast' | 'slow', fetchGen?: number) => {
if (tier === 'fast') {
if (fetchGen !== undefined && fetchGen !== fastFetchGenRef.current) return;
const delay = hasData ? 15000 : 3000; // 3s startup retry → 15s steady state
const needsFullFastPayload = fetchedStartupFastPayload && !fastEtag.current;
fastTimerId = setTimeout(fetchFastData, needsFullFastPayload ? 750 : delay);
@@ -242,6 +267,34 @@ export function useDataPolling() {
}
};
const queueViewportFastRefetch = () => {
if (_pollingPaused) return;
const key = liveDataBoundsKey();
if (!key) {
lastViewportFetchKey = null;
return;
}
if (key === lastViewportFetchKey) return;
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
viewportDebounceTimer = setTimeout(() => {
viewportDebounceTimer = null;
if (_pollingPaused) return;
const currentKey = liveDataBoundsKey();
if (!currentKey || currentKey === lastViewportFetchKey) return;
const now = Date.now();
if (now - lastViewportFetchAt < VIEWPORT_FAST_REFETCH_MIN_INTERVAL_MS) return;
lastViewportFetchKey = currentKey;
lastViewportFetchAt = now;
fastEtag.current = null;
void fetchFastData();
}, VIEWPORT_FAST_REFETCH_DEBOUNCE_MS);
};
// When a layer toggle fires, immediately refetch slow data so the user
// doesn't wait up to 120s for power plants / GDELT / etc. to appear.
const onLayerToggle = () => {
@@ -251,6 +304,7 @@ export function useDataPolling() {
fetchSlowData();
};
window.addEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
window.addEventListener(VIEWPORT_COMMITTED_EVENT, queueViewportFastRefetch);
void (async () => {
await fetchCriticalBootstrap();
@@ -261,9 +315,11 @@ export function useDataPolling() {
return () => {
window.removeEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
window.removeEventListener(VIEWPORT_COMMITTED_EVENT, queueViewportFastRefetch);
if (fastTimerId) clearTimeout(fastTimerId);
if (slowTimerId) clearTimeout(slowTimerId);
if (fastAbortRef.current) fastAbortRef.current.abort();
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
abortInFlightFastFetch();
if (slowAbortRef.current) slowAbortRef.current.abort();
};
}, []);
+22 -1
View File
@@ -203,7 +203,12 @@
"aiIntel": "AI Intel",
"sar": "SAR",
"roadCorridorTrends": "Road Freight Trends",
"roadCorridorSource": "Copernicus S-2 · trends not live"
"roadCorridorSource": "Copernicus S-2 · trends not live",
"submarineCables": "Submarine Cables",
"malwareC2": "Malware C2",
"scmSuppliers": "SCM Suppliers",
"cyberThreats": "Cyber Threats",
"telegramOsint": "Telegram OSINT"
},
"roadCorridor": {
"analyzeHere": "ANALYZE HERE",
@@ -214,6 +219,15 @@
"panMapFirst": "Pan the map to choose an area",
"analyzeFailed": "Analysis failed"
},
"recon": {
"title": "Recon Toolkit",
"proxyNote": "Server-side proxy · local operator only"
},
"scm": {
"title": "Supply Chain",
"allClear": "No elevated risk at monitored Tier 1/2 nodes.",
"layerOff": "Off — enable Supply Chain in Data Layers to monitor fabs."
},
"shodan": {
"title": "Shodan Connector",
"searchPlaceholder": "Search devices...",
@@ -253,5 +267,12 @@
"vegetation": "Vegetation Disturbance",
"damage": "Damage Assessment",
"coherence": "Coherence Change"
},
"telegram": {
"disclaimer": "WARNING: Content below is loaded from public Telegram channels. Shadowbroker does not host, verify, or endorse it. What you view is entirely at your own risk and has nothing to do with Shadowbroker.",
"loadMedia": "VIEW MEDIA (TELEGRAM)",
"openOriginal": "OPEN ON TELEGRAM →",
"embedTitle": "Telegram post embed",
"postsAtLocation": "{count} posts at this location — scroll for more"
}
}
+22 -1
View File
@@ -203,7 +203,12 @@
"aiIntel": "Infos IA",
"sar": "SAR",
"roadCorridorTrends": "Tendances fret routier",
"roadCorridorSource": "Copernicus S-2 · tendances (pas en direct)"
"roadCorridorSource": "Copernicus S-2 · tendances (pas en direct)",
"submarineCables": "Câbles sous-marins",
"malwareC2": "Malware C2",
"scmSuppliers": "Fournisseurs SCM",
"cyberThreats": "Cybermenaces",
"telegramOsint": "OSINT Telegram"
},
"roadCorridor": {
"analyzeHere": "ANALYSER ICI",
@@ -214,6 +219,15 @@
"panMapFirst": "Déplacez la carte pour choisir une zone",
"analyzeFailed": "Échec de l'analyse"
},
"recon": {
"title": "Boîte à outils recon",
"proxyNote": "Proxy côté serveur · opérateur local uniquement"
},
"scm": {
"title": "Chaîne d'approvisionnement",
"allClear": "Aucun risque élevé sur les nœuds Tier 1/2 surveillés.",
"layerOff": "Désactivé — activez la couche dans Données pour surveiller les fabs."
},
"shodan": {
"title": "Connecteur Shodan",
"searchPlaceholder": "Rechercher des appareils...",
@@ -253,5 +267,12 @@
"vegetation": "Perturbation végétale",
"damage": "Évaluation des dégâts",
"coherence": "Changement de cohérence"
},
"telegram": {
"disclaimer": "AVERTISSEMENT : le contenu ci-dessous provient de canaux Telegram publics. Shadowbroker ne l'héberge pas, ne le vérifie pas et ne le cautionne pas. Vous le consultez à vos risques et cela n'a aucun lien avec Shadowbroker.",
"loadMedia": "AFFICHER LE MÉDIA (TELEGRAM)",
"openOriginal": "OUVRIR SUR TELEGRAM →",
"embedTitle": "Intégration Telegram",
"postsAtLocation": "{count} posts à cet endroit — faites défiler"
}
}
+22 -1
View File
@@ -203,7 +203,12 @@
"aiIntel": "AI 情报",
"sar": "SAR",
"roadCorridorTrends": "公路货运趋势",
"roadCorridorSource": "Copernicus S-2 · 趋势(非实时)"
"roadCorridorSource": "Copernicus S-2 · 趋势(非实时)",
"submarineCables": "海底电缆",
"malwareC2": "恶意软件 C2",
"scmSuppliers": "供应链供应商",
"cyberThreats": "网络威胁",
"telegramOsint": "Telegram OSINT"
},
"roadCorridor": {
"analyzeHere": "分析此处",
@@ -214,6 +219,15 @@
"panMapFirst": "请先平移地图以选择区域",
"analyzeFailed": "分析失败"
},
"recon": {
"title": "侦察工具包",
"proxyNote": "服务端代理 · 仅本地操作员"
},
"scm": {
"title": "供应链",
"allClear": "受监控的 Tier 1/2 节点无升高风险。",
"layerOff": "已关闭 — 在数据图层中启用供应链以监控晶圆厂。"
},
"shodan": {
"title": "Shodan 连接器",
"searchPlaceholder": "搜索设备...",
@@ -253,5 +267,12 @@
"vegetation": "植被干扰",
"damage": "损毁评估",
"coherence": "相干变化"
},
"telegram": {
"disclaimer": "警告:以下内容来自公开 Telegram 频道。Shadowbroker 不托管、不核实、不背书该内容。您自行承担一切风险,与 Shadowbroker 无关。",
"loadMedia": "查看媒体(Telegram",
"openOriginal": "在 Telegram 打开 →",
"embedTitle": "Telegram 帖子嵌入",
"postsAtLocation": "此位置 {count} 条帖子 — 向下滚动查看更多"
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { SelectedEntity } from '@/types/dashboard';
const GRAPH_TYPES = new Set(['aircraft', 'vessel', 'company', 'person', 'ip', 'country']);
const SELECTION_TO_GRAPH: Record<string, string> = {
flight: 'aircraft',
private_flight: 'aircraft',
military_flight: 'aircraft',
private_jet: 'aircraft',
tracked_flight: 'aircraft',
ship: 'vessel',
};
export function mapEntityToGraphType(type: string): string | null {
const mapped = SELECTION_TO_GRAPH[type] || type;
return GRAPH_TYPES.has(mapped) ? mapped : null;
}
export function isEntityGraphEligible(entity: SelectedEntity | null | undefined): boolean {
if (!entity) return false;
return mapEntityToGraphType(entity.type) !== null;
}
+13
View File
@@ -67,6 +67,19 @@ export function getLiveDataBounds(): LiveDataBounds | null {
return _current;
}
/** Stable cache key for the active bbox-scoped fetch window (1° quantization,
* matching appendLiveDataBoundsParams / backend ETag). Returns null when
* world-scale fetching is active. */
export function liveDataBoundsKey(): string | null {
const b = _current;
if (!b) return null;
const s = Math.floor(b.south);
const w = Math.floor(b.west);
const n = Math.ceil(b.north);
const e = Math.ceil(b.east);
return `${s},${w},${n},${e}`;
}
/** Append `s/w/n/e` query params to a URL when bounds are set, otherwise
* return the URL unchanged. Centralised so all live-data callers stay in
* sync about quantization and the world-scale skip rule. */
+97
View File
@@ -0,0 +1,97 @@
/** Synthetic TeleGeography corridor overlays — not real cable routes. */
const SYNTHETIC_CABLE_NAMES = new Set([
'SEA-ME-WE Corridor',
'Trans-Atlantic North',
'Trans-Atlantic South',
'WACS / SAT-3 Corridor',
'EASSy / SEACOM',
'East Asia Corridor',
'Asia-Australia',
'Trans-Pacific',
'South Atlantic',
]);
type LngLat = [number, number];
function lonJumpDegrees(a: LngLat, b: LngLat): number {
const d = Math.abs(b[0] - a[0]);
return Math.min(d, 360 - d);
}
function iterParts(geometry: GeoJSON.Geometry): LngLat[][] {
if (geometry.type === 'LineString') {
return [geometry.coordinates as LngLat[]];
}
if (geometry.type === 'MultiLineString') {
return geometry.coordinates as LngLat[][];
}
return [];
}
/** Split a path when consecutive vertices jump across continents / dateline. */
function splitAtJumps(coords: LngLat[], maxJumpDeg = 90): LngLat[][] {
if (coords.length < 2) return coords.length ? [coords] : [];
const segments: LngLat[][] = [[coords[0]]];
for (let i = 1; i < coords.length; i += 1) {
const prev = segments[segments.length - 1][segments[segments.length - 1].length - 1];
const next = coords[i];
if (lonJumpDegrees(prev, next) > maxJumpDeg) {
segments.push([next]);
} else {
segments[segments.length - 1].push(next);
}
}
return segments.filter((seg) => seg.length >= 2);
}
function partsToGeometry(parts: LngLat[][]): GeoJSON.LineString | GeoJSON.MultiLineString | null {
if (!parts.length) return null;
if (parts.length === 1) {
return { type: 'LineString', coordinates: parts[0] };
}
return { type: 'MultiLineString', coordinates: parts };
}
/**
* Drop synthetic corridor junk and split lines that cut across the dateline.
* Land-crossing segments are stripped at build time (see scripts/sanitize_submarine_cables.py).
*/
export function sanitizeSubmarineCables(
collection: GeoJSON.FeatureCollection,
): GeoJSON.FeatureCollection {
const byName = new Map<string, GeoJSON.Feature>();
for (const feature of collection.features) {
const name = String(feature.properties?.name || '').trim();
if (!name || SYNTHETIC_CABLE_NAMES.has(name)) continue;
if (!feature.geometry || feature.geometry.type === 'GeometryCollection') continue;
const splitParts: LngLat[][] = [];
for (const part of iterParts(feature.geometry)) {
splitParts.push(...splitAtJumps(part));
}
const geometry = partsToGeometry(splitParts);
if (!geometry) continue;
const cleaned: GeoJSON.Feature = {
type: 'Feature',
properties: feature.properties ?? {},
geometry,
};
const existing = byName.get(name);
if (!existing) {
byName.set(name, cleaned);
continue;
}
const existingPts = iterParts(existing.geometry!).flat().length;
const newPts = splitParts.flat().length;
if (newPts > existingPts) byName.set(name, cleaned);
}
return {
type: 'FeatureCollection',
features: Array.from(byName.values()),
};
}
+6
View File
@@ -0,0 +1,6 @@
/** Proxy Telegram CDN media through the backend (host allowlist + range requests). */
export function buildTelegramMediaProxyUrl(rawUrl: string): string {
return rawUrl.startsWith('http')
? `/api/telegram/media?url=${encodeURIComponent(rawUrl)}`
: rawUrl;
}
+2 -1
View File
@@ -27,7 +27,8 @@ function buildCsp(nonce: string, strictScripts = false): string {
"object-src 'none'",
"worker-src 'self' blob:",
"child-src 'self' blob:",
"frame-src 'self' https://video.ibm.com https://ustream.tv https://www.ustream.tv",
"frame-src 'self' https://video.ibm.com https://ustream.tv https://www.ustream.tv https://t.me",
"media-src 'self' blob:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
+76
View File
@@ -934,6 +934,77 @@ export interface DashboardData {
error?: string | null;
}>;
};
malware_threats?: {
threats?: MalwareThreat[];
total?: number;
timestamp?: string | null;
source?: string;
};
cyber_threats?: {
threats?: Array<{
id: string;
name: string;
vendor?: string;
product?: string;
severity?: string;
date?: string;
source?: string;
}>;
stats?: Record<string, unknown>;
};
scm_suppliers?: {
suppliers?: ScmSupplier[];
critical_count?: number;
total?: number;
timestamp?: string;
};
telegram_osint?: {
posts?: TelegramOsintPost[];
total?: number;
geolocated?: number;
timestamp?: string | null;
channels?: string[];
};
}
export interface TelegramOsintPost {
id: string;
title?: string;
description?: string;
link?: string;
published?: string;
source?: string;
channel?: string;
risk_score?: number;
coords?: [number, number] | null;
media_type?: 'video' | 'photo' | null;
media_url?: string | null;
embed_url?: string | null;
}
export interface MalwareThreat {
id: string;
lat: number;
lng: number;
ip: string;
port?: number;
malware: string;
status?: string;
country?: string;
threat_type?: string;
}
export interface ScmSupplier {
id: string;
name: string;
city: string;
country: string;
category: string;
lat: number;
lng: number;
risk_level: string;
active_threats: string[];
}
// ─── SAR ─────────────────────────────────────────────────────────────────────
@@ -1044,6 +1115,11 @@ export interface ActiveLayers {
crowdthreat: boolean;
sar: boolean;
road_corridor_trends: boolean;
malware_c2: boolean;
submarine_cables: boolean;
scm_suppliers: boolean;
cyber_threats: boolean;
telegram_osint: boolean;
}
export interface SelectedEntity {
+1
View File
@@ -61,3 +61,4 @@ describe('spreadAlertItems', () => {
expect(hasNonZeroOffset).toBe(true);
});
});
+1
View File
@@ -184,3 +184,4 @@ export function spreadAlertItems(
showLine: Math.abs(item.offsetX) > 5 || Math.abs(item.offsetY) > 5,
})) as SpreadAlertItem[];
}