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:
authored and GitHub committed 2026-10-17 22:27:57 +00:00
1 parent 4fcd7350d5
commit 6a2ce321e8
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
+16 -2
View File
@@ -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),
)
+1
View File
@@ -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(
+4
View File
@@ -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)
+2
View File
@@ -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;
}
+2 -1
View File
@@ -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
+4 -1
View File
@@ -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" },
};
+1
View File
@@ -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"
+17 -7
View File
@@ -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": "جارٍ الفهرسة",
+19 -9
View File
@@ -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",
+17 -7
View File
@@ -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",
+17 -7
View File
@@ -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",
+17 -7
View File
@@ -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",
+20 -10
View File
@@ -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": "インデックス作成中",
+17 -7
View File
@@ -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": "인덱싱 중",
+17 -7
View File
@@ -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",
+20 -10
View File
@@ -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": "正在索引",
+1
View File
@@ -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;
@@ -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) {