1
0
forked from github/onyx

Compare commits

...

17 Commits

Author SHA1 Message Date
pablonyx
58d27a403f nit 2025-02-17 15:17:06 -08:00
pablonyx
b475f20d34 add back "reindex" 2025-02-17 14:55:45 -08:00
pablonyx
bf40cf826e fix build 2025-02-17 13:42:49 -08:00
pablonyx
9320ae358f update 2025-02-17 12:44:44 -08:00
pablonyx
880444101a k 2025-02-17 12:43:44 -08:00
pablonyx
35f635596c k 2025-02-17 12:40:41 -08:00
pablonyx
05d15b1210 quick addition 2025-02-17 12:39:08 -08:00
pablonyx
191eb55495 k 2025-02-17 12:39:08 -08:00
pablonyx
8c15349371 update 2025-02-17 12:39:08 -08:00
pablonyx
2c5da23a41 k 2025-02-17 12:39:08 -08:00
pablonyx
aa6e7828df k 2025-02-17 12:39:08 -08:00
pablonyx
ea1ebd1abc add validation 2025-02-17 12:39:08 -08:00
pablonyx
a72e021c81 add jira connector 2025-02-17 12:39:08 -08:00
pablonyx
0526f9b769 k 2025-02-17 12:29:02 -08:00
pablonyx
d62ad00727 pretty 2025-02-17 12:27:51 -08:00
pablonyx
3cfeb16b34 minor cleanup 2025-02-17 10:20:44 -08:00
pablonyx
a145ef95d8 Connector validation 2025-02-17 10:15:30 -08:00
29 changed files with 561 additions and 97 deletions

View File

@@ -28,11 +28,11 @@ RUN apt-get update && \
curl \
zip \
ca-certificates \
libgnutls30=3.7.9-2+deb12u3 \
libblkid1=2.38.1-5+deb12u1 \
libmount1=2.38.1-5+deb12u1 \
libsmartcols1=2.38.1-5+deb12u1 \
libuuid1=2.38.1-5+deb12u1 \
libgnutls30 \
libblkid1 \
libmount1 \
libsmartcols1 \
libuuid1 \
libxmlsec1-dev \
pkg-config \
gcc \

View File

@@ -0,0 +1,29 @@
"""remove inactive ccpair status on downgrade
Revision ID: acaab4ef4507
Revises: b7a7eee5aa15
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 = "b7a7eee5aa15"
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

@@ -47,6 +47,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
@@ -103,6 +104,9 @@ class IndexingWatchdogTerminalStatus(str, Enum):
"index_attempt_mismatch" # expected index attempt metadata not found in db
)
CONNECTOR_VALIDATION_ERROR = (
"connector_validation_error" # the connector validation failed
)
CONNECTOR_EXCEPTIONED = "connector_exceptioned" # the connector itself exceptioned
WATCHDOG_EXCEPTIONED = "watchdog_exceptioned" # the watchdog exceptioned
@@ -121,6 +125,7 @@ class IndexingWatchdogTerminalStatus(str, Enum):
_ENUM_TO_CODE: dict[IndexingWatchdogTerminalStatus, int] = {
IndexingWatchdogTerminalStatus.PROCESS_SIGNAL_SIGKILL: -9,
IndexingWatchdogTerminalStatus.OUT_OF_MEMORY: 137,
IndexingWatchdogTerminalStatus.CONNECTOR_VALIDATION_ERROR: 247,
IndexingWatchdogTerminalStatus.BLOCKED_BY_DELETION: 248,
IndexingWatchdogTerminalStatus.BLOCKED_BY_STOP_SIGNAL: 249,
IndexingWatchdogTerminalStatus.FENCE_NOT_FOUND: 250,
@@ -137,6 +142,7 @@ class IndexingWatchdogTerminalStatus(str, Enum):
def from_code(cls, code: int) -> "IndexingWatchdogTerminalStatus":
_CODE_TO_ENUM: dict[int, IndexingWatchdogTerminalStatus] = {
-9: IndexingWatchdogTerminalStatus.PROCESS_SIGNAL_SIGKILL,
247: IndexingWatchdogTerminalStatus.CONNECTOR_VALIDATION_ERROR,
248: IndexingWatchdogTerminalStatus.BLOCKED_BY_DELETION,
249: IndexingWatchdogTerminalStatus.BLOCKED_BY_STOP_SIGNAL,
250: IndexingWatchdogTerminalStatus.FENCE_NOT_FOUND,
@@ -788,6 +794,15 @@ def connector_indexing_task(
# get back the total number of indexed docs and return it
n_final_progress = redis_connector_index.get_progress()
redis_connector_index.set_generator_complete(HTTPStatus.OK.value)
except ConnectorValidationError:
raise SimpleJobException(
f"Indexing task failed: attempt={index_attempt_id} "
f"tenant={tenant_id} "
f"cc_pair={cc_pair_id} "
f"search_settings={search_settings_id}",
code=IndexingWatchdogTerminalStatus.CONNECTOR_VALIDATION_ERROR.code,
)
except Exception as e:
logger.exception(
f"Indexing spawned task failed: attempt={index_attempt_id} "
@@ -795,8 +810,8 @@ def connector_indexing_task(
f"cc_pair={cc_pair_id} "
f"search_settings={search_settings_id}"
)
raise e
finally:
if lock.owned():
lock.release()
@@ -996,9 +1011,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

@@ -23,6 +23,7 @@ from onyx.connectors.connector_runner import ConnectorRunner
from onyx.connectors.factory import instantiate_connector
from onyx.connectors.models import ConnectorCheckpoint
from onyx.connectors.models import ConnectorFailure
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
@@ -86,6 +87,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}")
@@ -567,8 +572,28 @@ def _run_indexing(
"Connector run exceptioned after elapsed time: "
f"{time.monotonic() - start_time} seconds"
)
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 isinstance(e, ConnectorStopSignal):
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,
)
memory_tracer.stop()
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,16 @@ 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.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 +145,29 @@ 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("Failed to validate Dropbox credentials")
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 Exception as e:
raise Exception(f"Unexpected error during Dropbox settings validation: {e}")
if __name__ == "__main__":
import os

View File

@@ -31,6 +31,7 @@ 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 CheckpointConnector
from onyx.connectors.interfaces import ConnectorValidationError
from onyx.connectors.interfaces import EventConnector
from onyx.connectors.interfaces import LoadConnector
from onyx.connectors.interfaces import PollConnector
@@ -52,8 +53,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):
@@ -174,3 +178,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,7 +17,10 @@ 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
@@ -26,7 +30,6 @@ 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 +229,48 @@ 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 invalid or expired (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 Exception(
f"Unexpected error during GitHub settings validation: {exc}"
)
if __name__ == "__main__":
import os

View File

@@ -12,7 +12,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]]
@@ -45,6 +44,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):
@@ -139,3 +146,46 @@ class CheckpointConnector(BaseConnector):
```
"""
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(Exception):
"""Raised when an unexpected error occurs during connector validation.
Unexpected errors don't necessarily mean the credential is invalid,
but rather that there was an error during the validation process
or we encountered a currently unhandled error case.
"""
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.models import ConnectorMissingCredentialError
from onyx.connectors.models import Document
from onyx.connectors.models import Section
from onyx.utils.batching import batch_generator
@@ -616,6 +621,64 @@ class NotionConnector(LoadConnector, PollConnector):
else:
break
def validate_connector_settings(self) -> None:
if not self.headers.get("Authorization"):
raise ConnectorMissingCredentialError("Notion credentials not loaded.")
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 Exception(
f"Unexpected Notion HTTP error (status={status_code}): {http_err}"
) from http_err
except Exception as exc:
raise Exception(
f"Unexpected error during Notion settings validation: {exc}"
)
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,40 @@ class JiraConnector(LoadConnector, PollConnector, SlimConnector):
yield slim_doc_batch
def validate_connector_settings(self) -> None:
if self._jira_client is None:
raise ConnectorMissingCredentialError("Jira")
if not self._jira_project:
raise ConnectorValidationError(
"Invalid connector settings: 'jira_project' must be provided."
)
try:
self.jira_client.project(self._jira_project)
except Exception as e:
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 Exception(f"Unexpected Jira error during validation: {e}")
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

@@ -25,6 +25,9 @@ from onyx.background.celery.versioned_apps.primary import app as primary_app
from onyx.background.indexing.models import IndexAttemptErrorPydantic
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,
@@ -616,6 +619,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,
@@ -640,10 +647,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

@@ -67,12 +67,13 @@ export default function UpgradingPage({
};
const statusOrder: Record<ValidStatuses, number> = useMemo(
() => ({
failed: 0,
canceled: 1,
completed_with_errors: 2,
not_started: 3,
in_progress: 4,
success: 5,
invalid: 0,
failed: 1,
canceled: 2,
completed_with_errors: 3,
not_started: 4,
in_progress: 5,
success: 6,
}),
[]
);

View File

@@ -4,9 +4,14 @@ import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import { Button } from "@/components/ui/button";
import Text from "@/components/ui/text";
import { triggerIndexing } from "./lib";
import { mutate } from "swr";
import { buildCCPairInfoUrl, getTooltipMessage } from "./lib";
import { useState } from "react";
import { Modal } from "@/components/Modal";
import { Separator } from "@/components/ui/separator";
import { ConnectorCredentialPairStatus } from "./types";
import { CCPairStatus } from "@/components/Status";
import { getCCPairStatusMessage } from "@/lib/ccPair";
function ReIndexPopup({
connectorId,
@@ -83,16 +88,16 @@ export function ReIndexButton({
ccPairId,
connectorId,
credentialId,
isDisabled,
isIndexing,
isDeleting,
isDisabled,
ccPairStatus,
}: {
ccPairId: number;
connectorId: number;
credentialId: number;
isDisabled: boolean;
isIndexing: boolean;
isDeleting: boolean;
isDisabled: boolean;
ccPairStatus: ConnectorCredentialPairStatus;
}) {
const { popup, setPopup } = usePopup();
const [reIndexPopupVisible, setReIndexPopupVisible] = useState(false);
@@ -115,18 +120,14 @@ export function ReIndexButton({
onClick={() => {
setReIndexPopupVisible(true);
}}
disabled={isDisabled || isDeleting}
tooltip={
isDeleting
? "Cannot index while connector is deleting"
: isIndexing
? "Indexing is already in progress"
: isDisabled
? "Connector must be re-enabled before indexing"
: undefined
disabled={
isDisabled ||
ccPairStatus == ConnectorCredentialPairStatus.DELETING ||
ccPairStatus == ConnectorCredentialPairStatus.PAUSED
}
tooltip={getCCPairStatusMessage(isDisabled, isIndexing, ccPairStatus)}
>
Index
Re-Index
</Button>
</>
);

View File

@@ -40,3 +40,24 @@ export async function triggerIndexing(
}
mutate(buildCCPairInfoUrl(ccPairId));
}
export function getTooltipMessage(
isInvalid: boolean,
isDeleting: boolean,
isIndexing: boolean,
isDisabled: boolean
): string | undefined {
if (isInvalid) {
return "Connector is in an invalid state. Please update the credentials or configuration before re-indexing.";
}
if (isDeleting) {
return "Cannot index while connector is deleting";
}
if (isIndexing) {
return "Indexing is already in progress";
}
if (isDisabled) {
return "Connector must be re-enabled before indexing";
}
return undefined;
}

View File

@@ -43,6 +43,7 @@ import IndexAttemptErrorsModal from "./IndexAttemptErrorsModal";
import usePaginatedFetch from "@/hooks/usePaginatedFetch";
import { IndexAttemptSnapshot } from "@/lib/types";
import { Spinner } from "@/components/Spinner";
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
@@ -363,6 +364,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
<div className="ml-auto flex gap-x-2">
<ReIndexButton
ccPairId={ccPair.id}
ccPairStatus={ccPair.status}
connectorId={ccPair.connector.id}
credentialId={ccPair.credential.id}
isDisabled={
@@ -370,7 +372,6 @@ function Main({ ccPairId }: { ccPairId: number }) {
ccPair.status === ConnectorCredentialPairStatus.PAUSED
}
isIndexing={ccPair.indexing}
isDeleting={isDeleting}
/>
{!isDeleting && <ModifyStatusButtonCluster ccPair={ccPair} />}
@@ -379,8 +380,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
</div>
<CCPairStatus
status={ccPair.last_index_attempt_status || "not_started"}
disabled={ccPair.status === ConnectorCredentialPairStatus.PAUSED}
isDeleting={isDeleting}
ccPairStatus={ccPair.status}
/>
<div className="text-sm mt-1">
Creator:{" "}
@@ -424,6 +424,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

@@ -8,58 +8,32 @@ interface HoverPopupProps {
style?: "basic" | "dark";
}
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export const HoverPopup = ({
mainContent,
popupContent,
classNameModifications,
direction = "bottom",
style = "basic",
}: HoverPopupProps) => {
const [hovered, setHovered] = useState(false);
let popupDirectionClass;
let popupStyle = {};
switch (direction) {
case "left":
popupDirectionClass = "top-0 left-0 transform";
popupStyle = { transform: "translateX(calc(-100% - 5px))" };
break;
case "left-top":
popupDirectionClass = "bottom-0 left-0";
popupStyle = { transform: "translate(calc(-100% - 5px), 0)" };
break;
case "bottom":
popupDirectionClass = "top-0 left-0 mt-6 pt-2";
break;
case "top":
popupDirectionClass = "top-0 left-0 translate-y-[-100%] pb-2";
break;
}
return (
<div
className="relative flex"
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
>
{hovered && (
<div
className={`absolute ${popupDirectionClass} z-30`}
style={popupStyle}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{mainContent}</div>
</TooltipTrigger>
<TooltipContent
side={direction === "left-top" ? "left" : direction}
className={classNameModifications}
>
<div
className={
`px-3 py-2 rounded bg-background border border-border` +
(classNameModifications || "")
}
>
{popupContent}
</div>
</div>
)}
<div>{mainContent}</div>
</div>
{popupContent}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View File

@@ -10,6 +10,7 @@ import {
FiPauseCircle,
} from "react-icons/fi";
import { HoverPopup } from "./HoverPopup";
import { ConnectorCredentialPairStatus } from "@/app/admin/connector/[ccPairId]/types";
export function IndexAttemptStatus({
status,
@@ -70,6 +71,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}>
@@ -83,29 +90,33 @@ export function IndexAttemptStatus({
export function CCPairStatus({
status,
disabled,
isDeleting,
ccPairStatus,
size = "md",
}: {
status: ValidStatuses;
disabled: boolean;
isDeleting: boolean;
ccPairStatus: ConnectorCredentialPairStatus;
size?: "xs" | "sm" | "md" | "lg";
}) {
let badge;
if (isDeleting) {
if (ccPairStatus == ConnectorCredentialPairStatus.DELETING) {
badge = (
<Badge variant="destructive" icon={FiAlertTriangle}>
Deleting
</Badge>
);
} else if (disabled) {
} else if (ccPairStatus == ConnectorCredentialPairStatus.PAUSED) {
badge = (
<Badge variant="paused" icon={FiPauseCircle}>
Paused
</Badge>
);
} else if (ccPairStatus == ConnectorCredentialPairStatus.INVALID) {
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

@@ -46,3 +46,23 @@ export async function setCCPairStatus(
});
}
}
export const getCCPairStatusMessage = (
isDisabled: boolean,
isIndexing: boolean,
ccPairStatus: ConnectorCredentialPairStatus
) => {
if (ccPairStatus === ConnectorCredentialPairStatus.INVALID) {
return "Connector is in an invalid state. Please update the credentials or configuration before re-indexing.";
}
if (ccPairStatus === ConnectorCredentialPairStatus.DELETING) {
return "Cannot index while connector is deleting";
}
if (isIndexing) {
return "Indexing is already in progress";
}
if (isDisabled) {
return "Connector must be re-enabled before indexing";
}
return undefined;
};

View File

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