mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-03 14:45:46 +00:00
Compare commits
24 Commits
hide_colum
...
table-prim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c08ce34682 | ||
|
|
144c1b6453 | ||
|
|
009954c04a | ||
|
|
63db2d437b | ||
|
|
bff82198af | ||
|
|
8d629247a5 | ||
|
|
9212d0e80e | ||
|
|
d345975c46 | ||
|
|
1128f92972 | ||
|
|
98e7ddc11d | ||
|
|
6f81e94f11 | ||
|
|
fcaf396159 | ||
|
|
657623192f | ||
|
|
826f7f72bb | ||
|
|
0c153fef75 | ||
|
|
60fe3e9ad6 | ||
|
|
6aa56821d6 | ||
|
|
eda436de01 | ||
|
|
07915a6c01 | ||
|
|
2c3e9aecd1 | ||
|
|
fa29cc3849 | ||
|
|
24ac8b37d3 | ||
|
|
be8b108ae4 | ||
|
|
f380a75df3 |
@@ -0,0 +1,37 @@
|
||||
"""add cache_store table
|
||||
|
||||
Revision ID: 2664261bfaab
|
||||
Revises: 4a1e4b1c89d2
|
||||
Create Date: 2026-02-27 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2664261bfaab"
|
||||
down_revision = "4a1e4b1c89d2"
|
||||
branch_labels: None = None
|
||||
depends_on: None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cache_store",
|
||||
sa.Column("key", sa.String(), nullable=False),
|
||||
sa.Column("value", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("key"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_cache_store_expires",
|
||||
"cache_store",
|
||||
["expires_at"],
|
||||
postgresql_where=sa.text("expires_at IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cache_store_expires", table_name="cache_store")
|
||||
op.drop_table("cache_store")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""make scim_user_mapping.external_id nullable
|
||||
|
||||
Revision ID: a3b8d9e2f1c4
|
||||
Revises: 2664261bfaab
|
||||
Create Date: 2026-03-02
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a3b8d9e2f1c4"
|
||||
down_revision = "2664261bfaab"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"scim_user_mapping",
|
||||
"external_id",
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Delete any rows where external_id is NULL before re-applying NOT NULL
|
||||
op.execute("DELETE FROM scim_user_mapping WHERE external_id IS NULL")
|
||||
op.alter_column(
|
||||
"scim_user_mapping",
|
||||
"external_id",
|
||||
nullable=False,
|
||||
)
|
||||
@@ -126,12 +126,16 @@ class ScimDAL(DAL):
|
||||
|
||||
def create_user_mapping(
|
||||
self,
|
||||
external_id: str,
|
||||
external_id: str | None,
|
||||
user_id: UUID,
|
||||
scim_username: str | None = None,
|
||||
fields: ScimMappingFields | None = None,
|
||||
) -> ScimUserMapping:
|
||||
"""Create a mapping between a SCIM externalId and an Onyx user."""
|
||||
"""Create a SCIM mapping for a user.
|
||||
|
||||
``external_id`` may be ``None`` when the IdP omits it (RFC 7643
|
||||
allows this). The mapping still marks the user as SCIM-managed.
|
||||
"""
|
||||
f = fields or ScimMappingFields()
|
||||
mapping = ScimUserMapping(
|
||||
external_id=external_id,
|
||||
@@ -270,8 +274,13 @@ class ScimDAL(DAL):
|
||||
Raises:
|
||||
ValueError: If the filter uses an unsupported attribute.
|
||||
"""
|
||||
query = select(User).where(
|
||||
User.role.notin_([UserRole.SLACK_USER, UserRole.EXT_PERM_USER])
|
||||
# Inner-join with ScimUserMapping so only SCIM-managed users appear.
|
||||
# Pre-existing system accounts (anonymous, admin, etc.) are excluded
|
||||
# unless they were explicitly linked via SCIM provisioning.
|
||||
query = (
|
||||
select(User)
|
||||
.join(ScimUserMapping, ScimUserMapping.user_id == User.id)
|
||||
.where(User.role.notin_([UserRole.SLACK_USER, UserRole.EXT_PERM_USER]))
|
||||
)
|
||||
|
||||
if scim_filter:
|
||||
@@ -321,34 +330,37 @@ class ScimDAL(DAL):
|
||||
scim_username: str | None = None,
|
||||
fields: ScimMappingFields | None = None,
|
||||
) -> None:
|
||||
"""Create, update, or delete the external ID mapping for a user.
|
||||
"""Sync the SCIM mapping for a user.
|
||||
|
||||
If a mapping already exists, its fields are updated (including
|
||||
setting ``external_id`` to ``None`` when the IdP omits it).
|
||||
If no mapping exists and ``new_external_id`` is provided, a new
|
||||
mapping is created. A mapping is never deleted here — SCIM-managed
|
||||
users must retain their mapping to remain visible in ``GET /Users``.
|
||||
|
||||
When *fields* is provided, all mapping fields are written
|
||||
unconditionally — including ``None`` values — so that a caller can
|
||||
clear a previously-set field (e.g. removing a department).
|
||||
"""
|
||||
mapping = self.get_user_mapping_by_user_id(user_id)
|
||||
if new_external_id:
|
||||
if mapping:
|
||||
if mapping.external_id != new_external_id:
|
||||
mapping.external_id = new_external_id
|
||||
if scim_username is not None:
|
||||
mapping.scim_username = scim_username
|
||||
if fields is not None:
|
||||
mapping.department = fields.department
|
||||
mapping.manager = fields.manager
|
||||
mapping.given_name = fields.given_name
|
||||
mapping.family_name = fields.family_name
|
||||
mapping.scim_emails_json = fields.scim_emails_json
|
||||
else:
|
||||
self.create_user_mapping(
|
||||
external_id=new_external_id,
|
||||
user_id=user_id,
|
||||
scim_username=scim_username,
|
||||
fields=fields,
|
||||
)
|
||||
elif mapping:
|
||||
self.delete_user_mapping(mapping.id)
|
||||
if mapping:
|
||||
if mapping.external_id != new_external_id:
|
||||
mapping.external_id = new_external_id
|
||||
if scim_username is not None:
|
||||
mapping.scim_username = scim_username
|
||||
if fields is not None:
|
||||
mapping.department = fields.department
|
||||
mapping.manager = fields.manager
|
||||
mapping.given_name = fields.given_name
|
||||
mapping.family_name = fields.family_name
|
||||
mapping.scim_emails_json = fields.scim_emails_json
|
||||
elif new_external_id:
|
||||
self.create_user_mapping(
|
||||
external_id=new_external_id,
|
||||
user_id=user_id,
|
||||
scim_username=scim_username,
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
def _get_user_mappings_batch(
|
||||
self, user_ids: list[UUID]
|
||||
|
||||
@@ -423,15 +423,63 @@ def create_user(
|
||||
|
||||
email = user_resource.userName.strip()
|
||||
|
||||
# Enforce seat limit
|
||||
# Check for existing user — if they exist but aren't SCIM-managed yet,
|
||||
# link them to the IdP rather than rejecting with 409.
|
||||
external_id: str | None = user_resource.externalId
|
||||
scim_username: str = user_resource.userName.strip()
|
||||
fields: ScimMappingFields = _fields_from_resource(user_resource)
|
||||
|
||||
existing_user = dal.get_user_by_email(email)
|
||||
if existing_user:
|
||||
existing_mapping = dal.get_user_mapping_by_user_id(existing_user.id)
|
||||
if existing_mapping:
|
||||
return _scim_error_response(409, f"User with email {email} already exists")
|
||||
|
||||
# Adopt pre-existing user into SCIM management.
|
||||
# Reactivating a deactivated user consumes a seat, so enforce the
|
||||
# seat limit the same way replace_user does.
|
||||
if user_resource.active and not existing_user.is_active:
|
||||
seat_error = _check_seat_availability(dal)
|
||||
if seat_error:
|
||||
return _scim_error_response(403, seat_error)
|
||||
|
||||
personal_name = _scim_name_to_str(user_resource.name)
|
||||
dal.update_user(
|
||||
existing_user,
|
||||
is_active=user_resource.active,
|
||||
**({"personal_name": personal_name} if personal_name else {}),
|
||||
)
|
||||
|
||||
try:
|
||||
dal.create_user_mapping(
|
||||
external_id=external_id,
|
||||
user_id=existing_user.id,
|
||||
scim_username=scim_username,
|
||||
fields=fields,
|
||||
)
|
||||
dal.commit()
|
||||
except IntegrityError:
|
||||
dal.rollback()
|
||||
return _scim_error_response(
|
||||
409, f"User with email {email} already has a SCIM mapping"
|
||||
)
|
||||
|
||||
return _scim_resource_response(
|
||||
provider.build_user_resource(
|
||||
existing_user,
|
||||
external_id,
|
||||
scim_username=scim_username,
|
||||
fields=fields,
|
||||
),
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
# Only enforce seat limit for net-new users — adopting a pre-existing
|
||||
# user doesn't consume a new seat.
|
||||
seat_error = _check_seat_availability(dal)
|
||||
if seat_error:
|
||||
return _scim_error_response(403, seat_error)
|
||||
|
||||
# Check for existing user
|
||||
if dal.get_user_by_email(email):
|
||||
return _scim_error_response(409, f"User with email {email} already exists")
|
||||
|
||||
# Create user with a random password (SCIM users authenticate via IdP)
|
||||
personal_name = _scim_name_to_str(user_resource.name)
|
||||
user = User(
|
||||
@@ -449,21 +497,21 @@ def create_user(
|
||||
dal.rollback()
|
||||
return _scim_error_response(409, f"User with email {email} already exists")
|
||||
|
||||
# Create SCIM mapping when externalId is provided — this is how the IdP
|
||||
# correlates this user on subsequent requests. Per RFC 7643, externalId
|
||||
# is optional and assigned by the provisioning client.
|
||||
external_id = user_resource.externalId
|
||||
scim_username = user_resource.userName.strip()
|
||||
fields = _fields_from_resource(user_resource)
|
||||
if external_id:
|
||||
# Always create a SCIM mapping so that the user is marked as
|
||||
# SCIM-managed. externalId may be None (RFC 7643 says it's optional).
|
||||
try:
|
||||
dal.create_user_mapping(
|
||||
external_id=external_id,
|
||||
user_id=user.id,
|
||||
scim_username=scim_username,
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
dal.commit()
|
||||
dal.commit()
|
||||
except IntegrityError:
|
||||
dal.rollback()
|
||||
return _scim_error_response(
|
||||
409, f"User with email {email} already has a SCIM mapping"
|
||||
)
|
||||
|
||||
return _scim_resource_response(
|
||||
provider.build_user_resource(
|
||||
|
||||
@@ -170,7 +170,10 @@ class ScimProvider(ABC):
|
||||
formatted=user.personal_name or "",
|
||||
)
|
||||
if not user.personal_name:
|
||||
return ScimName(givenName="", familyName="", formatted="")
|
||||
# Derive a reasonable name from the email so that SCIM spec tests
|
||||
# see non-empty givenName / familyName for every user resource.
|
||||
local = user.email.split("@")[0] if user.email else ""
|
||||
return ScimName(givenName=local, familyName="", formatted=local)
|
||||
parts = user.personal_name.split(" ", 1)
|
||||
return ScimName(
|
||||
givenName=parts[0],
|
||||
|
||||
@@ -520,6 +520,7 @@ def process_user_file_impl(
|
||||
task_logger.exception(
|
||||
f"process_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if file_lock is not None and file_lock.owned():
|
||||
file_lock.release()
|
||||
@@ -675,6 +676,7 @@ def delete_user_file_impl(
|
||||
task_logger.exception(
|
||||
f"delete_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if file_lock is not None and file_lock.owned():
|
||||
file_lock.release()
|
||||
@@ -849,6 +851,7 @@ def project_sync_user_file_impl(
|
||||
task_logger.exception(
|
||||
f"project_sync_user_file_impl - Error syncing project for file id={user_file_id} - {e.__class__.__name__}"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if file_lock is not None and file_lock.owned():
|
||||
file_lock.release()
|
||||
|
||||
@@ -59,6 +59,12 @@ def _run_auto_llm_update() -> None:
|
||||
sync_llm_models_from_github(db_session)
|
||||
|
||||
|
||||
def _run_cache_cleanup() -> None:
|
||||
from onyx.cache.postgres_backend import cleanup_expired_cache_entries
|
||||
|
||||
cleanup_expired_cache_entries()
|
||||
|
||||
|
||||
def _run_scheduled_eval() -> None:
|
||||
from onyx.configs.app_configs import BRAINTRUST_API_KEY
|
||||
from onyx.configs.app_configs import SCHEDULED_EVAL_DATASET_NAMES
|
||||
@@ -100,12 +106,26 @@ def _run_scheduled_eval() -> None:
|
||||
)
|
||||
|
||||
|
||||
_CACHE_CLEANUP_INTERVAL_SECONDS = 300
|
||||
|
||||
|
||||
def _build_periodic_tasks() -> list[_PeriodicTaskDef]:
|
||||
from onyx.cache.interface import CacheBackendType
|
||||
from onyx.configs.app_configs import AUTO_LLM_CONFIG_URL
|
||||
from onyx.configs.app_configs import AUTO_LLM_UPDATE_INTERVAL_SECONDS
|
||||
from onyx.configs.app_configs import CACHE_BACKEND
|
||||
from onyx.configs.app_configs import SCHEDULED_EVAL_DATASET_NAMES
|
||||
|
||||
tasks: list[_PeriodicTaskDef] = []
|
||||
if CACHE_BACKEND == CacheBackendType.POSTGRES:
|
||||
tasks.append(
|
||||
_PeriodicTaskDef(
|
||||
name="cache-cleanup",
|
||||
interval_seconds=_CACHE_CLEANUP_INTERVAL_SECONDS,
|
||||
lock_id=PERIODIC_TASK_LOCK_BASE + 2,
|
||||
run_fn=_run_cache_cleanup,
|
||||
)
|
||||
)
|
||||
if AUTO_LLM_CONFIG_URL:
|
||||
tasks.append(
|
||||
_PeriodicTaskDef(
|
||||
|
||||
@@ -75,31 +75,41 @@ def _claim_next_processing_file(db_session: Session) -> UUID | None:
|
||||
return file_id
|
||||
|
||||
|
||||
def _claim_next_deleting_file(db_session: Session) -> UUID | None:
|
||||
def _claim_next_deleting_file(
|
||||
db_session: Session,
|
||||
exclude_ids: set[UUID] | None = None,
|
||||
) -> UUID | None:
|
||||
"""Claim the next DELETING file.
|
||||
|
||||
No status transition needed — the impl deletes the row on success.
|
||||
The short-lived FOR UPDATE lock prevents concurrent claims.
|
||||
*exclude_ids* prevents re-processing the same file if the impl fails.
|
||||
"""
|
||||
file_id = db_session.execute(
|
||||
stmt = (
|
||||
select(UserFile.id)
|
||||
.where(UserFile.status == UserFileStatus.DELETING)
|
||||
.order_by(UserFile.created_at)
|
||||
.limit(1)
|
||||
.with_for_update(skip_locked=True)
|
||||
).scalar_one_or_none()
|
||||
# Commit to release the row lock promptly.
|
||||
)
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(UserFile.id.notin_(exclude_ids))
|
||||
file_id = db_session.execute(stmt).scalar_one_or_none()
|
||||
db_session.commit()
|
||||
return file_id
|
||||
|
||||
|
||||
def _claim_next_sync_file(db_session: Session) -> UUID | None:
|
||||
def _claim_next_sync_file(
|
||||
db_session: Session,
|
||||
exclude_ids: set[UUID] | None = None,
|
||||
) -> UUID | None:
|
||||
"""Claim the next file needing project/persona sync.
|
||||
|
||||
No status transition needed — the impl clears the sync flags on
|
||||
success. The short-lived FOR UPDATE lock prevents concurrent claims.
|
||||
*exclude_ids* prevents re-processing the same file if the impl fails.
|
||||
"""
|
||||
file_id = db_session.execute(
|
||||
stmt = (
|
||||
select(UserFile.id)
|
||||
.where(
|
||||
sa.and_(
|
||||
@@ -113,7 +123,10 @@ def _claim_next_sync_file(db_session: Session) -> UUID | None:
|
||||
.order_by(UserFile.created_at)
|
||||
.limit(1)
|
||||
.with_for_update(skip_locked=True)
|
||||
).scalar_one_or_none()
|
||||
)
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(UserFile.id.notin_(exclude_ids))
|
||||
file_id = db_session.execute(stmt).scalar_one_or_none()
|
||||
db_session.commit()
|
||||
return file_id
|
||||
|
||||
@@ -135,11 +148,14 @@ def drain_processing_loop(tenant_id: str) -> None:
|
||||
file_id = _claim_next_processing_file(session)
|
||||
if file_id is None:
|
||||
break
|
||||
process_user_file_impl(
|
||||
user_file_id=str(file_id),
|
||||
tenant_id=tenant_id,
|
||||
redis_locking=False,
|
||||
)
|
||||
try:
|
||||
process_user_file_impl(
|
||||
user_file_id=str(file_id),
|
||||
tenant_id=tenant_id,
|
||||
redis_locking=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to process user file {file_id}")
|
||||
|
||||
|
||||
def drain_delete_loop(tenant_id: str) -> None:
|
||||
@@ -149,16 +165,21 @@ def drain_delete_loop(tenant_id: str) -> None:
|
||||
)
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
|
||||
failed: set[UUID] = set()
|
||||
while True:
|
||||
with get_session_with_current_tenant() as session:
|
||||
file_id = _claim_next_deleting_file(session)
|
||||
file_id = _claim_next_deleting_file(session, exclude_ids=failed)
|
||||
if file_id is None:
|
||||
break
|
||||
delete_user_file_impl(
|
||||
user_file_id=str(file_id),
|
||||
tenant_id=tenant_id,
|
||||
redis_locking=False,
|
||||
)
|
||||
try:
|
||||
delete_user_file_impl(
|
||||
user_file_id=str(file_id),
|
||||
tenant_id=tenant_id,
|
||||
redis_locking=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to delete user file {file_id}")
|
||||
failed.add(file_id)
|
||||
|
||||
|
||||
def drain_project_sync_loop(tenant_id: str) -> None:
|
||||
@@ -168,13 +189,18 @@ def drain_project_sync_loop(tenant_id: str) -> None:
|
||||
)
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
|
||||
failed: set[UUID] = set()
|
||||
while True:
|
||||
with get_session_with_current_tenant() as session:
|
||||
file_id = _claim_next_sync_file(session)
|
||||
file_id = _claim_next_sync_file(session, exclude_ids=failed)
|
||||
if file_id is None:
|
||||
break
|
||||
project_sync_user_file_impl(
|
||||
user_file_id=str(file_id),
|
||||
tenant_id=tenant_id,
|
||||
redis_locking=False,
|
||||
)
|
||||
try:
|
||||
project_sync_user_file_impl(
|
||||
user_file_id=str(file_id),
|
||||
tenant_id=tenant_id,
|
||||
redis_locking=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to sync user file {file_id}")
|
||||
failed.add(file_id)
|
||||
|
||||
8
backend/onyx/cache/factory.py
vendored
8
backend/onyx/cache/factory.py
vendored
@@ -12,9 +12,15 @@ def _build_redis_backend(tenant_id: str) -> CacheBackend:
|
||||
return RedisCacheBackend(redis_pool.get_client(tenant_id))
|
||||
|
||||
|
||||
def _build_postgres_backend(tenant_id: str) -> CacheBackend:
|
||||
from onyx.cache.postgres_backend import PostgresCacheBackend
|
||||
|
||||
return PostgresCacheBackend(tenant_id)
|
||||
|
||||
|
||||
_BACKEND_BUILDERS: dict[CacheBackendType, Callable[[str], CacheBackend]] = {
|
||||
CacheBackendType.REDIS: _build_redis_backend,
|
||||
# CacheBackendType.POSTGRES will be added in a follow-up PR.
|
||||
CacheBackendType.POSTGRES: _build_postgres_backend,
|
||||
}
|
||||
|
||||
|
||||
|
||||
17
backend/onyx/cache/interface.py
vendored
17
backend/onyx/cache/interface.py
vendored
@@ -1,6 +1,9 @@
|
||||
import abc
|
||||
from enum import Enum
|
||||
|
||||
TTL_KEY_NOT_FOUND = -2
|
||||
TTL_NO_EXPIRY = -1
|
||||
|
||||
|
||||
class CacheBackendType(str, Enum):
|
||||
REDIS = "redis"
|
||||
@@ -26,6 +29,14 @@ class CacheLock(abc.ABC):
|
||||
def owned(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def __enter__(self) -> "CacheLock":
|
||||
if not self.acquire():
|
||||
raise RuntimeError("Failed to acquire lock")
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.release()
|
||||
|
||||
|
||||
class CacheBackend(abc.ABC):
|
||||
"""Thin abstraction over a key-value cache with TTL, locks, and blocking lists.
|
||||
@@ -65,7 +76,11 @@ class CacheBackend(abc.ABC):
|
||||
|
||||
@abc.abstractmethod
|
||||
def ttl(self, key: str) -> int:
|
||||
"""Return remaining TTL in seconds. -1 if no expiry, -2 if key missing."""
|
||||
"""Return remaining TTL in seconds.
|
||||
|
||||
Returns ``TTL_NO_EXPIRY`` (-1) if key exists without expiry,
|
||||
``TTL_KEY_NOT_FOUND`` (-2) if key is missing or expired.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# -- distributed lock --------------------------------------------------
|
||||
|
||||
323
backend/onyx/cache/postgres_backend.py
vendored
Normal file
323
backend/onyx/cache/postgres_backend.py
vendored
Normal file
@@ -0,0 +1,323 @@
|
||||
"""PostgreSQL-backed ``CacheBackend`` for NO_VECTOR_DB deployments.
|
||||
|
||||
Uses the ``cache_store`` table for key-value storage, PostgreSQL advisory locks
|
||||
for distributed locking, and a polling loop for the BLPOP pattern.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import AbstractContextManager
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.cache.interface import CacheBackend
|
||||
from onyx.cache.interface import CacheLock
|
||||
from onyx.cache.interface import TTL_KEY_NOT_FOUND
|
||||
from onyx.cache.interface import TTL_NO_EXPIRY
|
||||
from onyx.db.models import CacheStore
|
||||
|
||||
_LIST_KEY_PREFIX = "_q:"
|
||||
# ASCII: ':' (0x3A) < ';' (0x3B). Upper bound for range queries so [prefix+, prefix;)
|
||||
# captures all list-item keys (e.g. _q:mylist:123:uuid) without including other
|
||||
# lists whose names share a prefix (e.g. _q:mylist2:...).
|
||||
_LIST_KEY_RANGE_TERMINATOR = ";"
|
||||
_LIST_ITEM_TTL_SECONDS = 3600
|
||||
_LOCK_POLL_INTERVAL = 0.1
|
||||
_BLPOP_POLL_INTERVAL = 0.25
|
||||
|
||||
|
||||
def _list_item_key(key: str) -> str:
|
||||
"""Unique key for a list item. Timestamp for FIFO ordering; UUID prevents
|
||||
collision when concurrent rpush calls occur within the same nanosecond.
|
||||
"""
|
||||
return f"{_LIST_KEY_PREFIX}{key}:{time.time_ns()}:{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _to_bytes(value: str | bytes | int | float) -> bytes:
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
return str(value).encode()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lock
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class PostgresCacheLock(CacheLock):
|
||||
"""Advisory-lock-based distributed lock.
|
||||
|
||||
Uses ``get_session_with_tenant`` for connection lifecycle. The lock is tied
|
||||
to the session's connection; releasing or closing the session frees it.
|
||||
|
||||
NOTE: Unlike Redis locks, advisory locks do not auto-expire after
|
||||
``timeout`` seconds. They are released when ``release()`` is
|
||||
called or when the session is closed.
|
||||
"""
|
||||
|
||||
def __init__(self, lock_id: int, timeout: float | None, tenant_id: str) -> None:
|
||||
self._lock_id = lock_id
|
||||
self._timeout = timeout
|
||||
self._tenant_id = tenant_id
|
||||
self._session_cm: AbstractContextManager[Session] | None = None
|
||||
self._session: Session | None = None
|
||||
self._acquired = False
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
blocking: bool = True,
|
||||
blocking_timeout: float | None = None,
|
||||
) -> bool:
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
|
||||
self._session_cm = get_session_with_tenant(tenant_id=self._tenant_id)
|
||||
self._session = self._session_cm.__enter__()
|
||||
try:
|
||||
if not blocking:
|
||||
return self._try_lock()
|
||||
|
||||
effective_timeout = blocking_timeout or self._timeout
|
||||
deadline = (
|
||||
(time.monotonic() + effective_timeout) if effective_timeout else None
|
||||
)
|
||||
while True:
|
||||
if self._try_lock():
|
||||
return True
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
return False
|
||||
time.sleep(_LOCK_POLL_INTERVAL)
|
||||
finally:
|
||||
if not self._acquired:
|
||||
self._close_session()
|
||||
|
||||
def release(self) -> None:
|
||||
if not self._acquired or self._session is None:
|
||||
return
|
||||
try:
|
||||
self._session.execute(select(func.pg_advisory_unlock(self._lock_id)))
|
||||
finally:
|
||||
self._acquired = False
|
||||
self._close_session()
|
||||
|
||||
def owned(self) -> bool:
|
||||
return self._acquired
|
||||
|
||||
def _close_session(self) -> None:
|
||||
if self._session_cm is not None:
|
||||
try:
|
||||
self._session_cm.__exit__(None, None, None)
|
||||
finally:
|
||||
self._session_cm = None
|
||||
self._session = None
|
||||
|
||||
def _try_lock(self) -> bool:
|
||||
assert self._session is not None
|
||||
result = self._session.execute(
|
||||
select(func.pg_try_advisory_lock(self._lock_id))
|
||||
).scalar()
|
||||
if result:
|
||||
self._acquired = True
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Backend
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class PostgresCacheBackend(CacheBackend):
|
||||
"""``CacheBackend`` backed by the ``cache_store`` table in PostgreSQL.
|
||||
|
||||
Each operation opens and closes its own database session so the backend
|
||||
is safe to share across threads. Tenant isolation is handled by
|
||||
SQLAlchemy's ``schema_translate_map`` (set by ``get_session_with_tenant``).
|
||||
"""
|
||||
|
||||
def __init__(self, tenant_id: str) -> None:
|
||||
self._tenant_id = tenant_id
|
||||
|
||||
# -- basic key/value ---------------------------------------------------
|
||||
|
||||
def get(self, key: str) -> bytes | None:
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
|
||||
stmt = select(CacheStore.value).where(
|
||||
CacheStore.key == key,
|
||||
or_(CacheStore.expires_at.is_(None), CacheStore.expires_at > func.now()),
|
||||
)
|
||||
with get_session_with_tenant(tenant_id=self._tenant_id) as session:
|
||||
value = session.execute(stmt).scalar_one_or_none()
|
||||
if value is None:
|
||||
return None
|
||||
return bytes(value)
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str | bytes | int | float,
|
||||
ex: int | None = None,
|
||||
) -> None:
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
|
||||
value_bytes = _to_bytes(value)
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=ex)
|
||||
if ex is not None
|
||||
else None
|
||||
)
|
||||
stmt = (
|
||||
pg_insert(CacheStore)
|
||||
.values(key=key, value=value_bytes, expires_at=expires_at)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[CacheStore.key],
|
||||
set_={"value": value_bytes, "expires_at": expires_at},
|
||||
)
|
||||
)
|
||||
with get_session_with_tenant(tenant_id=self._tenant_id) as session:
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
|
||||
with get_session_with_tenant(tenant_id=self._tenant_id) as session:
|
||||
session.execute(delete(CacheStore).where(CacheStore.key == key))
|
||||
session.commit()
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
|
||||
stmt = (
|
||||
select(CacheStore.key)
|
||||
.where(
|
||||
CacheStore.key == key,
|
||||
or_(
|
||||
CacheStore.expires_at.is_(None),
|
||||
CacheStore.expires_at > func.now(),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
with get_session_with_tenant(tenant_id=self._tenant_id) as session:
|
||||
return session.execute(stmt).first() is not None
|
||||
|
||||
# -- TTL ---------------------------------------------------------------
|
||||
|
||||
def expire(self, key: str, seconds: int) -> None:
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
|
||||
new_exp = datetime.now(timezone.utc) + timedelta(seconds=seconds)
|
||||
stmt = (
|
||||
update(CacheStore).where(CacheStore.key == key).values(expires_at=new_exp)
|
||||
)
|
||||
with get_session_with_tenant(tenant_id=self._tenant_id) as session:
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
|
||||
def ttl(self, key: str) -> int:
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
|
||||
stmt = select(CacheStore.expires_at).where(CacheStore.key == key)
|
||||
with get_session_with_tenant(tenant_id=self._tenant_id) as session:
|
||||
result = session.execute(stmt).first()
|
||||
if result is None:
|
||||
return TTL_KEY_NOT_FOUND
|
||||
expires_at: datetime | None = result[0]
|
||||
if expires_at is None:
|
||||
return TTL_NO_EXPIRY
|
||||
remaining = (expires_at - datetime.now(timezone.utc)).total_seconds()
|
||||
if remaining <= 0:
|
||||
return TTL_KEY_NOT_FOUND
|
||||
return int(remaining)
|
||||
|
||||
# -- distributed lock --------------------------------------------------
|
||||
|
||||
def lock(self, name: str, timeout: float | None = None) -> CacheLock:
|
||||
return PostgresCacheLock(
|
||||
self._lock_id_for(name), timeout, tenant_id=self._tenant_id
|
||||
)
|
||||
|
||||
# -- blocking list (MCP OAuth BLPOP pattern) ---------------------------
|
||||
|
||||
def rpush(self, key: str, value: str | bytes) -> None:
|
||||
self.set(_list_item_key(key), value, ex=_LIST_ITEM_TTL_SECONDS)
|
||||
|
||||
def blpop(self, keys: list[str], timeout: int = 0) -> tuple[bytes, bytes] | None:
|
||||
if timeout <= 0:
|
||||
raise ValueError(
|
||||
"PostgresCacheBackend.blpop requires timeout > 0. "
|
||||
"timeout=0 would block the calling thread indefinitely "
|
||||
"with no way to interrupt short of process termination."
|
||||
)
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
for key in keys:
|
||||
lower = f"{_LIST_KEY_PREFIX}{key}:"
|
||||
upper = f"{_LIST_KEY_PREFIX}{key}{_LIST_KEY_RANGE_TERMINATOR}"
|
||||
stmt = (
|
||||
select(CacheStore)
|
||||
.where(
|
||||
CacheStore.key >= lower,
|
||||
CacheStore.key < upper,
|
||||
or_(
|
||||
CacheStore.expires_at.is_(None),
|
||||
CacheStore.expires_at > func.now(),
|
||||
),
|
||||
)
|
||||
.order_by(CacheStore.key)
|
||||
.limit(1)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
with get_session_with_tenant(tenant_id=self._tenant_id) as session:
|
||||
row = session.execute(stmt).scalars().first()
|
||||
if row is not None:
|
||||
value = bytes(row.value) if row.value else b""
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return (key.encode(), value)
|
||||
if time.monotonic() >= deadline:
|
||||
return None
|
||||
time.sleep(_BLPOP_POLL_INTERVAL)
|
||||
|
||||
# -- helpers -----------------------------------------------------------
|
||||
|
||||
def _lock_id_for(self, name: str) -> int:
|
||||
"""Map *name* to a 64-bit signed int for ``pg_advisory_lock``."""
|
||||
h = hashlib.md5(f"{self._tenant_id}:{name}".encode()).digest()
|
||||
return struct.unpack("q", h[:8])[0]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Periodic cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def cleanup_expired_cache_entries() -> None:
|
||||
"""Delete rows whose ``expires_at`` is in the past.
|
||||
|
||||
Called by the periodic poller every 5 minutes.
|
||||
"""
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
|
||||
with get_session_with_current_tenant() as session:
|
||||
session.execute(
|
||||
delete(CacheStore).where(
|
||||
CacheStore.expires_at.is_not(None),
|
||||
CacheStore.expires_at < func.now(),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
@@ -1,57 +1,52 @@
|
||||
from uuid import UUID
|
||||
|
||||
from redis.client import Redis
|
||||
from onyx.cache.interface import CacheBackend
|
||||
|
||||
# Redis key prefixes for chat message processing
|
||||
PREFIX = "chatprocessing"
|
||||
FENCE_PREFIX = f"{PREFIX}_fence"
|
||||
FENCE_TTL = 30 * 60 # 30 minutes
|
||||
|
||||
|
||||
def _get_fence_key(chat_session_id: UUID) -> str:
|
||||
"""
|
||||
Generate the Redis key for a chat session processing a message.
|
||||
"""Generate the cache key for a chat session processing fence.
|
||||
|
||||
Args:
|
||||
chat_session_id: The UUID of the chat session
|
||||
|
||||
Returns:
|
||||
The fence key string (tenant_id is automatically added by the Redis client)
|
||||
The fence key string. Tenant isolation is handled automatically
|
||||
by the cache backend (Redis key-prefixing or Postgres schema routing).
|
||||
"""
|
||||
return f"{FENCE_PREFIX}_{chat_session_id}"
|
||||
|
||||
|
||||
def set_processing_status(
|
||||
chat_session_id: UUID, redis_client: Redis, value: bool
|
||||
chat_session_id: UUID, cache: CacheBackend, value: bool
|
||||
) -> None:
|
||||
"""
|
||||
Set or clear the fence for a chat session processing a message.
|
||||
"""Set or clear the fence for a chat session processing a message.
|
||||
|
||||
If the key exists, we are processing a message. If the key does not exist, we are not processing a message.
|
||||
If the key exists, a message is being processed.
|
||||
|
||||
Args:
|
||||
chat_session_id: The UUID of the chat session
|
||||
redis_client: The Redis client to use
|
||||
cache: Tenant-aware cache backend
|
||||
value: True to set the fence, False to clear it
|
||||
"""
|
||||
fence_key = _get_fence_key(chat_session_id)
|
||||
|
||||
if value:
|
||||
redis_client.set(fence_key, 0, ex=FENCE_TTL)
|
||||
cache.set(fence_key, 0, ex=FENCE_TTL)
|
||||
else:
|
||||
redis_client.delete(fence_key)
|
||||
cache.delete(fence_key)
|
||||
|
||||
|
||||
def is_chat_session_processing(chat_session_id: UUID, redis_client: Redis) -> bool:
|
||||
"""
|
||||
Check if the chat session is processing a message.
|
||||
def is_chat_session_processing(chat_session_id: UUID, cache: CacheBackend) -> bool:
|
||||
"""Check if the chat session is processing a message.
|
||||
|
||||
Args:
|
||||
chat_session_id: The UUID of the chat session
|
||||
redis_client: The Redis client to use
|
||||
cache: Tenant-aware cache backend
|
||||
|
||||
Returns:
|
||||
True if the chat session is processing a message, False otherwise
|
||||
"""
|
||||
fence_key = _get_fence_key(chat_session_id)
|
||||
return bool(redis_client.exists(fence_key))
|
||||
return cache.exists(_get_fence_key(chat_session_id))
|
||||
|
||||
@@ -11,9 +11,10 @@ from contextvars import Token
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from redis.client import Redis
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.cache.factory import get_cache_backend
|
||||
from onyx.cache.interface import CacheBackend
|
||||
from onyx.chat.chat_processing_checker import set_processing_status
|
||||
from onyx.chat.chat_state import ChatStateContainer
|
||||
from onyx.chat.chat_state import run_chat_loop_with_state_containers
|
||||
@@ -79,7 +80,6 @@ from onyx.llm.request_context import reset_llm_mock_response
|
||||
from onyx.llm.request_context import set_llm_mock_response
|
||||
from onyx.llm.utils import litellm_exception_to_error_msg
|
||||
from onyx.onyxbot.slack.models import SlackContext
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.server.query_and_chat.models import AUTO_PLACE_AFTER_LATEST_MESSAGE
|
||||
from onyx.server.query_and_chat.models import MessageResponseIDInfo
|
||||
from onyx.server.query_and_chat.models import SendMessageRequest
|
||||
@@ -448,7 +448,7 @@ def handle_stream_message_objects(
|
||||
|
||||
llm: LLM | None = None
|
||||
chat_session: ChatSession | None = None
|
||||
redis_client: Redis | None = None
|
||||
cache: CacheBackend | None = None
|
||||
|
||||
user_id = user.id
|
||||
if user.is_anonymous:
|
||||
@@ -809,19 +809,19 @@ def handle_stream_message_objects(
|
||||
)
|
||||
simple_chat_history.insert(0, summary_simple)
|
||||
|
||||
redis_client = get_redis_client()
|
||||
cache = get_cache_backend()
|
||||
|
||||
reset_cancel_status(
|
||||
chat_session.id,
|
||||
redis_client,
|
||||
cache,
|
||||
)
|
||||
|
||||
def check_is_connected() -> bool:
|
||||
return check_stop_signal(chat_session.id, redis_client)
|
||||
return check_stop_signal(chat_session.id, cache)
|
||||
|
||||
set_processing_status(
|
||||
chat_session_id=chat_session.id,
|
||||
redis_client=redis_client,
|
||||
cache=cache,
|
||||
value=True,
|
||||
)
|
||||
|
||||
@@ -968,10 +968,10 @@ def handle_stream_message_objects(
|
||||
reset_llm_mock_response(mock_response_token)
|
||||
|
||||
try:
|
||||
if redis_client is not None and chat_session is not None:
|
||||
if cache is not None and chat_session is not None:
|
||||
set_processing_status(
|
||||
chat_session_id=chat_session.id,
|
||||
redis_client=redis_client,
|
||||
cache=cache,
|
||||
value=False,
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -1,65 +1,58 @@
|
||||
from uuid import UUID
|
||||
|
||||
from redis.client import Redis
|
||||
from onyx.cache.interface import CacheBackend
|
||||
|
||||
# Redis key prefixes for chat session stop signals
|
||||
PREFIX = "chatsessionstop"
|
||||
FENCE_PREFIX = f"{PREFIX}_fence"
|
||||
FENCE_TTL = 10 * 60 # 10 minutes - defensive TTL to prevent memory leaks
|
||||
FENCE_TTL = 10 * 60 # 10 minutes
|
||||
|
||||
|
||||
def _get_fence_key(chat_session_id: UUID) -> str:
|
||||
"""
|
||||
Generate the Redis key for a chat session stop signal fence.
|
||||
"""Generate the cache key for a chat session stop signal fence.
|
||||
|
||||
Args:
|
||||
chat_session_id: The UUID of the chat session
|
||||
|
||||
Returns:
|
||||
The fence key string (tenant_id is automatically added by the Redis client)
|
||||
The fence key string. Tenant isolation is handled automatically
|
||||
by the cache backend (Redis key-prefixing or Postgres schema routing).
|
||||
"""
|
||||
return f"{FENCE_PREFIX}_{chat_session_id}"
|
||||
|
||||
|
||||
def set_fence(chat_session_id: UUID, redis_client: Redis, value: bool) -> None:
|
||||
"""
|
||||
Set or clear the stop signal fence for a chat session.
|
||||
def set_fence(chat_session_id: UUID, cache: CacheBackend, value: bool) -> None:
|
||||
"""Set or clear the stop signal fence for a chat session.
|
||||
|
||||
Args:
|
||||
chat_session_id: The UUID of the chat session
|
||||
redis_client: Redis client to use (tenant-aware client that auto-prefixes keys)
|
||||
cache: Tenant-aware cache backend
|
||||
value: True to set the fence (stop signal), False to clear it
|
||||
"""
|
||||
fence_key = _get_fence_key(chat_session_id)
|
||||
if not value:
|
||||
redis_client.delete(fence_key)
|
||||
cache.delete(fence_key)
|
||||
return
|
||||
|
||||
redis_client.set(fence_key, 0, ex=FENCE_TTL)
|
||||
cache.set(fence_key, 0, ex=FENCE_TTL)
|
||||
|
||||
|
||||
def is_connected(chat_session_id: UUID, redis_client: Redis) -> bool:
|
||||
"""
|
||||
Check if the chat session should continue (not stopped).
|
||||
def is_connected(chat_session_id: UUID, cache: CacheBackend) -> bool:
|
||||
"""Check if the chat session should continue (not stopped).
|
||||
|
||||
Args:
|
||||
chat_session_id: The UUID of the chat session to check
|
||||
redis_client: Redis client to use for checking the stop signal (tenant-aware client that auto-prefixes keys)
|
||||
cache: Tenant-aware cache backend
|
||||
|
||||
Returns:
|
||||
True if the session should continue, False if it should stop
|
||||
"""
|
||||
fence_key = _get_fence_key(chat_session_id)
|
||||
return not bool(redis_client.exists(fence_key))
|
||||
return not cache.exists(_get_fence_key(chat_session_id))
|
||||
|
||||
|
||||
def reset_cancel_status(chat_session_id: UUID, redis_client: Redis) -> None:
|
||||
"""
|
||||
Clear the stop signal for a chat session.
|
||||
def reset_cancel_status(chat_session_id: UUID, cache: CacheBackend) -> None:
|
||||
"""Clear the stop signal for a chat session.
|
||||
|
||||
Args:
|
||||
chat_session_id: The UUID of the chat session
|
||||
redis_client: Redis client to use (tenant-aware client that auto-prefixes keys)
|
||||
cache: Tenant-aware cache backend
|
||||
"""
|
||||
fence_key = _get_fence_key(chat_session_id)
|
||||
redis_client.delete(fence_key)
|
||||
cache.delete(_get_fence_key(chat_session_id))
|
||||
|
||||
@@ -2822,8 +2822,17 @@ class LLMProvider(Base):
|
||||
postgresql.JSONB(), nullable=True
|
||||
)
|
||||
|
||||
# Deprecated: use LLMModelFlow with CHAT flow type instead
|
||||
default_model_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
deployment_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
# Deprecated: use LLMModelFlow.is_default with CHAT flow type instead
|
||||
is_default_provider: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
# Deprecated: use LLMModelFlow.is_default with VISION flow type instead
|
||||
is_default_vision_provider: Mapped[bool | None] = mapped_column(Boolean)
|
||||
# Deprecated: use LLMModelFlow with VISION flow type instead
|
||||
default_vision_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# EE only
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
# Auto mode: models, visibility, and defaults are managed by GitHub config
|
||||
@@ -4917,7 +4926,9 @@ class ScimUserMapping(Base):
|
||||
__tablename__ = "scim_user_mapping"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
external_id: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
external_id: Mapped[str | None] = mapped_column(
|
||||
String, unique=True, index=True, nullable=True
|
||||
)
|
||||
user_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("user.id", ondelete="CASCADE"), unique=True, nullable=False
|
||||
)
|
||||
@@ -4974,3 +4985,25 @@ class CodeInterpreterServer(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
server_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
class CacheStore(Base):
|
||||
"""Key-value cache table used by ``PostgresCacheBackend``.
|
||||
|
||||
Replaces Redis for simple KV caching, locks, and list operations
|
||||
when ``CACHE_BACKEND=postgres`` (NO_VECTOR_DB deployments).
|
||||
|
||||
Intentionally separate from ``KVStore``:
|
||||
- Stores raw bytes (LargeBinary) vs JSONB, matching Redis semantics.
|
||||
- Has ``expires_at`` for TTL; rows are periodically garbage-collected.
|
||||
- Holds ephemeral data (tokens, stop signals, lock state) not
|
||||
persistent application config, so cleanup can be aggressive.
|
||||
"""
|
||||
|
||||
__tablename__ = "cache_store"
|
||||
|
||||
key: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
value: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
expires_at: Mapped[datetime.datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
@@ -4,39 +4,33 @@ import base64
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
|
||||
from onyx.cache.factory import get_cache_backend
|
||||
from onyx.configs.app_configs import WEB_DOMAIN
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
# Redis key prefix for OAuth state
|
||||
OAUTH_STATE_PREFIX = "federated_oauth"
|
||||
# Default TTL for OAuth state (5 minutes)
|
||||
OAUTH_STATE_TTL = 300
|
||||
OAUTH_STATE_TTL = 300 # 5 minutes
|
||||
|
||||
|
||||
class OAuthSession:
|
||||
"""Represents an OAuth session stored in Redis."""
|
||||
"""Represents an OAuth session stored in the cache backend."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
federated_connector_id: int,
|
||||
user_id: str,
|
||||
redirect_uri: Optional[str] = None,
|
||||
additional_data: Optional[Dict[str, Any]] = None,
|
||||
redirect_uri: str | None = None,
|
||||
additional_data: dict[str, Any] | None = None,
|
||||
):
|
||||
self.federated_connector_id = federated_connector_id
|
||||
self.user_id = user_id
|
||||
self.redirect_uri = redirect_uri
|
||||
self.additional_data = additional_data or {}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for Redis storage."""
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"federated_connector_id": self.federated_connector_id,
|
||||
"user_id": self.user_id,
|
||||
@@ -45,8 +39,7 @@ class OAuthSession:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "OAuthSession":
|
||||
"""Create from dictionary retrieved from Redis."""
|
||||
def from_dict(cls, data: dict[str, Any]) -> "OAuthSession":
|
||||
return cls(
|
||||
federated_connector_id=data["federated_connector_id"],
|
||||
user_id=data["user_id"],
|
||||
@@ -58,31 +51,27 @@ class OAuthSession:
|
||||
def generate_oauth_state(
|
||||
federated_connector_id: int,
|
||||
user_id: str,
|
||||
redirect_uri: Optional[str] = None,
|
||||
additional_data: Optional[Dict[str, Any]] = None,
|
||||
redirect_uri: str | None = None,
|
||||
additional_data: dict[str, Any] | None = None,
|
||||
ttl: int = OAUTH_STATE_TTL,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a secure state parameter and store session data in Redis.
|
||||
Generate a secure state parameter and store session data in the cache backend.
|
||||
|
||||
Args:
|
||||
federated_connector_id: ID of the federated connector
|
||||
user_id: ID of the user initiating OAuth
|
||||
redirect_uri: Optional redirect URI after OAuth completion
|
||||
additional_data: Any additional data to store with the session
|
||||
ttl: Time-to-live in seconds for the Redis key
|
||||
ttl: Time-to-live in seconds for the cache key
|
||||
|
||||
Returns:
|
||||
Base64-encoded state parameter
|
||||
"""
|
||||
# Generate a random UUID for the state
|
||||
state_uuid = uuid.uuid4()
|
||||
state_b64 = base64.urlsafe_b64encode(state_uuid.bytes).decode("utf-8").rstrip("=")
|
||||
|
||||
# Convert UUID to base64 for URL-safe state parameter
|
||||
state_bytes = state_uuid.bytes
|
||||
state_b64 = base64.urlsafe_b64encode(state_bytes).decode("utf-8").rstrip("=")
|
||||
|
||||
# Create session object
|
||||
session = OAuthSession(
|
||||
federated_connector_id=federated_connector_id,
|
||||
user_id=user_id,
|
||||
@@ -90,15 +79,9 @@ def generate_oauth_state(
|
||||
additional_data=additional_data,
|
||||
)
|
||||
|
||||
# Store in Redis with TTL
|
||||
redis_client = get_redis_client()
|
||||
redis_key = f"{OAUTH_STATE_PREFIX}:{state_uuid}"
|
||||
|
||||
redis_client.set(
|
||||
redis_key,
|
||||
json.dumps(session.to_dict()),
|
||||
ex=ttl,
|
||||
)
|
||||
cache = get_cache_backend()
|
||||
cache_key = f"{OAUTH_STATE_PREFIX}:{state_uuid}"
|
||||
cache.set(cache_key, json.dumps(session.to_dict()), ex=ttl)
|
||||
|
||||
logger.info(
|
||||
f"Generated OAuth state for federated_connector_id={federated_connector_id}, "
|
||||
@@ -125,18 +108,15 @@ def verify_oauth_state(state: str) -> OAuthSession:
|
||||
state_bytes = base64.urlsafe_b64decode(padded_state)
|
||||
state_uuid = uuid.UUID(bytes=state_bytes)
|
||||
|
||||
# Look up in Redis
|
||||
redis_client = get_redis_client()
|
||||
redis_key = f"{OAUTH_STATE_PREFIX}:{state_uuid}"
|
||||
cache = get_cache_backend()
|
||||
cache_key = f"{OAUTH_STATE_PREFIX}:{state_uuid}"
|
||||
|
||||
session_data = cast(bytes, redis_client.get(redis_key))
|
||||
session_data = cache.get(cache_key)
|
||||
if not session_data:
|
||||
raise ValueError(f"OAuth state not found in Redis: {state}")
|
||||
raise ValueError(f"OAuth state not found: {state}")
|
||||
|
||||
# Delete the key after retrieval (one-time use)
|
||||
redis_client.delete(redis_key)
|
||||
cache.delete(cache_key)
|
||||
|
||||
# Parse and return session
|
||||
session_dict = json.loads(session_data)
|
||||
return OAuthSession.from_dict(session_dict)
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import json
|
||||
from typing import cast
|
||||
|
||||
from redis.client import Redis
|
||||
|
||||
from onyx.cache.interface import CacheBackend
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.models import KVStore
|
||||
from onyx.key_value_store.interface import KeyValueStore
|
||||
from onyx.key_value_store.interface import KvKeyNotFoundError
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.special_types import JSON_ro
|
||||
|
||||
@@ -20,22 +18,27 @@ KV_REDIS_KEY_EXPIRATION = 60 * 60 * 24 # 1 Day
|
||||
|
||||
|
||||
class PgRedisKVStore(KeyValueStore):
|
||||
def __init__(self, redis_client: Redis | None = None) -> None:
|
||||
# If no redis_client is provided, fall back to the context var
|
||||
if redis_client is not None:
|
||||
self.redis_client = redis_client
|
||||
else:
|
||||
self.redis_client = get_redis_client()
|
||||
def __init__(self, cache: CacheBackend | None = None) -> None:
|
||||
self._cache = cache
|
||||
|
||||
def _get_cache(self) -> CacheBackend:
|
||||
if self._cache is None:
|
||||
from onyx.cache.factory import get_cache_backend
|
||||
|
||||
self._cache = get_cache_backend()
|
||||
return self._cache
|
||||
|
||||
def store(self, key: str, val: JSON_ro, encrypt: bool = False) -> None:
|
||||
# Not encrypted in Redis, but encrypted in Postgres
|
||||
# Not encrypted in Cache backend (typically Redis), but encrypted in Postgres
|
||||
try:
|
||||
self.redis_client.set(
|
||||
self._get_cache().set(
|
||||
REDIS_KEY_PREFIX + key, json.dumps(val), ex=KV_REDIS_KEY_EXPIRATION
|
||||
)
|
||||
except Exception as e:
|
||||
# Fallback gracefully to Postgres if Redis fails
|
||||
logger.error(f"Failed to set value in Redis for key '{key}': {str(e)}")
|
||||
# Fallback gracefully to Postgres if Cache backend fails
|
||||
logger.error(
|
||||
f"Failed to set value in Cache backend for key '{key}': {str(e)}"
|
||||
)
|
||||
|
||||
encrypted_val = val if encrypt else None
|
||||
plain_val = val if not encrypt else None
|
||||
@@ -53,16 +56,12 @@ class PgRedisKVStore(KeyValueStore):
|
||||
def load(self, key: str, refresh_cache: bool = False) -> JSON_ro:
|
||||
if not refresh_cache:
|
||||
try:
|
||||
redis_value = self.redis_client.get(REDIS_KEY_PREFIX + key)
|
||||
if redis_value:
|
||||
if not isinstance(redis_value, bytes):
|
||||
raise ValueError(
|
||||
f"Redis value for key '{key}' is not a bytes object"
|
||||
)
|
||||
return json.loads(redis_value.decode("utf-8"))
|
||||
cached = self._get_cache().get(REDIS_KEY_PREFIX + key)
|
||||
if cached is not None:
|
||||
return json.loads(cached.decode("utf-8"))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get value from Redis for key '{key}': {str(e)}"
|
||||
f"Failed to get value from cache for key '{key}': {str(e)}"
|
||||
)
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
@@ -79,21 +78,21 @@ class PgRedisKVStore(KeyValueStore):
|
||||
value = None
|
||||
|
||||
try:
|
||||
self.redis_client.set(
|
||||
self._get_cache().set(
|
||||
REDIS_KEY_PREFIX + key,
|
||||
json.dumps(value),
|
||||
ex=KV_REDIS_KEY_EXPIRATION,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set value in Redis for key '{key}': {str(e)}")
|
||||
logger.error(f"Failed to set value in cache for key '{key}': {str(e)}")
|
||||
|
||||
return cast(JSON_ro, value)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
try:
|
||||
self.redis_client.delete(REDIS_KEY_PREFIX + key)
|
||||
self._get_cache().delete(REDIS_KEY_PREFIX + key)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete value from Redis for key '{key}': {str(e)}")
|
||||
logger.error(f"Failed to delete value from cache for key '{key}': {str(e)}")
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
result = db_session.query(KVStore).filter_by(key=key).delete()
|
||||
|
||||
@@ -67,6 +67,18 @@ Status checked against LiteLLM v1.81.6-nightly (2026-02-02):
|
||||
STATUS: STILL NEEDED - litellm_core_utils/litellm_logging.py lines 3185-3199 set
|
||||
usage as a dict with chat completion format instead of keeping it as
|
||||
ResponseAPIUsage. Our patch creates a deep copy before modification.
|
||||
|
||||
7. Responses API metadata=None TypeError (_patch_responses_metadata_none):
|
||||
- LiteLLM's @client decorator wrapper in utils.py uses kwargs.get("metadata", {})
|
||||
to check for router calls, but when metadata is explicitly None (key exists with
|
||||
value None), the default {} is not used
|
||||
- This causes "argument of type 'NoneType' is not iterable" TypeError which swallows
|
||||
the real exception (e.g. AuthenticationError for wrong API key)
|
||||
- Surfaces as: APIConnectionError: OpenAIException - argument of type 'NoneType' is
|
||||
not iterable
|
||||
STATUS: STILL NEEDED - litellm/utils.py wrapper function (line 1721) does not guard
|
||||
against metadata being explicitly None. Triggered when Responses API bridge
|
||||
passes **litellm_params containing metadata=None.
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -725,6 +737,44 @@ def _patch_logging_assembled_streaming_response() -> None:
|
||||
LiteLLMLoggingObj._get_assembled_streaming_response = _patched_get_assembled_streaming_response # type: ignore[method-assign]
|
||||
|
||||
|
||||
def _patch_responses_metadata_none() -> None:
|
||||
"""
|
||||
Patches litellm.responses to normalize metadata=None to metadata={} in kwargs.
|
||||
|
||||
LiteLLM's @client decorator wrapper in utils.py (line 1721) does:
|
||||
_is_litellm_router_call = "model_group" in kwargs.get("metadata", {})
|
||||
When metadata is explicitly None in kwargs, kwargs.get("metadata", {}) returns
|
||||
None (the key exists, so the default is not used), causing:
|
||||
TypeError: argument of type 'NoneType' is not iterable
|
||||
|
||||
This swallows the real exception (e.g. AuthenticationError) and surfaces as:
|
||||
APIConnectionError: OpenAIException - argument of type 'NoneType' is not iterable
|
||||
|
||||
This happens when the Responses API bridge calls litellm.responses() with
|
||||
**litellm_params which may contain metadata=None.
|
||||
|
||||
STATUS: STILL NEEDED - litellm/utils.py wrapper function uses kwargs.get("metadata", {})
|
||||
which does not guard against metadata being explicitly None. Same pattern exists
|
||||
on line 1407 for async path.
|
||||
"""
|
||||
import litellm as _litellm
|
||||
from functools import wraps
|
||||
|
||||
original_responses = _litellm.responses
|
||||
|
||||
if getattr(original_responses, "_metadata_patched", False):
|
||||
return
|
||||
|
||||
@wraps(original_responses)
|
||||
def _patched_responses(*args: Any, **kwargs: Any) -> Any:
|
||||
if kwargs.get("metadata") is None:
|
||||
kwargs["metadata"] = {}
|
||||
return original_responses(*args, **kwargs)
|
||||
|
||||
_patched_responses._metadata_patched = True # type: ignore[attr-defined]
|
||||
_litellm.responses = _patched_responses
|
||||
|
||||
|
||||
def apply_monkey_patches() -> None:
|
||||
"""
|
||||
Apply all necessary monkey patches to LiteLLM for compatibility.
|
||||
@@ -736,6 +786,7 @@ def apply_monkey_patches() -> None:
|
||||
- Patching AzureOpenAIResponsesAPIConfig.should_fake_stream to enable native streaming
|
||||
- Patching ResponsesAPIResponse.model_construct to fix usage format in all code paths
|
||||
- Patching LiteLLMLoggingObj._get_assembled_streaming_response to avoid mutating original response
|
||||
- Patching litellm.responses to fix metadata=None causing TypeError in error handling
|
||||
"""
|
||||
_patch_ollama_chunk_parser()
|
||||
_patch_openai_responses_parallel_tool_calls()
|
||||
@@ -743,3 +794,4 @@ def apply_monkey_patches() -> None:
|
||||
_patch_azure_responses_should_fake_stream()
|
||||
_patch_responses_api_usage_format()
|
||||
_patch_logging_assembled_streaming_response()
|
||||
_patch_responses_metadata_none()
|
||||
|
||||
@@ -13,44 +13,38 @@ from datetime import datetime
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.cache.factory import get_cache_backend
|
||||
from onyx.configs.app_configs import AUTO_LLM_CONFIG_URL
|
||||
from onyx.db.llm import fetch_auto_mode_providers
|
||||
from onyx.db.llm import sync_auto_mode_models
|
||||
from onyx.llm.well_known_providers.auto_update_models import LLMRecommendations
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
# Redis key for caching the last updated timestamp (per-tenant)
|
||||
_REDIS_KEY_LAST_UPDATED_AT = "auto_llm_update:last_updated_at"
|
||||
_CACHE_KEY_LAST_UPDATED_AT = "auto_llm_update:last_updated_at"
|
||||
_CACHE_TTL_SECONDS = 60 * 60 * 24 # 24 hours
|
||||
|
||||
|
||||
def _get_cached_last_updated_at() -> datetime | None:
|
||||
"""Get the cached last_updated_at timestamp from Redis."""
|
||||
try:
|
||||
redis_client = get_redis_client()
|
||||
value = redis_client.get(_REDIS_KEY_LAST_UPDATED_AT)
|
||||
if value and isinstance(value, bytes):
|
||||
# Value is bytes, decode to string then parse as ISO format
|
||||
value = get_cache_backend().get(_CACHE_KEY_LAST_UPDATED_AT)
|
||||
if value is not None:
|
||||
return datetime.fromisoformat(value.decode("utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get cached last_updated_at from Redis: {e}")
|
||||
logger.warning(f"Failed to get cached last_updated_at: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _set_cached_last_updated_at(updated_at: datetime) -> None:
|
||||
"""Set the cached last_updated_at timestamp in Redis."""
|
||||
try:
|
||||
redis_client = get_redis_client()
|
||||
# Store as ISO format string, with 24 hour expiration
|
||||
redis_client.set(
|
||||
_REDIS_KEY_LAST_UPDATED_AT,
|
||||
get_cache_backend().set(
|
||||
_CACHE_KEY_LAST_UPDATED_AT,
|
||||
updated_at.isoformat(),
|
||||
ex=60 * 60 * 24, # 24 hours
|
||||
ex=_CACHE_TTL_SECONDS,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set cached last_updated_at in Redis: {e}")
|
||||
logger.warning(f"Failed to set cached last_updated_at: {e}")
|
||||
|
||||
|
||||
def fetch_llm_recommendations_from_github(
|
||||
@@ -148,9 +142,8 @@ def sync_llm_models_from_github(
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Reset the cache timestamp in Redis. Useful for testing."""
|
||||
"""Reset the cache timestamp. Useful for testing."""
|
||||
try:
|
||||
redis_client = get_redis_client()
|
||||
redis_client.delete(_REDIS_KEY_LAST_UPDATED_AT)
|
||||
get_cache_backend().delete(_CACHE_KEY_LAST_UPDATED_AT)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reset cache in Redis: {e}")
|
||||
logger.warning(f"Failed to reset cache: {e}")
|
||||
|
||||
@@ -8,10 +8,10 @@ import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx import __version__
|
||||
from onyx.cache.factory import get_shared_cache_backend
|
||||
from onyx.configs.app_configs import INSTANCE_TYPE
|
||||
from onyx.configs.constants import OnyxRedisLocks
|
||||
from onyx.db.release_notes import create_release_notifications_for_versions
|
||||
from onyx.redis.redis_pool import get_shared_redis_client
|
||||
from onyx.server.features.release_notes.constants import AUTO_REFRESH_THRESHOLD_SECONDS
|
||||
from onyx.server.features.release_notes.constants import FETCH_TIMEOUT
|
||||
from onyx.server.features.release_notes.constants import GITHUB_CHANGELOG_RAW_URL
|
||||
@@ -113,60 +113,46 @@ def parse_mdx_to_release_note_entries(mdx_content: str) -> list[ReleaseNoteEntry
|
||||
|
||||
|
||||
def get_cached_etag() -> str | None:
|
||||
"""Get the cached GitHub ETag from Redis."""
|
||||
redis_client = get_shared_redis_client()
|
||||
cache = get_shared_cache_backend()
|
||||
try:
|
||||
etag = redis_client.get(REDIS_KEY_ETAG)
|
||||
etag = cache.get(REDIS_KEY_ETAG)
|
||||
if etag:
|
||||
return etag.decode("utf-8") if isinstance(etag, bytes) else str(etag)
|
||||
return etag.decode("utf-8")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get cached etag from Redis: {e}")
|
||||
logger.error(f"Failed to get cached etag: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_last_fetch_time() -> datetime | None:
|
||||
"""Get the last fetch timestamp from Redis."""
|
||||
redis_client = get_shared_redis_client()
|
||||
cache = get_shared_cache_backend()
|
||||
try:
|
||||
fetched_at_str = redis_client.get(REDIS_KEY_FETCHED_AT)
|
||||
if not fetched_at_str:
|
||||
raw = cache.get(REDIS_KEY_FETCHED_AT)
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
decoded = (
|
||||
fetched_at_str.decode("utf-8")
|
||||
if isinstance(fetched_at_str, bytes)
|
||||
else str(fetched_at_str)
|
||||
)
|
||||
|
||||
last_fetch = datetime.fromisoformat(decoded)
|
||||
|
||||
# Defensively ensure timezone awareness
|
||||
# fromisoformat() returns naive datetime if input lacks timezone
|
||||
last_fetch = datetime.fromisoformat(raw.decode("utf-8"))
|
||||
if last_fetch.tzinfo is None:
|
||||
# Assume UTC for naive datetimes
|
||||
last_fetch = last_fetch.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
# Convert to UTC if timezone-aware
|
||||
last_fetch = last_fetch.astimezone(timezone.utc)
|
||||
|
||||
return last_fetch
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get last fetch time from Redis: {e}")
|
||||
logger.error(f"Failed to get last fetch time from cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_fetch_metadata(etag: str | None) -> None:
|
||||
"""Save ETag and fetch timestamp to Redis."""
|
||||
redis_client = get_shared_redis_client()
|
||||
cache = get_shared_cache_backend()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
redis_client.set(REDIS_KEY_FETCHED_AT, now.isoformat(), ex=REDIS_CACHE_TTL)
|
||||
cache.set(REDIS_KEY_FETCHED_AT, now.isoformat(), ex=REDIS_CACHE_TTL)
|
||||
if etag:
|
||||
redis_client.set(REDIS_KEY_ETAG, etag, ex=REDIS_CACHE_TTL)
|
||||
cache.set(REDIS_KEY_ETAG, etag, ex=REDIS_CACHE_TTL)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save fetch metadata to Redis: {e}")
|
||||
logger.error(f"Failed to save fetch metadata to cache: {e}")
|
||||
|
||||
|
||||
def is_cache_stale() -> bool:
|
||||
@@ -196,11 +182,10 @@ def ensure_release_notes_fresh_and_notify(db_session: Session) -> None:
|
||||
if not is_cache_stale():
|
||||
return
|
||||
|
||||
# Acquire lock to prevent concurrent fetches
|
||||
redis_client = get_shared_redis_client()
|
||||
lock = redis_client.lock(
|
||||
cache = get_shared_cache_backend()
|
||||
lock = cache.lock(
|
||||
OnyxRedisLocks.RELEASE_NOTES_FETCH_LOCK,
|
||||
timeout=90, # 90 second timeout for the lock
|
||||
timeout=90,
|
||||
)
|
||||
|
||||
# Non-blocking acquire - if we can't get the lock, another request is handling it
|
||||
|
||||
@@ -479,10 +479,20 @@ def put_llm_provider(
|
||||
@admin_router.delete("/provider/{provider_id}")
|
||||
def delete_llm_provider(
|
||||
provider_id: int,
|
||||
force: bool = Query(False),
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
try:
|
||||
if not force:
|
||||
model = fetch_default_llm_model(db_session)
|
||||
|
||||
if model and model.llm_provider_id == provider_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete the default LLM provider",
|
||||
)
|
||||
|
||||
remove_llm_provider(db_session, provider_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@@ -13,13 +13,13 @@ from fastapi import Request
|
||||
from fastapi import Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from redis.client import Redis
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.api_key import get_hashed_api_key_from_request
|
||||
from onyx.auth.pat import get_hashed_pat_from_request
|
||||
from onyx.auth.users import current_chat_accessible_user
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.cache.factory import get_cache_backend
|
||||
from onyx.chat.chat_processing_checker import is_chat_session_processing
|
||||
from onyx.chat.chat_state import ChatStateContainer
|
||||
from onyx.chat.chat_utils import convert_chat_history_basic
|
||||
@@ -67,7 +67,6 @@ from onyx.llm.constants import LlmProviderNames
|
||||
from onyx.llm.factory import get_default_llm
|
||||
from onyx.llm.factory import get_llm_for_persona
|
||||
from onyx.llm.factory import get_llm_token_counter
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.secondary_llm_flows.chat_session_naming import generate_chat_session_name
|
||||
from onyx.server.api_key_usage import check_api_key_usage
|
||||
from onyx.server.query_and_chat.models import ChatFeedbackRequest
|
||||
@@ -330,7 +329,7 @@ def get_chat_session(
|
||||
]
|
||||
|
||||
try:
|
||||
is_processing = is_chat_session_processing(session_id, get_redis_client())
|
||||
is_processing = is_chat_session_processing(session_id, get_cache_backend())
|
||||
# Edit the last message to indicate loading (Overriding default message value)
|
||||
if is_processing and chat_message_details:
|
||||
last_msg = chat_message_details[-1]
|
||||
@@ -927,11 +926,10 @@ async def search_chats(
|
||||
def stop_chat_session(
|
||||
chat_session_id: UUID,
|
||||
user: User = Depends(current_user), # noqa: ARG001
|
||||
redis_client: Redis = Depends(get_redis_client),
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Stop a chat session by setting a stop signal in Redis.
|
||||
Stop a chat session by setting a stop signal.
|
||||
This endpoint is called by the frontend when the user clicks the stop button.
|
||||
"""
|
||||
set_fence(chat_session_id, redis_client, True)
|
||||
set_fence(chat_session_id, get_cache_backend(), True)
|
||||
return {"message": "Chat session stopped"}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from onyx.cache.factory import get_cache_backend
|
||||
from onyx.configs.app_configs import DISABLE_USER_KNOWLEDGE
|
||||
from onyx.configs.app_configs import ENABLE_OPENSEARCH_INDEXING_FOR_ONYX
|
||||
from onyx.configs.app_configs import ONYX_QUERY_HISTORY_TYPE
|
||||
@@ -6,11 +7,8 @@ from onyx.configs.constants import KV_SETTINGS_KEY
|
||||
from onyx.configs.constants import OnyxRedisLocks
|
||||
from onyx.key_value_store.factory import get_kv_store
|
||||
from onyx.key_value_store.interface import KvKeyNotFoundError
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.server.settings.models import Settings
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@@ -33,30 +31,22 @@ def load_settings() -> Settings:
|
||||
logger.error(f"Error loading settings from KV store: {str(e)}")
|
||||
settings = Settings()
|
||||
|
||||
tenant_id = get_current_tenant_id() if MULTI_TENANT else None
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
cache = get_cache_backend()
|
||||
|
||||
try:
|
||||
value = redis_client.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED)
|
||||
value = cache.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED)
|
||||
if value is not None:
|
||||
assert isinstance(value, bytes)
|
||||
anonymous_user_enabled = int(value.decode("utf-8")) == 1
|
||||
else:
|
||||
# Default to False
|
||||
anonymous_user_enabled = False
|
||||
# Optionally store the default back to Redis
|
||||
redis_client.set(
|
||||
OnyxRedisLocks.ANONYMOUS_USER_ENABLED, "0", ex=SETTINGS_TTL
|
||||
)
|
||||
cache.set(OnyxRedisLocks.ANONYMOUS_USER_ENABLED, "0", ex=SETTINGS_TTL)
|
||||
except Exception as e:
|
||||
# Log the error and reset to default
|
||||
logger.error(f"Error loading anonymous user setting from Redis: {str(e)}")
|
||||
logger.error(f"Error loading anonymous user setting from cache: {str(e)}")
|
||||
anonymous_user_enabled = False
|
||||
|
||||
settings.anonymous_user_enabled = anonymous_user_enabled
|
||||
settings.query_history_type = ONYX_QUERY_HISTORY_TYPE
|
||||
|
||||
# Override user knowledge setting if disabled via environment variable
|
||||
if DISABLE_USER_KNOWLEDGE:
|
||||
settings.user_knowledge_enabled = False
|
||||
|
||||
@@ -66,11 +56,10 @@ def load_settings() -> Settings:
|
||||
|
||||
|
||||
def store_settings(settings: Settings) -> None:
|
||||
tenant_id = get_current_tenant_id() if MULTI_TENANT else None
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
cache = get_cache_backend()
|
||||
|
||||
if settings.anonymous_user_enabled is not None:
|
||||
redis_client.set(
|
||||
cache.set(
|
||||
OnyxRedisLocks.ANONYMOUS_USER_ENABLED,
|
||||
"1" if settings.anonymous_user_enabled else "0",
|
||||
ex=SETTINGS_TTL,
|
||||
|
||||
@@ -13,9 +13,11 @@ the correct files.
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.background.periodic_poller import recover_stuck_user_files
|
||||
@@ -55,6 +57,32 @@ def _create_user_file(
|
||||
return uf
|
||||
|
||||
|
||||
def _fake_delete_impl(
|
||||
user_file_id: str, tenant_id: str, redis_locking: bool # noqa: ARG001
|
||||
) -> None:
|
||||
"""Mock side-effect: delete the row so the drain loop terminates."""
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
|
||||
with get_session_with_current_tenant() as session:
|
||||
session.execute(sa.delete(UserFile).where(UserFile.id == UUID(user_file_id)))
|
||||
session.commit()
|
||||
|
||||
|
||||
def _fake_sync_impl(
|
||||
user_file_id: str, tenant_id: str, redis_locking: bool # noqa: ARG001
|
||||
) -> None:
|
||||
"""Mock side-effect: clear sync flags so the drain loop terminates."""
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
|
||||
with get_session_with_current_tenant() as session:
|
||||
session.execute(
|
||||
sa.update(UserFile)
|
||||
.where(UserFile.id == UUID(user_file_id))
|
||||
.values(needs_project_sync=False, needs_persona_sync=False)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _cleanup_user_files(db_session: Session) -> Generator[list[UserFile], None, None]:
|
||||
"""Track created UserFile rows and delete them after each test."""
|
||||
@@ -125,9 +153,9 @@ class TestRecoverDeletingFiles:
|
||||
) -> None:
|
||||
user = create_test_user(db_session, "recovery_del")
|
||||
uf = _create_user_file(db_session, user.id, status=UserFileStatus.DELETING)
|
||||
_cleanup_user_files.append(uf)
|
||||
# Row is deleted by _fake_delete_impl, so no cleanup needed.
|
||||
|
||||
mock_impl = MagicMock()
|
||||
mock_impl = MagicMock(side_effect=_fake_delete_impl)
|
||||
with patch(f"{_IMPL_MODULE}.delete_user_file_impl", mock_impl):
|
||||
recover_stuck_user_files(TEST_TENANT_ID)
|
||||
|
||||
@@ -155,7 +183,7 @@ class TestRecoverSyncFiles:
|
||||
)
|
||||
_cleanup_user_files.append(uf)
|
||||
|
||||
mock_impl = MagicMock()
|
||||
mock_impl = MagicMock(side_effect=_fake_sync_impl)
|
||||
with patch(f"{_IMPL_MODULE}.project_sync_user_file_impl", mock_impl):
|
||||
recover_stuck_user_files(TEST_TENANT_ID)
|
||||
|
||||
@@ -179,7 +207,7 @@ class TestRecoverSyncFiles:
|
||||
)
|
||||
_cleanup_user_files.append(uf)
|
||||
|
||||
mock_impl = MagicMock()
|
||||
mock_impl = MagicMock(side_effect=_fake_sync_impl)
|
||||
with patch(f"{_IMPL_MODULE}.project_sync_user_file_impl", mock_impl):
|
||||
recover_stuck_user_files(TEST_TENANT_ID)
|
||||
|
||||
@@ -217,3 +245,108 @@ class TestRecoveryMultipleFiles:
|
||||
f"Expected all {len(files)} files to be recovered. "
|
||||
f"Missing: {expected_ids - called_ids}"
|
||||
)
|
||||
|
||||
|
||||
class TestTransientFailures:
|
||||
"""Drain loops skip failed files, process the rest, and terminate."""
|
||||
|
||||
def test_processing_failure_skips_and_continues(
|
||||
self,
|
||||
db_session: Session,
|
||||
tenant_context: None, # noqa: ARG002
|
||||
_cleanup_user_files: list[UserFile],
|
||||
) -> None:
|
||||
user = create_test_user(db_session, "fail_proc")
|
||||
uf_fail = _create_user_file(
|
||||
db_session, user.id, status=UserFileStatus.PROCESSING
|
||||
)
|
||||
uf_ok = _create_user_file(db_session, user.id, status=UserFileStatus.PROCESSING)
|
||||
_cleanup_user_files.extend([uf_fail, uf_ok])
|
||||
|
||||
fail_id = str(uf_fail.id)
|
||||
|
||||
def side_effect(
|
||||
*, user_file_id: str, tenant_id: str, redis_locking: bool # noqa: ARG001
|
||||
) -> None:
|
||||
if user_file_id == fail_id:
|
||||
raise RuntimeError("transient failure")
|
||||
|
||||
mock_impl = MagicMock(side_effect=side_effect)
|
||||
with patch(f"{_IMPL_MODULE}.process_user_file_impl", mock_impl):
|
||||
recover_stuck_user_files(TEST_TENANT_ID)
|
||||
|
||||
called_ids = [call.kwargs["user_file_id"] for call in mock_impl.call_args_list]
|
||||
assert fail_id in called_ids, "Failed file should have been attempted"
|
||||
assert str(uf_ok.id) in called_ids, "Healthy file should have been processed"
|
||||
assert called_ids.count(fail_id) == 1, "Failed file retried — infinite loop"
|
||||
assert called_ids.count(str(uf_ok.id)) == 1
|
||||
|
||||
def test_delete_failure_skips_and_continues(
|
||||
self,
|
||||
db_session: Session,
|
||||
tenant_context: None, # noqa: ARG002
|
||||
_cleanup_user_files: list[UserFile],
|
||||
) -> None:
|
||||
user = create_test_user(db_session, "fail_del")
|
||||
uf_fail = _create_user_file(db_session, user.id, status=UserFileStatus.DELETING)
|
||||
uf_ok = _create_user_file(db_session, user.id, status=UserFileStatus.DELETING)
|
||||
_cleanup_user_files.append(uf_fail)
|
||||
|
||||
fail_id = str(uf_fail.id)
|
||||
|
||||
def side_effect(
|
||||
*, user_file_id: str, tenant_id: str, redis_locking: bool
|
||||
) -> None:
|
||||
if user_file_id == fail_id:
|
||||
raise RuntimeError("transient failure")
|
||||
_fake_delete_impl(user_file_id, tenant_id, redis_locking)
|
||||
|
||||
mock_impl = MagicMock(side_effect=side_effect)
|
||||
with patch(f"{_IMPL_MODULE}.delete_user_file_impl", mock_impl):
|
||||
recover_stuck_user_files(TEST_TENANT_ID)
|
||||
|
||||
called_ids = [call.kwargs["user_file_id"] for call in mock_impl.call_args_list]
|
||||
assert fail_id in called_ids, "Failed file should have been attempted"
|
||||
assert str(uf_ok.id) in called_ids, "Healthy file should have been deleted"
|
||||
assert called_ids.count(fail_id) == 1, "Failed file retried — infinite loop"
|
||||
assert called_ids.count(str(uf_ok.id)) == 1
|
||||
|
||||
def test_sync_failure_skips_and_continues(
|
||||
self,
|
||||
db_session: Session,
|
||||
tenant_context: None, # noqa: ARG002
|
||||
_cleanup_user_files: list[UserFile],
|
||||
) -> None:
|
||||
user = create_test_user(db_session, "fail_sync")
|
||||
uf_fail = _create_user_file(
|
||||
db_session,
|
||||
user.id,
|
||||
status=UserFileStatus.COMPLETED,
|
||||
needs_project_sync=True,
|
||||
)
|
||||
uf_ok = _create_user_file(
|
||||
db_session,
|
||||
user.id,
|
||||
status=UserFileStatus.COMPLETED,
|
||||
needs_persona_sync=True,
|
||||
)
|
||||
_cleanup_user_files.extend([uf_fail, uf_ok])
|
||||
|
||||
fail_id = str(uf_fail.id)
|
||||
|
||||
def side_effect(
|
||||
*, user_file_id: str, tenant_id: str, redis_locking: bool
|
||||
) -> None:
|
||||
if user_file_id == fail_id:
|
||||
raise RuntimeError("transient failure")
|
||||
_fake_sync_impl(user_file_id, tenant_id, redis_locking)
|
||||
|
||||
mock_impl = MagicMock(side_effect=side_effect)
|
||||
with patch(f"{_IMPL_MODULE}.project_sync_user_file_impl", mock_impl):
|
||||
recover_stuck_user_files(TEST_TENANT_ID)
|
||||
|
||||
called_ids = [call.kwargs["user_file_id"] for call in mock_impl.call_args_list]
|
||||
assert fail_id in called_ids, "Failed file should have been attempted"
|
||||
assert str(uf_ok.id) in called_ids, "Healthy file should have been synced"
|
||||
assert called_ids.count(fail_id) == 1, "Failed file retried — infinite loop"
|
||||
assert called_ids.count(str(uf_ok.id)) == 1
|
||||
|
||||
57
backend/tests/external_dependency_unit/cache/conftest.py
vendored
Normal file
57
backend/tests/external_dependency_unit/cache/conftest.py
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Fixtures for cache backend tests.
|
||||
|
||||
Requires a running PostgreSQL instance (and Redis for parity tests).
|
||||
Run with::
|
||||
|
||||
python -m dotenv -f .vscode/.env run -- pytest tests/external_dependency_unit/cache/
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.cache.interface import CacheBackend
|
||||
from onyx.cache.postgres_backend import PostgresCacheBackend
|
||||
from onyx.cache.redis_backend import RedisCacheBackend
|
||||
from onyx.db.engine.sql_engine import SqlEngine
|
||||
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
from tests.external_dependency_unit.constants import TEST_TENANT_ID
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _init_db() -> Generator[None, None, None]:
|
||||
"""Initialize DB engine. Assumes Postgres has migrations applied (e.g. via docker compose)."""
|
||||
SqlEngine.init_engine(pool_size=5, max_overflow=2)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tenant_context() -> Generator[None, None, None]:
|
||||
token = CURRENT_TENANT_ID_CONTEXTVAR.set(TEST_TENANT_ID)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_cache() -> PostgresCacheBackend:
|
||||
return PostgresCacheBackend(TEST_TENANT_ID)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def redis_cache() -> RedisCacheBackend:
|
||||
from onyx.redis.redis_pool import redis_pool
|
||||
|
||||
return RedisCacheBackend(redis_pool.get_client(TEST_TENANT_ID))
|
||||
|
||||
|
||||
@pytest.fixture(params=["postgres", "redis"], ids=["postgres", "redis"])
|
||||
def cache(
|
||||
request: pytest.FixtureRequest,
|
||||
pg_cache: PostgresCacheBackend,
|
||||
redis_cache: RedisCacheBackend,
|
||||
) -> CacheBackend:
|
||||
if request.param == "postgres":
|
||||
return pg_cache
|
||||
return redis_cache
|
||||
100
backend/tests/external_dependency_unit/cache/test_cache_backend_parity.py
vendored
Normal file
100
backend/tests/external_dependency_unit/cache/test_cache_backend_parity.py
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Parameterized tests that run the same CacheBackend operations against
|
||||
both Redis and PostgreSQL, asserting identical return values.
|
||||
|
||||
Each test runs twice (once per backend) via the ``cache`` fixture defined
|
||||
in conftest.py.
|
||||
"""
|
||||
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from onyx.cache.interface import CacheBackend
|
||||
from onyx.cache.interface import TTL_KEY_NOT_FOUND
|
||||
from onyx.cache.interface import TTL_NO_EXPIRY
|
||||
|
||||
|
||||
def _key() -> str:
|
||||
return f"parity_{uuid4().hex[:12]}"
|
||||
|
||||
|
||||
class TestKVParity:
|
||||
def test_get_missing(self, cache: CacheBackend) -> None:
|
||||
assert cache.get(_key()) is None
|
||||
|
||||
def test_get_set(self, cache: CacheBackend) -> None:
|
||||
k = _key()
|
||||
cache.set(k, b"value")
|
||||
assert cache.get(k) == b"value"
|
||||
|
||||
def test_overwrite(self, cache: CacheBackend) -> None:
|
||||
k = _key()
|
||||
cache.set(k, b"a")
|
||||
cache.set(k, b"b")
|
||||
assert cache.get(k) == b"b"
|
||||
|
||||
def test_set_string(self, cache: CacheBackend) -> None:
|
||||
k = _key()
|
||||
cache.set(k, "hello")
|
||||
assert cache.get(k) == b"hello"
|
||||
|
||||
def test_set_int(self, cache: CacheBackend) -> None:
|
||||
k = _key()
|
||||
cache.set(k, 42)
|
||||
assert cache.get(k) == b"42"
|
||||
|
||||
def test_delete(self, cache: CacheBackend) -> None:
|
||||
k = _key()
|
||||
cache.set(k, b"x")
|
||||
cache.delete(k)
|
||||
assert cache.get(k) is None
|
||||
|
||||
def test_exists(self, cache: CacheBackend) -> None:
|
||||
k = _key()
|
||||
assert not cache.exists(k)
|
||||
cache.set(k, b"x")
|
||||
assert cache.exists(k)
|
||||
|
||||
|
||||
class TestTTLParity:
|
||||
def test_ttl_missing(self, cache: CacheBackend) -> None:
|
||||
assert cache.ttl(_key()) == TTL_KEY_NOT_FOUND
|
||||
|
||||
def test_ttl_no_expiry(self, cache: CacheBackend) -> None:
|
||||
k = _key()
|
||||
cache.set(k, b"x")
|
||||
assert cache.ttl(k) == TTL_NO_EXPIRY
|
||||
|
||||
def test_ttl_remaining(self, cache: CacheBackend) -> None:
|
||||
k = _key()
|
||||
cache.set(k, b"x", ex=10)
|
||||
remaining = cache.ttl(k)
|
||||
assert 8 <= remaining <= 10
|
||||
|
||||
def test_set_with_ttl_expires(self, cache: CacheBackend) -> None:
|
||||
k = _key()
|
||||
cache.set(k, b"x", ex=1)
|
||||
assert cache.get(k) == b"x"
|
||||
time.sleep(1.5)
|
||||
assert cache.get(k) is None
|
||||
|
||||
|
||||
class TestLockParity:
|
||||
def test_acquire_release(self, cache: CacheBackend) -> None:
|
||||
lock = cache.lock(f"parity_lock_{uuid4().hex[:8]}")
|
||||
assert lock.acquire(blocking=False)
|
||||
assert lock.owned()
|
||||
lock.release()
|
||||
assert not lock.owned()
|
||||
|
||||
|
||||
class TestListParity:
|
||||
def test_rpush_blpop(self, cache: CacheBackend) -> None:
|
||||
k = f"parity_list_{uuid4().hex[:8]}"
|
||||
cache.rpush(k, b"item")
|
||||
result = cache.blpop([k], timeout=1)
|
||||
assert result is not None
|
||||
assert result[1] == b"item"
|
||||
|
||||
def test_blpop_timeout(self, cache: CacheBackend) -> None:
|
||||
result = cache.blpop([f"parity_empty_{uuid4().hex[:8]}"], timeout=1)
|
||||
assert result is None
|
||||
129
backend/tests/external_dependency_unit/cache/test_kv_store_cache_layer.py
vendored
Normal file
129
backend/tests/external_dependency_unit/cache/test_kv_store_cache_layer.py
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Tests for PgRedisKVStore's cache layer integration with CacheBackend.
|
||||
|
||||
Verifies that the KV store correctly uses the CacheBackend for caching
|
||||
in front of PostgreSQL: cache hits, cache misses falling through to PG,
|
||||
cache population after PG reads, cache invalidation on delete, and
|
||||
graceful degradation when the cache backend raises.
|
||||
|
||||
Requires running PostgreSQL.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete
|
||||
|
||||
from onyx.cache.interface import CacheBackend
|
||||
from onyx.cache.postgres_backend import PostgresCacheBackend
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
from onyx.db.models import CacheStore
|
||||
from onyx.db.models import KVStore
|
||||
from onyx.key_value_store.interface import KvKeyNotFoundError
|
||||
from onyx.key_value_store.store import PgRedisKVStore
|
||||
from onyx.key_value_store.store import REDIS_KEY_PREFIX
|
||||
from tests.external_dependency_unit.constants import TEST_TENANT_ID
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_kv() -> Generator[None, None, None]:
|
||||
yield
|
||||
with get_session_with_tenant(tenant_id=TEST_TENANT_ID) as session:
|
||||
session.execute(delete(KVStore))
|
||||
session.execute(delete(CacheStore))
|
||||
session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kv_store(pg_cache: PostgresCacheBackend) -> PgRedisKVStore:
|
||||
return PgRedisKVStore(cache=pg_cache)
|
||||
|
||||
|
||||
class TestStoreAndLoad:
|
||||
def test_store_populates_cache_and_pg(
|
||||
self, kv_store: PgRedisKVStore, pg_cache: PostgresCacheBackend
|
||||
) -> None:
|
||||
kv_store.store("k1", {"hello": "world"})
|
||||
|
||||
cached = pg_cache.get(REDIS_KEY_PREFIX + "k1")
|
||||
assert cached is not None
|
||||
assert json.loads(cached) == {"hello": "world"}
|
||||
|
||||
loaded = kv_store.load("k1")
|
||||
assert loaded == {"hello": "world"}
|
||||
|
||||
def test_load_returns_cached_value_without_pg_hit(
|
||||
self, pg_cache: PostgresCacheBackend
|
||||
) -> None:
|
||||
"""If the cache already has the value, PG should not be queried."""
|
||||
pg_cache.set(REDIS_KEY_PREFIX + "cached_only", json.dumps({"from": "cache"}))
|
||||
kv = PgRedisKVStore(cache=pg_cache)
|
||||
assert kv.load("cached_only") == {"from": "cache"}
|
||||
|
||||
def test_load_falls_through_to_pg_on_cache_miss(
|
||||
self, kv_store: PgRedisKVStore, pg_cache: PostgresCacheBackend
|
||||
) -> None:
|
||||
kv_store.store("k2", [1, 2, 3])
|
||||
|
||||
pg_cache.delete(REDIS_KEY_PREFIX + "k2")
|
||||
assert pg_cache.get(REDIS_KEY_PREFIX + "k2") is None
|
||||
|
||||
loaded = kv_store.load("k2")
|
||||
assert loaded == [1, 2, 3]
|
||||
|
||||
repopulated = pg_cache.get(REDIS_KEY_PREFIX + "k2")
|
||||
assert repopulated is not None
|
||||
assert json.loads(repopulated) == [1, 2, 3]
|
||||
|
||||
def test_load_with_refresh_cache_skips_cache(
|
||||
self, kv_store: PgRedisKVStore, pg_cache: PostgresCacheBackend
|
||||
) -> None:
|
||||
kv_store.store("k3", "original")
|
||||
|
||||
pg_cache.set(REDIS_KEY_PREFIX + "k3", json.dumps("stale"))
|
||||
|
||||
loaded = kv_store.load("k3", refresh_cache=True)
|
||||
assert loaded == "original"
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_removes_from_cache_and_pg(
|
||||
self, kv_store: PgRedisKVStore, pg_cache: PostgresCacheBackend
|
||||
) -> None:
|
||||
kv_store.store("del_me", "bye")
|
||||
kv_store.delete("del_me")
|
||||
|
||||
assert pg_cache.get(REDIS_KEY_PREFIX + "del_me") is None
|
||||
|
||||
with pytest.raises(KvKeyNotFoundError):
|
||||
kv_store.load("del_me")
|
||||
|
||||
def test_delete_missing_key_raises(self, kv_store: PgRedisKVStore) -> None:
|
||||
with pytest.raises(KvKeyNotFoundError):
|
||||
kv_store.delete("nonexistent")
|
||||
|
||||
|
||||
class TestCacheFailureGracefulDegradation:
|
||||
def test_store_succeeds_when_cache_set_raises(self) -> None:
|
||||
failing_cache = MagicMock(spec=CacheBackend)
|
||||
failing_cache.set.side_effect = ConnectionError("cache down")
|
||||
|
||||
kv = PgRedisKVStore(cache=failing_cache)
|
||||
kv.store("resilient", {"data": True})
|
||||
|
||||
working_cache = MagicMock(spec=CacheBackend)
|
||||
working_cache.get.return_value = None
|
||||
kv_reader = PgRedisKVStore(cache=working_cache)
|
||||
loaded = kv_reader.load("resilient")
|
||||
assert loaded == {"data": True}
|
||||
|
||||
def test_load_falls_through_when_cache_get_raises(self) -> None:
|
||||
failing_cache = MagicMock(spec=CacheBackend)
|
||||
failing_cache.get.side_effect = ConnectionError("cache down")
|
||||
failing_cache.set.side_effect = ConnectionError("cache down")
|
||||
|
||||
kv = PgRedisKVStore(cache=failing_cache)
|
||||
kv.store("survive", 42)
|
||||
loaded = kv.load("survive")
|
||||
assert loaded == 42
|
||||
229
backend/tests/external_dependency_unit/cache/test_postgres_cache_backend.py
vendored
Normal file
229
backend/tests/external_dependency_unit/cache/test_postgres_cache_backend.py
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Tests for PostgresCacheBackend against real PostgreSQL.
|
||||
|
||||
Covers every method on the backend: KV CRUD, TTL behaviour, advisory
|
||||
locks (acquire / release / contention), list operations (rpush / blpop),
|
||||
and the periodic cleanup function.
|
||||
"""
|
||||
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from onyx.cache.interface import TTL_KEY_NOT_FOUND
|
||||
from onyx.cache.interface import TTL_NO_EXPIRY
|
||||
from onyx.cache.postgres_backend import cleanup_expired_cache_entries
|
||||
from onyx.cache.postgres_backend import PostgresCacheBackend
|
||||
from onyx.db.models import CacheStore
|
||||
|
||||
|
||||
def _key() -> str:
|
||||
return f"test_{uuid4().hex[:12]}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Basic KV
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKV:
|
||||
def test_get_set(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"hello")
|
||||
assert pg_cache.get(k) == b"hello"
|
||||
|
||||
def test_get_missing(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
assert pg_cache.get(_key()) is None
|
||||
|
||||
def test_set_overwrite(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"first")
|
||||
pg_cache.set(k, b"second")
|
||||
assert pg_cache.get(k) == b"second"
|
||||
|
||||
def test_set_string_value(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, "string_val")
|
||||
assert pg_cache.get(k) == b"string_val"
|
||||
|
||||
def test_set_int_value(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, 42)
|
||||
assert pg_cache.get(k) == b"42"
|
||||
|
||||
def test_delete(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"to_delete")
|
||||
pg_cache.delete(k)
|
||||
assert pg_cache.get(k) is None
|
||||
|
||||
def test_delete_missing_is_noop(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
pg_cache.delete(_key())
|
||||
|
||||
def test_exists(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
assert not pg_cache.exists(k)
|
||||
pg_cache.set(k, b"x")
|
||||
assert pg_cache.exists(k)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TTL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTTL:
|
||||
def test_set_with_ttl_expires(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"ephemeral", ex=1)
|
||||
assert pg_cache.get(k) == b"ephemeral"
|
||||
time.sleep(1.5)
|
||||
assert pg_cache.get(k) is None
|
||||
|
||||
def test_ttl_no_expiry(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"forever")
|
||||
assert pg_cache.ttl(k) == TTL_NO_EXPIRY
|
||||
|
||||
def test_ttl_missing_key(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
assert pg_cache.ttl(_key()) == TTL_KEY_NOT_FOUND
|
||||
|
||||
def test_ttl_remaining(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"x", ex=10)
|
||||
remaining = pg_cache.ttl(k)
|
||||
assert 8 <= remaining <= 10
|
||||
|
||||
def test_ttl_expired_key(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"x", ex=1)
|
||||
time.sleep(1.5)
|
||||
assert pg_cache.ttl(k) == TTL_KEY_NOT_FOUND
|
||||
|
||||
def test_expire_adds_ttl(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"x")
|
||||
assert pg_cache.ttl(k) == TTL_NO_EXPIRY
|
||||
pg_cache.expire(k, 10)
|
||||
assert 8 <= pg_cache.ttl(k) <= 10
|
||||
|
||||
def test_exists_respects_ttl(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"x", ex=1)
|
||||
assert pg_cache.exists(k)
|
||||
time.sleep(1.5)
|
||||
assert not pg_cache.exists(k)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Locks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLock:
|
||||
def test_acquire_release(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
lock = pg_cache.lock(f"lock_{uuid4().hex[:8]}")
|
||||
assert lock.acquire(blocking=False)
|
||||
assert lock.owned()
|
||||
lock.release()
|
||||
assert not lock.owned()
|
||||
|
||||
def test_contention(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
name = f"contention_{uuid4().hex[:8]}"
|
||||
lock1 = pg_cache.lock(name)
|
||||
lock2 = pg_cache.lock(name)
|
||||
|
||||
assert lock1.acquire(blocking=False)
|
||||
assert not lock2.acquire(blocking=False)
|
||||
|
||||
lock1.release()
|
||||
assert lock2.acquire(blocking=False)
|
||||
lock2.release()
|
||||
|
||||
def test_context_manager(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
with pg_cache.lock(f"ctx_{uuid4().hex[:8]}") as lock:
|
||||
assert lock.owned()
|
||||
assert not lock.owned()
|
||||
|
||||
def test_blocking_timeout(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
name = f"timeout_{uuid4().hex[:8]}"
|
||||
holder = pg_cache.lock(name)
|
||||
holder.acquire(blocking=False)
|
||||
|
||||
waiter = pg_cache.lock(name, timeout=0.3)
|
||||
start = time.monotonic()
|
||||
assert not waiter.acquire(blocking=True, blocking_timeout=0.3)
|
||||
elapsed = time.monotonic() - start
|
||||
assert elapsed >= 0.25
|
||||
|
||||
holder.release()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# List (rpush / blpop)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_rpush_blpop(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = f"list_{uuid4().hex[:8]}"
|
||||
pg_cache.rpush(k, b"item1")
|
||||
result = pg_cache.blpop([k], timeout=1)
|
||||
assert result is not None
|
||||
assert result == (k.encode(), b"item1")
|
||||
|
||||
def test_blpop_timeout(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
result = pg_cache.blpop([f"empty_{uuid4().hex[:8]}"], timeout=1)
|
||||
assert result is None
|
||||
|
||||
def test_fifo_order(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = f"fifo_{uuid4().hex[:8]}"
|
||||
pg_cache.rpush(k, b"first")
|
||||
time.sleep(0.01)
|
||||
pg_cache.rpush(k, b"second")
|
||||
|
||||
r1 = pg_cache.blpop([k], timeout=1)
|
||||
r2 = pg_cache.blpop([k], timeout=1)
|
||||
assert r1 is not None and r1[1] == b"first"
|
||||
assert r2 is not None and r2[1] == b"second"
|
||||
|
||||
def test_multiple_keys(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k1 = f"mk1_{uuid4().hex[:8]}"
|
||||
k2 = f"mk2_{uuid4().hex[:8]}"
|
||||
pg_cache.rpush(k2, b"from_k2")
|
||||
|
||||
result = pg_cache.blpop([k1, k2], timeout=1)
|
||||
assert result is not None
|
||||
assert result == (k2.encode(), b"from_k2")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCleanup:
|
||||
def test_removes_expired_rows(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
|
||||
k = _key()
|
||||
pg_cache.set(k, b"stale", ex=1)
|
||||
time.sleep(1.5)
|
||||
cleanup_expired_cache_entries()
|
||||
|
||||
stmt = select(CacheStore.key).where(CacheStore.key == k)
|
||||
with get_session_with_current_tenant() as session:
|
||||
row = session.execute(stmt).first()
|
||||
assert row is None, "expired row should be physically deleted"
|
||||
|
||||
def test_preserves_unexpired_rows(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"fresh", ex=300)
|
||||
cleanup_expired_cache_entries()
|
||||
assert pg_cache.get(k) == b"fresh"
|
||||
|
||||
def test_preserves_no_ttl_rows(self, pg_cache: PostgresCacheBackend) -> None:
|
||||
k = _key()
|
||||
pg_cache.set(k, b"permanent")
|
||||
cleanup_expired_cache_entries()
|
||||
assert pg_cache.get(k) == b"permanent"
|
||||
@@ -386,6 +386,261 @@ def test_delete_llm_provider(
|
||||
assert provider_data is None
|
||||
|
||||
|
||||
def test_delete_default_llm_provider_rejected(reset: None) -> None: # noqa: ARG001
|
||||
"""Deleting the default LLM provider should return 400."""
|
||||
admin_user = UserManager.create(name="admin_user")
|
||||
|
||||
# Create a provider
|
||||
response = requests.put(
|
||||
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"name": "test-provider-default-delete",
|
||||
"provider": LlmProviderNames.OPENAI,
|
||||
"api_key": "sk-000000000000000000000000000000000000000000000000",
|
||||
"model_configurations": [
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
).model_dump()
|
||||
],
|
||||
"is_public": True,
|
||||
"groups": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
created_provider = response.json()
|
||||
|
||||
# Set this provider as the default
|
||||
set_default_response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/default",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"provider_id": created_provider["id"],
|
||||
"model_name": "gpt-4o-mini",
|
||||
},
|
||||
)
|
||||
assert set_default_response.status_code == 200
|
||||
|
||||
# Attempt to delete the default provider — should be rejected
|
||||
delete_response = requests.delete(
|
||||
f"{API_SERVER_URL}/admin/llm/provider/{created_provider['id']}",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert delete_response.status_code == 400
|
||||
assert "Cannot delete the default LLM provider" in delete_response.json()["detail"]
|
||||
|
||||
# Verify provider still exists
|
||||
provider_data = _get_provider_by_id(admin_user, created_provider["id"])
|
||||
assert provider_data is not None
|
||||
|
||||
|
||||
def test_delete_non_default_llm_provider_with_default_set(
|
||||
reset: None, # noqa: ARG001
|
||||
) -> None:
|
||||
"""Deleting a non-default provider should succeed even when a default is set."""
|
||||
admin_user = UserManager.create(name="admin_user")
|
||||
|
||||
# Create two providers
|
||||
response_default = requests.put(
|
||||
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"name": "default-provider",
|
||||
"provider": LlmProviderNames.OPENAI,
|
||||
"api_key": "sk-000000000000000000000000000000000000000000000000",
|
||||
"model_configurations": [
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
).model_dump()
|
||||
],
|
||||
"is_public": True,
|
||||
"groups": [],
|
||||
},
|
||||
)
|
||||
assert response_default.status_code == 200
|
||||
default_provider = response_default.json()
|
||||
|
||||
response_other = requests.put(
|
||||
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"name": "other-provider",
|
||||
"provider": LlmProviderNames.OPENAI,
|
||||
"api_key": "sk-000000000000000000000000000000000000000000000000",
|
||||
"model_configurations": [
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o", is_visible=True
|
||||
).model_dump()
|
||||
],
|
||||
"is_public": True,
|
||||
"groups": [],
|
||||
},
|
||||
)
|
||||
assert response_other.status_code == 200
|
||||
other_provider = response_other.json()
|
||||
|
||||
# Set the first provider as default
|
||||
set_default_response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/default",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"provider_id": default_provider["id"],
|
||||
"model_name": "gpt-4o-mini",
|
||||
},
|
||||
)
|
||||
assert set_default_response.status_code == 200
|
||||
|
||||
# Delete the non-default provider — should succeed
|
||||
delete_response = requests.delete(
|
||||
f"{API_SERVER_URL}/admin/llm/provider/{other_provider['id']}",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert delete_response.status_code == 200
|
||||
|
||||
# Verify the non-default provider is gone
|
||||
provider_data = _get_provider_by_id(admin_user, other_provider["id"])
|
||||
assert provider_data is None
|
||||
|
||||
# Verify the default provider still exists
|
||||
default_data = _get_provider_by_id(admin_user, default_provider["id"])
|
||||
assert default_data is not None
|
||||
|
||||
|
||||
def test_force_delete_default_llm_provider(
|
||||
reset: None, # noqa: ARG001
|
||||
) -> None:
|
||||
"""Force-deleting the default LLM provider should succeed."""
|
||||
admin_user = UserManager.create(name="admin_user")
|
||||
|
||||
# Create a provider
|
||||
response = requests.put(
|
||||
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"name": "test-provider-force-delete",
|
||||
"provider": LlmProviderNames.OPENAI,
|
||||
"api_key": "sk-000000000000000000000000000000000000000000000000",
|
||||
"model_configurations": [
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
).model_dump()
|
||||
],
|
||||
"is_public": True,
|
||||
"groups": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
created_provider = response.json()
|
||||
|
||||
# Set this provider as the default
|
||||
set_default_response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/default",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"provider_id": created_provider["id"],
|
||||
"model_name": "gpt-4o-mini",
|
||||
},
|
||||
)
|
||||
assert set_default_response.status_code == 200
|
||||
|
||||
# Attempt to delete without force — should be rejected
|
||||
delete_response = requests.delete(
|
||||
f"{API_SERVER_URL}/admin/llm/provider/{created_provider['id']}",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert delete_response.status_code == 400
|
||||
|
||||
# Force delete — should succeed
|
||||
force_delete_response = requests.delete(
|
||||
f"{API_SERVER_URL}/admin/llm/provider/{created_provider['id']}?force=true",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert force_delete_response.status_code == 200
|
||||
|
||||
# Verify provider is gone
|
||||
provider_data = _get_provider_by_id(admin_user, created_provider["id"])
|
||||
assert provider_data is None
|
||||
|
||||
|
||||
def test_delete_default_vision_provider_clears_vision_default(
|
||||
reset: None, # noqa: ARG001
|
||||
) -> None:
|
||||
"""Deleting the default vision provider should succeed and clear the vision default."""
|
||||
admin_user = UserManager.create(name="admin_user")
|
||||
|
||||
# Create a text provider and set it as default (so we have a default text provider)
|
||||
text_response = requests.put(
|
||||
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"name": "text-provider",
|
||||
"provider": LlmProviderNames.OPENAI,
|
||||
"api_key": "sk-000000000000000000000000000000000000000000000001",
|
||||
"model_configurations": [
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
).model_dump()
|
||||
],
|
||||
"is_public": True,
|
||||
"groups": [],
|
||||
},
|
||||
)
|
||||
assert text_response.status_code == 200
|
||||
text_provider = text_response.json()
|
||||
_set_default_provider(admin_user, text_provider["id"], "gpt-4o-mini")
|
||||
|
||||
# Create a vision provider and set it as default vision
|
||||
vision_response = requests.put(
|
||||
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"name": "vision-provider",
|
||||
"provider": LlmProviderNames.OPENAI,
|
||||
"api_key": "sk-000000000000000000000000000000000000000000000002",
|
||||
"model_configurations": [
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o",
|
||||
is_visible=True,
|
||||
supports_image_input=True,
|
||||
).model_dump()
|
||||
],
|
||||
"is_public": True,
|
||||
"groups": [],
|
||||
},
|
||||
)
|
||||
assert vision_response.status_code == 200
|
||||
vision_provider = vision_response.json()
|
||||
_set_default_vision_provider(admin_user, vision_provider["id"], "gpt-4o")
|
||||
|
||||
# Verify vision default is set
|
||||
data = _get_providers_admin(admin_user)
|
||||
assert data is not None
|
||||
_, _, vision_default = _unpack_data(data)
|
||||
assert vision_default is not None
|
||||
assert vision_default["provider_id"] == vision_provider["id"]
|
||||
|
||||
# Delete the vision provider — should succeed (only text default is protected)
|
||||
delete_response = requests.delete(
|
||||
f"{API_SERVER_URL}/admin/llm/provider/{vision_provider['id']}",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert delete_response.status_code == 200
|
||||
|
||||
# Verify the vision provider is gone
|
||||
provider_data = _get_provider_by_id(admin_user, vision_provider["id"])
|
||||
assert provider_data is None
|
||||
|
||||
# Verify there is no default vision provider
|
||||
data = _get_providers_admin(admin_user)
|
||||
assert data is not None
|
||||
_, text_default, vision_default = _unpack_data(data)
|
||||
assert vision_default is None
|
||||
|
||||
# Verify the text default is still intact
|
||||
assert text_default is not None
|
||||
assert text_default["provider_id"] == text_provider["id"]
|
||||
|
||||
|
||||
def test_duplicate_provider_name_rejected(reset: None) -> None: # noqa: ARG001
|
||||
"""Creating a provider with a name that already exists should return 400."""
|
||||
admin_user = UserManager.create(name="admin_user")
|
||||
|
||||
166
backend/tests/unit/onyx/chat/test_stop_signal_checker.py
Normal file
166
backend/tests/unit/onyx/chat/test_stop_signal_checker.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Unit tests for stop_signal_checker and chat_processing_checker.
|
||||
|
||||
These modules are safety-critical — they control whether a chat stream
|
||||
continues or stops. The tests use a simple in-memory CacheBackend stub
|
||||
so no external services are needed.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from onyx.cache.interface import CacheBackend
|
||||
from onyx.cache.interface import CacheLock
|
||||
from onyx.chat.chat_processing_checker import is_chat_session_processing
|
||||
from onyx.chat.chat_processing_checker import set_processing_status
|
||||
from onyx.chat.stop_signal_checker import FENCE_TTL
|
||||
from onyx.chat.stop_signal_checker import is_connected
|
||||
from onyx.chat.stop_signal_checker import reset_cancel_status
|
||||
from onyx.chat.stop_signal_checker import set_fence
|
||||
|
||||
|
||||
class _MemoryCacheBackend(CacheBackend):
|
||||
"""Minimal in-memory CacheBackend for unit tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, bytes] = {}
|
||||
|
||||
def get(self, key: str) -> bytes | None:
|
||||
return self._store.get(key)
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str | bytes | int | float,
|
||||
ex: int | None = None, # noqa: ARG002
|
||||
) -> None:
|
||||
if isinstance(value, bytes):
|
||||
self._store[key] = value
|
||||
else:
|
||||
self._store[key] = str(value).encode()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._store.pop(key, None)
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
return key in self._store
|
||||
|
||||
def expire(self, key: str, seconds: int) -> None:
|
||||
pass
|
||||
|
||||
def ttl(self, key: str) -> int:
|
||||
return -2 if key not in self._store else -1
|
||||
|
||||
def lock(self, name: str, timeout: float | None = None) -> CacheLock:
|
||||
raise NotImplementedError
|
||||
|
||||
def rpush(self, key: str, value: str | bytes) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def blpop(self, keys: list[str], timeout: int = 0) -> tuple[bytes, bytes] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ── stop_signal_checker ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSetFence:
|
||||
def test_set_fence_true_creates_key(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
sid = uuid4()
|
||||
set_fence(sid, cache, True)
|
||||
assert not is_connected(sid, cache)
|
||||
|
||||
def test_set_fence_false_removes_key(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
sid = uuid4()
|
||||
set_fence(sid, cache, True)
|
||||
set_fence(sid, cache, False)
|
||||
assert is_connected(sid, cache)
|
||||
|
||||
def test_set_fence_false_noop_when_absent(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
sid = uuid4()
|
||||
set_fence(sid, cache, False)
|
||||
assert is_connected(sid, cache)
|
||||
|
||||
def test_set_fence_uses_ttl(self) -> None:
|
||||
"""Verify set_fence passes ex=FENCE_TTL to cache.set."""
|
||||
calls: list[dict[str, object]] = []
|
||||
cache = _MemoryCacheBackend()
|
||||
original_set = cache.set
|
||||
|
||||
def tracking_set(
|
||||
key: str,
|
||||
value: str | bytes | int | float,
|
||||
ex: int | None = None,
|
||||
) -> None:
|
||||
calls.append({"key": key, "ex": ex})
|
||||
original_set(key, value, ex=ex)
|
||||
|
||||
cache.set = tracking_set # type: ignore[method-assign]
|
||||
|
||||
set_fence(uuid4(), cache, True)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["ex"] == FENCE_TTL
|
||||
|
||||
|
||||
class TestIsConnected:
|
||||
def test_connected_when_no_fence(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
assert is_connected(uuid4(), cache)
|
||||
|
||||
def test_disconnected_when_fence_set(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
sid = uuid4()
|
||||
set_fence(sid, cache, True)
|
||||
assert not is_connected(sid, cache)
|
||||
|
||||
def test_sessions_are_isolated(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
sid1, sid2 = uuid4(), uuid4()
|
||||
set_fence(sid1, cache, True)
|
||||
assert not is_connected(sid1, cache)
|
||||
assert is_connected(sid2, cache)
|
||||
|
||||
|
||||
class TestResetCancelStatus:
|
||||
def test_clears_fence(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
sid = uuid4()
|
||||
set_fence(sid, cache, True)
|
||||
reset_cancel_status(sid, cache)
|
||||
assert is_connected(sid, cache)
|
||||
|
||||
def test_noop_when_no_fence(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
reset_cancel_status(uuid4(), cache)
|
||||
|
||||
|
||||
# ── chat_processing_checker ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSetProcessingStatus:
|
||||
def test_set_true_marks_processing(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
sid = uuid4()
|
||||
set_processing_status(sid, cache, True)
|
||||
assert is_chat_session_processing(sid, cache)
|
||||
|
||||
def test_set_false_clears_processing(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
sid = uuid4()
|
||||
set_processing_status(sid, cache, True)
|
||||
set_processing_status(sid, cache, False)
|
||||
assert not is_chat_session_processing(sid, cache)
|
||||
|
||||
|
||||
class TestIsChatSessionProcessing:
|
||||
def test_not_processing_by_default(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
assert not is_chat_session_processing(uuid4(), cache)
|
||||
|
||||
def test_sessions_are_isolated(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
sid1, sid2 = uuid4(), uuid4()
|
||||
set_processing_status(sid1, cache, True)
|
||||
assert is_chat_session_processing(sid1, cache)
|
||||
assert not is_chat_session_processing(sid2, cache)
|
||||
163
backend/tests/unit/onyx/federated_connectors/test_oauth_utils.py
Normal file
163
backend/tests/unit/onyx/federated_connectors/test_oauth_utils.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Unit tests for federated OAuth state generation and verification.
|
||||
|
||||
Uses unittest.mock to patch get_cache_backend so no external services
|
||||
are needed. Verifies the generate -> verify round-trip, one-time-use
|
||||
semantics, TTL propagation, and error handling.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.cache.interface import CacheBackend
|
||||
from onyx.cache.interface import CacheLock
|
||||
from onyx.federated_connectors.oauth_utils import generate_oauth_state
|
||||
from onyx.federated_connectors.oauth_utils import OAUTH_STATE_TTL
|
||||
from onyx.federated_connectors.oauth_utils import OAuthSession
|
||||
from onyx.federated_connectors.oauth_utils import verify_oauth_state
|
||||
|
||||
|
||||
class _MemoryCacheBackend(CacheBackend):
|
||||
"""Minimal in-memory CacheBackend for unit tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, bytes] = {}
|
||||
self.set_calls: list[dict[str, object]] = []
|
||||
|
||||
def get(self, key: str) -> bytes | None:
|
||||
return self._store.get(key)
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str | bytes | int | float,
|
||||
ex: int | None = None,
|
||||
) -> None:
|
||||
self.set_calls.append({"key": key, "ex": ex})
|
||||
if isinstance(value, bytes):
|
||||
self._store[key] = value
|
||||
else:
|
||||
self._store[key] = str(value).encode()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._store.pop(key, None)
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
return key in self._store
|
||||
|
||||
def expire(self, key: str, seconds: int) -> None:
|
||||
pass
|
||||
|
||||
def ttl(self, key: str) -> int:
|
||||
return -2 if key not in self._store else -1
|
||||
|
||||
def lock(self, name: str, timeout: float | None = None) -> CacheLock:
|
||||
raise NotImplementedError
|
||||
|
||||
def rpush(self, key: str, value: str | bytes) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def blpop(self, keys: list[str], timeout: int = 0) -> tuple[bytes, bytes] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _patched(cache: _MemoryCacheBackend): # type: ignore[no-untyped-def]
|
||||
return patch(
|
||||
"onyx.federated_connectors.oauth_utils.get_cache_backend",
|
||||
return_value=cache,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateAndVerifyRoundTrip:
|
||||
def test_round_trip_basic(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
with _patched(cache):
|
||||
state = generate_oauth_state(
|
||||
federated_connector_id=42,
|
||||
user_id="user-abc",
|
||||
)
|
||||
session = verify_oauth_state(state)
|
||||
|
||||
assert session.federated_connector_id == 42
|
||||
assert session.user_id == "user-abc"
|
||||
assert session.redirect_uri is None
|
||||
assert session.additional_data == {}
|
||||
|
||||
def test_round_trip_with_all_fields(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
with _patched(cache):
|
||||
state = generate_oauth_state(
|
||||
federated_connector_id=7,
|
||||
user_id="user-xyz",
|
||||
redirect_uri="https://example.com/callback",
|
||||
additional_data={"scope": "read"},
|
||||
)
|
||||
session = verify_oauth_state(state)
|
||||
|
||||
assert session.federated_connector_id == 7
|
||||
assert session.user_id == "user-xyz"
|
||||
assert session.redirect_uri == "https://example.com/callback"
|
||||
assert session.additional_data == {"scope": "read"}
|
||||
|
||||
|
||||
class TestOneTimeUse:
|
||||
def test_verify_deletes_state(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
with _patched(cache):
|
||||
state = generate_oauth_state(federated_connector_id=1, user_id="u")
|
||||
verify_oauth_state(state)
|
||||
|
||||
with pytest.raises(ValueError, match="OAuth state not found"):
|
||||
verify_oauth_state(state)
|
||||
|
||||
|
||||
class TestTTLPropagation:
|
||||
def test_default_ttl(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
with _patched(cache):
|
||||
generate_oauth_state(federated_connector_id=1, user_id="u")
|
||||
|
||||
assert len(cache.set_calls) == 1
|
||||
assert cache.set_calls[0]["ex"] == OAUTH_STATE_TTL
|
||||
|
||||
def test_custom_ttl(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
with _patched(cache):
|
||||
generate_oauth_state(federated_connector_id=1, user_id="u", ttl=600)
|
||||
|
||||
assert cache.set_calls[0]["ex"] == 600
|
||||
|
||||
|
||||
class TestVerifyInvalidState:
|
||||
def test_missing_state_raises(self) -> None:
|
||||
cache = _MemoryCacheBackend()
|
||||
with _patched(cache):
|
||||
state = generate_oauth_state(federated_connector_id=1, user_id="u")
|
||||
# Manually clear the cache to simulate expiration
|
||||
cache._store.clear()
|
||||
|
||||
with pytest.raises(ValueError, match="OAuth state not found"):
|
||||
verify_oauth_state(state)
|
||||
|
||||
|
||||
class TestOAuthSessionSerialization:
|
||||
def test_to_dict_from_dict_round_trip(self) -> None:
|
||||
session = OAuthSession(
|
||||
federated_connector_id=5,
|
||||
user_id="u-123",
|
||||
redirect_uri="https://redir.example.com",
|
||||
additional_data={"key": "val"},
|
||||
)
|
||||
d = session.to_dict()
|
||||
restored = OAuthSession.from_dict(d)
|
||||
|
||||
assert restored.federated_connector_id == 5
|
||||
assert restored.user_id == "u-123"
|
||||
assert restored.redirect_uri == "https://redir.example.com"
|
||||
assert restored.additional_data == {"key": "val"}
|
||||
|
||||
def test_from_dict_defaults(self) -> None:
|
||||
minimal = {"federated_connector_id": 1, "user_id": "u"}
|
||||
session = OAuthSession.from_dict(minimal)
|
||||
assert session.redirect_uri is None
|
||||
assert session.additional_data == {}
|
||||
@@ -117,7 +117,10 @@ class TestOktaProvider:
|
||||
user = _make_mock_user(personal_name=None)
|
||||
result = provider.build_user_resource(user, None)
|
||||
|
||||
assert result.name == ScimName(givenName="", familyName="", formatted="")
|
||||
# Falls back to deriving name from email local part
|
||||
assert result.name == ScimName(
|
||||
givenName="test", familyName="", formatted="test"
|
||||
)
|
||||
assert result.displayName is None
|
||||
|
||||
def test_build_user_resource_scim_username_preserves_case(self) -> None:
|
||||
|
||||
@@ -215,7 +215,7 @@ class TestCreateUser:
|
||||
mock_dal.commit.assert_called_once()
|
||||
|
||||
@patch("ee.onyx.server.scim.api._check_seat_availability", return_value=None)
|
||||
def test_missing_external_id_creates_user_without_mapping(
|
||||
def test_missing_external_id_still_creates_mapping(
|
||||
self,
|
||||
mock_seats: MagicMock, # noqa: ARG002
|
||||
mock_db_session: MagicMock,
|
||||
@@ -223,6 +223,7 @@ class TestCreateUser:
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
"""Mapping is always created to mark user as SCIM-managed."""
|
||||
mock_dal.get_user_by_email.return_value = None
|
||||
resource = make_scim_user(externalId=None)
|
||||
|
||||
@@ -236,11 +237,11 @@ class TestCreateUser:
|
||||
parsed = parse_scim_user(result, status=201)
|
||||
assert parsed.userName is not None
|
||||
mock_dal.add_user.assert_called_once()
|
||||
mock_dal.create_user_mapping.assert_not_called()
|
||||
mock_dal.create_user_mapping.assert_called_once()
|
||||
mock_dal.commit.assert_called_once()
|
||||
|
||||
@patch("ee.onyx.server.scim.api._check_seat_availability", return_value=None)
|
||||
def test_duplicate_email_returns_409(
|
||||
def test_duplicate_scim_managed_email_returns_409(
|
||||
self,
|
||||
mock_seats: MagicMock, # noqa: ARG002
|
||||
mock_db_session: MagicMock,
|
||||
@@ -248,7 +249,12 @@ class TestCreateUser:
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_user_by_email.return_value = make_db_user()
|
||||
"""409 only when the existing user already has a SCIM mapping."""
|
||||
existing = make_db_user()
|
||||
mock_dal.get_user_by_email.return_value = existing
|
||||
mock_dal.get_user_mapping_by_user_id.return_value = make_user_mapping(
|
||||
user_id=existing.id
|
||||
)
|
||||
resource = make_scim_user()
|
||||
|
||||
result = create_user(
|
||||
@@ -260,6 +266,40 @@ class TestCreateUser:
|
||||
|
||||
assert_scim_error(result, 409)
|
||||
|
||||
@patch("ee.onyx.server.scim.api._check_seat_availability", return_value=None)
|
||||
def test_existing_user_without_mapping_gets_linked(
|
||||
self,
|
||||
mock_seats: MagicMock, # noqa: ARG002
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
"""Pre-existing user without SCIM mapping gets adopted (linked)."""
|
||||
existing = make_db_user(email="admin@example.com", personal_name=None)
|
||||
mock_dal.get_user_by_email.return_value = existing
|
||||
mock_dal.get_user_mapping_by_user_id.return_value = None
|
||||
resource = make_scim_user(userName="admin@example.com", externalId="ext-admin")
|
||||
|
||||
result = create_user(
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
parsed = parse_scim_user(result, status=201)
|
||||
assert parsed.userName == "admin@example.com"
|
||||
# Should NOT create a new user — reuse existing
|
||||
mock_dal.add_user.assert_not_called()
|
||||
# Should sync is_active and personal_name from the SCIM request
|
||||
mock_dal.update_user.assert_called_once_with(
|
||||
existing, is_active=True, personal_name="Test User"
|
||||
)
|
||||
# Should create a SCIM mapping for the existing user
|
||||
mock_dal.create_user_mapping.assert_called_once()
|
||||
mock_dal.commit.assert_called_once()
|
||||
|
||||
@patch("ee.onyx.server.scim.api._check_seat_availability", return_value=None)
|
||||
def test_integrity_error_returns_409(
|
||||
self,
|
||||
|
||||
@@ -126,7 +126,9 @@ Resources:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- secretsmanager:GetSecretValue
|
||||
Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/postgres/user/password-*
|
||||
Resource:
|
||||
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/postgres/user/password-*
|
||||
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/onyx/user-auth-secret-*
|
||||
|
||||
Outputs:
|
||||
OutputEcsCluster:
|
||||
|
||||
@@ -167,10 +167,12 @@ Resources:
|
||||
- ImportedNamespace: !ImportValue
|
||||
Fn::Sub: "${Environment}-onyx-cluster-OnyxNamespaceName"
|
||||
- Name: AUTH_TYPE
|
||||
Value: disabled
|
||||
Value: basic
|
||||
Secrets:
|
||||
- Name: POSTGRES_PASSWORD
|
||||
ValueFrom: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/postgres/user/password
|
||||
- Name: USER_AUTH_SECRET
|
||||
ValueFrom: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/onyx/user-auth-secret
|
||||
VolumesFrom: []
|
||||
SystemControls: []
|
||||
|
||||
|
||||
@@ -166,9 +166,11 @@ Resources:
|
||||
- ImportedNamespace: !ImportValue
|
||||
Fn::Sub: "${Environment}-onyx-cluster-OnyxNamespaceName"
|
||||
- Name: AUTH_TYPE
|
||||
Value: disabled
|
||||
Value: basic
|
||||
Secrets:
|
||||
- Name: POSTGRES_PASSWORD
|
||||
ValueFrom: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/postgres/user/password
|
||||
- Name: USER_AUTH_SECRET
|
||||
ValueFrom: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/onyx/user-auth-secret
|
||||
VolumesFrom: []
|
||||
SystemControls: []
|
||||
|
||||
22
web/lib/opal/src/icons/column.tsx
Normal file
22
web/lib/opal/src/icons/column.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgColumn = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M6 14H3.33333C2.59695 14 2 13.403 2 12.6667V3.33333C2 2.59695 2.59695 2 3.33333 2H6M6 14V2M6 14H10M6 2H10M10 2H12.6667C13.403 2 14 2.59695 14 3.33333V12.6667C14 13.403 13.403 14 12.6667 14H10M10 2V14"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SvgColumn;
|
||||
21
web/lib/opal/src/icons/grip-vertical.tsx
Normal file
21
web/lib/opal/src/icons/grip-vertical.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgGripVertical = ({ size = 16, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="6" cy="3.5" r="1" fill="currentColor" />
|
||||
<circle cx="10" cy="3.5" r="1" fill="currentColor" />
|
||||
<circle cx="6" cy="8" r="1" fill="currentColor" />
|
||||
<circle cx="10" cy="8" r="1" fill="currentColor" />
|
||||
<circle cx="6" cy="12.5" r="1" fill="currentColor" />
|
||||
<circle cx="10" cy="12.5" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SvgGripVertical;
|
||||
20
web/lib/opal/src/icons/handle.tsx
Normal file
20
web/lib/opal/src/icons/handle.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgHandle = ({ size = 16, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={Math.round((size * 3) / 17)}
|
||||
height={size}
|
||||
viewBox="0 0 3 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M0.5 0.5V16.5M2.5 0.5V16.5"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SvgHandle;
|
||||
@@ -49,6 +49,7 @@ export { default as SvgClock } from "@opal/icons/clock";
|
||||
export { default as SvgClockHandsSmall } from "@opal/icons/clock-hands-small";
|
||||
export { default as SvgCloud } from "@opal/icons/cloud";
|
||||
export { default as SvgCode } from "@opal/icons/code";
|
||||
export { default as SvgColumn } from "@opal/icons/column";
|
||||
export { default as SvgCopy } from "@opal/icons/copy";
|
||||
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
|
||||
export { default as SvgCpu } from "@opal/icons/cpu";
|
||||
@@ -79,6 +80,8 @@ export { default as SvgFolderPartialOpen } from "@opal/icons/folder-partial-open
|
||||
export { default as SvgFolderPlus } from "@opal/icons/folder-plus";
|
||||
export { default as SvgGemini } from "@opal/icons/gemini";
|
||||
export { default as SvgGlobe } from "@opal/icons/globe";
|
||||
export { default as SvgGripVertical } from "@opal/icons/grip-vertical";
|
||||
export { default as SvgHandle } from "@opal/icons/handle";
|
||||
export { default as SvgHardDrive } from "@opal/icons/hard-drive";
|
||||
export { default as SvgHashSmall } from "@opal/icons/hash-small";
|
||||
export { default as SvgHash } from "@opal/icons/hash";
|
||||
@@ -146,6 +149,7 @@ export { default as SvgSlack } from "@opal/icons/slack";
|
||||
export { default as SvgSlash } from "@opal/icons/slash";
|
||||
export { default as SvgSliders } from "@opal/icons/sliders";
|
||||
export { default as SvgSlidersSmall } from "@opal/icons/sliders-small";
|
||||
export { default as SvgSort } from "@opal/icons/sort";
|
||||
export { default as SvgSparkle } from "@opal/icons/sparkle";
|
||||
export { default as SvgStar } from "@opal/icons/star";
|
||||
export { default as SvgStep1 } from "@opal/icons/step1";
|
||||
|
||||
27
web/lib/opal/src/icons/sort.tsx
Normal file
27
web/lib/opal/src/icons/sort.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgSort = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M2 4.5H10M2 8H7M2 11.5H5"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 5V12M12 12L14 10M12 12L10 10"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgSort;
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { SvgMcp } from "@opal/icons";
|
||||
import MCPPageContent from "@/sections/actions/MCPPageContent";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.MCP_ACTIONS]!;
|
||||
|
||||
export default function Main() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgMcp}
|
||||
title="MCP Actions"
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Connect MCP (Model Context Protocol) servers to add custom actions and tools for your agents."
|
||||
separator
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { SvgActions } from "@opal/icons";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import OpenApiPageContent from "@/sections/actions/OpenApiPageContent";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.OPENAPI_ACTIONS]!;
|
||||
|
||||
export default function Main() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgActions}
|
||||
title="OpenAPI Actions"
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Connect OpenAPI servers to add custom actions and tools for your agents."
|
||||
separator
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { SourceCategory, SourceMetadata } from "@/lib/search/interfaces";
|
||||
import { listSourceMetadata } from "@/lib/sources";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
@@ -32,7 +32,7 @@ import { SettingsContext } from "@/providers/SettingsProvider";
|
||||
import SourceTile from "@/components/SourceTile";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgUploadCloud } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
function SourceTileTooltipWrapper({
|
||||
sourceMetadata,
|
||||
preSelect,
|
||||
@@ -124,6 +124,7 @@ function SourceTileTooltipWrapper({
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.ADD_CONNECTOR]!;
|
||||
const sources = useMemo(() => listSourceMetadata(), []);
|
||||
|
||||
const [rawSearchTerm, setSearchTerm] = useState("");
|
||||
@@ -248,61 +249,37 @@ export default function Page() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
icon={SvgUploadCloud}
|
||||
title="Add Connector"
|
||||
farRightElement={
|
||||
<SettingsLayouts.Root width="full">
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
rightChildren={
|
||||
<Button href="/admin/indexing/status" primary>
|
||||
See Connectors
|
||||
</Button>
|
||||
}
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<InputTypeIn
|
||||
type="text"
|
||||
placeholder="Search Connectors"
|
||||
ref={searchInputRef}
|
||||
value={rawSearchTerm} // keep the input bound to immediate state
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
className="w-96 flex-none"
|
||||
/>
|
||||
|
||||
<InputTypeIn
|
||||
type="text"
|
||||
placeholder="Search Connectors"
|
||||
ref={searchInputRef}
|
||||
value={rawSearchTerm} // keep the input bound to immediate state
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
className="w-96 flex-none"
|
||||
/>
|
||||
|
||||
{dedupedPopular.length > 0 && (
|
||||
<div className="pt-8">
|
||||
<Text as="p" headingH3>
|
||||
Popular
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-4 p-4">
|
||||
{dedupedPopular.map((source) => (
|
||||
<SourceTileTooltipWrapper
|
||||
preSelect={false}
|
||||
key={source.internalName}
|
||||
sourceMetadata={source}
|
||||
federatedConnectors={federatedConnectors}
|
||||
slackCredentials={slackCredentials}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(categorizedSources)
|
||||
.filter(([_, sources]) => sources.length > 0)
|
||||
.map(([category, sources], categoryInd) => (
|
||||
<div key={category} className="pt-8">
|
||||
{dedupedPopular.length > 0 && (
|
||||
<div className="pt-8">
|
||||
<Text as="p" headingH3>
|
||||
{category}
|
||||
Popular
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-4 p-4">
|
||||
{sources.map((source, sourceInd) => (
|
||||
{dedupedPopular.map((source) => (
|
||||
<SourceTileTooltipWrapper
|
||||
preSelect={
|
||||
(searchTerm?.length ?? 0) > 0 &&
|
||||
categoryInd == 0 &&
|
||||
sourceInd == 0
|
||||
}
|
||||
preSelect={false}
|
||||
key={source.internalName}
|
||||
sourceMetadata={source}
|
||||
federatedConnectors={federatedConnectors}
|
||||
@@ -311,7 +288,33 @@ export default function Page() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{Object.entries(categorizedSources)
|
||||
.filter(([_, sources]) => sources.length > 0)
|
||||
.map(([category, sources], categoryInd) => (
|
||||
<div key={category} className="pt-8">
|
||||
<Text as="p" headingH3>
|
||||
{category}
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-4 p-4">
|
||||
{sources.map((source, sourceInd) => (
|
||||
<SourceTileTooltipWrapper
|
||||
preSelect={
|
||||
(searchTerm?.length ?? 0) > 0 &&
|
||||
categoryInd == 0 &&
|
||||
sourceInd == 0
|
||||
}
|
||||
key={source.internalName}
|
||||
sourceMetadata={source}
|
||||
federatedConnectors={federatedConnectors}
|
||||
slackCredentials={slackCredentials}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ import { PersonasTable } from "./PersonaTable";
|
||||
import Text from "@/components/ui/text";
|
||||
import Title from "@/components/ui/title";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { SubLabel } from "@/components/Field";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import { useAdminPersonas } from "@/hooks/useAdminPersonas";
|
||||
import { Persona } from "./interfaces";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { SvgOnyxOctagon } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import { useState, useEffect } from "react";
|
||||
import Pagination from "@/refresh-components/Pagination";
|
||||
|
||||
@@ -120,6 +120,7 @@ function MainContent({
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.AGENTS]!;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const { personas, totalItems, isLoading, error, refresh } = useAdminPersonas({
|
||||
pageNum: currentPage - 1, // Backend uses 0-indexed pages
|
||||
@@ -127,31 +128,33 @@ export default function Page() {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle icon={SvgOnyxOctagon} title="Agents" />
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
|
||||
{isLoading && <ThreeDotsLoader />}
|
||||
<SettingsLayouts.Body>
|
||||
{isLoading && <ThreeDotsLoader />}
|
||||
|
||||
{error && (
|
||||
<ErrorCallout
|
||||
errorTitle="Failed to load agents"
|
||||
errorMsg={
|
||||
error?.info?.message ||
|
||||
error?.info?.detail ||
|
||||
"An unknown error occurred"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<ErrorCallout
|
||||
errorTitle="Failed to load agents"
|
||||
errorMsg={
|
||||
error?.info?.message ||
|
||||
error?.info?.detail ||
|
||||
"An unknown error occurred"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<MainContent
|
||||
personas={personas}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
onPageChange={setCurrentPage}
|
||||
refreshPersonas={refresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{!isLoading && !error && (
|
||||
<MainContent
|
||||
personas={personas}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
onPageChange={setCurrentPage}
|
||||
refreshPersonas={refresh}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
@@ -32,6 +32,9 @@ import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgEdit, SvgKey, SvgRefreshCw } from "@opal/icons";
|
||||
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.API_KEYS]!;
|
||||
|
||||
function Main() {
|
||||
const {
|
||||
@@ -233,10 +236,11 @@ function Main() {
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle title="API Keys" icon={SvgKey} />
|
||||
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header title={route.title} icon={route.icon} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import CardSection from "@/components/admin/CardSection";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { SlackTokensForm } from "./SlackTokensForm";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { SvgSlack } from "@opal/icons";
|
||||
|
||||
export const NewSlackBotForm = () => {
|
||||
const [formValues] = useState({
|
||||
@@ -19,20 +18,19 @@ export const NewSlackBotForm = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageTitle
|
||||
icon={<SourceIcon iconSize={36} sourceType={ValidSources.Slack} />}
|
||||
title="New Slack Bot"
|
||||
/>
|
||||
<CardSection>
|
||||
<div className="p-4">
|
||||
<SlackTokensForm
|
||||
isUpdate={false}
|
||||
initialValues={formValues}
|
||||
router={router}
|
||||
/>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={SvgSlack} title="New Slack Bot" separator />
|
||||
<SettingsLayouts.Body>
|
||||
<CardSection>
|
||||
<div className="p-4">
|
||||
<SlackTokensForm
|
||||
isUpdate={false}
|
||||
initialValues={formValues}
|
||||
router={router}
|
||||
/>
|
||||
</div>
|
||||
</CardSection>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { SlackChannelConfigCreationForm } from "../SlackChannelConfigCreationForm";
|
||||
import { fetchSS } from "@/lib/utilsSS";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import {
|
||||
DocumentSetSummary,
|
||||
SlackChannelConfig,
|
||||
ValidSources,
|
||||
} from "@/lib/types";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { DocumentSetSummary, SlackChannelConfig } from "@/lib/types";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { SvgSlack } from "@opal/icons";
|
||||
import { FetchAgentsResponse, fetchAgentsSS } from "@/lib/agentsSS";
|
||||
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
|
||||
|
||||
@@ -77,27 +72,28 @@ async function EditslackChannelConfigPage(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl container">
|
||||
<SettingsLayouts.Root>
|
||||
<InstantSSRAutoRefresh />
|
||||
|
||||
<BackButton />
|
||||
<AdminPageTitle
|
||||
icon={<SourceIcon sourceType={ValidSources.Slack} iconSize={32} />}
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgSlack}
|
||||
title={
|
||||
slackChannelConfig.is_default
|
||||
? "Edit Default Slack Config"
|
||||
: "Edit Slack Channel Config"
|
||||
}
|
||||
separator
|
||||
backButton
|
||||
/>
|
||||
|
||||
<SlackChannelConfigCreationForm
|
||||
slack_bot_id={slackChannelConfig.slack_bot_id}
|
||||
documentSets={documentSets}
|
||||
personas={assistants}
|
||||
standardAnswerCategoryResponse={eeStandardAnswerCategoryResponse}
|
||||
existingSlackChannelConfig={slackChannelConfig}
|
||||
/>
|
||||
</div>
|
||||
<SettingsLayouts.Body>
|
||||
<SlackChannelConfigCreationForm
|
||||
slack_bot_id={slackChannelConfig.slack_bot_id}
|
||||
documentSets={documentSets}
|
||||
personas={assistants}
|
||||
standardAnswerCategoryResponse={eeStandardAnswerCategoryResponse}
|
||||
existingSlackChannelConfig={slackChannelConfig}
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { SlackChannelConfigCreationForm } from "../SlackChannelConfigCreationForm";
|
||||
import { fetchSS } from "@/lib/utilsSS";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { DocumentSetSummary, ValidSources } from "@/lib/types";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { DocumentSetSummary } from "@/lib/types";
|
||||
import { fetchAgentsSS } from "@/lib/agentsSS";
|
||||
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { SvgSlack } from "@opal/icons";
|
||||
|
||||
async function NewChannelConfigPage(props: {
|
||||
params: Promise<{ "bot-id": string }>;
|
||||
@@ -50,20 +49,22 @@ async function NewChannelConfigPage(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackButton />
|
||||
<AdminPageTitle
|
||||
icon={<SourceIcon iconSize={32} sourceType={ValidSources.Slack} />}
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgSlack}
|
||||
title="Configure OnyxBot for Slack Channel"
|
||||
separator
|
||||
backButton
|
||||
/>
|
||||
|
||||
<SlackChannelConfigCreationForm
|
||||
slack_bot_id={slack_bot_id}
|
||||
documentSets={documentSets}
|
||||
personas={agentsResponse[0]}
|
||||
standardAnswerCategoryResponse={standardAnswerCategoryResponse}
|
||||
/>
|
||||
</>
|
||||
<SettingsLayouts.Body>
|
||||
<SlackChannelConfigCreationForm
|
||||
slack_bot_id={slack_bot_id}
|
||||
documentSets={documentSets}
|
||||
personas={agentsResponse[0]}
|
||||
standardAnswerCategoryResponse={standardAnswerCategoryResponse}
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { SlackBotTable } from "./SlackBotTable";
|
||||
import { useSlackBots } from "./[bot-id]/hooks";
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import { DOCS_ADMINS_PATH } from "@/lib/constants";
|
||||
|
||||
const Main = () => {
|
||||
function Main() {
|
||||
const {
|
||||
data: slackBots,
|
||||
isLoading: isSlackBotsLoading,
|
||||
@@ -73,20 +72,18 @@ const Main = () => {
|
||||
<SlackBotTable slackBots={slackBots} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.SLACK_BOTS]!;
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
icon={<SourceIcon iconSize={36} sourceType={ValidSources.Slack} />}
|
||||
title="Slack Bots"
|
||||
/>
|
||||
<InstantSSRAutoRefresh />
|
||||
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<InstantSSRAutoRefresh />
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ import { useState } from "react";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import { DocumentIcon2 } from "@/components/icons/icons";
|
||||
import useSWR from "swr";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SvgLock } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_PROCESSING]!;
|
||||
|
||||
function Main() {
|
||||
const {
|
||||
@@ -149,12 +151,11 @@ function Main() {
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
title="Document Processing"
|
||||
icon={<DocumentIcon2 size={32} className="my-auto" />}
|
||||
/>
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { SvgImage } from "@opal/icons";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import ImageGenerationContent from "./ImageGenerationContent";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.IMAGE_GENERATION]!;
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgImage}
|
||||
title="Image Generation"
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Settings for in-chat image generation."
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import Text from "@/components/ui/text";
|
||||
import Title from "@/components/ui/title";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
@@ -19,7 +19,10 @@ import { SettingsContext } from "@/providers/SettingsProvider";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { useToastFromQuery } from "@/hooks/useToast";
|
||||
import { SvgSearch } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.SEARCH_SETTINGS]!;
|
||||
|
||||
export interface EmbeddingDetails {
|
||||
api_key: string;
|
||||
custom_config: any;
|
||||
@@ -141,9 +144,11 @@ function Main() {
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle title="Search Settings" icon={SvgSearch} />
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header title={route.title} icon={route.icon} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
SvgOnyxLogo,
|
||||
SvgX,
|
||||
} from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import { WebProviderSetupModal } from "@/app/admin/configuration/web-search/WebProviderSetupModal";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.WEB_SEARCH]!;
|
||||
import {
|
||||
SEARCH_PROVIDERS_URL,
|
||||
SEARCH_PROVIDER_DETAILS,
|
||||
@@ -403,8 +406,8 @@ export default function Page() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgGlobe}
|
||||
title="Web Search"
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Search settings for external search across the internet."
|
||||
separator
|
||||
/>
|
||||
@@ -426,8 +429,8 @@ export default function Page() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgGlobe}
|
||||
title="Web Search"
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Search settings for external search across the internet."
|
||||
separator
|
||||
/>
|
||||
@@ -832,8 +835,8 @@ export default function Page() {
|
||||
<>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgGlobe}
|
||||
title="Web Search"
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Search settings for external search across the internet."
|
||||
separator
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { FiDownload } from "react-icons/fi";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import {
|
||||
Table,
|
||||
@@ -17,6 +16,10 @@ import { Card } from "@/components/ui/card";
|
||||
import Text from "@/components/ui/text";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { SvgDownloadCloud } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DEBUG]!;
|
||||
|
||||
function Main() {
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -114,13 +117,13 @@ function Main() {
|
||||
);
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle icon={<FiDownload size={32} />} title="Debug Logs" />
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
}
|
||||
|
||||
405
web/src/app/admin/demo/data-table/page.tsx
Normal file
405
web/src/app/admin/demo/data-table/page.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
"use no memo";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Content } from "@opal/layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import DataTable from "@/refresh-components/table/DataTable";
|
||||
import { createTableColumns } from "@/refresh-components/table/columns";
|
||||
import { SvgCheckCircle, SvgClock, SvgAlertCircle } from "@opal/icons";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
email: string;
|
||||
role: string;
|
||||
department: string;
|
||||
status: "active" | "pending" | "inactive";
|
||||
joinDate: string;
|
||||
}
|
||||
|
||||
const DATA: TeamMember[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Alice Johnson",
|
||||
initials: "AJ",
|
||||
email: "alice.johnson@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-01-15",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Bob Smith",
|
||||
initials: "BS",
|
||||
email: "bob.smith@onyx.app",
|
||||
role: "Designer",
|
||||
department: "Design",
|
||||
status: "active",
|
||||
joinDate: "2023-02-20",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Carol Lee",
|
||||
initials: "CL",
|
||||
email: "carol.lee@onyx.app",
|
||||
role: "PM",
|
||||
department: "Product",
|
||||
status: "pending",
|
||||
joinDate: "2023-03-10",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "David Chen",
|
||||
initials: "DC",
|
||||
email: "david.chen@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-04-05",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Eva Martinez",
|
||||
initials: "EM",
|
||||
email: "eva.martinez@onyx.app",
|
||||
role: "Analyst",
|
||||
department: "Data",
|
||||
status: "active",
|
||||
joinDate: "2023-05-18",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "Frank Kim",
|
||||
initials: "FK",
|
||||
email: "frank.kim@onyx.app",
|
||||
role: "Designer",
|
||||
department: "Design",
|
||||
status: "inactive",
|
||||
joinDate: "2023-06-22",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
name: "Grace Wang",
|
||||
initials: "GW",
|
||||
email: "grace.wang@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-07-01",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
name: "Henry Patel",
|
||||
initials: "HP",
|
||||
email: "henry.patel@onyx.app",
|
||||
role: "PM",
|
||||
department: "Product",
|
||||
status: "active",
|
||||
joinDate: "2023-07-15",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
name: "Ivy Nguyen",
|
||||
initials: "IN",
|
||||
email: "ivy.nguyen@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "pending",
|
||||
joinDate: "2023-08-03",
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "Jack Brown",
|
||||
initials: "JB",
|
||||
email: "jack.brown@onyx.app",
|
||||
role: "Analyst",
|
||||
department: "Data",
|
||||
status: "active",
|
||||
joinDate: "2023-08-20",
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
name: "Karen Davis",
|
||||
initials: "KD",
|
||||
email: "karen.davis@onyx.app",
|
||||
role: "Designer",
|
||||
department: "Design",
|
||||
status: "active",
|
||||
joinDate: "2023-09-11",
|
||||
},
|
||||
{
|
||||
id: "12",
|
||||
name: "Leo Garcia",
|
||||
initials: "LG",
|
||||
email: "leo.garcia@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-09-25",
|
||||
},
|
||||
{
|
||||
id: "13",
|
||||
name: "Mia Thompson",
|
||||
initials: "MT",
|
||||
email: "mia.thompson@onyx.app",
|
||||
role: "PM",
|
||||
department: "Product",
|
||||
status: "inactive",
|
||||
joinDate: "2023-10-08",
|
||||
},
|
||||
{
|
||||
id: "14",
|
||||
name: "Noah Wilson",
|
||||
initials: "NW",
|
||||
email: "noah.wilson@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-10-19",
|
||||
},
|
||||
{
|
||||
id: "15",
|
||||
name: "Olivia Taylor",
|
||||
initials: "OT",
|
||||
email: "olivia.taylor@onyx.app",
|
||||
role: "Analyst",
|
||||
department: "Data",
|
||||
status: "active",
|
||||
joinDate: "2023-11-02",
|
||||
},
|
||||
{
|
||||
id: "16",
|
||||
name: "Paul Anderson",
|
||||
initials: "PA",
|
||||
email: "paul.anderson@onyx.app",
|
||||
role: "Designer",
|
||||
department: "Design",
|
||||
status: "pending",
|
||||
joinDate: "2023-11-14",
|
||||
},
|
||||
{
|
||||
id: "17",
|
||||
name: "Quinn Harris",
|
||||
initials: "QH",
|
||||
email: "quinn.harris@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-11-28",
|
||||
},
|
||||
{
|
||||
id: "18",
|
||||
name: "Rachel Clark",
|
||||
initials: "RC",
|
||||
email: "rachel.clark@onyx.app",
|
||||
role: "PM",
|
||||
department: "Product",
|
||||
status: "active",
|
||||
joinDate: "2023-12-05",
|
||||
},
|
||||
{
|
||||
id: "19",
|
||||
name: "Sam Robinson",
|
||||
initials: "SR",
|
||||
email: "sam.robinson@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2024-01-10",
|
||||
},
|
||||
{
|
||||
id: "20",
|
||||
name: "Tina Lewis",
|
||||
initials: "TL",
|
||||
email: "tina.lewis@onyx.app",
|
||||
role: "Analyst",
|
||||
department: "Data",
|
||||
status: "inactive",
|
||||
joinDate: "2024-01-22",
|
||||
},
|
||||
{
|
||||
id: "21",
|
||||
name: "Uma Walker",
|
||||
initials: "UW",
|
||||
email: "uma.walker@onyx.app",
|
||||
role: "Designer",
|
||||
department: "Design",
|
||||
status: "active",
|
||||
joinDate: "2024-02-03",
|
||||
},
|
||||
{
|
||||
id: "22",
|
||||
name: "Victor Hall",
|
||||
initials: "VH",
|
||||
email: "victor.hall@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2024-02-15",
|
||||
},
|
||||
{
|
||||
id: "23",
|
||||
name: "Wendy Young",
|
||||
initials: "WY",
|
||||
email: "wendy.young@onyx.app",
|
||||
role: "PM",
|
||||
department: "Product",
|
||||
status: "pending",
|
||||
joinDate: "2024-03-01",
|
||||
},
|
||||
{
|
||||
id: "24",
|
||||
name: "Xander King",
|
||||
initials: "XK",
|
||||
email: "xander.king@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2024-03-18",
|
||||
},
|
||||
{
|
||||
id: "25",
|
||||
name: "Yara Scott",
|
||||
initials: "YS",
|
||||
email: "yara.scott@onyx.app",
|
||||
role: "Analyst",
|
||||
department: "Data",
|
||||
status: "active",
|
||||
joinDate: "2024-04-02",
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
active: { icon: SvgCheckCircle },
|
||||
pending: { icon: SvgClock },
|
||||
inactive: { icon: SvgAlertCircle },
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definitions (module scope — stable reference)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tc = createTableColumns<TeamMember>();
|
||||
|
||||
const columns = [
|
||||
tc.qualifier({ content: "avatar-user", getInitials: (r) => r.initials }),
|
||||
tc.column("name", {
|
||||
header: "Name",
|
||||
weight: 23,
|
||||
minWidth: 120,
|
||||
cell: (value) => (
|
||||
<Content sizePreset="main-ui" variant="body" title={value} />
|
||||
),
|
||||
}),
|
||||
tc.column("email", {
|
||||
header: "Email",
|
||||
weight: 28,
|
||||
minWidth: 150,
|
||||
cell: (value) => (
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
title={value}
|
||||
prominence="muted"
|
||||
/>
|
||||
),
|
||||
}),
|
||||
tc.column("role", {
|
||||
header: "Role",
|
||||
weight: 16,
|
||||
minWidth: 80,
|
||||
cell: (value) => (
|
||||
<Content sizePreset="main-ui" variant="body" title={value} />
|
||||
),
|
||||
}),
|
||||
tc.column("department", {
|
||||
header: "Department",
|
||||
weight: 19,
|
||||
minWidth: 100,
|
||||
cell: (value) => (
|
||||
<Content sizePreset="main-ui" variant="body" title={value} />
|
||||
),
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
minWidth: 80,
|
||||
cell: (value) => {
|
||||
const { icon } = STATUS_CONFIG[value];
|
||||
return (
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
icon={icon}
|
||||
title={value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
tc.actions({
|
||||
sortingFooterText:
|
||||
"Everyone in your organization will see the explore agents list in this order.",
|
||||
}),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function DataTableDemoPage() {
|
||||
const [items, setItems] = useState(DATA);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Text headingH2>Data Table Demo</Text>
|
||||
<Text mainContentMuted text03>
|
||||
Demonstrates Onyx table primitives wired to TanStack Table with
|
||||
sorting, column resizing, row selection, and pagination.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
initialColumnVisibility={{ department: false }}
|
||||
draggable={{
|
||||
getRowId: (row) => row.id,
|
||||
onReorder: (ids, changedOrders) => {
|
||||
setItems(ids.map((id) => items.find((r) => r.id === id)!));
|
||||
console.log("Changed sort orders:", changedOrders);
|
||||
},
|
||||
}}
|
||||
footer={{ mode: "selection" }}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Text headingH3>Small Variant</Text>
|
||||
<Text mainContentMuted text03>
|
||||
Same table rendered with the small size variant for denser layouts.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="border border-border-01 rounded-lg overflow-hidden">
|
||||
<DataTable
|
||||
data={DATA}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
size="small"
|
||||
initialColumnVisibility={{ department: false }}
|
||||
footer={{ mode: "selection" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { createGuildConfig } from "@/app/admin/discord-bot/lib";
|
||||
import { DiscordGuildsTable } from "@/app/admin/discord-bot/DiscordGuildsTable";
|
||||
import { BotConfigCard } from "@/app/admin/discord-bot/BotConfigCard";
|
||||
import { SvgDiscordMono } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
function DiscordBotContent() {
|
||||
const { data: guilds, isLoading, error, refreshGuilds } = useDiscordGuilds();
|
||||
@@ -118,11 +118,13 @@ function DiscordBotContent() {
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DISCORD_BOTS]!;
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgDiscordMono}
|
||||
title="Discord Bots"
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Connect Onyx to your Discord servers. Users can ask questions directly in Discord channels."
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { SvgArrowExchange } from "@opal/icons";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.INDEX_MIGRATION]!;
|
||||
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import { Content, ContentAction } from "@opal/layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
@@ -213,8 +216,8 @@ export default function Page() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgArrowExchange}
|
||||
title="Document Index Migration"
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Monitor the migration from Vespa to OpenSearch and control the active retrieval source."
|
||||
separator
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import { Explorer } from "./Explorer";
|
||||
import { Connector } from "@/lib/connectors/connectors";
|
||||
import { DocumentSetSummary } from "@/lib/types";
|
||||
|
||||
interface DocumentExplorerPageProps {
|
||||
initialSearchValue: string | undefined;
|
||||
connectors: Connector<any>[];
|
||||
documentSets: DocumentSetSummary[];
|
||||
}
|
||||
|
||||
export default function DocumentExplorerPage({
|
||||
initialSearchValue,
|
||||
connectors,
|
||||
documentSets,
|
||||
}: DocumentExplorerPageProps) {
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_EXPLORER]!;
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
<Explorer
|
||||
initialSearchValue={initialSearchValue}
|
||||
connectors={connectors}
|
||||
documentSets={documentSets}
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { Explorer } from "./Explorer";
|
||||
import { fetchValidFilterInfo } from "@/lib/search/utilsSS";
|
||||
import { SvgZoomIn } from "@opal/icons";
|
||||
import DocumentExplorerPage from "./DocumentExplorerPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{ [key: string]: string }>;
|
||||
}) {
|
||||
@@ -9,17 +8,10 @@ export default async function Page(props: {
|
||||
const { connectors, documentSets } = await fetchValidFilterInfo();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
icon={<SvgZoomIn className="stroke-text-04 h-8 w-8" />}
|
||||
title="Document Explorer"
|
||||
/>
|
||||
|
||||
<Explorer
|
||||
initialSearchValue={searchParams.query}
|
||||
connectors={connectors}
|
||||
documentSets={documentSets}
|
||||
/>
|
||||
</>
|
||||
<DocumentExplorerPage
|
||||
initialSearchValue={searchParams.query}
|
||||
connectors={connectors}
|
||||
documentSets={documentSets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { LoadingAnimation } from "@/components/Loading";
|
||||
import { useMostReactedToDocuments } from "@/lib/hooks";
|
||||
import { DocumentFeedbackTable } from "./DocumentFeedbackTable";
|
||||
import { numPages, numToDisplay } from "./constants";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import Title from "@/components/ui/title";
|
||||
import { SvgThumbsUp } from "@opal/icons";
|
||||
const Main = () => {
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
function Main() {
|
||||
const {
|
||||
data: mostLikedDocuments,
|
||||
isLoading: isMostLikedDocumentsLoading,
|
||||
@@ -57,16 +58,17 @@ const Main = () => {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_FEEDBACK]!;
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle icon={SvgThumbsUp} title="Document Feedback" />
|
||||
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@ import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { refreshDocumentSets, useDocumentSets } from "../hooks";
|
||||
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { BookmarkIcon } from "@/components/icons/icons";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import { DocumentSetCreationForm } from "../DocumentSetCreationForm";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -69,24 +68,17 @@ function Main({ documentSetId }: { documentSetId: number }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageTitle
|
||||
icon={<BookmarkIcon size={32} />}
|
||||
title={documentSet.name}
|
||||
<CardSection>
|
||||
<DocumentSetCreationForm
|
||||
ccPairs={ccPairs}
|
||||
userGroups={userGroups}
|
||||
onClose={() => {
|
||||
refreshDocumentSets();
|
||||
router.push("/admin/documents/sets");
|
||||
}}
|
||||
existingDocumentSet={documentSet}
|
||||
/>
|
||||
|
||||
<CardSection>
|
||||
<DocumentSetCreationForm
|
||||
ccPairs={ccPairs}
|
||||
userGroups={userGroups}
|
||||
onClose={() => {
|
||||
refreshDocumentSets();
|
||||
router.push("/admin/documents/sets");
|
||||
}}
|
||||
existingDocumentSet={documentSet}
|
||||
/>
|
||||
</CardSection>
|
||||
</div>
|
||||
</CardSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,12 +87,19 @@ export default function Page(props: {
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const documentSetId = parseInt(params.documentSetId);
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_SETS]!;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackButton />
|
||||
|
||||
<Main documentSetId={documentSetId} />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title="Edit Document Set"
|
||||
separator
|
||||
backButton
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<Main documentSetId={documentSetId} />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { BookmarkIcon } from "@/components/icons/icons";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import { DocumentSetCreationForm } from "../DocumentSetCreationForm";
|
||||
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { refreshDocumentSets } from "../hooks";
|
||||
@@ -56,19 +55,20 @@ function Main() {
|
||||
);
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
export default function Page() {
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_SETS]!;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackButton />
|
||||
|
||||
<AdminPageTitle
|
||||
icon={<BookmarkIcon size={32} />}
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title="New Document Set"
|
||||
separator
|
||||
backButton
|
||||
/>
|
||||
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { PageSelector } from "@/components/PageSelector";
|
||||
import { BookmarkIcon, InfoIcon } from "@/components/icons/icons";
|
||||
import { InfoIcon } from "@/components/icons/icons";
|
||||
import {
|
||||
Table,
|
||||
TableHead,
|
||||
@@ -19,7 +19,8 @@ import { useDocumentSets } from "./hooks";
|
||||
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
|
||||
import { deleteDocumentSet } from "./lib";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import {
|
||||
FiAlertTriangle,
|
||||
FiCheckCircle,
|
||||
@@ -358,7 +359,7 @@ const DocumentSetTable = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Main = () => {
|
||||
function Main() {
|
||||
const {
|
||||
data: documentSets,
|
||||
isLoading: isDocumentSetsLoading,
|
||||
@@ -418,16 +419,17 @@ const Main = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_SETS]!;
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle icon={<BookmarkIcon size={32} />} title="Document Sets" />
|
||||
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ function ConnectorRow({
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<TableCell className="">
|
||||
<p className="lg:w-[200px] xl:w-[400px] inline-block ellipsis truncate">
|
||||
<p className="max-w-[200px] xl:max-w-[400px] inline-block ellipsis truncate">
|
||||
{ccPairsIndexingStatus.name}
|
||||
</p>
|
||||
</TableCell>
|
||||
@@ -246,7 +246,7 @@ function FederatedConnectorRow({
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<TableCell className="">
|
||||
<p className="lg:w-[200px] xl:w-[400px] inline-block ellipsis truncate">
|
||||
<p className="max-w-[200px] xl:max-w-[400px] inline-block ellipsis truncate">
|
||||
{federatedConnector.name}
|
||||
</p>
|
||||
</TableCell>
|
||||
@@ -293,7 +293,7 @@ export function CCPairIndexingStatusTable({
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
return (
|
||||
<Table className="-mt-8">
|
||||
<Table className="-mt-8 table-fixed">
|
||||
<TableHeader>
|
||||
<ConnectorRow
|
||||
invisible
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { NotebookIcon } from "@/components/icons/icons";
|
||||
import { CCPairIndexingStatusTable } from "./CCPairIndexingStatusTable";
|
||||
import { SearchAndFilterControls } from "./SearchAndFilterControls";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import Link from "next/link";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import Text from "@/components/ui/text";
|
||||
import { useConnectorIndexingStatusWithPagination } from "@/lib/hooks";
|
||||
import { useToastFromQuery } from "@/hooks/useToast";
|
||||
@@ -201,6 +201,8 @@ function Main() {
|
||||
}
|
||||
|
||||
export default function Status() {
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.INDEXING_STATUS]!;
|
||||
|
||||
useToastFromQuery({
|
||||
"connector-created": {
|
||||
message: "Connector created successfully",
|
||||
@@ -213,16 +215,18 @@ export default function Status() {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
icon={<NotebookIcon size={32} />}
|
||||
title="Existing Connectors"
|
||||
farRightElement={
|
||||
<SettingsLayouts.Root width="full">
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
rightChildren={
|
||||
<Button href="/admin/add-connector">Add Connector</Button>
|
||||
}
|
||||
separator
|
||||
/>
|
||||
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import {
|
||||
DatePickerField,
|
||||
FieldLabel,
|
||||
TextArrayField,
|
||||
TextFormField,
|
||||
} from "@/components/Field";
|
||||
import { BrainIcon } from "@/components/icons/icons";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import SwitchField from "@/refresh-components/form/SwitchField";
|
||||
@@ -31,6 +30,9 @@ import KGEntityTypes from "@/app/admin/kg/KGEntityTypes";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SvgSettings } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.KNOWLEDGE_GRAPH]!;
|
||||
|
||||
function createDomainField(
|
||||
name: string,
|
||||
@@ -324,12 +326,11 @@ export default function Page() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
title="Knowledge Graph"
|
||||
icon={<BrainIcon size={32} className="my-auto" />}
|
||||
/>
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import SimpleTabs from "@/refresh-components/SimpleTabs";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import Text from "@/components/ui/text";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
@@ -16,8 +16,11 @@ import { toast } from "@/hooks/useToast";
|
||||
import CreateRateLimitModal from "./CreateRateLimitModal";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import { SvgGlobe, SvgShield, SvgUser, SvgUsers } from "@opal/icons";
|
||||
import { SvgGlobe, SvgUser, SvgUsers } from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.TOKEN_RATE_LIMITS]!;
|
||||
const BASE_URL = "/api/admin/token-rate-limits";
|
||||
const GLOBAL_TOKEN_FETCH_URL = `${BASE_URL}/global`;
|
||||
const USER_TOKEN_FETCH_URL = `${BASE_URL}/users`;
|
||||
@@ -208,9 +211,11 @@ function Main() {
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle title="Token Rate Limits" icon={SvgShield} />
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header title={route.title} icon={route.icon} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ import SimpleTabs from "@/refresh-components/SimpleTabs";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import InvitedUserTable from "@/components/admin/users/InvitedUserTable";
|
||||
import SignedUpUserTable from "@/components/admin/users/SignedUpUserTable";
|
||||
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
@@ -22,7 +21,11 @@ import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { SvgDownloadCloud, SvgUser, SvgUserPlus } from "@opal/icons";
|
||||
import { SvgDownloadCloud, SvgUserPlus } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.USERS]!;
|
||||
|
||||
interface CountDisplayProps {
|
||||
label: string;
|
||||
value: number | null;
|
||||
@@ -48,7 +51,7 @@ function CountDisplay({ label, value, isLoading }: CountDisplayProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const UsersTables = ({
|
||||
function UsersTables({
|
||||
q,
|
||||
isDownloadingUsers,
|
||||
setIsDownloadingUsers,
|
||||
@@ -56,7 +59,7 @@ const UsersTables = ({
|
||||
q: string;
|
||||
isDownloadingUsers: boolean;
|
||||
setIsDownloadingUsers: (loading: boolean) => void;
|
||||
}) => {
|
||||
}) {
|
||||
const [currentUsersCount, setCurrentUsersCount] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
@@ -236,9 +239,9 @@ const UsersTables = ({
|
||||
});
|
||||
|
||||
return <SimpleTabs tabs={tabs} defaultValue="current" />;
|
||||
};
|
||||
}
|
||||
|
||||
const SearchableTables = () => {
|
||||
function SearchableTables() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isDownloadingUsers, setIsDownloadingUsers] = useState(false);
|
||||
|
||||
@@ -262,7 +265,7 @@ const SearchableTables = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function AddUserButton() {
|
||||
const [bulkAddUsersModal, setBulkAddUsersModal] = useState(false);
|
||||
@@ -325,13 +328,13 @@ function AddUserButton() {
|
||||
);
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle title="Manage Users" icon={SvgUser} />
|
||||
<SearchableTables />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header title={route.title} icon={route.icon} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<SearchableTables />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
:root {
|
||||
--app-page-main-content-width: 52.5rem;
|
||||
--block-width-form-input-min: 10rem;
|
||||
|
||||
--container-sm: 42rem;
|
||||
--container-sm-md: 47rem;
|
||||
--container-md: 54.5rem;
|
||||
--container-lg: 62rem;
|
||||
--container-full: 100%;
|
||||
}
|
||||
|
||||
106
web/src/app/css/table.css
Normal file
106
web/src/app/css/table.css
Normal file
@@ -0,0 +1,106 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Table primitives — data-attribute driven styling
|
||||
* Follows the same pattern as card.css / line-item.css.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/* ---- TableCell ---- */
|
||||
|
||||
.tbl-cell[data-size="regular"] {
|
||||
@apply px-1 py-0.5;
|
||||
}
|
||||
.tbl-cell[data-size="small"] {
|
||||
@apply pl-0.5 pr-1.5 py-1.5;
|
||||
}
|
||||
.tbl-cell[data-sticky] {
|
||||
@apply sticky right-0;
|
||||
}
|
||||
|
||||
.tbl-cell-inner[data-size="regular"] {
|
||||
@apply h-10 px-1;
|
||||
}
|
||||
.tbl-cell-inner[data-size="small"] {
|
||||
@apply h-6 px-0.5;
|
||||
}
|
||||
|
||||
/* ---- TableHead ---- */
|
||||
|
||||
.table-head {
|
||||
@apply relative;
|
||||
}
|
||||
.table-head[data-size="regular"] {
|
||||
@apply px-2 py-1;
|
||||
}
|
||||
.table-head[data-size="small"] {
|
||||
@apply p-1.5;
|
||||
}
|
||||
.table-head[data-bottom-border] {
|
||||
@apply border-b border-transparent hover:border-border-03;
|
||||
}
|
||||
.table-head[data-sticky] {
|
||||
@apply sticky right-0 z-10;
|
||||
}
|
||||
|
||||
/* Inner text wrapper */
|
||||
.table-head[data-size="regular"] .table-head-label {
|
||||
@apply py-2 px-0.5;
|
||||
}
|
||||
.table-head[data-size="small"] .table-head-label {
|
||||
@apply py-1;
|
||||
}
|
||||
|
||||
/* Sort button wrapper */
|
||||
.table-head[data-size="regular"] .table-head-sort {
|
||||
@apply py-1.5;
|
||||
}
|
||||
|
||||
/* ---- TableRow ---- */
|
||||
|
||||
.tbl-row > td {
|
||||
@apply bg-background-tint-00;
|
||||
}
|
||||
|
||||
.tbl-row[data-variant="table"] > td {
|
||||
@apply border-b border-border-01;
|
||||
}
|
||||
|
||||
.tbl-row[data-variant="list"] > td {
|
||||
@apply bg-clip-padding border-y-[4px] border-x-0 border-transparent;
|
||||
}
|
||||
.tbl-row[data-variant="list"] > td:first-child {
|
||||
@apply rounded-l-12;
|
||||
}
|
||||
.tbl-row[data-variant="list"] > td:last-child {
|
||||
@apply rounded-r-12;
|
||||
}
|
||||
|
||||
/* When a drag handle is present the second-to-last td gets the rounding */
|
||||
.tbl-row[data-variant="list"][data-drag-handle] > td:nth-last-child(2) {
|
||||
@apply rounded-r-12;
|
||||
}
|
||||
.tbl-row[data-variant="list"][data-drag-handle] > td:last-child {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ---- TableQualifier ---- */
|
||||
|
||||
.table-qualifier[data-size="regular"] {
|
||||
@apply h-9 w-9;
|
||||
}
|
||||
.table-qualifier[data-size="small"] {
|
||||
@apply h-7 w-7;
|
||||
}
|
||||
.table-qualifier-inner[data-size="regular"] {
|
||||
@apply h-9 w-9;
|
||||
}
|
||||
.table-qualifier-inner[data-size="small"] {
|
||||
@apply h-7 w-7;
|
||||
}
|
||||
|
||||
/* ---- Footer ---- */
|
||||
|
||||
.table-footer[data-size="regular"] {
|
||||
@apply min-h-[2.75rem];
|
||||
}
|
||||
.table-footer[data-size="small"] {
|
||||
@apply min-h-[2.25rem];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import BillingInformationPage from "./BillingInformationPage";
|
||||
import { MdOutlineCreditCard } from "react-icons/md";
|
||||
import { SvgCreditCard } from "@opal/icons";
|
||||
|
||||
export interface BillingInformation {
|
||||
stripe_subscription_id: string;
|
||||
@@ -18,12 +18,15 @@ export interface BillingInformation {
|
||||
|
||||
export default function page() {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgCreditCard}
|
||||
title="Billing Information"
|
||||
icon={<MdOutlineCreditCard size={32} className="my-auto" />}
|
||||
separator
|
||||
/>
|
||||
<BillingInformationPage />
|
||||
</>
|
||||
<SettingsLayouts.Body>
|
||||
<BillingInformationPage />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
"use client";
|
||||
import { use } from "react";
|
||||
|
||||
import { use } from "react";
|
||||
import { GroupDisplay } from "./GroupDisplay";
|
||||
import { useSpecificUserGroup } from "./hook";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { useConnectorStatus } from "@/lib/hooks";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { SvgUsers } from "@opal/icons";
|
||||
const Page = (props: { params: Promise<{ groupId: string }> }) => {
|
||||
const params = use(props.params);
|
||||
const router = useRouter();
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.GROUPS]!;
|
||||
|
||||
function Main({ groupId }: { groupId: string }) {
|
||||
const {
|
||||
userGroup,
|
||||
isLoading: userGroupIsLoading,
|
||||
error: userGroupError,
|
||||
refreshUserGroup,
|
||||
} = useSpecificUserGroup(params.groupId);
|
||||
} = useSpecificUserGroup(groupId);
|
||||
const {
|
||||
data: users,
|
||||
isLoading: userIsLoading,
|
||||
@@ -53,22 +51,31 @@ const Page = (props: { params: Promise<{ groupId: string }> }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackButton />
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title={userGroup.name || "Unknown"}
|
||||
separator
|
||||
backButton
|
||||
/>
|
||||
|
||||
<AdminPageTitle title={userGroup.name || "Unknown"} icon={SvgUsers} />
|
||||
|
||||
{userGroup ? (
|
||||
<SettingsLayouts.Body>
|
||||
<GroupDisplay
|
||||
users={users.accepted}
|
||||
ccPairs={ccPairs}
|
||||
userGroup={userGroup}
|
||||
refreshUserGroup={refreshUserGroup}
|
||||
/>
|
||||
) : (
|
||||
<div>Unable to fetch User Group :(</div>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default Page;
|
||||
export default function Page(props: { params: Promise<{ groupId: string }> }) {
|
||||
const params = use(props.params);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<Main groupId={params.groupId} />
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,15 @@ import UserGroupCreationForm from "./UserGroupCreationForm";
|
||||
import { useState } from "react";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import { SvgUsers } from "@opal/icons";
|
||||
const Main = () => {
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.GROUPS]!;
|
||||
|
||||
function Main() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refreshUserGroups } = useUserGroups();
|
||||
@@ -70,16 +72,16 @@ const Main = () => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle title="Manage User Groups" icon={SvgUsers} />
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { CUSTOM_ANALYTICS_ENABLED } from "@/lib/constants";
|
||||
import { Callout } from "@/components/ui/callout";
|
||||
import { FiBarChart2 } from "react-icons/fi";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import Text from "@/components/ui/text";
|
||||
import { CustomAnalyticsUpdateForm } from "./CustomAnalyticsUpdateForm";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.CUSTOM_ANALYTICS]!;
|
||||
|
||||
function Main() {
|
||||
if (!CUSTOM_ANALYTICS_ENABLED) {
|
||||
return (
|
||||
@@ -35,13 +37,11 @@ function Main() {
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="pt-4 mx-auto container">
|
||||
<AdminPageTitle
|
||||
title="Custom Analytics"
|
||||
icon={<FiBarChart2 size={32} />}
|
||||
/>
|
||||
|
||||
<Main />
|
||||
</main>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { QueryHistoryTable } from "@/app/ee/admin/performance/query-history/QueryHistoryTable";
|
||||
import { SvgServer } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.QUERY_HISTORY]!;
|
||||
|
||||
export default function QueryHistoryPage() {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle title="Query History" icon={SvgServer} />
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
|
||||
<QueryHistoryTable />
|
||||
</>
|
||||
<SettingsLayouts.Body>
|
||||
<QueryHistoryTable />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,32 +6,36 @@ import { FeedbackChart } from "@/app/ee/admin/performance/usage/FeedbackChart";
|
||||
import { QueryPerformanceChart } from "@/app/ee/admin/performance/usage/QueryPerformanceChart";
|
||||
import { PersonaMessagesChart } from "@/app/ee/admin/performance/usage/PersonaMessagesChart";
|
||||
import { useTimeRange } from "@/app/ee/admin/performance/lib";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import UsageReports from "@/app/ee/admin/performance/usage/UsageReports";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { useAdminPersonas } from "@/hooks/useAdminPersonas";
|
||||
import { SvgActivity } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.USAGE]!;
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [timeRange, setTimeRange] = useTimeRange();
|
||||
const { personas } = useAdminPersonas();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle title="Usage Statistics" icon={SvgActivity} />
|
||||
<AdminDateRangeSelector
|
||||
value={timeRange}
|
||||
onValueChange={(value) => setTimeRange(value as any)}
|
||||
/>
|
||||
<QueryPerformanceChart timeRange={timeRange} />
|
||||
<FeedbackChart timeRange={timeRange} />
|
||||
<OnyxBotChart timeRange={timeRange} />
|
||||
<PersonaMessagesChart
|
||||
availablePersonas={personas}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
<Separator />
|
||||
<UsageReports />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<AdminDateRangeSelector
|
||||
value={timeRange}
|
||||
onValueChange={(value) => setTimeRange(value as any)}
|
||||
/>
|
||||
<QueryPerformanceChart timeRange={timeRange} />
|
||||
<FeedbackChart timeRange={timeRange} />
|
||||
<OnyxBotChart timeRange={timeRange} />
|
||||
<PersonaMessagesChart
|
||||
availablePersonas={personas}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
<Separator />
|
||||
<UsageReports />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { StandardAnswerCreationForm } from "@/app/ee/admin/standard-answer/StandardAnswerCreationForm";
|
||||
import { fetchSS } from "@/lib/utilsSS";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { ClipboardIcon } from "@/components/icons/icons";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import { StandardAnswer, StandardAnswerCategory } from "@/lib/types";
|
||||
|
||||
async function Page(props: { params: Promise<{ id: string }> }) {
|
||||
const params = await props.params;
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.STANDARD_ANSWERS]!;
|
||||
|
||||
async function Main({ id }: { id: string }) {
|
||||
const tasks = [
|
||||
fetchSS("/manage/admin/standard-answer"),
|
||||
fetchSS(`/manage/admin/standard-answer/category`),
|
||||
@@ -35,14 +35,14 @@ async function Page(props: { params: Promise<{ id: string }> }) {
|
||||
const allStandardAnswers =
|
||||
(await standardAnswersResponse.json()) as StandardAnswer[];
|
||||
const standardAnswer = allStandardAnswers.find(
|
||||
(answer) => answer.id.toString() === params.id
|
||||
(answer) => answer.id.toString() === id
|
||||
);
|
||||
|
||||
if (!standardAnswer) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Did not find standard answer with ID: ${params.id}`}
|
||||
errorMsg={`Did not find standard answer with ID: ${id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -67,20 +67,29 @@ async function Page(props: { params: Promise<{ id: string }> }) {
|
||||
|
||||
const standardAnswerCategories =
|
||||
(await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[];
|
||||
return (
|
||||
<>
|
||||
<BackButton />
|
||||
<AdminPageTitle
|
||||
title="Edit Standard Answer"
|
||||
icon={<ClipboardIcon size={32} />}
|
||||
/>
|
||||
|
||||
<StandardAnswerCreationForm
|
||||
standardAnswerCategories={standardAnswerCategories}
|
||||
existingStandardAnswer={standardAnswer}
|
||||
/>
|
||||
</>
|
||||
return (
|
||||
<StandardAnswerCreationForm
|
||||
standardAnswerCategories={standardAnswerCategories}
|
||||
existingStandardAnswer={standardAnswer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
export default async function Page(props: { params: Promise<{ id: string }> }) {
|
||||
const params = await props.params;
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title="Edit Standard Answer"
|
||||
backButton
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<Main id={params.id} />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { StandardAnswerCreationForm } from "@/app/ee/admin/standard-answer/StandardAnswerCreationForm";
|
||||
import { fetchSS } from "@/lib/utilsSS";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { ClipboardIcon } from "@/components/icons/icons";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import { StandardAnswerCategory } from "@/lib/types";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.STANDARD_ANSWERS]!;
|
||||
|
||||
async function Page() {
|
||||
const standardAnswerCategoriesResponse = await fetchSS(
|
||||
"/manage/admin/standard-answer/category"
|
||||
@@ -23,17 +24,19 @@ async function Page() {
|
||||
(await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackButton />
|
||||
<AdminPageTitle
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title="New Standard Answer"
|
||||
icon={<ClipboardIcon size={32} />}
|
||||
backButton
|
||||
separator
|
||||
/>
|
||||
|
||||
<StandardAnswerCreationForm
|
||||
standardAnswerCategories={standardAnswerCategories}
|
||||
/>
|
||||
</>
|
||||
<SettingsLayouts.Body>
|
||||
<StandardAnswerCreationForm
|
||||
standardAnswerCategories={standardAnswerCategories}
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { ClipboardIcon, EditIcon } from "@/components/icons/icons";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useStandardAnswers, useStandardAnswerCategories } from "./hooks";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
@@ -29,10 +28,13 @@ import { PageSelector } from "@/components/PageSelector";
|
||||
import Text from "@/components/ui/text";
|
||||
import { TableHeader } from "@/components/ui/table";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import { SvgTrash } from "@opal/icons";
|
||||
import { SvgEdit, SvgTrash } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
const NUM_RESULTS_PER_PAGE = 10;
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.STANDARD_ANSWERS]!;
|
||||
|
||||
type Displayable = JSX.Element | string;
|
||||
|
||||
const RowTemplate = ({
|
||||
@@ -113,7 +115,7 @@ const StandardAnswersTableRow = ({
|
||||
key={`edit-${standardAnswer.id}`}
|
||||
href={`/ee/admin/standard-answer/${standardAnswer.id}` as Route}
|
||||
>
|
||||
<EditIcon />
|
||||
<SvgEdit size={16} />
|
||||
</Link>,
|
||||
<div key={`categories-${standardAnswer.id}`}>
|
||||
{standardAnswer.categories.map((category) => (
|
||||
@@ -344,7 +346,7 @@ const StandardAnswersTable = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Main = () => {
|
||||
function Main() {
|
||||
const {
|
||||
data: standardAnswers,
|
||||
error: standardAnswersError,
|
||||
@@ -413,18 +415,15 @@ const Main = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
icon={<ClipboardIcon size={32} />}
|
||||
title="Standard Answers"
|
||||
/>
|
||||
<Main />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<Main />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { SvgPaintBrush } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import {
|
||||
AppearanceThemeSettings,
|
||||
@@ -15,6 +15,8 @@ import * as Yup from "yup";
|
||||
import { EnterpriseSettings } from "@/interfaces/settings";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.THEME]!;
|
||||
|
||||
const CHAR_LIMITS = {
|
||||
application_name: 50,
|
||||
custom_greeting_message: 50,
|
||||
@@ -211,9 +213,9 @@ export default function ThemePage() {
|
||||
<Form className="w-full h-full">
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
title="Appearance & Theming"
|
||||
title={route.title}
|
||||
description="Customize how the application appears to users across your organization."
|
||||
icon={SvgPaintBrush}
|
||||
icon={route.icon}
|
||||
rightChildren={
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@import "css/sizes.css";
|
||||
@import "css/square-button.css";
|
||||
@import "css/switch.css";
|
||||
@import "css/table.css";
|
||||
@import "css/z-index.css";
|
||||
|
||||
/* KH Teka Font */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import { ApplicationStatus } from "@/interfaces/settings";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
export interface ClientLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -18,16 +19,32 @@ export interface ClientLayoutProps {
|
||||
// the `py-10 px-4 md:px-12` padding below can be removed entirely and
|
||||
// this prefix list can be deleted.
|
||||
const SETTINGS_LAYOUT_PREFIXES = [
|
||||
"/admin/configuration/chat-preferences",
|
||||
"/admin/configuration/image-generation",
|
||||
"/admin/configuration/web-search",
|
||||
"/admin/actions/mcp",
|
||||
"/admin/actions/open-api",
|
||||
"/admin/billing",
|
||||
"/admin/document-index-migration",
|
||||
"/admin/discord-bot",
|
||||
"/admin/theme",
|
||||
"/admin/configuration/llm",
|
||||
ADMIN_PATHS.CHAT_PREFERENCES,
|
||||
ADMIN_PATHS.IMAGE_GENERATION,
|
||||
ADMIN_PATHS.WEB_SEARCH,
|
||||
ADMIN_PATHS.MCP_ACTIONS,
|
||||
ADMIN_PATHS.OPENAPI_ACTIONS,
|
||||
ADMIN_PATHS.BILLING,
|
||||
ADMIN_PATHS.INDEX_MIGRATION,
|
||||
ADMIN_PATHS.DISCORD_BOTS,
|
||||
ADMIN_PATHS.THEME,
|
||||
ADMIN_PATHS.LLM_MODELS,
|
||||
ADMIN_PATHS.AGENTS,
|
||||
ADMIN_PATHS.USERS,
|
||||
ADMIN_PATHS.TOKEN_RATE_LIMITS,
|
||||
ADMIN_PATHS.SEARCH_SETTINGS,
|
||||
ADMIN_PATHS.DOCUMENT_PROCESSING,
|
||||
ADMIN_PATHS.CODE_INTERPRETER,
|
||||
ADMIN_PATHS.API_KEYS,
|
||||
ADMIN_PATHS.ADD_CONNECTOR,
|
||||
ADMIN_PATHS.INDEXING_STATUS,
|
||||
ADMIN_PATHS.DOCUMENTS,
|
||||
ADMIN_PATHS.DEBUG,
|
||||
ADMIN_PATHS.KNOWLEDGE_GRAPH,
|
||||
ADMIN_PATHS.SLACK_BOTS,
|
||||
ADMIN_PATHS.STANDARD_ANSWERS,
|
||||
ADMIN_PATHS.GROUPS,
|
||||
ADMIN_PATHS.PERFORMANCE,
|
||||
];
|
||||
|
||||
export function ClientLayout({
|
||||
|
||||
292
web/src/hooks/useColumnWidths.ts
Normal file
292
web/src/hooks/useColumnWidths.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* useColumnWidths — Proportional column widths with splitter resize.
|
||||
*
|
||||
* WHY NOT TANSTACK'S BUILT-IN COLUMN SIZING?
|
||||
*
|
||||
* TanStack Table's column resize system (columnSizing state,
|
||||
* header.getResizeHandler(), columnResizeMode) doesn't support the
|
||||
* behavior our design requires:
|
||||
*
|
||||
* 1. No proportional fill — TanStack uses absolute pixel widths from
|
||||
* columnDef.size. When the container is wider than the sum of sizes,
|
||||
* the extra space is not distributed. We need weight-based proportional
|
||||
* distribution so columns fill the container at any width.
|
||||
*
|
||||
* 2. No splitter semantics — TanStack's resize changes one column's size
|
||||
* in isolation (the total table width grows/shrinks). We need "splitter"
|
||||
* behavior: dragging column i's right edge grows column i and shrinks
|
||||
* column i+1 by the same amount, keeping the total fixed. This prevents
|
||||
* the actions column from jittering.
|
||||
*
|
||||
* 3. No per-column min-width enforcement during drag — TanStack only has a
|
||||
* global minSize default. We enforce per-column min-widths and clamp the
|
||||
* drag delta so neither the dragged column nor its neighbor can shrink
|
||||
* below their floor.
|
||||
*
|
||||
* 4. No weight-based resize persistence — TanStack stores absolute pixel
|
||||
* deltas. When the window resizes after a column drag, the proportions
|
||||
* drift. We store weights, so a user-resized column scales proportionally
|
||||
* with the container — the ratio is preserved, not the pixel count.
|
||||
*
|
||||
* APPROACH:
|
||||
*
|
||||
* We still rely on TanStack for everything else (sorting, pagination,
|
||||
* visibility, row selection). Only column width computation and resize
|
||||
* interaction are handled here. The columnDef.size values are used as
|
||||
* initial weights, and TanStack's enableResizing / getCanResize() flags
|
||||
* are still respected in the render loop.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { ColumnDef, Header } from "@tanstack/react-table";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extracted config ready to pass to useColumnWidths. */
|
||||
export interface WidthConfig {
|
||||
fixedColumnIds: Set<string>;
|
||||
columnWeights: Record<string, number>;
|
||||
columnMinWidths: Record<string, number>;
|
||||
}
|
||||
|
||||
interface UseColumnWidthsOptions {
|
||||
/** Visible headers from TanStack's first header group. */
|
||||
headers: Header<any, unknown>[];
|
||||
/** Column IDs that have fixed pixel widths (e.g. qualifier, actions). */
|
||||
fixedColumnIds: Set<string>;
|
||||
/** Explicit column weights (takes precedence over columnDef.size). */
|
||||
columnWeights?: Record<string, number>;
|
||||
/** Per-column minimum widths for data (non-fixed) columns. */
|
||||
columnMinWidths: Record<string, number>;
|
||||
}
|
||||
|
||||
interface UseColumnWidthsReturn {
|
||||
/** Attach to the scrollable container for width measurement. */
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Computed pixel widths keyed by column ID. */
|
||||
columnWidths: Record<string, number>;
|
||||
/** Factory to create a splitter resize handler for a column pair. */
|
||||
createResizeHandler: (
|
||||
columnId: string,
|
||||
neighborId: string
|
||||
) => (event: React.MouseEvent | React.TouchEvent) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: measure container width via ResizeObserver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useElementWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return [ref, width];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: compute pixel widths from weights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeColumnWidths(
|
||||
containerWidth: number,
|
||||
headers: Header<any, unknown>[],
|
||||
customWeights: Record<string, number>,
|
||||
fixedColumnIds: Set<string>,
|
||||
columnWeights: Record<string, number>,
|
||||
columnMinWidths: Record<string, number>
|
||||
): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
let fixedTotal = 0;
|
||||
const dataColumns: { id: string; weight: number; minWidth: number }[] = [];
|
||||
|
||||
for (const h of headers) {
|
||||
const baseSize = h.column.columnDef.size ?? 20;
|
||||
if (fixedColumnIds.has(h.id)) {
|
||||
fixedTotal += baseSize;
|
||||
} else {
|
||||
dataColumns.push({
|
||||
id: h.id,
|
||||
weight: customWeights[h.id] ?? columnWeights[h.id] ?? baseSize,
|
||||
minWidth: columnMinWidths[h.id] ?? 50,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tableMinWidth =
|
||||
fixedTotal + dataColumns.reduce((sum, col) => sum + col.minWidth, 0);
|
||||
const tableWidth =
|
||||
containerWidth > 0 ? Math.max(containerWidth, tableMinWidth) : 0;
|
||||
|
||||
if (tableWidth === 0) {
|
||||
for (const h of headers) {
|
||||
result[h.id] = h.column.columnDef.size ?? 20;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const available = tableWidth - fixedTotal;
|
||||
const totalWeight = dataColumns.reduce((sum, col) => sum + col.weight, 0);
|
||||
|
||||
// Proportional allocation with min-width clamping
|
||||
let clampedTotal = 0;
|
||||
let unclampedWeight = 0;
|
||||
const clamped = new Set<string>();
|
||||
|
||||
for (const col of dataColumns) {
|
||||
const proportional = available * (col.weight / totalWeight);
|
||||
if (proportional < col.minWidth) {
|
||||
result[col.id] = col.minWidth;
|
||||
clampedTotal += col.minWidth;
|
||||
clamped.add(col.id);
|
||||
} else {
|
||||
unclampedWeight += col.weight;
|
||||
}
|
||||
}
|
||||
|
||||
// Distribute remaining space among unclamped columns
|
||||
const remainingSpace = available - clampedTotal;
|
||||
let assigned = 0;
|
||||
const unclampedCols = dataColumns.filter((col) => !clamped.has(col.id));
|
||||
|
||||
for (let i = 0; i < unclampedCols.length; i++) {
|
||||
const col = unclampedCols[i]!;
|
||||
if (i === unclampedCols.length - 1) {
|
||||
result[col.id] = remainingSpace - assigned;
|
||||
} else {
|
||||
const w = Math.round(remainingSpace * (col.weight / unclampedWeight));
|
||||
result[col.id] = w;
|
||||
assigned += w;
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed columns keep their base size
|
||||
for (const h of headers) {
|
||||
if (fixedColumnIds.has(h.id)) {
|
||||
result[h.id] = h.column.columnDef.size ?? 20;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: create a splitter resize handler for a column pair
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createSplitterResizeHandler(
|
||||
columnId: string,
|
||||
neighborId: string,
|
||||
startColumnWidth: number,
|
||||
startNeighborWidth: number,
|
||||
startColumnWeight: number,
|
||||
startNeighborWeight: number,
|
||||
columnMinWidth: number,
|
||||
neighborMinWidth: number,
|
||||
setter: (value: React.SetStateAction<Record<string, number>>) => void
|
||||
): (event: React.MouseEvent | React.TouchEvent) => void {
|
||||
return (event: React.MouseEvent | React.TouchEvent) => {
|
||||
const startX =
|
||||
"touches" in event ? event.touches[0]!.clientX : event.clientX;
|
||||
|
||||
const onMove = (e: MouseEvent | TouchEvent) => {
|
||||
const currentX =
|
||||
"touches" in e
|
||||
? (e as TouchEvent).touches[0]!.clientX
|
||||
: (e as MouseEvent).clientX;
|
||||
const rawDelta = currentX - startX;
|
||||
const minDelta = columnMinWidth - startColumnWidth;
|
||||
const maxDelta = startNeighborWidth - neighborMinWidth;
|
||||
const delta = Math.max(minDelta, Math.min(maxDelta, rawDelta));
|
||||
|
||||
setter((prev) => ({
|
||||
...prev,
|
||||
[columnId]:
|
||||
startColumnWeight * ((startColumnWidth + delta) / startColumnWidth),
|
||||
[neighborId]:
|
||||
startNeighborWeight *
|
||||
((startNeighborWidth - delta) / startNeighborWidth),
|
||||
}));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.removeEventListener("touchmove", onMove);
|
||||
document.removeEventListener("touchend", onUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
document.addEventListener("touchmove", onMove);
|
||||
document.addEventListener("touchend", onUp);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function useColumnWidths({
|
||||
headers,
|
||||
fixedColumnIds,
|
||||
columnWeights = {},
|
||||
columnMinWidths,
|
||||
}: UseColumnWidthsOptions): UseColumnWidthsReturn {
|
||||
const [containerRef, containerWidth] = useElementWidth();
|
||||
const [customWeights, setCustomWeights] = useState<Record<string, number>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const columnWidths = computeColumnWidths(
|
||||
containerWidth,
|
||||
headers,
|
||||
customWeights,
|
||||
fixedColumnIds,
|
||||
columnWeights,
|
||||
columnMinWidths
|
||||
);
|
||||
|
||||
const createResizeHandler = useCallback(
|
||||
(columnId: string, neighborId: string) => {
|
||||
const header = headers.find((h) => h.id === columnId);
|
||||
const neighbor = headers.find((h) => h.id === neighborId);
|
||||
|
||||
return createSplitterResizeHandler(
|
||||
columnId,
|
||||
neighborId,
|
||||
columnWidths[columnId] ?? 0,
|
||||
columnWidths[neighborId] ?? 0,
|
||||
customWeights[columnId] ??
|
||||
columnWeights[columnId] ??
|
||||
header?.column.columnDef.size ??
|
||||
20,
|
||||
customWeights[neighborId] ??
|
||||
columnWeights[neighborId] ??
|
||||
neighbor?.column.columnDef.size ??
|
||||
20,
|
||||
columnMinWidths[columnId] ?? 50,
|
||||
columnMinWidths[neighborId] ?? 50,
|
||||
setCustomWeights
|
||||
);
|
||||
},
|
||||
[headers, columnWidths, customWeights, columnWeights, columnMinWidths]
|
||||
);
|
||||
|
||||
return { containerRef, columnWidths, createResizeHandler };
|
||||
}
|
||||
244
web/src/hooks/useDataTable.ts
Normal file
244
web/src/hooks/useDataTable.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
type Table,
|
||||
type ColumnDef,
|
||||
type RowData,
|
||||
type SortingState,
|
||||
type RowSelectionState,
|
||||
type ColumnSizingState,
|
||||
type PaginationState,
|
||||
type ColumnResizeMode,
|
||||
type TableOptions,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OnyxSortDirection = "none" | "ascending" | "descending";
|
||||
export type OnyxSelectionState = "none" | "partial" | "all";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a TanStack sort direction to an Onyx sort direction string.
|
||||
*
|
||||
* This is a **named export** (not on the return object) because it is used
|
||||
* statically inside JSX header loops, not tied to hook state.
|
||||
*/
|
||||
export function toOnyxSortDirection(
|
||||
dir: false | "asc" | "desc"
|
||||
): OnyxSortDirection {
|
||||
if (dir === "asc") return "ascending";
|
||||
if (dir === "desc") return "descending";
|
||||
return "none";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook options & return types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Keys managed internally — callers cannot override these via `tableOptions`. */
|
||||
type ManagedKeys =
|
||||
| "data"
|
||||
| "columns"
|
||||
| "state"
|
||||
| "onSortingChange"
|
||||
| "onRowSelectionChange"
|
||||
| "onColumnSizingChange"
|
||||
| "onColumnVisibilityChange"
|
||||
| "onPaginationChange"
|
||||
| "getCoreRowModel"
|
||||
| "getSortedRowModel"
|
||||
| "getPaginationRowModel"
|
||||
| "columnResizeMode"
|
||||
| "enableRowSelection";
|
||||
|
||||
/**
|
||||
* Options accepted by {@link useDataTable}.
|
||||
*
|
||||
* Only `data` and `columns` are required — everything else has sensible defaults.
|
||||
*/
|
||||
interface UseDataTableOptions<TData extends RowData> {
|
||||
/** The row data array. */
|
||||
data: TData[];
|
||||
/** TanStack column definitions. */
|
||||
columns: ColumnDef<TData, any>[];
|
||||
/** Rows per page. Set `Infinity` to disable pagination. @default 10 */
|
||||
pageSize?: number;
|
||||
/** Whether rows can be selected. @default true */
|
||||
enableRowSelection?: boolean;
|
||||
/** Whether columns can be resized. @default true */
|
||||
enableColumnResizing?: boolean;
|
||||
/** Resize strategy. @default "onChange" */
|
||||
columnResizeMode?: ColumnResizeMode;
|
||||
/** Initial sorting state. @default [] */
|
||||
initialSorting?: SortingState;
|
||||
/** Initial column visibility state. @default {} */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Escape-hatch: extra options spread into `useReactTable`. Managed keys are excluded. */
|
||||
tableOptions?: Partial<Omit<TableOptions<TData>, ManagedKeys>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Values returned by {@link useDataTable}.
|
||||
*/
|
||||
interface UseDataTableReturn<TData extends RowData> {
|
||||
/** Full TanStack table instance for rendering. */
|
||||
table: Table<TData>;
|
||||
|
||||
// Pagination (1-based, matching Onyx Footer)
|
||||
/** Current page number (1-based). */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Total number of rows. */
|
||||
totalItems: number;
|
||||
/** Rows per page. */
|
||||
pageSize: number;
|
||||
/** Navigate to a page (1-based, clamped to valid range). */
|
||||
setPage: (page: number) => void;
|
||||
/** Whether pagination is active (pageSize is finite). */
|
||||
isPaginated: boolean;
|
||||
|
||||
// Selection (pre-computed for Onyx Footer)
|
||||
/** Aggregate selection state for the current page. */
|
||||
selectionState: OnyxSelectionState;
|
||||
/** Number of selected rows. */
|
||||
selectedCount: number;
|
||||
/** Whether every row on the current page is selected. */
|
||||
isAllPageRowsSelected: boolean;
|
||||
/** Deselect all rows. */
|
||||
clearSelection: () => void;
|
||||
/** Select or deselect all rows on the current page. */
|
||||
toggleAllPageRowsSelected: (selected: boolean) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wraps TanStack `useReactTable` with Onyx-specific defaults and derived
|
||||
* state so that consumers only need to provide `data` + `columns`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const {
|
||||
* table, currentPage, totalPages, setPage, pageSize,
|
||||
* selectionState, selectedCount, clearSelection,
|
||||
* } = useDataTable({ data: rows, columns });
|
||||
* ```
|
||||
*/
|
||||
export default function useDataTable<TData extends RowData>(
|
||||
options: UseDataTableOptions<TData>
|
||||
): UseDataTableReturn<TData> {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
pageSize: pageSizeOption = 10,
|
||||
enableRowSelection = true,
|
||||
enableColumnResizing = true,
|
||||
columnResizeMode = "onChange",
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
tableOptions,
|
||||
} = options;
|
||||
|
||||
// ---- internal state -----------------------------------------------------
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
initialColumnVisibility
|
||||
);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: pageSizeOption,
|
||||
});
|
||||
|
||||
// ---- TanStack table instance --------------------------------------------
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
columnSizing,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
columnResizeMode,
|
||||
enableRowSelection,
|
||||
enableColumnResizing,
|
||||
...tableOptions,
|
||||
});
|
||||
|
||||
// ---- derived values -----------------------------------------------------
|
||||
const isAllPageRowsSelected = table.getIsAllPageRowsSelected();
|
||||
const isSomePageRowsSelected = table.getIsSomePageRowsSelected();
|
||||
|
||||
const selectionState: OnyxSelectionState = isAllPageRowsSelected
|
||||
? "all"
|
||||
: isSomePageRowsSelected
|
||||
? "partial"
|
||||
: "none";
|
||||
|
||||
const selectedCount = Object.keys(rowSelection).length;
|
||||
const totalPages = table.getPageCount();
|
||||
const currentPage = pagination.pageIndex + 1;
|
||||
const totalItems = data.length;
|
||||
const isPaginated = isFinite(pageSizeOption);
|
||||
|
||||
// ---- actions ------------------------------------------------------------
|
||||
const setPage = useCallback(
|
||||
(page: number) => {
|
||||
const clamped = Math.max(1, Math.min(page, totalPages));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: clamped - 1 }));
|
||||
},
|
||||
[totalPages]
|
||||
);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
table.resetRowSelection();
|
||||
}, [table]);
|
||||
|
||||
const toggleAllPageRowsSelected = useCallback(
|
||||
(selected: boolean) => {
|
||||
table.toggleAllPageRowsSelected(selected);
|
||||
},
|
||||
[table]
|
||||
);
|
||||
|
||||
return {
|
||||
table,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
pageSize: pageSizeOption,
|
||||
setPage,
|
||||
isPaginated,
|
||||
selectionState,
|
||||
selectedCount,
|
||||
isAllPageRowsSelected,
|
||||
clearSelection,
|
||||
toggleAllPageRowsSelected,
|
||||
};
|
||||
}
|
||||
139
web/src/hooks/useDraggableRows.ts
Normal file
139
web/src/hooks/useDraggableRows.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
useSensors,
|
||||
useSensor,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
closestCenter,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseDraggableRowsOptions<TData> {
|
||||
/** Current display-order data. */
|
||||
data: TData[];
|
||||
/** Extract a unique string ID from each row. */
|
||||
getRowId: (row: TData) => string;
|
||||
/** Disable DnD (e.g. when column sorting is active). @default true */
|
||||
enabled?: boolean;
|
||||
/** Called after a successful reorder with the new ID order and a map of changed positions. */
|
||||
onReorder?: (
|
||||
ids: string[],
|
||||
changedOrders: Record<string, number>
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface DraggableRowsReturn {
|
||||
/** Props to pass to TableBody's `dndSortable` prop. */
|
||||
dndContextProps: {
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
collisionDetection: typeof closestCenter;
|
||||
modifiers: Array<typeof restrictToVerticalAxis>;
|
||||
onDragStart: (event: DragStartEvent) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onDragCancel: () => void;
|
||||
};
|
||||
/** Ordered list of IDs for SortableContext. */
|
||||
sortableItems: string[];
|
||||
/** ID of the currently dragged row, or null. */
|
||||
activeId: string | null;
|
||||
/** Whether a drag is in progress. */
|
||||
isDragging: boolean;
|
||||
/** Whether DnD is enabled. */
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function useDraggableRows<TData>(
|
||||
options: UseDraggableRowsOptions<TData>
|
||||
): DraggableRowsReturn {
|
||||
const { data, getRowId, enabled = true, onReorder } = options;
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const sortableItems = useMemo(
|
||||
() => data.map((row) => getRowId(row)),
|
||||
[data, getRowId]
|
||||
);
|
||||
|
||||
const sortableIndexMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 0; i < sortableItems.length; i++) {
|
||||
const item = sortableItems[i];
|
||||
if (item !== undefined) {
|
||||
map.set(item, i);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [sortableItems]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(String(event.active.id));
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = sortableIndexMap.get(String(active.id));
|
||||
const newIndex = sortableIndexMap.get(String(over.id));
|
||||
if (oldIndex === undefined || newIndex === undefined) return;
|
||||
|
||||
const reordered = arrayMove(sortableItems, oldIndex, newIndex);
|
||||
|
||||
const minIdx = Math.min(oldIndex, newIndex);
|
||||
const maxIdx = Math.max(oldIndex, newIndex);
|
||||
const changedOrders: Record<string, number> = {};
|
||||
for (let i = minIdx; i <= maxIdx; i++) {
|
||||
const id = reordered[i];
|
||||
if (id !== undefined) {
|
||||
changedOrders[id] = i;
|
||||
}
|
||||
}
|
||||
|
||||
onReorder?.(reordered, changedOrders);
|
||||
},
|
||||
[sortableItems, sortableIndexMap, onReorder]
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
dndContextProps: {
|
||||
sensors,
|
||||
collisionDetection: closestCenter,
|
||||
modifiers: [restrictToVerticalAxis],
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragCancel: handleDragCancel,
|
||||
},
|
||||
sortableItems,
|
||||
activeId,
|
||||
isDragging: activeId !== null,
|
||||
isEnabled: enabled,
|
||||
};
|
||||
}
|
||||
@@ -43,8 +43,11 @@ import { Content } from "@opal/layouts";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
|
||||
const widthClasses = {
|
||||
md: "w-[min(50rem,100%)]",
|
||||
lg: "w-[min(60rem,100%)]",
|
||||
sm: "w-[min(var(--container-sm),100%)]",
|
||||
"sm-md": "w-[min(var(--container-sm-md),100%)]",
|
||||
md: "w-[min(var(--container-md),100%)]",
|
||||
lg: "w-[min(var(--container-lg),100%)]",
|
||||
full: "w-[var(--container-full)]",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -57,18 +60,19 @@ const widthClasses = {
|
||||
* - Full height container with centered content
|
||||
* - Automatic overflow-y scrolling
|
||||
* - Contains the scroll container ID that Settings.Header uses for shadow detection
|
||||
* - Configurable width: "md" (50rem max) or "full" (full width with 4rem padding)
|
||||
* - Configurable width via CSS variables defined in sizes.css:
|
||||
* "sm" (672px), "sm-md" (752px), "md" (872px, default), "lg" (992px), "full" (100%)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Default medium width (50rem max)
|
||||
* // Default medium width (872px max)
|
||||
* <SettingsLayouts.Root>
|
||||
* <SettingsLayouts.Header {...} />
|
||||
* <SettingsLayouts.Body>...</SettingsLayouts.Body>
|
||||
* </SettingsLayouts.Root>
|
||||
*
|
||||
* // Full width with padding
|
||||
* <SettingsLayouts.Root width="full">
|
||||
* // Large width (992px max)
|
||||
* <SettingsLayouts.Root width="lg">
|
||||
* <SettingsLayouts.Header {...} />
|
||||
* <SettingsLayouts.Body>...</SettingsLayouts.Body>
|
||||
* </SettingsLayouts.Root>
|
||||
|
||||
245
web/src/lib/admin-routes.ts
Normal file
245
web/src/lib/admin-routes.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
import {
|
||||
SvgActions,
|
||||
SvgActivity,
|
||||
SvgArrowExchange,
|
||||
SvgBarChart,
|
||||
SvgBookOpen,
|
||||
SvgBubbleText,
|
||||
SvgClipboard,
|
||||
SvgCpu,
|
||||
SvgDiscordMono,
|
||||
SvgDownload,
|
||||
SvgFileText,
|
||||
SvgFolder,
|
||||
SvgGlobe,
|
||||
SvgImage,
|
||||
SvgKey,
|
||||
SvgMcp,
|
||||
SvgNetworkGraph,
|
||||
SvgOnyxOctagon,
|
||||
SvgPaintBrush,
|
||||
SvgSearch,
|
||||
SvgServer,
|
||||
SvgShield,
|
||||
SvgSlack,
|
||||
SvgTerminal,
|
||||
SvgThumbsUp,
|
||||
SvgUploadCloud,
|
||||
SvgUser,
|
||||
SvgUsers,
|
||||
SvgWallet,
|
||||
SvgZoomIn,
|
||||
} from "@opal/icons";
|
||||
|
||||
/**
|
||||
* Canonical path constants for every admin route.
|
||||
*/
|
||||
export const ADMIN_PATHS = {
|
||||
INDEXING_STATUS: "/admin/indexing/status",
|
||||
ADD_CONNECTOR: "/admin/add-connector",
|
||||
DOCUMENT_SETS: "/admin/documents/sets",
|
||||
DOCUMENT_EXPLORER: "/admin/documents/explorer",
|
||||
DOCUMENT_FEEDBACK: "/admin/documents/feedback",
|
||||
AGENTS: "/admin/agents",
|
||||
SLACK_BOTS: "/admin/bots",
|
||||
DISCORD_BOTS: "/admin/discord-bot",
|
||||
MCP_ACTIONS: "/admin/actions/mcp",
|
||||
OPENAPI_ACTIONS: "/admin/actions/open-api",
|
||||
STANDARD_ANSWERS: "/admin/standard-answer",
|
||||
GROUPS: "/admin/groups",
|
||||
CHAT_PREFERENCES: "/admin/configuration/chat-preferences",
|
||||
LLM_MODELS: "/admin/configuration/llm",
|
||||
WEB_SEARCH: "/admin/configuration/web-search",
|
||||
IMAGE_GENERATION: "/admin/configuration/image-generation",
|
||||
CODE_INTERPRETER: "/admin/configuration/code-interpreter",
|
||||
SEARCH_SETTINGS: "/admin/configuration/search",
|
||||
DOCUMENT_PROCESSING: "/admin/configuration/document-processing",
|
||||
KNOWLEDGE_GRAPH: "/admin/kg",
|
||||
USERS: "/admin/users",
|
||||
API_KEYS: "/admin/api-key",
|
||||
TOKEN_RATE_LIMITS: "/admin/token-rate-limits",
|
||||
USAGE: "/admin/performance/usage",
|
||||
QUERY_HISTORY: "/admin/performance/query-history",
|
||||
CUSTOM_ANALYTICS: "/admin/performance/custom-analytics",
|
||||
THEME: "/admin/theme",
|
||||
BILLING: "/admin/billing",
|
||||
INDEX_MIGRATION: "/admin/document-index-migration",
|
||||
DEBUG: "/admin/debug",
|
||||
// Prefix-only entries (used in SETTINGS_LAYOUT_PREFIXES but have no
|
||||
// single page header of their own)
|
||||
DOCUMENTS: "/admin/documents",
|
||||
PERFORMANCE: "/admin/performance",
|
||||
} as const;
|
||||
|
||||
interface AdminRouteConfig {
|
||||
icon: IconFunctionComponent;
|
||||
title: string;
|
||||
sidebarLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for icon, page-header title, and sidebar label
|
||||
* for every admin route. Keyed by path from `ADMIN_PATHS`.
|
||||
*/
|
||||
export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
|
||||
[ADMIN_PATHS.INDEXING_STATUS]: {
|
||||
icon: SvgBookOpen,
|
||||
title: "Existing Connectors",
|
||||
sidebarLabel: "Existing Connectors",
|
||||
},
|
||||
[ADMIN_PATHS.ADD_CONNECTOR]: {
|
||||
icon: SvgUploadCloud,
|
||||
title: "Add Connector",
|
||||
sidebarLabel: "Add Connector",
|
||||
},
|
||||
[ADMIN_PATHS.DOCUMENT_SETS]: {
|
||||
icon: SvgFolder,
|
||||
title: "Document Sets",
|
||||
sidebarLabel: "Document Sets",
|
||||
},
|
||||
[ADMIN_PATHS.DOCUMENT_EXPLORER]: {
|
||||
icon: SvgZoomIn,
|
||||
title: "Document Explorer",
|
||||
sidebarLabel: "Explorer",
|
||||
},
|
||||
[ADMIN_PATHS.DOCUMENT_FEEDBACK]: {
|
||||
icon: SvgThumbsUp,
|
||||
title: "Document Feedback",
|
||||
sidebarLabel: "Feedback",
|
||||
},
|
||||
[ADMIN_PATHS.AGENTS]: {
|
||||
icon: SvgOnyxOctagon,
|
||||
title: "Agents",
|
||||
sidebarLabel: "Agents",
|
||||
},
|
||||
[ADMIN_PATHS.SLACK_BOTS]: {
|
||||
icon: SvgSlack,
|
||||
title: "Slack Bots",
|
||||
sidebarLabel: "Slack Bots",
|
||||
},
|
||||
[ADMIN_PATHS.DISCORD_BOTS]: {
|
||||
icon: SvgDiscordMono,
|
||||
title: "Discord Bots",
|
||||
sidebarLabel: "Discord Bots",
|
||||
},
|
||||
[ADMIN_PATHS.MCP_ACTIONS]: {
|
||||
icon: SvgMcp,
|
||||
title: "MCP Actions",
|
||||
sidebarLabel: "MCP Actions",
|
||||
},
|
||||
[ADMIN_PATHS.OPENAPI_ACTIONS]: {
|
||||
icon: SvgActions,
|
||||
title: "OpenAPI Actions",
|
||||
sidebarLabel: "OpenAPI Actions",
|
||||
},
|
||||
[ADMIN_PATHS.STANDARD_ANSWERS]: {
|
||||
icon: SvgClipboard,
|
||||
title: "Standard Answers",
|
||||
sidebarLabel: "Standard Answers",
|
||||
},
|
||||
[ADMIN_PATHS.GROUPS]: {
|
||||
icon: SvgUsers,
|
||||
title: "Manage User Groups",
|
||||
sidebarLabel: "Groups",
|
||||
},
|
||||
[ADMIN_PATHS.CHAT_PREFERENCES]: {
|
||||
icon: SvgBubbleText,
|
||||
title: "Chat Preferences",
|
||||
sidebarLabel: "Chat Preferences",
|
||||
},
|
||||
[ADMIN_PATHS.LLM_MODELS]: {
|
||||
icon: SvgCpu,
|
||||
title: "LLM Models",
|
||||
sidebarLabel: "LLM Models",
|
||||
},
|
||||
[ADMIN_PATHS.WEB_SEARCH]: {
|
||||
icon: SvgGlobe,
|
||||
title: "Web Search",
|
||||
sidebarLabel: "Web Search",
|
||||
},
|
||||
[ADMIN_PATHS.IMAGE_GENERATION]: {
|
||||
icon: SvgImage,
|
||||
title: "Image Generation",
|
||||
sidebarLabel: "Image Generation",
|
||||
},
|
||||
[ADMIN_PATHS.CODE_INTERPRETER]: {
|
||||
icon: SvgTerminal,
|
||||
title: "Code Interpreter",
|
||||
sidebarLabel: "Code Interpreter",
|
||||
},
|
||||
[ADMIN_PATHS.SEARCH_SETTINGS]: {
|
||||
icon: SvgSearch,
|
||||
title: "Search Settings",
|
||||
sidebarLabel: "Search Settings",
|
||||
},
|
||||
[ADMIN_PATHS.DOCUMENT_PROCESSING]: {
|
||||
icon: SvgFileText,
|
||||
title: "Document Processing",
|
||||
sidebarLabel: "Document Processing",
|
||||
},
|
||||
[ADMIN_PATHS.KNOWLEDGE_GRAPH]: {
|
||||
icon: SvgNetworkGraph,
|
||||
title: "Knowledge Graph",
|
||||
sidebarLabel: "Knowledge Graph",
|
||||
},
|
||||
[ADMIN_PATHS.USERS]: {
|
||||
icon: SvgUser,
|
||||
title: "Manage Users",
|
||||
sidebarLabel: "Users",
|
||||
},
|
||||
[ADMIN_PATHS.API_KEYS]: {
|
||||
icon: SvgKey,
|
||||
title: "API Keys",
|
||||
sidebarLabel: "API Keys",
|
||||
},
|
||||
[ADMIN_PATHS.TOKEN_RATE_LIMITS]: {
|
||||
icon: SvgShield,
|
||||
title: "Token Rate Limits",
|
||||
sidebarLabel: "Token Rate Limits",
|
||||
},
|
||||
[ADMIN_PATHS.USAGE]: {
|
||||
icon: SvgActivity,
|
||||
title: "Usage Statistics",
|
||||
sidebarLabel: "Usage Statistics",
|
||||
},
|
||||
[ADMIN_PATHS.QUERY_HISTORY]: {
|
||||
icon: SvgServer,
|
||||
title: "Query History",
|
||||
sidebarLabel: "Query History",
|
||||
},
|
||||
[ADMIN_PATHS.CUSTOM_ANALYTICS]: {
|
||||
icon: SvgBarChart,
|
||||
title: "Custom Analytics",
|
||||
sidebarLabel: "Custom Analytics",
|
||||
},
|
||||
[ADMIN_PATHS.THEME]: {
|
||||
icon: SvgPaintBrush,
|
||||
title: "Appearance & Theming",
|
||||
sidebarLabel: "Appearance & Theming",
|
||||
},
|
||||
[ADMIN_PATHS.BILLING]: {
|
||||
icon: SvgWallet,
|
||||
title: "Plans & Billing",
|
||||
sidebarLabel: "Plans & Billing",
|
||||
},
|
||||
[ADMIN_PATHS.INDEX_MIGRATION]: {
|
||||
icon: SvgArrowExchange,
|
||||
title: "Document Index Migration",
|
||||
sidebarLabel: "Document Index Migration",
|
||||
},
|
||||
[ADMIN_PATHS.DEBUG]: {
|
||||
icon: SvgDownload,
|
||||
title: "Debug Logs",
|
||||
sidebarLabel: "Debug Logs",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper that converts a route config entry into the `{ name, icon, link }`
|
||||
* shape expected by the sidebar. Extra fields (e.g. `error`) can be spread in.
|
||||
*/
|
||||
export function sidebarItem(path: string) {
|
||||
const config = ADMIN_ROUTE_CONFIG[path]!;
|
||||
return { name: config.sidebarLabel, icon: config.icon, link: path };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { VisionProvider } from "@/interfaces/llm";
|
||||
import { LLMProviderResponse, VisionProvider } from "@/interfaces/llm";
|
||||
import { LLM_ADMIN_URL } from "@/lib/llmConfig/constants";
|
||||
|
||||
export async function fetchVisionProviders(): Promise<VisionProvider[]> {
|
||||
const response = await fetch("/api/admin/llm/vision-providers", {
|
||||
const response = await fetch(`${LLM_ADMIN_URL}/vision-providers`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
@@ -11,24 +12,24 @@ export async function fetchVisionProviders(): Promise<VisionProvider[]> {
|
||||
`Failed to fetch vision providers: ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
const data = (await response.json()) as LLMProviderResponse<VisionProvider>;
|
||||
return data.providers;
|
||||
}
|
||||
|
||||
export async function setDefaultVisionProvider(
|
||||
providerId: number,
|
||||
visionModel: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`/api/admin/llm/provider/${providerId}/default-vision?vision_model=${encodeURIComponent(
|
||||
visionModel
|
||||
)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
const response = await fetch(`${LLM_ADMIN_URL}/default-vision`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider_id: providerId,
|
||||
model_name: visionModel,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
|
||||
@@ -140,14 +140,13 @@ export default function Divider({
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-1",
|
||||
!dividerLine && (foldable ? "pl-1.5" : "px-2")
|
||||
!dividerLine && (foldable ? "pl-1.5" : "px-2"),
|
||||
dividerLine && !foldable && "pl-1.5"
|
||||
)}
|
||||
>
|
||||
{/* Left divider line */}
|
||||
{dividerLine && (
|
||||
<div
|
||||
className={cn("h-px bg-border-01", foldable ? "w-1.5" : "w-2")}
|
||||
/>
|
||||
{/* Left divider line (only for foldable dividers) */}
|
||||
{dividerLine && foldable && (
|
||||
<div className={cn("h-px bg-border-01 w-1.5")} />
|
||||
)}
|
||||
|
||||
{/* Content container */}
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
ModelConfiguration,
|
||||
WellKnownLLMProviderDescriptor,
|
||||
} from "@/interfaces/llm";
|
||||
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
|
||||
import {
|
||||
LLM_ADMIN_URL,
|
||||
LLM_PROVIDERS_ADMIN_URL,
|
||||
} from "@/lib/llmConfig/constants";
|
||||
import { OnboardingActions, OnboardingState } from "../types";
|
||||
import { APIFormFieldState } from "@/refresh-components/form/types";
|
||||
import {
|
||||
@@ -225,13 +228,33 @@ export function OnboardingFormWrapper<T extends Record<string, any>>({
|
||||
try {
|
||||
const newLlmProvider = await response.json();
|
||||
if (newLlmProvider?.id != null) {
|
||||
const setDefaultResponse = await fetch(
|
||||
`${LLM_PROVIDERS_ADMIN_URL}/${newLlmProvider.id}/default`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!setDefaultResponse.ok) {
|
||||
const err = await setDefaultResponse.json().catch(() => ({}));
|
||||
console.error("Failed to set provider as default", err?.detail);
|
||||
const defaultModelName =
|
||||
(payload as Record<string, any>).default_model_name ??
|
||||
(payload as Record<string, any>).model_configurations?.[0]?.name ??
|
||||
"";
|
||||
|
||||
if (!defaultModelName) {
|
||||
console.error(
|
||||
"No model name available to set as default — skipping set-default call"
|
||||
);
|
||||
} else {
|
||||
const setDefaultResponse = await fetch(`${LLM_ADMIN_URL}/default`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider_id: newLlmProvider.id,
|
||||
model_name: defaultModelName,
|
||||
}),
|
||||
});
|
||||
if (!setDefaultResponse.ok) {
|
||||
const err = await setDefaultResponse.json().catch(() => ({}));
|
||||
setErrorMessage(
|
||||
err?.detail ?? "Failed to set provider as default"
|
||||
);
|
||||
setApiStatus("error");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
|
||||
107
web/src/refresh-components/table/ColumnVisibilityPopover.tsx
Normal file
107
web/src/refresh-components/table/ColumnVisibilityPopover.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
type Table,
|
||||
type ColumnDef,
|
||||
type RowData,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgColumn, SvgCheck } from "@opal/icons";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Divider from "@/refresh-components/Divider";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Popover UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ColumnVisibilityPopoverProps<TData extends RowData = RowData> {
|
||||
table: Table<TData>;
|
||||
columnVisibility: VisibilityState;
|
||||
size?: "regular" | "small";
|
||||
}
|
||||
|
||||
function ColumnVisibilityPopover<TData extends RowData>({
|
||||
table,
|
||||
columnVisibility,
|
||||
size = "regular",
|
||||
}: ColumnVisibilityPopoverProps<TData>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hideableColumns = useMemo(
|
||||
() => table.getAllLeafColumns().filter((col) => col.getCanHide()),
|
||||
[table]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
icon={SvgColumn}
|
||||
transient={open}
|
||||
size={size === "small" ? "sm" : "md"}
|
||||
prominence="internal"
|
||||
tooltip="Columns"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content width="lg" align="end" side="bottom">
|
||||
<Divider showTitle text="Shown Columns" />
|
||||
<Popover.Menu>
|
||||
{hideableColumns.map((column) => {
|
||||
const isVisible = columnVisibility[column.id] !== false;
|
||||
const label =
|
||||
typeof column.columnDef.header === "string"
|
||||
? column.columnDef.header
|
||||
: column.id;
|
||||
|
||||
return (
|
||||
<LineItem
|
||||
key={column.id}
|
||||
selected={isVisible}
|
||||
emphasized
|
||||
rightChildren={isVisible ? <SvgCheck size={16} /> : undefined}
|
||||
onClick={() => {
|
||||
column.toggleVisibility();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</Popover.Menu>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definition factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateColumnVisibilityColumnOptions {
|
||||
size?: "regular" | "small";
|
||||
}
|
||||
|
||||
function createColumnVisibilityColumn<TData>(
|
||||
options?: CreateColumnVisibilityColumnOptions
|
||||
): ColumnDef<TData, unknown> {
|
||||
return {
|
||||
id: "__columnVisibility",
|
||||
size: 44,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
header: ({ table }) => (
|
||||
<ColumnVisibilityPopover
|
||||
table={table}
|
||||
columnVisibility={table.getState().columnVisibility}
|
||||
size={options?.size}
|
||||
/>
|
||||
),
|
||||
cell: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
export { ColumnVisibilityPopover, createColumnVisibilityColumn };
|
||||
429
web/src/refresh-components/table/DataTable.tsx
Normal file
429
web/src/refresh-components/table/DataTable.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import useDataTable, { toOnyxSortDirection } from "@/hooks/useDataTable";
|
||||
import useColumnWidths from "@/hooks/useColumnWidths";
|
||||
import useDraggableRows from "@/hooks/useDraggableRows";
|
||||
import Table from "@/refresh-components/table/Table";
|
||||
import TableHeader from "@/refresh-components/table/TableHeader";
|
||||
import TableBody from "@/refresh-components/table/TableBody";
|
||||
import TableRow from "@/refresh-components/table/TableRow";
|
||||
import TableHead from "@/refresh-components/table/TableHead";
|
||||
import TableCell from "@/refresh-components/table/TableCell";
|
||||
import TableQualifier from "@/refresh-components/table/TableQualifier";
|
||||
import DragOverlayRow from "@/refresh-components/table/DragOverlayRow";
|
||||
import Footer from "@/refresh-components/table/Footer";
|
||||
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
|
||||
import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibilityPopover";
|
||||
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
|
||||
import type { WidthConfig } from "@/hooks/useColumnWidths";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type {
|
||||
DataTableProps,
|
||||
OnyxColumnDef,
|
||||
OnyxQualifierColumn,
|
||||
OnyxActionsColumn,
|
||||
} from "@/refresh-components/table/dataTableTypes";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: resolve size-dependent widths and build TanStack columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProcessedColumns<TData> {
|
||||
tanstackColumns: ColumnDef<TData, any>[];
|
||||
widthConfig: WidthConfig;
|
||||
qualifierColumn: OnyxQualifierColumn<TData> | null;
|
||||
actionsColumn: OnyxActionsColumn<TData> | null;
|
||||
/** Map from column ID → OnyxColumnDef for dispatch in render loops. */
|
||||
columnKindMap: Map<string, OnyxColumnDef<TData>>;
|
||||
}
|
||||
|
||||
function processColumns<TData>(
|
||||
columns: OnyxColumnDef<TData>[],
|
||||
size: TableSize
|
||||
): ProcessedColumns<TData> {
|
||||
const tanstackColumns: ColumnDef<TData, any>[] = [];
|
||||
const fixedColumnIds = new Set<string>();
|
||||
const columnWeights: Record<string, number> = {};
|
||||
const columnMinWidths: Record<string, number> = {};
|
||||
const columnKindMap = new Map<string, OnyxColumnDef<TData>>();
|
||||
let qualifierColumn: OnyxQualifierColumn<TData> | null = null;
|
||||
let actionsColumn: OnyxActionsColumn<TData> | null = null;
|
||||
|
||||
for (const col of columns) {
|
||||
const resolvedWidth =
|
||||
typeof col.width === "function" ? col.width(size) : col.width;
|
||||
|
||||
// Set def.size so TanStack has a reasonable internal value
|
||||
(col.def as ColumnDef<TData, any>).size =
|
||||
"fixed" in resolvedWidth ? resolvedWidth.fixed : resolvedWidth.weight;
|
||||
|
||||
tanstackColumns.push(col.def);
|
||||
|
||||
const id =
|
||||
col.def.id ??
|
||||
(col.def as ColumnDef<TData, any> & { accessorKey?: string }).accessorKey;
|
||||
|
||||
if (id) {
|
||||
columnKindMap.set(id, col);
|
||||
if ("fixed" in resolvedWidth) {
|
||||
fixedColumnIds.add(id);
|
||||
} else {
|
||||
columnWeights[id] = resolvedWidth.weight;
|
||||
columnMinWidths[id] = resolvedWidth.minWidth ?? 50;
|
||||
}
|
||||
}
|
||||
|
||||
if (col.kind === "qualifier") qualifierColumn = col;
|
||||
if (col.kind === "actions") actionsColumn = col;
|
||||
}
|
||||
|
||||
return {
|
||||
tanstackColumns,
|
||||
widthConfig: { fixedColumnIds, columnWeights, columnMinWidths },
|
||||
qualifierColumn,
|
||||
actionsColumn,
|
||||
columnKindMap,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataTable component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Config-driven table component that wires together `useDataTable`,
|
||||
* `useColumnWidths`, and `useDraggableRows` automatically.
|
||||
*
|
||||
* Full flexibility via the column definitions from `createTableColumns()`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const tc = createTableColumns<TeamMember>();
|
||||
* const columns = [
|
||||
* tc.qualifier({ content: "avatar-user", getInitials: (r) => r.initials }),
|
||||
* tc.column("name", { header: "Name", weight: 23, minWidth: 120 }),
|
||||
* tc.column("email", { header: "Email", weight: 28 }),
|
||||
* tc.actions(),
|
||||
* ];
|
||||
*
|
||||
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
|
||||
* ```
|
||||
*/
|
||||
export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
pageSize = 10,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
draggable,
|
||||
footer,
|
||||
size = "regular",
|
||||
onRowClick,
|
||||
} = props;
|
||||
|
||||
// 1. Process columns (memoized on columns + size)
|
||||
const {
|
||||
tanstackColumns,
|
||||
widthConfig,
|
||||
qualifierColumn,
|
||||
actionsColumn,
|
||||
columnKindMap,
|
||||
} = useMemo(() => processColumns(columns, size), [columns, size]);
|
||||
|
||||
// 2. Call useDataTable
|
||||
const {
|
||||
table,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
setPage,
|
||||
pageSize: resolvedPageSize,
|
||||
selectionState,
|
||||
selectedCount,
|
||||
clearSelection,
|
||||
toggleAllPageRowsSelected,
|
||||
isAllPageRowsSelected,
|
||||
} = useDataTable({
|
||||
data,
|
||||
columns: tanstackColumns,
|
||||
pageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
});
|
||||
|
||||
// 3. Call useColumnWidths
|
||||
const { containerRef, columnWidths, createResizeHandler } = useColumnWidths({
|
||||
headers: table.getHeaderGroups()[0]?.headers ?? [],
|
||||
...widthConfig,
|
||||
});
|
||||
|
||||
// 4. Call useDraggableRows (conditional)
|
||||
const draggableReturn = useDraggableRows({
|
||||
data,
|
||||
getRowId: draggable?.getRowId ?? (() => ""),
|
||||
enabled: !!draggable && table.getState().sorting.length === 0,
|
||||
onReorder: draggable?.onReorder,
|
||||
});
|
||||
|
||||
const hasDraggable = !!draggable;
|
||||
const rowVariant = hasDraggable ? "table" : "list";
|
||||
|
||||
// Determine if qualifier exists for footer
|
||||
const hasQualifier = !!qualifierColumn;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto" ref={containerRef}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, headerIndex) => {
|
||||
const colDef = columnKindMap.get(header.id);
|
||||
|
||||
// Qualifier header
|
||||
if (colDef?.kind === "qualifier") {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
width={columnWidths[header.id]}
|
||||
>
|
||||
<TableQualifier
|
||||
content={
|
||||
qualifierColumn?.qualifierContentType ?? "simple"
|
||||
}
|
||||
selectable
|
||||
selected={isAllPageRowsSelected}
|
||||
onSelectChange={(checked) =>
|
||||
toggleAllPageRowsSelected(checked)
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
// Actions header
|
||||
if (colDef?.kind === "actions") {
|
||||
const actionsDef = colDef as OnyxActionsColumn<TData>;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
width={columnWidths[header.id]}
|
||||
sticky
|
||||
bottomBorder={false}
|
||||
>
|
||||
<div className="flex flex-row items-center">
|
||||
{actionsDef.showColumnVisibility !== false && (
|
||||
<ColumnVisibilityPopover
|
||||
table={table}
|
||||
columnVisibility={
|
||||
table.getState().columnVisibility
|
||||
}
|
||||
size={size}
|
||||
/>
|
||||
)}
|
||||
{actionsDef.showSorting !== false && (
|
||||
<SortingPopover
|
||||
table={table}
|
||||
sorting={table.getState().sorting}
|
||||
size={size}
|
||||
footerText={actionsDef.sortingFooterText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
// Data / Display header
|
||||
const canSort = header.column.getCanSort();
|
||||
const sortDir = header.column.getIsSorted();
|
||||
const nextHeader = headerGroup.headers[headerIndex + 1];
|
||||
const canResize =
|
||||
header.column.getCanResize() &&
|
||||
!!nextHeader &&
|
||||
!widthConfig.fixedColumnIds.has(nextHeader.id);
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
width={columnWidths[header.id]}
|
||||
sorted={
|
||||
canSort ? toOnyxSortDirection(sortDir) : undefined
|
||||
}
|
||||
onSort={
|
||||
canSort
|
||||
? () => header.column.toggleSorting()
|
||||
: undefined
|
||||
}
|
||||
resizable={canResize}
|
||||
onResizeStart={
|
||||
canResize
|
||||
? createResizeHandler(header.id, nextHeader.id)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody
|
||||
dndSortable={hasDraggable ? draggableReturn : undefined}
|
||||
renderDragOverlay={
|
||||
hasDraggable
|
||||
? (activeId) => {
|
||||
const row = table
|
||||
.getRowModel()
|
||||
.rows.find(
|
||||
(r) => draggable!.getRowId(r.original) === activeId
|
||||
);
|
||||
if (!row) return null;
|
||||
return <DragOverlayRow row={row} variant={rowVariant} />;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const rowId = hasDraggable
|
||||
? draggable!.getRowId(row.original)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
variant={rowVariant}
|
||||
sortableId={rowId}
|
||||
selected={row.getIsSelected()}
|
||||
onClick={() => {
|
||||
if (onRowClick) {
|
||||
onRowClick(row.original);
|
||||
} else {
|
||||
row.toggleSelected();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const cellColDef = columnKindMap.get(cell.column.id);
|
||||
|
||||
// Qualifier cell
|
||||
if (cellColDef?.kind === "qualifier") {
|
||||
const qDef = cellColDef as OnyxQualifierColumn<TData>;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TableQualifier
|
||||
content={qDef.content}
|
||||
initials={qDef.getInitials?.(row.original)}
|
||||
icon={qDef.getIcon?.(row.original)}
|
||||
imageSrc={qDef.getImageSrc?.(row.original)}
|
||||
selectable
|
||||
selected={row.getIsSelected()}
|
||||
onSelectChange={(checked) => {
|
||||
row.toggleSelected(checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
// Actions cell
|
||||
if (cellColDef?.kind === "actions") {
|
||||
return (
|
||||
<TableCell key={cell.id} sticky>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
// Data / Display cell
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{footer && renderFooter()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
if (!footer) return null;
|
||||
|
||||
if (footer.mode === "selection") {
|
||||
return (
|
||||
<Footer
|
||||
mode="selection"
|
||||
multiSelect={footer.multiSelect !== false}
|
||||
selectionState={selectionState}
|
||||
selectedCount={selectedCount}
|
||||
showQualifier={hasQualifier}
|
||||
qualifierChecked={isAllPageRowsSelected}
|
||||
onQualifierChange={(checked) => toggleAllPageRowsSelected(checked)}
|
||||
onClear={footer.onClear ?? clearSelection}
|
||||
onView={footer.onView}
|
||||
pageSize={resolvedPageSize}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary mode
|
||||
const rangeStart = (currentPage - 1) * resolvedPageSize + 1;
|
||||
const rangeEnd = Math.min(currentPage * resolvedPageSize, totalItems);
|
||||
|
||||
return (
|
||||
<Footer
|
||||
mode="summary"
|
||||
pageSize={resolvedPageSize}
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in TableSizeProvider when not "regular"
|
||||
if (size !== "regular") {
|
||||
return <TableSizeProvider size={size}>{renderContent()}</TableSizeProvider>;
|
||||
}
|
||||
|
||||
return renderContent();
|
||||
}
|
||||
36
web/src/refresh-components/table/DragOverlayRow.tsx
Normal file
36
web/src/refresh-components/table/DragOverlayRow.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { memo } from "react";
|
||||
import { type Row, flexRender } from "@tanstack/react-table";
|
||||
import TableRow from "@/refresh-components/table/TableRow";
|
||||
import TableCell from "@/refresh-components/table/TableCell";
|
||||
|
||||
interface DragOverlayRowProps<TData> {
|
||||
row: Row<TData>;
|
||||
variant?: "table" | "list";
|
||||
}
|
||||
|
||||
function DragOverlayRowInner<TData>({
|
||||
row,
|
||||
variant,
|
||||
}: DragOverlayRowProps<TData>) {
|
||||
return (
|
||||
<table
|
||||
className="min-w-full border-collapse"
|
||||
style={{ tableLayout: "fixed" }}
|
||||
>
|
||||
<tbody>
|
||||
<TableRow variant={variant} selected={row.getIsSelected()}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} width={cell.column.getSize()}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
const DragOverlayRow = memo(DragOverlayRowInner) as typeof DragOverlayRowInner;
|
||||
|
||||
export default DragOverlayRow;
|
||||
export type { DragOverlayRowProps };
|
||||
287
web/src/refresh-components/table/Footer.tsx
Normal file
287
web/src/refresh-components/table/Footer.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import Checkbox from "@/refresh-components/inputs/Checkbox";
|
||||
import { Button } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Pagination from "@/refresh-components/table/Pagination";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import { SvgEye, SvgXCircle } from "@opal/icons";
|
||||
|
||||
type SelectionState = "none" | "partial" | "all";
|
||||
|
||||
/**
|
||||
* Footer mode for tables with selectable rows.
|
||||
* Displays a selection message on the left (with optional view/clear actions)
|
||||
* and a `count`-type pagination on the right.
|
||||
*/
|
||||
interface FooterSelectionModeProps {
|
||||
mode: "selection";
|
||||
/** Whether the table supports selecting multiple rows. */
|
||||
multiSelect: boolean;
|
||||
/** Current selection state: `"none"`, `"partial"`, or `"all"`. */
|
||||
selectionState: SelectionState;
|
||||
/** Number of currently selected items. */
|
||||
selectedCount: number;
|
||||
/** When `true`, renders a qualifier checkbox on the far left. */
|
||||
showQualifier?: boolean;
|
||||
/** Controlled checked state for the qualifier checkbox. */
|
||||
qualifierChecked?: boolean;
|
||||
/** Called when the qualifier checkbox value changes. */
|
||||
onQualifierChange?: (checked: boolean) => void;
|
||||
/** If provided, renders a "View" icon button when items are selected. */
|
||||
onView?: () => void;
|
||||
/** If provided, renders a "Clear" icon button when items are selected. */
|
||||
onClear?: () => void;
|
||||
/** Number of items displayed per page. */
|
||||
pageSize: number;
|
||||
/** Total number of items across all pages. */
|
||||
totalItems: number;
|
||||
/** The 1-based current page number. */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
|
||||
size?: TableSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer mode for read-only tables (no row selection).
|
||||
* Displays "Showing X~Y of Z" on the left and a `list`-type pagination
|
||||
* on the right.
|
||||
*/
|
||||
interface FooterSummaryModeProps {
|
||||
mode: "summary";
|
||||
/** Number of items displayed per page. */
|
||||
pageSize: number;
|
||||
/** First item number in the current page (e.g. `1`). */
|
||||
rangeStart: number;
|
||||
/** Last item number in the current page (e.g. `25`). */
|
||||
rangeEnd: number;
|
||||
/** Total number of items across all pages. */
|
||||
totalItems: number;
|
||||
/** When `true`, renders a qualifier checkbox on the far left. */
|
||||
showQualifier?: boolean;
|
||||
/** Controlled checked state for the qualifier checkbox. */
|
||||
qualifierChecked?: boolean;
|
||||
/** Called when the qualifier checkbox value changes. */
|
||||
onQualifierChange?: (checked: boolean) => void;
|
||||
/** The 1-based current page number. */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
|
||||
size?: TableSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of footer modes.
|
||||
* Use `mode: "selection"` for tables with selectable rows, or
|
||||
* `mode: "summary"` for read-only tables.
|
||||
*/
|
||||
export type FooterProps = FooterSelectionModeProps | FooterSummaryModeProps;
|
||||
|
||||
function getSelectionMessage(
|
||||
state: SelectionState,
|
||||
multi: boolean,
|
||||
count: number
|
||||
): string {
|
||||
if (state === "none") {
|
||||
return multi ? "Select items to continue" : "Select an item to continue";
|
||||
}
|
||||
if (!multi) return "Item selected";
|
||||
return `${count} items selected`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table footer combining status information on the left with pagination on the
|
||||
* right. Use `mode: "selection"` for tables with selectable rows, or
|
||||
* `mode: "summary"` for read-only tables.
|
||||
*/
|
||||
export default function Footer(props: FooterProps) {
|
||||
const contextSize = useTableSize();
|
||||
const resolvedSize = props.size ?? contextSize;
|
||||
const isSmall = resolvedSize === "small";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"table-footer",
|
||||
"flex w-full items-center justify-between border-t border-border-01",
|
||||
props.className
|
||||
)}
|
||||
data-size={resolvedSize}
|
||||
>
|
||||
{/* Left side */}
|
||||
<div className="flex items-center gap-1 px-1">
|
||||
{props.showQualifier && (
|
||||
<div className="flex items-center px-1">
|
||||
<Checkbox
|
||||
checked={props.qualifierChecked}
|
||||
indeterminate={
|
||||
props.mode === "selection" && props.selectionState === "partial"
|
||||
}
|
||||
onCheckedChange={props.onQualifierChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.mode === "selection" ? (
|
||||
<SelectionLeft
|
||||
selectionState={props.selectionState}
|
||||
multiSelect={props.multiSelect}
|
||||
selectedCount={props.selectedCount}
|
||||
onView={props.onView}
|
||||
onClear={props.onClear}
|
||||
isSmall={isSmall}
|
||||
/>
|
||||
) : (
|
||||
<SummaryLeft
|
||||
rangeStart={props.rangeStart}
|
||||
rangeEnd={props.rangeEnd}
|
||||
totalItems={props.totalItems}
|
||||
isSmall={isSmall}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 px-1 py-2">
|
||||
{props.mode === "selection" ? (
|
||||
<Pagination
|
||||
type="count"
|
||||
pageSize={props.pageSize}
|
||||
totalItems={props.totalItems}
|
||||
currentPage={props.currentPage}
|
||||
totalPages={props.totalPages}
|
||||
onPageChange={props.onPageChange}
|
||||
showUnits
|
||||
size={isSmall ? "sm" : "md"}
|
||||
/>
|
||||
) : (
|
||||
<Pagination
|
||||
type="list"
|
||||
currentPage={props.currentPage}
|
||||
totalPages={props.totalPages}
|
||||
onPageChange={props.onPageChange}
|
||||
size={isSmall ? "md" : "lg"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectionLeftProps {
|
||||
selectionState: SelectionState;
|
||||
multiSelect: boolean;
|
||||
selectedCount: number;
|
||||
onView?: () => void;
|
||||
onClear?: () => void;
|
||||
isSmall: boolean;
|
||||
}
|
||||
|
||||
function SelectionLeft({
|
||||
selectionState,
|
||||
multiSelect,
|
||||
selectedCount,
|
||||
onView,
|
||||
onClear,
|
||||
isSmall,
|
||||
}: SelectionLeftProps) {
|
||||
const message = getSelectionMessage(
|
||||
selectionState,
|
||||
multiSelect,
|
||||
selectedCount
|
||||
);
|
||||
const hasSelection = selectionState !== "none";
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1 items-center justify-center w-fit flex-shrink-0 h-fit px-1">
|
||||
{isSmall ? (
|
||||
<Text
|
||||
secondaryAction={hasSelection}
|
||||
secondaryBody={!hasSelection}
|
||||
text03
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
) : (
|
||||
<Text mainUiBody={hasSelection} mainUiMuted={!hasSelection} text03>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hasSelection && (
|
||||
<div className="flex flex-row items-center w-fit flex-shrink-0 h-fit">
|
||||
{onView && (
|
||||
<Button
|
||||
icon={SvgEye}
|
||||
onClick={onView}
|
||||
tooltip="View"
|
||||
size="md"
|
||||
prominence="tertiary"
|
||||
/>
|
||||
)}
|
||||
{onClear && (
|
||||
<Button
|
||||
icon={SvgXCircle}
|
||||
onClick={onClear}
|
||||
tooltip="Clear selection"
|
||||
size="md"
|
||||
prominence="tertiary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryLeftProps {
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
totalItems: number;
|
||||
isSmall: boolean;
|
||||
}
|
||||
|
||||
function SummaryLeft({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
totalItems,
|
||||
isSmall,
|
||||
}: SummaryLeftProps) {
|
||||
return (
|
||||
<div className="flex flex-row gap-1 items-center w-fit h-fit px-1">
|
||||
{isSmall ? (
|
||||
<Text secondaryBody text03>
|
||||
Showing{" "}
|
||||
<Text as="span" secondaryMono text03>
|
||||
{rangeStart}~{rangeEnd}
|
||||
</Text>{" "}
|
||||
of{" "}
|
||||
<Text as="span" secondaryMono text03>
|
||||
{totalItems}
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text mainUiMuted text03>
|
||||
Showing{" "}
|
||||
<Text as="span" mainUiMono text03>
|
||||
{rangeStart}~{rangeEnd}
|
||||
</Text>{" "}
|
||||
of{" "}
|
||||
<Text as="span" mainUiMono text03>
|
||||
{totalItems}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
380
web/src/refresh-components/table/Pagination.tsx
Normal file
380
web/src/refresh-components/table/Pagination.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import { Button } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SvgChevronLeft, SvgChevronRight } from "@opal/icons";
|
||||
|
||||
type PaginationSize = "lg" | "md" | "sm";
|
||||
|
||||
/**
|
||||
* Minimal page navigation showing `currentPage / totalPages` with prev/next arrows.
|
||||
* Use when you only need simple forward/backward navigation.
|
||||
*/
|
||||
interface SimplePaginationProps {
|
||||
type: "simple";
|
||||
/** The 1-based current page number. */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** When `true`, displays the word "pages" after the page indicator. */
|
||||
showUnits?: boolean;
|
||||
/** When `false`, hides the page indicator between the prev/next arrows. Defaults to `true`. */
|
||||
showPageIndicator?: boolean;
|
||||
/** Controls button and text sizing. Defaults to `"lg"`. */
|
||||
size?: PaginationSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item-count pagination showing `currentItems of totalItems` with optional page
|
||||
* controls and a "Go to" button. Use inside table footers that need to communicate
|
||||
* how many items the user is viewing.
|
||||
*/
|
||||
interface CountPaginationProps {
|
||||
type: "count";
|
||||
/** Number of items displayed per page. Used to compute the visible range. */
|
||||
pageSize: number;
|
||||
/** Total number of items across all pages. */
|
||||
totalItems: number;
|
||||
/** The 1-based current page number. */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** When `false`, hides the page number between the prev/next arrows (arrows still visible). Defaults to `true`. */
|
||||
showPageIndicator?: boolean;
|
||||
/** When `true`, renders a "Go to" button. Requires `onGoTo`. */
|
||||
showGoTo?: boolean;
|
||||
/** Callback invoked when the "Go to" button is clicked. */
|
||||
onGoTo?: () => void;
|
||||
/** When `true`, displays the word "items" after the total count. */
|
||||
showUnits?: boolean;
|
||||
/** Controls button and text sizing. Defaults to `"lg"`. */
|
||||
size?: PaginationSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered page-list pagination with clickable page buttons and ellipsis
|
||||
* truncation for large page counts. Does not support `"sm"` size.
|
||||
*/
|
||||
interface ListPaginationProps {
|
||||
type: "list";
|
||||
/** The 1-based current page number. */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** When `false`, hides the page buttons between the prev/next arrows. Defaults to `true`. */
|
||||
showPageIndicator?: boolean;
|
||||
/** Controls button and text sizing. Defaults to `"lg"`. Only `"lg"` and `"md"` are supported. */
|
||||
size?: Exclude<PaginationSize, "sm">;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of all pagination variants.
|
||||
* Use the `type` prop to select between `"simple"`, `"count"`, and `"list"`.
|
||||
*/
|
||||
export type PaginationProps =
|
||||
| SimplePaginationProps
|
||||
| CountPaginationProps
|
||||
| ListPaginationProps;
|
||||
|
||||
function getPageNumbers(currentPage: number, totalPages: number) {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxPagesToShow = 7;
|
||||
|
||||
if (totalPages <= maxPagesToShow) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
|
||||
let startPage = Math.max(2, currentPage - 1);
|
||||
let endPage = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
if (currentPage <= 3) {
|
||||
endPage = 5;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
startPage = totalPages - 4;
|
||||
}
|
||||
|
||||
if (startPage > 2) {
|
||||
pages.push("start-ellipsis");
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (endPage < totalPages - 1) {
|
||||
pages.push("end-ellipsis");
|
||||
}
|
||||
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function sizedTextProps(isSmall: boolean, variant: "mono" | "muted") {
|
||||
if (variant === "mono") {
|
||||
return isSmall ? { secondaryMono: true } : { mainUiMono: true };
|
||||
}
|
||||
return isSmall ? { secondaryBody: true } : { mainUiMuted: true };
|
||||
}
|
||||
|
||||
interface NavButtonsProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
size: PaginationSize;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function NavButtons({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
size,
|
||||
children,
|
||||
}: NavButtonsProps) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon={SvgChevronLeft}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
size={size}
|
||||
prominence="tertiary"
|
||||
/>
|
||||
{children}
|
||||
<Button
|
||||
icon={SvgChevronRight}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
size={size}
|
||||
prominence="tertiary"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Table pagination component with three variants: `simple`, `count`, and `list`.
|
||||
* Pass the `type` prop to select the variant, and the component will render the
|
||||
* appropriate UI.
|
||||
*/
|
||||
export default function Pagination(props: PaginationProps) {
|
||||
switch (props.type) {
|
||||
case "simple":
|
||||
return <SimplePaginationInner {...props} />;
|
||||
case "count":
|
||||
return <CountPaginationInner {...props} />;
|
||||
case "list":
|
||||
return <ListPaginationInner {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function SimplePaginationInner({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
showUnits,
|
||||
showPageIndicator = true,
|
||||
size = "lg",
|
||||
className,
|
||||
}: SimplePaginationProps) {
|
||||
const isSmall = size === "sm";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<NavButtons
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
size={size}
|
||||
>
|
||||
{showPageIndicator && (
|
||||
<>
|
||||
<Text {...sizedTextProps(isSmall, "mono")} text03>
|
||||
{currentPage}
|
||||
<Text as="span" {...sizedTextProps(isSmall, "muted")} text03>
|
||||
/
|
||||
</Text>
|
||||
{totalPages}
|
||||
</Text>
|
||||
{showUnits && (
|
||||
<Text {...sizedTextProps(isSmall, "muted")} text03>
|
||||
pages
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavButtons>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountPaginationInner({
|
||||
pageSize,
|
||||
totalItems,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
showPageIndicator = true,
|
||||
showGoTo,
|
||||
onGoTo,
|
||||
showUnits,
|
||||
size = "lg",
|
||||
className,
|
||||
}: CountPaginationProps) {
|
||||
const isSmall = size === "sm";
|
||||
const rangeStart = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||
const rangeEnd = Math.min(currentPage * pageSize, totalItems);
|
||||
const currentItems = `${rangeStart}~${rangeEnd}`;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<Text {...sizedTextProps(isSmall, "mono")} text03>
|
||||
{currentItems}
|
||||
</Text>
|
||||
<Text {...sizedTextProps(isSmall, "muted")} text03>
|
||||
of
|
||||
</Text>
|
||||
<Text {...sizedTextProps(isSmall, "mono")} text03>
|
||||
{totalItems}
|
||||
</Text>
|
||||
{showUnits && (
|
||||
<Text {...sizedTextProps(isSmall, "muted")} text03>
|
||||
items
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<NavButtons
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
size={size}
|
||||
>
|
||||
{showPageIndicator && (
|
||||
<Text {...sizedTextProps(isSmall, "mono")} text03>
|
||||
{currentPage}
|
||||
</Text>
|
||||
)}
|
||||
</NavButtons>
|
||||
|
||||
{showGoTo && onGoTo && (
|
||||
<Button onClick={onGoTo} size={size} prominence="tertiary">
|
||||
Go to
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageNumberIconProps {
|
||||
className?: string;
|
||||
pageNum: number;
|
||||
isActive: boolean;
|
||||
isLarge: boolean;
|
||||
}
|
||||
|
||||
function PageNumberIcon({
|
||||
className: iconClassName,
|
||||
pageNum,
|
||||
isActive,
|
||||
isLarge,
|
||||
}: PageNumberIconProps) {
|
||||
return (
|
||||
<div className={cn(iconClassName, "flex flex-col justify-center")}>
|
||||
{isLarge ? (
|
||||
<Text
|
||||
mainUiBody={isActive}
|
||||
mainUiMuted={!isActive}
|
||||
text04={isActive}
|
||||
text02={!isActive}
|
||||
>
|
||||
{pageNum}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
secondaryAction={isActive}
|
||||
secondaryBody={!isActive}
|
||||
text04={isActive}
|
||||
text02={!isActive}
|
||||
>
|
||||
{pageNum}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListPaginationInner({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
showPageIndicator = true,
|
||||
size = "lg",
|
||||
className,
|
||||
}: ListPaginationProps) {
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||
const isLarge = size === "lg";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<NavButtons
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
size={size}
|
||||
>
|
||||
{showPageIndicator && (
|
||||
<div className="flex items-center">
|
||||
{pageNumbers.map((page) => {
|
||||
if (typeof page === "string") {
|
||||
return (
|
||||
<Text
|
||||
key={page}
|
||||
mainUiMuted={isLarge}
|
||||
secondaryBody={!isLarge}
|
||||
text03
|
||||
>
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const pageNum = page as number;
|
||||
const isActive = pageNum === currentPage;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
size={size}
|
||||
prominence="tertiary"
|
||||
transient={isActive}
|
||||
icon={({ className: iconClassName }) => (
|
||||
<PageNumberIcon
|
||||
className={iconClassName}
|
||||
pageNum={pageNum}
|
||||
isActive={isActive}
|
||||
isLarge={isLarge}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</NavButtons>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
web/src/refresh-components/table/SortingPopover.tsx
Normal file
183
web/src/refresh-components/table/SortingPopover.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
type Table,
|
||||
type ColumnDef,
|
||||
type RowData,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgSort, SvgCheck } from "@opal/icons";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import Divider from "@/refresh-components/Divider";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Popover UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SortingPopoverProps<TData extends RowData = RowData> {
|
||||
table: Table<TData>;
|
||||
sorting: SortingState;
|
||||
size?: "regular" | "small";
|
||||
footerText?: string;
|
||||
ascendingLabel?: string;
|
||||
descendingLabel?: string;
|
||||
}
|
||||
|
||||
function SortingPopover<TData extends RowData>({
|
||||
table,
|
||||
sorting,
|
||||
size = "regular",
|
||||
footerText,
|
||||
ascendingLabel = "Ascending",
|
||||
descendingLabel = "Descending",
|
||||
}: SortingPopoverProps<TData>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const sortableColumns = useMemo(
|
||||
() => table.getAllLeafColumns().filter((col) => col.getCanSort()),
|
||||
[table]
|
||||
);
|
||||
|
||||
const currentSort = sorting[0] ?? null;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
icon={SvgSort}
|
||||
transient={open}
|
||||
size={size === "small" ? "sm" : "md"}
|
||||
prominence="internal"
|
||||
tooltip="Sort"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content width="lg" align="end" side="bottom">
|
||||
<Popover.Menu
|
||||
footer={
|
||||
footerText ? (
|
||||
<div className="px-2 py-1">
|
||||
<Text secondaryBody text03>
|
||||
{footerText}
|
||||
</Text>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Divider showTitle text="Sort by" />
|
||||
|
||||
<LineItem
|
||||
selected={currentSort === null}
|
||||
emphasized
|
||||
rightChildren={
|
||||
currentSort === null ? <SvgCheck size={16} /> : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
table.resetSorting();
|
||||
}}
|
||||
>
|
||||
Manual Ordering
|
||||
</LineItem>
|
||||
|
||||
{sortableColumns.map((column) => {
|
||||
const isSorted = currentSort?.id === column.id;
|
||||
const label =
|
||||
typeof column.columnDef.header === "string"
|
||||
? column.columnDef.header
|
||||
: column.id;
|
||||
|
||||
return (
|
||||
<LineItem
|
||||
key={column.id}
|
||||
selected={isSorted}
|
||||
emphasized
|
||||
rightChildren={isSorted ? <SvgCheck size={16} /> : undefined}
|
||||
onClick={() => {
|
||||
if (isSorted) return;
|
||||
column.toggleSorting(false);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<Divider showTitle text="Sorting Order" />
|
||||
|
||||
<LineItem
|
||||
selected={currentSort !== null && !currentSort.desc}
|
||||
emphasized
|
||||
rightChildren={
|
||||
currentSort !== null && !currentSort.desc ? (
|
||||
<SvgCheck size={16} />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (currentSort) {
|
||||
table.setSorting([{ id: currentSort.id, desc: false }]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ascendingLabel}
|
||||
</LineItem>
|
||||
|
||||
<LineItem
|
||||
selected={currentSort !== null && currentSort.desc}
|
||||
emphasized
|
||||
rightChildren={
|
||||
currentSort !== null && currentSort.desc ? (
|
||||
<SvgCheck size={16} />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (currentSort) {
|
||||
table.setSorting([{ id: currentSort.id, desc: true }]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{descendingLabel}
|
||||
</LineItem>
|
||||
</Popover.Menu>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definition factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateSortingColumnOptions {
|
||||
size?: "regular" | "small";
|
||||
footerText?: string;
|
||||
ascendingLabel?: string;
|
||||
descendingLabel?: string;
|
||||
}
|
||||
|
||||
function createSortingColumn<TData>(
|
||||
options?: CreateSortingColumnOptions
|
||||
): ColumnDef<TData, unknown> {
|
||||
return {
|
||||
id: "__sorting",
|
||||
size: 44,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
header: ({ table }) => (
|
||||
<SortingPopover
|
||||
table={table}
|
||||
sorting={table.getState().sorting}
|
||||
size={options?.size}
|
||||
footerText={options?.footerText}
|
||||
ascendingLabel={options?.ascendingLabel}
|
||||
descendingLabel={options?.descendingLabel}
|
||||
/>
|
||||
),
|
||||
cell: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
export { SortingPopover, createSortingColumn };
|
||||
26
web/src/refresh-components/table/Table.tsx
Normal file
26
web/src/refresh-components/table/Table.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
|
||||
interface TableProps
|
||||
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
|
||||
ref?: React.Ref<HTMLTableElement>;
|
||||
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
|
||||
* When provided the table uses exactly this width instead of stretching
|
||||
* to fill its container, which prevents `table-layout: fixed` from
|
||||
* redistributing extra space across columns on resize. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
function Table({ ref, width, ...props }: TableProps) {
|
||||
return (
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("border-separate border-spacing-0", "min-w-full")}
|
||||
style={{ tableLayout: "fixed", width: width ?? undefined }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Table;
|
||||
export type { TableProps };
|
||||
85
web/src/refresh-components/table/TableBody.tsx
Normal file
85
web/src/refresh-components/table/TableBody.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type CollisionDetection,
|
||||
type Modifier,
|
||||
type SensorDescriptor,
|
||||
type SensorOptions,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DraggableProps {
|
||||
dndContextProps: {
|
||||
sensors: SensorDescriptor<SensorOptions>[];
|
||||
collisionDetection: CollisionDetection;
|
||||
modifiers: Modifier[];
|
||||
onDragStart: (event: DragStartEvent) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onDragCancel: () => void;
|
||||
};
|
||||
sortableItems: string[];
|
||||
activeId: string | null;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
interface TableBodyProps
|
||||
extends WithoutStyles<React.HTMLAttributes<HTMLTableSectionElement>> {
|
||||
ref?: React.Ref<HTMLTableSectionElement>;
|
||||
/** DnD context props from useDraggableRows — enables drag-and-drop reordering */
|
||||
dndSortable?: DraggableProps;
|
||||
/** Render function for the drag overlay row */
|
||||
renderDragOverlay?: (activeId: string) => ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TableBody({
|
||||
ref,
|
||||
dndSortable,
|
||||
renderDragOverlay,
|
||||
...props
|
||||
}: TableBodyProps) {
|
||||
if (dndSortable?.isEnabled) {
|
||||
const { dndContextProps, sortableItems, activeId } = dndSortable;
|
||||
return (
|
||||
<DndContext
|
||||
sensors={dndContextProps.sensors}
|
||||
collisionDetection={dndContextProps.collisionDetection}
|
||||
modifiers={dndContextProps.modifiers}
|
||||
onDragStart={dndContextProps.onDragStart}
|
||||
onDragEnd={dndContextProps.onDragEnd}
|
||||
onDragCancel={dndContextProps.onDragCancel}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableItems}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody ref={ref} {...props} />
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeId && renderDragOverlay ? renderDragOverlay(activeId) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
return <tbody ref={ref} {...props} />;
|
||||
}
|
||||
|
||||
export default TableBody;
|
||||
export type { TableBodyProps, DraggableProps };
|
||||
43
web/src/refresh-components/table/TableCell.tsx
Normal file
43
web/src/refresh-components/table/TableCell.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
|
||||
interface TableCellProps
|
||||
extends WithoutStyles<React.TdHTMLAttributes<HTMLTableCellElement>> {
|
||||
children: React.ReactNode;
|
||||
size?: TableSize;
|
||||
/** When `true`, pins the cell to the right edge of the scroll container. */
|
||||
sticky?: boolean;
|
||||
/** Explicit pixel width for the cell. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export default function TableCell({
|
||||
size,
|
||||
sticky,
|
||||
width,
|
||||
children,
|
||||
...props
|
||||
}: TableCellProps) {
|
||||
const contextSize = useTableSize();
|
||||
const resolvedSize = size ?? contextSize;
|
||||
return (
|
||||
<td
|
||||
className="tbl-cell"
|
||||
data-size={resolvedSize}
|
||||
data-sticky={sticky || undefined}
|
||||
style={width != null ? { width } : undefined}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn("tbl-cell-inner", "flex items-center")}
|
||||
data-size={resolvedSize}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export type { TableCellProps };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user