Improve index attempt display (#4511)

This commit is contained in:
Chris Weaver 2025-04-13 15:57:47 -07:00 committed by GitHub
parent 65fd8b90a8
commit e3aab8e85e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1479 additions and 633 deletions

View File

@ -0,0 +1,57 @@
"""Update status length
Revision ID: d961aca62eb3
Revises: cf90764725d8
Create Date: 2025-03-23 16:10:05.683965
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "d961aca62eb3"
down_revision = "cf90764725d8"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Drop the existing enum type constraint
op.execute("ALTER TABLE connector_credential_pair ALTER COLUMN status TYPE varchar")
# Create new enum type with all values
op.execute(
"ALTER TABLE connector_credential_pair ALTER COLUMN status TYPE VARCHAR(20) USING status::varchar(20)"
)
# Update the enum type to include all possible values
op.alter_column(
"connector_credential_pair",
"status",
type_=sa.Enum(
"SCHEDULED",
"INITIAL_INDEXING",
"ACTIVE",
"PAUSED",
"DELETING",
"INVALID",
name="connectorcredentialpairstatus",
native_enum=False,
),
existing_type=sa.String(20),
nullable=False,
)
op.add_column(
"connector_credential_pair",
sa.Column(
"in_repeated_error_state", sa.Boolean, default=False, server_default="false"
),
)
def downgrade() -> None:
# no need to convert back to the old enum type, since we're not using it anymore
op.drop_column("connector_credential_pair", "in_repeated_error_state")

View File

@ -26,6 +26,7 @@ from onyx.background.celery.celery_utils import httpx_init_vespa_pool
from onyx.background.celery.memory_monitoring import emit_process_memory
from onyx.background.celery.tasks.indexing.utils import get_unfenced_index_attempt_ids
from onyx.background.celery.tasks.indexing.utils import IndexingCallback
from onyx.background.celery.tasks.indexing.utils import is_in_repeated_error_state
from onyx.background.celery.tasks.indexing.utils import should_index
from onyx.background.celery.tasks.indexing.utils import try_creating_indexing_task
from onyx.background.celery.tasks.indexing.utils import validate_indexing_fences
@ -54,11 +55,12 @@ from onyx.connectors.exceptions 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
from onyx.db.connector_credential_pair import set_cc_pair_repeated_error_state
from onyx.db.engine import get_session_with_current_tenant
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import IndexingMode
from onyx.db.enums import IndexingStatus
from onyx.db.index_attempt import get_index_attempt
from onyx.db.index_attempt import get_last_attempt_for_cc_pair
from onyx.db.index_attempt import mark_attempt_canceled
from onyx.db.index_attempt import mark_attempt_failed
from onyx.db.search_settings import get_active_search_settings_list
@ -241,6 +243,16 @@ def monitor_ccpair_indexing_taskset(
if not payload:
return
# if the CC Pair is `SCHEDULED`, moved it to `INITIAL_INDEXING`. A CC Pair
# should only ever be `SCHEDULED` if it's a new connector.
cc_pair = get_connector_credential_pair_from_id(db_session, cc_pair_id)
if cc_pair is None:
raise RuntimeError(f"CC Pair {cc_pair_id} not found")
if cc_pair.status == ConnectorCredentialPairStatus.SCHEDULED:
cc_pair.status = ConnectorCredentialPairStatus.INITIAL_INDEXING
db_session.commit()
elapsed_started_str = None
if payload.started:
elapsed_started = datetime.now(timezone.utc) - payload.started
@ -355,6 +367,22 @@ def monitor_ccpair_indexing_taskset(
redis_connector_index.reset()
# mark the CC Pair as `ACTIVE` if it's not already
if (
# it should never technically be in this state, but we'll handle it anyway
cc_pair.status == ConnectorCredentialPairStatus.SCHEDULED
or cc_pair.status == ConnectorCredentialPairStatus.INITIAL_INDEXING
):
cc_pair.status = ConnectorCredentialPairStatus.ACTIVE
db_session.commit()
# if the index attempt is successful, clear the repeated error state
if cc_pair.in_repeated_error_state:
index_attempt = get_index_attempt(db_session, payload.index_attempt_id)
if index_attempt and index_attempt.status.is_successful():
cc_pair.in_repeated_error_state = False
db_session.commit()
@shared_task(
name=OnyxCeleryTask.CHECK_FOR_INDEXING,
@ -441,6 +469,21 @@ def check_for_indexing(self: Task, *, tenant_id: str) -> int | None:
for cc_pair_entry in cc_pairs:
cc_pair_ids.append(cc_pair_entry.id)
# mark CC Pairs that are repeatedly failing as in repeated error state
with get_session_with_current_tenant() as db_session:
current_search_settings = get_current_search_settings(db_session)
for cc_pair_id in cc_pair_ids:
if is_in_repeated_error_state(
cc_pair_id=cc_pair_id,
search_settings_id=current_search_settings.id,
db_session=db_session,
):
set_cc_pair_repeated_error_state(
db_session=db_session,
cc_pair_id=cc_pair_id,
in_repeated_error_state=True,
)
# kick off index attempts
for cc_pair_id in cc_pair_ids:
lock_beat.reacquire()
@ -480,13 +523,8 @@ def check_for_indexing(self: Task, *, tenant_id: str) -> int | None:
)
continue
last_attempt = get_last_attempt_for_cc_pair(
cc_pair.id, search_settings_instance.id, db_session
)
if not should_index(
cc_pair=cc_pair,
last_index=last_attempt,
search_settings_instance=search_settings_instance,
secondary_index_building=len(search_settings_list) > 1,
db_session=db_session,
@ -494,7 +532,6 @@ def check_for_indexing(self: Task, *, tenant_id: str) -> int | None:
task_logger.info(
f"check_for_indexing - Not indexing cc_pair_id: {cc_pair_id} "
f"search_settings={search_settings_instance.id}, "
f"last_attempt={last_attempt.id if last_attempt else None}, "
f"secondary_index_building={len(search_settings_list) > 1}"
)
continue
@ -502,7 +539,6 @@ def check_for_indexing(self: Task, *, tenant_id: str) -> int | None:
task_logger.info(
f"check_for_indexing - Will index cc_pair_id: {cc_pair_id} "
f"search_settings={search_settings_instance.id}, "
f"last_attempt={last_attempt.id if last_attempt else None}, "
f"secondary_index_building={len(search_settings_list) > 1}"
)

View File

@ -22,6 +22,7 @@ from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import OnyxRedisConstants
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
from onyx.db.engine import get_db_current_time
from onyx.db.engine import get_session_with_current_tenant
from onyx.db.enums import ConnectorCredentialPairStatus
@ -31,6 +32,8 @@ from onyx.db.index_attempt import create_index_attempt
from onyx.db.index_attempt import delete_index_attempt
from onyx.db.index_attempt import get_all_index_attempts_by_status
from onyx.db.index_attempt import get_index_attempt
from onyx.db.index_attempt import get_last_attempt_for_cc_pair
from onyx.db.index_attempt import get_recent_attempts_for_cc_pair
from onyx.db.index_attempt import mark_attempt_failed
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import IndexAttempt
@ -44,6 +47,8 @@ from onyx.utils.logger import setup_logger
logger = setup_logger()
NUM_REPEAT_ERRORS_BEFORE_REPEATED_ERROR_STATE = 5
def get_unfenced_index_attempt_ids(db_session: Session, r: redis.Redis) -> list[int]:
"""Gets a list of unfenced index attempts. Should not be possible, so we'd typically
@ -346,9 +351,42 @@ def validate_indexing_fences(
return
def is_in_repeated_error_state(
cc_pair_id: int, search_settings_id: int, db_session: Session
) -> bool:
"""Checks if the cc pair / search setting combination is in a repeated error state."""
cc_pair = get_connector_credential_pair_from_id(
db_session=db_session,
cc_pair_id=cc_pair_id,
)
if not cc_pair:
raise RuntimeError(
f"is_in_repeated_error_state - could not find cc_pair with id={cc_pair_id}"
)
# if the connector doesn't have a refresh_freq, a single failed attempt is enough
number_of_failed_attempts_in_a_row_needed = (
NUM_REPEAT_ERRORS_BEFORE_REPEATED_ERROR_STATE
if cc_pair.connector.refresh_freq is not None
else 1
)
most_recent_index_attempts = get_recent_attempts_for_cc_pair(
cc_pair_id=cc_pair_id,
search_settings_id=search_settings_id,
limit=number_of_failed_attempts_in_a_row_needed,
db_session=db_session,
)
return len(
most_recent_index_attempts
) >= number_of_failed_attempts_in_a_row_needed and all(
attempt.status == IndexingStatus.FAILED
for attempt in most_recent_index_attempts
)
def should_index(
cc_pair: ConnectorCredentialPair,
last_index: IndexAttempt | None,
search_settings_instance: SearchSettings,
secondary_index_building: bool,
db_session: Session,
@ -362,6 +400,16 @@ def should_index(
Return True if we should try to index, False if not.
"""
connector = cc_pair.connector
last_index_attempt = get_last_attempt_for_cc_pair(
cc_pair_id=cc_pair.id,
search_settings_id=search_settings_instance.id,
db_session=db_session,
)
all_recent_errored = is_in_repeated_error_state(
cc_pair_id=cc_pair.id,
search_settings_id=search_settings_instance.id,
db_session=db_session,
)
# uncomment for debugging
# task_logger.info(f"_should_index: "
@ -388,24 +436,24 @@ def should_index(
# When switching over models, always index at least once
if search_settings_instance.status == IndexModelStatus.FUTURE:
if last_index:
if last_index_attempt:
# No new index if the last index attempt succeeded
# Once is enough. The model will never be able to swap otherwise.
if last_index.status == IndexingStatus.SUCCESS:
if last_index_attempt.status == IndexingStatus.SUCCESS:
# print(
# f"Not indexing cc_pair={cc_pair.id}: FUTURE model with successful last index attempt={last_index.id}"
# )
return False
# No new index if the last index attempt is waiting to start
if last_index.status == IndexingStatus.NOT_STARTED:
if last_index_attempt.status == IndexingStatus.NOT_STARTED:
# print(
# f"Not indexing cc_pair={cc_pair.id}: FUTURE model with NOT_STARTED last index attempt={last_index.id}"
# )
return False
# No new index if the last index attempt is running
if last_index.status == IndexingStatus.IN_PROGRESS:
if last_index_attempt.status == IndexingStatus.IN_PROGRESS:
# print(
# f"Not indexing cc_pair={cc_pair.id}: FUTURE model with IN_PROGRESS last index attempt={last_index.id}"
# )
@ -439,18 +487,27 @@ def should_index(
return True
# if no attempt has ever occurred, we should index regardless of refresh_freq
if not last_index:
if not last_index_attempt:
return True
if connector.refresh_freq is None:
# print(f"Not indexing cc_pair={cc_pair.id}: refresh_freq is None")
return False
# if in the "initial" phase, we should always try and kick-off indexing
# as soon as possible if there is no ongoing attempt. In other words,
# no delay UNLESS we're repeatedly failing to index.
if (
cc_pair.status == ConnectorCredentialPairStatus.INITIAL_INDEXING
and not all_recent_errored
):
return True
current_db_time = get_db_current_time(db_session)
time_since_index = current_db_time - last_index.time_updated
time_since_index = current_db_time - last_index_attempt.time_updated
if time_since_index.total_seconds() < connector.refresh_freq:
# print(
# f"Not indexing cc_pair={cc_pair.id}: Last index attempt={last_index.id} "
# f"Not indexing cc_pair={cc_pair.id}: Last index attempt={last_index_attempt.id} "
# f"too recent ({time_since_index.total_seconds()}s < {connector.refresh_freq}s)"
# )
return False

View File

@ -483,14 +483,6 @@ CONTINUE_ON_CONNECTOR_FAILURE = os.environ.get(
DISABLE_INDEX_UPDATE_ON_SWAP = (
os.environ.get("DISABLE_INDEX_UPDATE_ON_SWAP", "").lower() == "true"
)
# Controls how many worker processes we spin up to index documents in the
# background. This is useful for speeding up indexing, but does require a
# fairly large amount of memory in order to increase substantially, since
# each worker loads the embedding models into memory.
NUM_INDEXING_WORKERS = int(os.environ.get("NUM_INDEXING_WORKERS") or 1)
NUM_SECONDARY_INDEXING_WORKERS = int(
os.environ.get("NUM_SECONDARY_INDEXING_WORKERS") or NUM_INDEXING_WORKERS
)
# More accurate results at the expense of indexing speed and index size (stores additional 4 MINI_CHUNK vectors)
ENABLE_MULTIPASS_INDEXING = (
os.environ.get("ENABLE_MULTIPASS_INDEXING", "").lower() == "true"

View File

@ -101,6 +101,7 @@ class ConfluenceConnector(
self.labels_to_skip = labels_to_skip
self.timezone_offset = timezone_offset
self._confluence_client: OnyxConfluence | None = None
self._low_timeout_confluence_client: OnyxConfluence | None = None
self._fetched_titles: set[str] = set()
self.allow_images = False
@ -156,6 +157,12 @@ class ConfluenceConnector(
raise ConnectorMissingCredentialError("Confluence")
return self._confluence_client
@property
def low_timeout_confluence_client(self) -> OnyxConfluence:
if self._low_timeout_confluence_client is None:
raise ConnectorMissingCredentialError("Confluence")
return self._low_timeout_confluence_client
def set_credentials_provider(
self, credentials_provider: CredentialsProviderInterface
) -> None:
@ -163,13 +170,27 @@ class ConfluenceConnector(
# raises exception if there's a problem
confluence_client = OnyxConfluence(
self.is_cloud, self.wiki_base, credentials_provider
is_cloud=self.is_cloud,
url=self.wiki_base,
credentials_provider=credentials_provider,
)
confluence_client._probe_connection(**self.probe_kwargs)
confluence_client._initialize_connection(**self.final_kwargs)
self._confluence_client = confluence_client
# create a low timeout confluence client for sync flows
low_timeout_confluence_client = OnyxConfluence(
is_cloud=self.is_cloud,
url=self.wiki_base,
credentials_provider=credentials_provider,
timeout=3,
)
low_timeout_confluence_client._probe_connection(**self.probe_kwargs)
low_timeout_confluence_client._initialize_connection(**self.final_kwargs)
self._low_timeout_confluence_client = low_timeout_confluence_client
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
raise NotImplementedError("Use set_credentials_provider with this connector.")
@ -521,11 +542,8 @@ class ConfluenceConnector(
yield doc_metadata_list
def validate_connector_settings(self) -> None:
if self._confluence_client is None:
raise ConnectorMissingCredentialError("Confluence credentials not loaded.")
try:
spaces = self._confluence_client.get_all_spaces(limit=1)
spaces = self.low_timeout_confluence_client.get_all_spaces(limit=1)
except HTTPError as e:
status_code = e.response.status_code if e.response else None
if status_code == 401:

View File

@ -72,12 +72,14 @@ class OnyxConfluence:
CREDENTIAL_PREFIX = "connector:confluence:credential"
CREDENTIAL_TTL = 300 # 5 min
PROBE_TIMEOUT = 5 # 5 seconds
def __init__(
self,
is_cloud: bool,
url: str,
credentials_provider: CredentialsProviderInterface,
timeout: int | None = None,
) -> None:
self._is_cloud = is_cloud
self._url = url.rstrip("/")
@ -100,11 +102,13 @@ class OnyxConfluence:
self._kwargs: Any = None
self.shared_base_kwargs = {
self.shared_base_kwargs: dict[str, str | int | bool] = {
"api_version": "cloud" if is_cloud else "latest",
"backoff_and_retry": True,
"cloud": is_cloud,
}
if timeout:
self.shared_base_kwargs["timeout"] = timeout
def _renew_credentials(self) -> tuple[dict[str, Any], bool]:
"""credential_json - the current json credentials
@ -191,6 +195,8 @@ class OnyxConfluence:
**kwargs: Any,
) -> None:
merged_kwargs = {**self.shared_base_kwargs, **kwargs}
# add special timeout to make sure that we don't hang indefinitely
merged_kwargs["timeout"] = self.PROBE_TIMEOUT
with self._credentials_provider:
credentials, _ = self._renew_credentials()

View File

@ -7,6 +7,7 @@ from sqlalchemy import desc
from sqlalchemy import exists
from sqlalchemy import Select
from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.orm import aliased
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import selectinload
@ -394,6 +395,20 @@ def update_connector_credential_pair(
)
def set_cc_pair_repeated_error_state(
db_session: Session,
cc_pair_id: int,
in_repeated_error_state: bool,
) -> None:
stmt = (
update(ConnectorCredentialPair)
.where(ConnectorCredentialPair.id == cc_pair_id)
.values(in_repeated_error_state=in_repeated_error_state)
)
db_session.execute(stmt)
db_session.commit()
def delete_connector_credential_pair__no_commit(
db_session: Session,
connector_id: int,
@ -457,7 +472,7 @@ def add_credential_to_connector(
access_type: AccessType,
groups: list[int] | None,
auto_sync_options: dict | None = None,
initial_status: ConnectorCredentialPairStatus = ConnectorCredentialPairStatus.ACTIVE,
initial_status: ConnectorCredentialPairStatus = ConnectorCredentialPairStatus.SCHEDULED,
last_successful_index_time: datetime | None = None,
seeding_flow: bool = False,
is_user_file: bool = False,

View File

@ -18,6 +18,12 @@ class IndexingStatus(str, PyEnum):
}
return self in terminal_states
def is_successful(self) -> bool:
return (
self == IndexingStatus.SUCCESS
or self == IndexingStatus.COMPLETED_WITH_ERRORS
)
class IndexingMode(str, PyEnum):
UPDATE = "update"
@ -73,13 +79,19 @@ class ChatSessionSharedStatus(str, PyEnum):
class ConnectorCredentialPairStatus(str, PyEnum):
SCHEDULED = "SCHEDULED"
INITIAL_INDEXING = "INITIAL_INDEXING"
ACTIVE = "ACTIVE"
PAUSED = "PAUSED"
DELETING = "DELETING"
INVALID = "INVALID"
def is_active(self) -> bool:
return self == ConnectorCredentialPairStatus.ACTIVE
return (
self == ConnectorCredentialPairStatus.ACTIVE
or self == ConnectorCredentialPairStatus.SCHEDULED
or self == ConnectorCredentialPairStatus.INITIAL_INDEXING
)
class AccessType(str, PyEnum):

View File

@ -59,6 +59,7 @@ def get_recent_completed_attempts_for_cc_pair(
limit: int,
db_session: Session,
) -> list[IndexAttempt]:
"""Most recent to least recent."""
return (
db_session.query(IndexAttempt)
.filter(
@ -74,6 +75,25 @@ def get_recent_completed_attempts_for_cc_pair(
)
def get_recent_attempts_for_cc_pair(
cc_pair_id: int,
search_settings_id: int,
limit: int,
db_session: Session,
) -> list[IndexAttempt]:
"""Most recent to least recent."""
return (
db_session.query(IndexAttempt)
.filter(
IndexAttempt.connector_credential_pair_id == cc_pair_id,
IndexAttempt.search_settings_id == search_settings_id,
)
.order_by(IndexAttempt.time_updated.desc())
.limit(limit)
.all()
)
def get_index_attempt(
db_session: Session, index_attempt_id: int
) -> IndexAttempt | None:

View File

@ -437,6 +437,9 @@ class ConnectorCredentialPair(Base):
status: Mapped[ConnectorCredentialPairStatus] = mapped_column(
Enum(ConnectorCredentialPairStatus, native_enum=False), nullable=False
)
# this is separate from the `status` above, since a connector can be `INITIAL_INDEXING`, `ACTIVE`,
# or `PAUSED` and still be in a repeated error state.
in_repeated_error_state: Mapped[bool] = mapped_column(Boolean, default=False)
connector_id: Mapped[int] = mapped_column(
ForeignKey("connector.id"), primary_key=True
)

View File

@ -783,6 +783,7 @@ def get_connector_indexing_status(
name=cc_pair.name,
in_progress=in_progress,
cc_pair_status=cc_pair.status,
in_repeated_error_state=cc_pair.in_repeated_error_state,
connector=ConnectorSnapshot.from_connector_db_model(
connector, connector_to_cc_pair_ids.get(connector.id, [])
),

View File

@ -1,4 +1,5 @@
from datetime import datetime
from datetime import timezone
from typing import Any
from typing import Generic
from typing import TypeVar
@ -128,6 +129,7 @@ class CredentialBase(BaseModel):
class CredentialSnapshot(CredentialBase):
id: int
user_id: UUID | None
user_email: str | None = None
time_created: datetime
time_updated: datetime
@ -141,6 +143,7 @@ class CredentialSnapshot(CredentialBase):
else credential.credential_json
),
user_id=credential.user_id,
user_email=credential.user.email if credential.user else None,
admin_public=credential.admin_public,
time_created=credential.time_created,
time_updated=credential.time_updated,
@ -207,6 +210,7 @@ class CCPairFullInfo(BaseModel):
id: int
name: str
status: ConnectorCredentialPairStatus
in_repeated_error_state: bool
num_docs_indexed: int
connector: ConnectorSnapshot
credential: CredentialSnapshot
@ -220,6 +224,13 @@ class CCPairFullInfo(BaseModel):
creator: UUID | None
creator_email: str | None
# information on syncing/indexing
last_indexed: datetime | None
last_pruned: datetime | None
last_permission_sync: datetime | None
overall_indexing_speed: float | None
latest_checkpoint_description: str | None
@classmethod
def from_models(
cls,
@ -237,7 +248,8 @@ class CCPairFullInfo(BaseModel):
# there is a mismatch between these two numbers which may confuse users.
last_indexing_status = last_index_attempt.status if last_index_attempt else None
if (
last_indexing_status == IndexingStatus.SUCCESS
# only need to do this if the last indexing attempt is still in progress
last_indexing_status == IndexingStatus.IN_PROGRESS
and number_of_index_attempts == 1
and last_index_attempt
and last_index_attempt.new_docs_indexed
@ -246,10 +258,18 @@ class CCPairFullInfo(BaseModel):
last_index_attempt.new_docs_indexed if last_index_attempt else 0
)
overall_indexing_speed = num_docs_indexed / (
(
datetime.now(tz=timezone.utc) - cc_pair_model.connector.time_created
).total_seconds()
/ 60
)
return cls(
id=cc_pair_model.id,
name=cc_pair_model.name,
status=cc_pair_model.status,
in_repeated_error_state=cc_pair_model.in_repeated_error_state,
num_docs_indexed=num_docs_indexed,
connector=ConnectorSnapshot.from_connector_db_model(
cc_pair_model.connector
@ -268,6 +288,15 @@ class CCPairFullInfo(BaseModel):
creator_email=(
cc_pair_model.creator.email if cc_pair_model.creator else None
),
last_indexed=(
last_index_attempt.time_started if last_index_attempt else None
),
last_pruned=cc_pair_model.last_pruned,
last_permission_sync=(
last_index_attempt.time_started if last_index_attempt else None
),
overall_indexing_speed=overall_indexing_speed,
latest_checkpoint_description=None,
)
@ -308,6 +337,9 @@ class ConnectorIndexingStatus(ConnectorStatus):
"""Represents the full indexing status of a connector"""
cc_pair_status: ConnectorCredentialPairStatus
# this is separate from the `status` above, since a connector can be `INITIAL_INDEXING`, `ACTIVE`,
# or `PAUSED` and still be in a repeated error state.
in_repeated_error_state: bool
owner: str
last_finished_status: IndexingStatus | None
last_status: IndexingStatus | None

View File

@ -69,6 +69,7 @@ class CCPairManager:
connector_specific_config: dict[str, Any] | None = None,
credential_json: dict[str, Any] | None = None,
user_performing_action: DATestUser | None = None,
refresh_freq: int | None = None,
) -> DATestCCPair:
connector = ConnectorManager.create(
name=name,
@ -78,6 +79,7 @@ class CCPairManager:
access_type=access_type,
groups=groups,
user_performing_action=user_performing_action,
refresh_freq=refresh_freq,
)
credential = CredentialManager.create(
credential_json=credential_json,

View File

@ -23,6 +23,7 @@ class ConnectorManager:
access_type: AccessType = AccessType.PUBLIC,
groups: list[int] | None = None,
user_performing_action: DATestUser | None = None,
refresh_freq: int | None = None,
) -> DATestConnector:
name = f"{name}-connector" if name else f"test-connector-{uuid4()}"
@ -36,6 +37,7 @@ class ConnectorManager:
),
access_type=access_type,
groups=groups or [],
refresh_freq=refresh_freq,
)
response = requests.post(

View File

@ -0,0 +1,18 @@
import httpx
import pytest
from tests.integration.common_utils.constants import MOCK_CONNECTOR_SERVER_HOST
from tests.integration.common_utils.constants import MOCK_CONNECTOR_SERVER_PORT
@pytest.fixture
def mock_server_client() -> httpx.Client:
print(
f"Initializing mock server client with host: "
f"{MOCK_CONNECTOR_SERVER_HOST} and port: "
f"{MOCK_CONNECTOR_SERVER_PORT}"
)
return httpx.Client(
base_url=f"http://{MOCK_CONNECTOR_SERVER_HOST}:{MOCK_CONNECTOR_SERVER_PORT}",
timeout=5.0,
)

View File

@ -4,7 +4,6 @@ from datetime import timedelta
from datetime import timezone
import httpx
import pytest
from onyx.configs.constants import DocumentSource
from onyx.connectors.mock_connector.connector import MockConnectorCheckpoint
@ -26,19 +25,6 @@ from tests.integration.common_utils.test_models import DATestUser
from tests.integration.common_utils.vespa import vespa_fixture
@pytest.fixture
def mock_server_client() -> httpx.Client:
print(
f"Initializing mock server client with host: "
f"{MOCK_CONNECTOR_SERVER_HOST} and port: "
f"{MOCK_CONNECTOR_SERVER_PORT}"
)
return httpx.Client(
base_url=f"http://{MOCK_CONNECTOR_SERVER_HOST}:{MOCK_CONNECTOR_SERVER_PORT}",
timeout=5.0,
)
def test_mock_connector_basic_flow(
mock_server_client: httpx.Client,
vespa_client: vespa_fixture,

View File

@ -0,0 +1,204 @@
import time
import uuid
import httpx
from onyx.background.celery.tasks.indexing.utils import (
NUM_REPEAT_ERRORS_BEFORE_REPEATED_ERROR_STATE,
)
from onyx.configs.constants import DocumentSource
from onyx.connectors.mock_connector.connector import MockConnectorCheckpoint
from onyx.connectors.models import InputType
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
from onyx.db.engine import get_session_context_manager
from onyx.db.enums import IndexingStatus
from tests.integration.common_utils.constants import MOCK_CONNECTOR_SERVER_HOST
from tests.integration.common_utils.constants import MOCK_CONNECTOR_SERVER_PORT
from tests.integration.common_utils.managers.cc_pair import CCPairManager
from tests.integration.common_utils.managers.document import DocumentManager
from tests.integration.common_utils.managers.index_attempt import IndexAttemptManager
from tests.integration.common_utils.test_document_utils import create_test_document
from tests.integration.common_utils.test_models import DATestUser
from tests.integration.common_utils.vespa import vespa_fixture
def test_repeated_error_state_detection_and_recovery(
mock_server_client: httpx.Client,
vespa_client: vespa_fixture,
admin_user: DATestUser,
) -> None:
"""Test that a connector is marked as in a repeated error state after
NUM_REPEAT_ERRORS_BEFORE_REPEATED_ERROR_STATE consecutive failures, and
that it recovers after a successful indexing.
This test ensures we properly wait for the required number of indexing attempts
to fail before checking that the connector is in a repeated error state."""
# Create test document for successful response
test_doc = create_test_document()
# First, set up the mock server to consistently fail
error_response = {
"documents": [],
"checkpoint": MockConnectorCheckpoint(has_more=False).model_dump(mode="json"),
"failures": [],
"unhandled_exception": "Simulated unhandled error for testing repeated errors",
}
# Create a list of failure responses with at least the same length
# as NUM_REPEAT_ERRORS_BEFORE_REPEATED_ERROR_STATE
failure_behaviors = [error_response] * (
5 * NUM_REPEAT_ERRORS_BEFORE_REPEATED_ERROR_STATE
)
response = mock_server_client.post(
"/set-behavior",
json=failure_behaviors,
)
assert response.status_code == 200
# Create a new CC pair for testing
cc_pair = CCPairManager.create_from_scratch(
name=f"mock-repeated-error-{uuid.uuid4()}",
source=DocumentSource.MOCK_CONNECTOR,
input_type=InputType.POLL,
connector_specific_config={
"mock_server_host": MOCK_CONNECTOR_SERVER_HOST,
"mock_server_port": MOCK_CONNECTOR_SERVER_PORT,
},
user_performing_action=admin_user,
refresh_freq=60 * 60, # a very long time
)
# Wait for the required number of failed indexing attempts
# This shouldn't take long, since we keep retrying while we haven't
# succeeded yet
start_time = time.monotonic()
while True:
index_attempts_page = IndexAttemptManager.get_index_attempt_page(
cc_pair_id=cc_pair.id,
page=0,
page_size=100,
user_performing_action=admin_user,
)
index_attempts = [
ia
for ia in index_attempts_page.items
if ia.status and ia.status.is_terminal()
]
if len(index_attempts) == NUM_REPEAT_ERRORS_BEFORE_REPEATED_ERROR_STATE:
break
if time.monotonic() - start_time > 180:
raise TimeoutError(
"Did not get required number of failed attempts within 180 seconds"
)
# make sure that we don't mark the connector as in repeated error state
# before we have the required number of failed attempts
with get_session_context_manager() as db_session:
cc_pair_obj = get_connector_credential_pair_from_id(
db_session=db_session,
cc_pair_id=cc_pair.id,
)
assert cc_pair_obj is not None
assert not cc_pair_obj.in_repeated_error_state
time.sleep(2)
# Verify we have the correct number of failed attempts
assert len(index_attempts) == NUM_REPEAT_ERRORS_BEFORE_REPEATED_ERROR_STATE
for attempt in index_attempts:
assert attempt.status == IndexingStatus.FAILED
# Check if the connector is in a repeated error state
start_time = time.monotonic()
while True:
with get_session_context_manager() as db_session:
cc_pair_obj = get_connector_credential_pair_from_id(
db_session=db_session,
cc_pair_id=cc_pair.id,
)
assert cc_pair_obj is not None
if cc_pair_obj.in_repeated_error_state:
break
if time.monotonic() - start_time > 30:
assert False, "CC pair did not enter repeated error state within 30 seconds"
time.sleep(2)
# Reset the mock server state
response = mock_server_client.post("/reset")
assert response.status_code == 200
# Now set up the mock server to succeed
success_response = {
"documents": [test_doc.model_dump(mode="json")],
"checkpoint": MockConnectorCheckpoint(has_more=False).model_dump(mode="json"),
"failures": [],
}
response = mock_server_client.post(
"/set-behavior",
json=[success_response],
)
assert response.status_code == 200
# Run another indexing attempt that should succeed
CCPairManager.run_once(
cc_pair, from_beginning=True, user_performing_action=admin_user
)
recovery_index_attempt = IndexAttemptManager.wait_for_index_attempt_start(
cc_pair_id=cc_pair.id,
index_attempts_to_ignore=[index_attempt.id for index_attempt in index_attempts],
user_performing_action=admin_user,
)
IndexAttemptManager.wait_for_index_attempt_completion(
index_attempt_id=recovery_index_attempt.id,
cc_pair_id=cc_pair.id,
user_performing_action=admin_user,
)
# Validate the indexing succeeded
finished_recovery_attempt = IndexAttemptManager.get_index_attempt_by_id(
index_attempt_id=recovery_index_attempt.id,
cc_pair_id=cc_pair.id,
user_performing_action=admin_user,
)
assert finished_recovery_attempt.status == IndexingStatus.SUCCESS
# Verify the document was indexed
with get_session_context_manager() as db_session:
documents = DocumentManager.fetch_documents_for_cc_pair(
cc_pair_id=cc_pair.id,
db_session=db_session,
vespa_client=vespa_client,
)
assert len(documents) == 1
assert documents[0].id == test_doc.id
# Verify the CC pair is no longer in a repeated error state
start = time.monotonic()
while True:
with get_session_context_manager() as db_session:
cc_pair_obj = get_connector_credential_pair_from_id(
db_session=db_session,
cc_pair_id=cc_pair.id,
)
assert cc_pair_obj is not None
if not cc_pair_obj.in_repeated_error_state:
break
elapsed = time.monotonic() - start
if elapsed > 30:
raise TimeoutError(
"CC pair did not exit repeated error state within 30 seconds"
)
print(
f"Waiting for CC pair to exit repeated error state. elapsed={elapsed:.2f}"
)
time.sleep(1)

View File

@ -199,7 +199,6 @@ services:
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
# Indexing Configs
- VESPA_SEARCHER_THREADS=${VESPA_SEARCHER_THREADS:-}
- NUM_INDEXING_WORKERS=${NUM_INDEXING_WORKERS:-}
- ENABLED_CONNECTOR_TYPES=${ENABLED_CONNECTOR_TYPES:-}
- DISABLE_INDEX_UPDATE_ON_SWAP=${DISABLE_INDEX_UPDATE_ON_SWAP:-}
- DASK_JOB_CLIENT_ENABLED=${DASK_JOB_CLIENT_ENABLED:-}

View File

@ -163,7 +163,6 @@ services:
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
# Indexing Configs
- VESPA_SEARCHER_THREADS=${VESPA_SEARCHER_THREADS:-}
- NUM_INDEXING_WORKERS=${NUM_INDEXING_WORKERS:-}
- ENABLED_CONNECTOR_TYPES=${ENABLED_CONNECTOR_TYPES:-}
- DISABLE_INDEX_UPDATE_ON_SWAP=${DISABLE_INDEX_UPDATE_ON_SWAP:-}
- DASK_JOB_CLIENT_ENABLED=${DASK_JOB_CLIENT_ENABLED:-}

View File

@ -182,7 +182,6 @@ services:
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
# Indexing Configs
- VESPA_SEARCHER_THREADS=${VESPA_SEARCHER_THREADS:-}
- NUM_INDEXING_WORKERS=${NUM_INDEXING_WORKERS:-}
- ENABLED_CONNECTOR_TYPES=${ENABLED_CONNECTOR_TYPES:-}
- DISABLE_INDEX_UPDATE_ON_SWAP=${DISABLE_INDEX_UPDATE_ON_SWAP:-}
- DASK_JOB_CLIENT_ENABLED=${DASK_JOB_CLIENT_ENABLED:-}

View File

@ -468,6 +468,10 @@ configMap:
JIRA_API_VERSION: ""
GONG_CONNECTOR_START_TIME: ""
NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP: ""
# Worker Parallelism
CELERY_WORKER_INDEXING_CONCURRENCY: ""
CELERY_WORKER_LIGHT_CONCURRENCY: ""
CELERY_WORKER_LIGHT_PREFETCH_MULTIPLIER: ""
# OnyxBot SlackBot Configs
DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER: ""
DANSWER_BOT_DISPLAY_ERROR_MSGS: ""

View File

@ -49,7 +49,6 @@ data:
MIN_THREADS_ML_MODELS: ""
# Indexing Configs
VESPA_SEARCHER_THREADS: ""
NUM_INDEXING_WORKERS: ""
ENABLED_CONNECTOR_TYPES: ""
DISABLE_INDEX_UPDATE_ON_SWAP: ""
DASK_JOB_CLIENT_ENABLED: ""
@ -62,6 +61,10 @@ data:
NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP: ""
MAX_DOCUMENT_CHARS: ""
MAX_FILE_SIZE_BYTES: ""
# Worker Parallelism
CELERY_WORKER_INDEXING_CONCURRENCY: ""
CELERY_WORKER_LIGHT_CONCURRENCY: ""
CELERY_WORKER_LIGHT_PREFETCH_MULTIPLIER: ""
# OnyxBot SlackBot Configs
DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER: ""
DANSWER_BOT_DISPLAY_ERROR_MSGS: ""

View File

@ -1,7 +1,5 @@
import CardSection from "@/components/admin/CardSection";
import { getNameFromPath } from "@/lib/fileUtils";
import { ValidSources } from "@/lib/types";
import Title from "@/components/ui/title";
import { EditIcon } from "@/components/icons/icons";
import { useState } from "react";
@ -44,7 +42,15 @@ function buildConfigEntries(
return obj;
}
function ConfigItem({ label, value }: { label: string; value: any }) {
function ConfigItem({
label,
value,
onEdit,
}: {
label: string;
value: any;
onEdit?: () => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const isExpandable = Array.isArray(value) && value.length > 5;
@ -52,11 +58,11 @@ function ConfigItem({ label, value }: { label: string; value: any }) {
if (Array.isArray(value)) {
const displayedItems = isExpanded ? value : value.slice(0, 5);
return (
<ul className="list-disc max-w-full pl-4 mt-2 overflow-x-auto">
<ul className="list-disc pl-4 overflow-x-auto">
{displayedItems.map((item, index) => (
<li
key={index}
className="mb-1 max-w-full overflow-hidden text-right text-ellipsis whitespace-nowrap"
className="mb-1 overflow-hidden text-ellipsis whitespace-nowrap"
>
{convertObjectToString(item)}
</li>
@ -65,7 +71,7 @@ function ConfigItem({ label, value }: { label: string; value: any }) {
);
} else if (typeof value === "object" && value !== null) {
return (
<div className="mt-2 overflow-x-auto">
<div className="overflow-x-auto">
{Object.entries(value).map(([key, val]) => (
<div key={key} className="mb-1">
<span className="font-semibold">{key}:</span>{" "}
@ -75,20 +81,24 @@ function ConfigItem({ label, value }: { label: string; value: any }) {
</div>
);
}
// TODO: figure out a nice way to display boolean values
else if (typeof value === "boolean") {
return value ? "True" : "False";
}
return convertObjectToString(value) || "-";
};
return (
<li className="w-full py-2">
<div className="flex items-center justify-between w-full">
<span className="mb-2">{label}</span>
<div className="mt-2 overflow-x-auto w-fit">
<li className="w-full py-4 px-1">
<div className="flex items-center w-full">
<span className="text-sm">{label}</span>
<div className="text-right overflow-x-auto max-w-[60%] text-sm font-normal ml-auto">
{renderValue()}
{isExpandable && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 ml-auto text-text-600 hover:text-text-800 flex items-center"
className="mt-2 text-sm text-text-600 hover:text-text-800 flex items-center ml-auto"
>
{isExpanded ? (
<>
@ -104,6 +114,11 @@ function ConfigItem({ label, value }: { label: string; value: any }) {
</button>
)}
</div>
{onEdit && (
<button onClick={onEdit} className="ml-4">
<EditIcon size={12} />
</button>
)}
</div>
</li>
);
@ -146,63 +161,41 @@ export function AdvancedConfigDisplay({
};
return (
<>
<Title className="mt-8 mb-2">Advanced Configuration</Title>
<CardSection>
<ul className="w-full text-sm divide-y divide-neutral-200 dark:divide-neutral-700">
{pruneFreq && (
<li
key={0}
className="w-full flex justify-between items-center py-2"
>
<span>Pruning Frequency</span>
<span className="ml-auto w-24">
{formatPruneFrequency(pruneFreq)}
</span>
<span className="w-8 text-right">
<button onClick={() => onPruningEdit()}>
<EditIcon size={12} />
</button>
</span>
</li>
)}
{refreshFreq && (
<li
key={1}
className="w-full flex justify-between items-center py-2"
>
<span>Refresh Frequency</span>
<span className="ml-auto w-24">
{formatRefreshFrequency(refreshFreq)}
</span>
<span className="w-8 text-right">
<button onClick={() => onRefreshEdit()}>
<EditIcon size={12} />
</button>
</span>
</li>
)}
{indexingStart && (
<li
key={2}
className="w-full flex justify-between items-center py-2"
>
<span>Indexing Start</span>
<span>{formatDate(indexingStart)}</span>
</li>
)}
</ul>
</CardSection>
</>
<div>
<ul className="w-full divide-y divide-neutral-200 dark:divide-neutral-700">
{pruneFreq !== null && (
<ConfigItem
label="Pruning Frequency"
value={formatPruneFrequency(pruneFreq)}
onEdit={onPruningEdit}
/>
)}
{refreshFreq && (
<ConfigItem
label="Refresh Frequency"
value={formatRefreshFrequency(refreshFreq)}
onEdit={onRefreshEdit}
/>
)}
{indexingStart && (
<ConfigItem
label="Indexing Start"
value={formatDate(indexingStart)}
/>
)}
</ul>
</div>
);
}
export function ConfigDisplay({
connectorSpecificConfig,
sourceType,
onEdit,
}: {
connectorSpecificConfig: any;
sourceType: ValidSources;
onEdit?: (key: string) => void;
}) {
const configEntries = Object.entries(
buildConfigEntries(connectorSpecificConfig, sourceType)
@ -212,15 +205,15 @@ export function ConfigDisplay({
}
return (
<>
<Title className="mb-2">Configuration</Title>
<CardSection>
<ul className="w-full text-sm divide-y divide-background-200 dark:divide-background-700">
{configEntries.map(([key, value]) => (
<ConfigItem key={key} label={key} value={value} />
))}
</ul>
</CardSection>
</>
<ul className="w-full divide-y divide-background-200 dark:divide-background-700">
{configEntries.map(([key, value]) => (
<ConfigItem
key={key}
label={key}
value={value}
onEdit={onEdit ? () => onEdit(key) : undefined}
/>
))}
</ul>
);
}

View File

@ -15,13 +15,9 @@ import { CCPairFullInfo } from "./types";
import { IndexAttemptSnapshot } from "@/lib/types";
import { IndexAttemptStatus } from "@/components/Status";
import { PageSelector } from "@/components/PageSelector";
import { ThreeDotsLoader } from "@/components/Loading";
import { buildCCPairInfoUrl } from "./lib";
import { localizeAndPrettify } from "@/lib/time";
import { getDocsProcessedPerMinute } from "@/lib/indexAttempt";
import { ErrorCallout } from "@/components/ErrorCallout";
import { InfoIcon, SearchIcon } from "@/components/icons/icons";
import Link from "next/link";
import { InfoIcon } from "@/components/icons/icons";
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
import {
Tooltip,
@ -29,10 +25,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import usePaginatedFetch from "@/hooks/usePaginatedFetch";
const ITEMS_PER_PAGE = 8;
const PAGES_PER_BATCH = 8;
export interface IndexingAttemptsTableProps {
ccPair: CCPairFullInfo;
@ -84,14 +76,14 @@ export function IndexingAttemptsTable({
<TableRow>
<TableHead>Time Started</TableHead>
<TableHead>Status</TableHead>
<TableHead>New Doc Cnt</TableHead>
<TableHead className="whitespace-nowrap">New Docs</TableHead>
<TableHead>
<div className="w-fit">
<div className="w-fit whitespace-nowrap">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help flex items-center">
Total Doc Cnt
Total Docs
<InfoIcon className="ml-1 w-4 h-4" />
</span>
</TooltipTrigger>

View File

@ -1,107 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
CCPairFullInfo,
ConnectorCredentialPairStatus,
statusIsNotCurrentlyActive,
} from "./types";
import { usePopup } from "@/components/admin/connectors/Popup";
import { mutate } from "swr";
import { buildCCPairInfoUrl } from "./lib";
import { setCCPairStatus } from "@/lib/ccPair";
import { useState } from "react";
import { LoadingAnimation } from "@/components/Loading";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
export function ModifyStatusButtonCluster({
ccPair,
}: {
ccPair: CCPairFullInfo;
}) {
const { popup, setPopup } = usePopup();
const [isUpdating, setIsUpdating] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const handleStatusChange = async (
newStatus: ConnectorCredentialPairStatus
) => {
if (isUpdating) return; // Prevent double-clicks or multiple requests
if (
ccPair.status === ConnectorCredentialPairStatus.INVALID &&
newStatus === ConnectorCredentialPairStatus.ACTIVE
) {
setShowConfirmModal(true);
} else {
await updateStatus(newStatus);
}
};
const updateStatus = async (newStatus: ConnectorCredentialPairStatus) => {
setIsUpdating(true);
try {
// Call the backend to update the status
await setCCPairStatus(ccPair.id, newStatus, setPopup);
// Use mutate to revalidate the status on the backend
await mutate(buildCCPairInfoUrl(ccPair.id));
} catch (error) {
console.error("Failed to update status", error);
} finally {
// Reset local updating state and button text after mutation
setIsUpdating(false);
}
};
// Compute the button text based on current state and backend status
const isNotActive = statusIsNotCurrentlyActive(ccPair.status);
const buttonText = isNotActive ? "Re-Enable" : "Pause";
const tooltip = isNotActive
? "Click to start indexing again!"
: "When paused, the connector's documents will still be visible. However, no new documents will be indexed.";
return (
<>
{popup}
<Button
className="flex items-center justify-center w-auto min-w-[100px] px-4 py-2"
variant={isNotActive ? "success-reverse" : "default"}
disabled={isUpdating}
onClick={() =>
handleStatusChange(
isNotActive
? ConnectorCredentialPairStatus.ACTIVE
: ConnectorCredentialPairStatus.PAUSED
)
}
tooltip={tooltip}
>
{isUpdating ? (
<LoadingAnimation
text={isNotActive ? "Resuming" : "Pausing"}
size="text-md"
/>
) : (
buttonText
)}
</Button>
{showConfirmModal && (
<ConfirmEntityModal
entityType="Invalid Connector"
entityName={ccPair.name}
onClose={() => setShowConfirmModal(false)}
onSubmit={() => {
setShowConfirmModal(false);
updateStatus(ConnectorCredentialPairStatus.ACTIVE);
}}
additionalDetails="This connector was previously marked as invalid. Please verify that your configuration is correct before re-enabling. Are you sure you want to proceed?"
actionButtonText="Re-Enable"
variant="action"
/>
)}
</>
);
}

View File

@ -1,135 +0,0 @@
"use client";
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,
credentialId,
ccPairId,
setPopup,
hide,
}: {
connectorId: number;
credentialId: number;
ccPairId: number;
setPopup: (popupSpec: PopupSpec | null) => void;
hide: () => void;
}) {
return (
<Modal title="Run Indexing" onOutsideClick={hide}>
<div>
<Button
variant="submit"
className="ml-auto"
onClick={() => {
triggerIndexing(
false,
connectorId,
credentialId,
ccPairId,
setPopup
);
hide();
}}
>
Run Update
</Button>
<Text className="mt-2">
This will pull in and index all documents that have changed and/or
have been added since the last successful indexing run.
</Text>
<Separator />
<Button
variant="submit"
className="ml-auto"
onClick={() => {
triggerIndexing(
true,
connectorId,
credentialId,
ccPairId,
setPopup
);
hide();
}}
>
Run Complete Re-Indexing
</Button>
<Text className="mt-2">
This will cause a complete re-indexing of all documents from the
source.
</Text>
<Text className="mt-2">
<b>NOTE:</b> depending on the number of documents stored in the
source, this may take a long time.
</Text>
</div>
</Modal>
);
}
export function ReIndexButton({
ccPairId,
connectorId,
credentialId,
isIndexing,
isDisabled,
ccPairStatus,
}: {
ccPairId: number;
connectorId: number;
credentialId: number;
isIndexing: boolean;
isDisabled: boolean;
ccPairStatus: ConnectorCredentialPairStatus;
}) {
const { popup, setPopup } = usePopup();
const [reIndexPopupVisible, setReIndexPopupVisible] = useState(false);
return (
<>
{reIndexPopupVisible && (
<ReIndexPopup
connectorId={connectorId}
credentialId={credentialId}
ccPairId={ccPairId}
setPopup={setPopup}
hide={() => setReIndexPopupVisible(false)}
/>
)}
{popup}
<Button
variant="success-reverse"
className="ml-auto min-w-[100px]"
onClick={() => {
setReIndexPopupVisible(true);
}}
disabled={
isDisabled ||
ccPairStatus == ConnectorCredentialPairStatus.DELETING ||
ccPairStatus == ConnectorCredentialPairStatus.PAUSED ||
ccPairStatus == ConnectorCredentialPairStatus.INVALID
}
tooltip={getCCPairStatusMessage(isDisabled, isIndexing, ccPairStatus)}
>
Re-Index
</Button>
</>
);
}

View File

@ -0,0 +1,162 @@
"use client";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { usePopup, PopupSpec } from "@/components/admin/connectors/Popup";
import { triggerIndexing } from "./lib";
import { Modal } from "@/components/Modal";
import Text from "@/components/ui/text";
import { Separator } from "@/components/ui/separator";
// Hook to handle re-indexing functionality
export function useReIndexModal(
connectorId: number | null,
credentialId: number | null,
ccPairId: number | null,
setPopup: (popupSpec: PopupSpec | null) => void
) {
const [reIndexPopupVisible, setReIndexPopupVisible] = useState(false);
const showReIndexModal = () => {
if (!connectorId || !credentialId || !ccPairId) {
return;
}
setReIndexPopupVisible(true);
};
const hideReIndexModal = () => {
setReIndexPopupVisible(false);
};
const triggerReIndex = async (fromBeginning: boolean) => {
if (!connectorId || !credentialId || !ccPairId) {
return;
}
try {
const result = await triggerIndexing(
fromBeginning,
connectorId,
credentialId,
ccPairId,
setPopup
);
// Show appropriate notification based on result
if (result.success) {
setPopup({
message: `${fromBeginning ? "Complete re-indexing" : "Indexing update"} started successfully`,
type: "success",
});
} else {
setPopup({
message: result.message || "Failed to start indexing",
type: "error",
});
}
} catch (error) {
console.error("Failed to trigger indexing:", error);
setPopup({
message: "An unexpected error occurred while trying to start indexing",
type: "error",
});
}
};
const FinalReIndexModal =
reIndexPopupVisible && connectorId && credentialId && ccPairId ? (
<ReIndexModal
setPopup={setPopup}
hide={hideReIndexModal}
onRunIndex={triggerReIndex}
/>
) : null;
return {
showReIndexModal,
ReIndexModal: FinalReIndexModal,
};
}
interface ReIndexModalProps {
setPopup: (popupSpec: PopupSpec | null) => void;
hide: () => void;
onRunIndex: (fromBeginning: boolean) => Promise<void>;
}
export default function ReIndexModal({
setPopup,
hide,
onRunIndex,
}: ReIndexModalProps) {
const [isProcessing, setIsProcessing] = useState(false);
const handleRunIndex = async (fromBeginning: boolean) => {
if (isProcessing) return;
setIsProcessing(true);
try {
// First show immediate feedback with a popup
setPopup({
message: `Starting ${fromBeginning ? "complete re-indexing" : "indexing update"}...`,
type: "info",
});
// Then close the modal
hide();
// Then run the indexing operation
await onRunIndex(fromBeginning);
} catch (error) {
console.error("Error starting indexing:", error);
// Show error in popup if needed
setPopup({
message: "Failed to start indexing process",
type: "error",
});
} finally {
setIsProcessing(false);
}
};
return (
<Modal title="Run Indexing" onOutsideClick={hide}>
<div>
<Button
variant="submit"
className="ml-auto"
onClick={() => handleRunIndex(false)}
disabled={isProcessing}
>
Run Update
</Button>
<Text className="mt-2">
This will pull in and index all documents that have changed and/or
have been added since the last successful indexing run.
</Text>
<Separator />
<Button
variant="submit"
className="ml-auto"
onClick={() => handleRunIndex(true)}
disabled={isProcessing}
>
Run Complete Re-Indexing
</Button>
<Text className="mt-2">
This will cause a complete re-indexing of all documents from the
source.
</Text>
<Text className="mt-2">
<b>NOTE:</b> depending on the number of documents stored in the
source, this may take a long time.
</Text>
</div>
</Modal>
);
}

View File

@ -0,0 +1,90 @@
"use client";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { Button } from "@/components/ui/button";
import Text from "@/components/ui/text";
import { Modal } from "@/components/Modal";
import { Separator } from "@/components/ui/separator";
import { useState } from "react";
interface ReIndexPopupProps {
connectorId: number;
credentialId: number;
ccPairId: number;
setPopup: (popupSpec: PopupSpec | null) => void;
hide: () => void;
onRunIndex: (fromBeginning: boolean) => Promise<void>;
}
export default function ReIndexPopup({
connectorId,
credentialId,
ccPairId,
setPopup,
hide,
onRunIndex,
}: ReIndexPopupProps) {
const [isProcessing, setIsProcessing] = useState(false);
const handleRunIndex = async (fromBeginning: boolean) => {
if (isProcessing) return;
setIsProcessing(true);
try {
// First close the modal to give immediate feedback
hide();
// Then run the indexing operation
await onRunIndex(fromBeginning);
} catch (error) {
console.error("Error starting indexing:", error);
// Show error in popup if needed
setPopup({
message: "Failed to start indexing process",
type: "error",
});
} finally {
setIsProcessing(false);
}
};
return (
<Modal title="Run Indexing" onOutsideClick={hide}>
<div>
<Button
variant="submit"
className="ml-auto"
onClick={() => handleRunIndex(false)}
disabled={isProcessing}
>
Run Update
</Button>
<Text className="mt-2">
This will pull in and index all documents that have changed and/or
have been added since the last successful indexing run.
</Text>
<Separator />
<Button
variant="submit"
className="ml-auto"
onClick={() => handleRunIndex(true)}
disabled={isProcessing}
>
Run Complete Re-Indexing
</Button>
<Text className="mt-2">
This will cause a complete re-indexing of all documents from the
source.
</Text>
<Text className="mt-2">
<b>NOTE:</b> depending on the number of documents stored in the
source, this may take a long time.
</Text>
</div>
</Modal>
);
}

View File

@ -21,24 +21,26 @@ export async function triggerIndexing(
credentialId: number,
ccPairId: number,
setPopup: (popupSpec: PopupSpec | null) => void
) {
): Promise<{ success: boolean; message: string }> {
const errorMsg = await runConnector(
connectorId,
[credentialId],
fromBeginning
);
if (errorMsg) {
setPopup({
message: errorMsg,
type: "error",
});
} else {
setPopup({
message: "Triggered connector run",
type: "success",
});
}
mutate(buildCCPairInfoUrl(ccPairId));
if (errorMsg) {
return {
success: false,
message: errorMsg,
};
} else {
return {
success: true,
message: "Triggered connector run",
};
}
}
export function getTooltipMessage(

View File

@ -13,37 +13,53 @@ import {
} from "@/lib/connector";
import { credentialTemplates } from "@/lib/connectors/credentials";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { ValidSources } from "@/lib/types";
import Title from "@/components/ui/title";
import { Separator } from "@/components/ui/separator";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState, use } from "react";
import useSWR, { mutate } from "swr";
import { AdvancedConfigDisplay, ConfigDisplay } from "./ConfigDisplay";
import { DeletionButton } from "./DeletionButton";
import DeletionErrorStatus from "./DeletionErrorStatus";
import { IndexingAttemptsTable } from "./IndexingAttemptsTable";
import { ModifyStatusButtonCluster } from "./ModifyStatusButtonCluster";
import { ReIndexButton } from "./ReIndexButton";
import { buildCCPairInfoUrl, triggerIndexing } from "./lib";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
CCPairFullInfo,
ConnectorCredentialPairStatus,
IndexAttemptError,
PaginatedIndexAttemptErrors,
statusIsNotCurrentlyActive,
} from "./types";
import { EditableStringFieldDisplay } from "@/components/EditableStringFieldDisplay";
import { Button } from "@/components/ui/button";
import EditPropertyModal from "@/components/modals/EditPropertyModal";
import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle";
import { deleteCCPair } from "@/lib/documentDeletion";
import * as Yup from "yup";
import { AlertCircle } from "lucide-react";
import {
AlertCircle,
PlayIcon,
PauseIcon,
Trash2Icon,
RefreshCwIcon,
AlertTriangle,
} from "lucide-react";
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";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DropdownMenuItemWithTooltip } from "@/components/ui/dropdown-menu-with-tooltip";
import { FiSettings } from "react-icons/fi";
import { timeAgo } from "@/lib/time";
import { useStatusChange } from "./useStatusChange";
import { useReIndexModal } from "./ReIndexModal";
import { Button } from "@/components/ui/button";
// synchronize these validations with the SQLAlchemy connector class until we have a
// centralized schema for both frontend and backend
@ -68,6 +84,8 @@ const PAGES_PER_BATCH = 8;
function Main({ ccPairId }: { ccPairId: number }) {
const router = useRouter();
const { popup, setPopup } = usePopup();
const {
data: ccPair,
isLoading: isLoadingCCPair,
@ -90,6 +108,17 @@ function Main({ ccPairId }: { ccPairId: number }) {
endpoint: `${buildCCPairInfoUrl(ccPairId)}/index-attempts`,
});
// need to always have the most recent index attempts around
// so just kick off a separate fetch
const {
currentPageData: mostRecentIndexAttempts,
isLoading: isLoadingMostRecentIndexAttempts,
} = usePaginatedFetch<IndexAttemptSnapshot>({
itemsPerPage: ITEMS_PER_PAGE,
pagesPerBatch: 1,
endpoint: `${buildCCPairInfoUrl(ccPairId)}/index-attempts`,
});
const {
currentPageData: indexAttemptErrorsPage,
currentPage: errorsCurrentPage,
@ -101,6 +130,20 @@ function Main({ ccPairId }: { ccPairId: number }) {
endpoint: `/api/manage/admin/cc-pair/${ccPairId}/errors`,
});
// Initialize hooks at top level to avoid conditional hook calls
const { showReIndexModal, ReIndexModal } = useReIndexModal(
ccPair?.connector?.id || null,
ccPair?.credential?.id || null,
ccPairId,
setPopup
);
const {
handleStatusChange,
isUpdating: isStatusUpdating,
ConfirmModal,
} = useStatusChange(ccPair || null);
const indexAttemptErrors = indexAttemptErrorsPage
? {
items: indexAttemptErrorsPage,
@ -118,7 +161,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
const [showIndexAttemptErrors, setShowIndexAttemptErrors] = useState(false);
const [showIsResolvingKickoffLoader, setShowIsResolvingKickoffLoader] =
useState(false);
const { popup, setPopup } = usePopup();
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const latestIndexAttempt = indexAttempts?.[0];
const isResolvingErrors =
@ -134,6 +177,52 @@ function Main({ ccPairId }: { ccPairId: number }) {
router.push("/admin/indexing/status?message=connector-deleted");
}, [router]);
const handleStatusUpdate = async (
newStatus: ConnectorCredentialPairStatus
) => {
setShowIsResolvingKickoffLoader(true); // Show fullscreen spinner
await handleStatusChange(newStatus);
setShowIsResolvingKickoffLoader(false); // Hide fullscreen spinner
};
const triggerReIndex = async (fromBeginning: boolean) => {
if (!ccPair) return;
setShowIsResolvingKickoffLoader(true);
try {
const result = await triggerIndexing(
fromBeginning,
ccPair.connector.id,
ccPair.credential.id,
ccPair.id,
setPopup
);
if (result.success) {
setPopup({
message: `${
fromBeginning ? "Complete re-indexing" : "Indexing update"
} started successfully`,
type: "success",
});
} else {
setPopup({
message: result.message || "Failed to start indexing",
type: "error",
});
}
} catch (error) {
console.error("Failed to trigger indexing:", error);
setPopup({
message: "An unexpected error occurred while trying to start indexing",
type: "error",
});
} finally {
setShowIsResolvingKickoffLoader(false);
}
};
useEffect(() => {
if (isLoadingCCPair) {
return;
@ -259,7 +348,11 @@ function Main({ ccPairId }: { ccPairId: number }) {
}
};
if (isLoadingCCPair || isLoadingIndexAttempts) {
if (
isLoadingCCPair ||
isLoadingIndexAttempts ||
isLoadingMostRecentIndexAttempts
) {
return <ThreeDotsLoader />;
}
@ -292,6 +385,8 @@ function Main({ ccPairId }: { ccPairId: number }) {
<>
{popup}
{showIsResolvingKickoffLoader && !isResolvingErrors && <Spinner />}
{ReIndexModal}
{ConfirmModal}
{editingRefreshFrequency && (
<EditPropertyModal
@ -324,18 +419,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
onResolveAll={async () => {
setShowIndexAttemptErrors(false);
setShowIsResolvingKickoffLoader(true);
await triggerIndexing(
true,
ccPair.connector.id,
ccPair.credential.id,
ccPair.id,
setPopup
);
// show the loader for a max of 10 seconds
setTimeout(() => {
setShowIsResolvingKickoffLoader(false);
}, 10000);
await triggerReIndex(true);
}}
isResolvingErrors={isResolvingErrors}
onPageChange={goToErrorsPage}
@ -346,12 +430,21 @@ function Main({ ccPairId }: { ccPairId: number }) {
<BackButton
behaviorOverride={() => router.push("/admin/indexing/status")}
/>
<div className="flex items-center justify-between h-14">
<div
className="flex
items-center
justify-between
h-16
pb-2
border-b
border-neutral-200
dark:border-neutral-600"
>
<div className="my-auto">
<SourceIcon iconSize={32} sourceType={ccPair.connector.source} />
</div>
<div className="ml-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1 mr-4">
<div className="ml-2 overflow-hidden text-ellipsis whitespace-nowrap flex-1 mr-4">
<EditableStringFieldDisplay
value={ccPair.name}
isEditable={ccPair.is_editable_for_current_user}
@ -360,45 +453,109 @@ function Main({ ccPairId }: { ccPairId: number }) {
/>
</div>
{ccPair.is_editable_for_current_user && (
<div className="ml-auto flex gap-x-2">
<ReIndexButton
ccPairId={ccPair.id}
ccPairStatus={ccPair.status}
connectorId={ccPair.connector.id}
credentialId={ccPair.credential.id}
isDisabled={
ccPair.indexing ||
ccPair.status === ConnectorCredentialPairStatus.PAUSED
}
isIndexing={ccPair.indexing}
/>
{!isDeleting && <ModifyStatusButtonCluster ccPair={ccPair} />}
</div>
)}
</div>
<CCPairStatus
status={ccPair.last_index_attempt_status || "not_started"}
ccPairStatus={ccPair.status}
/>
<div className="text-sm mt-1">
Creator:{" "}
<b className="text-emphasis">{ccPair.creator_email ?? "Unknown"}</b>
</div>
<div className="text-sm mt-1">
Total Documents Indexed:{" "}
<b className="text-emphasis">{ccPair.num_docs_indexed}</b>
</div>
{!ccPair.is_editable_for_current_user && (
<div className="text-sm mt-2 text-text-500 italic">
{ccPair.access_type === "public"
? "Public connectors are not editable by curators."
: ccPair.access_type === "sync"
? "Sync connectors are not editable by curators unless the curator is also the owner."
: "This connector belongs to groups where you don't have curator permissions, so it's not editable."}
<div className="ml-auto flex gap-x-2">
{ccPair.is_editable_for_current_user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-x-1"
>
<FiSettings className="h-4 w-4" />
<span className="text-sm ml-1">Manage</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItemWithTooltip
onClick={() => {
if (
!ccPair.indexing &&
ccPair.status !== ConnectorCredentialPairStatus.PAUSED &&
ccPair.status !== ConnectorCredentialPairStatus.INVALID
) {
showReIndexModal();
}
}}
disabled={
ccPair.indexing ||
ccPair.status === ConnectorCredentialPairStatus.PAUSED ||
ccPair.status === ConnectorCredentialPairStatus.INVALID
}
className="flex items-center gap-x-2 cursor-pointer px-3 py-2"
tooltip={
ccPair.indexing
? "Cannot re-index while indexing is already in progress"
: ccPair.status === ConnectorCredentialPairStatus.PAUSED
? "Resume the connector before re-indexing"
: ccPair.status ===
ConnectorCredentialPairStatus.INVALID
? "Fix the connector configuration before re-indexing"
: undefined
}
>
<RefreshCwIcon className="h-4 w-4" />
<span>Re-Index</span>
</DropdownMenuItemWithTooltip>
{!isDeleting && (
<DropdownMenuItemWithTooltip
onClick={() =>
handleStatusUpdate(
statusIsNotCurrentlyActive(ccPair.status)
? ConnectorCredentialPairStatus.ACTIVE
: ConnectorCredentialPairStatus.PAUSED
)
}
disabled={isStatusUpdating}
className="flex items-center gap-x-2 cursor-pointer px-3 py-2"
tooltip={
isStatusUpdating ? "Status update in progress" : undefined
}
>
{statusIsNotCurrentlyActive(ccPair.status) ? (
<PlayIcon className="h-4 w-4" />
) : (
<PauseIcon className="h-4 w-4" />
)}
<span>
{statusIsNotCurrentlyActive(ccPair.status)
? "Resume"
: "Pause"}
</span>
</DropdownMenuItemWithTooltip>
)}
{!isDeleting && (
<DropdownMenuItemWithTooltip
onClick={async () => {
try {
await deleteCCPair(
ccPair.connector.id,
ccPair.credential.id,
setPopup,
() => mutate(buildCCPairInfoUrl(ccPair.id))
);
refresh();
} catch (error) {
console.error("Error deleting connector:", error);
}
}}
disabled={!statusIsNotCurrentlyActive(ccPair.status)}
className="flex items-center gap-x-2 cursor-pointer px-3 py-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
tooltip={
!statusIsNotCurrentlyActive(ccPair.status)
? "Pause the connector before deleting"
: undefined
}
>
<Trash2Icon className="h-4 w-4" />
<span>Delete</span>
</DropdownMenuItemWithTooltip>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</div>
{ccPair.deletion_failure_message &&
ccPair.status === ConnectorCredentialPairStatus.DELETING && (
@ -410,23 +567,8 @@ function Main({ ccPairId }: { ccPairId: number }) {
</>
)}
{credentialTemplates[ccPair.connector.source] &&
ccPair.is_editable_for_current_user && (
<>
<Separator />
<Title className="mb-2">Credentials</Title>
<CredentialSection
ccPair={ccPair}
sourceType={ccPair.connector.source}
refresh={() => refresh()}
/>
</>
)}
{ccPair.status === ConnectorCredentialPairStatus.INVALID && (
<div className="mt-2">
<div className="mt-6">
<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.
@ -434,70 +576,158 @@ function Main({ ccPairId }: { ccPairId: number }) {
</div>
)}
<Separator />
<ConfigDisplay
connectorSpecificConfig={ccPair.connector.connector_specific_config}
sourceType={ccPair.connector.source}
/>
{(pruneFreq || indexingStart || refreshFreq) && (
<AdvancedConfigDisplay
pruneFreq={pruneFreq}
indexingStart={indexingStart}
refreshFreq={refreshFreq}
onRefreshEdit={handleRefreshEdit}
onPruningEdit={handlePruningEdit}
/>
{indexAttemptErrors && indexAttemptErrors.total_items > 0 && (
<Alert className="border-alert bg-yellow-50 dark:bg-yellow-800 my-2 mt-6">
<AlertCircle className="h-4 w-4 text-yellow-700 dark:text-yellow-500" />
<AlertTitle className="text-yellow-950 dark:text-yellow-200 font-semibold">
Some documents failed to index
</AlertTitle>
<AlertDescription className="text-yellow-900 dark:text-yellow-300">
{isResolvingErrors ? (
<span>
<span className="text-sm text-yellow-700 dark:text-yellow-400 da animate-pulse">
Resolving failures
</span>
</span>
) : (
<>
We ran into some issues while processing some documents.{" "}
<b
className="text-link cursor-pointer dark:text-blue-300"
onClick={() => setShowIndexAttemptErrors(true)}
>
View details.
</b>
</>
)}
</AlertDescription>
</Alert>
)}
<Title className="mb-2 mt-6" size="md">
Indexing
</Title>
<Card className="px-8 py-12">
<div className="flex">
<div className="w-[200px]">
<div className="text-sm font-medium mb-1">Status</div>
<CCPairStatus
ccPairStatus={ccPair.status}
inRepeatedErrorState={ccPair.in_repeated_error_state}
lastIndexAttemptStatus={latestIndexAttempt?.status}
/>
</div>
<div className="w-[200px]">
<div className="text-sm font-medium mb-1">Documents Indexed</div>
<div className="text-sm text-text-default flex items-center gap-x-1">
{ccPair.num_docs_indexed.toLocaleString()}
{ccPair.status ===
ConnectorCredentialPairStatus.INITIAL_INDEXING &&
ccPair.overall_indexing_speed !== null &&
ccPair.num_docs_indexed > 0 && (
<div className="ml-0.5 text-xs font-medium">
({ccPair.overall_indexing_speed.toFixed(1)} docs / min)
</div>
)}
</div>
</div>
<div className="w-[200px]">
<div className="text-sm font-medium mb-1">Last Indexed</div>
<div className="text-sm text-text-default">
{timeAgo(
indexAttempts?.find((attempt) => attempt.status === "success")
?.time_started
) ?? "-"}
</div>
</div>
{ccPair.access_type === "sync" && (
<div className="w-[200px]">
<div className="text-sm font-medium mb-1">
Last Permission Synced
</div>
<div className="text-sm text-text-default">
{timeAgo(ccPair.last_permission_sync) ?? "-"}
</div>
</div>
)}
</div>
</Card>
{credentialTemplates[ccPair.connector.source] &&
ccPair.is_editable_for_current_user && (
<>
<Title size="md" className="mt-10 mb-2">
Credential
</Title>
<div className="mt-2">
<CredentialSection
ccPair={ccPair}
sourceType={ccPair.connector.source}
refresh={() => refresh()}
/>
</div>
</>
)}
<Title size="md" className="mt-10 mb-2">
Connector Configuration
</Title>
<Card className="px-8 py-4">
<ConfigDisplay
connectorSpecificConfig={ccPair.connector.connector_specific_config}
sourceType={ccPair.connector.source}
/>
</Card>
<div className="mt-6">
<div className="flex">
<Title>Indexing Attempts</Title>
</div>
{indexAttemptErrors && indexAttemptErrors.total_items > 0 && (
<Alert className="border-alert bg-yellow-50 dark:bg-yellow-800 my-2">
<AlertCircle className="h-4 w-4 text-yellow-700 dark:text-yellow-500" />
<AlertTitle className="text-yellow-950 dark:text-yellow-200 font-semibold">
Some documents failed to index
</AlertTitle>
<AlertDescription className="text-yellow-900 dark:text-yellow-300">
{isResolvingErrors ? (
<span>
<span className="text-sm text-yellow-700 dark:text-yellow-400 da animate-pulse">
Resolving failures
</span>
</span>
) : (
<>
We ran into some issues while processing some documents.{" "}
<b
className="text-link cursor-pointer dark:text-blue-300"
onClick={() => setShowIndexAttemptErrors(true)}
>
View details.
</b>
</>
)}
</AlertDescription>
</Alert>
)}
{indexAttempts && (
<IndexingAttemptsTable
ccPair={ccPair}
indexAttempts={indexAttempts}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={goToPage}
<AdvancedOptionsToggle
showAdvancedOptions={showAdvancedOptions}
setShowAdvancedOptions={setShowAdvancedOptions}
title="Advanced"
/>
)}
</div>
<Separator />
<div className="flex mt-4">
<div className="mx-auto">
{ccPair.is_editable_for_current_user && (
<DeletionButton ccPair={ccPair} refresh={refresh} />
)}
</div>
{showAdvancedOptions && (
<div className="pb-16">
{(pruneFreq || indexingStart || refreshFreq) && (
<>
<Title size="md" className="mt-3 mb-2">
Advanced Configuration
</Title>
<Card className="px-8 py-4">
<div>
<AdvancedConfigDisplay
pruneFreq={pruneFreq}
indexingStart={indexingStart}
refreshFreq={refreshFreq}
onRefreshEdit={handleRefreshEdit}
onPruningEdit={handlePruningEdit}
/>
</div>
</Card>
</>
)}
<Title size="md" className="mt-6 mb-2">
Indexing Attempts
</Title>
{indexAttempts && (
<IndexingAttemptsTable
ccPair={ccPair}
indexAttempts={indexAttempts}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={goToPage}
/>
)}
</div>
)}
</div>
</>
);
@ -508,7 +738,7 @@ export default function Page(props: { params: Promise<{ ccPairId: string }> }) {
const ccPairId = parseInt(params.ccPairId);
return (
<div className="mx-auto container">
<div className="mx-auto w-[800px]">
<Main ccPairId={ccPairId} />
</div>
);

View File

@ -9,6 +9,8 @@ import {
import { UUID } from "crypto";
export enum ConnectorCredentialPairStatus {
SCHEDULED = "SCHEDULED",
INITIAL_INDEXING = "INITIAL_INDEXING",
ACTIVE = "ACTIVE",
PAUSED = "PAUSED",
DELETING = "DELETING",
@ -31,6 +33,7 @@ export interface CCPairFullInfo {
id: number;
name: string;
status: ConnectorCredentialPairStatus;
in_repeated_error_state: boolean;
num_docs_indexed: number;
connector: Connector<any>;
credential: Credential<any>;
@ -43,6 +46,12 @@ export interface CCPairFullInfo {
indexing: boolean;
creator: UUID | null;
creator_email: string | null;
last_indexed: string | null;
last_pruned: string | null;
last_permission_sync: string | null;
overall_indexing_speed: number | null;
latest_checkpoint_description: string | null;
}
export interface PaginatedIndexAttempts {

View File

@ -0,0 +1,75 @@
"use client";
import { CCPairFullInfo, ConnectorCredentialPairStatus } from "./types";
import { usePopup } from "@/components/admin/connectors/Popup";
import { mutate } from "swr";
import { buildCCPairInfoUrl } from "./lib";
import { setCCPairStatus } from "@/lib/ccPair";
import { useState } from "react";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
// Export the status change functionality separately
export function useStatusChange(ccPair: CCPairFullInfo | null) {
const { setPopup } = usePopup();
const [isUpdating, setIsUpdating] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const updateStatus = async (newStatus: ConnectorCredentialPairStatus) => {
if (!ccPair) return false;
setIsUpdating(true);
try {
// Call the backend to update the status
await setCCPairStatus(ccPair.id, newStatus, setPopup);
// Use mutate to revalidate the status on the backend
await mutate(buildCCPairInfoUrl(ccPair.id));
} catch (error) {
console.error("Failed to update status", error);
} finally {
// Reset local updating state and button text after mutation
setIsUpdating(false);
}
return true;
};
const handleStatusChange = async (
newStatus: ConnectorCredentialPairStatus
) => {
if (isUpdating || !ccPair) return false; // Prevent double-clicks or multiple requests
if (
ccPair.status === ConnectorCredentialPairStatus.INVALID &&
newStatus === ConnectorCredentialPairStatus.ACTIVE
) {
setShowConfirmModal(true);
return false;
} else {
return await updateStatus(newStatus);
}
};
const ConfirmModal =
showConfirmModal && ccPair ? (
<ConfirmEntityModal
entityType="Invalid Connector"
entityName={ccPair.name}
onClose={() => setShowConfirmModal(false)}
onSubmit={() => {
setShowConfirmModal(false);
updateStatus(ConnectorCredentialPairStatus.ACTIVE);
}}
additionalDetails="This connector was previously marked as invalid. Please verify that your configuration is correct before re-enabling. Are you sure you want to proceed?"
actionButtonText="Re-Enable"
variant="action"
/>
) : null;
return {
handleStatusChange,
isUpdating,
ConfirmModal,
};
}

View File

@ -9,7 +9,7 @@ import {
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { IndexAttemptStatus } from "@/components/Status";
import { CCPairStatus, IndexAttemptStatus } from "@/components/Status";
import { timeAgo } from "@/lib/time";
import {
ConnectorIndexingStatus,
@ -114,17 +114,6 @@ function SummaryRow({
</div>
</TableCell>
<TableCell>
<div className="text-sm text-neutral-500 dark:text-neutral-300">
Errors
</div>
<div className="flex items-center text-lg gap-x-1 font-semibold">
{summary.errors > 0 && <Warning className="text-error h-6 w-6" />}
{summary.errors}
</div>
</TableCell>
<TableCell />
</TableRow>
);
@ -147,59 +136,6 @@ function ConnectorRow({
router.push(`/admin/connector/${ccPairsIndexingStatus.cc_pair_id}`);
};
const getActivityBadge = () => {
if (
ccPairsIndexingStatus.cc_pair_status ===
ConnectorCredentialPairStatus.DELETING
) {
return <Badge variant="destructive">Deleting</Badge>;
} else if (
ccPairsIndexingStatus.cc_pair_status ===
ConnectorCredentialPairStatus.PAUSED
) {
return (
<Badge icon={FiPauseCircle} variant="paused">
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
switch (ccPairsIndexingStatus.last_status) {
case "in_progress":
return (
<Badge circle variant="success">
Indexing
</Badge>
);
case "not_started":
return (
<Badge circle variant="not_started">
Scheduled
</Badge>
);
default:
return (
<Badge circle variant="success">
Active
</Badge>
);
}
};
return (
<TableRow
className={`
@ -221,20 +157,28 @@ border border-border dark:border-neutral-700
<TableCell>
{timeAgo(ccPairsIndexingStatus?.last_success) || "-"}
</TableCell>
<TableCell>{getActivityBadge()}</TableCell>
<TableCell>
<CCPairStatus
ccPairStatus={ccPairsIndexingStatus.cc_pair_status}
inRepeatedErrorState={ccPairsIndexingStatus.in_repeated_error_state}
lastIndexAttemptStatus={
ccPairsIndexingStatus.latest_index_attempt?.status
}
/>
</TableCell>
{isPaidEnterpriseFeaturesEnabled && (
<TableCell>
{ccPairsIndexingStatus.access_type === "public" ? (
<Badge variant={isEditable ? "success" : "default"} icon={FiUnlock}>
Public
Organization Public
</Badge>
) : ccPairsIndexingStatus.access_type === "sync" ? (
<Badge
variant={isEditable ? "auto-sync" : "default"}
icon={FiRefreshCw}
>
Auto-Sync
Inherited from{" "}
{getSourceDisplayName(ccPairsIndexingStatus.connector.source)}
</Badge>
) : (
<Badge variant={isEditable ? "private" : "default"} icon={FiLock}>
@ -244,12 +188,6 @@ border border-border dark:border-neutral-700
</TableCell>
)}
<TableCell>{ccPairsIndexingStatus.docs_indexed}</TableCell>
<TableCell>
<IndexAttemptStatus
status={ccPairsIndexingStatus.last_finished_status || null}
errorMsg={ccPairsIndexingStatus?.latest_index_attempt?.error_msg}
/>
</TableCell>
<TableCell>
{isEditable && (
<TooltipProvider>
@ -564,6 +502,7 @@ export function CCPairIndexingStatusTable({
name: "Sample Credential",
source: ValidSources.File,
user_id: "1",
user_email: "sample@example.com",
time_created: "2023-07-01T12:00:00Z",
time_updated: "2023-07-01T12:00:00Z",
credential_json: {},
@ -575,6 +514,7 @@ export function CCPairIndexingStatusTable({
last_finished_status: "success",
latest_index_attempt: null,
groups: [], // Add this line
in_repeated_error_state: false,
}}
isEditable={false}
/>
@ -691,12 +631,11 @@ export function CCPairIndexingStatusTable({
<TableRow className="border border-border dark:border-neutral-700">
<TableHead>Name</TableHead>
<TableHead>Last Indexed</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Status</TableHead>
{isPaidEnterpriseFeaturesEnabled && (
<TableHead>Permissions</TableHead>
)}
<TableHead>Total Docs</TableHead>
<TableHead>Last Status</TableHead>
<TableHead></TableHead>
</TableRow>
{(sourceMatches ? statuses : matchingConnectors).map(

View File

@ -95,12 +95,14 @@ export function IndexAttemptStatus({
}
export function CCPairStatus({
status,
ccPairStatus,
inRepeatedErrorState,
lastIndexAttemptStatus,
size = "md",
}: {
status: ValidStatuses;
ccPairStatus: ConnectorCredentialPairStatus;
inRepeatedErrorState: boolean;
lastIndexAttemptStatus: ValidStatuses | undefined | null;
size?: "xs" | "sm" | "md" | "lg";
}) {
let badge;
@ -117,24 +119,48 @@ export function CCPairStatus({
Paused
</Badge>
);
} else if (ccPairStatus == ConnectorCredentialPairStatus.INVALID) {
badge = (
<Badge variant="invalid" icon={FiAlertTriangle}>
Invalid
</Badge>
);
} else if (status === "failed") {
} else if (inRepeatedErrorState) {
badge = (
<Badge variant="destructive" icon={FiAlertTriangle}>
Error
</Badge>
);
} else {
} else if (ccPairStatus == ConnectorCredentialPairStatus.SCHEDULED) {
badge = (
<Badge variant="success" icon={FiCheckCircle}>
Active
<Badge variant="not_started" icon={FiClock}>
Scheduled
</Badge>
);
} else if (ccPairStatus == ConnectorCredentialPairStatus.INITIAL_INDEXING) {
badge = (
<Badge variant="in_progress" icon={FiClock}>
Initial Indexing
</Badge>
);
} else if (ccPairStatus == ConnectorCredentialPairStatus.INVALID) {
badge = (
<Badge
tooltip="Connector is in an invalid state. Please update the credentials or create a new connector."
circle
variant="invalid"
>
Invalid
</Badge>
);
} else {
if (lastIndexAttemptStatus && lastIndexAttemptStatus === "in_progress") {
badge = (
<Badge variant="in_progress" icon={FiClock}>
Indexing
</Badge>
);
} else {
badge = (
<Badge variant="success" icon={FiCheckCircle}>
Indexed
</Badge>
);
}
}
return <div>{badge}</div>;

View File

@ -3,8 +3,9 @@
import { ValidSources } from "@/lib/types";
import useSWR, { mutate } from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { FaSwatchbook } from "react-icons/fa";
import { FaKey } from "react-icons/fa";
import { useState } from "react";
import { FiEdit2 } from "react-icons/fi";
import {
deleteCredential,
swapCredential,
@ -32,6 +33,7 @@ import {
} from "@/lib/connectors/oauth";
import { Spinner } from "@/components/Spinner";
import { CreateStdOAuthCredential } from "@/components/credentials/actions/CreateStdOAuthCredential";
import { Card } from "../ui/card";
export default function CredentialSection({
ccPair,
@ -154,26 +156,66 @@ export default function CredentialSection({
}
return (
<div className="flex justify-start flex-col gap-y-2">
<div
className="flex
flex-col
gap-y-4
rounded-lg
bg-background"
>
{popup}
<div className="flex gap-x-2">
<p>Current credential:</p>
<Text className="ml-1 italic font-bold my-auto">
{ccPair.credential.name || `Credential #${ccPair.credential.id}`}
</Text>
</div>
<div className="flex text-sm justify-start mr-auto gap-x-2">
<button
onClick={() => {
setShowModifyCredential(true);
}}
className="flex items-center gap-x-2 cursor-pointer bg-neutral-800 border-neutral-600 border-2 hover:bg-neutral-700 p-1.5 rounded-lg text-neutral-300"
>
<FaSwatchbook />
Update Credentials
</button>
</div>
<Card className="p-6">
<div className="flex items-center">
<div className="flex-shrink-0 mr-3">
<FaKey className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-grow flex flex-col justify-center">
<div className="flex items-center justify-between">
<div>
<Text className="font-medium">
{ccPair.credential.name ||
`Credential #${ccPair.credential.id}`}
</Text>
<div className="text-xs text-muted-foreground/70">
Created{" "}
<i>
{new Date(
ccPair.credential.time_created
).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</i>
{ccPair.credential.user_email && (
<>
{" "}
by <i>{ccPair.credential.user_email}</i>
</>
)}
</div>
</div>
<button
onClick={() => setShowModifyCredential(true)}
className="inline-flex
items-center
justify-center
p-2
rounded-md
text-muted-foreground
hover:bg-accent
hover:text-accent-foreground
transition-colors"
>
<FiEdit2 className="h-4 w-4" />
<span className="sr-only">Update Credentials</span>
</button>
</div>
</div>
</div>
</Card>
{showModifyCredential && (
<Modal
onOutsideClick={closeModifyCredential}

View File

@ -119,6 +119,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
asChild = false,
icon: Icon,
tooltip,
disabled,
...props
},
ref
@ -134,6 +135,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
})
)}
ref={ref}
disabled={disabled}
{...props}
>
{Icon && <Icon />}
@ -145,8 +147,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div>{button}</div>
<TooltipTrigger asChild>
<div className={disabled ? "cursor-not-allowed" : ""}>
{button}
</div>
</TooltipTrigger>
<TooltipContent showTick={true}>
<p>{tooltip}</p>

View File

@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import { DropdownMenuItem } from "./dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./tooltip";
import { cn } from "@/lib/utils";
interface DropdownMenuItemWithTooltipProps
extends React.ComponentPropsWithoutRef<typeof DropdownMenuItem> {
tooltip?: string;
}
const DropdownMenuItemWithTooltip = React.forwardRef<
React.ElementRef<typeof DropdownMenuItem>,
DropdownMenuItemWithTooltipProps
>(({ className, tooltip, disabled, ...props }, ref) => {
// Only show tooltip if the item is disabled and a tooltip is provided
if (!tooltip || !disabled) {
return (
<DropdownMenuItem
ref={ref}
className={className}
disabled={disabled}
{...props}
/>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-not-allowed">
<DropdownMenuItem
ref={ref}
className={cn(className)}
disabled={disabled}
{...props}
/>
</div>
</TooltipTrigger>
<TooltipContent showTick={true}>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
DropdownMenuItemWithTooltip.displayName = "DropdownMenuItemWithTooltip";
export { DropdownMenuItemWithTooltip };

View File

@ -23,6 +23,7 @@ export interface CredentialBase<T> {
export interface Credential<T> extends CredentialBase<T> {
id: number;
user_id: string | null;
user_email: string | null;
time_created: string;
time_updated: string;
}

View File

@ -170,6 +170,7 @@ export interface ConnectorIndexingStatus<
last_status: ValidStatuses | null;
last_finished_status: ValidStatuses | null;
cc_pair_status: ConnectorCredentialPairStatus;
in_repeated_error_state: boolean;
latest_index_attempt: IndexAttemptSnapshot | null;
docs_indexed: number;
}