mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-20 23:21:06 +02:00
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:
parent
a7e0bad42a
commit
51f263da72
11 changed files with 864 additions and 420 deletions
|
|
@ -31,35 +31,47 @@ class ElasticsearchConnector:
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
"""Establish connection to Elasticsearch"""
|
"""Establish connection to Elasticsearch"""
|
||||||
try:
|
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 = {
|
connection_params = {
|
||||||
"hosts": [f"{self.config.hostname}:{self.config.port}"],
|
"hosts": [url],
|
||||||
"verify_certs": self.config.ssl_enabled
|
"verify_certs": self.config.get("ssl_enabled", True),
|
||||||
if hasattr(self.config, "ssl_enabled")
|
|
||||||
else True,
|
|
||||||
"request_timeout": 30,
|
"request_timeout": 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add authentication if provided
|
# Handle different authentication methods
|
||||||
|
auth_method = self.config.get("auth_method", "none")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasattr(self.config, "username")
|
auth_method == "basic"
|
||||||
and hasattr(self.config, "password")
|
and self.config.get("username")
|
||||||
and self.config.username
|
and self.config.get("password")
|
||||||
and self.config.password
|
|
||||||
):
|
):
|
||||||
connection_params["basic_auth"] = (
|
connection_params["basic_auth"] = (
|
||||||
self.config.username,
|
self.config["username"],
|
||||||
self.config.password,
|
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
|
logger.info(f"Connecting to Elasticsearch at {url}")
|
||||||
if hasattr(self.config, "api_key") and self.config.api_key:
|
|
||||||
connection_params["api_key"] = self.config.api_key
|
|
||||||
|
|
||||||
self.client = AsyncElasticsearch(**connection_params)
|
self.client = AsyncElasticsearch(**connection_params)
|
||||||
|
|
||||||
# Test connection
|
# Test connection
|
||||||
await self.client.info()
|
info = await self.client.info()
|
||||||
logger.info("Successfully connected to Elasticsearch")
|
logger.info(
|
||||||
|
f"Successfully connected to Elasticsearch cluster: {info.get('cluster_name', 'Unknown')}"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except (ConnectionError, AuthenticationException) as e:
|
except (ConnectionError, AuthenticationException) as e:
|
||||||
|
|
@ -92,6 +104,9 @@ class ElasticsearchConnector:
|
||||||
"cluster_name": info.get("cluster_name"),
|
"cluster_name": info.get("cluster_name"),
|
||||||
"version": info.get("version", {}).get("number"),
|
"version": info.get("version", {}).get("number"),
|
||||||
"indices_count": len(indices) if indices else 0,
|
"indices_count": len(indices) if indices else 0,
|
||||||
|
"indices": [
|
||||||
|
idx["index"] for idx in indices if not idx["index"].startswith(".")
|
||||||
|
],
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ from .airtable_add_connector_route import (
|
||||||
)
|
)
|
||||||
from .chats_routes import router as chats_router
|
from .chats_routes import router as chats_router
|
||||||
from .documents_routes import router as documents_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 (
|
from .google_calendar_add_connector_route import (
|
||||||
router as google_calendar_add_connector_router,
|
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(documents_router)
|
||||||
router.include_router(podcasts_router)
|
router.include_router(podcasts_router)
|
||||||
router.include_router(chats_router)
|
router.include_router(chats_router)
|
||||||
router.include_router(elasticsearch_router)
|
|
||||||
router.include_router(search_source_connectors_router)
|
router.include_router(search_source_connectors_router)
|
||||||
router.include_router(google_calendar_add_connector_router)
|
router.include_router(google_calendar_add_connector_router)
|
||||||
router.include_router(google_gmail_add_connector_router)
|
router.include_router(google_gmail_add_connector_router)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -216,6 +216,7 @@ class SearchSourceConnectorBase(BaseModel):
|
||||||
"username",
|
"username",
|
||||||
"password",
|
"password",
|
||||||
"api_key",
|
"api_key",
|
||||||
|
"auth_method",
|
||||||
"ssl_enabled",
|
"ssl_enabled",
|
||||||
"indices",
|
"indices",
|
||||||
"query",
|
"query",
|
||||||
|
|
@ -227,6 +228,18 @@ class SearchSourceConnectorBase(BaseModel):
|
||||||
if not config.get("hostname"):
|
if not config.get("hostname"):
|
||||||
raise ValueError("Elasticsearch connector must have a 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
|
# Validate that all config keys are allowed
|
||||||
for key in config:
|
for key in config:
|
||||||
if key not in allowed_keys:
|
if key not in allowed_keys:
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class ElasticsearchIndexer:
|
||||||
|
|
||||||
def __init__(self, connector_config: dict):
|
def __init__(self, connector_config: dict):
|
||||||
self.connector_config = connector_config
|
self.connector_config = connector_config
|
||||||
self.connector = ElasticsearchConnector(self.config)
|
self.connector = ElasticsearchConnector(self.connector_config)
|
||||||
|
|
||||||
async def get_documents(self):
|
async def get_documents(self):
|
||||||
"""Get documents from Elasticsearch (kept for backward compatibility)"""
|
"""Get documents from Elasticsearch (kept for backward compatibility)"""
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,13 @@ const connectorCategories: ConnectorCategory[] = [
|
||||||
icon: getConnectorIcon(EnumConnectorName.LINKUP_API, "h-6 w-6"),
|
icon: getConnectorIcon(EnumConnectorName.LINKUP_API, "h-6 w-6"),
|
||||||
status: "available",
|
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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ export function DashboardBreadcrumb() {
|
||||||
"serper-api": "Serper API",
|
"serper-api": "Serper API",
|
||||||
"linkup-api": "LinkUp API",
|
"linkup-api": "LinkUp API",
|
||||||
"luma-connector": "Luma",
|
"luma-connector": "Luma",
|
||||||
|
"elasticsearch-connector": "Elasticsearch",
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectorLabel = connectorLabels[connectorType] || connectorType;
|
const connectorLabel = connectorLabels[connectorType] || connectorType;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
IconBook,
|
IconBook,
|
||||||
IconBrandDiscord,
|
IconBrandDiscord,
|
||||||
|
IconBrandElastic,
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
IconBrandNotion,
|
IconBrandNotion,
|
||||||
IconBrandSlack,
|
IconBrandSlack,
|
||||||
|
|
@ -17,7 +18,6 @@ import {
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { File, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react";
|
import { File, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react";
|
||||||
import { EnumConnectorName } from "./connector";
|
import { EnumConnectorName } from "./connector";
|
||||||
|
|
||||||
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
|
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
|
||||||
const iconProps = { className: className || "h-4 w-4" };
|
const iconProps = { className: className || "h-4 w-4" };
|
||||||
|
|
||||||
|
|
@ -52,6 +52,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
||||||
return <IconChecklist {...iconProps} />;
|
return <IconChecklist {...iconProps} />;
|
||||||
case EnumConnectorName.LUMA_CONNECTOR:
|
case EnumConnectorName.LUMA_CONNECTOR:
|
||||||
return <IconSparkles {...iconProps} />;
|
return <IconSparkles {...iconProps} />;
|
||||||
|
case EnumConnectorName.ELASTICSEARCH_CONNECTOR:
|
||||||
|
return <IconBrandElastic {...iconProps} />;
|
||||||
// Additional cases for non-enum connector types
|
// Additional cases for non-enum connector types
|
||||||
case "YOUTUBE_VIDEO":
|
case "YOUTUBE_VIDEO":
|
||||||
return <IconBrandYoutube {...iconProps} />;
|
return <IconBrandYoutube {...iconProps} />;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@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-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
|
|
||||||
101
surfsense_web/pnpm-lock.yaml
generated
101
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -47,6 +47,9 @@ importers:
|
||||||
'@radix-ui/react-popover':
|
'@radix-ui/react-popover':
|
||||||
specifier: ^1.1.14
|
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)
|
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':
|
'@radix-ui/react-scroll-area':
|
||||||
specifier: ^1.2.9
|
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)
|
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)
|
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:
|
fumadocs-mdx:
|
||||||
specifier: ^11.7.1
|
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:
|
fumadocs-ui:
|
||||||
specifier: ^15.6.6
|
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)
|
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':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-primitive@1.0.0':
|
||||||
resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==}
|
resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1793,6 +1809,19 @@ packages:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-roving-focus@1.1.10':
|
||||||
resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==}
|
resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1806,6 +1835,19 @@ packages:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-scroll-area@1.2.9':
|
||||||
resolution: {integrity: sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==}
|
resolution: {integrity: sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -6554,7 +6596,7 @@ snapshots:
|
||||||
|
|
||||||
'@marijn/find-cluster-break@1.0.2': {}
|
'@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:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
'@types/estree-jsx': 1.0.5
|
'@types/estree-jsx': 1.0.5
|
||||||
|
|
@ -6568,7 +6610,7 @@ snapshots:
|
||||||
hast-util-to-jsx-runtime: 2.3.6
|
hast-util-to-jsx-runtime: 2.3.6
|
||||||
markdown-extensions: 2.0.0
|
markdown-extensions: 2.0.0
|
||||||
recma-build-jsx: 1.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
|
recma-stringify: 1.0.0
|
||||||
rehype-recma: 1.0.0
|
rehype-recma: 1.0.0
|
||||||
remark-mdx: 3.1.0
|
remark-mdx: 3.1.0
|
||||||
|
|
@ -7260,6 +7302,16 @@ snapshots:
|
||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
'@types/react-dom': 19.1.6(@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)':
|
'@radix-ui/react-primitive@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.26.9
|
'@babel/runtime': 7.26.9
|
||||||
|
|
@ -7305,6 +7357,24 @@ snapshots:
|
||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
'@types/react-dom': 19.1.6(@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.2
|
'@radix-ui/primitive': 1.1.2
|
||||||
|
|
@ -7322,6 +7392,23 @@ snapshots:
|
||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
'@types/react-dom': 19.1.6(@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/number': 1.1.1
|
'@radix-ui/number': 1.1.1
|
||||||
|
|
@ -9073,9 +9160,9 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
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
|
'@standard-schema/spec': 1.0.0
|
||||||
chokidar: 4.0.3
|
chokidar: 4.0.3
|
||||||
esbuild: 0.25.8
|
esbuild: 0.25.8
|
||||||
|
|
@ -11137,9 +11224,9 @@ snapshots:
|
||||||
estree-util-build-jsx: 3.0.1
|
estree-util-build-jsx: 3.0.1
|
||||||
vfile: 6.0.3
|
vfile: 6.0.3
|
||||||
|
|
||||||
recma-jsx@1.0.0(acorn@8.14.0):
|
recma-jsx@1.0.0(acorn@8.15.0):
|
||||||
dependencies:
|
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
|
estree-util-to-js: 2.0.0
|
||||||
recma-parse: 1.0.0
|
recma-parse: 1.0.0
|
||||||
recma-stringify: 1.0.0
|
recma-stringify: 1.0.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue