Kraft102's picture
fix: sql.js Docker/Alpine compatibility layer for PatternMemory and FailureMemory
5a81b95
/**
* 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;