Compare commits

...

17 Commits

Author SHA1 Message Date
pablonyx
406e493ea7 k 2025-02-16 18:27:53 -08:00
pablonyx
2177f54ffe k 2025-02-16 18:27:53 -08:00
pablonyx
2f9e213c4c add validation 2025-02-16 18:27:53 -08:00
pablonyx
5c0a3605d6 add jira connector 2025-02-16 18:27:53 -08:00
pablonyx
d8ed35af9f k 2025-02-16 18:27:40 -08:00
pablonyx
d47d31007a add missing alembic migration 2025-02-16 18:24:04 -08:00
pablonyx
2e796332c5 quick nit 2025-02-16 18:12:09 -08:00
pablonyx
4303fcd8fc k 2025-02-16 18:04:09 -08:00
pablonyx
8586bce4b7 k 2025-02-16 18:00:37 -08:00
pablonyx
1a2102926b generally functional 2025-02-16 17:59:18 -08:00
pablonyx
c2066de10d k 2025-02-16 14:35:16 -08:00
pablonyx
8e8ce1b735 update 2025-02-16 14:34:31 -08:00
pablonyx
0f40ffcd60 fix typing 2025-02-16 14:30:58 -08:00
pablonyx
05ad51bfd7 nit 2025-02-16 13:58:12 -08:00
pablodanswer
de45cd98ce update config 2025-02-15 17:01:51 -08:00
pablodanswer
599fb4a713 additional warning 2025-02-15 16:04:03 -08:00
pablodanswer
f9f26dfd95 update 2025-02-15 15:53:20 -08:00
25 changed files with 504 additions and 25 deletions

View File

@@ -0,0 +1,29 @@
"""remove inactive ccpair status on downgrade
Revision ID: acaab4ef4507
Revises: f39c5794c10a
Create Date: 2025-02-16 18:21:41.330212
"""
from alembic import op
from onyx.db.models import ConnectorCredentialPair
from onyx.db.enums import ConnectorCredentialPairStatus
from sqlalchemy import update
# revision identifiers, used by Alembic.
revision = "acaab4ef4507"
down_revision = "f39c5794c10a"
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
op.execute(
update(ConnectorCredentialPair)
.where(ConnectorCredentialPair.status == ConnectorCredentialPairStatus.INVALID)
.values(status=ConnectorCredentialPairStatus.ACTIVE)
)

View File

@@ -42,6 +42,7 @@ from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import OnyxRedisConstants
from onyx.configs.constants import OnyxRedisLocks
from onyx.configs.constants import OnyxRedisSignals
from onyx.connectors.interfaces import ConnectorValidationError
from onyx.db.connector import mark_ccpair_with_indexing_trigger
from onyx.db.connector_credential_pair import fetch_connector_credential_pairs
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
@@ -111,6 +112,8 @@ class IndexingWatchdogTerminalStatus(str, Enum):
PROCESS_SIGNAL_SIGKILL = "process_signal_sigkill"
CONNECTOR_VALIDATION_ERROR = "connector_validation_error"
@property
def code(self) -> int:
_ENUM_TO_CODE: dict[IndexingWatchdogTerminalStatus, int] = {
@@ -124,6 +127,7 @@ class IndexingWatchdogTerminalStatus(str, Enum):
IndexingWatchdogTerminalStatus.TASK_ALREADY_RUNNING: 253,
IndexingWatchdogTerminalStatus.INDEX_ATTEMPT_MISMATCH: 254,
IndexingWatchdogTerminalStatus.CONNECTOR_EXCEPTIONED: 255,
IndexingWatchdogTerminalStatus.CONNECTOR_VALIDATION_ERROR: 256,
}
return _ENUM_TO_CODE[self]
@@ -140,6 +144,7 @@ class IndexingWatchdogTerminalStatus(str, Enum):
253: IndexingWatchdogTerminalStatus.TASK_ALREADY_RUNNING,
254: IndexingWatchdogTerminalStatus.INDEX_ATTEMPT_MISMATCH,
255: IndexingWatchdogTerminalStatus.CONNECTOR_EXCEPTIONED,
256: IndexingWatchdogTerminalStatus.CONNECTOR_VALIDATION_ERROR,
}
if code in _CODE_TO_ENUM:
@@ -991,9 +996,13 @@ def connector_indexing_proxy_task(
)
)
continue
except Exception:
except Exception as e:
result.status = IndexingWatchdogTerminalStatus.WATCHDOG_EXCEPTIONED
result.exception_str = traceback.format_exc()
if isinstance(e, ConnectorValidationError):
# No need to expose full stack trace for validation errors
result.exception_str = str(e)
else:
result.exception_str = traceback.format_exc()
# handle exit and reporting
elapsed = time.monotonic() - start

View File

@@ -425,6 +425,7 @@ def connector_pruning_generator_task(
f"cc_pair={cc_pair_id} "
f"connector_source={cc_pair.connector.source}"
)
runnable_connector = instantiate_connector(
db_session,
cc_pair.connector.source,

View File

@@ -15,6 +15,7 @@ from typing import Literal
from typing import Optional
from onyx.configs.constants import POSTGRES_CELERY_WORKER_INDEXING_CHILD_APP_NAME
from onyx.connectors.interfaces import ConnectorValidationError
from onyx.db.engine import SqlEngine
from onyx.utils.logger import setup_logger
@@ -75,6 +76,11 @@ def _initializer(
queue.put(error_msg) # Send the exception to the parent process
sys.exit(e.code) # use the given exit code
except ConnectorValidationError as e:
error_msg = str(e)
queue.put(error_msg) # Send the exception to the parent process
sys.exit(256) # use 256 to indicate a connector validation error
except Exception:
logger.exception("SimpleJob raised an exception")
error_msg = traceback.format_exc()

View File

@@ -17,6 +17,7 @@ from onyx.configs.constants import DocumentSource
from onyx.configs.constants import MilestoneRecordType
from onyx.connectors.connector_runner import ConnectorRunner
from onyx.connectors.factory import instantiate_connector
from onyx.connectors.interfaces import ConnectorValidationError
from onyx.connectors.models import Document
from onyx.connectors.models import IndexAttemptMetadata
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
@@ -76,6 +77,10 @@ def _get_connector_runner(
credential=attempt.connector_credential_pair.credential,
tenant_id=tenant_id,
)
# validate the connector settings
runnable_connector.validate_connector_settings()
except Exception as e:
logger.exception(f"Unable to instantiate connector due to {e}")
@@ -422,8 +427,30 @@ def _run_indexing(
logger.exception(
f"Connector run exceptioned after elapsed time: {time.time() - start_time} seconds"
)
logger.error(type(e))
if isinstance(e, ConnectorStopSignal):
if isinstance(e, ConnectorValidationError):
# On validation errors during indexing, we want to cancel the indexing attempt
# and mark the CCPair as invalid. This prevents the connector from being
# used in the future until the credentials are updated.
with get_session_with_tenant(tenant_id) as db_session_temp:
mark_attempt_canceled(
index_attempt_id,
db_session_temp,
reason=str(e),
)
if ctx.is_primary:
update_connector_credential_pair(
db_session=db_session_temp,
connector_id=ctx.connector_id,
credential_id=ctx.credential_id,
status=ConnectorCredentialPairStatus.INVALID,
)
raise e
elif isinstance(e, ConnectorStopSignal):
with get_session_with_tenant(tenant_id) as db_session_temp:
mark_attempt_canceled(
index_attempt_id,

View File

@@ -4,12 +4,17 @@ from typing import Any
from dropbox import Dropbox # type: ignore
from dropbox.exceptions import ApiError # type:ignore
from dropbox.exceptions import AuthError # type:ignore
from dropbox.exceptions import HttpError # type:ignore
from dropbox.files import FileMetadata # type:ignore
from dropbox.files import FolderMetadata # type:ignore
from onyx.configs.app_configs import INDEX_BATCH_SIZE
from onyx.configs.constants import DocumentSource
from onyx.connectors.interfaces import ConnectorValidationError
from onyx.connectors.interfaces import CredentialInvalidError
from onyx.connectors.interfaces import GenerateDocumentsOutput
from onyx.connectors.interfaces import InsufficientPermissionsError
from onyx.connectors.interfaces import LoadConnector
from onyx.connectors.interfaces import PollConnector
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
@@ -141,6 +146,33 @@ class DropboxConnector(LoadConnector, PollConnector):
return None
def validate_connector_settings(self) -> None:
if self.dropbox_client is None:
raise ConnectorMissingCredentialError("Dropbox credentials not loaded.")
try:
self.dropbox_client.files_list_folder(path="", limit=1)
except AuthError as e:
logger.exception(f"Failed to validate Dropbox credentials: {e}")
raise CredentialInvalidError(f"Dropbox credential is invalid: {e.error}")
except ApiError as e:
if (
e.error is not None
and "insufficient_permissions" in str(e.error).lower()
):
raise InsufficientPermissionsError(
"Your Dropbox token does not have sufficient permissions."
)
raise ConnectorValidationError(
f"Unexpected Dropbox error during validation: {e.user_message_text or e}"
)
except HttpError as e:
raise ConnectorValidationError(f"Unexpected Dropbox HTTP error: {e}")
except Exception as exc:
raise ConnectorValidationError(
f"Unexpected error during Dropbox settings validation: {exc}"
)
if __name__ == "__main__":
import os

View File

@@ -30,6 +30,7 @@ from onyx.connectors.google_site.connector import GoogleSitesConnector
from onyx.connectors.guru.connector import GuruConnector
from onyx.connectors.hubspot.connector import HubSpotConnector
from onyx.connectors.interfaces import BaseConnector
from onyx.connectors.interfaces import ConnectorValidationError
from onyx.connectors.interfaces import EventConnector
from onyx.connectors.interfaces import LoadConnector
from onyx.connectors.interfaces import PollConnector
@@ -50,8 +51,11 @@ from onyx.connectors.wikipedia.connector import WikipediaConnector
from onyx.connectors.xenforo.connector import XenforoConnector
from onyx.connectors.zendesk.connector import ZendeskConnector
from onyx.connectors.zulip.connector import ZulipConnector
from onyx.db.connector import fetch_connector_by_id
from onyx.db.credentials import backend_update_credential_json
from onyx.db.credentials import fetch_credential_by_id_for_user
from onyx.db.models import Credential
from onyx.db.models import User
class ConnectorMissingException(Exception):
@@ -157,3 +161,39 @@ def instantiate_connector(
backend_update_credential_json(credential, new_credentials, db_session)
return connector
def validate_ccpair_for_user(
connector_id: int,
credential_id: int,
db_session: Session,
user: User | None,
tenant_id: str | None,
) -> None:
# Validate the connector settings
connector = fetch_connector_by_id(connector_id, db_session)
credential = fetch_credential_by_id_for_user(
credential_id,
user,
db_session,
get_editable=False,
)
if not credential:
raise ValueError("Credential not found")
if not connector:
raise ValueError("Connector not found")
try:
runnable_connector = instantiate_connector(
db_session=db_session,
source=connector.source,
input_type=connector.input_type,
connector_specific_config=connector.connector_specific_config,
credential=credential,
tenant_id=tenant_id,
)
except Exception as e:
error_msg = f"Unexpected error creating connector: {e}"
raise ConnectorValidationError(error_msg)
runnable_connector.validate_connector_settings()

View File

@@ -9,6 +9,7 @@ from typing import cast
from github import Github
from github import RateLimitExceededException
from github import Repository
from github.GithubException import GithubException
from github.Issue import Issue
from github.PaginatedList import PaginatedList
from github.PullRequest import PullRequest
@@ -16,17 +17,20 @@ from github.PullRequest import PullRequest
from onyx.configs.app_configs import GITHUB_CONNECTOR_BASE_URL
from onyx.configs.app_configs import INDEX_BATCH_SIZE
from onyx.configs.constants import DocumentSource
from onyx.connectors.interfaces import ConnectorValidationError
from onyx.connectors.interfaces import CredentialExpiredError
from onyx.connectors.interfaces import GenerateDocumentsOutput
from onyx.connectors.interfaces import InsufficientPermissionsError
from onyx.connectors.interfaces import LoadConnector
from onyx.connectors.interfaces import PollConnector
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
from onyx.connectors.interfaces import UnexpectedError
from onyx.connectors.models import ConnectorMissingCredentialError
from onyx.connectors.models import Document
from onyx.connectors.models import Section
from onyx.utils.batching import batch_generator
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -226,6 +230,50 @@ class GithubConnector(LoadConnector, PollConnector):
return self._fetch_from_github(adjusted_start_datetime, end_datetime)
def validate_connector_settings(self) -> None:
if self.github_client is None:
raise ConnectorMissingCredentialError("GitHub credentials not loaded.")
if not self.repo_owner or not self.repo_name:
raise ConnectorValidationError(
"Invalid connector settings: 'repo_owner' and 'repo_name' must be provided."
)
try:
test_repo = self.github_client.get_repo(
f"{self.repo_owner}/{self.repo_name}"
)
test_repo.get_contents("")
except RateLimitExceededException:
raise ConnectorValidationError(
"Validation failed due to GitHub rate-limits being exceeded. Please try again later."
)
except GithubException as e:
if e.status == 401:
raise CredentialExpiredError(
"GitHub credential appears to be expired or invalid (HTTP 401)."
)
elif e.status == 403:
raise InsufficientPermissionsError(
"Your GitHub token does not have sufficient permissions for this repository (HTTP 403)."
)
elif e.status == 404:
raise ConnectorValidationError(
f"GitHub repository not found with name: {self.repo_owner}/{self.repo_name}"
)
else:
raise ConnectorValidationError(
f"Unexpected GitHub error (status={e.status}): {e.data}"
)
except Exception as exc:
raise UnexpectedError(
f"Unexpected error during GitHub settings validation: {exc}"
)
logger.info("GitHub connector settings have been successfully validated.")
if __name__ == "__main__":
import os

View File

@@ -9,7 +9,6 @@ from onyx.connectors.models import Document
from onyx.connectors.models import SlimDocument
from onyx.indexing.indexing_heartbeat import IndexingHeartbeatInterface
SecondsSinceUnixEpoch = float
GenerateDocumentsOutput = Iterator[list[Document]]
@@ -41,6 +40,14 @@ class BaseConnector(abc.ABC):
raise RuntimeError(custom_parser_req_msg)
return metadata_lines
def validate_connector_settings(self) -> None:
"""
Override this if your connector needs to validate credentials or settings.
Raise an exception if invalid, otherwise do nothing.
Default is a no-op (always successful).
"""
# Large set update or reindex, generally pulling a complete state or from a savestate file
class LoadConnector(BaseConnector):
@@ -105,3 +112,41 @@ class EventConnector(BaseConnector):
@abc.abstractmethod
def handle_event(self, event: Any) -> GenerateDocumentsOutput:
raise NotImplementedError
class ConnectorValidationError(Exception):
"""General exception for connector validation errors."""
def __init__(self, message: str):
self.message = message
super().__init__(self.message)
class UnexpectedError(ConnectorValidationError):
"""Raised when an unexpected error occurs during connector validation."""
def __init__(self, message: str = "Unexpected error during connector validation"):
super().__init__(message)
class CredentialInvalidError(ConnectorValidationError):
"""Raised when a connector's credential is invalid."""
def __init__(self, message: str = "Credential is invalid"):
super().__init__(message)
class CredentialExpiredError(ConnectorValidationError):
"""Raised when a connector's credential is expired."""
def __init__(self, message: str = "Credential has expired"):
super().__init__(message)
class InsufficientPermissionsError(ConnectorValidationError):
"""Raised when the credential does not have sufficient API permissions."""
def __init__(
self, message: str = "Insufficient permissions for the requested operation"
):
super().__init__(message)

View File

@@ -7,6 +7,7 @@ from datetime import timezone
from typing import Any
from typing import Optional
import requests
from retry import retry
from onyx.configs.app_configs import INDEX_BATCH_SIZE
@@ -15,10 +16,14 @@ from onyx.configs.constants import DocumentSource
from onyx.connectors.cross_connector_utils.rate_limit_wrapper import (
rl_requests,
)
from onyx.connectors.interfaces import ConnectorValidationError
from onyx.connectors.interfaces import CredentialExpiredError
from onyx.connectors.interfaces import GenerateDocumentsOutput
from onyx.connectors.interfaces import InsufficientPermissionsError
from onyx.connectors.interfaces import LoadConnector
from onyx.connectors.interfaces import PollConnector
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
from onyx.connectors.interfaces import UnexpectedError
from onyx.connectors.models import Document
from onyx.connectors.models import Section
from onyx.utils.batching import batch_generator
@@ -616,6 +621,74 @@ class NotionConnector(LoadConnector, PollConnector):
else:
break
def validate_connector_settings(self) -> None:
# 1. Ensure the Notion credentials are loaded
if "Authorization" not in self.headers or not self.headers["Authorization"]:
raise ConnectorValidationError("Notion credentials not loaded.")
# 2. Attempt a small test call to Notion to verify credentials and permissions
try:
# We'll do a minimal search call (page_size=1) to confirm accessibility
if self.root_page_id:
# If root_page_id is set, fetch the specific page
res = rl_requests.get(
f"https://api.notion.com/v1/pages/{self.root_page_id}",
headers=self.headers,
timeout=_NOTION_CALL_TIMEOUT,
)
else:
# If root_page_id is not set, perform a minimal search
test_query = {
"filter": {"property": "object", "value": "page"},
"page_size": 1,
}
res = rl_requests.post(
"https://api.notion.com/v1/search",
headers=self.headers,
json=test_query,
timeout=_NOTION_CALL_TIMEOUT,
)
res.raise_for_status()
except requests.exceptions.HTTPError as http_err:
status_code = http_err.response.status_code if http_err.response else None
if status_code == 401:
raise CredentialExpiredError(
"Notion credential appears to be invalid or expired (HTTP 401)."
)
elif status_code == 403:
raise InsufficientPermissionsError(
"Your Notion token does not have sufficient permissions (HTTP 403)."
)
elif status_code == 404:
# Typically means resource not found or not shared. Could be root_page_id is invalid.
raise ConnectorValidationError(
"Notion resource not found or not shared with the integration (HTTP 404)."
)
elif status_code == 429:
raise ConnectorValidationError(
"Validation failed due to Notion rate-limits being exceeded (HTTP 429). "
"Please try again later."
)
else:
raise ConnectorValidationError(
f"Unexpected Notion HTTP error (status={status_code}): {http_err}"
) from http_err
except requests.exceptions.RequestException as req_exc:
raise ConnectorValidationError(
f"Unexpected network error during Notion validation: {req_exc}"
) from req_exc
except Exception as exc:
# Catch-all for anything else
raise UnexpectedError(
f"Unexpected error during Notion settings validation: {exc}"
)
# 3. If we made it here, validation checks have passed
logger.info("Notion connector settings have been successfully validated.")
if __name__ == "__main__":
import os

View File

@@ -12,8 +12,11 @@ from onyx.configs.app_configs import JIRA_CONNECTOR_LABELS_TO_SKIP
from onyx.configs.app_configs import JIRA_CONNECTOR_MAX_TICKET_SIZE
from onyx.configs.constants import DocumentSource
from onyx.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
from onyx.connectors.interfaces import ConnectorValidationError
from onyx.connectors.interfaces import CredentialExpiredError
from onyx.connectors.interfaces import GenerateDocumentsOutput
from onyx.connectors.interfaces import GenerateSlimDocumentOutput
from onyx.connectors.interfaces import InsufficientPermissionsError
from onyx.connectors.interfaces import LoadConnector
from onyx.connectors.interfaces import PollConnector
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
@@ -272,6 +275,49 @@ class JiraConnector(LoadConnector, PollConnector, SlimConnector):
yield slim_doc_batch
def validate_connector_settings(self) -> None:
# 1. Ensure the Jira client is loaded
if self._jira_client is None:
raise ConnectorValidationError("Jira credentials not loaded.")
# 2. Validate required connector settings, e.g., the Jira project
if not self._jira_project:
raise ConnectorValidationError(
"Invalid connector settings: 'jira_project' must be provided."
)
# 3. Attempt a small test call to Jira to verify credentials and permissions
try:
# Try fetching the configured Jira project details
self.jira_client.project(self._jira_project)
except Exception as e:
# Jira might raise JIRAError or other exceptions; handle status codes or fallback to a generic error
status_code = getattr(e, "status_code", None)
if status_code == 401:
raise CredentialExpiredError(
"Jira credential appears to be expired or invalid (HTTP 401)."
)
elif status_code == 403:
raise InsufficientPermissionsError(
"Your Jira token does not have sufficient permissions for this project (HTTP 403)."
)
elif status_code == 404:
raise ConnectorValidationError(
f"Jira project not found with key: {self._jira_project}"
)
elif status_code == 429:
raise ConnectorValidationError(
"Validation failed due to Jira rate-limits being exceeded. Please try again later."
)
else:
raise ConnectorValidationError(
f"Unexpected Jira error during validation: {e}"
)
# If we made it this far, validation checks have passed
logger.info("Jira connector settings have been successfully validated.")
if __name__ == "__main__":
import os

View File

@@ -14,6 +14,7 @@ from onyx.configs.constants import DocumentSource
from onyx.connectors.google_utils.shared_constants import (
DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY,
)
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Credential
from onyx.db.models import Credential__UserGroup
@@ -245,6 +246,10 @@ def swap_credentials_connector(
existing_pair.credential_id = new_credential_id
existing_pair.credential = new_credential
# Update ccpair status if it's in INVALID state
if existing_pair.status == ConnectorCredentialPairStatus.INVALID:
existing_pair.status = ConnectorCredentialPairStatus.ACTIVE
# Commit the changes
db_session.commit()

View File

@@ -73,6 +73,7 @@ class ConnectorCredentialPairStatus(str, PyEnum):
ACTIVE = "ACTIVE"
PAUSED = "PAUSED"
DELETING = "DELETING"
INVALID = "INVALID"
def is_active(self) -> bool:
return self == ConnectorCredentialPairStatus.ACTIVE

View File

@@ -24,6 +24,9 @@ from onyx.background.celery.tasks.pruning.tasks import (
from onyx.background.celery.versioned_apps.primary import app as primary_app
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryTask
from onyx.connectors.factory import validate_ccpair_for_user
from onyx.connectors.interfaces import ConnectorValidationError
from onyx.db.connector import delete_connector
from onyx.db.connector_credential_pair import add_credential_to_connector
from onyx.db.connector_credential_pair import (
get_connector_credential_pair_from_id_for_user,
@@ -572,6 +575,10 @@ def associate_credential_to_connector(
)
try:
validate_ccpair_for_user(
connector_id, credential_id, db_session, user, tenant_id
)
response = add_credential_to_connector(
db_session=db_session,
user=user,
@@ -596,10 +603,26 @@ def associate_credential_to_connector(
)
return response
except ConnectorValidationError as e:
# If validation fails, delete the connector and commit the changes
# Ensures we don't leave invalid connectors in the database
# NOTE: consensus is that it makes sense to unify connector and ccpair creation flows
# which would rid us of needing to handle cases like these
delete_connector(db_session, connector_id)
db_session.commit()
raise HTTPException(
status_code=400, detail="Connector validation error: " + str(e)
)
except IntegrityError as e:
logger.error(f"IntegrityError: {e}")
raise HTTPException(status_code=400, detail="Name must be unique")
except Exception:
raise HTTPException(status_code=500, detail="Unexpected error")
@router.delete("/connector/{connector_id}/credential/{credential_id}")
def dissociate_credential_from_connector(

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from onyx.auth.users import current_admin_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_user
from onyx.connectors.factory import validate_ccpair_for_user
from onyx.db.credentials import alter_credential
from onyx.db.credentials import cleanup_gmail_credentials
from onyx.db.credentials import create_credential
@@ -17,6 +18,7 @@ from onyx.db.credentials import fetch_credentials_by_source_for_user
from onyx.db.credentials import fetch_credentials_for_user
from onyx.db.credentials import swap_credentials_connector
from onyx.db.credentials import update_credential
from onyx.db.engine import get_current_tenant_id
from onyx.db.engine import get_session
from onyx.db.models import DocumentSource
from onyx.db.models import User
@@ -98,7 +100,16 @@ def swap_credentials_for_connector(
credential_swap_req: CredentialSwapRequest,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id),
) -> StatusResponse:
validate_ccpair_for_user(
credential_swap_req.connector_id,
credential_swap_req.new_credential_id,
db_session,
user,
tenant_id,
)
connector_credential_pair = swap_credentials_connector(
new_credential_id=credential_swap_req.new_credential_id,
connector_id=credential_swap_req.connector_id,

View File

@@ -96,6 +96,7 @@ export function ReIndexButton({
isDisabled,
isIndexing,
isDeleting,
isInvalid,
}: {
ccPairId: number;
connectorId: number;
@@ -103,6 +104,7 @@ export function ReIndexButton({
isDisabled: boolean;
isIndexing: boolean;
isDeleting: boolean;
isInvalid: boolean;
}) {
const { popup, setPopup } = usePopup();
const [reIndexPopupVisible, setReIndexPopupVisible] = useState(false);
@@ -125,15 +127,17 @@ export function ReIndexButton({
onClick={() => {
setReIndexPopupVisible(true);
}}
disabled={isDisabled || isDeleting}
disabled={isDisabled || isDeleting || isInvalid}
tooltip={
isDeleting
? "Cannot index while connector is deleting"
: isIndexing
? "Indexing is already in progress"
: isDisabled
? "Connector must be re-enabled before indexing"
: undefined
isInvalid
? "Connector is in an invalid state. Please update the credentials or configuration before re-indexing."
: isDeleting
? "Cannot index while connector is deleting"
: isIndexing
? "Indexing is already in progress"
: isDisabled
? "Connector must be re-enabled before indexing"
: undefined
}
>
Index

View File

@@ -32,6 +32,7 @@ import { Button } from "@/components/ui/button";
import EditPropertyModal from "@/components/modals/EditPropertyModal";
import * as Yup from "yup";
import { Callout } from "@/components/ui/callout";
// synchronize these validations with the SQLAlchemy connector class until we have a
// centralized schema for both frontend and backend
@@ -265,6 +266,9 @@ function Main({ ccPairId }: { ccPairId: number }) {
<div className="ml-auto flex gap-x-2">
<ReIndexButton
ccPairId={ccPair.id}
isInvalid={
ccPair.status === ConnectorCredentialPairStatus.INVALID
}
connectorId={ccPair.connector.id}
credentialId={ccPair.credential.id}
isDisabled={
@@ -283,6 +287,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
status={ccPair.last_index_attempt_status || "not_started"}
disabled={ccPair.status === ConnectorCredentialPairStatus.PAUSED}
isDeleting={isDeleting}
isInvalid={ccPair.status === ConnectorCredentialPairStatus.INVALID}
/>
<div className="text-sm mt-1">
Creator:{" "}
@@ -326,6 +331,16 @@ function Main({ ccPairId }: { ccPairId: number }) {
/>
</>
)}
{ccPair.status === ConnectorCredentialPairStatus.INVALID && (
<div className="mt-2">
<Callout type="warning" title="Invalid Connector State">
This connector is in an invalid state. Please update your
credentials or create a new connector before re-indexing.
</Callout>
</div>
)}
<Separator />
<ConfigDisplay
connectorSpecificConfig={ccPair.connector.connector_specific_config}

View File

@@ -12,6 +12,7 @@ export enum ConnectorCredentialPairStatus {
ACTIVE = "ACTIVE",
PAUSED = "PAUSED",
DELETING = "DELETING",
INVALID = "INVALID",
}
export interface CCPairFullInfo {

View File

@@ -418,7 +418,7 @@ export default function AddConnector({
} else {
const errorData = await linkCredentialResponse.json();
setPopup({
message: errorData.message,
message: errorData.message || errorData.detail,
type: "error",
});
}

View File

@@ -159,6 +159,19 @@ function ConnectorRow({
Paused
</Badge>
);
} else if (
ccPairsIndexingStatus.cc_pair_status ===
ConnectorCredentialPairStatus.INVALID
) {
return (
<Badge
tooltip="Connector is in an invalid state. Please update the credentials or create a new connector."
circle
variant="invalid"
>
Invalid
</Badge>
);
}
// ACTIVE case

View File

@@ -84,6 +84,12 @@ export function IndexAttemptStatus({
Canceled
</Badge>
);
} else if (status === "invalid") {
badge = (
<Badge variant="invalid" icon={FiAlertTriangle}>
Invalid
</Badge>
);
} else {
badge = (
<Badge variant="outline" icon={FiMinus}>
@@ -99,11 +105,13 @@ export function CCPairStatus({
status,
disabled,
isDeleting,
isInvalid,
size = "md",
}: {
status: ValidStatuses;
disabled: boolean;
isDeleting: boolean;
isInvalid: boolean;
size?: "xs" | "sm" | "md" | "lg";
}) {
let badge;
@@ -120,6 +128,12 @@ export function CCPairStatus({
Paused
</Badge>
);
} else if (isInvalid) {
badge = (
<Badge variant="invalid" icon={FiAlertTriangle}>
Invalid
</Badge>
);
} else if (status === "failed") {
badge = (
<Badge variant="destructive" icon={FiAlertTriangle}>

View File

@@ -79,14 +79,24 @@ export default function CredentialSection({
selectedCredential: Credential<any>,
connectorId: number
) => {
await swapCredential(selectedCredential.id, connectorId);
mutate(buildSimilarCredentialInfoURL(sourceType));
refresh();
const response = await swapCredential(selectedCredential.id, connectorId);
if (response.ok) {
mutate(buildSimilarCredentialInfoURL(sourceType));
refresh();
setPopup({
message: "Swapped credential succesfully!",
type: "success",
});
setPopup({
message: "Swapped credential successfully!",
type: "success",
});
} else {
const errorData = await response.json();
setPopup({
message: `Issue swapping credential: ${
errorData.detail || errorData.message || "Unknown error"
}`,
type: "error",
});
}
};
const onUpdateCredential = async (

View File

@@ -1,6 +1,11 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
@@ -8,6 +13,8 @@ const badgeVariants = cva(
{
variants: {
variant: {
invalid:
"border-orange-200 bg-orange-50 text-orange-600 dark:border-orange-700 dark:bg-orange-900 dark:text-orange-50",
outline:
"border-neutral-200 bg-neutral-50 text-neutral-600 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50",
purple:
@@ -57,11 +64,13 @@ function Badge({
icon: Icon,
size = "sm",
circle,
tooltip,
...props
}: BadgeProps & {
icon?: React.ElementType;
size?: "sm" | "md" | "xs";
circle?: boolean;
tooltip?: string;
}) {
const sizeClasses = {
sm: "px-2.5 py-0.5 text-xs",
@@ -69,7 +78,7 @@ function Badge({
xs: "px-1.5 py-0.25 text-[.5rem]",
};
return (
const BadgeContent = (
<div
className={cn(
"flex-none inline-flex items-center whitespace-nowrap overflow-hidden",
@@ -98,6 +107,21 @@ function Badge({
<span className="truncate">{props.children}</span>
</div>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{BadgeContent}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return BadgeContent;
}
export { Badge, badgeVariants };

View File

@@ -88,7 +88,6 @@ export interface ButtonProps
tooltip?: string;
reverse?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
@@ -124,7 +123,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipTrigger>
<div>{button}</div>
</TooltipTrigger>
<TooltipContent showTick={true}>
<p>{tooltip}</p>
</TooltipContent>

View File

@@ -92,6 +92,7 @@ export type ValidInputTypes =
| "event"
| "slim_retrieval";
export type ValidStatuses =
| "invalid"
| "success"
| "completed_with_errors"
| "canceled"