Spaces:
Paused
Paused
| /** | |
| * SourceDiscoveryWidget - Displays orphan sources and allows widget generation | |
| * | |
| * This widget enables the reverse flow: Source → Widget | |
| * Shows data sources that don't have matching widgets and offers to generate them. | |
| */ | |
| import React, { useState } from 'react'; | |
| import { | |
| Database, Zap, Globe, AlertTriangle, Plus, RefreshCw, | |
| Check, Loader2, ChevronRight, Sparkles, Link2 | |
| } from 'lucide-react'; | |
| import { useSourceDiscovery, useGeneratedWidgets } from '@/services/SourceWidgetDiscovery'; | |
| import { cn } from '@/lib/utils'; | |
| // Icon mapping for source types | |
| const sourceTypeIcons: Record<string, React.ReactNode> = { | |
| database: <Database className="w-4 h-4" />, | |
| 'mcp-tool': <Zap className="w-4 h-4" />, | |
| file: <Database className="w-4 h-4" />, | |
| 'email-adapter': <AlertTriangle className="w-4 h-4" />, | |
| external: <Globe className="w-4 h-4" />, | |
| osint: <Globe className="w-4 h-4" />, | |
| threatIntel: <AlertTriangle className="w-4 h-4" />, | |
| }; | |
| export default function SourceDiscoveryWidget() { | |
| const { | |
| sources, | |
| orphanSources, | |
| suggestedWidgets, | |
| isLoading, | |
| refresh, | |
| generateWidget | |
| } = useSourceDiscovery(); | |
| const { generatedWidgets } = useGeneratedWidgets(); | |
| const [generatingFor, setGeneratingFor] = useState<string | null>(null); | |
| const [activeTab, setActiveTab] = useState<'orphans' | 'all' | 'generated'>('orphans'); | |
| const handleGenerateWidget = async (sourceId: string, suggestedName: string) => { | |
| setGeneratingFor(sourceId); | |
| try { | |
| await generateWidget(sourceId, suggestedName); | |
| } finally { | |
| setGeneratingFor(null); | |
| } | |
| }; | |
| return ( | |
| <div className="h-full flex flex-col bg-card/50 backdrop-blur-sm border border-border/50 rounded-lg overflow-hidden"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between p-4 border-b border-border/30 bg-gradient-to-r from-primary/10 to-transparent"> | |
| <div className="flex items-center gap-2"> | |
| <Link2 className="w-5 h-5 text-primary" /> | |
| <span className="font-display text-sm uppercase tracking-wider text-primary"> | |
| Source Discovery | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs text-muted-foreground"> | |
| {sources.length} kilder • {orphanSources.length} uden widget | |
| </span> | |
| <button | |
| onClick={refresh} | |
| className="p-1.5 hover:bg-secondary/50 rounded transition-colors" | |
| disabled={isLoading} | |
| > | |
| <RefreshCw className={cn("w-4 h-4 text-primary", isLoading && "animate-spin")} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Tabs */} | |
| <div className="flex border-b border-border/30"> | |
| {[ | |
| { id: 'orphans', label: 'Orphan Kilder', count: orphanSources.length }, | |
| { id: 'all', label: 'Alle Kilder', count: sources.length }, | |
| { id: 'generated', label: 'Genererede', count: generatedWidgets.length }, | |
| ].map(tab => ( | |
| <button | |
| key={tab.id} | |
| onClick={() => setActiveTab(tab.id as any)} | |
| className={cn( | |
| "flex-1 px-4 py-2 text-xs font-medium transition-colors", | |
| activeTab === tab.id | |
| ? "text-primary border-b-2 border-primary bg-primary/5" | |
| : "text-muted-foreground hover:text-foreground" | |
| )} | |
| > | |
| {tab.label} | |
| {tab.count > 0 && ( | |
| <span className="ml-1.5 px-1.5 py-0.5 bg-primary/20 text-primary rounded text-xs"> | |
| {tab.count} | |
| </span> | |
| )} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Content */} | |
| <div className="flex-1 overflow-y-auto p-4 space-y-3"> | |
| {isLoading ? ( | |
| <div className="flex items-center justify-center h-32"> | |
| <Loader2 className="w-6 h-6 text-primary animate-spin" /> | |
| </div> | |
| ) : activeTab === 'orphans' ? ( | |
| orphanSources.length === 0 ? ( | |
| <div className="text-center py-8"> | |
| <Check className="w-8 h-8 text-green-400 mx-auto mb-2" /> | |
| <p className="text-sm text-muted-foreground">Alle kilder har widgets!</p> | |
| </div> | |
| ) : ( | |
| orphanSources.map(source => { | |
| const suggested = suggestedWidgets.find(s => s.forSource === source.id); | |
| return ( | |
| <div | |
| key={source.id} | |
| className="p-3 bg-secondary/30 border border-border/30 rounded-lg hover:border-primary/30 transition-colors" | |
| > | |
| <div className="flex items-start justify-between gap-3"> | |
| <div className="flex items-center gap-2"> | |
| <div className="p-2 bg-primary/10 rounded"> | |
| {sourceTypeIcons[source.type] || <Database className="w-4 h-4" />} | |
| </div> | |
| <div> | |
| <p className="text-sm font-medium text-foreground">{source.name}</p> | |
| <p className="text-xs text-muted-foreground">{source.type}</p> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => handleGenerateWidget(source.id, suggested?.suggestedName || `${source.name} Widget`)} | |
| disabled={generatingFor === source.id} | |
| className={cn( | |
| "flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-all", | |
| "bg-primary/20 text-primary hover:bg-primary/30", | |
| generatingFor === source.id && "opacity-50 cursor-not-allowed" | |
| )} | |
| > | |
| {generatingFor === source.id ? ( | |
| <Loader2 className="w-3 h-3 animate-spin" /> | |
| ) : ( | |
| <Sparkles className="w-3 h-3" /> | |
| )} | |
| Generer Widget | |
| </button> | |
| </div> | |
| {/* Capabilities */} | |
| <div className="mt-2 flex flex-wrap gap-1"> | |
| {source.capabilities.slice(0, 4).map((cap, i) => ( | |
| <span key={i} className="px-1.5 py-0.5 bg-secondary/50 text-muted-foreground text-xs rounded"> | |
| {cap} | |
| </span> | |
| ))} | |
| {source.capabilities.length > 4 && ( | |
| <span className="px-1.5 py-0.5 text-muted-foreground text-xs"> | |
| +{source.capabilities.length - 4} flere | |
| </span> | |
| )} | |
| </div> | |
| {/* Suggested widget info */} | |
| {suggested && ( | |
| <div className="mt-2 flex items-center gap-2 text-xs text-primary/70"> | |
| <ChevronRight className="w-3 h-3" /> | |
| Foreslået: <span className="font-medium">{suggested.suggestedName}</span> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }) | |
| ) | |
| ) : activeTab === 'all' ? ( | |
| sources.map(source => ( | |
| <div | |
| key={source.id} | |
| className="p-3 bg-secondary/20 border border-border/20 rounded-lg" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <div className="p-1.5 bg-secondary/50 rounded"> | |
| {sourceTypeIcons[source.type] || <Database className="w-3 h-3" />} | |
| </div> | |
| <div className="flex-1"> | |
| <p className="text-sm text-foreground">{source.name}</p> | |
| <div className="flex items-center gap-2 text-xs text-muted-foreground"> | |
| <span>{source.type}</span> | |
| <span>•</span> | |
| <span>{source.estimatedLatency}ms</span> | |
| {source.recommendedWidgets.length > 0 && ( | |
| <> | |
| <span>•</span> | |
| <span className="text-primary">{source.recommendedWidgets[0]}</span> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {source.hasWidget ? ( | |
| <Check className="w-4 h-4 text-green-400" /> | |
| ) : ( | |
| <span className="text-xs text-orange-400">Ingen widget</span> | |
| )} | |
| </div> | |
| </div> | |
| )) | |
| ) : ( | |
| /* Generated widgets tab */ | |
| generatedWidgets.length === 0 ? ( | |
| <div className="text-center py-8"> | |
| <Plus className="w-8 h-8 text-muted-foreground mx-auto mb-2" /> | |
| <p className="text-sm text-muted-foreground">Ingen genererede widgets endnu</p> | |
| </div> | |
| ) : ( | |
| generatedWidgets.map((widget, i) => ( | |
| <div | |
| key={i} | |
| className="p-3 bg-gradient-to-r from-primary/10 to-transparent border border-primary/20 rounded-lg" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <Sparkles className="w-4 h-4 text-primary" /> | |
| <div> | |
| <p className="text-sm font-medium text-foreground">{widget.name}</p> | |
| <p className="text-xs text-muted-foreground"> | |
| For: {widget.dataSource} • Template: {widget.template} | |
| </p> | |
| </div> | |
| </div> | |
| <div className="mt-2 flex flex-wrap gap-1"> | |
| {widget.capabilities.slice(0, 3).map((cap, j) => ( | |
| <span key={j} className="px-1.5 py-0.5 bg-primary/10 text-primary text-xs rounded"> | |
| {cap} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| )) | |
| ) | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div className="p-3 border-t border-border/30 bg-secondary/20"> | |
| <p className="text-xs text-muted-foreground text-center"> | |
| Autonomt system lærer fra kilde↔widget koblinger | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| } | |