Spaces:
Paused
Paused
| /** | |
| * DataSourceSelector - Full-featured dynamic data source picker | |
| * Fetches ALL available sources from backend with search/filter | |
| */ | |
| import { useState, useEffect, useMemo } from 'react'; | |
| import { | |
| Check, Database, RefreshCw, Plus, Search, X, | |
| Wifi, Globe, Cpu, Activity, Zap, Server, Brain, HardDrive | |
| } from 'lucide-react'; | |
| import { Button } from '@/components/ui/button'; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| } from '@/components/ui/dialog'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Label } from '@/components/ui/label'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { ScrollArea } from '@/components/ui/scroll-area'; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; | |
| import { cn } from '@/lib/utils'; | |
| import { API_URL } from '@/config/api'; | |
| export interface DataSourceConfig { | |
| id: string; | |
| name: string; | |
| description: string; | |
| endpoint: string; | |
| type: 'rest' | 'websocket' | 'mcp' | 'graphql'; | |
| category: 'system' | 'data' | 'ai' | 'external' | 'realtime' | 'custom'; | |
| refreshInterval?: number; | |
| tags: string[]; | |
| active: boolean; | |
| } | |
| interface DataSourcesResponse { | |
| total: number; | |
| categories: string[]; | |
| types: string[]; | |
| sources: DataSourceConfig[]; | |
| } | |
| // Category icons | |
| const categoryIcons: Record<string, typeof Database> = { | |
| system: Server, | |
| data: HardDrive, | |
| ai: Brain, | |
| external: Globe, | |
| realtime: Zap, | |
| custom: Cpu | |
| }; | |
| // Type colors | |
| const typeColors: Record<string, string> = { | |
| rest: 'bg-green-500/20 text-green-400 border-green-500/30', | |
| websocket: 'bg-blue-500/20 text-blue-400 border-blue-500/30', | |
| mcp: 'bg-purple-500/20 text-purple-400 border-purple-500/30', | |
| graphql: 'bg-pink-500/20 text-pink-400 border-pink-500/30' | |
| }; | |
| interface DataSourceSelectorProps { | |
| /** Currently selected data source */ | |
| selectedSource?: DataSourceConfig; | |
| /** Called when a new source is selected */ | |
| onSourceChange: (source: DataSourceConfig) => void; | |
| /** Show as dialog (default) or inline */ | |
| inline?: boolean; | |
| /** Dialog open state (controlled) */ | |
| open?: boolean; | |
| /** Dialog close callback */ | |
| onOpenChange?: (open: boolean) => void; | |
| className?: string; | |
| } | |
| export function DataSourceSelector({ | |
| selectedSource, | |
| onSourceChange, | |
| inline = false, | |
| open, | |
| onOpenChange, | |
| className | |
| }: DataSourceSelectorProps) { | |
| const [sources, setSources] = useState<DataSourceConfig[]>([]); | |
| const [categories, setCategories] = useState<string[]>([]); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [selectedCategory, setSelectedCategory] = useState<string>('all'); | |
| const [testingSource, setTestingSource] = useState<string | null>(null); | |
| // Fetch sources from backend | |
| const fetchSources = async () => { | |
| setIsLoading(true); | |
| setError(null); | |
| try { | |
| const response = await fetch(`${API_URL}/api/datasources`); | |
| if (!response.ok) throw new Error('Failed to fetch data sources'); | |
| const data: DataSourcesResponse = await response.json(); | |
| setSources(data.sources); | |
| setCategories(['all', ...data.categories]); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : 'Unknown error'); | |
| // Fallback to empty | |
| setSources([]); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| fetchSources(); | |
| }, []); | |
| // Filter sources based on search and category | |
| const filteredSources = useMemo(() => { | |
| return sources.filter(source => { | |
| // Category filter | |
| if (selectedCategory !== 'all' && source.category !== selectedCategory) { | |
| return false; | |
| } | |
| // Search filter | |
| if (searchQuery) { | |
| const query = searchQuery.toLowerCase(); | |
| return ( | |
| source.name.toLowerCase().includes(query) || | |
| source.description.toLowerCase().includes(query) || | |
| source.endpoint.toLowerCase().includes(query) || | |
| source.tags.some(t => t.toLowerCase().includes(query)) | |
| ); | |
| } | |
| return true; | |
| }); | |
| }, [sources, searchQuery, selectedCategory]); | |
| // Group by category for display | |
| const groupedSources = useMemo(() => { | |
| const groups: Record<string, DataSourceConfig[]> = {}; | |
| filteredSources.forEach(source => { | |
| if (!groups[source.category]) { | |
| groups[source.category] = []; | |
| } | |
| groups[source.category].push(source); | |
| }); | |
| return groups; | |
| }, [filteredSources]); | |
| // Test connection | |
| const testConnection = async (sourceId: string) => { | |
| setTestingSource(sourceId); | |
| try { | |
| await fetch(`${API_URL}/api/datasources/${sourceId}/test`, { method: 'POST' }); | |
| } catch { | |
| // Silent fail | |
| } finally { | |
| setTestingSource(null); | |
| } | |
| }; | |
| const handleSelect = (source: DataSourceConfig) => { | |
| onSourceChange(source); | |
| onOpenChange?.(false); | |
| }; | |
| const content = ( | |
| <div className={cn("space-y-4", className)}> | |
| {/* Search Bar */} | |
| <div className="relative"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Søg i datakilder..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="pl-10 pr-10" | |
| /> | |
| {searchQuery && ( | |
| <button | |
| onClick={() => setSearchQuery('')} | |
| className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" | |
| > | |
| <X className="h-4 w-4" /> | |
| </button> | |
| )} | |
| </div> | |
| {/* Category Tabs */} | |
| <Tabs value={selectedCategory} onValueChange={setSelectedCategory}> | |
| <TabsList className="w-full flex-wrap h-auto gap-1 p-1"> | |
| {categories.map(cat => ( | |
| <TabsTrigger | |
| key={cat} | |
| value={cat} | |
| className="text-xs capitalize" | |
| > | |
| {cat === 'all' ? 'Alle' : cat} | |
| <Badge variant="secondary" className="ml-1 h-4 px-1 text-[10px]"> | |
| {cat === 'all' | |
| ? sources.length | |
| : sources.filter(s => s.category === cat).length} | |
| </Badge> | |
| </TabsTrigger> | |
| ))} | |
| </TabsList> | |
| </Tabs> | |
| {/* Loading State */} | |
| {isLoading && ( | |
| <div className="flex items-center justify-center py-8"> | |
| <RefreshCw className="h-6 w-6 animate-spin text-primary" /> | |
| <span className="ml-2 text-muted-foreground">Henter datakilder...</span> | |
| </div> | |
| )} | |
| {/* Error State */} | |
| {error && ( | |
| <div className="p-4 bg-destructive/10 border border-destructive/30 rounded-lg"> | |
| <p className="text-sm text-destructive">{error}</p> | |
| <Button variant="outline" size="sm" onClick={fetchSources} className="mt-2"> | |
| Prøv igen | |
| </Button> | |
| </div> | |
| )} | |
| {/* Sources List */} | |
| {!isLoading && !error && ( | |
| <ScrollArea className="h-[400px] pr-4"> | |
| <div className="space-y-6"> | |
| {Object.entries(groupedSources).map(([category, categorySources]) => { | |
| const Icon = categoryIcons[category] || Database; | |
| return ( | |
| <div key={category}> | |
| {/* Category Header */} | |
| <div className="flex items-center gap-2 mb-2 sticky top-0 bg-background py-1"> | |
| <Icon className="h-4 w-4 text-primary" /> | |
| <span className="text-xs font-mono uppercase tracking-wider text-muted-foreground"> | |
| {category} | |
| </span> | |
| <span className="text-xs text-muted-foreground"> | |
| ({categorySources.length}) | |
| </span> | |
| </div> | |
| {/* Sources Grid */} | |
| <div className="grid gap-2"> | |
| {categorySources.map(source => { | |
| const isSelected = selectedSource?.id === source.id; | |
| const isTesting = testingSource === source.id; | |
| return ( | |
| <div | |
| key={source.id} | |
| onClick={() => handleSelect(source)} | |
| className={cn( | |
| "p-3 rounded-lg border cursor-pointer transition-all", | |
| "hover:border-primary/50 hover:bg-primary/5", | |
| isSelected | |
| ? "bg-primary/10 border-primary/50 ring-1 ring-primary/30" | |
| : "bg-secondary/30 border-border/30" | |
| )} | |
| > | |
| <div className="flex items-start justify-between gap-2"> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| <span className="font-medium text-sm truncate"> | |
| {source.name} | |
| </span> | |
| <Badge | |
| variant="outline" | |
| className={cn("text-[10px] px-1.5 h-5", typeColors[source.type])} | |
| > | |
| {source.type.toUpperCase()} | |
| </Badge> | |
| {isSelected && ( | |
| <Check className="h-4 w-4 text-primary shrink-0" /> | |
| )} | |
| </div> | |
| <p className="text-xs text-muted-foreground mt-1 line-clamp-2"> | |
| {source.description} | |
| </p> | |
| <div className="flex items-center gap-2 mt-2"> | |
| <code className="text-[10px] text-muted-foreground bg-secondary/50 px-1.5 py-0.5 rounded"> | |
| {source.endpoint} | |
| </code> | |
| {source.refreshInterval && ( | |
| <span className="text-[10px] text-muted-foreground"> | |
| {Math.round(source.refreshInterval / 1000)}s | |
| </span> | |
| )} | |
| </div> | |
| {/* Tags */} | |
| {source.tags.length > 0 && ( | |
| <div className="flex flex-wrap gap-1 mt-2"> | |
| {source.tags.slice(0, 4).map(tag => ( | |
| <span | |
| key={tag} | |
| className="text-[9px] px-1.5 py-0.5 bg-secondary rounded text-muted-foreground" | |
| > | |
| {tag} | |
| </span> | |
| ))} | |
| {source.tags.length > 4 && ( | |
| <span className="text-[9px] text-muted-foreground"> | |
| +{source.tags.length - 4} | |
| </span> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-7 w-7 shrink-0" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| testConnection(source.id); | |
| }} | |
| disabled={isTesting} | |
| > | |
| <RefreshCw className={cn("h-3 w-3", isTesting && "animate-spin")} /> | |
| </Button> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| {/* Empty State */} | |
| {filteredSources.length === 0 && ( | |
| <div className="text-center py-8"> | |
| <Database className="h-12 w-12 mx-auto text-muted-foreground/50 mb-2" /> | |
| <p className="text-sm text-muted-foreground"> | |
| {searchQuery | |
| ? `Ingen datakilder matcher "${searchQuery}"` | |
| : 'Ingen datakilder fundet'} | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| </ScrollArea> | |
| )} | |
| {/* Footer Stats */} | |
| <div className="flex items-center justify-between text-xs text-muted-foreground border-t border-border/30 pt-3"> | |
| <span> | |
| {filteredSources.length} af {sources.length} datakilder | |
| </span> | |
| <Button variant="ghost" size="sm" onClick={fetchSources} disabled={isLoading}> | |
| <RefreshCw className={cn("h-3 w-3 mr-1", isLoading && "animate-spin")} /> | |
| Opdater | |
| </Button> | |
| </div> | |
| </div> | |
| ); | |
| // Inline mode | |
| if (inline) { | |
| return content; | |
| } | |
| // Dialog mode | |
| return ( | |
| <Dialog open={open} onOpenChange={onOpenChange}> | |
| <DialogContent className="max-w-2xl max-h-[90vh]"> | |
| <DialogHeader> | |
| <DialogTitle className="flex items-center gap-2"> | |
| <Database className="h-5 w-5 text-primary" /> | |
| Vælg Datakilde | |
| </DialogTitle> | |
| <DialogDescription> | |
| Vælg en datakilde fra listen nedenfor. Datakilder opdateres automatisk. | |
| </DialogDescription> | |
| </DialogHeader> | |
| {content} | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| } | |
| // Compact trigger button for CyberCard | |
| export function DataSourceTrigger({ | |
| selectedSource, | |
| onClick, | |
| className | |
| }: { | |
| selectedSource?: DataSourceConfig; | |
| onClick: () => void; | |
| className?: string; | |
| }) { | |
| return ( | |
| <button | |
| onClick={onClick} | |
| className={cn( | |
| "flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors", | |
| className | |
| )} | |
| > | |
| <Database className="h-3 w-3" /> | |
| <span className="truncate max-w-[100px]"> | |
| {selectedSource?.name || 'Vælg kilde'} | |
| </span> | |
| </button> | |
| ); | |
| } | |
| export default DataSourceSelector; | |