feat: Add Elasticsearch connector support with configuration forms, enhance connector dialog for indexing settings, and implement date range selection for periodic sync.

This commit is contained in:
Anish Sarkar 2025-12-31 15:29:33 +05:30
parent 95f95558dc
commit 36d25e9505
14 changed files with 1521 additions and 44 deletions

View file

@ -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 = () => {
>
<AttachmentPreviewDialog>
<TooltipTrigger asChild>
<div
<button
type="button"
className={cn(
"aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",
isComposer && "aui-attachment-tile-composer border-foreground/20",
isProcessing && "animate-pulse"
)}
role="button"
id="attachment-tile"
aria-label={isProcessing ? "Processing attachment..." : `${typeLabel} attachment`}
>
<AttachmentThumb />
</div>
</button>
</TooltipTrigger>
</AttachmentPreviewDialog>
{isComposer && !isProcessing && <AttachmentRemove />}
@ -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<HTMLInputElement>(null);
const handleFileUpload = () => {
router.push(`/dashboard/${searchSpaceId}/sources/add?tab=documents`);
};
const handleChatAttachment = () => {
chatAttachmentInputRef.current?.click();
};
return (
<ComposerPrimitive.AddAttachment asChild>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TooltipIconButton
tooltip="Add Attachment"
tooltip="Upload documents or add attachment"
side="bottom"
variant="ghost"
size="icon"
className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
aria-label="Add Attachment"
aria-label="Upload documents or add attachment"
>
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
</TooltipIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 bg-background border-border">
<DropdownMenuItem onSelect={handleChatAttachment} className="cursor-pointer">
<Paperclip className="size-4" />
<span>Add attachment(s)</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleFileUpload} className="cursor-pointer">
<Upload className="size-4" />
<span>File upload</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ComposerPrimitive.AddAttachment asChild>
<input
ref={chatAttachmentInputRef}
type="file"
multiple
className="hidden"
accept="image/*,application/pdf,.doc,.docx,.txt"
/>
</ComposerPrimitive.AddAttachment>
</>
);
};

View file

@ -40,8 +40,8 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
};
return (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<h3 className="font-medium text-xs sm:text-base mb-4">Select Date Range</h3>
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<h3 className="font-medium text-sm sm:text-base mb-4">Select Date Range</h3>
<p className="text-xs sm:text-sm text-muted-foreground mb-6">
Choose how far back you want to sync your data. You can always re-index later with different dates.
</p>

View file

@ -25,10 +25,10 @@ export const PeriodicSyncConfig: FC<PeriodicSyncConfigProps> = ({
onFrequencyChange,
}) => {
return (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-xs sm:text-base">Enable Periodic Sync</h3>
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>

View file

@ -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<typeof elasticsearchConnectorFormSchema>;
export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({
onSubmit,
isSubmitting,
}) => {
const isSubmittingRef = useRef(false);
const authBasicId = useId();
const authApiKeyId = useId();
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<ElasticsearchConnectorFormValues>({
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<string, string | number | boolean | string[]> = {
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 (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form id="elasticsearch-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Elasticsearch Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Connection Details */}
<div className="space-y-4">
<h3 className="text-sm sm:text-base font-medium">Connection Details</h3>
<FormField
control={form.control}
name="endpoint_url"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Elasticsearch Endpoint URL</FormLabel>
<FormControl>
<Input
type="url"
autoComplete="off"
placeholder="https://your-cluster.es.region.aws.com:443"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Enter the complete Elasticsearch endpoint URL. We'll automatically extract the hostname, port, and SSL settings.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed URL details */}
{form.watch("endpoint_url") && (
<div className="rounded-lg border border-border bg-muted/50 p-3">
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Parsed Connection Details:</h4>
<div className="text-[10px] sm:text-xs text-muted-foreground space-y-1">
{(() => {
try {
const url = new URL(form.watch("endpoint_url"));
return (
<>
<div>
<strong>Hostname:</strong> {url.hostname}
</div>
<div>
<strong>Port:</strong>{" "}
{url.port || (url.protocol === "https:" ? "443" : "80")}
</div>
<div>
<strong>SSL/TLS:</strong>{" "}
{url.protocol === "https:" ? "Enabled" : "Disabled"}
</div>
</>
);
} catch {
return <div className="text-destructive">Invalid URL format</div>;
}
})()}
</div>
</div>
)}
</div>
{/* Authentication */}
<div className="space-y-4">
<h3 className="text-sm sm:text-base font-medium">Authentication</h3>
<FormField
control={form.control}
name="auth_method"
render={({ field }) => (
<FormItem className="space-y-3">
<FormControl>
<RadioGroup.Root
onValueChange={(value) => {
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"
>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="api_key"
id={authApiKeyId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<Label htmlFor={authApiKeyId} className="text-xs sm:text-sm">API Key</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="basic"
id={authBasicId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<Label htmlFor={authBasicId} className="text-xs sm:text-sm">Username & Password</Label>
</div>
</RadioGroup.Root>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Basic Auth Fields */}
{form.watch("auth_method") === "basic" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Username</FormLabel>
<FormControl>
<Input
placeholder="elastic"
autoComplete="username"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
autoComplete="current-password"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* API Key Field */}
{form.watch("auth_method") === "api_key" && (
<FormField
control={form.control}
name="ELASTICSEARCH_API_KEY"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your API Key Here"
autoComplete="off"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Enter your Elasticsearch API key (base64 encoded). This will be stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
{/* Index Selection */}
<FormField
control={form.control}
name="indices"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Index Selection</FormLabel>
<FormControl>
<Input
placeholder="logs-*, documents-*, app-logs"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Comma-separated indices to search (e.g., "logs-*, documents-*").
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed indices as badges */}
{form.watch("indices")?.trim() && (
<div className="rounded-lg border border-border bg-muted/50 p-3">
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Selected Indices:</h4>
<div className="flex flex-wrap gap-2">
{stringToArray(form.watch("indices") ?? "").map((index) => (
<Badge key={index} variant="secondary" className="text-[10px]">
{index}
</Badge>
))}
</div>
</div>
)}
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Index Selection Tips</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px] mt-2">
<ul className="list-disc pl-4 space-y-1">
<li>Use wildcards like "logs-*" to match multiple indices</li>
<li>Separate multiple indices with commas</li>
<li>Leave empty to search all accessible indices including internal ones</li>
<li>Choosing specific indices improves search performance</li>
</ul>
</AlertDescription>
</Alert>
{/* Advanced Configuration */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advanced">
<AccordionTrigger className="text-xs sm:text-sm">Advanced Configuration</AccordionTrigger>
<AccordionContent className="space-y-4">
{/* Default Search Query */}
<FormField
control={form.control}
name="query"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">
Default Search Query{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input
placeholder="*"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Default Elasticsearch query to use for searches. Use "*" to match all documents.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Form Fields */}
<FormField
control={form.control}
name="search_fields"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">
Search Fields{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input
placeholder="title, content, description"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Comma-separated list of specific fields to search in (e.g., "title, content, description"). Leave empty to search all fields.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed search fields as badges */}
{form.watch("search_fields")?.trim() && (
<div className="rounded-lg border border-border bg-muted/50 p-3">
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields:</h4>
<div className="flex flex-wrap gap-2">
{stringToArray(form.watch("search_fields") ?? "").map((field) => (
<Badge key={field} variant="outline" className="text-[10px]">
{field}
</Badge>
))}
</div>
</div>
)}
<FormField
control={form.control}
name="max_documents"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">
Maximum Documents{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="1000"
min="1"
max="10000"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
onChange={(e) =>
field.onChange(
e.target.value === ""
? undefined
: parseInt(e.target.value, 10)
)
}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Maximum number of documents to retrieve per search (1-10,000). Leave empty to use Elasticsearch's default limit.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.ELASTICSEARCH_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Elasticsearch integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.ELASTICSEARCH_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
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.
</p>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Connection Setup</h3>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Get your Elasticsearch endpoint</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
You'll need the endpoint URL for your Elasticsearch cluster. This typically looks like:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>Cloud: <code className="bg-muted px-1 py-0.5 rounded">https://your-cluster.es.region.aws.com:443</code></li>
<li>Self-hosted: <code className="bg-muted px-1 py-0.5 rounded">https://elasticsearch.example.com:9200</code></li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Configure authentication</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Elasticsearch requires authentication. You can use either:
</p>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
<strong>API Key:</strong> A base64-encoded API key. You can create one in Elasticsearch by running:
<pre className="bg-muted p-2 rounded mt-1 text-[9px] overflow-x-auto">
<code>POST /_security/api_key</code>
</pre>
</li>
<li>
<strong>Username & Password:</strong> Basic authentication using your Elasticsearch username and password.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 3: Select indices</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Specify which indices to search. You can:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>Use wildcards: <code className="bg-muted px-1 py-0.5 rounded">logs-*</code> to match multiple indices</li>
<li>List specific indices: <code className="bg-muted px-1 py-0.5 rounded">logs-2024, documents-2024</code></li>
<li>Leave empty to search all accessible indices (not recommended for performance)</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Advanced Configuration</h3>
<div className="space-y-4">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Query</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
The default query used for searches. Use <code className="bg-muted px-1 py-0.5 rounded">*</code> to match all documents, or specify a more complex Elasticsearch query.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
Limit searches to specific fields for better performance. Common fields include:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li><code className="bg-muted px-1 py-0.5 rounded">title</code> - Document titles</li>
<li><code className="bg-muted px-1 py-0.5 rounded">content</code> - Main content</li>
<li><code className="bg-muted px-1 py-0.5 rounded">description</code> - Descriptions</li>
</ul>
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
Leave empty to search all fields in your documents.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Maximum Documents</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground">
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.
</p>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Troubleshooting</h3>
<div className="space-y-4">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Connection Issues</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>Invalid URL:</strong> Ensure your endpoint URL includes the protocol (https://) and port number if required.
</li>
<li>
<strong>SSL/TLS Errors:</strong> Verify that your cluster uses HTTPS and the certificate is valid. Self-signed certificates may require additional configuration.
</li>
<li>
<strong>Connection Timeout:</strong> Check your network connectivity and firewall settings. Ensure the Elasticsearch cluster is accessible from SurfSense servers.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Authentication Issues</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>Invalid Credentials:</strong> Double-check your username/password or API key. API keys must be base64-encoded.
</li>
<li>
<strong>Permission Denied:</strong> Ensure your API key or user account has read permissions for the indices you want to search.
</li>
<li>
<strong>API Key Format:</strong> Elasticsearch API keys are typically base64-encoded strings. Make sure you're using the full key value.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Issues</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>No Results:</strong> Verify that your index selection matches existing indices. Use wildcards carefully.
</li>
<li>
<strong>Slow Searches:</strong> Limit the number of indices or use specific index names instead of wildcards. Reduce the maximum documents limit.
</li>
<li>
<strong>Field Not Found:</strong> Ensure the search fields you specify actually exist in your Elasticsearch documents.
</li>
</ul>
</div>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mt-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Need More Help?</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
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.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -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<ConnectFormProps> = ({
isSubmitting,
}) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<LinearConnectorFormValues>({
resolver: zodResolver(linearConnectorFormSchema),
defaultValues: {
@ -72,9 +87,13 @@ export const LinearConnectForm: FC<ConnectFormProps> = ({
},
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<ConnectFormProps> = ({
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>

View file

@ -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<ConnectFormProps> = ({
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.TAVILY_API) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Tavily API:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.TAVILY_API)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
</div>
);
};

View file

@ -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: [...],
};

View file

@ -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<void>;
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;

View file

@ -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<ElasticsearchConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const authBasicId = useId();
const authApiKeyId = useId();
const [name, setName] = useState<string>(connector.name || "");
const [endpointUrl, setEndpointUrl] = useState<string>(
(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<string>(
(connector.config?.ELASTICSEARCH_USERNAME as string) || ""
);
const [password, setPassword] = useState<string>(
(connector.config?.ELASTICSEARCH_PASSWORD as string) || ""
);
const [apiKey, setApiKey] = useState<string>(
(connector.config?.ELASTICSEARCH_API_KEY as string) || ""
);
const [indices, setIndices] = useState<string>(
Array.isArray(connector.config?.ELASTICSEARCH_INDEX)
? (connector.config?.ELASTICSEARCH_INDEX as string[]).join(", ")
: (connector.config?.ELASTICSEARCH_INDEX as string) || ""
);
const [query, setQuery] = useState<string>(
(connector.config?.ELASTICSEARCH_QUERY as string) || "*"
);
const [searchFields, setSearchFields] = useState<string>(
Array.isArray(connector.config?.ELASTICSEARCH_FIELDS)
? (connector.config?.ELASTICSEARCH_FIELDS as string[]).join(", ")
: ""
);
const [maxDocuments, setMaxDocuments] = useState<string>(
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<string, unknown>) => {
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 (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Elasticsearch Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{/* Connection Details */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
<Server className="h-4 w-4" />
Connection Details
</h3>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Elasticsearch Endpoint URL</Label>
<Input
type="url"
value={endpointUrl}
onChange={(e) => handleEndpointUrlChange(e.target.value)}
placeholder="https://your-cluster.es.region.aws.com:443"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update the Elasticsearch endpoint URL if needed.
</p>
</div>
</div>
{/* Authentication */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
<KeyRound className="h-4 w-4" />
Authentication
</h3>
</div>
<div className="space-y-4">
<RadioGroup.Root
value={authMethod}
onValueChange={(value) => handleAuthMethodChange(value as "basic" | "api_key")}
className="flex flex-col space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="api_key"
id={authApiKeyId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<Label htmlFor={authApiKeyId} className="text-xs sm:text-sm">API Key</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="basic"
id={authBasicId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<Label htmlFor={authBasicId} className="text-xs sm:text-sm">Username & Password</Label>
</div>
</RadioGroup.Root>
{authMethod === "basic" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Username</Label>
<Input
value={username}
onChange={(e) => handleUsernameChange(e.target.value)}
placeholder="elastic"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Password</Label>
<Input
type="password"
value={password}
onChange={(e) => handlePasswordChange(e.target.value)}
placeholder="Password"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
</div>
</div>
)}
{authMethod === "api_key" && (
<div className="space-y-2">
<Label className="text-xs sm:text-sm">API Key</Label>
<Input
type="password"
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder="Your API Key Here"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update the Elasticsearch API key if needed.
</p>
</div>
)}
</div>
</div>
{/* Index Selection */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Index Selection</h3>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Indices</Label>
<Input
value={indices}
onChange={(e) => handleIndicesChange(e.target.value)}
placeholder="logs-*, documents-*, app-logs"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Comma-separated indices to search (e.g., "logs-*, documents-*").
</p>
</div>
{indices.trim() && (
<div className="rounded-lg border border-border bg-muted/50 p-3">
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Selected Indices:</h4>
<div className="flex flex-wrap gap-2">
{stringToArray(indices).map((index) => (
<Badge key={index} variant="secondary" className="text-[10px]">
{index}
</Badge>
))}
</div>
</div>
)}
</div>
{/* Advanced Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Advanced Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">
Default Search Query <span className="text-muted-foreground">(Optional)</span>
</Label>
<Input
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder="*"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Default Elasticsearch query to use for searches. Use "*" to match all documents.
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">
Search Fields <span className="text-muted-foreground">(Optional)</span>
</Label>
<Input
value={searchFields}
onChange={(e) => handleSearchFieldsChange(e.target.value)}
placeholder="title, content, description"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Comma-separated list of specific fields to search in.
</p>
</div>
{searchFields.trim() && (
<div className="rounded-lg border border-border bg-muted/50 p-3">
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields:</h4>
<div className="flex flex-wrap gap-2">
{stringToArray(searchFields).map((field) => (
<Badge key={field} variant="outline" className="text-[10px]">
{field}
</Badge>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-xs sm:text-sm">
Maximum Documents <span className="text-muted-foreground">(Optional)</span>
</Label>
<Input
type="number"
value={maxDocuments}
onChange={(e) => handleMaxDocumentsChange(e.target.value)}
placeholder="1000"
min="1"
max="10000"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Maximum number of documents to retrieve per search (1-10,000).
</p>
</div>
</div>
</div>
</div>
);
};

View file

@ -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;

View file

@ -45,6 +45,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
const formIdMap: Record<string, string> = {
TAVILY_API: "tavily-connect-form",
LINEAR_CONNECTOR: "linear-connect-form",
ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form",
};
const formId = formIdMap[connectorType];
if (formId) {

View file

@ -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);
}
// 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;
}

View file

@ -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<ActiveConnectorsTabProps> = ({
<p className="text-sm text-muted-foreground mt-1 max-w-[280px]">
Connect your first service to start searching across all your data.
</p>
<TabsTrigger value="all" className="mt-6 text-primary hover:underline" onClick={() => onTabChange("all")}>
<Button
variant="link"
className="mt-6 text-primary hover:underline"
onClick={() => onTabChange("all")}
>
Browse available connectors
</TabsTrigger>
</Button>
</div>
)}
</TabsContent>

View file

@ -95,6 +95,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
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<AllConnectorsTabProps> = ({
const handleConnect = isWebcrawler && onCreateWebcrawler
? onCreateWebcrawler
: (isTavily || isLinear) && onConnectNonOAuth
: (isTavily || isLinear || isElasticsearch) && onConnectNonOAuth
? () => onConnectNonOAuth(connector.connectorType)
: () => router.push(`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`);