Feat/gt analytics openclaw (#392)

* feat(telegram): auto-translate OSINT channel posts to English

Cherry-picked from @Bobpick PR #391 (telegram-only slice): server-side translation during fetch, SHOW ORIGINAL toggle in TelegramOsintPopup, and on-demand /api/telegram-feed?lang=.

Co-authored-by: Robert Pickett <bobpickettsr@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(gt): experimental Derived OSINT analytics with lean-node safeguards

Cherry-picked from @Bobpick PR #391 (GT + OpenClaw slice): Bayesian strategic-risk engine, map overlay, OpenClaw commands, and telegram_rhetoric watchdog. Off by default (GT_ANALYTICS_ENABLED=false, gt_risk layer false). 1 vCPU nodes get cgroup detection, UI warning on layer toggle, and lean profile that skips scheduled ingest/Louvain unless GT_ANALYTICS_ACK_LOW_CPU=true. Backtest HUD removed from dashboard (OpenClaw/API regression only).

Co-authored-by: Robert Pickett <bobpickettsr@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Robert Pickett <bobpickettsr@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Shadowbroker
2026-06-16 17:05:46 -06:00
committed by GitHub
parent 9c5a4054f6
commit cfbeabda1e
69 changed files with 8102 additions and 78 deletions
+14
View File
@@ -37,6 +37,7 @@ import { useDataPolling, LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
import { useBackendStatus, useDataKey, useDataKeys } from '@/hooks/useDataStore';
import { useReverseGeocode } from '@/hooks/useReverseGeocode';
import { useRegionDossier } from '@/hooks/useRegionDossier';
import { useGtDossier } from '@/hooks/useGtDossier';
import { useAgentActions } from '@/hooks/useAgentActions';
import { useFeedHealth } from '@/hooks/useFeedHealth';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
@@ -237,6 +238,7 @@ export default function Dashboard() {
wastewater: true,
// CrowdThreat is operator opt-in only.
crowdthreat: false,
gt_risk: false,
// Shodan
shodan_overlay: false,
// AI Intel
@@ -244,6 +246,16 @@ export default function Dashboard() {
// SAR (Synthetic Aperture Radar)
sar: true,
});
const regionLat =
selectedEntity?.type === 'region_dossier' ? selectedEntity.extra?.lat : undefined;
const regionLng =
selectedEntity?.type === 'region_dossier' ? selectedEntity.extra?.lng : undefined;
const { gtDossier, gtDossierLoading } = useGtDossier(
typeof regionLat === 'number' ? regionLat : undefined,
typeof regionLng === 'number' ? regionLng : undefined,
regionDossier?.country?.name,
activeLayers.gt_risk,
);
const [shodanResults, setShodanResults] = useState<ShodanSearchMatch[]>([]);
const [, setShodanQueryLabel] = useState('');
const [shodanStyle, setShodanStyle] = useState<import('@/types/shodan').ShodanStyleConfig>({ shape: 'circle', color: '#16a34a', size: 'md' });
@@ -776,6 +788,8 @@ export default function Dashboard() {
selectedEntity={selectedEntity}
regionDossier={regionDossier}
regionDossierLoading={regionDossierLoading}
gtDossier={gtDossier}
gtDossierLoading={gtDossierLoading}
onExpandEntityGraph={() => {
if (isEntityGraphEligible(selectedEntity)) setShowEntityGraph(true);
}}
@@ -0,0 +1,77 @@
'use client';
import React from 'react';
import { GripVertical, Minus, Plus } from 'lucide-react';
import { useTranslation } from '@/i18n';
import { useFloatingPanel } from '@/hooks/useFloatingPanel';
import GtBacktestPanel from '@/components/GtBacktestPanel';
import GtTopAlertsStrip from '@/components/GtTopAlertsStrip';
import type { SelectedEntity } from '@/types/dashboard';
interface Props {
layerEnabled?: boolean;
onFlyTo?: (lat: number, lng: number) => void;
onSelectEntity?: (entity: SelectedEntity | null) => void;
}
export default function GtAnalyticsHud({
layerEnabled = false,
onFlyTo,
onSelectEntity,
}: Props) {
const { t } = useTranslation();
const { position, isMinimized, setIsMinimized, isDragging, onDragStart } = useFloatingPanel(
'sb-gt-analytics-hud-v1',
{ defaultPosition: { x: 24, y: 380 } },
);
if (!layerEnabled) return null;
return (
<div
className={`pointer-events-auto fixed z-[201] flex flex-col border border-amber-700/45 bg-black/80 shadow-[0_0_16px_rgba(245,158,11,0.12)] backdrop-blur-sm ${
isMinimized ? 'w-fit' : 'w-[min(92vw,28rem)]'
} ${isDragging ? 'cursor-grabbing select-none' : ''}`}
style={{ left: position.x, top: position.y }}
>
<div
className={`flex items-center gap-2 bg-amber-950/30 px-2 py-1.5 cursor-grab active:cursor-grabbing ${
isMinimized ? '' : 'border-b border-amber-800/35'
}`}
onMouseDown={onDragStart}
title={t('gtHud.dragHint')}
>
<GripVertical size={12} className="shrink-0 text-amber-600/80" />
<span className="whitespace-nowrap text-[10px] font-mono font-bold tracking-widest text-amber-300">
{t('gtHud.title')}
</span>
{!isMinimized && (
<span className="text-[9px] font-mono tracking-wider text-amber-600/70">
{t('gtHud.dragHint')}
</span>
)}
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={() => setIsMinimized((prev) => !prev)}
className="ml-auto p-0.5 text-amber-500 transition-colors hover:text-amber-300"
title={isMinimized ? t('gtHud.expand') : t('gtHud.collapse')}
>
{isMinimized ? <Plus size={14} /> : <Minus size={14} />}
</button>
</div>
{!isMinimized && (
<div className="flex max-h-[min(70vh,28rem)] flex-col overflow-y-auto styled-scrollbar">
<GtBacktestPanel layerEnabled={layerEnabled} embedded />
<GtTopAlertsStrip
layerEnabled={layerEnabled}
onFlyTo={onFlyTo}
onSelectEntity={onSelectEntity}
embedded
/>
</div>
)}
</div>
);
}
+453
View File
@@ -0,0 +1,453 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { CheckCircle2, Minus, Plus, Radar, RefreshCw, XCircle } from 'lucide-react';
import { API_BASE } from '@/lib/api';
import { useTranslation } from '@/i18n';
import type { GtBacktestReport, GtMicroRollingReport, GtRollingReport } from '@/types/dashboard';
interface Props {
layerEnabled?: boolean;
embedded?: boolean;
}
type TabId = 'benchmark' | 'operational';
function pct(value: number | undefined): string {
if (value == null || Number.isNaN(value)) return '—';
return `${(value * 100).toFixed(1)}%`;
}
export default function GtBacktestPanel({ layerEnabled = false, embedded = false }: Props) {
const { t } = useTranslation();
const [isMinimized, setIsMinimized] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>('operational');
const [benchmark, setBenchmark] = useState<GtBacktestReport | null>(null);
const [rolling, setRolling] = useState<GtRollingReport | null>(null);
const [micro, setMicro] = useState<GtMicroRollingReport | null>(null);
const [loadingBenchmark, setLoadingBenchmark] = useState(false);
const [loadingRolling, setLoadingRolling] = useState(false);
const [loadingMicro, setLoadingMicro] = useState(false);
const [showFailures, setShowFailures] = useState(false);
const refreshBenchmark = useCallback(async () => {
if (!layerEnabled) {
setBenchmark(null);
return;
}
setLoadingBenchmark(true);
try {
const res = await fetch(`${API_BASE}/api/analytics/backtest?expanded=true&tune=false`);
if (res.ok) setBenchmark(await res.json());
} catch {
/* non-fatal */
} finally {
setLoadingBenchmark(false);
}
}, [layerEnabled]);
const refreshRolling = useCallback(async () => {
if (!layerEnabled) {
setRolling(null);
return;
}
setLoadingRolling(true);
try {
const res = await fetch(`${API_BASE}/api/analytics/rolling?weeks=8`);
if (res.ok) setRolling(await res.json());
} catch {
/* non-fatal */
} finally {
setLoadingRolling(false);
}
}, [layerEnabled]);
const refreshMicro = useCallback(async () => {
if (!layerEnabled) {
setMicro(null);
return;
}
setLoadingMicro(true);
try {
const res = await fetch(`${API_BASE}/api/analytics/rolling/micro?window_days=3&limit=6`);
if (res.ok) setMicro(await res.json());
} catch {
/* non-fatal */
} finally {
setLoadingMicro(false);
}
}, [layerEnabled]);
const refresh = useCallback(async () => {
await Promise.all([refreshBenchmark(), refreshRolling(), refreshMicro()]);
}, [refreshBenchmark, refreshRolling, refreshMicro]);
useEffect(() => {
refresh();
if (!layerEnabled) return undefined;
const id = setInterval(refresh, 15 * 60_000);
return () => clearInterval(id);
}, [refresh, layerEnabled]);
const failures = (benchmark?.cases || []).filter((row) => !row.correct);
const operationalScorable = Boolean(
rolling && ((rolling.weeks_scorable ?? 0) > 0 || rolling.latest?.scorable),
);
const benchmarkPass = benchmark?.meets_target;
const rollingPass = rolling?.meets_target;
const passBadge =
activeTab === 'benchmark'
? benchmarkPass
: operationalScorable
? rollingPass
: undefined;
const showCollectingBadge =
activeTab === 'operational' && layerEnabled && rolling?.enabled && !operationalScorable;
const loading =
activeTab === 'benchmark'
? loadingBenchmark
: loadingRolling || loadingMicro;
const latest = rolling?.latest;
const microRegions = micro?.ignitions?.length
? micro.ignitions
: (micro?.top_regions || []).slice(0, 4);
const shellClass = embedded
? 'pointer-events-auto flex-shrink-0 border-b border-amber-800/30 bg-black/70'
: 'pointer-events-auto flex-shrink-0 border border-amber-700/40 bg-black/75 backdrop-blur-sm shadow-[0_0_18px_rgba(245,158,11,0.10)]';
return (
<div className={shellClass}>
<div
className="flex items-center justify-between border-b border-amber-700/30 bg-amber-950/20 px-3 py-2.5 cursor-pointer hover:bg-amber-950/40 transition-colors"
onClick={() => setIsMinimized((prev) => !prev)}
>
<div className="flex items-center gap-2">
<Radar size={16} className="text-amber-400" />
<span className="text-[12px] font-mono font-bold tracking-widest text-amber-400">
{t('gtBacktest.title').toUpperCase()}
</span>
{showCollectingBadge && (
<span className="text-[11px] font-mono px-1.5 py-0.5 tracking-wider border bg-amber-900/25 border-amber-700/40 text-amber-300">
{t('gtBacktest.collecting')}
</span>
)}
{layerEnabled && passBadge != null && (
<span
className={`text-[11px] font-mono px-1.5 py-0.5 tracking-wider border ${
passBadge
? 'bg-emerald-900/30 border-emerald-700/40 text-emerald-300'
: 'bg-red-900/30 border-red-700/40 text-red-300'
}`}
>
{passBadge ? t('gtBacktest.pass') : t('gtBacktest.fail')}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
refresh();
}}
title={t('gtBacktest.refresh')}
className="text-amber-600 transition-colors hover:text-amber-400 p-0.5"
>
<RefreshCw size={11} className={loading ? 'animate-spin' : ''} />
</button>
{isMinimized ? (
<Plus size={16} className="text-amber-400" />
) : (
<Minus size={16} className="text-amber-400" />
)}
</div>
</div>
{!isMinimized && (
<div className="px-3 py-2 max-h-60 overflow-y-auto styled-scrollbar space-y-2">
{!layerEnabled ? (
<div className="text-[11px] font-mono tracking-wider text-amber-600/70 py-1">
{t('gtBacktest.layerOff')}
</div>
) : (
<>
<div className="flex gap-1">
{(['operational', 'benchmark'] as TabId[]).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`text-[10px] font-mono tracking-widest px-2 py-0.5 border transition-colors ${
activeTab === tab
? 'border-amber-500/60 bg-amber-900/30 text-amber-200'
: 'border-amber-800/30 text-amber-600/80 hover:text-amber-400'
}`}
>
{tab === 'benchmark'
? t('gtBacktest.tabBenchmark')
: t('gtBacktest.tabOperational')}
</button>
))}
</div>
{activeTab === 'benchmark' ? (
!benchmark?.enabled ? (
<div className="text-[11px] font-mono tracking-wider text-amber-600/70 py-1">
{t('gtBacktest.disabled')}
</div>
) : loadingBenchmark && !benchmark.accuracy ? (
<div className="text-[11px] font-mono tracking-wider text-amber-500/80 py-1">
{t('gtBacktest.loading')}
</div>
) : (
<>
<div className="text-[10px] font-mono tracking-wider text-amber-600/60">
{t('gtBacktest.benchmarkNote')}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="border border-amber-800/30 bg-amber-950/15 px-2 py-1.5">
<div className="text-[10px] font-mono tracking-widest text-amber-600/80">
{t('gtBacktest.accuracy')}
</div>
<div className="text-[13px] font-mono font-bold text-amber-200">
{pct(benchmark.accuracy)}
</div>
</div>
<div className="border border-amber-800/30 bg-amber-950/15 px-2 py-1.5">
<div className="text-[10px] font-mono tracking-widest text-amber-600/80">
{t('gtBacktest.confidence')}
</div>
<div className="text-[13px] font-mono font-bold text-amber-200">
{pct(benchmark.confidence_rate)}
</div>
</div>
</div>
<div className="text-[10px] font-mono tracking-wider text-amber-600/70 leading-relaxed">
{t('gtBacktest.cases').replace('{count}', String(benchmark.total_cases))} ·{' '}
{t('gtBacktest.threshold').replace('{value}', benchmark.alert_threshold.toFixed(2))} ·{' '}
{t('gtBacktest.target').replace('{value}', pct(benchmark.target_confidence))}
</div>
<div className="flex flex-wrap gap-2 text-[10px] font-mono tracking-wider">
<span className="text-emerald-400">TP {benchmark.true_positives}</span>
<span className="text-emerald-400">TN {benchmark.true_negatives}</span>
<span className="text-red-400">FP {benchmark.false_positives}</span>
<span className="text-red-400">FN {benchmark.false_negatives}</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] font-mono tracking-wider text-amber-500/90">
{benchmark.meets_target ? (
<CheckCircle2 size={12} className="text-emerald-400 shrink-0" />
) : (
<XCircle size={12} className="text-red-400 shrink-0" />
)}
<span>
{benchmark.meets_target
? t('gtBacktest.meetsTarget')
: t('gtBacktest.belowTarget')}
</span>
</div>
{failures.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowFailures((prev) => !prev)}
className="text-[10px] font-mono tracking-widest text-red-400 hover:text-red-300"
>
{showFailures ? '' : '+'} {t('gtBacktest.misclassified').replace('{count}', String(failures.length))}
</button>
{showFailures && (
<div className="mt-1 space-y-1">
{failures.map((row) => (
<div
key={row.case_id}
className="border border-red-800/30 bg-red-950/15 px-2 py-1 text-[10px] font-mono text-red-200/90"
>
{row.name} ({row.kind})
</div>
))}
</div>
)}
</div>
)}
</>
)
) : !rolling?.enabled && !micro?.enabled ? (
<div className="text-[11px] font-mono tracking-wider text-amber-600/70 py-1">
{t('gtBacktest.disabled')}
</div>
) : (loadingRolling || loadingMicro) && !rolling?.latest && !micro?.regions_tracked ? (
<div className="text-[11px] font-mono tracking-wider text-amber-500/80 py-1">
{t('gtBacktest.operationalLoading')}
</div>
) : (
<>
<div className="border border-amber-800/25 bg-amber-950/10 px-2 py-1.5 space-y-1">
<div className="text-[10px] font-mono tracking-widest text-amber-500/90">
{t('gtBacktest.microTitle').toUpperCase()}
</div>
{micro?.enabled ? (
<>
<div className="text-[10px] font-mono tracking-wider text-amber-600/75">
{t('gtBacktest.microWindow')
.replace('{days}', String(micro.window_days))
.replace('{delta}', micro.ignition_delta.toFixed(2))}
</div>
<div className="flex flex-wrap gap-2 text-[10px] font-mono tracking-wider">
<span className="text-orange-300">
{t('gtBacktest.microIgnitions').replace(
'{count}',
String(micro.ignition_count)
)}
</span>
<span className="text-amber-300/90">
{t('gtBacktest.microAlerted3d').replace(
'{count}',
String(micro.alerted_3d_count)
)}
</span>
</div>
{microRegions.length > 0 ? (
<div className="space-y-0.5">
{microRegions.map((row) => (
<div
key={row.region}
className="text-[10px] font-mono text-amber-200/85 flex items-center gap-1.5"
>
{row.ignition && (
<span className="text-orange-400 border border-orange-700/40 px-1 text-[9px]">
{t('gtBacktest.microIgnitionBadge')}
</span>
)}
<span>
{t('gtBacktest.microRegionLine')
.replace('{region}', row.region)
.replace('{spot}', pct(row.spot_risk))
.replace('{avg}', pct(row.risk_3d_avg))
.replace('{delta}', pct(row.risk_delta))}
</span>
</div>
))}
</div>
) : (
<div className="text-[10px] font-mono tracking-wider text-amber-600/65">
{t('gtBacktest.microEmpty')}
</div>
)}
</>
) : (
<div className="text-[10px] font-mono tracking-wider text-amber-600/65">
{t('gtBacktest.microEmpty')}
</div>
)}
</div>
<div className="text-[10px] font-mono tracking-widest text-amber-600/80 pt-1">
{t('gtBacktest.tabOperational').toUpperCase()} {t('gtBacktest.operationalTrend')}
</div>
{!rolling || rolling.weeks_stored === 0 ? (
<div className="text-[10px] font-mono tracking-wider text-amber-600/70 py-1">
{t('gtBacktest.operationalEmpty')}
</div>
) : (
<>
<div className="grid grid-cols-2 gap-2">
<div className="border border-amber-800/30 bg-amber-950/15 px-2 py-1.5">
<div className="text-[10px] font-mono tracking-widest text-amber-600/80">
{t('gtBacktest.accuracy')}
</div>
<div className="text-[13px] font-mono font-bold text-amber-200">
{latest?.scorable ? pct(latest.accuracy) : '—'}
</div>
</div>
<div className="border border-amber-800/30 bg-amber-950/15 px-2 py-1.5">
<div className="text-[10px] font-mono tracking-widest text-amber-600/80">
{t('gtBacktest.confidence')}
</div>
<div className="text-[13px] font-mono font-bold text-amber-200">
{latest?.scorable ? pct(latest.confidence_rate) : '—'}
</div>
</div>
</div>
<div className="text-[10px] font-mono tracking-wider text-amber-600/70 leading-relaxed">
{t('gtBacktest.operationalWeeks')
.replace('{stored}', String(rolling.weeks_stored))
.replace('{scorable}', String(rolling.weeks_scorable))}
{latest
? ` · ${t('gtBacktest.operationalLabeled')
.replace('{labeled}', String(latest.labeled))
.replace('{pending}', String(latest.pending))}`
: ''}
</div>
{latest && !latest.scorable && (
<div className="text-[10px] font-mono tracking-wider text-amber-500/80">
{t('gtBacktest.operationalMinLabels').replace(
'{count}',
String(rolling.min_labeled_per_week)
)}
</div>
)}
{latest?.scorable && (
<div className="flex flex-wrap gap-2 text-[10px] font-mono tracking-wider">
<span className="text-emerald-400">TP {latest.true_positives}</span>
<span className="text-emerald-400">TN {latest.true_negatives}</span>
<span className="text-red-400">FP {latest.false_positives}</span>
<span className="text-red-400">FN {latest.false_negatives}</span>
</div>
)}
{(rolling.accuracy_series?.length ?? 0) > 0 && (
<div>
<div className="text-[10px] font-mono tracking-widest text-amber-600/80 mb-1">
{t('gtBacktest.operationalTrend')}
</div>
<div className="flex flex-wrap gap-1.5">
{rolling.accuracy_series.map((point) => (
<span
key={point.week_id}
className="text-[10px] font-mono border border-amber-800/30 bg-amber-950/20 px-1.5 py-0.5 text-amber-200/90"
title={`${point.labeled} labeled`}
>
{point.week_id.replace('-W', 'w')}: {pct(point.accuracy)}
</span>
))}
</div>
</div>
)}
{latest?.scorable && (
<div className="flex items-center gap-1.5 text-[10px] font-mono tracking-wider text-amber-500/90">
{rolling.meets_target ? (
<CheckCircle2 size={12} className="text-emerald-400 shrink-0" />
) : (
<XCircle size={12} className="text-red-400 shrink-0" />
)}
<span>
{rolling.improving_vs_prior
? t('gtBacktest.operationalImproving')
: t('gtBacktest.operationalFlat')}
{' · '}
{rolling.meets_target
? t('gtBacktest.meetsTarget')
: t('gtBacktest.belowTarget')}
</span>
</div>
)}
</>
)}
</>
)}
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,121 @@
'use client';
import React, { useMemo } from 'react';
import { ChevronRight, Radar } from 'lucide-react';
import { useTranslation } from '@/i18n';
import { useDataKey } from '@/hooks/useDataStore';
import { extractGtAlerts } from '@/lib/gtAlerts';
import type { SelectedEntity } from '@/types/dashboard';
interface Props {
layerEnabled?: boolean;
onFlyTo?: (lat: number, lng: number) => void;
onSelectEntity?: (entity: SelectedEntity | null) => void;
embedded?: boolean;
}
function pct(value: number): string {
return `${(value * 100).toFixed(0)}%`;
}
export default function GtTopAlertsStrip({
layerEnabled = false,
onFlyTo,
onSelectEntity,
embedded = false,
}: Props) {
const { t } = useTranslation();
const gtRisk = useDataKey('gt_risk');
const { alerts, trackedRegions, plottedRegions, maxRegions } = useMemo(
() => extractGtAlerts(gtRisk, 8),
[gtRisk],
);
if (!layerEnabled || !gtRisk?.enabled) return null;
const handleSelect = (alert: (typeof alerts)[number]) => {
onFlyTo?.(alert.lat, alert.lng);
onSelectEntity?.({
id: alert.region,
type: 'gt_risk',
name: alert.regionLabel,
extra: {
region: alert.region,
risk: alert.risk,
financial: alert.financial,
unrest: alert.unrest,
conflict: alert.conflict,
contagion: alert.contagion,
lat: alert.lat,
lng: alert.lng,
risk_spot: alert.risk,
risk_3d_avg: alert.risk3d,
risk_delta: alert.riskDelta,
micro_ignition: alert.ignition,
},
});
};
const shellClass = embedded
? 'pointer-events-auto border-t border-amber-800/30 bg-black/70'
: 'pointer-events-auto max-w-[min(92vw,52rem)] border border-amber-700/45 bg-black/80 backdrop-blur-sm shadow-[0_0_16px_rgba(245,158,11,0.12)]';
return (
<div className={shellClass}>
<div className="flex items-center gap-2 border-b border-amber-800/35 bg-amber-950/25 px-2.5 py-1.5">
<Radar size={12} className="text-amber-400 shrink-0" />
<span className="text-[10px] font-mono font-bold tracking-widest text-amber-300">
{t('gtAlerts.title')}
</span>
<span className="text-[9px] font-mono tracking-wider text-amber-600/80">
{t('gtAlerts.counts')
.replace('{plotted}', String(plottedRegions))
.replace('{tracked}', String(trackedRegions))
.replace('{max}', String(maxRegions))}
</span>
</div>
{alerts.length === 0 ? (
<div className="px-2.5 py-2 text-[10px] font-mono tracking-wider text-amber-600/70">
{t('gtAlerts.empty')}
</div>
) : (
<div className="flex items-stretch gap-1 overflow-x-auto styled-scrollbar px-2 py-1.5">
{alerts.map((alert) => (
<button
key={alert.region}
type="button"
onClick={() => handleSelect(alert)}
className="group flex min-w-[9.5rem] shrink-0 flex-col gap-0.5 border border-amber-800/35 bg-amber-950/20 px-2 py-1 text-left transition-colors hover:border-amber-600/50 hover:bg-amber-900/25"
>
<div className="flex items-center gap-1">
<span className="truncate text-[10px] font-mono font-bold uppercase text-amber-100">
{alert.regionLabel}
</span>
{alert.ignition && (
<span className="shrink-0 border border-orange-700/50 px-1 text-[8px] font-mono text-orange-300">
{t('gtAlerts.ignition')}
</span>
)}
<ChevronRight
size={10}
className="ml-auto shrink-0 text-amber-600/60 group-hover:text-amber-400"
/>
</div>
<div className="text-[9px] font-mono tracking-wider text-amber-500/90">
{t('gtAlerts.line')
.replace('{risk}', pct(alert.risk))
.replace('{conflict}', pct(alert.conflict))}
</div>
</button>
))}
</div>
)}
<div className="border-t border-amber-900/30 px-2.5 py-1 text-[9px] font-mono leading-relaxed text-amber-600/65">
{t('gtAlerts.hint')}
</div>
</div>
);
}
+81 -1
View File
@@ -185,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 { GtRiskPopup } from '@/components/MaplibreViewer/popups/GtRiskPopup';
import { TelegramOsintPopup } from '@/components/MaplibreViewer/popups/TelegramOsintPopup';
import {
buildSentinelTileUrl,
@@ -196,6 +197,7 @@ import {
buildEarthquakesGeoJSON,
buildJammingGeoJSON,
buildCorrelationsGeoJSON,
buildGtRiskGeoJSON,
buildTinygsGeoJSON,
buildShodanGeoJSON,
buildAIIntelGeoJSON,
@@ -306,6 +308,7 @@ const MAP_EXTRA_DATA_KEYS = [
'crowdthreat',
'malware_threats',
'telegram_osint',
'gt_risk',
'datacenters',
'firms_fires',
'fishing_activity',
@@ -778,6 +781,11 @@ const MaplibreViewer = ({
[activeLayers.correlations, activeLayers.contradictions, data?.correlations],
);
const gtRiskGeoJSON = useMemo(
() => (activeLayers.gt_risk ? buildGtRiskGeoJSON(data?.gt_risk) : null),
[activeLayers.gt_risk, data?.gt_risk],
);
const tinygsGeoJSON = useMemo(
() => {
void interpTick;
@@ -1724,6 +1732,7 @@ const MaplibreViewer = ({
correlationsGeoJSON && 'corr-infra-fill',
correlationsGeoJSON && 'corr-contra-fill',
correlationsGeoJSON && 'corr-analysis-fill',
gtRiskGeoJSON && 'gt-risk-heatmap',
].filter(Boolean) as string[];
useEffect(() => {
@@ -1820,7 +1829,7 @@ const MaplibreViewer = ({
return (
<div
className={`relative h-full w-full z-0 isolate ${selectedEntity && ['region_dossier', 'gdelt', 'liveuamap', 'news', 'telegram_osint'].includes(selectedEntity.type) ? 'map-focus-active' : ''}`}
className={`relative h-full w-full z-0 isolate ${selectedEntity && ['region_dossier', 'gdelt', 'liveuamap', 'news', 'telegram_osint', 'gt_risk'].includes(selectedEntity.type) ? 'map-focus-active' : ''}`}
style={pinPlacementMode || sarAoiDropMode ? { cursor: 'crosshair' } : undefined}
>
<Map
@@ -2226,6 +2235,55 @@ const MaplibreViewer = ({
/>
</Source>
{/* Strategic Risk Heatmap — Bayesian posterior scores */}
<Source id="gt-risk-source" type="geojson" data={(gtRiskGeoJSON ?? EMPTY_FC)}>
<Layer
id="gt-risk-heatmap"
type="circle"
minzoom={2}
paint={{
'circle-radius': [
'interpolate',
['linear'],
['zoom'],
2,
['+', 6, ['*', 14, ['get', 'risk']]],
6,
['+', 10, ['*', 28, ['get', 'risk']]],
10,
['+', 14, ['*', 40, ['get', 'risk']]],
],
'circle-color': [
'interpolate',
['linear'],
['get', 'risk'],
0.15,
'#22c55e',
0.35,
'#84cc16',
0.5,
'#eab308',
0.65,
'#f97316',
0.8,
'#ef4444',
],
'circle-opacity': [
'interpolate',
['linear'],
['get', 'risk'],
0.15,
0.22,
0.8,
0.72,
],
'circle-stroke-width': 1,
'circle-stroke-color': '#fbbf24',
'circle-stroke-opacity': 0.35,
}}
/>
</Source>
{/* Correlation Alerts — Emergent Intelligence grid squares */}
<Source id="correlations" type="geojson" data={(correlationsGeoJSON ?? EMPTY_FC)}>
{/* RF Anomaly — grey */}
@@ -5712,6 +5770,28 @@ const MaplibreViewer = ({
return <FishingDestinationRoute vesselLat={event.lat} vesselLng={event.lng} destination={dest} />;
})()}
{(() => {
if (selectedEntity?.type !== 'gt_risk' || !selectedEntity.extra) return null;
const props = selectedEntity.extra as Record<string, unknown>;
const lat = Number(props.lat);
const lng = Number(props.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return (
<GtRiskPopup
region={String(props.region || props.name || selectedEntity.id)}
risk={Number(props.risk ?? 0)}
financial={Number(props.financial ?? 0)}
unrest={Number(props.unrest ?? 0)}
conflict={Number(props.conflict ?? 0)}
contagion={Number(props.contagion ?? 0)}
interpretation={String(props.interpretation || '')}
lat={lat}
lng={lng}
onClose={() => onEntityClick?.(null)}
/>
);
})()}
{(() => {
if (selectedEntity?.type !== 'telegram_osint' || !data?.telegram_osint?.posts) return null;
const allPosts = data.telegram_osint.posts;
@@ -0,0 +1,188 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Popup } from 'react-map-gl/maplibre';
import { Radar } from 'lucide-react';
import { useTranslation } from '@/i18n';
import { API_BASE } from '@/lib/api';
import { formatGtRegionLabel } from '@/lib/gtAlerts';
import type { GtDossier } from '@/types/dashboard';
export interface GtRiskPopupProps {
region: string;
risk: number;
financial?: number;
unrest?: number;
conflict?: number;
contagion?: number;
interpretation?: string;
lat: number;
lng: number;
onClose: () => void;
}
function riskColor(score: number): string {
if (score >= 0.6) return '#ef4444';
if (score >= 0.4) return '#f97316';
if (score >= 0.25) return '#eab308';
return '#22c55e';
}
function formatSignalName(name: string): string {
return name.replace(/_/g, ' ');
}
async function fetchDossier(region: string, lat: number, lng: number): Promise<GtDossier | null> {
const candidates = [
region.trim().toLowerCase(),
`${lat.toFixed(2)},${lng.toFixed(2)}`,
].filter((value, index, list) => value && list.indexOf(value) === index);
let best: GtDossier | null = null;
for (const key of candidates) {
try {
const response = await fetch(`${API_BASE}/api/analytics/dossier/${encodeURIComponent(key)}`);
if (!response.ok) continue;
const payload = (await response.json()) as GtDossier;
if (!payload.enabled) continue;
if (!best || (payload.current_risk ?? 0) >= (best.current_risk ?? 0)) {
best = payload;
}
} catch {
/* optional analytics */
}
}
return best;
}
export function GtRiskPopup({
region,
risk,
financial,
unrest,
conflict,
contagion,
interpretation,
lat,
lng,
onClose,
}: GtRiskPopupProps) {
const { t } = useTranslation();
const color = riskColor(risk);
const [dossier, setDossier] = useState<GtDossier | null>(null);
const [loadingSignals, setLoadingSignals] = useState(true);
useEffect(() => {
let cancelled = false;
setLoadingSignals(true);
void fetchDossier(region, lat, lng).then((result) => {
if (!cancelled) {
setDossier(result);
setLoadingSignals(false);
}
});
return () => {
cancelled = true;
};
}, [region, lat, lng]);
const resolvedInterpretation = interpretation || dossier?.interpretation || '';
const signals = dossier?.recent_signals || [];
return (
<Popup
longitude={lng}
latitude={lat}
closeButton={false}
closeOnClick={false}
onClose={onClose}
className="threat-popup"
maxWidth="360px"
>
<div className="bg-black/95 border border-amber-700/50 rounded-lg overflow-hidden font-mono text-[11px]">
<div className="px-3 py-2 border-b border-amber-800/40 bg-amber-950/40 flex items-center gap-2">
<Radar size={14} className="text-amber-400" />
<span className="text-amber-300 font-bold tracking-widest text-[10px]">
{t('gtRisk.popupTitle')}
</span>
<button
type="button"
onClick={onClose}
className="ml-auto text-[var(--text-muted)] hover:text-white"
>
</button>
</div>
<div className="p-3 flex flex-col gap-2 max-h-72 overflow-y-auto styled-scrollbar">
<div className="flex justify-between items-center">
<span className="text-[var(--text-muted)]">{t('gtRisk.region')}</span>
<span className="text-white font-bold uppercase">{formatGtRegionLabel(region)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--text-muted)]">{t('gtRisk.composite')}</span>
<span className="font-bold" style={{ color }}>
{(risk * 100).toFixed(1)}%
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-[10px]">
<div>
<div className="text-[var(--text-muted)]">{t('gtRisk.financial')}</div>
<div className="text-cyan-300">{((financial ?? 0) * 100).toFixed(0)}%</div>
</div>
<div>
<div className="text-[var(--text-muted)]">{t('gtRisk.unrest')}</div>
<div className="text-orange-300">{((unrest ?? 0) * 100).toFixed(0)}%</div>
</div>
<div>
<div className="text-[var(--text-muted)]">{t('gtRisk.conflict')}</div>
<div className="text-red-300">{((conflict ?? 0) * 100).toFixed(0)}%</div>
</div>
</div>
{contagion != null && contagion > 0 && (
<div className="flex justify-between">
<span className="text-[var(--text-muted)]">{t('gtRisk.contagion')}</span>
<span className="text-purple-300">{(contagion * 100).toFixed(1)}%</span>
</div>
)}
{resolvedInterpretation && (
<p className="text-[var(--text-secondary)] leading-relaxed border-t border-amber-900/40 pt-2">
<span className="text-amber-400 font-bold">&gt;_ </span>
{resolvedInterpretation}
</p>
)}
<div className="border-t border-amber-900/40 pt-2">
<div className="text-[10px] tracking-widest text-amber-500/90 font-bold mb-1.5">
{t('gtRisk.costlySignals')}
</div>
{loadingSignals ? (
<div className="text-[10px] text-amber-600/80">{t('gtRisk.loadingSignals')}</div>
) : signals.length > 0 ? (
<div className="space-y-1.5">
{signals.slice(-4).reverse().map((entry, idx) => (
<div
key={`${entry.timestamp}-${idx}`}
className="border-l-2 border-amber-700/60 pl-2 text-[10px] text-[var(--text-secondary)]"
>
<div className="text-amber-300 uppercase">
{Object.keys(entry.signals || {})
.map(formatSignalName)
.join(', ') || entry.domain}
</div>
<div className="text-[var(--text-muted)] truncate" title={entry.source}>
{entry.source || t('gtRisk.unknownSource')}
</div>
</div>
))}
</div>
) : (
<div className="text-[10px] text-amber-600/75 leading-relaxed">
{t('gtRisk.noSignals')}
</div>
)}
</div>
</div>
</div>
</Popup>
);
}
@@ -1,6 +1,6 @@
'use client';
import React, { useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Popup } from 'react-map-gl/maplibre';
import { Radio } from 'lucide-react';
import { useTranslation } from '@/i18n';
@@ -69,11 +69,58 @@ function riskTheme(rs: number) {
};
}
function postHeadline(post: TelegramOsintPost): string {
return String(post.title || post.description || 'Telegram intercept').trim();
const CYRILLIC_RE = /[\u0400-\u04FF]/;
function containsCyrillic(text: string): boolean {
return CYRILLIC_RE.test(text);
}
function postDetail(post: TelegramOsintPost): string | null {
function sourceLangLabel(post: TelegramOsintPost): string {
if (post.source_lang_label) return post.source_lang_label;
const code = String(post.source_lang || '').trim().toLowerCase();
const labels: Record<string, string> = {
uk: 'Ukrainian',
ru: 'Russian',
en: 'English',
ar: 'Arabic',
he: 'Hebrew',
'zh-cn': 'Chinese',
fr: 'French',
de: 'German',
pl: 'Polish',
};
return labels[code] || code.toUpperCase();
}
function hasTranslation(post: TelegramOsintPost): boolean {
const translated = String(post.title_translated || post.description_translated || '').trim();
const original = String(post.title || post.description || '').trim();
return Boolean(translated && translated !== original);
}
function postHeadline(post: TelegramOsintPost, showOriginal: boolean): string {
const original = String(post.title || post.description || 'Telegram intercept').trim();
const translated = String(post.title_translated || post.description_translated || '').trim();
if (!showOriginal && translated) {
return translated.split('\n', 1)[0].trim();
}
if (!showOriginal && containsCyrillic(original) && translated) {
return translated.split('\n', 1)[0].trim();
}
return original;
}
function postDetail(post: TelegramOsintPost, showOriginal: boolean): string | null {
if (!showOriginal && post.description_translated) {
const translatedTitle = String(post.title_translated || '').trim();
const translatedBody = String(post.description_translated || '').trim();
if (!translatedBody || translatedBody === translatedTitle) return null;
const extra = translatedBody.startsWith(translatedTitle)
? translatedBody.slice(translatedTitle.length).trim()
: translatedBody;
return extra || null;
}
const title = String(post.title || '').trim();
const description = String(post.description || '').trim();
if (!description || description === title || description.startsWith(title)) return null;
@@ -126,10 +173,12 @@ function TelegramPostMedia({ post }: { post: TelegramOsintPost }) {
function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
const { t } = useTranslation();
const [showOriginal, setShowOriginal] = useState(false);
const rs = post.risk_score ?? 1;
const theme = riskTheme(rs);
const headline = postHeadline(post);
const detail = postDetail(post);
const translated = hasTranslation(post);
const headline = postHeadline(post, showOriginal);
const detail = postDetail(post, showOriginal);
const isHigh = rs >= 8;
return (
@@ -150,12 +199,29 @@ function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
<p className="text-[11px] text-[var(--text-muted)] leading-relaxed whitespace-pre-wrap">{detail}</p>
) : null}
{translated && !showOriginal && post.source_lang ? (
<p className="text-[10px] text-cyan-700/80 uppercase tracking-wider">
{t('telegram.translatedFrom').replace('{lang}', sourceLangLabel(post))}
</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>
{translated ? (
<button
type="button"
onClick={() => setShowOriginal((prev) => !prev)}
className="text-[11px] font-mono text-cyan-600 hover:text-cyan-300 transition-colors"
>
{showOriginal
? t('telegram.showTranslation')
: t('telegram.showOriginal').replace('{lang}', sourceLangLabel(post))}
</button>
) : null}
{post.link ? (
<a
href={post.link}
@@ -172,15 +238,49 @@ function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
}
export function TelegramOsintPopup({ posts, lat, lng, onClose }: TelegramOsintPopupProps) {
const { t } = useTranslation();
const { t, locale } = useTranslation();
const [localizedPosts, setLocalizedPosts] = useState(posts);
useEffect(() => {
setLocalizedPosts(posts);
}, [posts]);
useEffect(() => {
const needsLocalizedFeed = posts.some((post) => !hasTranslation(post));
if (!needsLocalizedFeed) {
return;
}
let cancelled = false;
const controller = new AbortController();
fetch(`/api/telegram-feed?lang=${encodeURIComponent(locale)}`, { signal: controller.signal })
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (cancelled || !payload?.posts) return;
const byId = new Map(
(payload.posts as TelegramOsintPost[]).map((post) => [post.id, post]),
);
setLocalizedPosts(posts.map((post) => byId.get(post.id) || post));
})
.catch(() => {
/* keep feed posts when locale translation fetch fails */
});
return () => {
cancelled = true;
controller.abort();
};
}, [locale, posts]);
const sortedPosts = useMemo(
() =>
[...posts].sort(
[...localizedPosts].sort(
(a, b) =>
(b.risk_score ?? 0) - (a.risk_score ?? 0) ||
String(b.published || '').localeCompare(String(a.published || '')),
),
[posts],
[localizedPosts],
);
const maxRisk = sortedPosts[0]?.risk_score ?? 1;
@@ -252,4 +352,4 @@ export function TelegramOsintPopup({ posts, lat, lng, onClose }: TelegramOsintPo
</div>
</Popup>
);
}
}
+79 -1
View File
@@ -321,7 +321,7 @@ function EmissionsEstimateBlock({ flight }: { flight: any }) {
);
}
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 }) {
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, gtDossier, gtDossierLoading, onArticleClick, onExpandEntityGraph }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, gtDossier?: import('@/types/dashboard').GtDossier | null, gtDossierLoading?: 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',
@@ -535,6 +535,84 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
)}
{/* Sentinel-2 imagery now shown as map popup — see MaplibreViewer */}
{(gtDossierLoading || gtDossier?.enabled) && (
<>
<div className="text-[11px] text-amber-500 tracking-widest font-bold border-b border-amber-900/50 pb-1 mt-2">
STRATEGIC RISK (GT)
</div>
{gtDossierLoading ? (
<div className="text-amber-400/80 text-[11px]">Running game-theoretic analysis...</div>
) : gtDossier ? (
<div className="flex flex-col gap-2">
<div className="flex justify-between">
<span className="text-[var(--text-muted)]">POSTERIOR RISK</span>
<span className="text-amber-300 font-bold">
{((gtDossier.current_risk ?? 0) * 100).toFixed(1)}%
</span>
</div>
{gtDossier.domain_risks && (
<div className="grid grid-cols-3 gap-2 text-[10px]">
<div>
<div className="text-[var(--text-muted)]">FIN</div>
<div className="text-cyan-300">
{((gtDossier.domain_risks.financial ?? 0) * 100).toFixed(0)}%
</div>
</div>
<div>
<div className="text-[var(--text-muted)]">UNREST</div>
<div className="text-orange-300">
{((gtDossier.domain_risks.unrest ?? 0) * 100).toFixed(0)}%
</div>
</div>
<div>
<div className="text-[var(--text-muted)]">CONFLICT</div>
<div className="text-red-300">
{((gtDossier.domain_risks.conflict ?? 0) * 100).toFixed(0)}%
</div>
</div>
</div>
)}
{gtDossier.interpretation && (
<div className="p-2 bg-black/60 border border-amber-800/50 text-[11px] text-amber-200/90 leading-relaxed">
<span className="text-amber-400 font-bold">&gt;_ GT: </span>
{gtDossier.interpretation}
</div>
)}
{gtDossier.recent_signals && gtDossier.recent_signals.length > 0 && (
<div className="flex flex-col gap-1">
<div className="text-[10px] text-[var(--text-muted)] tracking-widest">
COSTLY SIGNALS
</div>
{gtDossier.recent_signals.slice(-3).map((entry, idx) => (
<div
key={`${entry.timestamp}-${idx}`}
className="text-[10px] border-l-2 border-amber-700/60 pl-2 text-[var(--text-secondary)]"
>
<span className="text-amber-300 uppercase">
{Object.keys(entry.signals || {}).join(', ') || entry.domain}
</span>
{' · '}
<span className="text-[var(--text-muted)]">{entry.source}</span>
</div>
))}
</div>
)}
{gtDossier.scenarios && gtDossier.scenarios.length > 0 && (
<div className="flex flex-col gap-1">
<div className="text-[10px] text-[var(--text-muted)] tracking-widest">SCENARIOS</div>
{gtDossier.scenarios.map((scenario) => (
<div key={scenario.name} className="text-[10px] text-[var(--text-secondary)]">
<span className="text-amber-400 font-bold">{scenario.name}: </span>
{scenario.summary}
</div>
))}
</div>
)}
</div>
) : null}
</>
)}
</div>
) : d?.error ? (
<div className="p-4 text-[var(--text-secondary)] text-[12px]">{d.error}</div>
+53 -5
View File
@@ -55,6 +55,8 @@ import { useTheme } from '@/lib/ThemeContext';
import { useTranslation } from '@/i18n';
import SarModeChooserModal from './SarModeChooserModal';
import KiwiSdrConsentDialog from './ui/KiwiSdrConsentDialog';
import { extractGtAlerts } from '@/lib/gtAlerts';
import { gtLeanLayerWarning, useRuntimeProfile } from '@/hooks/useRuntimeProfile';
function relativeTime(iso: string | undefined): string {
if (!iso) return '';
@@ -115,6 +117,7 @@ const FRESHNESS_MAP: Record<string, string> = {
scm_suppliers: 'scm_suppliers',
cyber_threats: 'cyber_threats',
telegram_osint: 'telegram_osint',
gt_risk: 'gt_risk',
};
// POTUS fleet ICAO hex codes for client-side filtering
@@ -726,7 +729,11 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
const [liveuamapModalOpen, setLiveuamapModalOpen] = useState(false);
const [liveuamapPendingEnable, setLiveuamapPendingEnable] = useState<(() => void) | null>(null);
const [gtLeanModalOpen, setGtLeanModalOpen] = useState(false);
const [gtLeanPendingEnable, setGtLeanPendingEnable] = useState<(() => void) | null>(null);
const { needsConsentBeforeEnable, confirmOptIn } = useLiveUamapScraperOptIn();
const runtimeProfile = useRuntimeProfile();
const gtLeanWarning = gtLeanLayerWarning(runtimeProfile);
const withGlobalIncidentsConsent = useCallback(
(layerId: string, turningOn: boolean, apply: () => void) => {
@@ -740,6 +747,18 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
[needsConsentBeforeEnable],
);
const withGtRiskLeanWarning = useCallback(
(layerId: string, turningOn: boolean, apply: () => void) => {
if (layerId === 'gt_risk' && turningOn && gtLeanWarning) {
setGtLeanPendingEnable(() => apply);
setGtLeanModalOpen(true);
return;
}
apply();
},
[gtLeanWarning],
);
const isAllToggleableLayersOn = useMemo(
() =>
Object.entries(activeLayers)
@@ -1371,6 +1390,16 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
count: data?.correlations?.filter((c: { type: string }) => c.type === 'contradiction').length || 0,
icon: Zap,
},
{
id: 'gt_risk',
name: t('layers.derivedOsint'),
source: t('layers.derivedOsintSource'),
count:
extractGtAlerts(data?.gt_risk).plottedRegions ||
data?.gt_risk?.meta?.plotted_regions ||
0,
icon: Radar,
},
{
id: 'day_night',
name: t('layers.dayNight'),
@@ -1394,7 +1423,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
sections.forEach((s) => {
// 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),
['global_incidents', 'telegram_osint', 'ukraine_frontline', 'gt_risk'].includes(l.id),
);
});
return initial;
@@ -1746,10 +1775,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
return;
}
withGlobalIncidentsConsent(layer.id, !active, () => {
setActiveLayers((prev: ActiveLayers) => ({
...prev,
[layer.id]: !active,
}));
withGtRiskLeanWarning(layer.id, !active, () => {
setActiveLayers((prev: ActiveLayers) => ({
...prev,
[layer.id]: !active,
}));
});
});
}}
>
@@ -2081,6 +2112,23 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
})();
}}
/>
<ConfirmDialog
open={gtLeanModalOpen}
title={t('gtLean.title')}
message={gtLeanWarning || t('gtLean.message')}
confirmLabel={t('gtLean.confirm')}
cancelLabel={t('gtLean.cancel')}
danger={false}
onCancel={() => {
setGtLeanModalOpen(false);
setGtLeanPendingEnable(null);
}}
onConfirm={() => {
gtLeanPendingEnable?.();
setGtLeanModalOpen(false);
setGtLeanPendingEnable(null);
}}
/>
</>
);
});
@@ -1956,3 +1956,64 @@ export function buildSarAoisGeoJSON(aois?: SarAoi[]): FC {
if (features.length === 0) return null;
return { type: 'FeatureCollection' as const, features };
}
// ─── Strategic Risk Analytics (GT early warning) ────────────────────────────
export function buildGtRiskGeoJSON(
payload?: {
enabled?: boolean;
heatmap?: { features?: Array<GTRiskHeatmapFeatureLike> };
} | null,
): FC {
const features = payload?.heatmap?.features;
if (!features?.length) return null;
const normalized = features
.map((feature, index) => {
const coords = feature.geometry?.coordinates;
if (!coords || coords.length < 2) return null;
const [lng, lat] = coords;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (Math.abs(lat) < 0.001 && Math.abs(lng) < 0.001) return null;
const props = feature.properties || {};
const region = String(props.region || `region-${index}`);
return {
type: 'Feature' as const,
properties: {
...props,
type: 'gt_risk',
id: region,
name: region,
lat,
lng,
risk: Number(props.risk ?? 0),
financial: Number(props.financial ?? 0),
unrest: Number(props.unrest ?? 0),
conflict: Number(props.conflict ?? 0),
contagion: Number(props.contagion ?? 0),
},
geometry: {
type: 'Point' as const,
coordinates: [lng, lat] as [number, number],
},
};
})
.filter(Boolean) as GeoJSON.Feature[];
if (!normalized.length) return null;
return { type: 'FeatureCollection' as const, features: normalized };
}
type GTRiskHeatmapFeatureLike = {
properties?: {
region?: string;
risk?: number;
financial?: number;
unrest?: number;
conflict?: number;
contagion?: number;
};
geometry?: {
coordinates?: [number, number];
};
};
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
export interface FloatingPanelPosition {
x: number;
y: number;
}
interface StoredFloatingPanelState {
position?: FloatingPanelPosition;
isMinimized?: boolean;
}
interface UseFloatingPanelOptions {
defaultPosition?: FloatingPanelPosition;
minVisible?: number;
}
export function useFloatingPanel(
storageKey: string,
{ defaultPosition = { x: 24, y: 380 }, minVisible = 48 }: UseFloatingPanelOptions = {},
) {
const [position, setPosition] = useState<FloatingPanelPosition>(defaultPosition);
const [isMinimized, setIsMinimized] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef({ x: 0, y: 0, posX: 0, posY: 0 });
const hydratedRef = useRef(false);
useEffect(() => {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as StoredFloatingPanelState;
if (
parsed.position &&
Number.isFinite(parsed.position.x) &&
Number.isFinite(parsed.position.y)
) {
setPosition(parsed.position);
}
if (typeof parsed.isMinimized === 'boolean') {
setIsMinimized(parsed.isMinimized);
}
} catch {
/* non-fatal */
} finally {
hydratedRef.current = true;
}
}, [storageKey]);
useEffect(() => {
if (!hydratedRef.current) return;
try {
localStorage.setItem(
storageKey,
JSON.stringify({ position, isMinimized } satisfies StoredFloatingPanelState),
);
} catch {
/* non-fatal */
}
}, [storageKey, position, isMinimized]);
const clampPosition = useCallback(
(next: FloatingPanelPosition): FloatingPanelPosition => {
const maxX = Math.max(0, window.innerWidth - minVisible);
const maxY = Math.max(0, window.innerHeight - minVisible);
return {
x: Math.min(Math.max(0, next.x), maxX),
y: Math.min(Math.max(0, next.y), maxY),
};
},
[minVisible],
);
const onDragStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
setIsDragging(true);
dragStartRef.current = {
x: event.clientX,
y: event.clientY,
posX: position.x,
posY: position.y,
};
},
[position.x, position.y],
);
useEffect(() => {
if (!isDragging) return undefined;
const handleMove = (event: MouseEvent) => {
const dx = event.clientX - dragStartRef.current.x;
const dy = event.clientY - dragStartRef.current.y;
setPosition(
clampPosition({
x: dragStartRef.current.posX + dx,
y: dragStartRef.current.posY + dy,
}),
);
};
const handleUp = () => setIsDragging(false);
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
}, [isDragging, clampPosition]);
return {
position,
isMinimized,
setIsMinimized,
isDragging,
onDragStart,
};
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import type { GtDossier } from '@/types/dashboard';
import { API_BASE } from '@/lib/api';
export function useGtDossier(
lat: number | undefined,
lng: number | undefined,
countryName?: string,
enabled = true,
) {
const [gtDossier, setGtDossier] = useState<GtDossier | null>(null);
const [gtDossierLoading, setGtDossierLoading] = useState(false);
useEffect(() => {
if (!enabled || lat == null || lng == null) {
setGtDossier(null);
setGtDossierLoading(false);
return;
}
let cancelled = false;
const regions = [
`${lat.toFixed(2)},${lng.toFixed(2)}`,
countryName?.trim().toLowerCase(),
].filter((value): value is string => Boolean(value));
const load = async () => {
setGtDossierLoading(true);
let best: GtDossier | null = null;
for (const region of regions) {
try {
const response = await fetch(
`${API_BASE}/api/analytics/dossier/${encodeURIComponent(region)}`,
);
if (!response.ok) continue;
const payload = (await response.json()) as GtDossier;
if (!payload.enabled) continue;
if (!best || (payload.current_risk ?? 0) > (best.current_risk ?? 0)) {
best = { ...payload, region };
}
} catch {
// GT analytics optional — ignore fetch errors
}
}
if (!cancelled) {
setGtDossier(best);
setGtDossierLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [lat, lng, countryName, enabled]);
return { gtDossier, gtDossierLoading };
}
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { useEffect, useState } from 'react';
import { API_BASE } from '@/lib/api';
export interface RuntimeGtAnalytics {
enabled?: boolean;
operational?: boolean;
profile?: string;
lean_node?: boolean;
recommended?: boolean;
warning?: string | null;
experimental?: boolean;
}
export interface RuntimeProfile {
profile?: string;
cpu_limit?: number | null;
memory_limit_mb?: number | null;
gt_analytics?: RuntimeGtAnalytics;
}
export function useRuntimeProfile(): RuntimeProfile | null {
const [runtime, setRuntime] = useState<RuntimeProfile | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const res = await fetch(`${API_BASE}/api/health`, { cache: 'no-store' });
if (!res.ok || cancelled) return;
const body = await res.json();
if (!cancelled && body?.runtime) {
setRuntime(body.runtime as RuntimeProfile);
}
} catch {
/* health unavailable during boot */
}
};
void load();
const timer = window.setInterval(load, 60_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
return runtime;
}
export function gtLeanLayerWarning(runtime: RuntimeProfile | null): string | null {
const gt = runtime?.gt_analytics;
if (!gt?.lean_node) return null;
return (
gt.warning ||
'This node is capped at 1 vCPU. Enabling Strategic Risk (Derived OSINT) may slow OSINT fetches.'
);
}
+77 -2
View File
@@ -208,7 +208,79 @@
"malwareC2": "Malware C2",
"scmSuppliers": "SCM Suppliers",
"cyberThreats": "Cyber Threats",
"telegramOsint": "Telegram OSINT"
"telegramOsint": "Telegram OSINT",
"strategicRisk": "Strategic Risk Heatmap",
"derivedOsint": "Derived OSINT (Strategic Risk)",
"derivedOsintSource": "Experimental · off by default"
},
"gtLean": {
"title": "Enable Derived OSINT on a lean node?",
"message": "Shadowbroker detected a 1 vCPU cap on this node. Turning on the Strategic Risk map layer is safe for display, but enabling the backend engine (GT_ANALYTICS_ENABLED) may slow Telegram, GDELT, and other OSINT fetches. Use OpenClaw watchdog alerts without the full engine on fleet nodes.",
"confirm": "Turn on layer anyway",
"cancel": "Cancel"
},
"gtHud": {
"title": "GT ANALYTICS",
"dragHint": "drag to move",
"collapse": "Collapse panel",
"expand": "Expand panel"
},
"gtAlerts": {
"title": "TOP ALERTS",
"counts": "{plotted} on map · {tracked} tracked (max {max})",
"empty": "No plottable regions yet — need geotagged intel (Telegram/GDELT/news).",
"ignition": "IGNITE",
"line": "risk {risk} · conflict {conflict}",
"hint": "500 = max tracked regions, not individual events. Click to fly there."
},
"gtRisk": {
"popupTitle": "STRATEGIC RISK",
"region": "REGION",
"composite": "POSTERIOR RISK",
"financial": "FIN",
"unrest": "UNREST",
"conflict": "CONFLICT",
"contagion": "CONTAGION",
"costlySignals": "COSTLY SIGNALS",
"loadingSignals": "Loading feed matches…",
"noSignals": "No costly-signal text matched in recent Telegram/GDELT/news for this region. Scores can rise from domain priors or nearby contagion.",
"unknownSource": "unknown source"
},
"gtBacktest": {
"title": "GT Backtest",
"layerOff": "Off — enable Strategic Risk Heatmap in Data Layers.",
"disabled": "GT analytics disabled (set GT_ANALYTICS_ENABLED=true).",
"loading": "Running historical validation…",
"refresh": "Refresh backtest",
"accuracy": "ACCURACY",
"confidence": "WILSON 95% LB",
"cases": "{count} labeled cases",
"threshold": "alert ≥ {value}",
"target": "target {value}",
"pass": "PASS",
"fail": "FAIL",
"collecting": "COLLECTING",
"meetsTarget": "Meets confidence target",
"belowTarget": "Below confidence target",
"misclassified": "{count} misclassified",
"tabBenchmark": "Benchmark",
"tabOperational": "Operational",
"benchmarkNote": "Static labeled corpus — regression test, not live forecasting.",
"operationalLoading": "Loading rolling operational trend…",
"operationalEmpty": "No weekly snapshots yet — freeze runs Mondays 00:05 UTC or via OpenClaw.",
"operationalWeeks": "{stored} weeks stored · {scorable} scorable",
"operationalLabeled": "{labeled} labeled · {pending} pending",
"operationalTrend": "Week-over-week accuracy",
"operationalImproving": "Improving vs prior scorable week",
"operationalFlat": "Flat or down vs prior scorable week",
"operationalMinLabels": "Need ≥{count} labels/week to score",
"microTitle": "3-day micro",
"microWindow": "{days}-day rolling avg · ignition Δ ≥ {delta}",
"microIgnitions": "{count} ignition(s)",
"microAlerted3d": "{count} above threshold on 3d avg",
"microEmpty": "Collecting daily readings — refreshes with GT ingest.",
"microRegionLine": "{region}: spot {spot} · 3d {avg} · Δ {delta}",
"microIgnitionBadge": "IGNITION"
},
"roadCorridor": {
"analyzeHere": "ANALYZE HERE",
@@ -273,6 +345,9 @@
"loadMedia": "VIEW MEDIA (TELEGRAM)",
"openOriginal": "OPEN ON TELEGRAM →",
"embedTitle": "Telegram post embed",
"postsAtLocation": "{count} posts at this location — scroll for more"
"postsAtLocation": "{count} posts at this location — scroll for more",
"translatedFrom": "Translated from {lang}",
"showOriginal": "SHOW ORIGINAL ({lang})",
"showTranslation": "SHOW TRANSLATION"
}
}
+77 -2
View File
@@ -208,7 +208,79 @@
"malwareC2": "Malware C2",
"scmSuppliers": "Fournisseurs SCM",
"cyberThreats": "Cybermenaces",
"telegramOsint": "OSINT Telegram"
"telegramOsint": "OSINT Telegram",
"strategicRisk": "Carte de risque stratégique",
"derivedOsint": "OSINT dérivé (risque stratégique)",
"derivedOsintSource": "Expérimental · désactivé par défaut"
},
"gtLean": {
"title": "Activer l'OSINT dérivé sur un nœud limité ?",
"message": "Shadowbroker a détecté une limite de 1 vCPU. La couche carte peut s'afficher, mais activer le moteur backend peut ralentir les flux OSINT.",
"confirm": "Activer la couche",
"cancel": "Annuler"
},
"gtHud": {
"title": "ANALYTIQUE GT",
"dragHint": "glisser pour déplacer",
"collapse": "Réduire le panneau",
"expand": "Développer le panneau"
},
"gtAlerts": {
"title": "ALERTES TOP",
"counts": "{plotted} sur carte · {tracked} suivies (max {max})",
"empty": "Aucune région plottable — intel géolocalisée requise.",
"ignition": "IGNITE",
"line": "risque {risk} · conflit {conflict}",
"hint": "500 = régions max suivies, pas des événements. Cliquer pour voler."
},
"gtRisk": {
"popupTitle": "RISQUE STRATÉGIQUE",
"region": "RÉGION",
"composite": "RISQUE POSTÉRIEUR",
"financial": "FIN",
"unrest": "TROUBLES",
"conflict": "CONFLIT",
"contagion": "CONTAGION",
"costlySignals": "SIGNAUX COÛTEUX",
"loadingSignals": "Chargement des correspondances…",
"noSignals": "Aucun signal coûteux récent pour cette région dans Telegram/GDELT/news.",
"unknownSource": "source inconnue"
},
"gtBacktest": {
"title": "Backtest GT",
"layerOff": "Désactivé — activez la carte de risque stratégique dans Couches.",
"disabled": "Analytique GT désactivée (GT_ANALYTICS_ENABLED=true).",
"loading": "Validation historique en cours…",
"refresh": "Actualiser le backtest",
"accuracy": "PRÉCISION",
"confidence": "BORNE INF. WILSON 95%",
"cases": "{count} cas étiquetés",
"threshold": "alerte ≥ {value}",
"target": "cible {value}",
"pass": "OK",
"fail": "ÉCHEC",
"collecting": "COLLECTE",
"meetsTarget": "Objectif de confiance atteint",
"belowTarget": "Sous l'objectif de confiance",
"misclassified": "{count} mal classés",
"tabBenchmark": "Référence",
"tabOperational": "Opérationnel",
"benchmarkNote": "Corpus historique étiqueté — test de régression, pas prévision live.",
"operationalLoading": "Chargement de la tendance opérationnelle…",
"operationalEmpty": "Aucun instantané hebdomadaire — gel chaque lundi 00:05 UTC ou via OpenClaw.",
"operationalWeeks": "{stored} semaines · {scorable} exploitables",
"operationalLabeled": "{labeled} étiquetés · {pending} en attente",
"operationalTrend": "Précision semaine après semaine",
"operationalImproving": "En hausse vs semaine précédente",
"operationalFlat": "Stable ou en baisse vs semaine précédente",
"operationalMinLabels": "≥{count} étiquettes/semaine requis",
"microTitle": "Micro 3 jours",
"microWindow": "Moy. glissante {days} j · ignition Δ ≥ {delta}",
"microIgnitions": "{count} ignition(s)",
"microAlerted3d": "{count} au-dessus du seuil (moy. 3j)",
"microEmpty": "Lecture quotidienne en cours — mis à jour à chaque ingest GT.",
"microRegionLine": "{region}: spot {spot} · 3j {avg} · Δ {delta}",
"microIgnitionBadge": "IGNITION"
},
"roadCorridor": {
"analyzeHere": "ANALYSER ICI",
@@ -273,6 +345,9 @@
"loadMedia": "AFFICHER LE MÉDIA (TELEGRAM)",
"openOriginal": "OUVRIR SUR TELEGRAM →",
"embedTitle": "Intégration Telegram",
"postsAtLocation": "{count} posts à cet endroit — faites défiler"
"postsAtLocation": "{count} posts à cet endroit — faites défiler",
"translatedFrom": "Traduit depuis {lang}",
"showOriginal": "AFFICHER L'ORIGINAL ({lang})",
"showTranslation": "AFFICHER LA TRADUCTION"
}
}
+77 -2
View File
@@ -208,7 +208,79 @@
"malwareC2": "恶意软件 C2",
"scmSuppliers": "供应链供应商",
"cyberThreats": "网络威胁",
"telegramOsint": "Telegram OSINT"
"telegramOsint": "Telegram OSINT",
"strategicRisk": "战略风险热力图",
"derivedOsint": "衍生 OSINT(战略风险)",
"derivedOsintSource": "实验功能 · 默认关闭"
},
"gtLean": {
"title": "在低配节点上启用衍生 OSINT",
"message": "Shadowbroker 检测到该节点 CPU 上限为 1 vCPU。开启地图图层通常安全,但启用后端引擎可能会拖慢 Telegram、GDELT 等 OSINT 抓取。",
"confirm": "仍要开启图层",
"cancel": "取消"
},
"gtHud": {
"title": "GT 分析",
"dragHint": "拖动移动",
"collapse": "收起面板",
"expand": "展开面板"
},
"gtAlerts": {
"title": "重点警报",
"counts": "地图 {plotted} · 跟踪 {tracked}(上限 {max}",
"empty": "尚无可绘制区域 — 需要带地理标签的情报。",
"ignition": "点火",
"line": "风险 {risk} · 冲突 {conflict}",
"hint": "500 = 最大跟踪区域数,非事件数。点击飞往。"
},
"gtRisk": {
"popupTitle": "战略风险",
"region": "区域",
"composite": "后验风险",
"financial": "金融",
"unrest": "动荡",
"conflict": "冲突",
"contagion": "传染",
"costlySignals": "成本信号",
"loadingSignals": "正在加载情报匹配…",
"noSignals": "该区域最近在 Telegram/GDELT/新闻中未匹配到成本信号文本。",
"unknownSource": "未知来源"
},
"gtBacktest": {
"title": "GT 回测",
"layerOff": "已关闭 — 请在数据图层中启用战略风险热力图。",
"disabled": "GT 分析未启用(需设置 GT_ANALYTICS_ENABLED=true)。",
"loading": "正在运行历史验证…",
"refresh": "刷新回测",
"accuracy": "准确率",
"confidence": "Wilson 95% 下界",
"cases": "{count} 个标注案例",
"threshold": "警报阈值 ≥ {value}",
"target": "目标 {value}",
"pass": "通过",
"fail": "未通过",
"collecting": "采集中",
"meetsTarget": "达到置信目标",
"belowTarget": "低于置信目标",
"misclassified": "{count} 个误分类",
"tabBenchmark": "基准测试",
"tabOperational": "运营验证",
"benchmarkNote": "静态标注语料 — 回归测试,非实时预测。",
"operationalLoading": "正在加载滚动运营趋势…",
"operationalEmpty": "尚无周快照 — 每周一 00:05 UTC 自动冻结,或通过 OpenClaw。",
"operationalWeeks": "已存 {stored} 周 · {scorable} 周可评分",
"operationalLabeled": "已标注 {labeled} · 待标注 {pending}",
"operationalTrend": "逐周准确率",
"operationalImproving": "较上一可评分周有所提升",
"operationalFlat": "较上一可评分周持平或下降",
"operationalMinLabels": "每周需 ≥{count} 条标注才可评分",
"microTitle": "3日微观",
"microWindow": "{days}日滚动均值 · 点火 Δ ≥ {delta}",
"microIgnitions": "{count} 个点火",
"microAlerted3d": "{count} 个区域 3日均值超阈值",
"microEmpty": "正在采集日读数 — 随 GT 摄入更新。",
"microRegionLine": "{region}:即时 {spot} · 3日 {avg} · Δ {delta}",
"microIgnitionBadge": "点火"
},
"roadCorridor": {
"analyzeHere": "分析此处",
@@ -273,6 +345,9 @@
"loadMedia": "查看媒体(Telegram",
"openOriginal": "在 Telegram 打开 →",
"embedTitle": "Telegram 帖子嵌入",
"postsAtLocation": "此位置 {count} 条帖子 — 向下滚动查看更多"
"postsAtLocation": "此位置 {count} 条帖子 — 向下滚动查看更多",
"translatedFrom": "由 {lang} 翻译",
"showOriginal": "显示原文({lang}",
"showTranslation": "显示译文"
}
}
+104
View File
@@ -0,0 +1,104 @@
import type { GTRiskPayload } from '@/types/dashboard';
export interface GtAlertRow {
region: string;
regionLabel: string;
risk: number;
conflict: number;
unrest: number;
financial: number;
contagion: number;
lat: number;
lng: number;
score: number;
ignition: boolean;
risk3d?: number;
riskDelta?: number;
}
export function formatGtRegionLabel(region: string): string {
const text = String(region || '').trim();
if (!text) return 'unknown';
const coord = text.match(/^(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)$/);
if (coord) {
return `${Number(coord[1]).toFixed(2)}°, ${Number(coord[2]).toFixed(2)}°`;
}
const parts = text.split(',').map((piece) => piece.trim()).filter(Boolean);
if (parts.length >= 2) {
const lat = Number(parts[0]);
const lng = Number(parts[parts.length - 1]);
if (Number.isFinite(lat) && Number.isFinite(lng)) {
return `${lat.toFixed(2)}°, ${lng.toFixed(2)}°`;
}
}
return text.replace(/_/g, ' ');
}
function validCoords(coords: unknown): { lat: number; lng: number } | null {
if (!Array.isArray(coords) || coords.length < 2) return null;
const lng = Number(coords[0]);
const lat = Number(coords[1]);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (Math.abs(lat) < 0.001 && Math.abs(lng) < 0.001) return null;
return { lat, lng };
}
function peakScore(props: Record<string, unknown>): number {
const composite = Number(props.risk ?? 0);
const financial = Number(props.financial ?? 0);
const unrest = Number(props.unrest ?? 0);
const conflict = Number(props.conflict ?? 0);
return Math.max(composite, financial, unrest, conflict);
}
export function extractGtAlerts(
payload?: GTRiskPayload | null,
limit = 8,
): {
alerts: GtAlertRow[];
trackedRegions: number;
plottedRegions: number;
maxRegions: number;
} {
const features = payload?.heatmap?.features || [];
const meta = payload?.meta;
const rows: GtAlertRow[] = [];
for (const feature of features) {
const coords = validCoords(feature.geometry?.coordinates);
if (!coords) continue;
const props = (feature.properties || {}) as Record<string, unknown>;
const region = String(props.region || '').trim().toLowerCase();
if (!region) continue;
rows.push({
region,
regionLabel: formatGtRegionLabel(region),
risk: Number(props.risk ?? 0),
financial: Number(props.financial ?? 0),
unrest: Number(props.unrest ?? 0),
conflict: Number(props.conflict ?? 0),
contagion: Number(props.contagion ?? 0),
lat: coords.lat,
lng: coords.lng,
score: peakScore(props),
ignition: Boolean(props.micro_ignition),
risk3d: props.risk_3d_avg != null ? Number(props.risk_3d_avg) : undefined,
riskDelta: props.risk_delta != null ? Number(props.risk_delta) : undefined,
});
}
rows.sort((a, b) => {
if (a.ignition !== b.ignition) return a.ignition ? -1 : 1;
const deltaA = a.riskDelta ?? 0;
const deltaB = b.riskDelta ?? 0;
if (deltaA !== deltaB) return deltaB - deltaA;
return b.score - a.score;
});
return {
alerts: rows.slice(0, limit),
trackedRegions: meta?.tracked_regions ?? features.length,
plottedRegions: meta?.plotted_regions ?? rows.length,
maxRegions: meta?.max_regions ?? 500,
};
}
+182
View File
@@ -966,12 +966,193 @@ export interface DashboardData {
timestamp?: string | null;
channels?: string[];
};
gt_risk?: GTRiskPayload;
}
export interface GTRiskHeatmapFeature {
type: 'Feature';
properties: {
region: string;
risk: number;
financial?: number;
unrest?: number;
conflict?: number;
contagion?: number;
updates?: number;
risk_spot?: number;
risk_3d_avg?: number;
risk_delta?: number;
micro_ignition?: boolean;
};
geometry: {
type: 'Point';
coordinates: [number, number];
};
}
export interface GTRiskPayload {
enabled?: boolean;
timestamp?: string | null;
processed?: number;
meta?: {
tracked_regions?: number;
engine_regions?: number;
plotted_regions?: number;
max_regions?: number;
};
heatmap?: {
type: 'FeatureCollection';
features: GTRiskHeatmapFeature[];
};
clusters?: Array<{
cluster_id: number;
size: number;
mean_risk: number;
regions?: string[];
members?: string[];
}>;
}
export interface GtDossierSignalEntry {
timestamp: string;
domain: string;
signals: Record<string, number>;
strength: number;
posterior: number;
source: string;
deviation_score?: number;
}
export interface GtBacktestCaseResult {
case_id: string;
name: string;
kind: string;
correct: boolean;
alerted: boolean;
peak_domain_risk: number;
peak_composite_risk: number;
costly_signals: string[];
}
export interface GtBacktestReport {
enabled?: boolean;
total_cases: number;
correct: number;
accuracy: number;
confidence_rate: number;
wilson_lower_95: number;
wilson_upper_95: number;
true_positives: number;
true_negatives: number;
false_positives: number;
false_negatives: number;
sensitivity: number;
specificity: number;
alert_threshold: number;
target_confidence: number;
meets_target: boolean;
expanded_suite?: boolean;
tuned?: boolean;
recommended_alert_threshold?: number;
cases?: GtBacktestCaseResult[];
}
export interface GtRollingWeekScore {
week_id: string;
frozen_at?: string;
alert_threshold: number;
total_regions: number;
labeled: number;
pending: number;
alerted: number;
correct: number;
accuracy: number;
confidence_rate: number;
wilson_lower_95: number;
wilson_upper_95: number;
true_positives: number;
true_negatives: number;
false_positives: number;
false_negatives: number;
sensitivity: number;
specificity: number;
scorable: boolean;
}
export interface GtMicroRegionView {
region: string;
spot_risk: number;
risk_3d_avg: number;
risk_delta: number;
days_in_window: number;
day_scores: number[];
alerted_spot: boolean;
alerted_3d: boolean;
ignition: boolean;
financial: number;
unrest: number;
conflict: number;
}
export interface GtMicroRollingReport {
enabled?: boolean;
mode?: string;
window_days: number;
alert_threshold: number;
ignition_delta: number;
as_of: string;
days_stored: number;
regions_tracked: number;
ignition_count: number;
alerted_3d_count: number;
ignitions: GtMicroRegionView[];
top_regions: GtMicroRegionView[];
note?: string;
message?: string;
}
export interface GtRollingReport {
enabled?: boolean;
mode?: string;
alert_threshold: number;
target_confidence: number;
weeks_requested: number;
weeks_stored: number;
weeks_scorable: number;
min_labeled_per_week: number;
latest: GtRollingWeekScore | null;
trend: GtRollingWeekScore[];
accuracy_series: { week_id: string; accuracy: number; labeled: number }[];
improving_vs_prior: boolean;
meets_target: boolean;
note?: string;
message?: string;
}
export interface GtDossier {
enabled?: boolean;
region: string;
current_risk: number;
domain_risks?: {
financial?: number;
unrest?: number;
conflict?: number;
};
recent_signals?: GtDossierSignalEntry[];
contagion_risk?: number;
interpretation?: string;
scenarios?: Array<{ name: string; summary: string }>;
}
export interface TelegramOsintPost {
id: string;
title?: string;
description?: string;
title_translated?: string;
description_translated?: string;
source_lang?: string;
source_lang_label?: string;
translate_to?: string;
link?: string;
published?: string;
source?: string;
@@ -1120,6 +1301,7 @@ export interface ActiveLayers {
scm_suppliers: boolean;
cyber_threats: boolean;
telegram_osint: boolean;
gt_risk: boolean;
}
export interface SelectedEntity {