feat: Add Elasticsearch connector integration

- Updated ElasticsearchIndexer to use connector_config for initialization.
- Created a new page for adding Elasticsearch connectors with a comprehensive form.
- Enhanced the dashboard to include the Elasticsearch connector in the connectors list.
- Updated breadcrumb component to display Elasticsearch connector label.
- Added Elasticsearch icon to the connector icons enum.
- Installed @radix-ui/react-radio-group for radio button functionality in the form.
This commit is contained in:
Anish Sarkar 2025-10-08 12:17:00 +05:30
parent a7e0bad42a
commit 51f263da72
11 changed files with 864 additions and 420 deletions

View file

@ -31,35 +31,47 @@ class ElasticsearchConnector:
async def connect(self) -> bool:
"""Establish connection to Elasticsearch"""
try:
# Build the URL with proper scheme
scheme = "https" if self.config.get("ssl_enabled", True) else "http"
port = self.config.get("port", 443 if scheme == "https" else 9200)
hostname = self.config["hostname"]
# Construct the full URL
url = f"{scheme}://{hostname}:{port}"
connection_params = {
"hosts": [f"{self.config.hostname}:{self.config.port}"],
"verify_certs": self.config.ssl_enabled
if hasattr(self.config, "ssl_enabled")
else True,
"hosts": [url],
"verify_certs": self.config.get("ssl_enabled", True),
"request_timeout": 30,
}
# Add authentication if provided
# Handle different authentication methods
auth_method = self.config.get("auth_method", "none")
if (
hasattr(self.config, "username")
and hasattr(self.config, "password")
and self.config.username
and self.config.password
auth_method == "basic"
and self.config.get("username")
and self.config.get("password")
):
connection_params["basic_auth"] = (
self.config.username,
self.config.password,
self.config["username"],
self.config["password"],
)
elif auth_method == "api_key" and self.config.get("api_key"):
api_key = self.config["api_key"]
if api_key.startswith("ApiKey "):
connection_params["headers"] = {"Authorization": api_key}
else:
connection_params["api_key"] = api_key
# Add API key authentication if provided
if hasattr(self.config, "api_key") and self.config.api_key:
connection_params["api_key"] = self.config.api_key
logger.info(f"Connecting to Elasticsearch at {url}")
self.client = AsyncElasticsearch(**connection_params)
# Test connection
await self.client.info()
logger.info("Successfully connected to Elasticsearch")
info = await self.client.info()
logger.info(
f"Successfully connected to Elasticsearch cluster: {info.get('cluster_name', 'Unknown')}"
)
return True
except (ConnectionError, AuthenticationException) as e:
@ -92,6 +104,9 @@ class ElasticsearchConnector:
"cluster_name": info.get("cluster_name"),
"version": info.get("version", {}).get("number"),
"indices_count": len(indices) if indices else 0,
"indices": [
idx["index"] for idx in indices if not idx["index"].startswith(".")
],
}
except Exception as e:
return {"success": False, "error": str(e)}

View file

@ -5,7 +5,6 @@ from .airtable_add_connector_route import (
)
from .chats_routes import router as chats_router
from .documents_routes import router as documents_router
from .elasticsearch_add_connector_route import router as elasticsearch_router
from .google_calendar_add_connector_route import (
router as google_calendar_add_connector_router,
)
@ -25,7 +24,6 @@ router.include_router(search_spaces_router)
router.include_router(documents_router)
router.include_router(podcasts_router)
router.include_router(chats_router)
router.include_router(elasticsearch_router)
router.include_router(search_source_connectors_router)
router.include_router(google_calendar_add_connector_router)
router.include_router(google_gmail_add_connector_router)

View file

@ -1,392 +0,0 @@
import contextlib
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.elasticsearch_connector import ElasticsearchConnector
from app.db import (
SearchSourceConnector,
SearchSourceConnectorType,
User,
get_async_session,
)
from app.users import current_active_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/elasticsearch", tags=["elasticsearch"])
class ElasticsearchConnectorConfig(BaseModel):
hostname: str
port: int = 9200
username: str | None = None
password: str | None = None
api_key: str | None = None
ssl_enabled: bool = True
indices: list[str] | None = None
query: str = "*"
search_fields: list[str] | None = None
max_documents: int = 1000
@router.post("/add-connector")
async def add_elasticsearch_connector(
connector_data: ElasticsearchConnectorConfig,
current_user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, Any]:
"""Add a new Elasticsearch connector for the current user"""
try:
elasticsearch_connector = ElasticsearchConnector(connector_data)
connection_test = await elasticsearch_connector.test_connection()
if not connection_test.get("success"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to connect to Elasticsearch: {connection_test.get('error')}",
)
except Exception as e:
logger.error(f"Error initializing or testing Elasticsearch connector: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to initialize or test Elasticsearch connector: {e!s}",
) from e
finally:
with contextlib.suppress(Exception):
await elasticsearch_connector.disconnect()
try:
# Check if connector already exists for this user
existing_connector_result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.user_id == current_user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR,
)
)
existing_connector = existing_connector_result.scalars().first()
if existing_connector:
# Update existing connector
existing_connector.config = connector_data.model_dump()
existing_connector.name = (
f"Elasticsearch - {connector_data.hostname}:{connector_data.port}"
)
existing_connector.is_indexable = True
logger.info(
f"Updated existing Elasticsearch connector for user {current_user.id}"
)
else:
# Create new connector
new_connector = SearchSourceConnector(
name=f"Elasticsearch - {connector_data.hostname}:{connector_data.port}",
connector_type=SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR,
is_indexable=True,
config=connector_data.model_dump(),
user_id=current_user.id,
)
session.add(new_connector)
logger.info(
f"Created new Elasticsearch connector for user {current_user.id}"
)
await session.commit()
# Get the connector (either existing or new) to return
if existing_connector:
connector = existing_connector
else:
await session.refresh(new_connector)
connector = new_connector
logger.info(
f"Successfully saved Elasticsearch connector for user {current_user.id}"
)
return {
"success": True,
"message": "Elasticsearch connector added successfully",
"connector": {
"id": connector.id,
"name": connector.name,
"connector_type": connector.connector_type.value,
"is_indexable": connector.is_indexable,
"created_at": connector.created_at.isoformat()
if connector.created_at
else None,
"last_indexed_at": connector.last_indexed_at.isoformat()
if connector.last_indexed_at
else None,
},
}
except IntegrityError as e:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Integrity error: A connector with this configuration already exists. {e!s}",
) from e
except Exception as e:
logger.error(f"Failed to create Elasticsearch connector: {e!s}")
await session.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create Elasticsearch connector: {e!s}",
) from e
@router.get("/connectors")
async def get_elasticsearch_connectors(
current_user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, Any]:
"""Get all Elasticsearch connectors for the current user"""
try:
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.user_id == current_user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR,
)
)
connectors = result.scalars().all()
connector_list = []
for connector in connectors:
connector_list.append(
{
"id": connector.id,
"name": connector.name,
"connector_type": connector.connector_type.value,
"is_indexable": connector.is_indexable,
"created_at": connector.created_at.isoformat()
if connector.created_at
else None,
"last_indexed_at": connector.last_indexed_at.isoformat()
if connector.last_indexed_at
else None,
"config": {
"hostname": connector.config.get("hostname"),
"port": connector.config.get("port"),
"indices": connector.config.get("indices"),
# Don't expose sensitive credentials
},
}
)
return {"success": True, "connectors": connector_list}
except Exception as e:
logger.error(f"Error fetching Elasticsearch connectors: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch Elasticsearch connectors",
) from e
@router.put("/connectors/{connector_id}")
async def update_elasticsearch_connector(
connector_id: int,
connector_data: ElasticsearchConnectorConfig,
current_user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, Any]:
"""Update an existing Elasticsearch connector"""
try:
# Get the connector
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == current_user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR,
)
)
connector = result.scalar_one_or_none()
if not connector:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Elasticsearch connector not found",
)
# Test the new connection with robust error handling
try:
elasticsearch_connector = ElasticsearchConnector(connector_data)
connection_test = await elasticsearch_connector.test_connection()
if not connection_test.get("success"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to connect to Elasticsearch: {connection_test.get('error')}",
)
except Exception as e:
logger.error(f"Error initializing or testing Elasticsearch connector: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to initialize or test Elasticsearch connector: {e!s}",
) from e
finally:
with contextlib.suppress(Exception):
await elasticsearch_connector.disconnect()
# Update the connector
connector.config = connector_data.model_dump()
connector.name = (
f"Elasticsearch - {connector_data.hostname}:{connector_data.port}"
)
await session.commit()
await session.refresh(connector)
return {
"success": True,
"message": "Elasticsearch connector updated successfully",
"connector": {
"id": connector.id,
"name": connector.name,
"connector_type": connector.connector_type.value,
"is_indexable": connector.is_indexable,
"created_at": connector.created_at.isoformat()
if connector.created_at
else None,
"last_indexed_at": connector.last_indexed_at.isoformat()
if connector.last_indexed_at
else None,
},
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating Elasticsearch connector: {e}")
await session.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update Elasticsearch connector",
) from e
@router.delete("/connectors/{connector_id}")
async def delete_elasticsearch_connector(
connector_id: int,
current_user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, Any]:
"""Delete an Elasticsearch connector"""
try:
# Get the connector
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == current_user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR,
)
)
connector = result.scalar_one_or_none()
if not connector:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Elasticsearch connector not found",
)
# Delete the connector
await session.delete(connector)
await session.commit()
return {
"success": True,
"message": "Elasticsearch connector deleted successfully",
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting Elasticsearch connector: {e}")
await session.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete Elasticsearch connector",
) from e
@router.get("/test-connection")
async def test_elasticsearch_connection(
hostname: str,
port: int = 9200,
username: str | None = None,
password: str | None = None,
api_key: str | None = None,
ssl_enabled: bool = True,
current_user: User = Depends(current_active_user),
) -> dict[str, Any]:
"""Test Elasticsearch connection with provided credentials"""
config_data = ElasticsearchConnectorConfig(
hostname=hostname,
port=port,
username=username,
password=password,
api_key=api_key,
ssl_enabled=ssl_enabled,
)
elasticsearch_connector = ElasticsearchConnector(config_data)
try:
result = await elasticsearch_connector.test_connection()
return result
except Exception as e:
logger.error(f"Error testing Elasticsearch connection: {e}")
return {"success": False, "error": str(e)}
finally:
await elasticsearch_connector.disconnect()
@router.get("/indices")
async def get_elasticsearch_indices(
hostname: str,
port: int = 9200,
username: str | None = None,
password: str | None = None,
api_key: str | None = None,
ssl_enabled: bool = True,
current_user: User = Depends(current_active_user),
) -> dict[str, Any]:
"""Get list of available Elasticsearch indices"""
config_data = ElasticsearchConnectorConfig(
hostname=hostname,
port=port,
username=username,
password=password,
api_key=api_key,
ssl_enabled=ssl_enabled,
)
elasticsearch_connector = ElasticsearchConnector(config_data)
try:
if not await elasticsearch_connector.connect():
return {"success": False, "error": "Failed to connect to Elasticsearch"}
indices = await elasticsearch_connector.get_indices()
return {"success": True, "indices": indices}
except Exception as e:
logger.error(f"Error fetching Elasticsearch indices: {e}")
return {"success": False, "error": str(e)}
finally:
await elasticsearch_connector.disconnect()
# Note: Removed the index_elasticsearch_connector endpoint - now handled by universal system

View file

@ -216,6 +216,7 @@ class SearchSourceConnectorBase(BaseModel):
"username",
"password",
"api_key",
"auth_method",
"ssl_enabled",
"indices",
"query",
@ -227,6 +228,18 @@ class SearchSourceConnectorBase(BaseModel):
if not config.get("hostname"):
raise ValueError("Elasticsearch connector must have a hostname")
# Validate authentication method
auth_method = config.get("auth_method", "none")
if auth_method not in ["none", "basic", "api_key"]:
raise ValueError("auth_method must be one of: none, basic, api_key")
# Validate auth credentials based on method
if auth_method == "basic":
if not config.get("username") or not config.get("password"):
raise ValueError("Username and password required for basic auth")
elif auth_method == "api_key" and not config.get("api_key"):
raise ValueError("API key required for api_key auth method")
# Validate that all config keys are allowed
for key in config:
if key not in allowed_keys:

View file

@ -32,7 +32,7 @@ class ElasticsearchIndexer:
def __init__(self, connector_config: dict):
self.connector_config = connector_config
self.connector = ElasticsearchConnector(self.config)
self.connector = ElasticsearchConnector(self.connector_config)
async def get_documents(self):
"""Get documents from Elasticsearch (kept for backward compatibility)"""

View file

@ -0,0 +1,712 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as RadioGroup from "@radix-ui/react-radio-group";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
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 { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
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 { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
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"]).default("api_key"),
username: z.string().optional(),
password: z.string().optional(),
api_key: z.string().optional(),
indices: z.string().optional(),
query: z.string().default("*"),
search_fields: z.array(z.string()).optional(),
max_documents: z.number().min(1).max(10000),
})
.refine(
(data) => {
if (data.auth_method === "basic") {
return data.username && data.password;
}
if (data.auth_method === "api_key") {
return data.api_key;
}
return true;
},
{
message: "Authentication credentials are required for the selected method.",
path: ["auth_method"],
}
);
// Define the type for the form values
type ElasticsearchConnectorFormValues = z.infer<typeof elasticsearchConnectorFormSchema>;
export default function ElasticsearchConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const authBasicId = useId();
const authApiKeyId = useId();
const { createConnector } = useSearchSourceConnectors();
// Initialize the form
const form = useForm<ElasticsearchConnectorFormValues>({
resolver: zodResolver(elasticsearchConnectorFormSchema),
defaultValues: {
name: "Elasticsearch Connector",
endpoint_url: "",
auth_method: "api_key",
username: "",
password: "",
api_key: "",
indices: "",
query: "*",
search_fields: "",
},
});
const stringToArray = (str: string): string[] => {
return str
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
};
// Handle form submission
const onSubmit = async (values: ElasticsearchConnectorFormValues) => {
setIsSubmitting(true);
try {
// Parse the endpoint URL to extract hostname, port, and SSL
const url = new URL(values.endpoint_url);
const hostname = url.hostname;
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
const ssl_enabled = url.protocol === "https:";
const config: any = {
hostname,
port,
ssl_enabled,
};
if (values.auth_method === "basic") {
config.username = values.username;
config.password = values.password;
} else if (values.auth_method === "api_key") {
config.api_key = values.api_key;
}
if (values.indices?.trim()) {
config.indices = stringToArray(values.indices);
}
if (values.query && values.query !== "*") {
config.query = values.query;
}
if (values.search_fields?.trim()) {
config.search_fields = stringToArray(values.search_fields);
}
if (values.max_documents !== undefined && values.max_documents > 0) {
config.max_documents = values.max_documents;
}
// Ensure the connector payload has the correct structure
const connectorPayload = {
name: values.name,
connector_type: "ELASTICSEARCH_CONNECTOR",
is_indexable: true,
config,
};
// console.log("Connector payload:", JSON.stringify(connectorPayload, null, 2));
// Use existing hook method
await createConnector(connectorPayload);
toast.success("Elasticsearch connector created successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.ELASTICSEARCH_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Elasticsearch</h1>
<p className="text-muted-foreground">
Connect to your Elasticsearch cluster to search and index documents.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Elasticsearch Cluster</CardTitle>
<CardDescription>
Connect to your Elasticsearch instance to search and index documents for enhanced
search capabilities.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Connector Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Elasticsearch Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Connection Details */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Connection Details</h3>
<FormField
control={form.control}
name="endpoint_url"
render={({ field }) => (
<FormItem>
<FormLabel>Elasticsearch Endpoint URL</FormLabel>
<FormControl>
<Input
placeholder="https://your-cluster.es.region.aws.com:443"
{...field}
/>
</FormControl>
<FormDescription>
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 bg-muted/50 p-3">
<h4 className="text-sm font-medium mb-2">Parsed Connection Details:</h4>
<div className="text-sm 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-lg 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("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}>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}>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>Username</FormLabel>
<FormControl>
<Input placeholder="elastic" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* API Key Field */}
{form.watch("auth_method") === "api_key" && (
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="Your API Key Here" {...field} />
</FormControl>
<FormDescription>
Enter your Elasticsearch API key (base64 encoded). This will be
stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
{/* Advanced Configuration */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advanced">
<AccordionTrigger>Advanced Configuration</AccordionTrigger>
<AccordionContent className="space-y-4">
{/* Index Selection */}
<FormField
control={form.control}
name="indices"
render={({ field }) => (
<FormItem>
<FormLabel>
Index Selection{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input placeholder="logs-*, documents-*, app-logs" {...field} />
</FormControl>
<FormDescription>
Comma-separated indices to search (e.g., "logs-*, documents-*").
Leave empty for all indices. Supports wildcards.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed indices as badges */}
{form.watch("indices")?.trim() && (
<div className="rounded-lg border bg-muted/50 p-3">
<h4 className="text-sm 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-xs">
{index}
</Badge>
))}
</div>
</div>
)}
<FormField
control={form.control}
name="query"
render={({ field }) => (
<FormItem>
<FormLabel>
Default Search Query{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input placeholder="*" {...field} />
</FormControl>
<FormDescription>
Default Elasticsearch query to use for searches. Use "*" to match
all documents.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="search_fields"
render={({ field }) => (
<FormItem>
<FormLabel>
Search Fields{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input placeholder="title, content, description" {...field} />
</FormControl>
<FormDescription>
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 bg-muted/50 p-3">
<h4 className="text-sm 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-xs">
{field}
</Badge>
))}
</div>
</div>
)}
<FormField
control={form.control}
name="max_documents"
render={({ field }) => (
<FormItem>
<FormLabel>
Maximum Documents{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="1000"
min="1"
max="10000"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
Maximum number of documents to retrieve per search (1-10,000).
Leave empty to use Elasticsearch's default limit.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Index Selection Tips</AlertTitle>
<AlertDescription className="mt-2">
<ul className="list-disc pl-4 space-y-1 text-sm">
<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</li>
<li>Choosing specific indices improves search performance</li>
</ul>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
<Separator />
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Elasticsearch
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">
What you get with Elasticsearch integration:
</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search across your indexed documents and logs</li>
<li>Access structured and unstructured data from your cluster</li>
<li>Leverage existing Elasticsearch indices for enhanced search</li>
<li>Real-time search capabilities with powerful query features</li>
<li>Integration with your existing Elasticsearch infrastructure</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Elasticsearch Connector Documentation
</CardTitle>
<CardDescription>
Learn how to set up and use the Elasticsearch connector to search your data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="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>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="connection">
<AccordionTrigger className="text-lg font-medium">
Connection Setup
</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
<strong>Endpoint URL:</strong> Enter the complete Elasticsearch endpoint
URL (e.g., https://your-cluster.es.region.aws.com:443). We'll
automatically extract hostname, port, and SSL settings.
</li>
<li>
<strong>Authentication:</strong> Choose the appropriate method:
<ul className="list-disc pl-5 mt-1">
<li>
<strong>API Key:</strong> Base64 encoded API key (recommended for
security)
</li>
<li>
<strong>Username/Password:</strong> Basic authentication credentials
</li>
</ul>
</li>
</ol>
</AccordionContent>
</AccordionItem>
<AccordionItem value="advanced">
<AccordionTrigger className="text-lg font-medium">
Advanced Configuration
</AccordionTrigger>
<AccordionContent className="space-y-4">
<p className="text-muted-foreground">
Fine-tune your Elasticsearch connector with these optional settings:
</p>
<ul className="list-disc pl-5 space-y-2">
<li>
<strong>Index Selection:</strong> Specify which indices to search using
comma-separated patterns (e.g., "logs-*, documents-*")
</li>
<li>
<strong>Search Fields:</strong> Limit searches to specific fields (e.g.,
"title, content") for better relevance
</li>
<li>
<strong>Default Query:</strong> Set a default Elasticsearch query pattern
</li>
<li>
<strong>Max Documents:</strong> Limit the number of documents returned per
search (1-10,000)
</li>
</ul>
</AccordionContent>
</AccordionItem>
<AccordionItem value="troubleshooting">
<AccordionTrigger className="text-lg font-medium">
Troubleshooting
</AccordionTrigger>
<AccordionContent className="space-y-4">
<div className="space-y-4">
<div>
<h4 className="font-medium mb-2">Common Connection Issues:</h4>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
<li>
<strong>Connection Refused:</strong> Check hostname and port. Ensure
Elasticsearch is running.
</li>
<li>
<strong>Authentication Failed:</strong> Verify credentials. For API
keys, ensure they have proper permissions.
</li>
<li>
<strong>SSL Errors:</strong> Try disabling SSL for local development
or check certificate validity.
</li>
<li>
<strong>No Indices Found:</strong> Ensure your credentials have
permission to list and read indices.
</li>
</ul>
</div>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Security Note</AlertTitle>
<AlertDescription>
For production environments, use API keys with minimal required
permissions: cluster monitoring and read access to specific indices.
</AlertDescription>
</Alert>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -52,6 +52,13 @@ const connectorCategories: ConnectorCategory[] = [
icon: getConnectorIcon(EnumConnectorName.LINKUP_API, "h-6 w-6"),
status: "available",
},
{
id: "elasticsearch-connector",
title: "Elasticsearch",
description: "Connect to your Elasticsearch cluster to search and index data.",
icon: getConnectorIcon(EnumConnectorName.ELASTICSEARCH_CONNECTOR, "h-6 w-6"),
status: "available",
},
],
},
{

View file

@ -88,6 +88,7 @@ export function DashboardBreadcrumb() {
"serper-api": "Serper API",
"linkup-api": "LinkUp API",
"luma-connector": "Luma",
"elasticsearch-connector": "Elasticsearch",
};
const connectorLabel = connectorLabels[connectorType] || connectorType;

View file

@ -1,6 +1,7 @@
import {
IconBook,
IconBrandDiscord,
IconBrandElastic,
IconBrandGithub,
IconBrandNotion,
IconBrandSlack,
@ -17,7 +18,6 @@ import {
} from "@tabler/icons-react";
import { File, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react";
import { EnumConnectorName } from "./connector";
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
const iconProps = { className: className || "h-4 w-4" };
@ -52,6 +52,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <IconChecklist {...iconProps} />;
case EnumConnectorName.LUMA_CONNECTOR:
return <IconSparkles {...iconProps} />;
case EnumConnectorName.ELASTICSEARCH_CONNECTOR:
return <IconBrandElastic {...iconProps} />;
// Additional cases for non-enum connector types
case "YOUTUBE_VIDEO":
return <IconBrandYoutube {...iconProps} />;

View file

@ -32,6 +32,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",

View file

@ -47,6 +47,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.14
version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-radio-group':
specifier: ^1.3.8
version: 1.3.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-scroll-area':
specifier: ^1.2.9
version: 1.2.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -118,7 +121,7 @@ importers:
version: 15.6.6(@types/react@19.1.8)(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
fumadocs-mdx:
specifier: ^11.7.1
version: 11.7.1(acorn@8.14.0)(fumadocs-core@15.6.6(@types/react@19.1.8)(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)
version: 11.7.1(acorn@8.15.0)(fumadocs-core@15.6.6(@types/react@19.1.8)(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)
fumadocs-ui:
specifier: ^15.6.6
version: 15.6.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.1.11)
@ -1735,6 +1738,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@1.0.0':
resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==}
peerDependencies:
@ -1793,6 +1809,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-radio-group@1.3.8':
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.10':
resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==}
peerDependencies:
@ -1806,6 +1835,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-scroll-area@1.2.9':
resolution: {integrity: sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==}
peerDependencies:
@ -6554,7 +6596,7 @@ snapshots:
'@marijn/find-cluster-break@1.0.2': {}
'@mdx-js/mdx@3.1.0(acorn@8.14.0)':
'@mdx-js/mdx@3.1.0(acorn@8.15.0)':
dependencies:
'@types/estree': 1.0.8
'@types/estree-jsx': 1.0.5
@ -6568,7 +6610,7 @@ snapshots:
hast-util-to-jsx-runtime: 2.3.6
markdown-extensions: 2.0.0
recma-build-jsx: 1.0.0
recma-jsx: 1.0.0(acorn@8.14.0)
recma-jsx: 1.0.0(acorn@8.15.0)
recma-stringify: 1.0.0
rehype-recma: 1.0.0
remark-mdx: 3.1.0
@ -7260,6 +7302,16 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-primitive@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.26.9
@ -7305,6 +7357,24 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@ -7322,6 +7392,23 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-scroll-area@1.2.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/number': 1.1.1
@ -9073,9 +9160,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
fumadocs-mdx@11.7.1(acorn@8.14.0)(fumadocs-core@15.6.6(@types/react@19.1.8)(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0):
fumadocs-mdx@11.7.1(acorn@8.15.0)(fumadocs-core@15.6.6(@types/react@19.1.8)(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0):
dependencies:
'@mdx-js/mdx': 3.1.0(acorn@8.14.0)
'@mdx-js/mdx': 3.1.0(acorn@8.15.0)
'@standard-schema/spec': 1.0.0
chokidar: 4.0.3
esbuild: 0.25.8
@ -11137,9 +11224,9 @@ snapshots:
estree-util-build-jsx: 3.0.1
vfile: 6.0.3
recma-jsx@1.0.0(acorn@8.14.0):
recma-jsx@1.0.0(acorn@8.15.0):
dependencies:
acorn-jsx: 5.3.2(acorn@8.14.0)
acorn-jsx: 5.3.2(acorn@8.15.0)
estree-util-to-js: 2.0.0
recma-parse: 1.0.0
recma-stringify: 1.0.0