From f03f97307fb7cb3bc9254919cf1bba08037dd2cc Mon Sep 17 00:00:00 2001 From: pablodanswer Date: Thu, 27 Jun 2024 17:12:20 -0700 Subject: [PATCH] Blob Storage (#1705) S3 + OCI + Google Cloud Storage + R2 --------- Co-authored-by: Art Matsak <5328078+artmatsak@users.noreply.github.com> --- backend/danswer/configs/constants.py | 11 + backend/danswer/connectors/blob/__init__.py | 0 backend/danswer/connectors/blob/connector.py | 277 ++++++++++++++++++ backend/danswer/connectors/factory.py | 6 +- backend/requirements/default.txt | 2 +- backend/requirements/dev.txt | 1 + web/public/GoogleCloudStorage.png | Bin 0 -> 22212 bytes web/public/OCI.svg | 1 + web/public/S3.png | Bin 0 -> 52426 bytes web/public/r2.webp | Bin 0 -> 938 bytes .../admin/connectors/google-storage/page.tsx | 257 ++++++++++++++++ .../admin/connectors/oracle-storage/page.tsx | 272 +++++++++++++++++ web/src/app/admin/connectors/r2/page.tsx | 265 +++++++++++++++++ web/src/app/admin/connectors/s3/page.tsx | 258 ++++++++++++++++ .../admin/connectors/ConnectorForm.tsx | 3 - web/src/components/icons/icons.tsx | 56 ++++ web/src/lib/sources.ts | 41 ++- web/src/lib/types.ts | 52 +++- 18 files changed, 1492 insertions(+), 10 deletions(-) create mode 100644 backend/danswer/connectors/blob/__init__.py create mode 100644 backend/danswer/connectors/blob/connector.py create mode 100644 web/public/GoogleCloudStorage.png create mode 100644 web/public/OCI.svg create mode 100644 web/public/S3.png create mode 100644 web/public/r2.webp create mode 100644 web/src/app/admin/connectors/google-storage/page.tsx create mode 100644 web/src/app/admin/connectors/oracle-storage/page.tsx create mode 100644 web/src/app/admin/connectors/r2/page.tsx create mode 100644 web/src/app/admin/connectors/s3/page.tsx diff --git a/backend/danswer/configs/constants.py b/backend/danswer/configs/constants.py index 84579ca33..b29d3558b 100644 --- a/backend/danswer/configs/constants.py +++ b/backend/danswer/configs/constants.py @@ -100,6 +100,17 @@ class DocumentSource(str, Enum): CLICKUP = "clickup" MEDIAWIKI = "mediawiki" WIKIPEDIA = "wikipedia" + S3 = "s3" + R2 = "r2" + GOOGLE_CLOUD_STORAGE = "google_cloud_storage" + OCI_STORAGE = "oci_storage" + + +class BlobType(str, Enum): + R2 = "r2" + S3 = "s3" + GOOGLE_CLOUD_STORAGE = "google_cloud_storage" + OCI_STORAGE = "oci_storage" class DocumentIndexType(str, Enum): diff --git a/backend/danswer/connectors/blob/__init__.py b/backend/danswer/connectors/blob/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/danswer/connectors/blob/connector.py b/backend/danswer/connectors/blob/connector.py new file mode 100644 index 000000000..2446bfd16 --- /dev/null +++ b/backend/danswer/connectors/blob/connector.py @@ -0,0 +1,277 @@ +import os +from datetime import datetime +from datetime import timezone +from io import BytesIO +from typing import Any +from typing import Optional + +import boto3 +from botocore.client import Config +from mypy_boto3_s3 import S3Client + +from danswer.configs.app_configs import INDEX_BATCH_SIZE +from danswer.configs.constants import BlobType +from danswer.configs.constants import DocumentSource +from danswer.connectors.interfaces import GenerateDocumentsOutput +from danswer.connectors.interfaces import LoadConnector +from danswer.connectors.interfaces import PollConnector +from danswer.connectors.interfaces import SecondsSinceUnixEpoch +from danswer.connectors.models import ConnectorMissingCredentialError +from danswer.connectors.models import Document +from danswer.connectors.models import Section +from danswer.file_processing.extract_file_text import extract_file_text +from danswer.utils.logger import setup_logger + +logger = setup_logger() + + +class BlobStorageConnector(LoadConnector, PollConnector): + def __init__( + self, + bucket_type: str, + bucket_name: str, + prefix: str = "", + batch_size: int = INDEX_BATCH_SIZE, + ) -> None: + self.bucket_type: BlobType = BlobType(bucket_type) + self.bucket_name = bucket_name + self.prefix = prefix if not prefix or prefix.endswith("/") else prefix + "/" + self.batch_size = batch_size + self.s3_client: Optional[S3Client] = None + + def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: + """Checks for boto3 credentials based on the bucket type. + (1) R2: Access Key ID, Secret Access Key, Account ID + (2) S3: AWS Access Key ID, AWS Secret Access Key + (3) GOOGLE_CLOUD_STORAGE: Access Key ID, Secret Access Key, Project ID + (4) OCI_STORAGE: Namespace, Region, Access Key ID, Secret Access Key + + For each bucket type, the method initializes the appropriate S3 client: + - R2: Uses Cloudflare R2 endpoint with S3v4 signature + - S3: Creates a standard boto3 S3 client + - GOOGLE_CLOUD_STORAGE: Uses Google Cloud Storage endpoint + - OCI_STORAGE: Uses Oracle Cloud Infrastructure Object Storage endpoint + + Raises ConnectorMissingCredentialError if required credentials are missing. + Raises ValueError for unsupported bucket types. + """ + + logger.info( + f"Loading credentials for {self.bucket_name} or type {self.bucket_type}" + ) + + if self.bucket_type == BlobType.R2: + if not all( + credentials.get(key) + for key in ["r2_access_key_id", "r2_secret_access_key", "account_id"] + ): + raise ConnectorMissingCredentialError("Cloudflare R2") + self.s3_client = boto3.client( + "s3", + endpoint_url=f"https://{credentials['account_id']}.r2.cloudflarestorage.com", + aws_access_key_id=credentials["r2_access_key_id"], + aws_secret_access_key=credentials["r2_secret_access_key"], + region_name="auto", + config=Config(signature_version="s3v4"), + ) + + elif self.bucket_type == BlobType.S3: + if not all( + credentials.get(key) + for key in ["aws_access_key_id", "aws_secret_access_key"] + ): + raise ConnectorMissingCredentialError("Google Cloud Storage") + + session = boto3.Session( + aws_access_key_id=credentials["aws_access_key_id"], + aws_secret_access_key=credentials["aws_secret_access_key"], + ) + self.s3_client = session.client("s3") + + elif self.bucket_type == BlobType.GOOGLE_CLOUD_STORAGE: + if not all( + credentials.get(key) for key in ["access_key_id", "secret_access_key"] + ): + raise ConnectorMissingCredentialError("Google Cloud Storage") + + self.s3_client = boto3.client( + "s3", + endpoint_url="https://storage.googleapis.com", + aws_access_key_id=credentials["access_key_id"], + aws_secret_access_key=credentials["secret_access_key"], + region_name="auto", + ) + + elif self.bucket_type == BlobType.OCI_STORAGE: + if not all( + credentials.get(key) + for key in ["namespace", "region", "access_key_id", "secret_access_key"] + ): + raise ConnectorMissingCredentialError("Oracle Cloud Infrastructure") + + self.s3_client = boto3.client( + "s3", + endpoint_url=f"https://{credentials['namespace']}.compat.objectstorage.{credentials['region']}.oraclecloud.com", + aws_access_key_id=credentials["access_key_id"], + aws_secret_access_key=credentials["secret_access_key"], + region_name=credentials["region"], + ) + + else: + raise ValueError(f"Unsupported bucket type: {self.bucket_type}") + + return None + + def _download_object(self, key: str) -> bytes: + if self.s3_client is None: + raise ConnectorMissingCredentialError("Blob storage") + object = self.s3_client.get_object(Bucket=self.bucket_name, Key=key) + return object["Body"].read() + + # NOTE: Left in as may be useful for one-off access to documents and sharing across orgs. + # def _get_presigned_url(self, key: str) -> str: + # if self.s3_client is None: + # raise ConnectorMissingCredentialError("Blog storage") + + # url = self.s3_client.generate_presigned_url( + # "get_object", + # Params={"Bucket": self.bucket_name, "Key": key}, + # ExpiresIn=self.presign_length, + # ) + # return url + + def _get_blob_link(self, key: str) -> str: + if self.s3_client is None: + raise ConnectorMissingCredentialError("Blob storage") + + if self.bucket_type == BlobType.R2: + account_id = self.s3_client.meta.endpoint_url.split("//")[1].split(".")[0] + return f"https://{account_id}.r2.cloudflarestorage.com/{self.bucket_name}/{key}" + + elif self.bucket_type == BlobType.S3: + region = self.s3_client.meta.region_name + return f"https://{self.bucket_name}.s3.{region}.amazonaws.com/{key}" + + elif self.bucket_type == BlobType.GOOGLE_CLOUD_STORAGE: + return f"https://storage.cloud.google.com/{self.bucket_name}/{key}" + + elif self.bucket_type == BlobType.OCI_STORAGE: + namespace = self.s3_client.meta.endpoint_url.split("//")[1].split(".")[0] + region = self.s3_client.meta.region_name + return f"https://objectstorage.{region}.oraclecloud.com/n/{namespace}/b/{self.bucket_name}/o/{key}" + + else: + raise ValueError(f"Unsupported bucket type: {self.bucket_type}") + + def _yield_blob_objects( + self, + start: datetime, + end: datetime, + ) -> GenerateDocumentsOutput: + if self.s3_client is None: + raise ConnectorMissingCredentialError("Blog storage") + + paginator = self.s3_client.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=self.bucket_name, Prefix=self.prefix) + + batch: list[Document] = [] + for page in pages: + if "Contents" not in page: + continue + + for obj in page["Contents"]: + if obj["Key"].endswith("/"): + continue + + last_modified = obj["LastModified"].replace(tzinfo=timezone.utc) + + if not start <= last_modified <= end: + continue + + downloaded_file = self._download_object(obj["Key"]) + link = self._get_blob_link(obj["Key"]) + name = os.path.basename(obj["Key"]) + + try: + text = extract_file_text( + name, + BytesIO(downloaded_file), + break_on_unprocessable=False, + ) + batch.append( + Document( + id=f"{self.bucket_type}:{self.bucket_name}:{obj['Key']}", + sections=[Section(link=link, text=text)], + source=DocumentSource(self.bucket_type.value), + semantic_identifier=name, + doc_updated_at=last_modified, + metadata={}, + ) + ) + if len(batch) == self.batch_size: + yield batch + batch = [] + + except Exception as e: + logger.exception( + f"Error decoding object {obj['Key']} as UTF-8: {e}" + ) + if batch: + yield batch + + def load_from_state(self) -> GenerateDocumentsOutput: + logger.info("Loading blob objects") + return self._yield_blob_objects( + start=datetime(1970, 1, 1, tzinfo=timezone.utc), + end=datetime.now(timezone.utc), + ) + + def poll_source( + self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch + ) -> GenerateDocumentsOutput: + if self.s3_client is None: + raise ConnectorMissingCredentialError("Blog storage") + + start_datetime = datetime.fromtimestamp(start, tz=timezone.utc) + end_datetime = datetime.fromtimestamp(end, tz=timezone.utc) + + for batch in self._yield_blob_objects(start_datetime, end_datetime): + yield batch + + return None + + +if __name__ == "__main__": + credentials_dict = { + "aws_access_key_id": os.environ.get("AWS_ACCESS_KEY_ID"), + "aws_secret_access_key": os.environ.get("AWS_SECRET_ACCESS_KEY"), + } + + # Initialize the connector + connector = BlobStorageConnector( + bucket_type=os.environ.get("BUCKET_TYPE") or "s3", + bucket_name=os.environ.get("BUCKET_NAME") or "test", + prefix="", + ) + + try: + connector.load_credentials(credentials_dict) + document_batch_generator = connector.load_from_state() + for document_batch in document_batch_generator: + print("First batch of documents:") + for doc in document_batch: + print(f"Document ID: {doc.id}") + print(f"Semantic Identifier: {doc.semantic_identifier}") + print(f"Source: {doc.source}") + print(f"Updated At: {doc.doc_updated_at}") + print("Sections:") + for section in doc.sections: + print(f" - Link: {section.link}") + print(f" - Text: {section.text[:100]}...") + print("---") + break + + except ConnectorMissingCredentialError as e: + print(f"Error: {e}") + except Exception as e: + print(f"An unexpected error occurred: {e}") diff --git a/backend/danswer/connectors/factory.py b/backend/danswer/connectors/factory.py index f70980a8d..1a3d605d3 100644 --- a/backend/danswer/connectors/factory.py +++ b/backend/danswer/connectors/factory.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session from danswer.configs.constants import DocumentSource from danswer.connectors.axero.connector import AxeroConnector +from danswer.connectors.blob.connector import BlobStorageConnector from danswer.connectors.bookstack.connector import BookstackConnector from danswer.connectors.clickup.connector import ClickupConnector from danswer.connectors.confluence.connector import ConfluenceConnector @@ -90,6 +91,10 @@ def identify_connector_class( DocumentSource.CLICKUP: ClickupConnector, DocumentSource.MEDIAWIKI: MediaWikiConnector, DocumentSource.WIKIPEDIA: WikipediaConnector, + DocumentSource.S3: BlobStorageConnector, + DocumentSource.R2: BlobStorageConnector, + DocumentSource.GOOGLE_CLOUD_STORAGE: BlobStorageConnector, + DocumentSource.OCI_STORAGE: BlobStorageConnector, } connector_by_source = connector_map.get(source, {}) @@ -115,7 +120,6 @@ def identify_connector_class( raise ConnectorMissingException( f"Connector for source={source} does not accept input_type={input_type}" ) - return connector diff --git a/backend/requirements/default.txt b/backend/requirements/default.txt index 2d8745f33..2252551b9 100644 --- a/backend/requirements/default.txt +++ b/backend/requirements/default.txt @@ -71,4 +71,4 @@ uvicorn==0.21.1 zulip==0.8.2 hubspot-api-client==8.1.0 zenpy==2.0.41 -dropbox==11.36.2 +dropbox==11.36.2 \ No newline at end of file diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt index 4a9bd21d3..3a062a859 100644 --- a/backend/requirements/dev.txt +++ b/backend/requirements/dev.txt @@ -19,3 +19,4 @@ types-regex==2023.3.23.1 types-requests==2.28.11.17 types-retry==0.9.9.3 types-urllib3==1.26.25.11 +boto3-stubs[s3]==1.34.133 \ No newline at end of file diff --git a/web/public/GoogleCloudStorage.png b/web/public/GoogleCloudStorage.png new file mode 100644 index 0000000000000000000000000000000000000000..4790f2c52441a9e0b06357a641be89f9858f7511 GIT binary patch literal 22212 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 M`iJ^e`Nkaj7g{K;T>t<8 literal 0 HcmV?d00001 diff --git a/web/public/OCI.svg b/web/public/OCI.svg new file mode 100644 index 000000000..75d359b00 --- /dev/null +++ b/web/public/OCI.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/S3.png b/web/public/S3.png new file mode 100644 index 0000000000000000000000000000000000000000..75eb1f19b34f06687b4ae6f6c2ae5104af7da9c3 GIT binary patch literal 52426 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 MU-W;`|9=ez0E51`t^fc4 literal 0 HcmV?d00001 diff --git a/web/src/app/admin/connectors/google-storage/page.tsx b/web/src/app/admin/connectors/google-storage/page.tsx new file mode 100644 index 000000000..a836df21f --- /dev/null +++ b/web/src/app/admin/connectors/google-storage/page.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { AdminPageTitle } from "@/components/admin/Title"; +import { HealthCheckBanner } from "@/components/health/healthcheck"; +import { GoogleStorageIcon, TrashIcon } from "@/components/icons/icons"; +import { LoadingAnimation } from "@/components/Loading"; +import { ConnectorForm } from "@/components/admin/connectors/ConnectorForm"; +import { CredentialForm } from "@/components/admin/connectors/CredentialForm"; +import { TextFormField } from "@/components/admin/connectors/Field"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { ConnectorsTable } from "@/components/admin/connectors/table/ConnectorsTable"; +import { adminDeleteCredential, linkCredential } from "@/lib/credential"; +import { errorHandlingFetcher } from "@/lib/fetcher"; +import { ErrorCallout } from "@/components/ErrorCallout"; +import { usePublicCredentials } from "@/lib/hooks"; +import { ConnectorIndexingStatus, Credential } from "@/lib/types"; + +import { GCSConfig, GCSCredentialJson } from "@/lib/types"; + +import { Card, Select, SelectItem, Text, Title } from "@tremor/react"; +import useSWR, { useSWRConfig } from "swr"; +import * as Yup from "yup"; +import { useState } from "react"; + +const GCSMain = () => { + const { popup, setPopup } = usePopup(); + const { mutate } = useSWRConfig(); + const { + data: connectorIndexingStatuses, + isLoading: isConnectorIndexingStatusesLoading, + error: connectorIndexingStatusesError, + } = useSWR[]>( + "/api/manage/admin/connector/indexing-status", + errorHandlingFetcher + ); + const { + data: credentialsData, + isLoading: isCredentialsLoading, + error: credentialsError, + refreshCredentials, + } = usePublicCredentials(); + + if ( + (!connectorIndexingStatuses && isConnectorIndexingStatusesLoading) || + (!credentialsData && isCredentialsLoading) + ) { + return ; + } + + if (connectorIndexingStatusesError || !connectorIndexingStatuses) { + return ( + + ); + } + + if (credentialsError || !credentialsData) { + return ( + + ); + } + + const gcsConnectorIndexingStatuses: ConnectorIndexingStatus< + GCSConfig, + GCSCredentialJson + >[] = connectorIndexingStatuses.filter( + (connectorIndexingStatus) => + connectorIndexingStatus.connector.source === "google_cloud_storage" + ); + + const gcsCredential: Credential | undefined = + credentialsData.find( + (credential) => credential.credential_json?.project_id + ); + + return ( + <> + {popup} + + Step 1: Provide your GCS access info + + {gcsCredential ? ( + <> +

+ + ) : ( + <> + +
    +
  • + Provide your GCS Project ID, Client Email, and Private Key for + authentication. +
  • +
  • + These credentials will be used to access your GCS buckets. +
  • +
+
+ + + formBody={ + <> + + + + + } + validationSchema={Yup.object().shape({ + secret_access_key: Yup.string().required( + "Client Email is required" + ), + access_key_id: Yup.string().required("Private Key is required"), + })} + initialValues={{ + secret_access_key: "", + access_key_id: "", + }} + onSubmit={(isSuccess) => { + if (isSuccess) { + refreshCredentials(); + } + }} + /> + + + )} + + + Step 2: Which GCS bucket do you want to make searchable? + + + {gcsConnectorIndexingStatuses.length > 0 && ( + <> + + GCS indexing status + + + The latest changes are fetched every 10 minutes. + +
+ + includeName={true} + connectorIndexingStatuses={gcsConnectorIndexingStatuses} + liveCredential={gcsCredential} + getCredential={(credential) => { + return
; + }} + onCredentialLink={async (connectorId) => { + if (gcsCredential) { + await linkCredential(connectorId, gcsCredential.id); + mutate("/api/manage/admin/connector/indexing-status"); + } + }} + onUpdate={() => + mutate("/api/manage/admin/connector/indexing-status") + } + /> +
+ + )} + + {gcsCredential && ( + <> + +

Create Connection

+ + Press connect below to start the connection to your GCS bucket. + + + nameBuilder={(values) => `GCSConnector-${values.bucket_name}`} + ccPairNameBuilder={(values) => + `GCSConnector-${values.bucket_name}` + } + source="google_cloud_storage" + inputType="poll" + formBodyBuilder={(values) => ( +
+ + +
+ )} + validationSchema={Yup.object().shape({ + bucket_type: Yup.string() + .oneOf(["google_cloud_storage"]) + .required("Bucket type must be google_cloud_storage"), + bucket_name: Yup.string().required( + "Please enter the name of the GCS bucket to index, e.g. my-gcs-bucket" + ), + prefix: Yup.string().default(""), + })} + initialValues={{ + bucket_type: "google_cloud_storage", + bucket_name: "", + prefix: "", + }} + refreshFreq={60 * 60 * 24} // 1 day + credentialId={gcsCredential.id} + /> +
+ + )} + + ); +}; + +export default function Page() { + return ( +
+
+ +
+ } + title="Google Cloud Storage" + /> + +
+ ); +} diff --git a/web/src/app/admin/connectors/oracle-storage/page.tsx b/web/src/app/admin/connectors/oracle-storage/page.tsx new file mode 100644 index 000000000..34847a4b9 --- /dev/null +++ b/web/src/app/admin/connectors/oracle-storage/page.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { AdminPageTitle } from "@/components/admin/Title"; +import { HealthCheckBanner } from "@/components/health/healthcheck"; +import { OCIStorageIcon, TrashIcon } from "@/components/icons/icons"; +import { LoadingAnimation } from "@/components/Loading"; +import { ConnectorForm } from "@/components/admin/connectors/ConnectorForm"; +import { CredentialForm } from "@/components/admin/connectors/CredentialForm"; +import { TextFormField } from "@/components/admin/connectors/Field"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { ConnectorsTable } from "@/components/admin/connectors/table/ConnectorsTable"; +import { adminDeleteCredential, linkCredential } from "@/lib/credential"; +import { errorHandlingFetcher } from "@/lib/fetcher"; +import { ErrorCallout } from "@/components/ErrorCallout"; +import { usePublicCredentials } from "@/lib/hooks"; + +import { + ConnectorIndexingStatus, + Credential, + OCIConfig, + OCICredentialJson, + R2Config, + R2CredentialJson, +} from "@/lib/types"; +import { Card, Select, SelectItem, Text, Title } from "@tremor/react"; +import useSWR, { useSWRConfig } from "swr"; +import * as Yup from "yup"; +import { useState } from "react"; + +const OCIMain = () => { + const { popup, setPopup } = usePopup(); + + const { mutate } = useSWRConfig(); + const { + data: connectorIndexingStatuses, + isLoading: isConnectorIndexingStatusesLoading, + error: connectorIndexingStatusesError, + } = useSWR[]>( + "/api/manage/admin/connector/indexing-status", + errorHandlingFetcher + ); + const { + data: credentialsData, + isLoading: isCredentialsLoading, + error: credentialsError, + refreshCredentials, + } = usePublicCredentials(); + + if ( + (!connectorIndexingStatuses && isConnectorIndexingStatusesLoading) || + (!credentialsData && isCredentialsLoading) + ) { + return ; + } + + if (connectorIndexingStatusesError || !connectorIndexingStatuses) { + return ( + + ); + } + + if (credentialsError || !credentialsData) { + return ( + + ); + } + + const ociConnectorIndexingStatuses: ConnectorIndexingStatus< + OCIConfig, + OCICredentialJson + >[] = connectorIndexingStatuses.filter( + (connectorIndexingStatus) => + connectorIndexingStatus.connector.source === "oci_storage" + ); + + const ociCredential: Credential | undefined = + credentialsData.find((credential) => credential.credential_json?.namespace); + + return ( + <> + {popup} + + Step 1: Provide your access info + + {ociCredential ? ( + <> + {" "} +
+

Existing OCI Access Key ID:

+

+ {ociCredential.credential_json.access_key_id} +

+ {", "} +

Namespace:

+

+ {ociCredential.credential_json.namespace} +

{" "} + +
+ + ) : ( + <> + +
    +
  • + Provide your OCI Access Key ID, Secret Access Key, Namespace, + and Region for authentication. +
  • +
  • + These credentials will be used to access your OCI buckets. +
  • +
+
+ + + formBody={ + <> + + + + + + } + validationSchema={Yup.object().shape({ + access_key_id: Yup.string().required( + "OCI Access Key ID is required" + ), + secret_access_key: Yup.string().required( + "OCI Secret Access Key is required" + ), + namespace: Yup.string().required("Namespace is required"), + region: Yup.string().required("Region is required"), + })} + initialValues={{ + access_key_id: "", + secret_access_key: "", + namespace: "", + region: "", + }} + onSubmit={(isSuccess) => { + if (isSuccess) { + refreshCredentials(); + } + }} + /> + + + )} + + + Step 2: Which OCI bucket do you want to make searchable? + + + {ociConnectorIndexingStatuses.length > 0 && ( + <> + + OCI indexing status + + + The latest changes are fetched every 10 minutes. + +
+ + includeName={true} + connectorIndexingStatuses={ociConnectorIndexingStatuses} + liveCredential={ociCredential} + getCredential={(credential) => { + return
; + }} + onCredentialLink={async (connectorId) => { + if (ociCredential) { + await linkCredential(connectorId, ociCredential.id); + mutate("/api/manage/admin/connector/indexing-status"); + } + }} + onUpdate={() => + mutate("/api/manage/admin/connector/indexing-status") + } + /> +
+ + )} + + {ociCredential && ( + <> + +

Create Connection

+ + Press connect below to start the connection to your OCI bucket. + + + nameBuilder={(values) => `OCIConnector-${values.bucket_name}`} + ccPairNameBuilder={(values) => + `OCIConnector-${values.bucket_name}` + } + source="oci_storage" + inputType="poll" + formBodyBuilder={(values) => ( +
+ + +
+ )} + validationSchema={Yup.object().shape({ + bucket_type: Yup.string() + .oneOf(["oci_storage"]) + .required("Bucket type must be oci_storage"), + bucket_name: Yup.string().required( + "Please enter the name of the OCI bucket to index, e.g. my-test-bucket" + ), + prefix: Yup.string().default(""), + })} + initialValues={{ + bucket_type: "oci_storage", + bucket_name: "", + prefix: "", + }} + refreshFreq={60 * 60 * 24} // 1 day + credentialId={ociCredential.id} + /> +
+ + )} + + ); +}; + +export default function Page() { + return ( +
+
+ +
+ } + title="Oracle Cloud Infrastructure" + /> + +
+ ); +} diff --git a/web/src/app/admin/connectors/r2/page.tsx b/web/src/app/admin/connectors/r2/page.tsx new file mode 100644 index 000000000..372660acc --- /dev/null +++ b/web/src/app/admin/connectors/r2/page.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { AdminPageTitle } from "@/components/admin/Title"; +import { HealthCheckBanner } from "@/components/health/healthcheck"; +import { R2Icon, S3Icon, TrashIcon } from "@/components/icons/icons"; +import { LoadingAnimation } from "@/components/Loading"; +import { ConnectorForm } from "@/components/admin/connectors/ConnectorForm"; +import { CredentialForm } from "@/components/admin/connectors/CredentialForm"; +import { TextFormField } from "@/components/admin/connectors/Field"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { ConnectorsTable } from "@/components/admin/connectors/table/ConnectorsTable"; +import { adminDeleteCredential, linkCredential } from "@/lib/credential"; +import { errorHandlingFetcher } from "@/lib/fetcher"; +import { ErrorCallout } from "@/components/ErrorCallout"; +import { usePublicCredentials } from "@/lib/hooks"; +import { + ConnectorIndexingStatus, + Credential, + R2Config, + R2CredentialJson, +} from "@/lib/types"; +import { Card, Select, SelectItem, Text, Title } from "@tremor/react"; +import useSWR, { useSWRConfig } from "swr"; +import * as Yup from "yup"; +import { useState } from "react"; + +const R2Main = () => { + const { popup, setPopup } = usePopup(); + + const { mutate } = useSWRConfig(); + const { + data: connectorIndexingStatuses, + isLoading: isConnectorIndexingStatusesLoading, + error: connectorIndexingStatusesError, + } = useSWR[]>( + "/api/manage/admin/connector/indexing-status", + errorHandlingFetcher + ); + const { + data: credentialsData, + isLoading: isCredentialsLoading, + error: credentialsError, + refreshCredentials, + } = usePublicCredentials(); + + if ( + (!connectorIndexingStatuses && isConnectorIndexingStatusesLoading) || + (!credentialsData && isCredentialsLoading) + ) { + return ; + } + + if (connectorIndexingStatusesError || !connectorIndexingStatuses) { + return ( + + ); + } + + if (credentialsError || !credentialsData) { + return ( + + ); + } + + const r2ConnectorIndexingStatuses: ConnectorIndexingStatus< + R2Config, + R2CredentialJson + >[] = connectorIndexingStatuses.filter( + (connectorIndexingStatus) => + connectorIndexingStatus.connector.source === "r2" + ); + + const r2Credential: Credential | undefined = + credentialsData.find( + (credential) => credential.credential_json?.account_id + ); + + return ( + <> + {popup} + + Step 1: Provide your access info + + {r2Credential ? ( + <> + {" "} +
+

Existing R2 Access Key ID:

+

+ {r2Credential.credential_json.r2_access_key_id} +

+ {", "} +

Account ID:

+

+ {r2Credential.credential_json.account_id} +

{" "} + +
+ + ) : ( + <> + +
    +
  • + Provide your R2 Access Key ID, Secret Access Key, and Account ID + for authentication. +
  • +
  • These credentials will be used to access your R2 buckets.
  • +
+
+ + + formBody={ + <> + + + + + } + validationSchema={Yup.object().shape({ + r2_access_key_id: Yup.string().required( + "R2 Access Key ID is required" + ), + r2_secret_access_key: Yup.string().required( + "R2 Secret Access Key is required" + ), + account_id: Yup.string().required("Account ID is required"), + })} + initialValues={{ + r2_access_key_id: "", + r2_secret_access_key: "", + account_id: "", + }} + onSubmit={(isSuccess) => { + if (isSuccess) { + refreshCredentials(); + } + }} + /> + + + )} + + + Step 2: Which R2 bucket do you want to make searchable? + + + {r2ConnectorIndexingStatuses.length > 0 && ( + <> + + R2 indexing status + + + The latest changes are fetched every 10 minutes. + +
+ + includeName={true} + connectorIndexingStatuses={r2ConnectorIndexingStatuses} + liveCredential={r2Credential} + getCredential={(credential) => { + return
; + }} + onCredentialLink={async (connectorId) => { + if (r2Credential) { + await linkCredential(connectorId, r2Credential.id); + mutate("/api/manage/admin/connector/indexing-status"); + } + }} + onUpdate={() => + mutate("/api/manage/admin/connector/indexing-status") + } + /> +
+ + )} + + {r2Credential && ( + <> + +

Create Connection

+ + Press connect below to start the connection to your R2 bucket. + + + nameBuilder={(values) => `R2Connector-${values.bucket_name}`} + ccPairNameBuilder={(values) => + `R2Connector-${values.bucket_name}` + } + source="r2" + inputType="poll" + formBodyBuilder={(values) => ( +
+ + +
+ )} + validationSchema={Yup.object().shape({ + bucket_type: Yup.string() + .oneOf(["r2"]) + .required("Bucket type must be r2"), + bucket_name: Yup.string().required( + "Please enter the name of the r2 bucket to index, e.g. my-test-bucket" + ), + prefix: Yup.string().default(""), + })} + initialValues={{ + bucket_type: "r2", + bucket_name: "", + prefix: "", + }} + refreshFreq={60 * 60 * 24} // 1 day + credentialId={r2Credential.id} + /> +
+ + )} + + ); +}; + +export default function Page() { + const [selectedStorage, setSelectedStorage] = useState("s3"); + + return ( +
+
+ +
+ } title="R2 Storage" /> + +
+ ); +} diff --git a/web/src/app/admin/connectors/s3/page.tsx b/web/src/app/admin/connectors/s3/page.tsx new file mode 100644 index 000000000..81064a70b --- /dev/null +++ b/web/src/app/admin/connectors/s3/page.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { AdminPageTitle } from "@/components/admin/Title"; +import { HealthCheckBanner } from "@/components/health/healthcheck"; +import { S3Icon, TrashIcon } from "@/components/icons/icons"; +import { LoadingAnimation } from "@/components/Loading"; +import { ConnectorForm } from "@/components/admin/connectors/ConnectorForm"; +import { CredentialForm } from "@/components/admin/connectors/CredentialForm"; +import { TextFormField } from "@/components/admin/connectors/Field"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { ConnectorsTable } from "@/components/admin/connectors/table/ConnectorsTable"; +import { adminDeleteCredential, linkCredential } from "@/lib/credential"; +import { errorHandlingFetcher } from "@/lib/fetcher"; +import { ErrorCallout } from "@/components/ErrorCallout"; +import { usePublicCredentials } from "@/lib/hooks"; +import { + ConnectorIndexingStatus, + Credential, + S3Config, + S3CredentialJson, +} from "@/lib/types"; +import { Card, Text, Title } from "@tremor/react"; +import useSWR, { useSWRConfig } from "swr"; +import * as Yup from "yup"; +import { useState } from "react"; + +const S3Main = () => { + const { popup, setPopup } = usePopup(); + + const { mutate } = useSWRConfig(); + const { + data: connectorIndexingStatuses, + isLoading: isConnectorIndexingStatusesLoading, + error: connectorIndexingStatusesError, + } = useSWR[]>( + "/api/manage/admin/connector/indexing-status", + errorHandlingFetcher + ); + const { + data: credentialsData, + isLoading: isCredentialsLoading, + error: credentialsError, + refreshCredentials, + } = usePublicCredentials(); + + if ( + (!connectorIndexingStatuses && isConnectorIndexingStatusesLoading) || + (!credentialsData && isCredentialsLoading) + ) { + return ; + } + + if (connectorIndexingStatusesError || !connectorIndexingStatuses) { + return ( + + ); + } + + if (credentialsError || !credentialsData) { + return ( + + ); + } + + const s3ConnectorIndexingStatuses: ConnectorIndexingStatus< + S3Config, + S3CredentialJson + >[] = connectorIndexingStatuses.filter( + (connectorIndexingStatus) => + connectorIndexingStatus.connector.source === "s3" + ); + + const s3Credential: Credential | undefined = + credentialsData.find( + (credential) => credential.credential_json?.aws_access_key_id + ); + + return ( + <> + {popup} + + Step 1: Provide your access info + + {s3Credential ? ( + <> + {" "} +
+

Existing AWS Access Key ID:

+

+ {s3Credential.credential_json.aws_access_key_id} +

+ +
+ + ) : ( + <> + +
    +
  • + If AWS Access Key ID and AWS Secret Access Key are provided, + they will be used for authenticating the connector. +
  • +
  • Otherwise, the Profile Name will be used (if provided).
  • +
  • + If no credentials are provided, then the connector will try to + authenticate with any default AWS credentials available. +
  • +
+
+ + + formBody={ + <> + + + + } + validationSchema={Yup.object().shape({ + aws_access_key_id: Yup.string().default(""), + aws_secret_access_key: Yup.string().default(""), + })} + initialValues={{ + aws_access_key_id: "", + aws_secret_access_key: "", + }} + onSubmit={(isSuccess) => { + if (isSuccess) { + refreshCredentials(); + } + }} + /> + + + )} + + + Step 2: Which S3 bucket do you want to make searchable? + + + {s3ConnectorIndexingStatuses.length > 0 && ( + <> + + S3 indexing status + + + The latest changes are fetched every 10 minutes. + +
+ + includeName={true} + connectorIndexingStatuses={s3ConnectorIndexingStatuses} + liveCredential={s3Credential} + getCredential={(credential) => { + return
; + }} + onCredentialLink={async (connectorId) => { + if (s3Credential) { + await linkCredential(connectorId, s3Credential.id); + mutate("/api/manage/admin/connector/indexing-status"); + } + }} + onUpdate={() => + mutate("/api/manage/admin/connector/indexing-status") + } + /> +
+ + )} + + {s3Credential && ( + <> + +

Create Connection

+ + Press connect below to start the connection to your S3 bucket. + + + nameBuilder={(values) => `S3Connector-${values.bucket_name}`} + ccPairNameBuilder={(values) => + `S3Connector-${values.bucket_name}` + } + source="s3" + inputType="poll" + formBodyBuilder={(values) => ( +
+ + +
+ )} + validationSchema={Yup.object().shape({ + bucket_type: Yup.string() + .oneOf(["s3"]) + .required("Bucket type must be s3"), + bucket_name: Yup.string().required( + "Please enter the name of the s3 bucket to index, e.g. my-test-bucket" + ), + prefix: Yup.string().default(""), + })} + initialValues={{ + bucket_type: "s3", + bucket_name: "", + prefix: "", + }} + refreshFreq={60 * 60 * 24} // 1 day + credentialId={s3Credential.id} + /> +
+ + )} + + ); +}; + +export default function Page() { + const [selectedStorage, setSelectedStorage] = useState("s3"); + + return ( +
+
+ +
+ } title="S3 Storage" /> + + +
+ ); +} diff --git a/web/src/components/admin/connectors/ConnectorForm.tsx b/web/src/components/admin/connectors/ConnectorForm.tsx index fd269501d..626eb0d18 100644 --- a/web/src/components/admin/connectors/ConnectorForm.tsx +++ b/web/src/components/admin/connectors/ConnectorForm.tsx @@ -27,7 +27,6 @@ export async function submitConnector( ): Promise<{ message: string; isSuccess: boolean; response?: Connector }> { const isUpdate = connectorId !== undefined; - let isSuccess = false; try { const response = await fetch( BASE_CONNECTOR_URL + (isUpdate ? `/${connectorId}` : ""), @@ -41,7 +40,6 @@ export async function submitConnector( ); if (response.ok) { - isSuccess = true; const responseJson = await response.json(); return { message: "Success!", isSuccess: true, response: responseJson }; } else { @@ -162,7 +160,6 @@ export function ConnectorForm({ }); return; } - const { message, isSuccess, response } = await submitConnector({ name: connectorName, source, diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx index e66afd9e2..b82ef5f31 100644 --- a/web/src/components/icons/icons.tsx +++ b/web/src/components/icons/icons.tsx @@ -44,6 +44,8 @@ import { SiBookstack } from "react-icons/si"; import Image from "next/image"; import jiraSVG from "../../../public/Jira.svg"; import confluenceSVG from "../../../public/Confluence.svg"; +import OCIStorageSVG from "../../../public/OCI.svg"; +import googleCloudStorageIcon from "../../../public/GoogleCloudStorage.png"; import guruIcon from "../../../public/Guru.svg"; import gongIcon from "../../../public/Gong.png"; import requestTrackerIcon from "../../../public/RequestTracker.png"; @@ -54,6 +56,8 @@ import document360Icon from "../../../public/Document360.png"; import googleSitesIcon from "../../../public/GoogleSites.png"; import zendeskIcon from "../../../public/Zendesk.svg"; import dropboxIcon from "../../../public/Dropbox.png"; +import s3Icon from "../../../public/S3.png"; +import r2Icon from "../../../public/r2.webp"; import salesforceIcon from "../../../public/Salesforce.png"; import sharepointIcon from "../../../public/Sharepoint.png"; import teamsIcon from "../../../public/Teams.png"; @@ -423,6 +427,20 @@ export const ConfluenceIcon = ({ ); }; +export const OCIStorageIcon = ({ + size = 16, + className = defaultTailwindCSS, +}: IconProps) => { + return ( +
+ Logo +
+ ); +}; + export const JiraIcon = ({ size = 16, className = defaultTailwindCSS, @@ -452,6 +470,20 @@ export const ZulipIcon = ({ ); }; +export const GoogleStorageIcon = ({ + size = 16, + className = defaultTailwindCSS, +}: IconProps) => { + return ( +
+ Logo +
+ ); +}; + export const ProductboardIcon = ({ size = 16, className = defaultTailwindCSS, @@ -543,6 +575,30 @@ export const SalesforceIcon = ({ ); +export const R2Icon = ({ + size = 16, + className = defaultTailwindCSS, +}: IconProps) => ( +
+ Logo +
+); + +export const S3Icon = ({ + size = 16, + className = defaultTailwindCSS, +}: IconProps) => ( +
+ Logo +
+); + export const SharepointIcon = ({ size = 16, className = defaultTailwindCSS, diff --git a/web/src/lib/sources.ts b/web/src/lib/sources.ts index 23affabde..f141e42ed 100644 --- a/web/src/lib/sources.ts +++ b/web/src/lib/sources.ts @@ -22,6 +22,7 @@ import { NotionIcon, ProductboardIcon, RequestTrackerIcon, + R2Icon, SalesforceIcon, SharepointIcon, TeamsIcon, @@ -31,10 +32,14 @@ import { ZulipIcon, MediaWikiIcon, WikipediaIcon, + S3Icon, + OCIStorageIcon, + GoogleStorageIcon, } from "@/components/icons/icons"; import { ValidSources } from "./types"; import { SourceCategory, SourceMetadata } from "./search/interfaces"; import { Persona } from "@/app/admin/assistants/interfaces"; +import internal from "stream"; interface PartialSourceMetadata { icon: React.FC<{ size?: number; className?: string }>; @@ -207,6 +212,26 @@ const SOURCE_METADATA_MAP: SourceMap = { displayName: "Clickup", category: SourceCategory.AppConnection, }, + s3: { + icon: S3Icon, + displayName: "S3", + category: SourceCategory.AppConnection, + }, + r2: { + icon: R2Icon, + displayName: "R2", + category: SourceCategory.AppConnection, + }, + oci_storage: { + icon: OCIStorageIcon, + displayName: "Oracle Storage", + category: SourceCategory.AppConnection, + }, + google_cloud_storage: { + icon: GoogleStorageIcon, + displayName: "Google Storage", + category: SourceCategory.AppConnection, + }, }; function fillSourceMetadata( @@ -223,13 +248,21 @@ function fillSourceMetadata( } export function getSourceMetadata(sourceType: ValidSources): SourceMetadata { - return fillSourceMetadata(SOURCE_METADATA_MAP[sourceType], sourceType); + const response = fillSourceMetadata( + SOURCE_METADATA_MAP[sourceType], + sourceType + ); + + return response; } export function listSourceMetadata(): SourceMetadata[] { - return Object.entries(SOURCE_METADATA_MAP).map(([source, metadata]) => { - return fillSourceMetadata(metadata, source as ValidSources); - }); + const entries = Object.entries(SOURCE_METADATA_MAP).map( + ([source, metadata]) => { + return fillSourceMetadata(metadata, source as ValidSources); + } + ); + return entries; } export function getSourceDisplayName(sourceType: ValidSources): string | null { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 30c336f97..67adca87c 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -59,7 +59,11 @@ export type ValidSources = | "clickup" | "axero" | "wikipedia" - | "mediawiki"; + | "mediawiki" + | "s3" + | "r2" + | "google_cloud_storage" + | "oci_storage"; export type ValidInputTypes = "load_state" | "poll" | "event"; export type ValidStatuses = @@ -219,6 +223,30 @@ export interface ZendeskConfig {} export interface DropboxConfig {} +export interface S3Config { + bucket_type: "s3"; + bucket_name: string; + prefix: string; +} + +export interface R2Config { + bucket_type: "r2"; + bucket_name: string; + prefix: string; +} + +export interface GCSConfig { + bucket_type: "google_cloud_storage"; + bucket_name: string; + prefix: string; +} + +export interface OCIConfig { + bucket_type: "oci_storage"; + bucket_name: string; + prefix: string; +} + export interface MediaWikiBaseConfig { connector_name: string; language_code: string; @@ -400,6 +428,28 @@ export interface DropboxCredentialJson { dropbox_access_token: string; } +export interface R2CredentialJson { + account_id: string; + r2_access_key_id: string; + r2_secret_access_key: string; +} + +export interface S3CredentialJson { + aws_access_key_id: string; + aws_secret_access_key: string; +} + +export interface GCSCredentialJson { + access_key_id: string; + secret_access_key: string; +} + +export interface OCICredentialJson { + namespace: string; + region: string; + access_key_id: string; + secret_access_key: string; +} export interface SalesforceCredentialJson { sf_username: string; sf_password: string;