import { useState, useEffect } from "react"; import { useTranslation } from 'react-i18next'; import { api } from "@/lib/api"; import { DataSourceForm, type DataSourceConfig } from "@/components/DataSourceForm"; import { Button } from "@/components/ui/button"; import { Plus, Database, Pencil, Trash2, Loader2, Info, ChevronLeft, FileText, Search, Network, GripVertical } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useProjectStore } from "@/store/projectStore"; import { useNavigate } from "react-router-dom"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, rectSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; const SOURCE_TYPES = [ { id: "csv", name: "CSV Upload", icon: }, { id: "bigquery", name: "BigQuery", icon: }, { id: "postgres", name: "PostgreSQL", icon: }, { id: "supabase", name: "Supabase", icon: }, { id: "mysql", name: "MySQL", icon: }, { id: "oracle", name: "Oracle", icon: }, { id: "sqlserver", name: "SQL Server", icon: }, { id: "clickhouse", name: "ClickHouse", icon: }, { id: "trino", name: "Trino", icon: }, { id: "snowflake", name: "Snowflake", icon: }, { id: "athena-trino", name: "Athena (Trino)", icon: }, { id: "redshift", name: "Redshift", icon: }, { id: "databricks", name: "Databricks", icon: }, { id: "emr-spark", name: "EMR (Spark)", icon: }, { id: "athena-spark", name: "Athena (Spark)", icon: }, { id: "spark", name: "Spark", icon: }, { id: "sqlite", name: "SQLite", icon: }, { id: "parquet", name: "Parquet", icon: }, ]; export function DataSources() { const { t } = useTranslation(); const [datasources, setDatasources] = useState([]); const [isLoading, setIsLoading] = useState(false); const [view, setView] = useState<"list" | "select-type">("list"); const [isOpen, setIsOpen] = useState(false); const [editingDs, setEditingDs] = useState(null); const [selectedType, setSelectedType] = useState(null); const { currentProject } = useProjectStore(); const navigate = useNavigate(); useEffect(() => { if (currentProject) { fetchDataSources(); } }, [currentProject]); const fetchDataSources = async () => { if (!currentProject) return; setIsLoading(true); try { const data = await api.get(`/api/v1/datasources?project_id=${currentProject.id}`); // 从 localStorage 中恢复顺序 const savedOrderStr = localStorage.getItem(`datasources_order_${currentProject.id}`); if (savedOrderStr) { try { const savedOrder = JSON.parse(savedOrderStr) as string[]; // 按照保存的 ID 顺序重新排列,同时把新添加的数据源放在末尾 data.sort((a, b) => { const indexA = savedOrder.indexOf(a.id as unknown as string); const indexB = savedOrder.indexOf(b.id as unknown as string); if (indexA === -1 && indexB === -1) return 0; if (indexA === -1) return 1; if (indexB === -1) return -1; return indexA - indexB; }); } catch (e) { console.error("Failed to parse saved datasource order", e); } } setDatasources(data); } catch (e) { console.error("Failed to fetch data sources", e); } finally { setIsLoading(false); } } const handleCreate = () => { setEditingDs(null); setSelectedType(null); setView("select-type"); }; const handleSelectType = (typeId: string) => { setSelectedType(typeId); setIsOpen(true); }; const handleEdit = (ds: DataSourceConfig) => { setEditingDs(ds); setSelectedType(ds.type); setIsOpen(true); }; const handleDelete = async (id: number) => { if (!window.confirm(t('confirmDeleteDataSource'))) return; try { await api.delete(`/api/v1/datasources/${id}`); fetchDataSources(); } catch (e) { console.error("Failed to delete data source", e); } }; const handleSubmit = async (data: Omit) => { if (!currentProject) return; try { if (editingDs?.id) { await api.put(`/api/v1/datasources/${editingDs.id}`, { ...data, project_id: currentProject.id }); } else { await api.post("/api/v1/datasources", { ...data, project_id: currentProject.id }); } setIsOpen(false); fetchDataSources(); } catch (e) { console.error("Failed to save data source", e); alert(t('saveFailed') + (e as any).message); } }; const handleTest = async (type: string, config: Record) => { try { const res = await api.post<{ success: boolean; message: string }>("/api/v1/datasources/test", { type, config }); return res.success; } catch (e) { console.error("Test connection failed", e); throw e; } }; if (view === "select-type") { return (

Connect an external data source

dbt integration is available for PostgreSQL, MySQL, BigQuery, Redshift, and Snowflake (For Essential Plan and above). Contact Us to suggest new data sources.

{SOURCE_TYPES.map((type) => ( ))}
{ setIsOpen(open); if (!open && !editingDs) setSelectedType(null); }}> {editingDs ? t('editDataSource') : t('createNewDataSourceWithType', { type: SOURCE_TYPES.find(t => t.id === selectedType)?.name || "" })}
setIsOpen(false)} />
); } // Helper function to extract host and db from connection string const parseConnectionString = (url: string | undefined, type: 'host' | 'database') => { if (!url) return null; try { // Very basic parser for postgresql://user:pass@host:port/dbname format // Works for postgresql, mysql, etc. const withoutScheme = url.split('://')[1]; if (!withoutScheme) return null; const parts = withoutScheme.split('@'); const hostPortPath = parts.length > 1 ? parts[1] : parts[0]; const pathParts = hostPortPath.split('/'); const hostAndPort = pathParts[0]; const host = hostAndPort.split(':')[0]; let db = pathParts.length > 1 ? pathParts[1] : null; if (db && db.includes('?')) { db = db.split('?')[0]; // Remove query params like ?sslmode=require } return type === 'host' ? host : db; } catch (e) { return null; } }; const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, // Require 8px movement before drag starts to avoid accidental drags }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { setDatasources((items) => { const oldIndex = items.findIndex((item) => item.id === active.id); const newIndex = items.findIndex((item) => item.id === over.id); const newItems = arrayMove(items, oldIndex, newIndex); // 保存新的顺序到 localStorage if (currentProject) { localStorage.setItem( `datasources_order_${currentProject.id}`, JSON.stringify(newItems.map(i => i.id)) ); } return newItems; }); } }; // Sortable Item Component const SortableDataSourceCard = ({ ds }: { ds: DataSourceConfig }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: ds.id! }); const style = { transform: CSS.Transform.toString(transform), transition, }; return (

{ds.name}

{ds.type}

Host {ds.config.host || parseConnectionString(ds.config.connection_string, 'host') || "Local / File"}
Database {ds.config.database || parseConnectionString(ds.config.connection_string, 'database') || (ds.config.file_path ? ds.config.file_path.split('/').pop() : "-")}
); }; return (

{t('dataSourceConfig')}

{t('manageDataSourceConnections')}

{isLoading ? (
) : datasources.length === 0 ? (

{t('noDataSources')}

{t('clickTopRightToAddFirstDataSource')}

) : ( ds.id!)} strategy={rectSortingStrategy} >
{datasources.map((ds) => ( ))}
)}
{ setIsOpen(open); if (!open && !editingDs) setSelectedType(null); }}> {editingDs ? t('editDataSourceWithType', { type: SOURCE_TYPES.find(t => t.id === editingDs.type)?.name || editingDs.type }) : t('createNewDataSourceWithType', { type: SOURCE_TYPES.find(t => t.id === selectedType)?.name || "" })}
setIsOpen(false)} />
); }