From 36d25e9505f27a471078c665bb51d604f185881f Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:29:33 +0530 Subject: [PATCH] feat: Add Elasticsearch connector support with configuration forms, enhance connector dialog for indexing settings, and implement date range selection for periodic sync. --- .../components/assistant-ui/attachment.tsx | 77 +- .../components/date-range-selector.tsx | 4 +- .../components/periodic-sync-config.tsx | 4 +- .../components/elasticsearch-connect-form.tsx | 791 ++++++++++++++++++ .../components/linear-connect-form.tsx | 73 +- .../components/tavily-api-connect-form.tsx | 13 + .../connect-forms/connector-benefits.ts | 13 +- .../connector-popup/connect-forms/index.tsx | 9 +- .../components/elasticsearch-config.tsx | 438 ++++++++++ .../connector-configs/index.tsx | 3 + .../views/connector-connect-view.tsx | 1 + .../hooks/use-connector-dialog.ts | 127 ++- .../tabs/active-connectors-tab.tsx | 9 +- .../tabs/all-connectors-tab.tsx | 3 +- 14 files changed, 1521 insertions(+), 44 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/elasticsearch-connect-form.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/elasticsearch-config.tsx diff --git a/surfsense_web/components/assistant-ui/attachment.tsx b/surfsense_web/components/assistant-ui/attachment.tsx index 7fce93321..2400f3966 100644 --- a/surfsense_web/components/assistant-ui/attachment.tsx +++ b/surfsense_web/components/assistant-ui/attachment.tsx @@ -7,12 +7,19 @@ import { useAssistantApi, useAssistantState, } from "@assistant-ui/react"; -import { FileText, Loader2, PlusIcon, XIcon } from "lucide-react"; +import { FileText, Loader2, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react"; import Image from "next/image"; -import { type FC, type PropsWithChildren, useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { type FC, type PropsWithChildren, useRef, useEffect, useState } from "react"; import { useShallow } from "zustand/shallow"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -184,18 +191,18 @@ const AttachmentUI: FC = () => { > -
-
+
{isComposer && !isProcessing && } @@ -312,18 +319,54 @@ export const ComposerAttachments: FC = () => { }; export const ComposerAddAttachment: FC = () => { + const router = useRouter(); + const params = useParams(); + const searchSpaceId = params.search_space_id as string; + const chatAttachmentInputRef = useRef(null); + + const handleFileUpload = () => { + router.push(`/dashboard/${searchSpaceId}/sources/add?tab=documents`); + }; + + const handleChatAttachment = () => { + chatAttachmentInputRef.current?.click(); + }; + return ( - - - - - + <> + + + + + + + + + + Add attachment(s) + + + + File upload + + + + + + + ); }; diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx index 5c7870639..9c84e7f03 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx @@ -40,8 +40,8 @@ export const DateRangeSelector: FC = ({ }; return ( -
-

Select Date Range

+
+

Select Date Range

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

diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx index f8b869a67..1d22e6890 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx @@ -25,10 +25,10 @@ export const PeriodicSyncConfig: FC = ({ onFrequencyChange, }) => { return ( -
+
-

Enable Periodic Sync

+

Enable Periodic Sync

Automatically re-index at regular intervals

diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/elasticsearch-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/elasticsearch-connect-form.tsx new file mode 100644 index 000000000..5420d3bb1 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/elasticsearch-connect-form.tsx @@ -0,0 +1,791 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { Info } from "lucide-react"; +import type { FC } from "react"; +import { useId, useRef } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { EnumConnectorName } from "@/contracts/enums/connector"; +import type { ConnectFormProps } from "../index"; +import { getConnectorBenefits } from "../connector-benefits"; +import { DateRangeSelector } from "../../components/date-range-selector"; +import { useState } from "react"; + +const elasticsearchConnectorFormSchema = z + .object({ + name: z.string().min(3, { + message: "Connector name must be at least 3 characters.", + }), + endpoint_url: z.string().url({ message: "Please enter a valid Elasticsearch endpoint URL." }), + auth_method: z.enum(["basic", "api_key"]), + username: z.string().optional(), + password: z.string().optional(), + ELASTICSEARCH_API_KEY: z.string().optional(), + indices: z.string().optional(), + query: z.string(), + search_fields: z.string().optional(), + max_documents: z.number().min(1).max(10000).optional(), + }) + .refine( + (data) => { + if (data.auth_method === "basic") { + return Boolean(data.username?.trim() && data.password?.trim()); + } + if (data.auth_method === "api_key") { + return Boolean(data.ELASTICSEARCH_API_KEY?.trim()); + } + return true; + }, + { + message: "Authentication credentials are required for the selected method.", + path: ["auth_method"], + } + ); + +type ElasticsearchConnectorFormValues = z.infer; + +export const ElasticsearchConnectForm: FC = ({ + onSubmit, + isSubmitting, +}) => { + const isSubmittingRef = useRef(false); + const authBasicId = useId(); + const authApiKeyId = useId(); + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [periodicEnabled, setPeriodicEnabled] = useState(false); + const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); + + const form = useForm({ + resolver: zodResolver(elasticsearchConnectorFormSchema), + defaultValues: { + name: "Elasticsearch Connector", + endpoint_url: "", + auth_method: "api_key", + username: "", + password: "", + ELASTICSEARCH_API_KEY: "", + indices: "", + query: "*", + search_fields: "", + max_documents: undefined, + }, + }); + + const stringToArray = (str: string): string[] => { + const items = str + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return Array.from(new Set(items)); + }; + + const handleSubmit = async (values: ElasticsearchConnectorFormValues) => { + // Prevent multiple submissions + if (isSubmittingRef.current || isSubmitting) { + return; + } + + isSubmittingRef.current = true; + try { + // Send full URL to backend (backend expects ELASTICSEARCH_URL) + const config: Record = { + ELASTICSEARCH_URL: values.endpoint_url, + // default to verifying certs; expose fields for CA/verify if UI added later + ELASTICSEARCH_VERIFY_CERTS: true, + }; + + if (values.auth_method === "basic") { + if (values.username) config.ELASTICSEARCH_USERNAME = values.username; + if (values.password) config.ELASTICSEARCH_PASSWORD = values.password; + } else if (values.auth_method === "api_key") { + if (values.ELASTICSEARCH_API_KEY) + config.ELASTICSEARCH_API_KEY = values.ELASTICSEARCH_API_KEY; + } + + const indicesInput = values.indices?.trim() ?? ""; + const indicesArr = stringToArray(indicesInput); + config.ELASTICSEARCH_INDEX = + indicesArr.length === 0 ? "*" : indicesArr.length === 1 ? indicesArr[0] : indicesArr; + + if (values.query && values.query !== "*") { + config.ELASTICSEARCH_QUERY = values.query; + } + + if (values.search_fields?.trim()) { + const fields = stringToArray(values.search_fields); + config.ELASTICSEARCH_FIELDS = fields; + config.ELASTICSEARCH_CONTENT_FIELDS = fields; + if (fields.includes("title")) { + config.ELASTICSEARCH_TITLE_FIELD = "title"; + } + } + + if (values.max_documents !== undefined && values.max_documents > 0) { + config.ELASTICSEARCH_MAX_DOCUMENTS = values.max_documents; + } + + await onSubmit({ + name: values.name, + connector_type: EnumConnectorName.ELASTICSEARCH_CONNECTOR, + config, + is_indexable: true, + last_indexed_at: null, + periodic_indexing_enabled: periodicEnabled, + indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null, + next_scheduled_at: null, + startDate, + endDate, + periodicEnabled, + frequencyMinutes, + }); + } finally { + isSubmittingRef.current = false; + } + }; + + return ( +
+ + +
+ API Key Required + + Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect. + +
+
+ +
+
+ + ( + + Connector Name + + + + + A friendly name to identify this connector. + + + + )} + /> + + {/* Connection Details */} +
+

Connection Details

+ + ( + + Elasticsearch Endpoint URL + + + + + Enter the complete Elasticsearch endpoint URL. We'll automatically extract the hostname, port, and SSL settings. + + + + )} + /> + + {/* Show parsed URL details */} + {form.watch("endpoint_url") && ( +
+

Parsed Connection Details:

+
+ {(() => { + try { + const url = new URL(form.watch("endpoint_url")); + return ( + <> +
+ Hostname: {url.hostname} +
+
+ Port:{" "} + {url.port || (url.protocol === "https:" ? "443" : "80")} +
+
+ SSL/TLS:{" "} + {url.protocol === "https:" ? "Enabled" : "Disabled"} +
+ + ); + } catch { + return
Invalid URL format
; + } + })()} +
+
+ )} +
+ + {/* Authentication */} +
+

Authentication

+ + ( + + + { + field.onChange(value); + // Clear auth fields when method changes + if (value !== "basic") { + form.setValue("username", ""); + form.setValue("password", ""); + } + if (value !== "api_key") { + form.setValue("ELASTICSEARCH_API_KEY", ""); + } + }} + value={field.value} + className="flex flex-col space-y-2" + > +
+ + +
+ + + +
+ +
+ + +
+ + + +
+ + + + + )} + /> + + {/* Basic Auth Fields */} + {form.watch("auth_method") === "basic" && ( +
+ ( + + Username + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> +
+ )} + + {/* API Key Field */} + {form.watch("auth_method") === "api_key" && ( + ( + + API Key + + + + + Enter your Elasticsearch API key (base64 encoded). This will be stored securely. + + + + )} + /> + )} +
+ + {/* Index Selection */} + ( + + Index Selection + + + + + Comma-separated indices to search (e.g., "logs-*, documents-*"). + + + + )} + /> + + {/* Show parsed indices as badges */} + {form.watch("indices")?.trim() && ( +
+

Selected Indices:

+
+ {stringToArray(form.watch("indices") ?? "").map((index) => ( + + {index} + + ))} +
+
+ )} + + + + Index Selection Tips + +
    +
  • Use wildcards like "logs-*" to match multiple indices
  • +
  • Separate multiple indices with commas
  • +
  • Leave empty to search all accessible indices including internal ones
  • +
  • Choosing specific indices improves search performance
  • +
+
+
+ + {/* Advanced Configuration */} + + + Advanced Configuration + + {/* Default Search Query */} + ( + + + Default Search Query{" "} + (Optional) + + + + + + Default Elasticsearch query to use for searches. Use "*" to match all documents. + + + + )} + /> + + {/* Form Fields */} + ( + + + Search Fields{" "} + (Optional) + + + + + + Comma-separated list of specific fields to search in (e.g., "title, content, description"). Leave empty to search all fields. + + + + )} + /> + + {/* Show parsed search fields as badges */} + {form.watch("search_fields")?.trim() && ( +
+

Search Fields:

+
+ {stringToArray(form.watch("search_fields") ?? "").map((field) => ( + + {field} + + ))} +
+
+ )} + + ( + + + Maximum Documents{" "} + (Optional) + + + + field.onChange( + e.target.value === "" + ? undefined + : parseInt(e.target.value, 10) + ) + } + /> + + + Maximum number of documents to retrieve per search (1-10,000). Leave empty to use Elasticsearch's default limit. + + + + )} + /> +
+
+
+ + {/* Indexing Configuration */} +
+

Indexing Configuration

+ + {/* Date Range Selector */} + + + {/* Periodic Sync Config */} +
+
+
+

Enable Periodic Sync

+

+ Automatically re-index at regular intervals +

+
+ +
+ + {periodicEnabled && ( +
+
+ + +
+
+ )} +
+
+ + +
+ + {/* What you get section */} + {getConnectorBenefits(EnumConnectorName.ELASTICSEARCH_CONNECTOR) && ( +
+

What you get with Elasticsearch integration:

+
    + {getConnectorBenefits(EnumConnectorName.ELASTICSEARCH_CONNECTOR)?.map((benefit) => ( +
  • {benefit}
  • + ))} +
+
+ )} + + {/* Documentation Section */} + + + + Documentation + + +
+

How it works

+

+ The Elasticsearch connector allows you to search and retrieve documents from your Elasticsearch cluster. Configure connection details, select specific indices, and set search parameters to make your existing data searchable within SurfSense. +

+
+ +
+
+

Connection Setup

+
+
+

Step 1: Get your Elasticsearch endpoint

+

+ You'll need the endpoint URL for your Elasticsearch cluster. This typically looks like: +

+
    +
  • Cloud: https://your-cluster.es.region.aws.com:443
  • +
  • Self-hosted: https://elasticsearch.example.com:9200
  • +
+
+ +
+

Step 2: Configure authentication

+

+ Elasticsearch requires authentication. You can use either: +

+
    +
  • + API Key: A base64-encoded API key. You can create one in Elasticsearch by running: +
    +													POST /_security/api_key
    +												
    +
  • +
  • + Username & Password: Basic authentication using your Elasticsearch username and password. +
  • +
+
+ +
+

Step 3: Select indices

+

+ Specify which indices to search. You can: +

+
    +
  • Use wildcards: logs-* to match multiple indices
  • +
  • List specific indices: logs-2024, documents-2024
  • +
  • Leave empty to search all accessible indices (not recommended for performance)
  • +
+
+
+
+
+ +
+
+

Advanced Configuration

+
+
+

Search Query

+

+ The default query used for searches. Use * to match all documents, or specify a more complex Elasticsearch query. +

+
+ +
+

Search Fields

+

+ Limit searches to specific fields for better performance. Common fields include: +

+
    +
  • title - Document titles
  • +
  • content - Main content
  • +
  • description - Descriptions
  • +
+

+ Leave empty to search all fields in your documents. +

+
+ +
+

Maximum Documents

+

+ Set a limit on the number of documents retrieved per search (1-10,000). This helps control response times and resource usage. Leave empty to use Elasticsearch's default limit. +

+
+
+
+
+ +
+
+

Troubleshooting

+
+
+

Connection Issues

+
    +
  • + Invalid URL: Ensure your endpoint URL includes the protocol (https://) and port number if required. +
  • +
  • + SSL/TLS Errors: Verify that your cluster uses HTTPS and the certificate is valid. Self-signed certificates may require additional configuration. +
  • +
  • + Connection Timeout: Check your network connectivity and firewall settings. Ensure the Elasticsearch cluster is accessible from SurfSense servers. +
  • +
+
+ +
+

Authentication Issues

+
    +
  • + Invalid Credentials: Double-check your username/password or API key. API keys must be base64-encoded. +
  • +
  • + Permission Denied: Ensure your API key or user account has read permissions for the indices you want to search. +
  • +
  • + API Key Format: Elasticsearch API keys are typically base64-encoded strings. Make sure you're using the full key value. +
  • +
+
+ +
+

Search Issues

+
    +
  • + No Results: Verify that your index selection matches existing indices. Use wildcards carefully. +
  • +
  • + Slow Searches: Limit the number of indices or use specific index names instead of wildcards. Reduce the maximum documents limit. +
  • +
  • + Field Not Found: Ensure the search fields you specify actually exist in your Elasticsearch documents. +
  • +
+
+ + + + Need More Help? + + If you continue to experience issues, check your Elasticsearch cluster logs and ensure your cluster version is compatible. For Elasticsearch Cloud deployments, verify your access policies and IP allowlists. + + +
+
+
+
+
+
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/linear-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/linear-connect-form.tsx index 3e1bb370a..cc80b5dd3 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/linear-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/linear-connect-form.tsx @@ -23,9 +23,20 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { EnumConnectorName } from "@/contracts/enums/connector"; import type { ConnectFormProps } from "../index"; import { getConnectorBenefits } from "../connector-benefits"; +import { DateRangeSelector } from "../../components/date-range-selector"; +import { useState } from "react"; const linearConnectorFormSchema = z.object({ name: z.string().min(3, { @@ -48,6 +59,10 @@ export const LinearConnectForm: FC = ({ isSubmitting, }) => { const isSubmittingRef = useRef(false); + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [periodicEnabled, setPeriodicEnabled] = useState(false); + const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); const form = useForm({ resolver: zodResolver(linearConnectorFormSchema), defaultValues: { @@ -72,9 +87,13 @@ export const LinearConnectForm: FC = ({ }, is_indexable: true, last_indexed_at: null, - periodic_indexing_enabled: false, - indexing_frequency_minutes: null, + periodic_indexing_enabled: periodicEnabled, + indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null, next_scheduled_at: null, + startDate, + endDate, + periodicEnabled, + frequencyMinutes, }); } finally { isSubmittingRef.current = false; @@ -148,6 +167,56 @@ export const LinearConnectForm: FC = ({ )} /> + + {/* Indexing Configuration */} +
+

Indexing Configuration

+ + {/* Date Range Selector */} + + + {/* Periodic Sync Config */} +
+
+
+

Enable Periodic Sync

+

+ Automatically re-index at regular intervals +

+
+ +
+ + {periodicEnabled && ( +
+
+ + +
+
+ )} +
+
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/tavily-api-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/tavily-api-connect-form.tsx index 4ce3a751d..ae9ad4144 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/tavily-api-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/tavily-api-connect-form.tsx @@ -19,6 +19,7 @@ import { import { Input } from "@/components/ui/input"; import { EnumConnectorName } from "@/contracts/enums/connector"; import type { ConnectFormProps } from "../index"; +import { getConnectorBenefits } from "../connector-benefits"; const tavilyApiFormSchema = z.object({ name: z.string().min(3, { @@ -139,6 +140,18 @@ export const TavilyApiConnectForm: FC = ({
+ + {/* What you get section */} + {getConnectorBenefits(EnumConnectorName.TAVILY_API) && ( +
+

What you get with Tavily API:

+
    + {getConnectorBenefits(EnumConnectorName.TAVILY_API)?.map((benefit) => ( +
  • {benefit}
  • + ))} +
+
+ )}
); }; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/connector-benefits.ts b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/connector-benefits.ts index 3851a2692..7dd6dd5cf 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/connector-benefits.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/connector-benefits.ts @@ -11,8 +11,19 @@ export function getConnectorBenefits(connectorType: string): string[] | null { "Keep your search results up-to-date with latest Linear content", "Index your Linear issues for enhanced search capabilities", ], + ELASTICSEARCH_CONNECTOR: [ + "Search across your indexed documents and logs", + "Access structured and unstructured data from your cluster", + "Leverage existing Elasticsearch indices for enhanced search", + "Real-time search capabilities with powerful query features", + "Integration with your existing Elasticsearch infrastructure", + ], + TAVILY_API: [ + "AI-powered search results tailored to your queries", + "Real-time information from the web", + "Enhanced search capabilities for your projects", + ], // Add other connectors as needed - // TAVILY_API: [...], // GITHUB_CONNECTOR: [...], }; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx index 4e48d7159..2c0ead6ec 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; +import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form"; import { LinearConnectForm } from "./components/linear-connect-form"; import { TavilyApiConnectForm } from "./components/tavily-api-connect-form"; @@ -10,8 +11,12 @@ export interface ConnectFormProps { is_indexable: boolean; last_indexed_at: null; periodic_indexing_enabled: boolean; - indexing_frequency_minutes: null; + indexing_frequency_minutes: number | null; next_scheduled_at: null; + startDate?: Date; + endDate?: Date; + periodicEnabled?: boolean; + frequencyMinutes?: string; }) => Promise; onBack: () => void; isSubmitting: boolean; @@ -31,6 +36,8 @@ export function getConnectFormComponent( return TavilyApiConnectForm; case "LINEAR_CONNECTOR": return LinearConnectForm; + case "ELASTICSEARCH_CONNECTOR": + return ElasticsearchConnectForm; // Add other connector types here as needed default: return null; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/elasticsearch-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/elasticsearch-config.tsx new file mode 100644 index 000000000..15eda1351 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/elasticsearch-config.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { KeyRound, Server } from "lucide-react"; +import { useState, useEffect } from "react"; +import type { FC } from "react"; +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { useId } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { ConnectorConfigProps } from "../index"; + +export interface ElasticsearchConfigProps extends ConnectorConfigProps { + onNameChange?: (name: string) => void; +} + +export const ElasticsearchConfig: FC = ({ + connector, + onConfigChange, + onNameChange, +}) => { + const authBasicId = useId(); + const authApiKeyId = useId(); + + const [name, setName] = useState(connector.name || ""); + const [endpointUrl, setEndpointUrl] = useState( + (connector.config?.ELASTICSEARCH_URL as string) || "" + ); + const [authMethod, setAuthMethod] = useState<"basic" | "api_key">( + (connector.config?.ELASTICSEARCH_API_KEY ? "api_key" : "basic") as "basic" | "api_key" + ); + const [username, setUsername] = useState( + (connector.config?.ELASTICSEARCH_USERNAME as string) || "" + ); + const [password, setPassword] = useState( + (connector.config?.ELASTICSEARCH_PASSWORD as string) || "" + ); + const [apiKey, setApiKey] = useState( + (connector.config?.ELASTICSEARCH_API_KEY as string) || "" + ); + const [indices, setIndices] = useState( + Array.isArray(connector.config?.ELASTICSEARCH_INDEX) + ? (connector.config?.ELASTICSEARCH_INDEX as string[]).join(", ") + : (connector.config?.ELASTICSEARCH_INDEX as string) || "" + ); + const [query, setQuery] = useState( + (connector.config?.ELASTICSEARCH_QUERY as string) || "*" + ); + const [searchFields, setSearchFields] = useState( + Array.isArray(connector.config?.ELASTICSEARCH_FIELDS) + ? (connector.config?.ELASTICSEARCH_FIELDS as string[]).join(", ") + : "" + ); + const [maxDocuments, setMaxDocuments] = useState( + connector.config?.ELASTICSEARCH_MAX_DOCUMENTS + ? String(connector.config.ELASTICSEARCH_MAX_DOCUMENTS) + : "" + ); + + // Update values when connector changes + useEffect(() => { + setName(connector.name || ""); + setEndpointUrl((connector.config?.ELASTICSEARCH_URL as string) || ""); + setAuthMethod( + (connector.config?.ELASTICSEARCH_API_KEY ? "api_key" : "basic") as "basic" | "api_key" + ); + setUsername((connector.config?.ELASTICSEARCH_USERNAME as string) || ""); + setPassword((connector.config?.ELASTICSEARCH_PASSWORD as string) || ""); + setApiKey((connector.config?.ELASTICSEARCH_API_KEY as string) || ""); + setIndices( + Array.isArray(connector.config?.ELASTICSEARCH_INDEX) + ? (connector.config?.ELASTICSEARCH_INDEX as string[]).join(", ") + : (connector.config?.ELASTICSEARCH_INDEX as string) || "" + ); + setQuery((connector.config?.ELASTICSEARCH_QUERY as string) || "*"); + setSearchFields( + Array.isArray(connector.config?.ELASTICSEARCH_FIELDS) + ? (connector.config?.ELASTICSEARCH_FIELDS as string[]).join(", ") + : "" + ); + setMaxDocuments( + connector.config?.ELASTICSEARCH_MAX_DOCUMENTS + ? String(connector.config.ELASTICSEARCH_MAX_DOCUMENTS) + : "" + ); + }, [connector.config, connector.name]); + + const stringToArray = (str: string): string[] => { + const items = str + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return Array.from(new Set(items)); + }; + + const updateConfig = (updates: Record) => { + if (onConfigChange) { + // Filter out undefined values to remove keys + const filteredUpdates = Object.fromEntries( + Object.entries(updates).filter(([_, value]) => value !== undefined) + ); + const newConfig = { + ...connector.config, + ...filteredUpdates, + }; + // Remove keys that were set to undefined + Object.keys(updates).forEach((key) => { + if (updates[key] === undefined) { + delete newConfig[key]; + } + }); + onConfigChange(newConfig); + } + }; + + const handleNameChange = (value: string) => { + setName(value); + if (onNameChange) { + onNameChange(value); + } + }; + + const handleEndpointUrlChange = (value: string) => { + setEndpointUrl(value); + updateConfig({ ELASTICSEARCH_URL: value }); + }; + + const handleAuthMethodChange = (value: "basic" | "api_key") => { + setAuthMethod(value); + if (value === "basic") { + updateConfig({ + ELASTICSEARCH_API_KEY: undefined, + }); + } else { + updateConfig({ + ELASTICSEARCH_USERNAME: undefined, + ELASTICSEARCH_PASSWORD: undefined, + }); + } + }; + + const handleUsernameChange = (value: string) => { + setUsername(value); + updateConfig({ ELASTICSEARCH_USERNAME: value }); + }; + + const handlePasswordChange = (value: string) => { + setPassword(value); + updateConfig({ ELASTICSEARCH_PASSWORD: value }); + }; + + const handleApiKeyChange = (value: string) => { + setApiKey(value); + updateConfig({ ELASTICSEARCH_API_KEY: value }); + }; + + const handleIndicesChange = (value: string) => { + setIndices(value); + const indicesArr = stringToArray(value); + const indexValue = + indicesArr.length === 0 ? "*" : indicesArr.length === 1 ? indicesArr[0] : indicesArr; + updateConfig({ ELASTICSEARCH_INDEX: indexValue }); + }; + + const handleQueryChange = (value: string) => { + setQuery(value); + if (value && value !== "*") { + updateConfig({ ELASTICSEARCH_QUERY: value }); + } else { + // Remove the key by setting it to undefined + updateConfig({ ELASTICSEARCH_QUERY: undefined }); + } + }; + + const handleSearchFieldsChange = (value: string) => { + setSearchFields(value); + if (value.trim()) { + const fields = stringToArray(value); + updateConfig({ + ELASTICSEARCH_FIELDS: fields, + ELASTICSEARCH_CONTENT_FIELDS: fields, + ELASTICSEARCH_TITLE_FIELD: fields.includes("title") ? "title" : undefined, + }); + } else { + // Remove the keys by setting them to undefined + updateConfig({ + ELASTICSEARCH_FIELDS: undefined, + ELASTICSEARCH_CONTENT_FIELDS: undefined, + ELASTICSEARCH_TITLE_FIELD: undefined, + }); + } + }; + + const handleMaxDocumentsChange = (value: string) => { + setMaxDocuments(value); + if (value && value.trim()) { + const num = parseInt(value, 10); + if (!isNaN(num) && num > 0) { + updateConfig({ ELASTICSEARCH_MAX_DOCUMENTS: num }); + } + } else { + // Remove the key by setting it to undefined + updateConfig({ ELASTICSEARCH_MAX_DOCUMENTS: undefined }); + } + }; + + return ( +
+ {/* Connector Name */} +
+
+ + handleNameChange(e.target.value)} + placeholder="My Elasticsearch Connector" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +

+ A friendly name to identify this connector. +

+
+
+ + {/* Connection Details */} +
+
+

+ + Connection Details +

+
+ +
+ + handleEndpointUrlChange(e.target.value)} + placeholder="https://your-cluster.es.region.aws.com:443" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +

+ Update the Elasticsearch endpoint URL if needed. +

+
+
+ + {/* Authentication */} +
+
+

+ + Authentication +

+
+ +
+ handleAuthMethodChange(value as "basic" | "api_key")} + className="flex flex-col space-y-2" + > +
+ + +
+ + + +
+ +
+ + +
+ + + +
+ + + {authMethod === "basic" && ( +
+
+ + handleUsernameChange(e.target.value)} + placeholder="elastic" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +
+
+ + handlePasswordChange(e.target.value)} + placeholder="Password" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +
+
+ )} + + {authMethod === "api_key" && ( +
+ + handleApiKeyChange(e.target.value)} + placeholder="Your API Key Here" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +

+ Update the Elasticsearch API key if needed. +

+
+ )} +
+
+ + {/* Index Selection */} +
+
+

Index Selection

+
+ +
+ + handleIndicesChange(e.target.value)} + placeholder="logs-*, documents-*, app-logs" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +

+ Comma-separated indices to search (e.g., "logs-*, documents-*"). +

+
+ + {indices.trim() && ( +
+

Selected Indices:

+
+ {stringToArray(indices).map((index) => ( + + {index} + + ))} +
+
+ )} +
+ + {/* Advanced Configuration */} +
+
+

Advanced Configuration

+
+ +
+
+ + handleQueryChange(e.target.value)} + placeholder="*" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +

+ Default Elasticsearch query to use for searches. Use "*" to match all documents. +

+
+ +
+ + handleSearchFieldsChange(e.target.value)} + placeholder="title, content, description" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +

+ Comma-separated list of specific fields to search in. +

+
+ + {searchFields.trim() && ( +
+

Search Fields:

+
+ {stringToArray(searchFields).map((field) => ( + + {field} + + ))} +
+
+ )} + +
+ + handleMaxDocumentsChange(e.target.value)} + placeholder="1000" + min="1" + max="10000" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +

+ Maximum number of documents to retrieve per search (1-10,000). +

+
+
+
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx index 71c549403..b03ed574d 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx @@ -2,6 +2,7 @@ import type { FC } from "react"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { ElasticsearchConfig } from "./components/elasticsearch-config"; import { GoogleDriveConfig } from "./components/google-drive-config"; import { LinearConfig } from "./components/linear-config"; import { TavilyApiConfig } from "./components/tavily-api-config"; @@ -30,6 +31,8 @@ export function getConnectorConfigComponent( return LinearConfig; case "WEBCRAWLER_CONNECTOR": return WebcrawlerConfig; + case "ELASTICSEARCH_CONNECTOR": + return ElasticsearchConfig; // OAuth connectors (Gmail, Calendar, Airtable) and others don't need special config UI default: return null; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx index 664425010..eb0102008 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx @@ -45,6 +45,7 @@ export const ConnectorConnectView: FC = ({ const formIdMap: Record = { TAVILY_API: "tavily-connect-form", LINEAR_CONNECTOR: "linear-connect-form", + ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form", }; const formId = formIdMap[connectorType]; if (formId) { diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 905e7cf32..b3114b631 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -349,8 +349,12 @@ export const useConnectorDialog = () => { is_indexable: boolean; last_indexed_at: null; periodic_indexing_enabled: boolean; - indexing_frequency_minutes: null; + indexing_frequency_minutes: number | null; next_scheduled_at: null; + startDate?: Date; + endDate?: Date; + periodicEnabled?: boolean; + frequencyMinutes?: string; } ) => { if (!searchSpaceId || !connectingConnectorType) return; @@ -361,9 +365,12 @@ export const useConnectorDialog = () => { setIsCreatingConnector(true); try { + // Extract UI-only fields before sending to backend + const { startDate, endDate, periodicEnabled, frequencyMinutes, ...connectorData } = formData; + // Create connector const newConnector = await createConnector({ - data: formData, + data: connectorData, queryParams: { search_space_id: searchSpaceId, }, @@ -379,29 +386,118 @@ export const useConnectorDialog = () => { // Validate connector data const connectorValidation = searchSourceConnector.safeParse(connector); if (connectorValidation.success) { + // Store connectingConnectorType before clearing it + const currentConnectorType = connectingConnectorType; + // Find connector title from constants const connectorInfo = OTHER_CONNECTORS.find( - c => c.connectorType === connectingConnectorType + c => c.connectorType === currentConnectorType ); const connectorTitle = connectorInfo?.title || connector.name; // Set up indexing config const config = validateIndexingConfigState({ - connectorType: connectingConnectorType as EnumConnectorName, + connectorType: currentConnectorType as EnumConnectorName, connectorId: connector.id, connectorTitle, }); + + // Clear connecting state to allow view transition + setConnectingConnectorType(null); + + // Set indexing config state setIndexingConfig(config); setIndexingConnector(connector); setIndexingConnectorConfig(connector.config || {}); - // Transition to configure view - const url = new URL(window.location.href); - url.searchParams.set("view", "configure"); - url.searchParams.delete("connectorType"); - window.history.replaceState({}, "", url.toString()); + // Pre-populate indexing configuration with values from form if provided + if (formData.startDate !== undefined) { + setStartDate(formData.startDate); + } + if (formData.endDate !== undefined) { + setEndDate(formData.endDate); + } + if (formData.periodicEnabled !== undefined) { + setPeriodicEnabled(formData.periodicEnabled); + } + if (formData.frequencyMinutes !== undefined) { + setFrequencyMinutes(formData.frequencyMinutes); + } - toast.success(`${connectorTitle} connected successfully!`); + // Auto-start indexing for non-OAuth reindexable connectors + // This only applies to non-OAuth reindexable connectors (e.g., Elasticsearch, Linear) + // Non-reindexable connectors (e.g., Tavily) have is_indexable: false, so they won't trigger this + // Backend will use default date ranges (365 days ago to today) if dates are not provided + if (connector.is_indexable) { + // Get indexing configuration from form (or use defaults) + const startDateForIndexing = formData.startDate; + const endDateForIndexing = formData.endDate; + const periodicEnabledForIndexing = formData.periodicEnabled || false; + const frequencyMinutesForIndexing = formData.frequencyMinutes || "1440"; + + // Update connector with periodic sync settings if enabled + if (periodicEnabledForIndexing) { + const frequency = parseInt(frequencyMinutesForIndexing, 10); + await updateConnector({ + id: connector.id, + data: { + periodic_indexing_enabled: true, + indexing_frequency_minutes: frequency, + }, + }); + } + + // Start indexing (backend will use defaults if dates are undefined) + const startDateStr = startDateForIndexing ? format(startDateForIndexing, "yyyy-MM-dd") : undefined; + const endDateStr = endDateForIndexing ? format(endDateForIndexing, "yyyy-MM-dd") : undefined; + + await indexConnector({ + connector_id: connector.id, + queryParams: { + search_space_id: searchSpaceId, + start_date: startDateStr, + end_date: endDateStr, + }, + }); + + toast.success(`${connectorTitle} connected and indexing started!`, { + description: periodicEnabledForIndexing + ? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutesForIndexing)}.` + : "You can continue working while we sync your data.", + }); + + // Close modal and return to main view + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorType"); + router.replace(url.pathname + url.search, { scroll: false }); + + // Clear indexing config state since we're not showing the view + setIndexingConfig(null); + setIndexingConnector(null); + setIndexingConnectorConfig(null); + + // Invalidate queries to refresh data + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + + // Refresh connectors list + await refetchAllConnectors(); + } else { + // Non-indexable connector - just show success message + toast.success(`${connectorTitle} connected successfully!`); + + // Close modal and return to main view + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorType"); + router.replace(url.pathname + url.search, { scroll: false }); + } } } } @@ -411,9 +507,9 @@ export const useConnectorDialog = () => { } finally { isCreatingConnectorRef.current = false; setIsCreatingConnector(false); - setConnectingConnectorType(null); + // Don't clear connectingConnectorType here - it's cleared above when transitioning to config view } - }, [connectingConnectorType, searchSpaceId, createConnector, refetchAllConnectors]); + }, [connectingConnectorType, searchSpaceId, createConnector, refetchAllConnectors, updateConnector, indexConnector, router, getFrequencyLabel, queryClient]); // Handle going back from connect view const handleBackFromConnect = useCallback(() => { @@ -561,13 +657,14 @@ export const useConnectorDialog = () => { (oauthConnector) => oauthConnector.connectorType === connector.connector_type ); - // Check if this is webcrawler, Tavily API, or Linear (can be managed in popup) + // Check if this is webcrawler, Tavily API, Linear, or Elasticsearch (can be managed in popup) const isWebcrawler = connector.connector_type === EnumConnectorName.WEBCRAWLER_CONNECTOR; const isTavilyApi = connector.connector_type === EnumConnectorName.TAVILY_API; const isLinear = connector.connector_type === EnumConnectorName.LINEAR_CONNECTOR; + const isElasticsearch = connector.connector_type === EnumConnectorName.ELASTICSEARCH_CONNECTOR; - // If not OAuth, not webcrawler, not Tavily API, and not Linear, redirect to old connector edit page - if (!isOAuthConnector && !isWebcrawler && !isTavilyApi && !isLinear) { + // If not OAuth, not webcrawler, not Tavily API, not Linear, and not Elasticsearch, redirect to old connector edit page + if (!isOAuthConnector && !isWebcrawler && !isTavilyApi && !isLinear && !isElasticsearch) { router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`); return; } diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx index fd364c3d1..7397264e8 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx @@ -12,7 +12,6 @@ import type { LogSummary, LogActiveTask } from "@/contracts/types/log.types"; import { cn } from "@/lib/utils"; import { TabsContent, - TabsTrigger, } from "@/components/ui/tabs"; interface ActiveConnectorsTabProps { @@ -140,9 +139,13 @@ export const ActiveConnectorsTab: FC = ({

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

- onTabChange("all")}> +
)} diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx index ef74c1269..887961bed 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx @@ -95,6 +95,7 @@ export const AllConnectorsTab: FC = ({ const isWebcrawler = connector.id === "webcrawler-connector"; const isTavily = connector.id === "tavily-api"; const isLinear = connector.id === "linear-connector"; + const isElasticsearch = connector.id === "elasticsearch-connector"; const isConnected = connectedTypes.has(connector.connectorType); const isConnecting = connectingId === connector.id; @@ -106,7 +107,7 @@ export const AllConnectorsTab: FC = ({ const handleConnect = isWebcrawler && onCreateWebcrawler ? onCreateWebcrawler - : (isTavily || isLinear) && onConnectNonOAuth + : (isTavily || isLinear || isElasticsearch) && onConnectNonOAuth ? () => onConnectNonOAuth(connector.connectorType) : () => router.push(`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`);