mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(web): Manage Index menu, activity cards, and runs card per the indexing mock (#16172)
Co-authored-by: Nik <nikolas.garza5@gmail.com> Co-authored-by: nik <nik@onyx.app>
This commit is contained in:
37 files changed
+1193
-511
No files matched your search
@@ -0,0 +1,28 @@
|
||||
"""record the documents a prune could not remove
|
||||
|
||||
Revision ID: c7e1a2f4b9d3
|
||||
Revises: c3d9a1f4b7e2
|
||||
Create Date: 2026-10-16 20:40:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c7e1a2f4b9d3"
|
||||
down_revision = "c3d9a1f4b7e2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sync_record",
|
||||
sa.Column("num_docs_failed", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sync_record", "num_docs_failed")
|
||||
@@ -909,6 +909,8 @@ def monitor_ccpair_pruning_taskset(
|
||||
# A generator that threw after fan-out still drains its tasks; record FAILED
|
||||
# so the prune retries after the backoff instead of advancing last_pruned.
|
||||
failed = redis_connector.prune.generator_failed
|
||||
# Read before any commit, so a Redis error here leaves nothing half done.
|
||||
failed_docs = redis_connector.prune.failed_docs
|
||||
if failed:
|
||||
task_logger.warning(
|
||||
f"Connector pruning failed after fan-out: cc_pair={cc_pair_id} num_pruned={initial}"
|
||||
@@ -931,6 +933,7 @@ def monitor_ccpair_pruning_taskset(
|
||||
sync_type=SyncType.PRUNING,
|
||||
sync_status=SyncStatus.FAILED if failed else SyncStatus.SUCCESS,
|
||||
num_docs_synced=initial,
|
||||
num_docs_failed=failed_docs,
|
||||
)
|
||||
|
||||
redis_connector.prune.taskset_clear()
|
||||
|
||||
@@ -34,6 +34,7 @@ from onyx.db.relationships import delete_document_references_from_kg
|
||||
from onyx.db.search_settings import get_active_search_settings
|
||||
from onyx.document_index.factory import get_default_document_index
|
||||
from onyx.document_index.interfaces import MetadataUpdateRequest
|
||||
from onyx.redis.redis_connector import RedisConnector
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.server.documents.models import ConnectorCredentialPairIdentifier
|
||||
|
||||
@@ -111,7 +112,8 @@ def document_by_cc_pair_cleanup_task(
|
||||
document_id: str,
|
||||
connector_id: int,
|
||||
credential_id: int,
|
||||
tenant_id: str, # noqa: ARG001 — kept on the celery task signature
|
||||
tenant_id: str,
|
||||
prune_cc_pair_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Remove one document relationship and reconcile its index metadata.
|
||||
|
||||
@@ -303,6 +305,16 @@ def document_by_cc_pair_cleanup_task(
|
||||
db_session, connector_id, credential_id, document_id
|
||||
)
|
||||
db_session.commit()
|
||||
if prune_cc_pair_id is not None:
|
||||
# The count is advisory, so a Redis failure must not change the outcome.
|
||||
try:
|
||||
RedisConnector(
|
||||
tenant_id, prune_cc_pair_id
|
||||
).prune.increment_failed_docs()
|
||||
except Exception:
|
||||
task_logger.exception(
|
||||
f"Could not count the failed prune doc: cc_pair={prune_cc_pair_id} doc={document_id}"
|
||||
)
|
||||
completion_status = OnyxCeleryTaskCompletionStatus.NON_RETRYABLE_EXCEPTION
|
||||
else:
|
||||
# Exponential backoff from 2^4 to 2^6 ... i.e. 16, 32, 64
|
||||
|
||||
@@ -383,6 +383,21 @@ def _doc_permission_sync_runs(
|
||||
)
|
||||
|
||||
|
||||
def _prune_errors(run_filter: CCPairRunFilter) -> ColumnElement[bool] | None:
|
||||
"""The ``errors`` filter for a prune, which counts the documents it could not
|
||||
remove. Nothing resolves these."""
|
||||
failed = SyncRecord.num_docs_failed
|
||||
match run_filter.errors:
|
||||
case CCPairRunErrors.UNRESOLVED:
|
||||
return failed > 0
|
||||
case CCPairRunErrors.RESOLVED:
|
||||
return false()
|
||||
case CCPairRunErrors.NONE:
|
||||
return failed == 0
|
||||
case None:
|
||||
return None
|
||||
|
||||
|
||||
def _group_sync_runs(run_filter: CCPairRunFilter, with_status_filter: bool) -> Select:
|
||||
stmt = select(ExternalGroupPermissionSyncAttempt).where(
|
||||
_group_sync_scope(run_filter)
|
||||
@@ -423,7 +438,6 @@ def _prune_runs(run_filter: CCPairRunFilter, with_status_filter: bool) -> Select
|
||||
newest.sync_type == SyncType.PRUNING,
|
||||
)
|
||||
)
|
||||
# A prune records no per-document errors, so it either fails or succeeds.
|
||||
return _narrowed(
|
||||
stmt,
|
||||
run_filter,
|
||||
@@ -432,7 +446,7 @@ def _prune_runs(run_filter: CCPairRunFilter, with_status_filter: bool) -> Select
|
||||
SyncRecord.sync_status,
|
||||
SyncStatus,
|
||||
with_status_filter,
|
||||
_no_item_errors(run_filter),
|
||||
_prune_errors(run_filter),
|
||||
has_newer_run,
|
||||
run_is_resolved(CCPairRunKind.PRUNING, SyncRecord.id),
|
||||
)
|
||||
|
||||
@@ -3180,6 +3180,7 @@ class SyncRecord(Base):
|
||||
sync_status: Mapped[SyncStatus] = mapped_column(Enum(SyncStatus, native_enum=False))
|
||||
|
||||
num_docs_synced: Mapped[int] = mapped_column(Integer, default=0)
|
||||
num_docs_failed: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
sync_start_time: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True))
|
||||
sync_end_time: Mapped[datetime.datetime | None] = mapped_column(
|
||||
|
||||
@@ -133,6 +133,7 @@ def update_sync_record_status(
|
||||
sync_type: SyncType,
|
||||
sync_status: SyncStatus,
|
||||
num_docs_synced: int | None = None,
|
||||
num_docs_failed: int | None = None,
|
||||
) -> None:
|
||||
"""Update the status of a sync record.
|
||||
|
||||
@@ -142,6 +143,7 @@ def update_sync_record_status(
|
||||
sync_type: The type of sync operation
|
||||
sync_status: The new status to set
|
||||
num_docs_synced: Optional number of documents synced to update
|
||||
num_docs_failed: Optional number of documents that failed to sync
|
||||
"""
|
||||
sync_record = fetch_latest_sync_record(db_session, entity_id, sync_type)
|
||||
if sync_record is None:
|
||||
@@ -155,6 +157,8 @@ def update_sync_record_status(
|
||||
sync_record.sync_status = sync_status
|
||||
if num_docs_synced is not None:
|
||||
sync_record.num_docs_synced = num_docs_synced
|
||||
if num_docs_failed is not None:
|
||||
sync_record.num_docs_failed = num_docs_failed
|
||||
|
||||
if sync_status.is_terminal():
|
||||
sync_record.sync_end_time = func.now()
|
||||
|
||||
@@ -69,6 +69,9 @@ class RedisConnectorPrune:
|
||||
# it to record the prune FAILED instead of falsely advancing last_pruned.
|
||||
GENERATOR_FAILED_PREFIX = PREFIX + "_generator_failed"
|
||||
|
||||
# Number of documents whose cleanup still failed after its last retry.
|
||||
FAILED_DOCS_PREFIX = PREFIX + "_failed_docs"
|
||||
|
||||
def __init__(self, tenant_id: str, id: int, redis: TenantRedisClient) -> None:
|
||||
self.tenant_id: str = tenant_id
|
||||
self.id = id
|
||||
@@ -85,6 +88,7 @@ class RedisConnectorPrune:
|
||||
self.active_key = f"{self.ACTIVE_PREFIX}_{id}"
|
||||
self.failure_backoff_key = f"{self.FAILURE_BACKOFF_PREFIX}_{id}"
|
||||
self.generator_failed_key = f"{self.GENERATOR_FAILED_PREFIX}_{id}"
|
||||
self.failed_docs_key = f"{self.FAILED_DOCS_PREFIX}_{id}"
|
||||
|
||||
def taskset_clear(self) -> None:
|
||||
self.redis.delete(self.taskset_key)
|
||||
@@ -93,6 +97,7 @@ class RedisConnectorPrune:
|
||||
self.redis.delete(self.generator_progress_key)
|
||||
self.redis.delete(self.generator_complete_key)
|
||||
self.redis.delete(self.generator_failed_key)
|
||||
self.redis.delete(self.failed_docs_key)
|
||||
|
||||
def get_remaining(self) -> int:
|
||||
# todo: move into fence
|
||||
@@ -169,6 +174,15 @@ class RedisConnectorPrune:
|
||||
def generator_failed(self) -> bool:
|
||||
return bool(self.redis.exists(self.generator_failed_key))
|
||||
|
||||
def increment_failed_docs(self) -> None:
|
||||
self.redis.incr(self.failed_docs_key)
|
||||
self.redis.expire(self.failed_docs_key, self.FENCE_TTL)
|
||||
|
||||
@property
|
||||
def failed_docs(self) -> int:
|
||||
value = self.redis.get(self.failed_docs_key)
|
||||
return int(value) if value is not None else 0
|
||||
|
||||
@property
|
||||
def generator_complete(self) -> int | None:
|
||||
"""the fence payload is an int representing the starting number of
|
||||
@@ -232,6 +246,7 @@ class RedisConnectorPrune:
|
||||
"connector_id": cc_pair.connector_id,
|
||||
"credential_id": cc_pair.credential_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"prune_cc_pair_id": cc_pair.id,
|
||||
},
|
||||
queue=OnyxCeleryQueues.CONNECTOR_DELETION,
|
||||
task_id=custom_task_id,
|
||||
@@ -249,6 +264,7 @@ class RedisConnectorPrune:
|
||||
self.redis.delete(self.generator_progress_key)
|
||||
self.redis.delete(self.generator_complete_key)
|
||||
self.redis.delete(self.generator_failed_key)
|
||||
self.redis.delete(self.failed_docs_key)
|
||||
self.redis.delete(self.taskset_key)
|
||||
self.redis.delete(self.fence_key)
|
||||
|
||||
@@ -281,3 +297,6 @@ class RedisConnectorPrune:
|
||||
|
||||
for key in r.scan_iter(RedisConnectorPrune.GENERATOR_FAILED_PREFIX + "*"):
|
||||
r.delete(key)
|
||||
|
||||
for key in r.scan_iter(RedisConnectorPrune.FAILED_DOCS_PREFIX + "*"):
|
||||
r.delete(key)
|
||||
@@ -569,6 +569,7 @@ class PruneRunSnapshot(BaseModel):
|
||||
id: int
|
||||
status: SyncStatus
|
||||
docs_removed: int
|
||||
docs_failed: int
|
||||
error_message: str | None
|
||||
time_created: str
|
||||
time_started: str | None
|
||||
@@ -580,6 +581,7 @@ class PruneRunSnapshot(BaseModel):
|
||||
id=record.id,
|
||||
status=record.sync_status,
|
||||
docs_removed=record.num_docs_synced,
|
||||
docs_failed=record.num_docs_failed,
|
||||
error_message=record.error_message,
|
||||
time_created=record.sync_start_time.isoformat(),
|
||||
time_started=record.sync_start_time.isoformat(),
|
||||
|
||||
@@ -69,6 +69,7 @@ from onyx.server.documents.models import (
|
||||
CCPairRunsResponse,
|
||||
ConnectorSnapshot,
|
||||
ConnectorUpdateRequest,
|
||||
PruneRunSnapshot,
|
||||
)
|
||||
from tests.external_dependency_unit.conftest import create_test_user
|
||||
|
||||
@@ -119,12 +120,14 @@ def _prune(
|
||||
status: SyncStatus,
|
||||
docs_removed: int = 0,
|
||||
error_message: str | None = None,
|
||||
docs_failed: int = 0,
|
||||
) -> SyncRecord:
|
||||
record = SyncRecord(
|
||||
entity_id=cc_pair.id,
|
||||
sync_type=SyncType.PRUNING,
|
||||
sync_status=status,
|
||||
num_docs_synced=docs_removed,
|
||||
num_docs_failed=docs_failed,
|
||||
sync_start_time=_T0 + timedelta(minutes=minutes),
|
||||
sync_end_time=(
|
||||
None
|
||||
@@ -315,6 +318,24 @@ def test_prunes_and_check_runs_join_the_feed(db_session: Session) -> None:
|
||||
assert _order(with_doc_errors) == []
|
||||
|
||||
|
||||
def test_a_prune_with_failed_docs_lists_with_the_errors(db_session: Session) -> None:
|
||||
admin = create_test_user(db_session, "history_admin", is_admin=True)
|
||||
cc_pair = _create_cc_pair(db_session)
|
||||
_prune(db_session, cc_pair, 0, SyncStatus.SUCCESS, docs_removed=5)
|
||||
failed_docs = _prune(
|
||||
db_session, cc_pair, 1, SyncStatus.SUCCESS, docs_removed=3, docs_failed=2
|
||||
)
|
||||
|
||||
with_errors = _runs(db_session, admin, cc_pair, errors=CCPairRunErrors.UNRESOLVED)
|
||||
resolved = _runs(db_session, admin, cc_pair, errors=CCPairRunErrors.RESOLVED)
|
||||
|
||||
assert _order(with_errors) == [(CCPairRunKind.PRUNING, failed_docs.id)]
|
||||
attempt = with_errors.items[0].attempt
|
||||
assert isinstance(attempt, PruneRunSnapshot)
|
||||
assert attempt.docs_failed == 2
|
||||
assert _order(resolved) == []
|
||||
|
||||
|
||||
def test_check_run_lifecycle_writes_one_history_row_per_run(
|
||||
db_session: Session,
|
||||
) -> None:
|
||||
|
||||
@@ -6,6 +6,7 @@ const CARD_COLORS = [
|
||||
"transparent",
|
||||
"background-tint-00",
|
||||
"background-tint-01",
|
||||
"background-tint-02",
|
||||
"status-info-00",
|
||||
"status-success-00",
|
||||
"status-warning-00",
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
@apply bg-background-tint-01;
|
||||
}
|
||||
|
||||
.opal-card[data-color="background-tint-02"] {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
|
||||
.opal-card[data-color="status-info-00"] {
|
||||
@apply bg-status-info-00;
|
||||
}
|
||||
@@ -118,6 +122,10 @@
|
||||
@apply bg-background-tint-01;
|
||||
}
|
||||
|
||||
.opal-card-expandable-header[data-color="background-tint-02"] {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
|
||||
.opal-card-expandable-header[data-color="status-info-00"] {
|
||||
@apply bg-status-info-00;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ Status tags use `font="secondary-body"`; `sm` keeps `figure-small-value` by defa
|
||||
|
||||
### `TagColor`
|
||||
|
||||
`"green" | "blue" | "purple" | "amber" | "orange" | "red" | "gray"`
|
||||
`"green" | "blue" | "purple" | "amber" | "orange" | "red" | "gray" | "white"`
|
||||
|
||||
| Color | Background | Text |
|
||||
| -------- | -------------------- | ------------------------------------------ |
|
||||
@@ -51,6 +51,7 @@ Status tags use `font="secondary-body"`; `sm` keeps `figure-small-value` by defa
|
||||
| `orange` | `status-warning-01` | `status-warning-05` |
|
||||
| `red` | `status-error-01` | `status-error-05` |
|
||||
| `gray` | `background-tint-02` | `text-03` (passive) / `text-04` (editable) |
|
||||
| `white` | `background-tint-00` | `text-04`, for a pill on a tinted row |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ export type TagColor =
|
||||
| "gray"
|
||||
| "amber"
|
||||
| "orange"
|
||||
| "red";
|
||||
| "red"
|
||||
| "white";
|
||||
|
||||
export const TAG_COLORS: Record<TagColor, { bg: string; text: string }> = {
|
||||
green: { bg: "bg-theme-green-01", text: "text-theme-green-05" },
|
||||
@@ -19,4 +20,6 @@ export const TAG_COLORS: Record<TagColor, { bg: string; text: string }> = {
|
||||
orange: { bg: "bg-status-warning-01", text: "text-status-warning-05" },
|
||||
red: { bg: "bg-status-error-01", text: "text-status-error-05" },
|
||||
gray: { bg: "bg-background-tint-02", text: "text-text-03" },
|
||||
// A pill on a tinted row, such as "Required" on a failed check.
|
||||
white: { bg: "bg-background-tint-00", text: "text-text-04" },
|
||||
};
|
||||
@@ -141,6 +141,7 @@ export type CardColor =
|
||||
| "transparent"
|
||||
| "background-tint-00"
|
||||
| "background-tint-01"
|
||||
| "background-tint-02"
|
||||
| "status-info-00"
|
||||
| "status-success-00"
|
||||
| "status-warning-00"
|
||||
|
||||
@@ -2714,8 +2714,7 @@
|
||||
"reIndex": {
|
||||
"tooltip": {
|
||||
"indexing": "لا يمكن إعادة الفهرسة أثناء وجود فهرسة قيد التنفيذ",
|
||||
"invalid": "أصلح إعدادات الموصل قبل إعادة الفهرسة",
|
||||
"paused": "استأنف الموصل قبل إعادة الفهرسة"
|
||||
"invalid": "أصلح إعدادات الموصل قبل إعادة الفهرسة"
|
||||
}
|
||||
},
|
||||
"toggleStatus": {
|
||||
@@ -2741,6 +2740,10 @@
|
||||
"fullReindex": {
|
||||
"label": "إعادة فهرسة كاملة",
|
||||
"description": "إعادة فهرسة كل المستندات من المصدر. قد يستغرق ذلك **ساعات أو أيامًا** حسب حجم المحتوى."
|
||||
},
|
||||
"startIndexing": {
|
||||
"label": "بدء الفهرسة",
|
||||
"description": "تخطَّ فحوصات الموصل وابدأ الفهرسة الآن."
|
||||
}
|
||||
},
|
||||
"reEnableModal": {
|
||||
@@ -2783,6 +2786,7 @@
|
||||
"label": "مستندات مفهرسة"
|
||||
},
|
||||
"indexingSpeed": "{speed} مستند/دقيقة",
|
||||
"indexingSpeedTooltip": "المستندات المفهرسة في الدقيقة منذ إنشاء الموصل",
|
||||
"lastIndexed": {
|
||||
"value": "آخر فهرسة {when}"
|
||||
},
|
||||
@@ -2942,7 +2946,9 @@
|
||||
"queued": "في قائمة الانتظار",
|
||||
"running": "قيد التشغيل",
|
||||
"rate": "{rate} مستند/دقيقة",
|
||||
"finished": "{time} ({minutes, number} دقيقة)"
|
||||
"finished": "{time} ({minutes, number} دقيقة)",
|
||||
"rateFinished": "{rate} مستند/دقيقة ({minutes, number} دقيقة)",
|
||||
"membershipRate": "{rate} عضوية/دقيقة"
|
||||
},
|
||||
"actions": {
|
||||
"stages": "عرض مقاييس المراحل",
|
||||
@@ -2976,7 +2982,8 @@
|
||||
"canceled": "{count, plural, one {تم إلغاء # تشغيل} other {تم إلغاء # تشغيلات}}",
|
||||
"passedChecks": "{count, plural, one {نجح فحص واحد} other {نجح # فحوصات}}",
|
||||
"failedChecks": "{count, plural, one {فشل فحص واحد} other {فشل # فحوصات}}",
|
||||
"skippedChecks": "{count, plural, one {تم تخطي فحص واحد} other {تم تخطي # فحوصات}}"
|
||||
"skippedChecks": "{count, plural, one {تم تخطي فحص واحد} other {تم تخطي # فحوصات}}",
|
||||
"syncedDocs": "{count, plural, one {تمت مزامنة مستند واحد} other {تمت مزامنة # مستندات}}"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "البحث في عمليات الفهرسة…"
|
||||
@@ -3004,7 +3011,9 @@
|
||||
"empty": {
|
||||
"title": "لا توجد عمليات فهرسة حديثة",
|
||||
"filteredTitle": "لا توجد عمليات تشغيل تطابق هذه المرشحات.",
|
||||
"description": "تتم جدولة عمليات التشغيل في الخلفية وقد يستغرق ظهورها بعض الوقت."
|
||||
"pastRuns": "{count, plural, one {عملية تشغيل سابقة واحدة} other {# عمليات تشغيل سابقة}}",
|
||||
"view": "عرض",
|
||||
"viewAria": "{count, plural, one {عرض عملية التشغيل السابقة} other {عرض # عمليات تشغيل سابقة}}"
|
||||
},
|
||||
"units": "{count, plural, one {تشغيل} other {عمليات تشغيل}}",
|
||||
"errorModal": {
|
||||
@@ -3022,7 +3031,8 @@
|
||||
"title": "فحوصات الموصّل",
|
||||
"close": "إغلاق",
|
||||
"count": "({count})"
|
||||
}
|
||||
},
|
||||
"showing": "عرض <mono>{from}</mono>~<mono>{to}</mono> من <mono>{total}</mono>"
|
||||
},
|
||||
"activity": {
|
||||
"search": {
|
||||
@@ -3137,6 +3147,7 @@
|
||||
"placeholder": "ابحث في المستندات…"
|
||||
},
|
||||
"resolveAll": "حل الكل",
|
||||
"resolveAllTooltip": "إعادة فهرسة كل المستندات الفاشلة في هذا التشغيل",
|
||||
"resolveOne": "حل هذا المستند",
|
||||
"more": "المزيد",
|
||||
"less": "أقل",
|
||||
@@ -9544,7 +9555,6 @@
|
||||
"badges": {
|
||||
"canceled": "ملغاة",
|
||||
"deleting": "جارٍ الحذف",
|
||||
"error": "خطأ",
|
||||
"failed": "فشلت",
|
||||
"indexed": "مفهرس",
|
||||
"indexing": "جارٍ الفهرسة",
|
||||
|
||||
@@ -2714,17 +2714,16 @@
|
||||
"reIndex": {
|
||||
"tooltip": {
|
||||
"indexing": "Eine Neuindizierung ist nicht möglich, während bereits eine Indizierung läuft",
|
||||
"invalid": "Korrigieren Sie die Konnektorkonfiguration vor der Neuindizierung",
|
||||
"paused": "Setzen Sie den Konnektor vor der Neuindizierung fort"
|
||||
"invalid": "Korrigieren Sie die Konnektorkonfiguration vor der Neuindizierung"
|
||||
}
|
||||
},
|
||||
"toggleStatus": {
|
||||
"pause": {
|
||||
"label": "Indexierung & Synchronisierung pausieren",
|
||||
"label": "Indizierung & Synchronisation pausieren",
|
||||
"description": "Laufende und künftige Indexläufe und Berechtigungssynchronisierungen abbrechen. Indexierte Dokumente sind dann nicht mehr aktuell, bleiben aber durchsuchbar."
|
||||
},
|
||||
"resume": {
|
||||
"label": "Indexierung & Synchronisierung fortsetzen",
|
||||
"label": "Indizierung & Synchronisation fortsetzen",
|
||||
"description": "Indexläufe und Berechtigungssynchronisierungen wieder starten."
|
||||
},
|
||||
"tooltip": {
|
||||
@@ -2741,6 +2740,10 @@
|
||||
"fullReindex": {
|
||||
"label": "Vollständig neu indizieren",
|
||||
"description": "Alle Dokumente der Quelle neu indizieren. Das kann je nach Umfang **Stunden oder Tage** dauern."
|
||||
},
|
||||
"startIndexing": {
|
||||
"label": "Indexierung starten",
|
||||
"description": "Konnektorprüfungen überspringen und jetzt indexieren."
|
||||
}
|
||||
},
|
||||
"reEnableModal": {
|
||||
@@ -2783,6 +2786,7 @@
|
||||
"label": "Indexierte Dokumente"
|
||||
},
|
||||
"indexingSpeed": "{speed} Dok./Min.",
|
||||
"indexingSpeedTooltip": "Indexierte Dokumente pro Minute seit dem Anlegen des Connectors",
|
||||
"lastIndexed": {
|
||||
"value": "Zuletzt indiziert {when}"
|
||||
},
|
||||
@@ -2942,7 +2946,9 @@
|
||||
"queued": "In Warteschlange",
|
||||
"running": "Läuft",
|
||||
"rate": "{rate} Dok./Min.",
|
||||
"finished": "{time} ({minutes, number} Min.)"
|
||||
"finished": "{time} ({minutes, number} Min.)",
|
||||
"rateFinished": "{rate} Dok./Min. ({minutes, number} Min.)",
|
||||
"membershipRate": "{rate} Mitgliedschaften/Min."
|
||||
},
|
||||
"actions": {
|
||||
"stages": "Phasenmetriken anzeigen",
|
||||
@@ -2976,7 +2982,8 @@
|
||||
"canceled": "{count, plural, one {# Lauf abgebrochen} other {# Läufe abgebrochen}}",
|
||||
"passedChecks": "{count, plural, one {# Prüfung bestanden} other {# Prüfungen bestanden}}",
|
||||
"failedChecks": "{count, plural, one {# Prüfung fehlgeschlagen} other {# Prüfungen fehlgeschlagen}}",
|
||||
"skippedChecks": "{count, plural, one {# Prüfung übersprungen} other {# Prüfungen übersprungen}}"
|
||||
"skippedChecks": "{count, plural, one {# Prüfung übersprungen} other {# Prüfungen übersprungen}}",
|
||||
"syncedDocs": "{count, plural, one {# Dokument synchronisiert} other {# Dokumente synchronisiert}}"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Indexläufe durchsuchen…"
|
||||
@@ -3004,7 +3011,9 @@
|
||||
"empty": {
|
||||
"title": "Keine aktuellen Indexläufe",
|
||||
"filteredTitle": "Keine Läufe entsprechen diesen Filtern.",
|
||||
"description": "Läufe werden im Hintergrund geplant und erscheinen eventuell erst nach kurzer Zeit."
|
||||
"pastRuns": "{count, plural, one {# vergangener Lauf} other {# vergangene Läufe}}",
|
||||
"view": "Anzeigen",
|
||||
"viewAria": "{count, plural, one {# vergangenen Lauf anzeigen} other {# vergangene Läufe anzeigen}}"
|
||||
},
|
||||
"units": "{count, plural, one {Lauf} other {Läufe}}",
|
||||
"errorModal": {
|
||||
@@ -3022,7 +3031,8 @@
|
||||
"title": "Connector-Prüfungen",
|
||||
"close": "Schließen",
|
||||
"count": "({count})"
|
||||
}
|
||||
},
|
||||
"showing": "<mono>{from}</mono>~<mono>{to}</mono> von <mono>{total}</mono>"
|
||||
},
|
||||
"activity": {
|
||||
"search": {
|
||||
@@ -3137,6 +3147,7 @@
|
||||
"placeholder": "Dokumente durchsuchen…"
|
||||
},
|
||||
"resolveAll": "Alle beheben",
|
||||
"resolveAllTooltip": "Alle fehlgeschlagenen Dokumente dieses Laufs neu indexieren",
|
||||
"resolveOne": "Dieses Dokument beheben",
|
||||
"more": "Mehr",
|
||||
"less": "Weniger",
|
||||
@@ -9544,7 +9555,6 @@
|
||||
"badges": {
|
||||
"canceled": "Abgebrochen",
|
||||
"deleting": "Wird gelöscht",
|
||||
"error": "Fehler",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"indexed": "Indexiert",
|
||||
"indexing": "Indexierung",
|
||||
|
||||
@@ -2714,8 +2714,7 @@
|
||||
"reIndex": {
|
||||
"tooltip": {
|
||||
"indexing": "Cannot re-index while indexing is already in progress",
|
||||
"invalid": "Fix the connector configuration before re-indexing",
|
||||
"paused": "Resume the connector before re-indexing"
|
||||
"invalid": "Fix the connector configuration before re-indexing"
|
||||
}
|
||||
},
|
||||
"toggleStatus": {
|
||||
@@ -2741,6 +2740,10 @@
|
||||
"fullReindex": {
|
||||
"label": "Full Re-index",
|
||||
"description": "Re-index all docs from the source. This may take **hours or days** depending on corpus size."
|
||||
},
|
||||
"startIndexing": {
|
||||
"label": "Start Indexing",
|
||||
"description": "Skip connector checks and index now."
|
||||
}
|
||||
},
|
||||
"reEnableModal": {
|
||||
@@ -2783,6 +2786,7 @@
|
||||
"label": "Indexed documents"
|
||||
},
|
||||
"indexingSpeed": "{speed} docs/min",
|
||||
"indexingSpeedTooltip": "Documents indexed per minute since the connector was created",
|
||||
"lastIndexed": {
|
||||
"value": "Last indexed {when}"
|
||||
},
|
||||
@@ -2942,7 +2946,9 @@
|
||||
"queued": "Queued",
|
||||
"running": "Running",
|
||||
"rate": "{rate} docs/min",
|
||||
"finished": "{time} ({minutes, number} min)"
|
||||
"finished": "{time} ({minutes, number} min)",
|
||||
"rateFinished": "{rate} docs/min ({minutes, number} min)",
|
||||
"membershipRate": "{rate} memberships/min"
|
||||
},
|
||||
"actions": {
|
||||
"stages": "View Stage Metrics",
|
||||
@@ -2976,7 +2982,8 @@
|
||||
"canceled": "{count, plural, one {# run canceled} other {# runs canceled}}",
|
||||
"passedChecks": "{count, plural, one {# check passed} other {# checks passed}}",
|
||||
"failedChecks": "{count, plural, one {# check failed} other {# checks failed}}",
|
||||
"skippedChecks": "{count, plural, one {# check skipped} other {# checks skipped}}"
|
||||
"skippedChecks": "{count, plural, one {# check skipped} other {# checks skipped}}",
|
||||
"syncedDocs": "{count, plural, one {# document synced} other {# documents synced}}"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search indexing runs…"
|
||||
@@ -3004,7 +3011,9 @@
|
||||
"empty": {
|
||||
"title": "No recent indexing runs",
|
||||
"filteredTitle": "No runs match these filters.",
|
||||
"description": "Runs are scheduled in the background and may take a moment to appear."
|
||||
"pastRuns": "{count, plural, one {# Past Run} other {# Past Runs}}",
|
||||
"view": "View",
|
||||
"viewAria": "{count, plural, one {View # past run} other {View # past runs}}"
|
||||
},
|
||||
"units": "{count, plural, one {run} other {runs}}",
|
||||
"errorModal": {
|
||||
@@ -3022,7 +3031,8 @@
|
||||
"title": "Connector Checks",
|
||||
"close": "Close",
|
||||
"count": "({count})"
|
||||
}
|
||||
},
|
||||
"showing": "Showing <mono>{from}</mono>~<mono>{to}</mono> of <mono>{total}</mono>"
|
||||
},
|
||||
"activity": {
|
||||
"search": {
|
||||
@@ -3137,6 +3147,7 @@
|
||||
"placeholder": "Search documents…"
|
||||
},
|
||||
"resolveAll": "Resolve All",
|
||||
"resolveAllTooltip": "Re-index every failed document of this run",
|
||||
"resolveOne": "Resolve this document",
|
||||
"more": "More",
|
||||
"less": "Less",
|
||||
@@ -9544,7 +9555,6 @@
|
||||
"badges": {
|
||||
"canceled": "Canceled",
|
||||
"deleting": "Deleting",
|
||||
"error": "Error",
|
||||
"failed": "Failed",
|
||||
"indexed": "Indexed",
|
||||
"indexing": "Indexing",
|
||||
|
||||
@@ -2714,8 +2714,7 @@
|
||||
"reIndex": {
|
||||
"tooltip": {
|
||||
"indexing": "No se puede reindexar mientras la indexación está en curso",
|
||||
"invalid": "Corrige la configuración del conector antes de reindexar",
|
||||
"paused": "Reanuda el conector antes de reindexar"
|
||||
"invalid": "Corrige la configuración del conector antes de reindexar"
|
||||
}
|
||||
},
|
||||
"toggleStatus": {
|
||||
@@ -2741,6 +2740,10 @@
|
||||
"fullReindex": {
|
||||
"label": "Reindexar todo",
|
||||
"description": "Reindexar todos los documentos de la fuente. Puede tardar **horas o días** según el tamaño."
|
||||
},
|
||||
"startIndexing": {
|
||||
"label": "Iniciar indexación",
|
||||
"description": "Omitir las comprobaciones del conector e indexar ahora."
|
||||
}
|
||||
},
|
||||
"reEnableModal": {
|
||||
@@ -2783,6 +2786,7 @@
|
||||
"label": "Documentos en el índice"
|
||||
},
|
||||
"indexingSpeed": "{speed} documentos/min",
|
||||
"indexingSpeedTooltip": "Documentos indexados por minuto desde que se creó el conector",
|
||||
"lastIndexed": {
|
||||
"value": "Última indexación {when}"
|
||||
},
|
||||
@@ -2942,7 +2946,9 @@
|
||||
"queued": "En cola",
|
||||
"running": "En curso",
|
||||
"rate": "{rate} documentos/min",
|
||||
"finished": "{time} ({minutes, number} min)"
|
||||
"finished": "{time} ({minutes, number} min)",
|
||||
"rateFinished": "{rate} documentos/min ({minutes, number} min)",
|
||||
"membershipRate": "{rate} membresías/min"
|
||||
},
|
||||
"actions": {
|
||||
"stages": "Ver métricas por etapa",
|
||||
@@ -2976,7 +2982,8 @@
|
||||
"canceled": "{count, plural, one {# ejecución cancelada} other {# ejecuciones canceladas}}",
|
||||
"passedChecks": "{count, plural, one {# comprobación superada} other {# comprobaciones superadas}}",
|
||||
"failedChecks": "{count, plural, one {# comprobación fallida} other {# comprobaciones fallidas}}",
|
||||
"skippedChecks": "{count, plural, one {# comprobación omitida} other {# comprobaciones omitidas}}"
|
||||
"skippedChecks": "{count, plural, one {# comprobación omitida} other {# comprobaciones omitidas}}",
|
||||
"syncedDocs": "{count, plural, one {# documento sincronizado} other {# documentos sincronizados}}"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar ejecuciones de indexación…"
|
||||
@@ -3004,7 +3011,9 @@
|
||||
"empty": {
|
||||
"title": "No hay ejecuciones de indexación recientes",
|
||||
"filteredTitle": "Ninguna ejecución coincide con estos filtros.",
|
||||
"description": "Las ejecuciones se programan en segundo plano y pueden tardar un momento en aparecer."
|
||||
"pastRuns": "{count, plural, one {# ejecución anterior} other {# ejecuciones anteriores}}",
|
||||
"view": "Ver",
|
||||
"viewAria": "{count, plural, one {Ver # ejecución anterior} other {Ver # ejecuciones anteriores}}"
|
||||
},
|
||||
"units": "{count, plural, one {ejecución} other {ejecuciones}}",
|
||||
"errorModal": {
|
||||
@@ -3022,7 +3031,8 @@
|
||||
"title": "Comprobaciones del conector",
|
||||
"close": "Cerrar",
|
||||
"count": "({count})"
|
||||
}
|
||||
},
|
||||
"showing": "Mostrando <mono>{from}</mono>~<mono>{to}</mono> de <mono>{total}</mono>"
|
||||
},
|
||||
"activity": {
|
||||
"search": {
|
||||
@@ -3137,6 +3147,7 @@
|
||||
"placeholder": "Buscar documentos…"
|
||||
},
|
||||
"resolveAll": "Resolver todo",
|
||||
"resolveAllTooltip": "Reindexar todos los documentos fallidos de esta ejecución",
|
||||
"resolveOne": "Resolver este documento",
|
||||
"more": "Más",
|
||||
"less": "Menos",
|
||||
@@ -9544,7 +9555,6 @@
|
||||
"badges": {
|
||||
"canceled": "Cancelado",
|
||||
"deleting": "Eliminando",
|
||||
"error": "Error",
|
||||
"failed": "Fallido",
|
||||
"indexed": "Indexado",
|
||||
"indexing": "Indexando",
|
||||
|
||||
@@ -2714,8 +2714,7 @@
|
||||
"reIndex": {
|
||||
"tooltip": {
|
||||
"indexing": "Impossible de réindexer pendant qu'une indexation est déjà en cours",
|
||||
"invalid": "Corrigez la configuration du connecteur avant de réindexer",
|
||||
"paused": "Reprenez le connecteur avant de réindexer"
|
||||
"invalid": "Corrigez la configuration du connecteur avant de réindexer"
|
||||
}
|
||||
},
|
||||
"toggleStatus": {
|
||||
@@ -2741,6 +2740,10 @@
|
||||
"fullReindex": {
|
||||
"label": "Réindexation complète",
|
||||
"description": "Réindexer tous les documents de la source. Cela peut prendre **des heures ou des jours** selon le volume."
|
||||
},
|
||||
"startIndexing": {
|
||||
"label": "Démarrer l'indexation",
|
||||
"description": "Ignorer les vérifications du connecteur et indexer maintenant."
|
||||
}
|
||||
},
|
||||
"reEnableModal": {
|
||||
@@ -2783,6 +2786,7 @@
|
||||
"label": "Documents dans l'index"
|
||||
},
|
||||
"indexingSpeed": "{speed} docs/min",
|
||||
"indexingSpeedTooltip": "Documents indexés par minute depuis la création du connecteur",
|
||||
"lastIndexed": {
|
||||
"value": "Dernière indexation {when}"
|
||||
},
|
||||
@@ -2942,7 +2946,9 @@
|
||||
"queued": "En file d'attente",
|
||||
"running": "En cours",
|
||||
"rate": "{rate} docs/min",
|
||||
"finished": "{time} ({minutes, number} min)"
|
||||
"finished": "{time} ({minutes, number} min)",
|
||||
"rateFinished": "{rate} docs/min ({minutes, number} min)",
|
||||
"membershipRate": "{rate} adhésions/min"
|
||||
},
|
||||
"actions": {
|
||||
"stages": "Voir les métriques par étape",
|
||||
@@ -2976,7 +2982,8 @@
|
||||
"canceled": "{count, plural, one {# exécution annulée} other {# exécutions annulées}}",
|
||||
"passedChecks": "{count, plural, one {# vérification réussie} other {# vérifications réussies}}",
|
||||
"failedChecks": "{count, plural, one {# vérification échouée} other {# vérifications échouées}}",
|
||||
"skippedChecks": "{count, plural, one {# vérification ignorée} other {# vérifications ignorées}}"
|
||||
"skippedChecks": "{count, plural, one {# vérification ignorée} other {# vérifications ignorées}}",
|
||||
"syncedDocs": "{count, plural, one {# document synchronisé} other {# documents synchronisés}}"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher des exécutions d'indexation…"
|
||||
@@ -3004,7 +3011,9 @@
|
||||
"empty": {
|
||||
"title": "Aucune exécution d'indexation récente",
|
||||
"filteredTitle": "Aucune exécution ne correspond à ces filtres.",
|
||||
"description": "Les exécutions sont planifiées en arrière-plan et peuvent mettre un moment à apparaître."
|
||||
"pastRuns": "{count, plural, one {# exécution passée} other {# exécutions passées}}",
|
||||
"view": "Afficher",
|
||||
"viewAria": "{count, plural, one {Afficher # exécution passée} other {Afficher # exécutions passées}}"
|
||||
},
|
||||
"units": "{count, plural, one {exécution} other {exécutions}}",
|
||||
"errorModal": {
|
||||
@@ -3022,7 +3031,8 @@
|
||||
"title": "Vérifications du connecteur",
|
||||
"close": "Fermer",
|
||||
"count": "({count})"
|
||||
}
|
||||
},
|
||||
"showing": "Affichage de <mono>{from}</mono>~<mono>{to}</mono> sur <mono>{total}</mono>"
|
||||
},
|
||||
"activity": {
|
||||
"search": {
|
||||
@@ -3137,6 +3147,7 @@
|
||||
"placeholder": "Rechercher des documents…"
|
||||
},
|
||||
"resolveAll": "Tout résoudre",
|
||||
"resolveAllTooltip": "Réindexer tous les documents en échec de cette exécution",
|
||||
"resolveOne": "Résoudre ce document",
|
||||
"more": "Plus",
|
||||
"less": "Moins",
|
||||
@@ -9544,7 +9555,6 @@
|
||||
"badges": {
|
||||
"canceled": "Annulé",
|
||||
"deleting": "Suppression",
|
||||
"error": "Erreur",
|
||||
"failed": "Échec",
|
||||
"indexed": "Indexé",
|
||||
"indexing": "Indexation",
|
||||
|
||||
@@ -2714,17 +2714,16 @@
|
||||
"reIndex": {
|
||||
"tooltip": {
|
||||
"indexing": "インデックス作成の実行中は再インデックスできません",
|
||||
"invalid": "再インデックスの前にコネクターの設定を修正してください",
|
||||
"paused": "再インデックスの前にコネクターを再開してください"
|
||||
"invalid": "再インデックスの前にコネクターの設定を修正してください"
|
||||
}
|
||||
},
|
||||
"toggleStatus": {
|
||||
"pause": {
|
||||
"label": "インデックス作成と同期を一時停止",
|
||||
"label": "インデックスと同期を一時停止",
|
||||
"description": "進行中および今後のインデックス実行と権限の同期を取り消します。インデックス済みのドキュメントは最新ではなくなりますが、引き続き検索できます。"
|
||||
},
|
||||
"resume": {
|
||||
"label": "インデックス作成と同期を再開",
|
||||
"label": "インデックスと同期を再開",
|
||||
"description": "インデックス実行と権限の同期を再開します。"
|
||||
},
|
||||
"tooltip": {
|
||||
@@ -2740,7 +2739,11 @@
|
||||
},
|
||||
"fullReindex": {
|
||||
"label": "完全な再インデックス",
|
||||
"description": "ソースの全ドキュメントを再インデックスします。規模によっては**数時間から数日**かかります。"
|
||||
"description": "ソースの全ドキュメントを再インデックスします。規模によっては **数時間から数日** かかります。"
|
||||
},
|
||||
"startIndexing": {
|
||||
"label": "インデックス作成を開始",
|
||||
"description": "コネクターのチェックをスキップして、今すぐインデックスを作成します。"
|
||||
}
|
||||
},
|
||||
"reEnableModal": {
|
||||
@@ -2783,6 +2786,7 @@
|
||||
"label": "インデックス済みドキュメント"
|
||||
},
|
||||
"indexingSpeed": "{speed} 件/分",
|
||||
"indexingSpeedTooltip": "コネクター作成以降の1分あたりのインデックス済みドキュメント数",
|
||||
"lastIndexed": {
|
||||
"value": "最終インデックス作成 {when}"
|
||||
},
|
||||
@@ -2942,7 +2946,9 @@
|
||||
"queued": "待機中",
|
||||
"running": "実行中",
|
||||
"rate": "{rate} 件/分",
|
||||
"finished": "{time}({minutes, number} 分)"
|
||||
"finished": "{time}({minutes, number} 分)",
|
||||
"rateFinished": "{rate} 件/分({minutes, number} 分)",
|
||||
"membershipRate": "{rate} メンバーシップ/分"
|
||||
},
|
||||
"actions": {
|
||||
"stages": "ステージの指標を表示",
|
||||
@@ -2976,7 +2982,8 @@
|
||||
"canceled": "{count, plural, other {# 件の実行がキャンセルされました}}",
|
||||
"passedChecks": "{count, plural, one {# 件のチェックが成功} other {# 件のチェックが成功}}",
|
||||
"failedChecks": "{count, plural, one {# 件のチェックが失敗} other {# 件のチェックが失敗}}",
|
||||
"skippedChecks": "{count, plural, one {# 件のチェックをスキップ} other {# 件のチェックをスキップ}}"
|
||||
"skippedChecks": "{count, plural, one {# 件のチェックをスキップ} other {# 件のチェックをスキップ}}",
|
||||
"syncedDocs": "{count, plural, other {# 件のドキュメントを同期済み}}"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "インデックス実行を検索…"
|
||||
@@ -3004,7 +3011,9 @@
|
||||
"empty": {
|
||||
"title": "最近のインデックス実行はありません",
|
||||
"filteredTitle": "このフィルターに一致する実行はありません。",
|
||||
"description": "実行はバックグラウンドでスケジュールされるため、表示されるまでに少し時間がかかる場合があります。"
|
||||
"pastRuns": "{count, plural, other {過去の実行 # 件}}",
|
||||
"view": "表示",
|
||||
"viewAria": "{count, plural, other {過去の実行 # 件を表示}}"
|
||||
},
|
||||
"units": "{count, plural, other {件の実行}}",
|
||||
"errorModal": {
|
||||
@@ -3022,7 +3031,8 @@
|
||||
"title": "コネクタチェック",
|
||||
"close": "閉じる",
|
||||
"count": "({count})"
|
||||
}
|
||||
},
|
||||
"showing": "<mono>{total}</mono> 件中 <mono>{from}</mono>~<mono>{to}</mono> 件を表示"
|
||||
},
|
||||
"activity": {
|
||||
"search": {
|
||||
@@ -3137,6 +3147,7 @@
|
||||
"placeholder": "ドキュメントを検索…"
|
||||
},
|
||||
"resolveAll": "すべて解決",
|
||||
"resolveAllTooltip": "この実行で失敗したすべてのドキュメントを再インデックス",
|
||||
"resolveOne": "このドキュメントを解決",
|
||||
"more": "さらに表示",
|
||||
"less": "折りたたむ",
|
||||
@@ -9544,7 +9555,6 @@
|
||||
"badges": {
|
||||
"canceled": "キャンセル済み",
|
||||
"deleting": "削除中",
|
||||
"error": "エラー",
|
||||
"failed": "失敗",
|
||||
"indexed": "インデックス済み",
|
||||
"indexing": "インデックス作成中",
|
||||
|
||||
@@ -2714,8 +2714,7 @@
|
||||
"reIndex": {
|
||||
"tooltip": {
|
||||
"indexing": "인덱싱이 이미 진행 중이면 재인덱싱할 수 없습니다",
|
||||
"invalid": "재인덱싱하기 전에 커넥터 구성을 수정하세요",
|
||||
"paused": "재인덱싱하기 전에 커넥터를 다시 시작하세요"
|
||||
"invalid": "재인덱싱하기 전에 커넥터 구성을 수정하세요"
|
||||
}
|
||||
},
|
||||
"toggleStatus": {
|
||||
@@ -2741,6 +2740,10 @@
|
||||
"fullReindex": {
|
||||
"label": "전체 재인덱싱",
|
||||
"description": "소스의 모든 문서를 다시 인덱싱합니다. 규모에 따라 **몇 시간 또는 며칠**이 걸릴 수 있습니다."
|
||||
},
|
||||
"startIndexing": {
|
||||
"label": "인덱싱 시작",
|
||||
"description": "커넥터 검사를 건너뛰고 지금 인덱싱합니다."
|
||||
}
|
||||
},
|
||||
"reEnableModal": {
|
||||
@@ -2783,6 +2786,7 @@
|
||||
"label": "인덱싱 완료 문서"
|
||||
},
|
||||
"indexingSpeed": "분당 {speed}개 문서",
|
||||
"indexingSpeedTooltip": "커넥터 생성 이후 분당 인덱싱된 문서 수",
|
||||
"lastIndexed": {
|
||||
"value": "마지막 인덱싱 {when}"
|
||||
},
|
||||
@@ -2942,7 +2946,9 @@
|
||||
"queued": "대기 중",
|
||||
"running": "실행 중",
|
||||
"rate": "분당 {rate}개 문서",
|
||||
"finished": "{time} ({minutes, number}분)"
|
||||
"finished": "{time} ({minutes, number}분)",
|
||||
"rateFinished": "분당 {rate}개 문서 ({minutes, number}분)",
|
||||
"membershipRate": "{rate} 멤버십/분"
|
||||
},
|
||||
"actions": {
|
||||
"stages": "단계별 지표 보기",
|
||||
@@ -2976,7 +2982,8 @@
|
||||
"canceled": "{count, plural, other {실행 #개 취소됨}}",
|
||||
"passedChecks": "{count, plural, one {검사 #개 통과} other {검사 #개 통과}}",
|
||||
"failedChecks": "{count, plural, one {검사 #개 실패} other {검사 #개 실패}}",
|
||||
"skippedChecks": "{count, plural, one {검사 #개 건너뜀} other {검사 #개 건너뜀}}"
|
||||
"skippedChecks": "{count, plural, one {검사 #개 건너뜀} other {검사 #개 건너뜀}}",
|
||||
"syncedDocs": "{count, plural, other {문서 #개 동기화됨}}"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "인덱싱 실행 검색…"
|
||||
@@ -3004,7 +3011,9 @@
|
||||
"empty": {
|
||||
"title": "최근 인덱싱 실행이 없습니다",
|
||||
"filteredTitle": "이 필터와 일치하는 실행이 없습니다.",
|
||||
"description": "실행은 백그라운드에서 예약되며 표시되기까지 잠시 걸릴 수 있습니다."
|
||||
"pastRuns": "{count, plural, other {지난 실행 #개}}",
|
||||
"view": "보기",
|
||||
"viewAria": "{count, plural, other {지난 실행 #개 보기}}"
|
||||
},
|
||||
"units": "{count, plural, other {실행}}",
|
||||
"errorModal": {
|
||||
@@ -3022,7 +3031,8 @@
|
||||
"title": "커넥터 검사",
|
||||
"close": "닫기",
|
||||
"count": "({count})"
|
||||
}
|
||||
},
|
||||
"showing": "<mono>{total}</mono>개 중 <mono>{from}</mono>~<mono>{to}</mono>개 표시"
|
||||
},
|
||||
"activity": {
|
||||
"search": {
|
||||
@@ -3137,6 +3147,7 @@
|
||||
"placeholder": "문서 검색…"
|
||||
},
|
||||
"resolveAll": "모두 해결",
|
||||
"resolveAllTooltip": "이 실행에서 실패한 모든 문서를 다시 색인",
|
||||
"resolveOne": "이 문서 해결",
|
||||
"more": "더 보기",
|
||||
"less": "접기",
|
||||
@@ -9544,7 +9555,6 @@
|
||||
"badges": {
|
||||
"canceled": "취소됨",
|
||||
"deleting": "삭제 중",
|
||||
"error": "오류",
|
||||
"failed": "실패",
|
||||
"indexed": "인덱싱됨",
|
||||
"indexing": "인덱싱 중",
|
||||
|
||||
@@ -2714,8 +2714,7 @@
|
||||
"reIndex": {
|
||||
"tooltip": {
|
||||
"indexing": "Não é possível reindexar enquanto uma indexação está em andamento",
|
||||
"invalid": "Corrija a configuração do conector antes de reindexar",
|
||||
"paused": "Retome o conector antes de reindexar"
|
||||
"invalid": "Corrija a configuração do conector antes de reindexar"
|
||||
}
|
||||
},
|
||||
"toggleStatus": {
|
||||
@@ -2741,6 +2740,10 @@
|
||||
"fullReindex": {
|
||||
"label": "Reindexar tudo",
|
||||
"description": "Reindexar todos os documentos da fonte. Pode levar **horas ou dias** dependendo do tamanho."
|
||||
},
|
||||
"startIndexing": {
|
||||
"label": "Iniciar indexação",
|
||||
"description": "Ignorar as verificações do conector e indexar agora."
|
||||
}
|
||||
},
|
||||
"reEnableModal": {
|
||||
@@ -2783,6 +2786,7 @@
|
||||
"label": "Documentos no índice"
|
||||
},
|
||||
"indexingSpeed": "{speed} documentos/min",
|
||||
"indexingSpeedTooltip": "Documentos indexados por minuto desde a criação do conector",
|
||||
"lastIndexed": {
|
||||
"value": "Última indexação {when}"
|
||||
},
|
||||
@@ -2942,7 +2946,9 @@
|
||||
"queued": "Na fila",
|
||||
"running": "Em execução",
|
||||
"rate": "{rate} documentos/min",
|
||||
"finished": "{time} ({minutes, number} min)"
|
||||
"finished": "{time} ({minutes, number} min)",
|
||||
"rateFinished": "{rate} documentos/min ({minutes, number} min)",
|
||||
"membershipRate": "{rate} associações/min"
|
||||
},
|
||||
"actions": {
|
||||
"stages": "Ver métricas por etapa",
|
||||
@@ -2976,7 +2982,8 @@
|
||||
"canceled": "{count, plural, one {# execução cancelada} other {# execuções canceladas}}",
|
||||
"passedChecks": "{count, plural, one {# verificação aprovada} other {# verificações aprovadas}}",
|
||||
"failedChecks": "{count, plural, one {# verificação falhou} other {# verificações falharam}}",
|
||||
"skippedChecks": "{count, plural, one {# verificação ignorada} other {# verificações ignoradas}}"
|
||||
"skippedChecks": "{count, plural, one {# verificação ignorada} other {# verificações ignoradas}}",
|
||||
"syncedDocs": "{count, plural, one {# documento sincronizado} other {# documentos sincronizados}}"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Pesquisar execuções de indexação…"
|
||||
@@ -3004,7 +3011,9 @@
|
||||
"empty": {
|
||||
"title": "Nenhuma execução de indexação recente",
|
||||
"filteredTitle": "Nenhuma execução corresponde a estes filtros.",
|
||||
"description": "As execuções são agendadas em segundo plano e podem demorar um pouco para aparecer."
|
||||
"pastRuns": "{count, plural, one {# execução anterior} other {# execuções anteriores}}",
|
||||
"view": "Ver",
|
||||
"viewAria": "{count, plural, one {Ver # execução anterior} other {Ver # execuções anteriores}}"
|
||||
},
|
||||
"units": "{count, plural, one {execução} other {execuções}}",
|
||||
"errorModal": {
|
||||
@@ -3022,7 +3031,8 @@
|
||||
"title": "Verificações do conector",
|
||||
"close": "Fechar",
|
||||
"count": "({count})"
|
||||
}
|
||||
},
|
||||
"showing": "Mostrando <mono>{from}</mono>~<mono>{to}</mono> de <mono>{total}</mono>"
|
||||
},
|
||||
"activity": {
|
||||
"search": {
|
||||
@@ -3137,6 +3147,7 @@
|
||||
"placeholder": "Pesquisar documentos…"
|
||||
},
|
||||
"resolveAll": "Resolver tudo",
|
||||
"resolveAllTooltip": "Reindexar todos os documentos com falha desta execução",
|
||||
"resolveOne": "Resolver este documento",
|
||||
"more": "Mais",
|
||||
"less": "Menos",
|
||||
@@ -9544,7 +9555,6 @@
|
||||
"badges": {
|
||||
"canceled": "Cancelado",
|
||||
"deleting": "Excluindo",
|
||||
"error": "Erro",
|
||||
"failed": "Falhou",
|
||||
"indexed": "Indexado",
|
||||
"indexing": "Indexando",
|
||||
|
||||
@@ -2714,17 +2714,16 @@
|
||||
"reIndex": {
|
||||
"tooltip": {
|
||||
"indexing": "索引正在进行中,无法重新索引",
|
||||
"invalid": "重新索引前请先修复连接器配置",
|
||||
"paused": "重新索引前请先恢复连接器"
|
||||
"invalid": "重新索引前请先修复连接器配置"
|
||||
}
|
||||
},
|
||||
"toggleStatus": {
|
||||
"pause": {
|
||||
"label": "暂停索引和同步",
|
||||
"label": "暂停索引与同步",
|
||||
"description": "取消进行中和未来的索引运行与权限同步。已索引的文档将不再保持同步,但仍可搜索。"
|
||||
},
|
||||
"resume": {
|
||||
"label": "恢复索引和同步",
|
||||
"label": "恢复索引与同步",
|
||||
"description": "重新开始索引运行与权限同步。"
|
||||
},
|
||||
"tooltip": {
|
||||
@@ -2740,7 +2739,11 @@
|
||||
},
|
||||
"fullReindex": {
|
||||
"label": "完全重新索引",
|
||||
"description": "重新索引来源中的所有文档。视语料规模可能需要**数小时或数天**。"
|
||||
"description": "重新索引来源中的所有文档。视语料规模可能需要 **数小时或数天**。"
|
||||
},
|
||||
"startIndexing": {
|
||||
"label": "开始索引",
|
||||
"description": "跳过连接器检查并立即索引。"
|
||||
}
|
||||
},
|
||||
"reEnableModal": {
|
||||
@@ -2783,6 +2786,7 @@
|
||||
"label": "已索引文档"
|
||||
},
|
||||
"indexingSpeed": "{speed} 文档/分钟",
|
||||
"indexingSpeedTooltip": "自创建连接器以来每分钟索引的文档数",
|
||||
"lastIndexed": {
|
||||
"value": "上次索引 {when}"
|
||||
},
|
||||
@@ -2942,7 +2946,9 @@
|
||||
"queued": "排队中",
|
||||
"running": "运行中",
|
||||
"rate": "{rate} 文档/分钟",
|
||||
"finished": "{time}({minutes, number} 分钟)"
|
||||
"finished": "{time}({minutes, number} 分钟)",
|
||||
"rateFinished": "{rate} 文档/分钟({minutes, number} 分钟)",
|
||||
"membershipRate": "{rate} 成员关系/分钟"
|
||||
},
|
||||
"actions": {
|
||||
"stages": "查看阶段指标",
|
||||
@@ -2976,7 +2982,8 @@
|
||||
"canceled": "{count, plural, other {已取消 # 次运行}}",
|
||||
"passedChecks": "{count, plural, one {# 项检查通过} other {# 项检查通过}}",
|
||||
"failedChecks": "{count, plural, one {# 项检查失败} other {# 项检查失败}}",
|
||||
"skippedChecks": "{count, plural, one {# 项检查已跳过} other {# 项检查已跳过}}"
|
||||
"skippedChecks": "{count, plural, one {# 项检查已跳过} other {# 项检查已跳过}}",
|
||||
"syncedDocs": "{count, plural, other {已同步 # 个文档}}"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索索引运行…"
|
||||
@@ -3004,7 +3011,9 @@
|
||||
"empty": {
|
||||
"title": "暂无近期索引运行",
|
||||
"filteredTitle": "没有符合这些筛选条件的运行。",
|
||||
"description": "运行在后台调度,可能需要一点时间才会显示。"
|
||||
"pastRuns": "{count, plural, other {# 次过去的运行}}",
|
||||
"view": "查看",
|
||||
"viewAria": "{count, plural, other {查看 # 次过去的运行}}"
|
||||
},
|
||||
"units": "{count, plural, other {个运行}}",
|
||||
"errorModal": {
|
||||
@@ -3022,7 +3031,8 @@
|
||||
"title": "连接器检查",
|
||||
"close": "关闭",
|
||||
"count": "({count})"
|
||||
}
|
||||
},
|
||||
"showing": "显示第 <mono>{from}</mono>~<mono>{to}</mono> 条,共 <mono>{total}</mono> 条"
|
||||
},
|
||||
"activity": {
|
||||
"search": {
|
||||
@@ -3137,6 +3147,7 @@
|
||||
"placeholder": "搜索文档…"
|
||||
},
|
||||
"resolveAll": "全部解决",
|
||||
"resolveAllTooltip": "重新索引此运行中所有失败的文档",
|
||||
"resolveOne": "解决此文档",
|
||||
"more": "更多",
|
||||
"less": "收起",
|
||||
@@ -9544,7 +9555,6 @@
|
||||
"badges": {
|
||||
"canceled": "已取消",
|
||||
"deleting": "正在删除",
|
||||
"error": "错误",
|
||||
"failed": "失败",
|
||||
"indexed": "已索引",
|
||||
"indexing": "正在索引",
|
||||
|
||||
@@ -191,6 +191,7 @@ export interface PruneRunSnapshot {
|
||||
id: number;
|
||||
status: PruneRunStatus;
|
||||
docs_removed: number;
|
||||
docs_failed: number;
|
||||
error_message: string | null;
|
||||
time_created: string;
|
||||
time_started: string | null;
|
||||
|
||||
+15
-10
@@ -68,9 +68,7 @@ export type CheckLine = Pick<
|
||||
| "docs_link"
|
||||
> & { state: CheckLineState };
|
||||
|
||||
// Outcomes that need reading (passed, failed, unverified) show their details
|
||||
// in a heavier font and their status colour; the rest stay muted.
|
||||
const OUTCOME_FONT: TextFont = "main-ui-action";
|
||||
// Passed and failed read plainly, an unverified check is heavy and orange so it gets read, and the rest stay muted.
|
||||
const MUTED = { font: "secondary-body", color: "text-03" } as const;
|
||||
|
||||
const CHECK_LOGS = {
|
||||
@@ -78,22 +76,22 @@ const CHECK_LOGS = {
|
||||
variant: "success-light",
|
||||
icon: SvgCheckCircle,
|
||||
detail: "status.passed",
|
||||
font: OUTCOME_FONT,
|
||||
font: "main-ui-body",
|
||||
color: "text-04",
|
||||
},
|
||||
// A failed required check blocks, so `CheckLog` makes it heavy.
|
||||
// Only a failed required check blocks, so `CheckLog` makes it heavy and red.
|
||||
failed: {
|
||||
variant: "error-light",
|
||||
icon: SvgXOctagon,
|
||||
detail: "status.failed",
|
||||
font: OUTCOME_FONT,
|
||||
color: "status-error-05",
|
||||
font: "main-ui-body",
|
||||
color: "text-04",
|
||||
},
|
||||
indeterminate: {
|
||||
variant: "warning-light",
|
||||
icon: SvgAlertCircle,
|
||||
detail: "status.indeterminate",
|
||||
font: OUTCOME_FONT,
|
||||
font: "main-ui-action",
|
||||
color: "status-warning-05",
|
||||
},
|
||||
skipped: {
|
||||
@@ -177,7 +175,7 @@ function CheckLog({ check }: CheckLogProps) {
|
||||
<OverflowText
|
||||
as="p"
|
||||
font={font}
|
||||
color={color}
|
||||
color={blocking ? "status-error-05" : color}
|
||||
textPosition="text-start"
|
||||
>
|
||||
{check.message || t(detail)}
|
||||
@@ -191,7 +189,14 @@ function CheckLog({ check }: CheckLogProps) {
|
||||
tooltip={guidance}
|
||||
/>
|
||||
)}
|
||||
{check.required && <Tag title={t("required.label")} color="gray" />}
|
||||
{check.required && (
|
||||
<Tag
|
||||
title={t("required.label")}
|
||||
color="white"
|
||||
variant="status"
|
||||
font="secondary-action"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -46,6 +46,9 @@ import {
|
||||
runWarnings,
|
||||
} from "@/views/admin/connectors/ConnectorDetailPage/RunRow";
|
||||
|
||||
// A Tag is clickable only with an onClick, and the popover trigger around it handles the click.
|
||||
const NOOP = () => {};
|
||||
|
||||
const GROUP_STYLE: Record<
|
||||
ActivityGroup,
|
||||
{
|
||||
@@ -53,6 +56,8 @@ const GROUP_STYLE: Record<
|
||||
icon: IconFunctionComponent;
|
||||
stroke: string;
|
||||
closedStroke: string;
|
||||
/** The 1px edge a hovered or selected card takes, in the level's 02 color. */
|
||||
outline: string;
|
||||
}
|
||||
> = {
|
||||
failures: {
|
||||
@@ -60,18 +65,21 @@ const GROUP_STYLE: Record<
|
||||
icon: SvgXOctagon,
|
||||
stroke: "stroke-status-error-05",
|
||||
closedStroke: "stroke-status-error-02",
|
||||
outline: "outline-status-error-02",
|
||||
},
|
||||
errors: {
|
||||
color: "status-warning-00",
|
||||
icon: SvgAlertTriangle,
|
||||
stroke: "stroke-status-warning-05",
|
||||
closedStroke: "stroke-status-warning-02",
|
||||
outline: "outline-status-warning-02",
|
||||
},
|
||||
other: {
|
||||
color: "background-tint-00",
|
||||
icon: SvgCheckCircle,
|
||||
stroke: "stroke-status-success-05",
|
||||
closedStroke: "stroke-status-success-02",
|
||||
outline: "outline-border-01",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -167,7 +175,16 @@ export default function ActivityCard({
|
||||
resolving,
|
||||
}: ActivityCardProps) {
|
||||
const t = useTranslations("admin.connector.activity");
|
||||
const [traceOpen, setTraceOpen] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [openPopover, setOpenPopover] = useState<"trace" | "items" | null>(
|
||||
null
|
||||
);
|
||||
const selected = openPopover !== null;
|
||||
const togglePopover = (popover: "trace" | "items") => (open: boolean) =>
|
||||
setOpenPopover((current) =>
|
||||
open ? popover : current === popover ? null : current
|
||||
);
|
||||
const tRuns = useTranslations("admin.connector.runs");
|
||||
const active = isActive(run);
|
||||
const style = GROUP_STYLE[group];
|
||||
@@ -218,13 +235,39 @@ export default function ActivityCard({
|
||||
count: listsDocs ? openDocs : warnings,
|
||||
});
|
||||
|
||||
const pillIconColor = closed ? undefined : "orange";
|
||||
|
||||
// The outline draws the edge inside the card, so hovering does not shift the list.
|
||||
return (
|
||||
<Section height="fit" className="group/activity">
|
||||
<Section
|
||||
alignItems="stretch"
|
||||
height="fit"
|
||||
className={cn(
|
||||
"rounded-08 -outline-offset-1",
|
||||
(hovered || selected) && "outline-1 outline-solid",
|
||||
(hovered || selected) && style.outline
|
||||
)}
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
setFocused(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
color={closed ? "background-tint-01" : style.color}
|
||||
color={
|
||||
hovered && !selected
|
||||
? "background-tint-02"
|
||||
: closed
|
||||
? "background-tint-01"
|
||||
: style.color
|
||||
}
|
||||
border="none"
|
||||
rounding={2}
|
||||
padding={1}
|
||||
shadow={selected ? "md" : "none"}
|
||||
>
|
||||
<Section alignItems="stretch" gap={0} height="fit">
|
||||
<Section
|
||||
@@ -331,15 +374,14 @@ export default function ActivityCard({
|
||||
wrap
|
||||
>
|
||||
{trace !== null && (
|
||||
<Popover open={traceOpen} onOpenChange={setTraceOpen}>
|
||||
<Popover onOpenChange={togglePopover("trace")}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
<Tag
|
||||
icon={SvgTextLines}
|
||||
prominence={traceOpen ? "primary" : "tertiary"}
|
||||
size="xs"
|
||||
>
|
||||
{t("tags.stackTrace")}
|
||||
</Button>
|
||||
title={t("tags.stackTrace")}
|
||||
size="md"
|
||||
onClick={NOOP}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<StackTracePopoverContent
|
||||
runId={run.attempt.id}
|
||||
@@ -348,30 +390,49 @@ export default function ActivityCard({
|
||||
</Popover>
|
||||
)}
|
||||
{listsDocs ? (
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<Button icon={SvgFiles} prominence="tertiary" size="xs">
|
||||
{docsLabel}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<FailedDocsPopoverContent
|
||||
ccPairId={ccPairId}
|
||||
indexAttemptId={run.attempt.id}
|
||||
canResolve={resolvable}
|
||||
onResolve={onResolve}
|
||||
resolving={resolving}
|
||||
/>
|
||||
</Popover>
|
||||
) : run.kind === "checks" && listsChecks ? (
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<>
|
||||
<Popover onOpenChange={togglePopover("items")}>
|
||||
<Popover.Trigger asChild>
|
||||
<Tag
|
||||
icon={WARNING_ICONS.indexing}
|
||||
iconColor={pillIconColor}
|
||||
title={docsLabel}
|
||||
size="md"
|
||||
onClick={NOOP}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<FailedDocsPopoverContent
|
||||
ccPairId={ccPairId}
|
||||
indexAttemptId={run.attempt.id}
|
||||
canResolve={resolvable}
|
||||
onResolve={onResolve}
|
||||
resolving={resolving}
|
||||
/>
|
||||
</Popover>
|
||||
{/* One click re-indexes every open failed document, without opening the list. */}
|
||||
{resolvable && !closed && (
|
||||
<Button
|
||||
icon={WARNING_ICONS.checks}
|
||||
rightIcon={SvgCheckAll}
|
||||
prominence="tertiary"
|
||||
size="xs"
|
||||
disabled={resolving}
|
||||
tooltip={t("docs.resolveAllTooltip")}
|
||||
onClick={() => onResolve(null)}
|
||||
>
|
||||
{docsLabel}
|
||||
{t("docs.resolveAll")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : run.kind === "checks" && listsChecks ? (
|
||||
<Popover onOpenChange={togglePopover("items")}>
|
||||
<Popover.Trigger asChild>
|
||||
<Tag
|
||||
icon={WARNING_ICONS.checks}
|
||||
iconColor={pillIconColor}
|
||||
title={docsLabel}
|
||||
size="md"
|
||||
onClick={NOOP}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<CheckRunPopoverContent run={run.attempt} beside />
|
||||
</Popover>
|
||||
@@ -379,8 +440,9 @@ export default function ActivityCard({
|
||||
warnings > 0 && (
|
||||
<Tag
|
||||
icon={WARNING_ICONS[run.kind]}
|
||||
iconColor={pillIconColor}
|
||||
title={docsLabel}
|
||||
color="gray"
|
||||
size="md"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -396,11 +458,14 @@ export default function ActivityCard({
|
||||
</SelectButton>
|
||||
) : (
|
||||
resolvesRun && (
|
||||
// Only on hover or focus, so an open list reads as its runs, not a column of actions. An open popover moves focus into its portal, so its trigger's open state keeps the link shown.
|
||||
// Only on hover, focus or an open pill, so an open list reads as its runs. Opacity keeps it in the tab order.
|
||||
<Section
|
||||
width="fit"
|
||||
height="fit"
|
||||
className="opacity-0 transition-opacity no-hover:opacity-100 group-hover/activity:opacity-100 group-focus-within/activity:opacity-100 group-has-[[data-state=open]]/activity:opacity-100"
|
||||
className={cn(
|
||||
"transition-opacity no-hover:opacity-100",
|
||||
!(hovered || focused || selected) && "opacity-0"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
rightIcon={SvgCheckAll}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IllustrationContent,
|
||||
Section,
|
||||
} from "@opal/layouts";
|
||||
import { SvgLongWait } from "@opal/illustrations";
|
||||
import { CardLoader } from "@opal/loaders";
|
||||
import {
|
||||
SvgActivity,
|
||||
@@ -279,6 +280,19 @@ export default function ActivityPanel({
|
||||
return shown === "all" ? t("empty.title") : t(`empty.${shown}`);
|
||||
}
|
||||
|
||||
const countTags = (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
gap={0.5}
|
||||
padding={title === undefined ? 1.5 : 0}
|
||||
width="fit"
|
||||
height="fit"
|
||||
>
|
||||
<ActivityCountTags failures={failures.total} errors={errors.total} />
|
||||
</Section>
|
||||
);
|
||||
|
||||
// Beside the main column the panel pins under the settings header and
|
||||
// scrolls inside its own height, so a long list never lengthens the page.
|
||||
return (
|
||||
@@ -320,6 +334,7 @@ export default function ActivityPanel({
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{/* Beside the page the search shares its row with the counts. The side panel gives the search its own row. */}
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
@@ -330,86 +345,85 @@ export default function ActivityPanel({
|
||||
<Section height="fit" className="min-w-0 flex-1">
|
||||
<InputTypeIn
|
||||
searchIcon
|
||||
variant="internal"
|
||||
variant={title === undefined ? "internal" : "primary"}
|
||||
placeholder={t("search.placeholder")}
|
||||
maxLength={SEARCH_MAX_LENGTH}
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
/>
|
||||
</Section>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
gap={2}
|
||||
padding={1.5}
|
||||
width="fit"
|
||||
height="fit"
|
||||
>
|
||||
<ActivityCountTags
|
||||
failures={failures.total}
|
||||
errors={errors.total}
|
||||
/>
|
||||
</Section>
|
||||
{title === undefined && countTags}
|
||||
</Section>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
justifyContent="between"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
height="fit"
|
||||
wrap
|
||||
>
|
||||
<Dropdown>
|
||||
<Dropdown.Trigger asChild>
|
||||
<FilterButton
|
||||
icon={SvgActivity}
|
||||
active={shown !== "all"}
|
||||
onClear={() => setShown("all")}
|
||||
>
|
||||
{t(`filter.${shown}`)}
|
||||
</FilterButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Data
|
||||
label={t("filter.label")}
|
||||
value={shown}
|
||||
onSelect={(option) =>
|
||||
setShown(
|
||||
shownOptions.find(
|
||||
(candidate) => candidate === option.value
|
||||
) ?? "all"
|
||||
)
|
||||
}
|
||||
items={shownOptions.map((candidate) => ({
|
||||
kind: "option" as const,
|
||||
value: candidate,
|
||||
title: t(`filter.${candidate}`),
|
||||
}))}
|
||||
/>
|
||||
</Dropdown>
|
||||
<SelectButton
|
||||
variant="select-filter"
|
||||
icon={showResolved ? SvgEye : SvgEyeClosed}
|
||||
state={showResolved ? "selected" : "empty"}
|
||||
onClick={() => setShowResolved((value) => !value)}
|
||||
{/* The chart's span sits with the other filters, inside the row that wraps instead of overflowing. */}
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
gap={1}
|
||||
width="fit"
|
||||
height="fit"
|
||||
wrap
|
||||
>
|
||||
{t("filter.resolved")}
|
||||
</SelectButton>
|
||||
{canResolveAll && resolvable.count > 0 && (
|
||||
<Button
|
||||
rightIcon={SvgCheckAll}
|
||||
prominence="tertiary"
|
||||
disabled={resolving || !resolveAllSettled}
|
||||
onClick={() =>
|
||||
setConfirmingResolveAll({
|
||||
level: resolveAllLevel,
|
||||
q: search,
|
||||
timeRange,
|
||||
count: resolvable.count,
|
||||
})
|
||||
}
|
||||
<Dropdown>
|
||||
<Dropdown.Trigger asChild>
|
||||
<FilterButton
|
||||
icon={SvgActivity}
|
||||
active={shown !== "all"}
|
||||
onClear={() => setShown("all")}
|
||||
>
|
||||
{t(`filter.${shown}`)}
|
||||
</FilterButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Data
|
||||
label={t("filter.label")}
|
||||
value={shown}
|
||||
onSelect={(option) =>
|
||||
setShown(
|
||||
shownOptions.find(
|
||||
(candidate) => candidate === option.value
|
||||
) ?? "all"
|
||||
)
|
||||
}
|
||||
items={shownOptions.map((candidate) => ({
|
||||
kind: "option" as const,
|
||||
value: candidate,
|
||||
title: t(`filter.${candidate}`),
|
||||
}))}
|
||||
/>
|
||||
</Dropdown>
|
||||
<SelectButton
|
||||
variant="select-filter"
|
||||
icon={showResolved ? SvgEye : SvgEyeClosed}
|
||||
state={showResolved ? "selected" : "empty"}
|
||||
onClick={() => setShowResolved((value) => !value)}
|
||||
>
|
||||
{t("resolveAll.button")}
|
||||
</Button>
|
||||
)}
|
||||
{t("filter.resolved")}
|
||||
</SelectButton>
|
||||
{canResolveAll && resolvable.count > 0 && (
|
||||
<Button
|
||||
rightIcon={SvgCheckAll}
|
||||
prominence="tertiary"
|
||||
disabled={resolving || !resolveAllSettled}
|
||||
onClick={() =>
|
||||
setConfirmingResolveAll({
|
||||
level: resolveAllLevel,
|
||||
q: search,
|
||||
timeRange,
|
||||
count: resolvable.count,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("resolveAll.button")}
|
||||
</Button>
|
||||
)}
|
||||
</Section>
|
||||
{title !== undefined && countTags}
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
@@ -430,7 +444,10 @@ export default function ActivityPanel({
|
||||
))}
|
||||
</Section>
|
||||
) : empty ? (
|
||||
<IllustrationContent title={emptyTitle()} />
|
||||
<IllustrationContent
|
||||
illustration={SvgLongWait}
|
||||
title={emptyTitle()}
|
||||
/>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<GroupList
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { Button, Popover, ShadowDiv, Text } from "@opal/components";
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
SelectButton,
|
||||
ShadowDiv,
|
||||
Text,
|
||||
} from "@opal/components";
|
||||
import { Section } from "@opal/layouts";
|
||||
import { SvgListCheck, SvgX } from "@opal/icons";
|
||||
import type { CapabilityCheckStatus } from "@/lib/connectors/checks/types";
|
||||
import type { CheckRunSnapshot } from "@/lib/connectors/types";
|
||||
import {
|
||||
NO_CHECKS,
|
||||
useConnectorChecksProgress,
|
||||
} from "@/lib/connectors/checks/hooks";
|
||||
import { CheckLineGroups } from "@/views/admin/connectors/AddConnectorPage/components/CredentialChecksCard";
|
||||
import { checkCount } from "@/views/admin/connectors/ConnectorDetailPage/RunRow";
|
||||
|
||||
/** Opens the results of one check run. Place it inside the Popover that holds the content. */
|
||||
export function CheckRunTrigger() {
|
||||
export interface CheckRunTriggerProps {
|
||||
/** Whether its popover is open. The open trigger shows selected. */
|
||||
open?: boolean;
|
||||
}
|
||||
export function CheckRunTrigger({ open = false }: CheckRunTriggerProps) {
|
||||
const t = useTranslations("admin.connector.runs");
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
<SelectButton
|
||||
icon={SvgListCheck}
|
||||
prominence="tertiary"
|
||||
variant="select-light"
|
||||
state={open ? "selected" : "empty"}
|
||||
size="xs"
|
||||
tooltip={t("actions.checks")}
|
||||
aria-label={t("actions.checks")}
|
||||
@@ -55,6 +70,10 @@ export default function CheckRunPopoverContent({
|
||||
tChecks(part.label, { count: checkCount(run, part.status) })
|
||||
);
|
||||
const summary = format.list(labels, { type: "unit" });
|
||||
const stateCounts = { ...NO_CHECKS };
|
||||
for (const result of run.check_results) stateCounts[result.status] += 1;
|
||||
// The checks card's ring, for a stored run.
|
||||
const { icon: Ring } = useConnectorChecksProgress(stateCounts, "passed");
|
||||
|
||||
return (
|
||||
<Popover.Content
|
||||
@@ -93,6 +112,7 @@ export default function CheckRunPopoverContent({
|
||||
gap={1}
|
||||
height="fit"
|
||||
>
|
||||
<Ring size={16} className="shrink-0" />
|
||||
<Text font="main-ui-action" color="text-04">
|
||||
{t("title")}
|
||||
</Text>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useDebouncedValue, useOverflow } from "@opal/hooks";
|
||||
import { Section } from "@opal/layouts";
|
||||
import { LineLoader } from "@opal/loaders";
|
||||
import { formatDateTimeLog } from "@/lib/dateUtils";
|
||||
import {
|
||||
SvgCheckAll,
|
||||
SvgCheckSquare,
|
||||
@@ -25,7 +26,6 @@ import type {
|
||||
IndexAttemptError,
|
||||
PaginatedIndexAttemptErrors,
|
||||
} from "@/lib/connectors/types";
|
||||
import { formatDateTimeLog } from "@/lib/dateUtils";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import {
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
const PAGE_SIZE = 50;
|
||||
// The errors route refuses a longer search.
|
||||
const SEARCH_MAX_LENGTH = 200;
|
||||
const CLAMPED_LINES = 2;
|
||||
const CLAMPED_LINES = 5;
|
||||
|
||||
interface FailedDocRowProps {
|
||||
error: IndexAttemptError;
|
||||
@@ -63,7 +63,7 @@ function FailedDocRow({
|
||||
gap={1}
|
||||
padding={1}
|
||||
height="fit"
|
||||
className="rounded-08 hover:bg-background-tint-01"
|
||||
className="group/row rounded-08 hover:bg-background-tint-02"
|
||||
>
|
||||
<Section
|
||||
alignItems="start"
|
||||
@@ -72,7 +72,7 @@ function FailedDocRow({
|
||||
height="fit"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Text font="secondary-mono" color="text-03">
|
||||
<Text font="secondary-mono-label" color="text-04">
|
||||
{formatDateTimeLog(error.time_created)}
|
||||
</Text>
|
||||
{error.document_name !== null && (
|
||||
@@ -122,9 +122,12 @@ function FailedDocRow({
|
||||
)
|
||||
)}
|
||||
</Section>
|
||||
{/* The toggle follows the clamped text, so "More" sits right after the ellipsis. */}
|
||||
<Section
|
||||
alignItems="start"
|
||||
gap={0}
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="end"
|
||||
gap={0.5}
|
||||
height="fit"
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
@@ -138,25 +141,33 @@ function FailedDocRow({
|
||||
{error.failure_message}
|
||||
</Text>
|
||||
{(expanded || clipped) && (
|
||||
<LinkButton
|
||||
external={false}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
{expanded ? t("less") : t("more")}
|
||||
</LinkButton>
|
||||
<Section width="fit" height="fit" className="shrink-0">
|
||||
<LinkButton
|
||||
external={false}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
{expanded ? t("less") : t("more")}
|
||||
</LinkButton>
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
{/* Only a document can be re-fetched. An entity-level error has nothing to target. */}
|
||||
{canResolve && error.document_id !== null && (
|
||||
<Button
|
||||
icon={SvgCheckSquare}
|
||||
prominence="tertiary"
|
||||
size="xs"
|
||||
tooltip={t("resolveOne")}
|
||||
aria-label={t("resolveOne")}
|
||||
disabled={resolving}
|
||||
onClick={onResolve}
|
||||
/>
|
||||
<Section
|
||||
width="fit"
|
||||
height="fit"
|
||||
className="opacity-0 no-hover:opacity-100 group-hover/row:opacity-100 group-focus-within/row:opacity-100"
|
||||
>
|
||||
<Button
|
||||
icon={SvgCheckSquare}
|
||||
prominence="tertiary"
|
||||
size="xs"
|
||||
tooltip={t("resolveOne")}
|
||||
aria-label={t("resolveOne")}
|
||||
disabled={resolving}
|
||||
onClick={onResolve}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
@@ -250,7 +261,7 @@ function FailedDocsBody({
|
||||
{/* Resolve All takes every open error, so it hides while a search narrows the list. */}
|
||||
{canResolve && q === "" && (
|
||||
<Button
|
||||
icon={SvgCheckAll}
|
||||
rightIcon={SvgCheckAll}
|
||||
prominence="tertiary"
|
||||
size="xs"
|
||||
disabled={resolving}
|
||||
@@ -311,7 +322,7 @@ export default function FailedDocsPopoverContent(
|
||||
const t = useTranslations("admin.connector.activity.docs");
|
||||
return (
|
||||
<Popover.Content
|
||||
width="3xl"
|
||||
width="2.5xl"
|
||||
side="left"
|
||||
align="start"
|
||||
aria-label={t("title")}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { Button, Card, Popover, Tag, Text } from "@opal/components";
|
||||
import { Button, Card, Popover, Tag, Text, Tooltip } from "@opal/components";
|
||||
import { Content, Section } from "@opal/layouts";
|
||||
import {
|
||||
SvgAlertTriangle,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SvgFiles,
|
||||
SvgListCheck,
|
||||
SvgScissors,
|
||||
SvgSlowMotion,
|
||||
SvgUser,
|
||||
SvgUserShield,
|
||||
SvgUsers,
|
||||
@@ -34,6 +35,7 @@ import CheckRunPopoverContent, {
|
||||
import StageDetailsPopoverContent, {
|
||||
StageDetailsTrigger,
|
||||
} from "@/views/admin/connectors/ConnectorDetailPage/StageDetailsPopover";
|
||||
import { focusDimClass } from "@/views/admin/connectors/ConnectorDetailPage/focusMode";
|
||||
|
||||
export const RUN_KIND_ICONS = {
|
||||
indexing: SvgFiles,
|
||||
@@ -83,9 +85,11 @@ export function runWarnings(run: CCPairRun): number {
|
||||
return run.attempt.error_count;
|
||||
case "doc_permission_sync":
|
||||
return run.attempt.docs_with_permission_errors;
|
||||
// A group sync has no per-group failure signal, so it either fails or succeeds.
|
||||
case "group_sync":
|
||||
case "pruning":
|
||||
return 0;
|
||||
case "pruning":
|
||||
return run.attempt.docs_failed;
|
||||
case "checks":
|
||||
return checkCount(run.attempt, "failed");
|
||||
}
|
||||
@@ -129,19 +133,53 @@ export function finishedAt(run: CCPairRun): string | null {
|
||||
return run.attempt.time_finished;
|
||||
}
|
||||
|
||||
/** Whole minutes from the run's start to its end, or `null` before either. */
|
||||
export function runMinutes(run: CCPairRun): number | null {
|
||||
/** Minutes from the run's start to its end, fractional, or `null` before either. */
|
||||
function runDurationMinutes(run: CCPairRun): number | null {
|
||||
const finished = finishedAt(run);
|
||||
const started = run.attempt.time_started;
|
||||
if (finished === null || started === null) return null;
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(new Date(finished).getTime() - new Date(started).getTime()) / 60_000
|
||||
)
|
||||
);
|
||||
return (new Date(finished).getTime() - new Date(started).getTime()) / 60_000;
|
||||
}
|
||||
|
||||
/** Whole minutes from the run's start to its end, or `null` before either. */
|
||||
export function runMinutes(run: CCPairRun): number | null {
|
||||
const minutes = runDurationMinutes(run);
|
||||
return minutes === null ? null : Math.max(0, Math.round(minutes));
|
||||
}
|
||||
|
||||
/** Documents a finished index run indexed per minute of its run, or `null` while it goes on or when it indexed none. */
|
||||
function finishedIndexRate(run: CCPairRun): number | null {
|
||||
if (run.kind !== "indexing" || run.attempt.total_docs_indexed === 0) {
|
||||
return null;
|
||||
}
|
||||
const minutes = runDurationMinutes(run);
|
||||
if (minutes === null || minutes <= 0) return null;
|
||||
return run.attempt.total_docs_indexed / minutes;
|
||||
}
|
||||
|
||||
/** What a running permission or group sync moves per minute since it started, or `null` before it starts. */
|
||||
function syncRate(run: CCPairRun, now: number): number | null {
|
||||
if (run.kind !== "doc_permission_sync" && run.kind !== "group_sync") {
|
||||
return null;
|
||||
}
|
||||
const started = run.attempt.time_started;
|
||||
if (started === null) return null;
|
||||
const minutes = (now - new Date(started).getTime()) / 60_000;
|
||||
if (minutes <= 0) return null;
|
||||
// The rate counts memberships, the work done, while the card counts groups.
|
||||
const moved =
|
||||
run.kind === "group_sync"
|
||||
? run.attempt.total_group_memberships_synced
|
||||
: runMoved(run);
|
||||
return moved / minutes;
|
||||
}
|
||||
|
||||
/** Rates print with two decimals, such as 24.23 docs/min. */
|
||||
const RATE_FORMAT = {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
} as const;
|
||||
|
||||
interface FigureProps {
|
||||
value: number;
|
||||
label: string | RichStr;
|
||||
@@ -167,21 +205,43 @@ function Figure({ value, label }: FigureProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** How a count shows: a gray tag, or a plain number with its icon after it as run rows show it. */
|
||||
export type CountLook = "tag" | "plain";
|
||||
|
||||
interface CountProps {
|
||||
icon: IconFunctionComponent;
|
||||
value: number;
|
||||
tooltip: string;
|
||||
look: CountLook;
|
||||
}
|
||||
/** A small count with its icon, such as new or removed documents. */
|
||||
function Count({ icon, value, tooltip }: CountProps) {
|
||||
function Count({ icon: Icon, value, tooltip, look }: CountProps) {
|
||||
const format = useFormatter();
|
||||
if (look === "tag") {
|
||||
return (
|
||||
<Tag
|
||||
icon={Icon}
|
||||
title={format.number(value)}
|
||||
tooltip={tooltip}
|
||||
color="gray"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
icon={icon}
|
||||
title={format.number(value)}
|
||||
tooltip={tooltip}
|
||||
color="gray"
|
||||
/>
|
||||
<Tooltip tooltip={tooltip} side="top">
|
||||
<Section
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
gap={0.25}
|
||||
width="fit"
|
||||
height="fit"
|
||||
>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{format.number(value)}
|
||||
</Text>
|
||||
<Icon size={12} className="shrink-0 stroke-text-02" />
|
||||
</Section>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,14 +264,17 @@ function CountsRow({ children }: CountsRowProps) {
|
||||
|
||||
export interface DocCountsProps {
|
||||
attempt: IndexAttemptSnapshot;
|
||||
/** @default "tag" */
|
||||
look?: CountLook;
|
||||
}
|
||||
/** An index attempt's new, updated and removed documents. */
|
||||
export function DocCounts({ attempt }: DocCountsProps) {
|
||||
export function DocCounts({ attempt, look = "tag" }: DocCountsProps) {
|
||||
const t = useTranslations("admin.connector.runs");
|
||||
return (
|
||||
<CountsRow>
|
||||
<Count
|
||||
icon={SvgFilePlus}
|
||||
look={look}
|
||||
value={attempt.new_docs_indexed}
|
||||
tooltip={t("counts.newDocs", {
|
||||
count: attempt.new_docs_indexed,
|
||||
@@ -219,6 +282,7 @@ export function DocCounts({ attempt }: DocCountsProps) {
|
||||
/>
|
||||
<Count
|
||||
icon={SvgFileRefresh}
|
||||
look={look}
|
||||
value={Math.max(
|
||||
0,
|
||||
attempt.total_docs_indexed - attempt.new_docs_indexed
|
||||
@@ -228,6 +292,7 @@ export function DocCounts({ attempt }: DocCountsProps) {
|
||||
{attempt.docs_removed_from_index > 0 && (
|
||||
<Count
|
||||
icon={SvgFileMinus}
|
||||
look={look}
|
||||
value={attempt.docs_removed_from_index}
|
||||
tooltip={t("counts.removedDocs", {
|
||||
count: attempt.docs_removed_from_index,
|
||||
@@ -245,6 +310,7 @@ interface RunFiguresProps {
|
||||
function RunFigures({ run }: RunFiguresProps) {
|
||||
const t = useTranslations("admin.connector.runs");
|
||||
switch (run.kind) {
|
||||
// The figure already says how many were removed, and the warning pill carries the failures.
|
||||
case "pruning":
|
||||
return (
|
||||
<Figure value={run.attempt.docs_removed} label={t("figures.removed")} />
|
||||
@@ -262,25 +328,28 @@ function RunFigures({ run }: RunFiguresProps) {
|
||||
<CountsRow>
|
||||
<Count
|
||||
icon={SvgCheckCircle}
|
||||
look="plain"
|
||||
value={checkCount(attempt, "passed")}
|
||||
tooltip={t("counts.passedChecks", {
|
||||
count: checkCount(attempt, "passed"),
|
||||
})}
|
||||
/>
|
||||
{failed > 0 && (
|
||||
<Count
|
||||
icon={SvgXOctagon}
|
||||
value={failed}
|
||||
tooltip={t("counts.failedChecks", { count: failed })}
|
||||
/>
|
||||
)}
|
||||
{skipped > 0 && (
|
||||
<Count
|
||||
icon={SvgXCircle}
|
||||
look="plain"
|
||||
value={skipped}
|
||||
tooltip={t("counts.skippedChecks", { count: skipped })}
|
||||
/>
|
||||
)}
|
||||
{failed > 0 && (
|
||||
<Count
|
||||
icon={SvgXOctagon}
|
||||
look="plain"
|
||||
value={failed}
|
||||
tooltip={t("counts.failedChecks", { count: failed })}
|
||||
/>
|
||||
)}
|
||||
</CountsRow>
|
||||
)}
|
||||
</>
|
||||
@@ -293,15 +362,27 @@ function RunFigures({ run }: RunFiguresProps) {
|
||||
value={run.attempt.total_docs_indexed}
|
||||
label={t("figures.indexed")}
|
||||
/>
|
||||
<DocCounts attempt={run.attempt} />
|
||||
<DocCounts attempt={run.attempt} look="plain" />
|
||||
</>
|
||||
);
|
||||
case "doc_permission_sync":
|
||||
return (
|
||||
<Figure
|
||||
value={run.attempt.total_docs_synced}
|
||||
label={t("figures.synced")}
|
||||
/>
|
||||
<>
|
||||
<Figure
|
||||
value={run.attempt.total_docs_synced}
|
||||
label={t("figures.synced")}
|
||||
/>
|
||||
<CountsRow>
|
||||
<Count
|
||||
icon={SvgFileRefresh}
|
||||
look="plain"
|
||||
value={run.attempt.total_docs_synced}
|
||||
tooltip={t("counts.syncedDocs", {
|
||||
count: run.attempt.total_docs_synced,
|
||||
})}
|
||||
/>
|
||||
</CountsRow>
|
||||
</>
|
||||
);
|
||||
case "group_sync": {
|
||||
const attempt = run.attempt;
|
||||
@@ -314,6 +395,7 @@ function RunFigures({ run }: RunFiguresProps) {
|
||||
<CountsRow>
|
||||
<Count
|
||||
icon={SvgUser}
|
||||
look="plain"
|
||||
value={attempt.total_users_processed}
|
||||
tooltip={t("counts.users", {
|
||||
count: attempt.total_users_processed,
|
||||
@@ -321,6 +403,7 @@ function RunFigures({ run }: RunFiguresProps) {
|
||||
/>
|
||||
<Count
|
||||
icon={SvgUsers}
|
||||
look="plain"
|
||||
value={attempt.total_groups_processed}
|
||||
tooltip={t("counts.groups", {
|
||||
count: attempt.total_groups_processed,
|
||||
@@ -339,6 +422,11 @@ export interface RunRowProps {
|
||||
canCancel: boolean;
|
||||
onCancel: () => void;
|
||||
onViewLogs: () => void;
|
||||
/** Whether the row's stage or checks popover is open. The open row is raised. */
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Another row's popover is open, so this row steps back. */
|
||||
dimmed: boolean;
|
||||
}
|
||||
|
||||
/** One run in the pair's run list: what ran and when, how it ended, and what it moved. */
|
||||
@@ -347,6 +435,9 @@ export default function RunRow({
|
||||
canCancel,
|
||||
onCancel,
|
||||
onViewLogs,
|
||||
open,
|
||||
onOpenChange,
|
||||
dimmed,
|
||||
}: RunRowProps) {
|
||||
const t = useTranslations("admin.connector.runs");
|
||||
const tBadges = useTranslations("admin.status.badges");
|
||||
@@ -354,25 +445,43 @@ export default function RunRow({
|
||||
const tag = runStatusTag(run);
|
||||
const active = isActive(run);
|
||||
const failed = run.attempt.status === "failed";
|
||||
const canceled = run.attempt.status === "canceled";
|
||||
|
||||
// Queued, then the live rate, then when it finished and how long it took.
|
||||
const finished = finishedAt(run);
|
||||
// A finished index run shows its rate and keeps its finish time in the tooltip.
|
||||
const finishedRate = finishedIndexRate(run);
|
||||
const finishedTime =
|
||||
finishedRate === null || finished === null
|
||||
? undefined
|
||||
: formatDateTimeLog(finished);
|
||||
|
||||
// Queued, then the live rate, then the rate or the time it finished at, and how long it took.
|
||||
function detail(): string {
|
||||
if (run.attempt.status === "not_started") return t("detail.queued");
|
||||
if (run.kind === "indexing" && active) {
|
||||
const rate = getDocsProcessedPerMinute(run.attempt);
|
||||
if (rate !== null) {
|
||||
return t("detail.rate", {
|
||||
rate: format.number(rate, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
});
|
||||
return t("detail.rate", { rate: format.number(rate, RATE_FORMAT) });
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
const rate = syncRate(run, Date.now());
|
||||
if (rate !== null) {
|
||||
return t(
|
||||
run.kind === "group_sync" ? "detail.membershipRate" : "detail.rate",
|
||||
{ rate: format.number(rate, RATE_FORMAT) }
|
||||
);
|
||||
}
|
||||
}
|
||||
const finished = finishedAt(run);
|
||||
if (finished === null) return t("detail.running");
|
||||
const minutes = runMinutes(run);
|
||||
if (minutes === null) return formatDateTimeLog(finished);
|
||||
if (finishedRate !== null) {
|
||||
return t("detail.rateFinished", {
|
||||
rate: format.number(finishedRate, RATE_FORMAT),
|
||||
minutes,
|
||||
});
|
||||
}
|
||||
return t("detail.finished", {
|
||||
time: formatDateTimeLog(finished),
|
||||
minutes,
|
||||
@@ -380,145 +489,168 @@ export default function RunRow({
|
||||
}
|
||||
|
||||
const warnings = runWarnings(run);
|
||||
const emphasized = active || failed || warnings > 0;
|
||||
const hasLogs = runErrorText(run) !== null;
|
||||
|
||||
// The stage details popover spans the row, so the row is its anchor.
|
||||
return (
|
||||
<Popover>
|
||||
<Popover.Anchor asChild>
|
||||
<Card color="background-tint-00" border="none" rounding={2} padding={1}>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
height="fit"
|
||||
<Section
|
||||
alignItems="stretch"
|
||||
height="fit"
|
||||
className={focusDimClass(dimmed)}
|
||||
>
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<Popover.Anchor asChild>
|
||||
<Card
|
||||
color="background-tint-00"
|
||||
border="none"
|
||||
rounding={2}
|
||||
padding={1}
|
||||
shadow={open ? "md" : "none"}
|
||||
>
|
||||
<Section
|
||||
alignItems="start"
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
padding={1}
|
||||
height="fit"
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
<Content
|
||||
icon={RUN_KIND_ICONS[run.kind]}
|
||||
title={t(`kinds.${run.kind}`)}
|
||||
sizePreset="secondary"
|
||||
variant="body"
|
||||
color="muted"
|
||||
width="fit"
|
||||
/>
|
||||
<Text
|
||||
font={active || failed ? "secondary-action" : "secondary-body"}
|
||||
color={active || failed ? "text-04" : "text-03"}
|
||||
>
|
||||
{formatDateTimeLog(run.time_created)}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
alignItems="start"
|
||||
gap={1}
|
||||
padding={1}
|
||||
width={12}
|
||||
height="fit"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
gap={1}
|
||||
width="fit"
|
||||
padding={1}
|
||||
height="fit"
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
<Tag
|
||||
icon={tag.icon}
|
||||
title={tBadges(tag.labelKey)}
|
||||
color={tag.color}
|
||||
variant="status"
|
||||
font="secondary-body"
|
||||
<Content
|
||||
icon={
|
||||
active || canceled
|
||||
? SvgSlowMotion
|
||||
: RUN_KIND_ICONS[run.kind]
|
||||
}
|
||||
title={t(`kinds.${run.kind}`)}
|
||||
sizePreset="secondary"
|
||||
variant="body"
|
||||
color="muted"
|
||||
width="fit"
|
||||
/>
|
||||
{run.kind === "indexing" && run.attempt.is_new_model && (
|
||||
<Text
|
||||
font={emphasized ? "secondary-action" : "secondary-body"}
|
||||
color={emphasized ? "text-04" : "text-03"}
|
||||
>
|
||||
{formatDateTimeLog(run.time_created)}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
alignItems="start"
|
||||
gap={1}
|
||||
padding={1}
|
||||
width={12}
|
||||
height="fit"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
gap={1}
|
||||
width="fit"
|
||||
height="fit"
|
||||
>
|
||||
<Tag
|
||||
title={t("newModel.label")}
|
||||
tooltip={t("newModel.tooltip")}
|
||||
color="purple"
|
||||
icon={tag.icon}
|
||||
title={tBadges(tag.labelKey)}
|
||||
color={tag.color}
|
||||
variant="status"
|
||||
font="secondary-body"
|
||||
/>
|
||||
{run.kind === "indexing" && run.attempt.is_new_model && (
|
||||
<Tag
|
||||
title={t("newModel.label")}
|
||||
tooltip={t("newModel.tooltip")}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
{run.kind === "indexing" && (
|
||||
<StageDetailsTrigger open={open} />
|
||||
)}
|
||||
{run.kind === "checks" &&
|
||||
run.attempt.check_results.length > 0 && (
|
||||
<CheckRunTrigger open={open} />
|
||||
)}
|
||||
{warnings > 0 && (
|
||||
<Tag
|
||||
icon={SvgAlertTriangle}
|
||||
title={format.number(warnings)}
|
||||
tooltip={t(
|
||||
run.kind === "checks"
|
||||
? "warnings.checks"
|
||||
: "warnings.tooltip",
|
||||
{ count: warnings }
|
||||
)}
|
||||
color="orange"
|
||||
variant="count"
|
||||
font="secondary-body"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Tooltip tooltip={finishedTime} side="top">
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{detail()}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
alignItems="end"
|
||||
gap={1}
|
||||
padding={1}
|
||||
width={9}
|
||||
height="fit"
|
||||
className="shrink-0"
|
||||
>
|
||||
<RunFigures run={run} />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
gap={1}
|
||||
width={3.5}
|
||||
height="fit"
|
||||
className="shrink-0"
|
||||
>
|
||||
{canCancel && run.kind === "indexing" && active && (
|
||||
<Button
|
||||
icon={SvgXCircle}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
tooltip={t("actions.cancel")}
|
||||
aria-label={t("actions.cancel")}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
)}
|
||||
{run.kind === "indexing" && <StageDetailsTrigger />}
|
||||
{run.kind === "checks" &&
|
||||
run.attempt.check_results.length > 0 && <CheckRunTrigger />}
|
||||
{warnings > 0 && (
|
||||
<Tag
|
||||
{hasLogs && (
|
||||
<Button
|
||||
icon={SvgAlertTriangle}
|
||||
title={format.number(warnings)}
|
||||
tooltip={t(
|
||||
run.kind === "checks"
|
||||
? "warnings.checks"
|
||||
: "warnings.tooltip",
|
||||
{ count: warnings }
|
||||
)}
|
||||
color="orange"
|
||||
variant="count"
|
||||
font="secondary-body"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
tooltip={t("actions.logs")}
|
||||
aria-label={t("actions.logs")}
|
||||
onClick={onViewLogs}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{detail()}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
alignItems="end"
|
||||
gap={1}
|
||||
padding={1}
|
||||
width={9}
|
||||
height="fit"
|
||||
className="shrink-0"
|
||||
>
|
||||
<RunFigures run={run} />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
gap={1}
|
||||
width={3.5}
|
||||
height="fit"
|
||||
className="shrink-0"
|
||||
>
|
||||
{canCancel && run.kind === "indexing" && active && (
|
||||
<Button
|
||||
icon={SvgXCircle}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
tooltip={t("actions.cancel")}
|
||||
aria-label={t("actions.cancel")}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
)}
|
||||
{hasLogs && (
|
||||
<Button
|
||||
icon={SvgAlertTriangle}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
tooltip={t("actions.logs")}
|
||||
aria-label={t("actions.logs")}
|
||||
onClick={onViewLogs}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
</Card>
|
||||
</Popover.Anchor>
|
||||
{run.kind === "indexing" && (
|
||||
<StageDetailsPopoverContent indexAttemptId={run.attempt.id} />
|
||||
)}
|
||||
{run.kind === "checks" && run.attempt.check_results.length > 0 && (
|
||||
<CheckRunPopoverContent run={run.attempt} />
|
||||
)}
|
||||
</Popover>
|
||||
</Card>
|
||||
</Popover.Anchor>
|
||||
{run.kind === "indexing" && (
|
||||
<StageDetailsPopoverContent indexAttemptId={run.attempt.id} />
|
||||
)}
|
||||
{run.kind === "checks" && run.attempt.check_results.length > 0 && (
|
||||
<CheckRunPopoverContent run={run.attempt} />
|
||||
)}
|
||||
</Popover>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { richNodes } from "@opal/utils";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Dropdown,
|
||||
FilterButton,
|
||||
InputTypeIn,
|
||||
MessageCard,
|
||||
Pagination,
|
||||
Tag,
|
||||
Text,
|
||||
} from "@opal/components";
|
||||
import { useDebouncedValue } from "@opal/hooks";
|
||||
import { IllustrationContent, Section, toast } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import { SvgLongWait, SvgNoResult } from "@opal/illustrations";
|
||||
import { CardLoader, IconLoader } from "@opal/loaders";
|
||||
import {
|
||||
SvgActivity,
|
||||
SvgCheckCircle,
|
||||
SvgChevronRight,
|
||||
SvgClock,
|
||||
SvgSlowMotion,
|
||||
SvgXCircle,
|
||||
@@ -99,6 +104,13 @@ export interface RunsCardProps {
|
||||
/** A chart span that narrows the list inside the shared window. The shared picker shows it, and its X calls `onClearRange`. */
|
||||
range?: SliceRange | null;
|
||||
onClearRange?: () => void;
|
||||
/** Called when a row's stage or checks popover opens or closes, so the page can step back around it. */
|
||||
onFocusChange?: (focused: boolean) => void;
|
||||
}
|
||||
|
||||
/** A run's key in the list. */
|
||||
function runKey(run: CCPairRun): string {
|
||||
return `${run.kind}-${run.attempt.id}`;
|
||||
}
|
||||
|
||||
/** The pair's index attempts and, when perm-synced, its permission syncs, newest first. */
|
||||
@@ -108,6 +120,7 @@ export default function RunsCard({
|
||||
embedded = false,
|
||||
sharedWindow,
|
||||
onSharedWindowChange,
|
||||
onFocusChange,
|
||||
range = null,
|
||||
onClearRange,
|
||||
}: RunsCardProps) {
|
||||
@@ -115,13 +128,15 @@ export default function RunsCard({
|
||||
const format = useFormatter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [picked, setPicked] = useState<TimeWindow>(ALL_TIME);
|
||||
// "View" on the empty state widens the list to all time until the window or slice changes.
|
||||
const [widened, setWidened] = useState(false);
|
||||
const windowSince =
|
||||
sharedWindow !== undefined
|
||||
? pickedWindowStart(sharedWindow)
|
||||
: picked.hours === null
|
||||
? null
|
||||
: windowStart(picked.hours);
|
||||
const { since, until } = rangeWithin(windowSince, range);
|
||||
const windowRange = rangeWithin(windowSince, range);
|
||||
// A new window or slice starts the list over at its first page. The key is
|
||||
// the selection, not the moving start, so a passing minute keeps the page.
|
||||
const selection = [
|
||||
@@ -133,7 +148,15 @@ export default function RunsCard({
|
||||
if (pagedSelection !== selection) {
|
||||
setPagedSelection(selection);
|
||||
setPage(1);
|
||||
setWidened(false);
|
||||
}
|
||||
const since = widened ? null : windowRange.since;
|
||||
const until = widened ? null : windowRange.until;
|
||||
const [openRun, setOpenRun] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
onFocusChange?.(openRun !== null);
|
||||
return () => onFocusChange?.(false);
|
||||
}, [openRun, onFocusChange]);
|
||||
const [kind, setKind] = useState<CCPairRunKind | null>(null);
|
||||
const [statusGroup, setStatusGroup] = useState<CCPairRunStatusGroup | null>(
|
||||
null
|
||||
@@ -159,9 +182,42 @@ export default function RunsCard({
|
||||
errorHandlingFetcher,
|
||||
{ refreshInterval: RUNS_POLL_MS, keepPreviousData: true }
|
||||
);
|
||||
// Polling can drop the open row from the page; the focus goes with it.
|
||||
useEffect(() => {
|
||||
if (openRun === null || data === undefined) return;
|
||||
if (!data.items.some((run) => runKey(run) === openRun)) setOpenRun(null);
|
||||
}, [data, openRun]);
|
||||
// An empty window with no other filter counts the pair's runs before it.
|
||||
const emptyWindow =
|
||||
data !== undefined &&
|
||||
data.items.length === 0 &&
|
||||
since !== null &&
|
||||
kind === null &&
|
||||
statusGroup === null &&
|
||||
search === "";
|
||||
const { data: allTime } = useSWR<CCPairRunsResponse>(
|
||||
emptyWindow
|
||||
? SWR_KEYS.ccPairRuns(ccPairId, {
|
||||
pageNum: 0,
|
||||
pageSize: 1,
|
||||
since: null,
|
||||
kinds: null,
|
||||
statusGroups: null,
|
||||
q: "",
|
||||
errors: null,
|
||||
until: null,
|
||||
superseded: null,
|
||||
resolved: null,
|
||||
})
|
||||
: null,
|
||||
errorHandlingFetcher,
|
||||
{ refreshInterval: RUNS_POLL_MS }
|
||||
);
|
||||
const pastRuns = allTime?.total_items ?? 0;
|
||||
|
||||
function pickWindow(window: TimeWindow) {
|
||||
setPicked(window);
|
||||
setWidened(false);
|
||||
}
|
||||
|
||||
function pickKind(next: CCPairRunKind | null) {
|
||||
@@ -274,7 +330,13 @@ export default function RunsCard({
|
||||
<ThroughputWindowPicker
|
||||
windows={PAIR_WINDOWS}
|
||||
value={sharedWindow.window}
|
||||
onChange={onSharedWindowChange}
|
||||
// A widened list reaches past the chart's window, and says so.
|
||||
label={widened ? t("window.all") : undefined}
|
||||
onChange={(window) => {
|
||||
// Picking the same window again still leaves the widened view.
|
||||
setWidened(false);
|
||||
onSharedWindowChange(window);
|
||||
}}
|
||||
selection={range}
|
||||
onClearSelection={onClearRange}
|
||||
/>
|
||||
@@ -390,37 +452,96 @@ export default function RunsCard({
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : emptyWindow ? (
|
||||
<Section alignItems="stretch" gap={0} height="fit">
|
||||
<IllustrationContent
|
||||
illustration={SvgLongWait}
|
||||
title={t("empty.title")}
|
||||
/>
|
||||
{pastRuns > 0 && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
gap={0}
|
||||
height="fit"
|
||||
>
|
||||
<Section height="fit" className="min-w-0 flex-1">
|
||||
<Divider title={t("empty.pastRuns", { count: pastRuns })} />
|
||||
</Section>
|
||||
<Button
|
||||
icon={SvgChevronRight}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
tooltip={t("empty.view")}
|
||||
aria-label={t("empty.viewAria", { count: pastRuns })}
|
||||
onClick={() => {
|
||||
setWidened(true);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
) : (data?.items.length ?? 0) === 0 ? (
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title={filtered ? t("empty.filteredTitle") : t("empty.title")}
|
||||
description={filtered ? undefined : t("empty.description")}
|
||||
/>
|
||||
) : (
|
||||
<Section alignItems="stretch" gap={1} height="fit">
|
||||
{data?.items.map((run) => (
|
||||
<RunRow
|
||||
key={`${run.kind}-${run.attempt.id}`}
|
||||
key={runKey(run)}
|
||||
run={run}
|
||||
canCancel={canOperate}
|
||||
onCancel={() => void cancel(run)}
|
||||
onViewLogs={() => setLogRun(run)}
|
||||
open={openRun === runKey(run)}
|
||||
onOpenChange={(open) =>
|
||||
setOpenRun((current) =>
|
||||
open
|
||||
? runKey(run)
|
||||
: current === runKey(run)
|
||||
? null
|
||||
: current
|
||||
)
|
||||
}
|
||||
dimmed={openRun !== null && openRun !== runKey(run)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{totalItems > PAGE_SIZE && (
|
||||
<Pagination
|
||||
variant="count"
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
pageSize={PAGE_SIZE}
|
||||
totalItems={totalItems}
|
||||
onChange={setPage}
|
||||
units={t("units", { count: totalItems })}
|
||||
size="sm"
|
||||
/>
|
||||
{totalItems > 0 && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
height="fit"
|
||||
>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{richNodes(
|
||||
t.rich("showing", {
|
||||
from: format.number((page - 1) * PAGE_SIZE + 1),
|
||||
to: format.number(Math.min(page * PAGE_SIZE, totalItems)),
|
||||
total: format.number(totalItems),
|
||||
mono: (chunks) => (
|
||||
<Text font="secondary-mono" color="text-03">
|
||||
{String(chunks)}
|
||||
</Text>
|
||||
),
|
||||
})
|
||||
)}
|
||||
</Text>
|
||||
<Pagination
|
||||
variant="list"
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onChange={setPage}
|
||||
size="sm"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
@@ -433,7 +554,13 @@ export default function RunsCard({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card border="solid" rounding={4} padding={2} data-testid="connector-runs">
|
||||
<Card
|
||||
color="background-tint-01"
|
||||
border="solid"
|
||||
rounding={4}
|
||||
padding={2}
|
||||
data-testid="connector-runs"
|
||||
>
|
||||
{body}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Divider,
|
||||
IconTooltip,
|
||||
Popover,
|
||||
SelectButton,
|
||||
ShadowDiv,
|
||||
Text,
|
||||
} from "@opal/components";
|
||||
@@ -181,13 +182,20 @@ export function barScale(buckets: BucketSummary[]): number {
|
||||
}
|
||||
|
||||
/** Opens the stage details of one attempt. Place it inside the Popover that holds the content. */
|
||||
export function StageDetailsTrigger() {
|
||||
export interface StageDetailsTriggerProps {
|
||||
/** Whether its popover is open. The open trigger shows selected. */
|
||||
open?: boolean;
|
||||
}
|
||||
export function StageDetailsTrigger({
|
||||
open = false,
|
||||
}: StageDetailsTriggerProps) {
|
||||
const t = useTranslations("admin.connector.runs");
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
<SelectButton
|
||||
icon={SvgStackLine}
|
||||
prominence="tertiary"
|
||||
variant="select-light"
|
||||
state={open ? "selected" : "empty"}
|
||||
size="xs"
|
||||
tooltip={t("actions.stages")}
|
||||
aria-label={t("actions.stages")}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { Button, Card, Popover, Tag, Text } from "@opal/components";
|
||||
import { Section, toast } from "@opal/layouts";
|
||||
import useSWR from "swr";
|
||||
import { Button, Card, Popover, Tag, Text, Tooltip } from "@opal/components";
|
||||
import { Section } from "@opal/layouts";
|
||||
import { SvgListCheck, SvgPlayCircle } from "@opal/icons";
|
||||
import { CardLoader } from "@opal/loaders";
|
||||
import { timeAgo } from "@opal/time";
|
||||
@@ -13,8 +12,6 @@ import {
|
||||
type CCPairFullInfo,
|
||||
type PaginatedIndexAttempts,
|
||||
} from "@/lib/connectors/types";
|
||||
import { releaseIndexingHold } from "@/lib/connectors/svc";
|
||||
import { buildCCPairInfoUrl } from "@/lib/connectors/utils";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import { ccPairStatusTag } from "@/views/admin/connectors/ConnectorDetailPage/statusTag";
|
||||
@@ -60,8 +57,9 @@ const POLL_MS = 5000;
|
||||
|
||||
export interface StatusCardProps {
|
||||
ccPair: CCPairFullInfo;
|
||||
/** Shows Start Indexing while the pair's failed checks hold its first run. */
|
||||
canOperate: boolean;
|
||||
/** Backs the Start Indexing button shown while failed checks hold the pair's first run; null hides it. */
|
||||
onStartIndexing: (() => void) | null;
|
||||
startingIndexing: boolean;
|
||||
/** Opens the checks card, from the checks icon a held pair shows in place of its stages. */
|
||||
onViewChecks: () => void;
|
||||
/** The page's time window, shared with the runs card. */
|
||||
@@ -75,7 +73,8 @@ export interface StatusCardProps {
|
||||
/** The pair at a glance: status, latest and total documents, its last run, and documents indexed over time. */
|
||||
export default function StatusCard({
|
||||
ccPair,
|
||||
canOperate,
|
||||
onStartIndexing,
|
||||
startingIndexing,
|
||||
onViewChecks,
|
||||
timeWindow,
|
||||
onTimeWindowChange,
|
||||
@@ -85,23 +84,7 @@ export default function StatusCard({
|
||||
const t = useTranslations("admin.connector.statusCard");
|
||||
const tBadges = useTranslations("admin.status.badges");
|
||||
const locale = useLocale();
|
||||
const [releasing, setReleasing] = useState(false);
|
||||
const hold = ccPair.indexing_hold;
|
||||
|
||||
async function release() {
|
||||
setReleasing(true);
|
||||
const failure = await releaseIndexingHold(
|
||||
ccPair.id,
|
||||
t("hold.releaseFailed")
|
||||
);
|
||||
setReleasing(false);
|
||||
if (failure !== null) {
|
||||
toast.error(failure);
|
||||
return;
|
||||
}
|
||||
toast.success(t("hold.released"));
|
||||
await mutate(buildCCPairInfoUrl(ccPair.id));
|
||||
}
|
||||
const { data: latestIndexAttempts, error } = useSWR<PaginatedIndexAttempts>(
|
||||
SWR_KEYS.ccPairLatestIndexAttempt(ccPair.id),
|
||||
errorHandlingFetcher,
|
||||
@@ -131,6 +114,8 @@ export default function StatusCard({
|
||||
const lastIndexed = t("lastIndexed.value", {
|
||||
when: timeAgo(ccPair.last_indexed, locale) ?? "-",
|
||||
});
|
||||
// The rate is the connector's average over every run, where a run row shows one run's live rate.
|
||||
const showsSpeed = !paused && hold === null && speed !== null;
|
||||
// A paused pair names only its last run. Others lead with the card's title.
|
||||
const statusLine = paused
|
||||
? lastIndexed
|
||||
@@ -195,19 +180,26 @@ export default function StatusCard({
|
||||
!paused && <StageDetailsTrigger />
|
||||
)}
|
||||
</Section>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{statusLine}
|
||||
</Text>
|
||||
<Tooltip
|
||||
tooltip={showsSpeed ? t("indexingSpeedTooltip") : undefined}
|
||||
side="top"
|
||||
>
|
||||
<Section width="fit" height="fit">
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{statusLine}
|
||||
</Text>
|
||||
</Section>
|
||||
</Tooltip>
|
||||
{/* A run in flight ends on its own; the other holds wait for the admin. */}
|
||||
{hold !== null &&
|
||||
hold.reason !== "checks_running" &&
|
||||
canOperate && (
|
||||
onStartIndexing !== null && (
|
||||
<Button
|
||||
icon={SvgPlayCircle}
|
||||
prominence="secondary"
|
||||
size="sm"
|
||||
disabled={releasing}
|
||||
onClick={() => void release()}
|
||||
disabled={startingIndexing}
|
||||
onClick={onStartIndexing}
|
||||
>
|
||||
{t("hold.start")}
|
||||
</Button>
|
||||
|
||||
@@ -182,6 +182,24 @@ function axisTicks(start: number, end: number, minutes: number): number[] {
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/** Unlabeled ticks at a third of the label spacing, inside the window. */
|
||||
function minorTicks(
|
||||
labels: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
minutes: number
|
||||
): number[] {
|
||||
const third = (minutes * 60_000) / 3;
|
||||
const ticks = new Set<number>();
|
||||
for (const label of labels) {
|
||||
for (const offset of [-2, -1, 1, 2]) {
|
||||
const tick = label + offset * third;
|
||||
if (tick >= start && tick <= end) ticks.add(tick);
|
||||
}
|
||||
}
|
||||
return Array.from(ticks);
|
||||
}
|
||||
|
||||
interface Run {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -232,6 +250,8 @@ export interface ThroughputWindowPickerProps<W extends PickerWindow> {
|
||||
/** A span picked on the chart. The picker shows it in place of the window, with an X that calls `onClearSelection`. */
|
||||
selection?: SliceRange | null;
|
||||
onClearSelection?: () => void;
|
||||
/** Shown instead of the window's name, for a list that widened past it. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Picks how far back a throughput chart, and any list that shares it, reaches, and shows the span picked inside it. */
|
||||
@@ -241,6 +261,7 @@ export function ThroughputWindowPicker<W extends PickerWindow>({
|
||||
onChange,
|
||||
selection = null,
|
||||
onClearSelection,
|
||||
label,
|
||||
}: ThroughputWindowPickerProps<W>) {
|
||||
const t = useTranslations("admin.connector.statusCard.throughput");
|
||||
const tWindow = useTranslations("admin.connector.runs.window");
|
||||
@@ -250,7 +271,7 @@ export function ThroughputWindowPicker<W extends PickerWindow>({
|
||||
<Dropdown.Trigger asChild>
|
||||
{selection === null || onClearSelection === undefined ? (
|
||||
<OpenButton icon={SvgClock} state="empty">
|
||||
{tWindow(windowLabelKey(value))}
|
||||
{label ?? tWindow(windowLabelKey(value))}
|
||||
</OpenButton>
|
||||
) : (
|
||||
// The span takes the picker's place, so the row keeps its shape. Its X returns to the window.
|
||||
@@ -355,7 +376,12 @@ export function ThroughputBars({
|
||||
);
|
||||
const tickMinutes =
|
||||
selection === null ? TICK_MINUTES[range] : spanTickMinutes(end - start);
|
||||
const ticks = axisTicks(start, end, tickMinutes);
|
||||
const labelTicks = axisTicks(start, end, tickMinutes);
|
||||
const labelSet = new Set(labelTicks);
|
||||
const ticks = [
|
||||
...labelTicks,
|
||||
...minorTicks(labelTicks, start, end, tickMinutes),
|
||||
].sort((a, b) => a - b);
|
||||
// A run still going covers up to the window's end.
|
||||
const runs: Run[] = data.runs.map((run) => ({
|
||||
start: Date.parse(run.started),
|
||||
@@ -432,16 +458,18 @@ export function ThroughputBars({
|
||||
allowDataOverflow
|
||||
ticks={ticks}
|
||||
tickFormatter={(value: number) =>
|
||||
tickMinutes >= DAY_MINUTES
|
||||
? format.dateTime(new Date(value), {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: format.dateTime(new Date(value), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
})
|
||||
!labelSet.has(value)
|
||||
? ""
|
||||
: tickMinutes >= DAY_MINUTES
|
||||
? format.dateTime(new Date(value), {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: format.dateTime(new Date(value), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
})
|
||||
}
|
||||
tick={{
|
||||
fontSize: 10,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
/** Fades content back while a run row's stage or checks popover is open. */
|
||||
export function focusDimClass(dimmed: boolean): string {
|
||||
return cn("transition-opacity", dimmed && "opacity-50");
|
||||
}
|
||||
@@ -21,23 +21,24 @@ import {
|
||||
toast,
|
||||
} from "@opal/layouts";
|
||||
import { IconLoader, PageLoader } from "@opal/loaders";
|
||||
import { markdown } from "@opal/utils";
|
||||
import { SvgPlugBroken } from "@opal/illustrations";
|
||||
import {
|
||||
SvgArrowLeft,
|
||||
SvgEditBig,
|
||||
SvgPause,
|
||||
SvgPlay,
|
||||
SvgPlayCircle,
|
||||
SvgRefreshCw,
|
||||
SvgSettings,
|
||||
SvgSlowMotion,
|
||||
SvgTrash,
|
||||
} from "@opal/icons";
|
||||
import { markdown } from "@opal/utils";
|
||||
import { isEditableSource } from "@/lib/connectors/edit/editable";
|
||||
import { updateConnectorCredentialPairName } from "@/lib/connector";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { buildCCPairInfoUrl } from "@/lib/connectors/utils";
|
||||
import { triggerIndexing } from "@/lib/connectors/svc";
|
||||
import { releaseIndexingHold, triggerIndexing } from "@/lib/connectors/svc";
|
||||
import { ConnectorCredentialPairStatus } from "@/lib/connectors/types";
|
||||
import type { CCPairFullInfo } from "@/lib/connectors/types";
|
||||
import { statusIsNotCurrentlyActive } from "@/lib/connectors/utils";
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
type PickedWindow,
|
||||
type SliceRange,
|
||||
} from "@/views/admin/connectors/ConnectorDetailPage/ThroughputChart";
|
||||
import { focusDimClass } from "@/views/admin/connectors/ConnectorDetailPage/focusMode";
|
||||
|
||||
const CC_PAIR_POLL_MS = 5000;
|
||||
|
||||
@@ -92,6 +94,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
useState(false);
|
||||
// Counts the status card's asks to open the checks card.
|
||||
const [checksExpandRequest, setChecksExpandRequest] = useState(0);
|
||||
const [runFocus, setRunFocus] = useState(false);
|
||||
// One window drives the status card's chart and the runs card's list.
|
||||
const [timeWindow, setTimeWindow] = useState<PickedWindow>(() =>
|
||||
pickWindow("12h")
|
||||
@@ -153,6 +156,22 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
}
|
||||
};
|
||||
|
||||
const startIndexing = async () => {
|
||||
if (!ccPair) return;
|
||||
setBusy(true);
|
||||
const failure = await releaseIndexingHold(
|
||||
ccPair.id,
|
||||
t("statusCard.hold.releaseFailed")
|
||||
);
|
||||
setBusy(false);
|
||||
if (failure !== null) {
|
||||
toast.error(failure);
|
||||
return;
|
||||
}
|
||||
toast.success(t("statusCard.hold.released"));
|
||||
refresh();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingCCPair) return;
|
||||
if (ccPair && !ccPairError) setHasLoadedOnce(true);
|
||||
@@ -226,75 +245,106 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
const onEdit = canOpenEditForm ? openEditForm : null;
|
||||
const canManageInlineFiles = source === "file" && can(ccPair, "edit");
|
||||
|
||||
// Unlike `paused`, this excludes INVALID, whose menu still offers the re-index rows disabled with a reason.
|
||||
const strictlyPaused = ccPair.status === ConnectorCredentialPairStatus.PAUSED;
|
||||
const held = ccPair.indexing_hold;
|
||||
// While held, the menu offers Start Indexing in place of the re-index rows; a check run in flight releases the hold itself.
|
||||
const canStartIndexing = held !== null && held.reason !== "checks_running";
|
||||
// The reason a row is disabled is its tooltip.
|
||||
const reIndexBlocked = ccPair.indexing
|
||||
? t("manageMenu.reIndex.tooltip.indexing")
|
||||
: ccPair.status === ConnectorCredentialPairStatus.PAUSED
|
||||
? t("manageMenu.reIndex.tooltip.paused")
|
||||
: ccPair.status === ConnectorCredentialPairStatus.INVALID
|
||||
? t("manageMenu.reIndex.tooltip.invalid")
|
||||
: undefined;
|
||||
const manageItems: DropdownMenuItem[] = [
|
||||
{
|
||||
kind: "action",
|
||||
id: "update-index",
|
||||
icon: SvgRefreshCw,
|
||||
title: t("manageMenu.updateIndex.label"),
|
||||
description: t("manageMenu.updateIndex.description"),
|
||||
tooltip: reIndexBlocked,
|
||||
disabled: reIndexBlocked !== undefined,
|
||||
onSelect: () => void triggerReIndex(false),
|
||||
},
|
||||
{
|
||||
kind: "action",
|
||||
id: "full-reindex",
|
||||
icon: SvgSlowMotion,
|
||||
title: t("manageMenu.fullReindex.label"),
|
||||
description: markdown(t("manageMenu.fullReindex.description")),
|
||||
tooltip: reIndexBlocked,
|
||||
disabled: reIndexBlocked !== undefined,
|
||||
onSelect: () => void triggerReIndex(true),
|
||||
},
|
||||
...(!isDeleting
|
||||
: ccPair.status === ConnectorCredentialPairStatus.INVALID
|
||||
? t("manageMenu.reIndex.tooltip.invalid")
|
||||
: undefined;
|
||||
const startItems: DropdownMenuItem[] = canStartIndexing
|
||||
? [
|
||||
{
|
||||
kind: "action",
|
||||
id: "start-indexing",
|
||||
icon: SvgPlay,
|
||||
title: t("manageMenu.startIndexing.label"),
|
||||
description: t("manageMenu.startIndexing.description"),
|
||||
disabled: busy,
|
||||
onSelect: () => void startIndexing(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const reIndexItems: DropdownMenuItem[] =
|
||||
strictlyPaused || held !== null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "action",
|
||||
id: "update-index",
|
||||
icon: SvgRefreshCw,
|
||||
title: t("manageMenu.updateIndex.label"),
|
||||
description: t("manageMenu.updateIndex.description"),
|
||||
tooltip: reIndexBlocked,
|
||||
disabled: reIndexBlocked !== undefined,
|
||||
onSelect: () => void triggerReIndex(false),
|
||||
},
|
||||
{
|
||||
kind: "action",
|
||||
id: "full-reindex",
|
||||
icon: SvgSlowMotion,
|
||||
title: t("manageMenu.fullReindex.label"),
|
||||
description: markdown(t("manageMenu.fullReindex.description")),
|
||||
tooltip: reIndexBlocked,
|
||||
disabled: reIndexBlocked !== undefined,
|
||||
onSelect: () => void triggerReIndex(true),
|
||||
},
|
||||
];
|
||||
const toggleItems: DropdownMenuItem[] = isDeleting
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "action",
|
||||
id: "toggle-status",
|
||||
icon: paused ? SvgPlayCircle : SvgPause,
|
||||
title: paused
|
||||
? t("manageMenu.toggleStatus.resume.label")
|
||||
: t("manageMenu.toggleStatus.pause.label"),
|
||||
description: paused
|
||||
? t("manageMenu.toggleStatus.resume.description")
|
||||
: t("manageMenu.toggleStatus.pause.description"),
|
||||
tooltip: isStatusUpdating
|
||||
? t("manageMenu.toggleStatus.tooltip.updating")
|
||||
: undefined,
|
||||
disabled: isStatusUpdating,
|
||||
onSelect: () =>
|
||||
handleStatusChange(
|
||||
paused
|
||||
? ConnectorCredentialPairStatus.ACTIVE
|
||||
: ConnectorCredentialPairStatus.PAUSED
|
||||
),
|
||||
},
|
||||
];
|
||||
// An untitled group, so the list draws its divider above the danger row.
|
||||
const dangerItems: DropdownMenuItem[] =
|
||||
!isDeleting && can(ccPair, "delete")
|
||||
? [
|
||||
{
|
||||
kind: "action" as const,
|
||||
id: "toggle-status",
|
||||
icon: paused ? SvgPlayCircle : SvgPause,
|
||||
title: paused
|
||||
? t("manageMenu.toggleStatus.resume.label")
|
||||
: t("manageMenu.toggleStatus.pause.label"),
|
||||
description: paused
|
||||
? t("manageMenu.toggleStatus.resume.description")
|
||||
: t("manageMenu.toggleStatus.pause.description"),
|
||||
tooltip: isStatusUpdating
|
||||
? t("manageMenu.toggleStatus.tooltip.updating")
|
||||
: undefined,
|
||||
disabled: isStatusUpdating,
|
||||
onSelect: () =>
|
||||
handleStatusChange(
|
||||
paused
|
||||
? ConnectorCredentialPairStatus.ACTIVE
|
||||
: ConnectorCredentialPairStatus.PAUSED
|
||||
),
|
||||
kind: "group",
|
||||
items: [
|
||||
{
|
||||
kind: "action",
|
||||
id: "delete",
|
||||
icon: SvgTrash,
|
||||
danger: true,
|
||||
title: t("manageMenu.delete.label"),
|
||||
tooltip: paused
|
||||
? undefined
|
||||
: t("manageMenu.delete.tooltip.active"),
|
||||
disabled: !paused,
|
||||
onSelect: () => setShowDeleteConnectorConfirmModal(true),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isDeleting && can(ccPair, "delete")
|
||||
? [
|
||||
{
|
||||
kind: "action" as const,
|
||||
id: "delete",
|
||||
icon: SvgTrash,
|
||||
danger: true,
|
||||
title: t("manageMenu.delete.label"),
|
||||
tooltip: paused ? undefined : t("manageMenu.delete.tooltip.active"),
|
||||
disabled: !paused,
|
||||
onSelect: () => setShowDeleteConnectorConfirmModal(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
: [];
|
||||
const manageItems: DropdownMenuItem[] = strictlyPaused
|
||||
? [...toggleItems, ...dangerItems]
|
||||
: [...startItems, ...reIndexItems, ...toggleItems, ...dangerItems];
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root width="xl">
|
||||
@@ -332,7 +382,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
</Button>,
|
||||
]
|
||||
: []),
|
||||
<Dropdown key="manage">
|
||||
<Dropdown key="manage" width={80} align="end">
|
||||
<Dropdown.Trigger asChild>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
@@ -341,9 +391,11 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
{t("manageMenu.trigger.label")}
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
{/* The mock sizes the menu to its rows. */}
|
||||
<Dropdown.Data
|
||||
label={t("manageMenu.trigger.label")}
|
||||
items={manageItems}
|
||||
maxHeight="none"
|
||||
/>
|
||||
</Dropdown>,
|
||||
]
|
||||
@@ -391,7 +443,8 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
|
||||
<StatusCard
|
||||
ccPair={ccPair}
|
||||
canOperate={canOperate}
|
||||
onStartIndexing={canOperate ? () => void startIndexing() : null}
|
||||
startingIndexing={busy}
|
||||
onViewChecks={() => setChecksExpandRequest((n) => n + 1)}
|
||||
timeWindow={timeWindow.window}
|
||||
onTimeWindowChange={changeWindow}
|
||||
@@ -404,22 +457,31 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
connectorId={ccPair.connector.id}
|
||||
expandRequest={checksExpandRequest}
|
||||
/>
|
||||
<AuthenticationAccountSection ccPair={ccPair} onEdit={onEdit} />
|
||||
<IndexConfigurationsSection
|
||||
ccPair={ccPair}
|
||||
onEdit={onEdit}
|
||||
manageFiles={canManageInlineFiles}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
<SharingSettingsSection ccPair={ccPair} onEdit={onEdit} />
|
||||
<Divider paddingParallel={0} paddingPerpendicular={0} />
|
||||
<ScheduleSection ccPair={ccPair} editable={canOperate} />
|
||||
{/* While a run row's popover is open, the sections step back; the status card stays full. */}
|
||||
<Section
|
||||
alignItems="stretch"
|
||||
gap={6}
|
||||
height="fit"
|
||||
className={focusDimClass(runFocus)}
|
||||
>
|
||||
<AuthenticationAccountSection ccPair={ccPair} onEdit={onEdit} />
|
||||
<IndexConfigurationsSection
|
||||
ccPair={ccPair}
|
||||
onEdit={onEdit}
|
||||
manageFiles={canManageInlineFiles}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
<SharingSettingsSection ccPair={ccPair} onEdit={onEdit} />
|
||||
<Divider paddingParallel={0} paddingPerpendicular={0} />
|
||||
<ScheduleSection ccPair={ccPair} editable={canOperate} />
|
||||
</Section>
|
||||
|
||||
<RunsCard
|
||||
ccPairId={ccPair.id}
|
||||
canOperate={canOperate}
|
||||
sharedWindow={timeWindow}
|
||||
onSharedWindowChange={changeWindow}
|
||||
onFocusChange={setRunFocus}
|
||||
range={sliceRange}
|
||||
onClearRange={() => setSliceRange(null)}
|
||||
/>
|
||||
@@ -445,6 +507,7 @@ function BackToConnectors() {
|
||||
<Button
|
||||
icon={SvgArrowLeft}
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
href={ADMIN_ROUTES.INDEXING_STATUS.path}
|
||||
>
|
||||
{t("backButton.label")}
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface StatusTag {
|
||||
labelKey:
|
||||
| "deleting"
|
||||
| "paused"
|
||||
| "error"
|
||||
| "scheduled"
|
||||
| "initialIndexing"
|
||||
| "invalid"
|
||||
@@ -47,7 +46,6 @@ export interface StatusTag {
|
||||
const TAGS = {
|
||||
deleting: { icon: SvgAlertTriangle, color: "red", labelKey: "deleting" },
|
||||
paused: { icon: SvgPause, color: "amber", labelKey: "paused" },
|
||||
error: { icon: SvgXOctagon, color: "red", labelKey: "error" },
|
||||
scheduled: { icon: SvgClock, color: "purple", labelKey: "scheduled" },
|
||||
initialIndexing: {
|
||||
icon: IconLoader,
|
||||
@@ -70,7 +68,7 @@ const TAGS = {
|
||||
failedToRun: { icon: SvgXOctagon, color: "red", labelKey: "failedToRun" },
|
||||
} satisfies Record<StatusTag["labelKey"], StatusTag>;
|
||||
|
||||
/** The tag for the pair's own state, else the latest attempt's. A live attempt outranks the sticky error. */
|
||||
/** The tag for the pair's own state, else the latest attempt's. A live attempt outranks repeated failures. */
|
||||
export function ccPairStatusTag(
|
||||
status: ConnectorCredentialPairStatus,
|
||||
inRepeatedErrorState: boolean,
|
||||
@@ -78,8 +76,9 @@ export function ccPairStatusTag(
|
||||
): StatusTag {
|
||||
if (status === ConnectorCredentialPairStatus.DELETING) return TAGS.deleting;
|
||||
if (status === ConnectorCredentialPairStatus.PAUSED) return TAGS.paused;
|
||||
// Repeated failures read "Failed", the same tag as the pair's row on the status page.
|
||||
if (inRepeatedErrorState && lastAttemptStatus !== "in_progress") {
|
||||
return TAGS.error;
|
||||
return TAGS.failed;
|
||||
}
|
||||
if (status === ConnectorCredentialPairStatus.SCHEDULED) return TAGS.scheduled;
|
||||
if (status === ConnectorCredentialPairStatus.INITIAL_INDEXING) {
|
||||
|
||||
Reference in new issue
Block a user