diff --git a/surfsense_web/app/dashboard/[workspace_id]/connectors/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/connectors/page.tsx new file mode 100644 index 000000000..d43a54d3d --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/connectors/page.tsx @@ -0,0 +1,5 @@ +import { ConnectorsSection } from "@/components/assistant-ui/connector-popup/connectors-panel"; + +export default function ConnectorsPage() { + return ; +} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx b/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx new file mode 100644 index 000000000..4e0e8b4b1 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx @@ -0,0 +1,548 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { ChevronRight, LayoutGrid, Search, TriangleAlert, X } from "lucide-react"; +import { type ReactNode, useMemo, useState } from "react"; +import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; +import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerHandle, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { Separator } from "@/components/ui/separator"; +import { Spinner } from "@/components/ui/spinner"; +import { EnumConnectorName } from "@/contracts/enums/connector"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { connectorIndexingMetadata } from "@/contracts/types/inbox.types"; +import { useConnectorsSync } from "@/hooks/use-connectors-sync"; +import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts"; +import { cn } from "@/lib/utils"; +import { ConnectorConnectView } from "./connector-configs/views/connector-connect-view"; +import { ConnectorEditView } from "./connector-configs/views/connector-edit-view"; +import { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view"; +import { COMPOSIO_CONNECTORS, getConnectorTitle, OAUTH_CONNECTORS } from "./constants/connector-constants"; +import { useConnectorDialog } from "./hooks/use-connector-dialog"; +import { useIndexingConnectors } from "./hooks/use-indexing-connectors"; +import { AllConnectorsTab } from "./tabs/all-connectors-tab"; +import { ConnectorAccountsListView } from "./views/connector-accounts-list-view"; +import { YouTubeCrawlerView } from "./views/youtube-crawler-view"; + +/** Health of a connected connector type, derived from indexing + inbox state. */ +type ConnectorHealth = "syncing" | "failed" | "ok"; + +interface ConnectedRow { + type: string; + title: string; + connectors: SearchSourceConnector[]; + accountCount: number; + health: ConnectorHealth; + errorMessage?: string; +} + +/** + * Connector management surface (single `/connectors` route) rendered inside the + * workspace panel. Stateful master–detail: + * - sub-rail: Overview + a flat "Your connectors" list (only what's connected, + * each with a live status glyph) + an "Add MCP server" action; + * - detail pane: the flat catalog (search + cards) OR one of the existing flow + * views (connect / edit / indexing / accounts / YouTube), reused verbatim + * from the former dialog. + * + * Unconnected connectors are one click from the catalog cards, so they never + * clutter the rail. The hook's internal `isOpen`/`connectorDialogOpenAtom` is + * inert on a route and ignored. + */ +export function ConnectorsSection() { + const workspaceId = useAtomValue(activeWorkspaceIdAtom); + const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId); + const statusInboxItems = useAtomValue(statusInboxItemsAtom); + const [drawerOpen, setDrawerOpen] = useState(false); + + const inboxItems = useMemo( + () => statusInboxItems.filter((item) => item.type === "connector_indexing"), + [statusInboxItems] + ); + + const { + connectingId, + searchQuery, + indexingConfig, + indexingConnector, + indexingConnectorConfig, + editingConnector, + connectingConnectorType, + isCreatingConnector, + startDate, + endDate, + isStartingIndexing, + isSaving, + isDisconnecting, + periodicEnabled, + frequencyMinutes, + enableVisionLlm, + allConnectors, + viewingAccountsType, + viewingMCPList, + isYouTubeView, + isFromOAuth, + setSearchQuery, + setStartDate, + setEndDate, + setPeriodicEnabled, + setFrequencyMinutes, + setEnableVisionLlm, + handleOpenChange, + handleConnectOAuth, + handleConnectNonOAuth, + handleCreateWebcrawler, + handleCreateYouTubeCrawler, + handleSubmitConnectForm, + handleStartIndexing, + handleSkipIndexing, + handleStartEdit, + handleSaveConnector, + handleDisconnectConnector, + handleBackFromEdit, + handleBackFromConnect, + handleBackFromYouTube, + handleViewAccountsList, + handleBackFromAccountsList, + handleViewMCPList, + handleBackFromMCPList, + handleAddNewMCPFromList, + handleQuickIndexConnector, + connectorConfig, + setConnectorConfig, + setIndexingConnectorConfig, + setConnectorName, + } = useConnectorDialog(); + + const { + connectors: connectorsFromSync = [], + loading: connectorsLoading, + error: connectorsError, + refreshConnectors: refreshConnectorsSync, + } = useConnectorsSync(workspaceId); + + const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError); + const connectors = (useSyncData ? connectorsFromSync : allConnectors || []) as SearchSourceConnector[]; + + const refreshConnectors = async () => { + if (useSyncData) { + await refreshConnectorsSync(); + } + }; + + const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors( + connectors, + inboxItems + ); + + const connectedTypes = useMemo( + () => new Set(connectors.map((c) => c.connector_type)), + [connectors] + ); + + // Latest indexing status per connector id, parsed from the status inbox. Used + // to drive the "Your connectors" status glyphs (syncing / failed). + const statusByConnectorId = useMemo(() => { + const map = new Map(); + for (const item of inboxItems) { + const parsed = connectorIndexingMetadata.safeParse(item.metadata); + if (!parsed.success) continue; + const at = item.updated_at ?? item.created_at; + const prev = map.get(parsed.data.connector_id); + if (!prev || at > prev.at) { + map.set(parsed.data.connector_id, { + status: parsed.data.status, + error: parsed.data.error_message ?? undefined, + at, + }); + } + } + return map; + }, [inboxItems]); + + // Flat "Your connectors" rows: one per connected type, alphabetized, with a + // derived health (syncing > failed > ok) and account count. + const connectedRows = useMemo(() => { + const byType = new Map(); + for (const c of connectors) { + const arr = byType.get(c.connector_type) ?? []; + arr.push(c); + byType.set(c.connector_type, arr); + } + return [...byType.entries()] + .map(([type, list]) => { + const ids = list.map((c) => c.id); + const syncing = ids.some( + (id) => indexingConnectorIds.has(id) || statusByConnectorId.get(id)?.status === "in_progress" + ); + const failedEntry = syncing + ? undefined + : ids.map((id) => statusByConnectorId.get(id)).find((s) => s?.status === "failed"); + return { + type, + title: + type === EnumConnectorName.MCP_CONNECTOR ? "MCP Servers" : getConnectorTitle(type), + connectors: list, + accountCount: list.length, + health: syncing ? "syncing" : failedEntry ? "failed" : "ok", + errorMessage: failedEntry?.error, + } satisfies ConnectedRow; + }) + .sort((a, b) => a.title.localeCompare(b.title)); + }, [connectors, indexingConnectorIds, statusByConnectorId]); + + if (!workspaceId) return null; + + const activeConnectorType = editingConnector + ? editingConnector.connector_type + : connectingConnectorType || + viewingAccountsType?.connectorType || + (viewingMCPList ? EnumConnectorName.MCP_CONNECTOR : null); + + const flowView = ((): ReactNode => { + if (isYouTubeView) { + return ; + } + if (viewingMCPList) { + return ( + + ); + } + if (viewingAccountsType) { + return ( + { + const oauthConnector = + OAUTH_CONNECTORS.find( + (c) => c.connectorType === viewingAccountsType.connectorType + ) || + COMPOSIO_CONNECTORS.find( + (c) => c.connectorType === viewingAccountsType.connectorType + ); + if (oauthConnector) { + handleConnectOAuth(oauthConnector); + } + }} + isConnecting={connectingId !== null} + /> + ); + } + if (connectingConnectorType) { + return ( + handleSubmitConnectForm(formData, startIndexing)} + onBack={handleBackFromConnect} + isSubmitting={isCreatingConnector} + /> + ); + } + if (editingConnector) { + return ( + c.id === editingConnector.id)?.last_indexed_at ?? + editingConnector.last_indexed_at, + }} + startDate={startDate} + endDate={endDate} + periodicEnabled={periodicEnabled} + frequencyMinutes={frequencyMinutes} + enableVisionLlm={enableVisionLlm} + isSaving={isSaving} + isDisconnecting={isDisconnecting} + isIndexing={indexingConnectorIds.has(editingConnector.id)} + workspaceId={workspaceId?.toString()} + onStartDateChange={setStartDate} + onEndDateChange={setEndDate} + onPeriodicEnabledChange={setPeriodicEnabled} + onFrequencyChange={setFrequencyMinutes} + onEnableVisionLlmChange={setEnableVisionLlm} + onSave={() => { + startIndexing(editingConnector.id); + handleSaveConnector(() => refreshConnectors()); + }} + onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())} + onBack={handleBackFromEdit} + onQuickIndex={(() => { + const cfg = connectorConfig || editingConnector.config; + const isDriveOrOneDrive = + editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || + editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || + editingConnector.connector_type === "ONEDRIVE_CONNECTOR" || + editingConnector.connector_type === "DROPBOX_CONNECTOR"; + const hasDriveItems = isDriveOrOneDrive + ? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 || + ((cfg?.selected_files as unknown[]) ?? []).length > 0 + : true; + if (!hasDriveItems) return undefined; + return () => { + startIndexing(editingConnector.id); + handleQuickIndexConnector( + editingConnector.id, + editingConnector.connector_type, + stopIndexing, + startDate, + endDate + ); + }; + })()} + onConfigChange={setConnectorConfig} + onNameChange={setConnectorName} + /> + ); + } + if (indexingConfig) { + return ( + { + if (indexingConfig.connectorId) { + startIndexing(indexingConfig.connectorId); + } + handleStartIndexing(() => refreshConnectors()); + }} + onSkip={handleSkipIndexing} + /> + ); + } + return null; + })(); + + const isFlowActive = flowView !== null; + + const detailTitle = + isFlowActive && activeConnectorType ? getConnectorTitle(activeConnectorType) : "Overview"; + + const hasConnected = connectedRows.length > 0; + + // Rail navigation: clear any open flow first (handleOpenChange(false) is the + // hook's full reset), then apply the target management action. + const resetFlow = () => handleOpenChange(false); + const goOverview = () => { + setDrawerOpen(false); + resetFlow(); + }; + const manageRow = (row: ConnectedRow) => { + setDrawerOpen(false); + resetFlow(); + if (row.type === EnumConnectorName.MCP_CONNECTOR) { + handleViewMCPList(); + return; + } + if (row.accountCount > 1) { + handleViewAccountsList(row.type, row.title); + return; + } + handleStartEdit(row.connectors[0]); + }; + const addMcpServer = () => { + setDrawerOpen(false); + resetFlow(); + handleConnectNonOAuth?.(EnumConnectorName.MCP_CONNECTOR); + }; + + const navBtnClass = (active: boolean) => + cn( + "inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none", + active + ? "bg-accent text-accent-foreground" + : "text-muted-foreground hover:bg-accent hover:text-accent-foreground" + ); + + const railLabel = (text: string) => ( +

{text}

+ ); + + const renderNav = () => ( + <> + + + {hasConnected && ( + <> + + {railLabel("Your connectors")} +
+ {connectedRows.map((row) => { + const isActive = isFlowActive && activeConnectorType === row.type; + return ( + + ); + })} +
+ + )} + + + + + ); + + const catalog = ( +
+
+
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+
+ + +
+ ); + + return ( +
+
+

+ Connectors +

+ + + + + + + + Connectors navigation + + + +
+ +
+
+

{detailTitle}

+ +
+
{isFlowActive ? flowView : catalog}
+
+
+ ); +}