"use client"; import { format, subDays, subYears } from "date-fns"; import { useAtomValue } from "jotai"; import { ArrowLeft, Cable, Calendar as CalendarIcon, Check, ChevronRight, Loader2, Search, } from "lucide-react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { type FC, useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { indexConnectorMutationAtom, updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { useLogsSummary } from "@/hooks/use-logs"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { authenticatedFetch } from "@/lib/auth-utils"; import { queryClient } from "@/lib/query-client/client"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; // Type for the indexing configuration state interface IndexingConfigState { connectorType: string; connectorId: number; connectorTitle: string; } // OAuth Connectors (Quick Connect) const OAUTH_CONNECTORS = [ { id: "google-gmail-connector", title: "Gmail", description: "Search through your emails", connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, authEndpoint: "/api/v1/auth/google/gmail/connector/add/", }, { id: "google-calendar-connector", title: "Google Calendar", description: "Search through your events", connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, authEndpoint: "/api/v1/auth/google/calendar/connector/add/", }, { id: "airtable-connector", title: "Airtable", description: "Search your Airtable bases", connectorType: EnumConnectorName.AIRTABLE_CONNECTOR, authEndpoint: "/api/v1/auth/airtable/connector/add/", }, ]; // Non-OAuth Connectors const OTHER_CONNECTORS = [ { id: "slack-connector", title: "Slack", description: "Search Slack messages", connectorType: EnumConnectorName.SLACK_CONNECTOR, }, { id: "discord-connector", title: "Discord", description: "Search Discord messages", connectorType: EnumConnectorName.DISCORD_CONNECTOR, }, { id: "notion-connector", title: "Notion", description: "Search Notion pages", connectorType: EnumConnectorName.NOTION_CONNECTOR, }, { id: "confluence-connector", title: "Confluence", description: "Search documentation", connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR, }, { id: "bookstack-connector", title: "BookStack", description: "Search BookStack docs", connectorType: EnumConnectorName.BOOKSTACK_CONNECTOR, }, { id: "github-connector", title: "GitHub", description: "Search repositories", connectorType: EnumConnectorName.GITHUB_CONNECTOR, }, { id: "linear-connector", title: "Linear", description: "Search issues & projects", connectorType: EnumConnectorName.LINEAR_CONNECTOR, }, { id: "jira-connector", title: "Jira", description: "Search Jira issues", connectorType: EnumConnectorName.JIRA_CONNECTOR, }, { id: "clickup-connector", title: "ClickUp", description: "Search ClickUp tasks", connectorType: EnumConnectorName.CLICKUP_CONNECTOR, }, { id: "luma-connector", title: "Luma", description: "Search Luma events", connectorType: EnumConnectorName.LUMA_CONNECTOR, }, { id: "elasticsearch-connector", title: "Elasticsearch", description: "Search ES indexes", connectorType: EnumConnectorName.ELASTICSEARCH_CONNECTOR, }, { id: "webcrawler-connector", title: "Web Pages", description: "Crawl web content", connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR, }, { id: "tavily-api", title: "Tavily AI", description: "Search with Tavily", connectorType: EnumConnectorName.TAVILY_API, }, { id: "searxng", title: "SearxNG", description: "Search with SearxNG", connectorType: EnumConnectorName.SEARXNG_API, }, { id: "linkup-api", title: "Linkup API", description: "Search with Linkup", connectorType: EnumConnectorName.LINKUP_API, }, { id: "baidu-search-api", title: "Baidu Search", description: "Search with Baidu", connectorType: EnumConnectorName.BAIDU_SEARCH_API, }, ]; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@/components/ui/tabs"; export const ConnectorIndicator: FC = () => { const router = useRouter(); const searchParams = useSearchParams(); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const { connectors, isLoading: connectorsLoading, refreshConnectors } = useSearchSourceConnectors( false, searchSpaceId ? Number(searchSpaceId) : undefined ); const { data: documentTypeCounts, isLoading: documentTypesLoading } = useAtomValue(documentTypeCountsAtom); const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom); const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom); const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom); const [isOpen, setIsOpen] = useState(false); const [activeTab, setActiveTab] = useState("all"); const [connectingId, setConnectingId] = useState(null); const [isScrolled, setIsScrolled] = useState(false); const [searchQuery, setSearchQuery] = useState(""); // Indexing configuration state (shown after OAuth success) const [indexingConfig, setIndexingConfig] = useState(null); const [startDate, setStartDate] = useState(undefined); const [endDate, setEndDate] = useState(undefined); const [isStartingIndexing, setIsStartingIndexing] = useState(false); // Periodic indexing state const [periodicEnabled, setPeriodicEnabled] = useState(false); const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); // Default: daily // Helper function to get frequency label const getFrequencyLabel = useCallback((minutes: string): string => { switch (minutes) { case "15": return "15 minutes"; case "60": return "hour"; case "360": return "6 hours"; case "720": return "12 hours"; case "1440": return "day"; case "10080": return "week"; default: return `${minutes} minutes`; } }, []); // Track active indexing tasks const { summary: logsSummary } = useLogsSummary( searchSpaceId ? Number(searchSpaceId) : 0, 24, { enablePolling: true, refetchInterval: 5000, } ); // Get connector IDs that are currently being indexed const indexingConnectorIds = useMemo(() => { if (!logsSummary?.active_tasks) return new Set(); return new Set( logsSummary.active_tasks .filter((task) => task.source?.includes("connector_indexing")) .map((task) => { // Extract connector ID from task metadata or source const match = task.source?.match(/connector[_-]?(\d+)/i); return match ? parseInt(match[1], 10) : null; }) .filter((id): id is number => id !== null) ); }, [logsSummary?.active_tasks]); const isLoading = connectorsLoading || documentTypesLoading; // Synchronize state with URL query params useEffect(() => { const modalParam = searchParams.get("modal"); const tabParam = searchParams.get("tab"); const viewParam = searchParams.get("view"); const connectorParam = searchParams.get("connector"); if (modalParam === "connectors") { if (!isOpen) setIsOpen(true); // Detect tab from URL query param if (tabParam === "active" || tabParam === "all") { if (activeTab !== tabParam) setActiveTab(tabParam); } // Restore indexing config view from URL if present (e.g., on page refresh) if (viewParam === "configure" && connectorParam && !indexingConfig) { const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); if (oauthConnector && allConnectors) { const existingConnector = allConnectors.find( (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType ); if (existingConnector) { setIndexingConfig({ connectorType: oauthConnector.connectorType, connectorId: existingConnector.id, connectorTitle: oauthConnector.title, }); } } } } else { if (isOpen) setIsOpen(false); } }, [searchParams, isOpen, activeTab, indexingConfig, allConnectors]); // Detect OAuth success and transition to config view useEffect(() => { const success = searchParams.get("success"); const connectorParam = searchParams.get("connector"); const modalParam = searchParams.get("modal"); if (success === "true" && connectorParam && searchSpaceId && modalParam === "connectors") { // Find the OAuth connector info const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); if (oauthConnector) { // Refetch connectors to get the newly created connector refetchAllConnectors().then((result) => { const newConnector = result.data?.find( (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType ); if (newConnector) { setIndexingConfig({ connectorType: oauthConnector.connectorType, connectorId: newConnector.id, connectorTitle: oauthConnector.title, }); setIsOpen(true); // Update URL to reflect config view (replace success with view=configure) const url = new URL(window.location.href); url.searchParams.delete("success"); url.searchParams.set("view", "configure"); // Keep connector param for URL restoration window.history.replaceState({}, "", url.toString()); } }); } } }, [searchParams, searchSpaceId, refetchAllConnectors]); // Handle starting indexing const handleStartIndexing = useCallback(async () => { if (!indexingConfig || !searchSpaceId) return; setIsStartingIndexing(true); try { const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; // Update periodic indexing settings if enabled if (periodicEnabled) { const frequency = parseInt(frequencyMinutes, 10); await updateConnector({ id: indexingConfig.connectorId, data: { periodic_indexing_enabled: true, indexing_frequency_minutes: frequency, }, }); } await indexConnector({ connector_id: indexingConfig.connectorId, queryParams: { search_space_id: searchSpaceId, start_date: startDateStr, end_date: endDateStr, }, }); toast.success(`${indexingConfig.connectorTitle} indexing started`, { description: periodicEnabled ? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutes)}.` : "You can continue working while we sync your data.", }); // Close the config view and reset state setIndexingConfig(null); setStartDate(undefined); setEndDate(undefined); setPeriodicEnabled(false); setFrequencyMinutes("1440"); // Clear config-related URL params and switch to active tab const url = new URL(window.location.href); url.searchParams.delete("view"); url.searchParams.delete("connector"); url.searchParams.set("tab", "active"); window.history.replaceState({}, "", url.toString()); setActiveTab("active"); // Refresh connectors list refreshConnectors(); queryClient.invalidateQueries({ queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), }); } catch (error) { console.error("Error starting indexing:", error); toast.error("Failed to start indexing"); } finally { setIsStartingIndexing(false); } }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, refreshConnectors, getFrequencyLabel]); // Handle skipping indexing for now const handleSkipIndexing = useCallback(() => { setIndexingConfig(null); setStartDate(undefined); setEndDate(undefined); setPeriodicEnabled(false); setFrequencyMinutes("1440"); // Clear config-related URL params const url = new URL(window.location.href); url.searchParams.delete("view"); url.searchParams.delete("connector"); window.history.replaceState({}, "", url.toString()); }, []); // Quick date range handlers const handleLast30Days = useCallback(() => { const today = new Date(); setStartDate(subDays(today, 30)); setEndDate(today); }, []); const handleLastYear = useCallback(() => { const today = new Date(); setStartDate(subYears(today, 1)); setEndDate(today); }, []); const handleClearDates = useCallback(() => { setStartDate(undefined); setEndDate(undefined); }, []); // Get document types that have documents in the search space const activeDocumentTypes = documentTypeCounts ? Object.entries(documentTypeCounts).filter(([, count]) => count > 0) : []; const hasConnectors = connectors.length > 0; const hasSources = hasConnectors || activeDocumentTypes.length > 0; const totalSourceCount = connectors.length + activeDocumentTypes.length; // Check which connectors are already connected const connectedTypes = new Set( (allConnectors || []).map((c: SearchSourceConnector) => c.connector_type) ); // Filter connectors based on search const filteredOAuth = OAUTH_CONNECTORS.filter(c => c.title.toLowerCase().includes(searchQuery.toLowerCase()) || c.description.toLowerCase().includes(searchQuery.toLowerCase()) ); const filteredOther = OTHER_CONNECTORS.filter(c => c.title.toLowerCase().includes(searchQuery.toLowerCase()) || c.description.toLowerCase().includes(searchQuery.toLowerCase()) ); // Handle OAuth connection const handleConnectOAuth = useCallback( async (connector: (typeof OAUTH_CONNECTORS)[0]) => { if (!searchSpaceId || !connector.authEndpoint) return; try { setConnectingId(connector.id); const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, { method: "GET" } ); if (!response.ok) { throw new Error(`Failed to initiate ${connector.title} OAuth`); } const data = await response.json(); window.location.href = data.auth_url; } catch (error) { console.error(`Error connecting to ${connector.title}:`, error); toast.error(`Failed to connect to ${connector.title}`); } finally { setConnectingId(null); } }, [searchSpaceId] ); const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); if (open) { // Add modal query params to current URL const url = new URL(window.location.href); url.searchParams.set("modal", "connectors"); url.searchParams.set("tab", activeTab); window.history.pushState({ modal: true }, "", url.toString()); } else { // Remove modal query params from URL const url = new URL(window.location.href); url.searchParams.delete("modal"); url.searchParams.delete("tab"); url.searchParams.delete("success"); url.searchParams.delete("connector"); url.searchParams.delete("view"); window.history.pushState({ modal: false }, "", url.toString()); setIsScrolled(false); setSearchQuery(""); // Reset indexing config when closing if (!isStartingIndexing) { setIndexingConfig(null); setStartDate(undefined); setEndDate(undefined); setPeriodicEnabled(false); setFrequencyMinutes("1440"); } } }, [activeTab, isStartingIndexing] ); const handleTabChange = useCallback( (value: string) => { setActiveTab(value); // Update tab query param const url = new URL(window.location.href); url.searchParams.set("tab", value); window.history.replaceState({ modal: true }, "", url.toString()); }, [] ); const handleScroll = useCallback((e: React.UIEvent) => { setIsScrolled(e.currentTarget.scrollTop > 0); }, []); if (!searchSpaceId) return null; return ( handleOpenChange(true)} > {isLoading ? ( ) : ( <> {totalSourceCount > 0 && ( {totalSourceCount > 99 ? "99+" : totalSourceCount} )} )} {/* Indexing Configuration View - shown after OAuth success */} {indexingConfig ? (
{/* Fixed Header */}
{/* Back button */} {/* Success header */}

{indexingConfig.connectorTitle} Connected!

Configure when to start syncing your data

{/* Scrollable Content */}

Select Date Range

Choose how far back you want to sync your data. You can always re-index later with different dates.

{/* Start Date */}
date > new Date()} />
{/* End Date */}
date > new Date() || (startDate ? date < startDate : false)} />
{/* Quick date range buttons */}
{/* Periodic Indexing Configuration */}

Enable Periodic Sync

Automatically re-index at regular intervals

{periodicEnabled && (
)}
{/* Info box */}
{getConnectorIcon(indexingConfig.connectorType, "size-4")}

Indexing runs in the background

You can continue using SurfSense while we sync your data. Check the Active tab to see progress.

{/* Fixed Footer - Action buttons */}
) : ( {/* Header */}
Connectors Search across all your apps and data in one place.
All Connectors Active {totalSourceCount > 0 && ( {totalSourceCount} )}
setSearchQuery(e.target.value)} />
{/* Content */}
{/* Quick Connect */} {filteredOAuth.length > 0 && (

Quick Connect

{filteredOAuth.map((connector) => { const isConnected = connectedTypes.has(connector.connectorType); const isConnecting = connectingId === connector.id; return (
{getConnectorIcon(connector.connectorType, "size-6")}
{connector.title} {isConnected && ( )}

{isConnected ? "Connected" : connector.description}

); })}
)} {/* More Integrations */} {filteredOther.length > 0 && (

More Integrations

{filteredOther.map((connector) => { const isConnected = connectedTypes.has(connector.connectorType); return (
{getConnectorIcon(connector.connectorType, "size-6")}
{connector.title} {isConnected && ( )}

{connector.description}

); })}
)}
{hasSources ? (

Currently Active

{activeDocumentTypes.map(([docType, count]) => (
{getConnectorIcon(docType, "size-6")}

{getDocumentTypeLabel(docType)}

{count as number} documents indexed

))} {connectors.map((connector) => { const isIndexing = indexingConnectorIds.has(connector.id); const activeTask = logsSummary?.active_tasks?.find( (task) => task.source?.includes(`connector_${connector.id}`) || task.source?.includes(`connector-${connector.id}`) ); return (
{getConnectorIcon(connector.connector_type, "size-6")}

{connector.name}

{isIndexing ? (

Indexing... {activeTask?.message && ( • {activeTask.message} )}

) : (

{connector.last_indexed_at ? `Last indexed: ${format(new Date(connector.last_indexed_at), "MMM d, yyyy")}` : "Never indexed"}

)}
); })}
) : (

No active sources

Connect your first service to start searching across all your data.

Browse available connectors
)}
{/* Bottom fade shadow */}
)}
); };