- {Object.entries(grouped).map(([category, categoryApis]) => {
- const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
- const isExpanded = expandedCategories.has(category);
-
- return (
-
- {/* Category Header */}
-
-
- {/* APIs in Category */}
-
- {isExpanded && (
-
- {categoryApis.map((api) => (
-
- {/* API Name + Status */}
-
-
- {api.required && }
- {api.name}
-
-
- {api.has_key ? (
- api.is_set ? (
-
- KEY SET
-
- ) : (
-
- MISSING
-
- )
- ) : (
-
- PUBLIC
-
- )}
- {api.url && (
-
e.stopPropagation()}
- >
-
-
- )}
-
-
-
- {/* Description */}
-
- {api.description}
-
-
- {/* Key Field (only for APIs with keys) */}
- {api.has_key && (
-
- {editingId === api.id ? (
- /* Edit Mode */
-
- setEditValue(e.target.value)}
- className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors"
- placeholder="Enter API key..."
- autoFocus
- />
-
-
-
- ) : (
- /* Display Mode */
-
-
startEditing(api)}
- >
-
- {api.is_set ? api.value_obfuscated : "Click to set key..."}
-
-
-
- )}
-
- )}
-
- ))}
-
- )}
-
+ {/* ==================== API KEYS TAB ==================== */}
+ {activeTab === "api-keys" && (
+ <>
+ {/* Info Banner */}
+
+
+
+
+ API keys are stored locally in the backend .env file. Keys marked with are required for full functionality. Public APIs need no key.
+
- );
- })}
-
+
- {/* Footer */}
-
-
- {apis.length} REGISTERED APIs
- {apis.filter(a => a.has_key).length} KEYS CONFIGURED
-
-
+ {/* API List */}
+
+ {Object.entries(grouped).map(([category, categoryApis]) => {
+ const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
+ const isExpanded = expandedCategories.has(category);
+ return (
+
+
+
+ {isExpanded && (
+
+ {categoryApis.map((api) => (
+
+
+
+ {api.required && }
+ {api.name}
+
+
+ {api.has_key ? (
+ api.is_set ? (
+
KEY SET
+ ) : (
+
MISSING
+ )
+ ) : (
+
PUBLIC
+ )}
+ {api.url && (
+
e.stopPropagation()}>
+
+
+ )}
+
+
+
{api.description}
+ {api.has_key && (
+
+ {editingId === api.id ? (
+
+ setEditValue(e.target.value)} className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors" placeholder="Enter API key..." autoFocus />
+
+
+
+ ) : (
+
+
startEditing(api)}>
+ {api.is_set ? api.value_obfuscated : "Click to set key..."}
+
+
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+ })}
+
+
+ {/* Footer */}
+
+
+ {apis.length} REGISTERED APIs
+ {apis.filter(a => a.has_key).length} KEYS CONFIGURED
+
+
+ >
+ )}
+
+ {/* ==================== NEWS FEEDS TAB ==================== */}
+ {activeTab === "news-feeds" && (
+ <>
+ {/* Info Banner */}
+
+
+
+
+ Configure RSS/Atom feeds for the Threat Intel news panel. Each feed is scored by keyword heuristics and weighted by the priority you set. Up to {MAX_FEEDS} sources.
+
+
+
+
+ {/* Feed List */}
+
+ {feeds.map((feed, idx) => (
+
+ {/* Row 1: Name + Weight + Delete */}
+
+
updateFeed(idx, "name", e.target.value)}
+ className="flex-1 bg-transparent border-b border-[var(--border-primary)] text-xs font-mono text-[var(--text-primary)] outline-none focus:border-cyan-500/70 transition-colors px-1 py-0.5"
+ placeholder="Source name..."
+ />
+ {/* Weight selector */}
+
+ {[1, 2, 3, 4, 5].map(w => (
+
+ ))}
+
+ {WEIGHT_LABELS[feed.weight] || "STD"}
+
+
+
+
+ {/* Row 2: URL */}
+
updateFeed(idx, "url", e.target.value)}
+ className="w-full bg-black/30 border border-[var(--border-primary)]/40 rounded px-2 py-1 text-[10px] font-mono text-[var(--text-muted)] outline-none focus:border-cyan-500/50 focus:text-cyan-300 transition-colors"
+ placeholder="https://example.com/rss.xml"
+ />
+
+ ))}
+
+ {/* Add Feed Button */}
+
+
+
+ {/* Status message */}
+ {feedMsg && (
+
+ {feedMsg.text}
+
+ )}
+
+ {/* Footer */}
+
+
+
+
+
+
+ {feeds.length}/{MAX_FEEDS} SOURCES
+ WEIGHT: 1=LOW 5=CRITICAL
+
+
+ >
+ )}
>
)}
diff --git a/frontend/src/components/WorldviewLeftPanel.tsx b/frontend/src/components/WorldviewLeftPanel.tsx
index 0d0b696..b180d05 100644
--- a/frontend/src/components/WorldviewLeftPanel.tsx
+++ b/frontend/src/components/WorldviewLeftPanel.tsx
@@ -2,9 +2,44 @@
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
-import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi } from "lucide-react";
+import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server } from "lucide-react";
import { useTheme } from "@/lib/ThemeContext";
+function relativeTime(iso: string | undefined): string {
+ if (!iso) return "";
+ const diff = Date.now() - new Date(iso + "Z").getTime();
+ if (diff < 0) return "now";
+ const sec = Math.floor(diff / 1000);
+ if (sec < 60) return `${sec}s ago`;
+ const min = Math.floor(sec / 60);
+ if (min < 60) return `${min}m ago`;
+ const hr = Math.floor(min / 60);
+ if (hr < 24) return `${hr}h ago`;
+ return `${Math.floor(hr / 24)}d ago`;
+}
+
+// Map layer IDs to freshness keys from the backend source_timestamps dict
+const FRESHNESS_MAP: Record
= {
+ flights: "commercial_flights",
+ private: "private_flights",
+ jets: "private_jets",
+ military: "military_flights",
+ tracked: "military_flights",
+ earthquakes: "earthquakes",
+ satellites: "satellites",
+ ships_important: "ships",
+ ships_civilian: "ships",
+ ships_passenger: "ships",
+ ukraine_frontline: "frontlines",
+ global_incidents: "gdelt",
+ cctv: "cctv",
+ gps_jamming: "commercial_flights",
+ kiwisdr: "kiwisdr",
+ firms: "firms_fires",
+ internet_outages: "internet_outages",
+ datacenters: "datacenters",
+};
+
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void }) {
const [isMinimized, setIsMinimized] = useState(false);
const { theme, toggleTheme } = useTheme();
@@ -60,6 +95,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{ id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio },
{ id: "firms", name: "Fire Hotspots (24h)", source: "NASA FIRMS VIIRS", count: data?.firms_fires?.length || 0, icon: Flame },
{ id: "internet_outages", name: "Internet Outages", source: "IODA / Georgia Tech", count: data?.internet_outages?.length || 0, icon: Wifi },
+ { id: "datacenters", name: "Data Centers", source: "DC Map (GitHub)", count: data?.datacenters?.length || 0, icon: Server },
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
];
@@ -146,7 +182,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active