/** * SourceWidgetDiscovery - Bidirectional Source↔Widget Coupling * * This service handles: * 1. Widget → Source: Widgets request data, suggests additional sources * 2. Source → Widget: Discovers orphan sources and suggests/generates widgets * * The autonomous system learns from these connections over time. */ import { useState, useEffect, useCallback } from 'react'; const API_BASE = 'http://localhost:3001/api'; // ============================================================ // TYPES // ============================================================ export interface DiscoveredSource { id: string; name: string; type: string; capabilities: string[]; estimatedLatency: number; recommendedWidgets: string[]; hasWidget: boolean; suggestNewWidget: boolean; } export interface SuggestedWidget { forSource: string; suggestedName: string; suggestedType: string; capabilities: string[]; } export interface GeneratedWidget { id: string; name: string; category: string; dataSource: string; capabilities: string[]; config: Array<{ key: string; type: string; default: any; label: string }>; template: 'data-display' | 'chart' | 'table' | 'status'; generatedAt: string; } export interface SourceDiscoveryResult { total: number; sources: DiscoveredSource[]; orphanSources: DiscoveredSource[]; suggestedNewWidgets: SuggestedWidget[]; } // ============================================================ // SOURCE WIDGET DISCOVERY SERVICE // ============================================================ class SourceWidgetDiscoveryService { private wsConnection: WebSocket | null = null; private listeners: Map void>> = new Map(); private discoveryCache: SourceDiscoveryResult | null = null; private cacheTimestamp: number = 0; private readonly CACHE_TTL = 60000; // 1 minute /** * Fetch all discovered sources and their widget recommendations */ async discoverSources(): Promise { // Return cache if fresh if (this.discoveryCache && Date.now() - this.cacheTimestamp < this.CACHE_TTL) { return this.discoveryCache; } try { const response = await fetch(`${API_BASE}/acquisition/discovered-sources`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const result = await response.json(); if (result.success) { this.discoveryCache = result.data; this.cacheTimestamp = Date.now(); return result.data; } throw new Error(result.error || 'Failed to discover sources'); } catch (error) { console.error('[SourceWidgetDiscovery] Error:', error); // Return empty result on error return { total: 0, sources: [], orphanSources: [], suggestedNewWidgets: [], }; } } /** * Request widget generation for an orphan source */ async generateWidgetForSource( sourceId: string, widgetName?: string, widgetType?: string ): Promise { try { const response = await fetch(`${API_BASE}/acquisition/generate-widget`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceId, widgetName, widgetType }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const result = await response.json(); if (result.success) { // Notify listeners of new widget this.emit('widget-generated', result.widget); return result.widget; } return null; } catch (error) { console.error('[SourceWidgetDiscovery] Widget generation error:', error); return null; } } /** * Enable a data source (triggered by user from widget recommendation) */ async enableSource( sourceId: string, sourceName: string, category: string, requiresApproval: boolean = false ): Promise { try { const response = await fetch(`${API_BASE}/acquisition/enable-source`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceId, sourceName, category, requiresApproval }), }); if (!response.ok) return false; const result = await response.json(); if (result.success) { // Invalidate cache this.discoveryCache = null; // Notify listeners this.emit('source-enabled', { sourceId, sourceName, category }); return true; } return false; } catch (error) { console.error('[SourceWidgetDiscovery] Enable source error:', error); return false; } } /** * Find the best widget for a given source */ findWidgetForSource(source: DiscoveredSource): string { // Priority mapping const typeToWidget: Record = { 'database': 'database', 'mcp-tool': 'events', 'email-adapter': 'news', 'file': 'datastreams', 'external': 'global', 'osint': 'threatmap', 'threatIntel': 'alerts', }; // Check capabilities for more specific matches const caps = source.capabilities.join(' ').toLowerCase(); if (caps.includes('alert') || caps.includes('security')) return 'alerts'; if (caps.includes('metric') || caps.includes('stats')) return 'metrics'; if (caps.includes('event') || caps.includes('log')) return 'events'; if (caps.includes('graph') || caps.includes('knowledge')) return 'statistics'; if (caps.includes('geo') || caps.includes('location')) return 'threatmap'; return typeToWidget[source.type] || 'events'; } /** * Get sources that need widgets (orphans) */ async getOrphanSources(): Promise { const discovery = await this.discoverSources(); return discovery.orphanSources; } /** * Subscribe to WebSocket events for real-time source discovery */ connectWebSocket(): void { if (this.wsConnection?.readyState === WebSocket.OPEN) return; try { this.wsConnection = new WebSocket('ws://localhost:3001/mcp/ws'); this.wsConnection.onmessage = (event) => { try { const data = JSON.parse(event.data); // Handle source:health events (new sources discovered) if (data.type === 'source:health') { this.emit('source-discovered', data); // Invalidate cache to pick up new source this.discoveryCache = null; } // Handle widget:invoke events (widget generation) if (data.type === 'widget:invoke' && data.action === 'widget-generated') { this.emit('widget-generated', data.widget); } } catch (e) { // Ignore parse errors } }; this.wsConnection.onclose = () => { // Reconnect after delay setTimeout(() => this.connectWebSocket(), 5000); }; } catch (error) { console.error('[SourceWidgetDiscovery] WebSocket error:', error); } } /** * Event emitter pattern */ on(event: string, callback: (data: any) => void): void { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()); } this.listeners.get(event)!.add(callback); } off(event: string, callback: (data: any) => void): void { this.listeners.get(event)?.delete(callback); } private emit(event: string, data: any): void { this.listeners.get(event)?.forEach(cb => cb(data)); } /** * Cleanup */ disconnect(): void { if (this.wsConnection) { this.wsConnection.close(); this.wsConnection = null; } this.listeners.clear(); } } // Singleton instance export const sourceWidgetDiscovery = new SourceWidgetDiscoveryService(); // ============================================================ // REACT HOOKS // ============================================================ /** * Hook to discover orphan sources and suggest widgets */ export function useSourceDiscovery() { const [sources, setSources] = useState([]); const [orphanSources, setOrphanSources] = useState([]); const [suggestedWidgets, setSuggestedWidgets] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const refresh = useCallback(async () => { setIsLoading(true); try { const discovery = await sourceWidgetDiscovery.discoverSources(); setSources(discovery.sources); setOrphanSources(discovery.orphanSources); setSuggestedWidgets(discovery.suggestedNewWidgets); setError(null); } catch (err: any) { setError(err.message); } finally { setIsLoading(false); } }, []); const generateWidget = useCallback(async (sourceId: string, name?: string) => { const widget = await sourceWidgetDiscovery.generateWidgetForSource(sourceId, name); if (widget) { // Refresh to update orphan list await refresh(); } return widget; }, [refresh]); useEffect(() => { refresh(); // Connect to WebSocket for real-time updates sourceWidgetDiscovery.connectWebSocket(); const handleNewSource = () => refresh(); sourceWidgetDiscovery.on('source-discovered', handleNewSource); sourceWidgetDiscovery.on('source-enabled', handleNewSource); return () => { sourceWidgetDiscovery.off('source-discovered', handleNewSource); sourceWidgetDiscovery.off('source-enabled', handleNewSource); }; }, [refresh]); return { sources, orphanSources, suggestedWidgets, isLoading, error, refresh, generateWidget, enableSource: sourceWidgetDiscovery.enableSource.bind(sourceWidgetDiscovery), }; } /** * Hook to listen for newly generated widgets */ export function useGeneratedWidgets() { const [generatedWidgets, setGeneratedWidgets] = useState([]); useEffect(() => { sourceWidgetDiscovery.connectWebSocket(); const handleWidget = (widget: GeneratedWidget) => { setGeneratedWidgets(prev => [...prev, widget]); }; sourceWidgetDiscovery.on('widget-generated', handleWidget); return () => { sourceWidgetDiscovery.off('widget-generated', handleWidget); }; }, []); return { generatedWidgets, clearWidgets: () => setGeneratedWidgets([]), }; }