Compare commits

..

6 Commits

56 changed files with 1351 additions and 567 deletions

View File

@@ -426,8 +426,9 @@ jobs:
ONYX_VERSION=${{ github.ref_name }}
NODE_OPTIONS=--max-old-space-size=8192
cache-from: |
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:web-cache-amd64
type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
cache-to: |
type=inline
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:web-cache-amd64,mode=max
@@ -499,8 +500,9 @@ jobs:
ONYX_VERSION=${{ github.ref_name }}
NODE_OPTIONS=--max-old-space-size=8192
cache-from: |
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:web-cache-arm64
type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
cache-to: |
type=inline
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:web-cache-arm64,mode=max
@@ -646,8 +648,8 @@ jobs:
NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK=true
NODE_OPTIONS=--max-old-space-size=8192
cache-from: |
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:cloudweb-cache-amd64
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
cache-to: |
type=inline
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:cloudweb-cache-amd64,mode=max
@@ -728,8 +730,8 @@ jobs:
NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK=true
NODE_OPTIONS=--max-old-space-size=8192
cache-from: |
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:cloudweb-cache-arm64
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
cache-to: |
type=inline
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:cloudweb-cache-arm64,mode=max
@@ -862,8 +864,9 @@ jobs:
build-args: |
ONYX_VERSION=${{ github.ref_name }}
cache-from: |
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-cache-amd64
type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
cache-to: |
type=inline
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-cache-amd64,mode=max
@@ -934,8 +937,9 @@ jobs:
build-args: |
ONYX_VERSION=${{ github.ref_name }}
cache-from: |
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-cache-arm64
type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
cache-to: |
type=inline
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-cache-arm64,mode=max
@@ -1072,8 +1076,8 @@ jobs:
ONYX_VERSION=${{ github.ref_name }}
ENABLE_CRAFT=true
cache-from: |
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-craft-cache-amd64
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
cache-to: |
type=inline
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-craft-cache-amd64,mode=max
@@ -1145,8 +1149,8 @@ jobs:
ONYX_VERSION=${{ github.ref_name }}
ENABLE_CRAFT=true
cache-from: |
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-craft-cache-arm64
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
cache-to: |
type=inline
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-craft-cache-arm64,mode=max
@@ -1287,8 +1291,9 @@ jobs:
build-args: |
ONYX_VERSION=${{ github.ref_name }}
cache-from: |
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:model-server-cache-amd64
type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
cache-to: |
type=inline
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:model-server-cache-amd64,mode=max
@@ -1366,8 +1371,9 @@ jobs:
build-args: |
ONYX_VERSION=${{ github.ref_name }}
cache-from: |
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:model-server-cache-arm64
type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
cache-to: |
type=inline
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:model-server-cache-arm64,mode=max

45
backend/onyx/cache/factory.py vendored Normal file
View File

@@ -0,0 +1,45 @@
from collections.abc import Callable
from onyx.cache.interface import CacheBackend
from onyx.cache.interface import CacheBackendType
from onyx.configs.app_configs import CACHE_BACKEND
def _build_redis_backend(tenant_id: str) -> CacheBackend:
from onyx.cache.redis_backend import RedisCacheBackend
from onyx.redis.redis_pool import redis_pool
return RedisCacheBackend(redis_pool.get_client(tenant_id))
_BACKEND_BUILDERS: dict[CacheBackendType, Callable[[str], CacheBackend]] = {
CacheBackendType.REDIS: _build_redis_backend,
# CacheBackendType.POSTGRES will be added in a follow-up PR.
}
def get_cache_backend(*, tenant_id: str | None = None) -> CacheBackend:
"""Return a tenant-aware ``CacheBackend``.
If *tenant_id* is ``None``, the current tenant is read from the
thread-local context variable (same behaviour as ``get_redis_client``).
"""
if tenant_id is None:
from shared_configs.contextvars import get_current_tenant_id
tenant_id = get_current_tenant_id()
builder = _BACKEND_BUILDERS.get(CACHE_BACKEND)
if builder is None:
raise ValueError(
f"Unsupported CACHE_BACKEND={CACHE_BACKEND!r}. "
f"Supported values: {[t.value for t in CacheBackendType]}"
)
return builder(tenant_id)
def get_shared_cache_backend() -> CacheBackend:
"""Return a ``CacheBackend`` in the shared (cross-tenant) namespace."""
from shared_configs.configs import DEFAULT_REDIS_PREFIX
return get_cache_backend(tenant_id=DEFAULT_REDIS_PREFIX)

89
backend/onyx/cache/interface.py vendored Normal file
View File

@@ -0,0 +1,89 @@
import abc
from enum import Enum
class CacheBackendType(str, Enum):
REDIS = "redis"
POSTGRES = "postgres"
class CacheLock(abc.ABC):
"""Abstract distributed lock returned by CacheBackend.lock()."""
@abc.abstractmethod
def acquire(
self,
blocking: bool = True,
blocking_timeout: float | None = None,
) -> bool:
raise NotImplementedError
@abc.abstractmethod
def release(self) -> None:
raise NotImplementedError
@abc.abstractmethod
def owned(self) -> bool:
raise NotImplementedError
class CacheBackend(abc.ABC):
"""Thin abstraction over a key-value cache with TTL, locks, and blocking lists.
Covers the subset of Redis operations used outside of Celery. When
CACHE_BACKEND=postgres, a PostgreSQL-backed implementation is used instead.
"""
# -- basic key/value ---------------------------------------------------
@abc.abstractmethod
def get(self, key: str) -> bytes | None:
raise NotImplementedError
@abc.abstractmethod
def set(
self,
key: str,
value: str | bytes | int | float,
ex: int | None = None,
) -> None:
raise NotImplementedError
@abc.abstractmethod
def delete(self, key: str) -> None:
raise NotImplementedError
@abc.abstractmethod
def exists(self, key: str) -> bool:
raise NotImplementedError
# -- TTL ---------------------------------------------------------------
@abc.abstractmethod
def expire(self, key: str, seconds: int) -> None:
raise NotImplementedError
@abc.abstractmethod
def ttl(self, key: str) -> int:
"""Return remaining TTL in seconds. -1 if no expiry, -2 if key missing."""
raise NotImplementedError
# -- distributed lock --------------------------------------------------
@abc.abstractmethod
def lock(self, name: str, timeout: float | None = None) -> CacheLock:
raise NotImplementedError
# -- blocking list (used by MCP OAuth BLPOP pattern) -------------------
@abc.abstractmethod
def rpush(self, key: str, value: str | bytes) -> None:
raise NotImplementedError
@abc.abstractmethod
def blpop(self, keys: list[str], timeout: int = 0) -> tuple[bytes, bytes] | None:
"""Block until a value is available on one of *keys*, or *timeout* expires.
Returns ``(key, value)`` or ``None`` on timeout.
"""
raise NotImplementedError

92
backend/onyx/cache/redis_backend.py vendored Normal file
View File

@@ -0,0 +1,92 @@
from typing import cast
from redis.client import Redis
from redis.lock import Lock as RedisLock
from onyx.cache.interface import CacheBackend
from onyx.cache.interface import CacheLock
class RedisCacheLock(CacheLock):
"""Wraps ``redis.lock.Lock`` behind the ``CacheLock`` interface."""
def __init__(self, lock: RedisLock) -> None:
self._lock = lock
def acquire(
self,
blocking: bool = True,
blocking_timeout: float | None = None,
) -> bool:
return bool(
self._lock.acquire(
blocking=blocking,
blocking_timeout=blocking_timeout,
)
)
def release(self) -> None:
self._lock.release()
def owned(self) -> bool:
return bool(self._lock.owned())
class RedisCacheBackend(CacheBackend):
"""``CacheBackend`` implementation that delegates to a ``redis.Redis`` client.
This is a thin pass-through — every method maps 1-to-1 to the underlying
Redis command. ``TenantRedis`` key-prefixing is handled by the client
itself (provided by ``get_redis_client``).
"""
def __init__(self, redis_client: Redis) -> None:
self._r = redis_client
# -- basic key/value ---------------------------------------------------
def get(self, key: str) -> bytes | None:
val = self._r.get(key)
if val is None:
return None
if isinstance(val, bytes):
return val
return str(val).encode()
def set(
self,
key: str,
value: str | bytes | int | float,
ex: int | None = None,
) -> None:
self._r.set(key, value, ex=ex)
def delete(self, key: str) -> None:
self._r.delete(key)
def exists(self, key: str) -> bool:
return bool(self._r.exists(key))
# -- TTL ---------------------------------------------------------------
def expire(self, key: str, seconds: int) -> None:
self._r.expire(key, seconds)
def ttl(self, key: str) -> int:
return cast(int, self._r.ttl(key))
# -- distributed lock --------------------------------------------------
def lock(self, name: str, timeout: float | None = None) -> CacheLock:
return RedisCacheLock(self._r.lock(name, timeout=timeout))
# -- blocking list (MCP OAuth BLPOP pattern) ---------------------------
def rpush(self, key: str, value: str | bytes) -> None:
self._r.rpush(key, value)
def blpop(self, keys: list[str], timeout: int = 0) -> tuple[bytes, bytes] | None:
result = cast(list[bytes] | None, self._r.blpop(keys, timeout=timeout))
if result is None:
return None
return (result[0], result[1])

View File

@@ -6,6 +6,7 @@ from datetime import timezone
from typing import cast
from onyx.auth.schemas import AuthBackend
from onyx.cache.interface import CacheBackendType
from onyx.configs.constants import AuthType
from onyx.configs.constants import QueryHistoryType
from onyx.file_processing.enums import HtmlBasedConnectorTransformLinksStrategy
@@ -54,6 +55,12 @@ DISABLE_USER_KNOWLEDGE = os.environ.get("DISABLE_USER_KNOWLEDGE", "").lower() ==
# are disabled but core chat, tools, user file uploads, and Projects still work.
DISABLE_VECTOR_DB = os.environ.get("DISABLE_VECTOR_DB", "").lower() == "true"
# Which backend to use for caching, locks, and ephemeral state.
# "redis" (default) or "postgres" (only valid when DISABLE_VECTOR_DB=true).
CACHE_BACKEND = CacheBackendType(
os.environ.get("CACHE_BACKEND", CacheBackendType.REDIS)
)
# Maximum token count for a single uploaded file. Files exceeding this are rejected.
# Defaults to 100k tokens (or 10M when vector DB is disabled).
_DEFAULT_FILE_TOKEN_LIMIT = 10_000_000 if DISABLE_VECTOR_DB else 100_000

View File

@@ -6,7 +6,6 @@ import httpx
from opensearchpy import NotFoundError
from onyx.access.models import DocumentAccess
from onyx.configs.app_configs import USING_AWS_MANAGED_OPENSEARCH
from onyx.configs.app_configs import VERIFY_CREATE_OPENSEARCH_INDEX_ON_INIT_MT
from onyx.configs.chat_configs import NUM_RETURNED_HITS
from onyx.configs.chat_configs import TITLE_CONTENT_RATIO
@@ -563,12 +562,7 @@ class OpenSearchDocumentIndex(DocumentIndex):
)
if not self._client.index_exists():
if USING_AWS_MANAGED_OPENSEARCH:
index_settings = (
DocumentSchema.get_index_settings_for_aws_managed_opensearch()
)
else:
index_settings = DocumentSchema.get_index_settings()
index_settings = DocumentSchema.get_index_settings_based_on_environment()
self._client.create_index(
mappings=expected_mappings,
settings=index_settings,

View File

@@ -12,6 +12,7 @@ from pydantic import model_validator
from pydantic import SerializerFunctionWrapHandler
from onyx.configs.app_configs import OPENSEARCH_TEXT_ANALYZER
from onyx.configs.app_configs import USING_AWS_MANAGED_OPENSEARCH
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.constants import DEFAULT_MAX_CHUNK_SIZE
from onyx.document_index.opensearch.constants import EF_CONSTRUCTION
@@ -525,7 +526,7 @@ class DocumentSchema:
}
@staticmethod
def get_index_settings_for_aws_managed_opensearch() -> dict[str, Any]:
def get_index_settings_for_aws_managed_opensearch_st_dev() -> dict[str, Any]:
"""
Settings for AWS-managed OpenSearch.
@@ -546,3 +547,41 @@ class DocumentSchema:
"knn.algo_param.ef_search": EF_SEARCH,
}
}
@staticmethod
def get_index_settings_for_aws_managed_opensearch_mt_cloud() -> dict[str, Any]:
"""
Settings for AWS-managed OpenSearch in multi-tenant cloud.
324 shards very roughly targets a storage load of ~30Gb per shard, which
according to AWS OpenSearch documentation is within a good target range.
As documented above we need 2 replicas for a total of 3 copies of the
data because the cluster is configured with 3-AZ awareness.
"""
return {
"index": {
"number_of_shards": 324,
"number_of_replicas": 2,
# Required for vector search.
"knn": True,
"knn.algo_param.ef_search": EF_SEARCH,
}
}
@staticmethod
def get_index_settings_based_on_environment() -> dict[str, Any]:
"""
Returns the index settings based on the environment.
"""
if USING_AWS_MANAGED_OPENSEARCH:
if MULTI_TENANT:
return (
DocumentSchema.get_index_settings_for_aws_managed_opensearch_mt_cloud()
)
else:
return (
DocumentSchema.get_index_settings_for_aws_managed_opensearch_st_dev()
)
else:
return DocumentSchema.get_index_settings()

View File

@@ -32,11 +32,13 @@ from onyx.auth.schemas import UserUpdate
from onyx.auth.users import auth_backend
from onyx.auth.users import create_onyx_oauth_router
from onyx.auth.users import fastapi_users
from onyx.cache.interface import CacheBackendType
from onyx.configs.app_configs import APP_API_PREFIX
from onyx.configs.app_configs import APP_HOST
from onyx.configs.app_configs import APP_PORT
from onyx.configs.app_configs import AUTH_RATE_LIMITING_ENABLED
from onyx.configs.app_configs import AUTH_TYPE
from onyx.configs.app_configs import CACHE_BACKEND
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.app_configs import LOG_ENDPOINT_LATENCY
from onyx.configs.app_configs import OAUTH_CLIENT_ID
@@ -255,6 +257,20 @@ def include_auth_router_with_prefix(
)
def validate_cache_backend_settings() -> None:
"""Validate that CACHE_BACKEND=postgres is only used with DISABLE_VECTOR_DB.
The Postgres cache backend eliminates the Redis dependency, but only works
when Celery is not running (which requires DISABLE_VECTOR_DB=true).
"""
if CACHE_BACKEND == CacheBackendType.POSTGRES and not DISABLE_VECTOR_DB:
raise RuntimeError(
"CACHE_BACKEND=postgres requires DISABLE_VECTOR_DB=true. "
"The Postgres cache backend is only supported in no-vector-DB "
"deployments where Celery is replaced by the in-process task runner."
)
def validate_no_vector_db_settings() -> None:
"""Validate that DISABLE_VECTOR_DB is not combined with incompatible settings.
@@ -286,6 +302,7 @@ def validate_no_vector_db_settings() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001
validate_no_vector_db_settings()
validate_cache_backend_settings()
# Set recursion limit
if SYSTEM_RECURSION_LIMIT is not None:

View File

@@ -0,0 +1,552 @@
"""Integration tests for SCIM group provisioning endpoints.
Covers the full group lifecycle as driven by an IdP (Okta / Azure AD):
1. Create a group via POST /Groups
2. Retrieve a group via GET /Groups/{id}
3. List, filter, and paginate groups via GET /Groups
4. Replace a group via PUT /Groups/{id}
5. Patch a group (add/remove members, rename) via PATCH /Groups/{id}
6. Delete a group via DELETE /Groups/{id}
7. Error cases: duplicate name, not-found, invalid member IDs
All tests are parameterized across IdP request styles (Okta sends lowercase
PATCH ops; Entra sends capitalized ops like ``"Replace"``). The server
normalizes both — these tests verify that.
Auth tests live in test_scim_tokens.py.
User lifecycle tests live in test_scim_users.py.
"""
import pytest
import requests
from onyx.auth.schemas import UserRole
from tests.integration.common_utils.managers.scim_client import ScimClient
from tests.integration.common_utils.managers.scim_token import ScimTokenManager
SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
SCIM_PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
@pytest.fixture(scope="module", params=["okta", "entra"])
def idp_style(request: pytest.FixtureRequest) -> str:
"""Parameterized IdP style — runs every test with both Okta and Entra request formats."""
return request.param
@pytest.fixture(scope="module")
def scim_token(idp_style: str) -> str:
"""Create a single SCIM token shared across all tests in this module.
Creating a new token revokes the previous one, so we create exactly once
per IdP-style run and reuse. Uses UserManager directly to avoid
fixture-scope conflicts with the function-scoped admin_user fixture.
"""
from tests.integration.common_utils.constants import ADMIN_USER_NAME
from tests.integration.common_utils.constants import GENERAL_HEADERS
from tests.integration.common_utils.managers.user import build_email
from tests.integration.common_utils.managers.user import DEFAULT_PASSWORD
from tests.integration.common_utils.managers.user import UserManager
from tests.integration.common_utils.test_models import DATestUser
try:
admin = UserManager.create(name=ADMIN_USER_NAME)
except Exception:
admin = UserManager.login_as_user(
DATestUser(
id="",
email=build_email(ADMIN_USER_NAME),
password=DEFAULT_PASSWORD,
headers=GENERAL_HEADERS,
role=UserRole.ADMIN,
is_active=True,
)
)
token = ScimTokenManager.create(
name=f"scim-group-tests-{idp_style}",
user_performing_action=admin,
).raw_token
assert token is not None
return token
def _make_group_resource(
display_name: str,
external_id: str | None = None,
members: list[dict] | None = None,
) -> dict:
"""Build a minimal SCIM GroupResource payload."""
resource: dict = {
"schemas": [SCIM_GROUP_SCHEMA],
"displayName": display_name,
}
if external_id is not None:
resource["externalId"] = external_id
if members is not None:
resource["members"] = members
return resource
def _make_user_resource(email: str, external_id: str) -> dict:
"""Build a minimal SCIM UserResource payload for member creation."""
return {
"schemas": [SCIM_USER_SCHEMA],
"userName": email,
"externalId": external_id,
"name": {"givenName": "Test", "familyName": "User"},
"active": True,
}
def _make_patch_request(operations: list[dict], idp_style: str = "okta") -> dict:
"""Build a SCIM PatchOp payload, applying IdP-specific operation casing.
Entra sends capitalized operations (e.g. ``"Replace"`` instead of
``"replace"``). The server's ``normalize_operation`` validator lowercases
them — these tests verify that both casings are accepted.
"""
cased_operations = []
for operation in operations:
cased = dict(operation)
if idp_style == "entra":
cased["op"] = operation["op"].capitalize()
cased_operations.append(cased)
return {
"schemas": [SCIM_PATCH_SCHEMA],
"Operations": cased_operations,
}
def _create_scim_user(token: str, email: str, external_id: str) -> requests.Response:
return ScimClient.post(
"/Users", token, json=_make_user_resource(email, external_id)
)
def _create_scim_group(
token: str,
display_name: str,
external_id: str | None = None,
members: list[dict] | None = None,
) -> requests.Response:
return ScimClient.post(
"/Groups",
token,
json=_make_group_resource(display_name, external_id, members),
)
# ------------------------------------------------------------------
# Lifecycle: create → get → list → replace → patch → delete
# ------------------------------------------------------------------
def test_create_group(scim_token: str, idp_style: str) -> None:
"""POST /Groups creates a group and returns 201."""
name = f"Engineering {idp_style}"
resp = _create_scim_group(scim_token, name, external_id=f"ext-eng-{idp_style}")
assert resp.status_code == 201
body = resp.json()
assert body["displayName"] == name
assert body["externalId"] == f"ext-eng-{idp_style}"
assert body["id"] # integer ID assigned by server
assert body["meta"]["resourceType"] == "Group"
def test_create_group_with_members(scim_token: str, idp_style: str) -> None:
"""POST /Groups with members populates the member list."""
user = _create_scim_user(
scim_token, f"grp_member1_{idp_style}@example.com", f"ext-gm-{idp_style}"
).json()
resp = _create_scim_group(
scim_token,
f"Backend Team {idp_style}",
external_id=f"ext-backend-{idp_style}",
members=[{"value": user["id"]}],
)
assert resp.status_code == 201
body = resp.json()
member_ids = [m["value"] for m in body["members"]]
assert user["id"] in member_ids
def test_get_group(scim_token: str, idp_style: str) -> None:
"""GET /Groups/{id} returns the group resource including members."""
user = _create_scim_user(
scim_token, f"grp_get_m_{idp_style}@example.com", f"ext-ggm-{idp_style}"
).json()
created = _create_scim_group(
scim_token,
f"Frontend Team {idp_style}",
external_id=f"ext-fe-{idp_style}",
members=[{"value": user["id"]}],
).json()
resp = ScimClient.get(f"/Groups/{created['id']}", scim_token)
assert resp.status_code == 200
body = resp.json()
assert body["id"] == created["id"]
assert body["displayName"] == f"Frontend Team {idp_style}"
assert body["externalId"] == f"ext-fe-{idp_style}"
member_ids = [m["value"] for m in body["members"]]
assert user["id"] in member_ids
def test_list_groups(scim_token: str, idp_style: str) -> None:
"""GET /Groups returns a ListResponse containing provisioned groups."""
name = f"DevOps Team {idp_style}"
_create_scim_group(scim_token, name, external_id=f"ext-devops-{idp_style}")
resp = ScimClient.get("/Groups", scim_token)
assert resp.status_code == 200
body = resp.json()
assert body["totalResults"] >= 1
names = [r["displayName"] for r in body["Resources"]]
assert name in names
def test_list_groups_pagination(scim_token: str, idp_style: str) -> None:
"""GET /Groups with startIndex and count returns correct pagination."""
_create_scim_group(
scim_token, f"Page Group A {idp_style}", external_id=f"ext-page-a-{idp_style}"
)
_create_scim_group(
scim_token, f"Page Group B {idp_style}", external_id=f"ext-page-b-{idp_style}"
)
resp = ScimClient.get("/Groups?startIndex=1&count=1", scim_token)
assert resp.status_code == 200
body = resp.json()
assert body["startIndex"] == 1
assert body["itemsPerPage"] == 1
assert body["totalResults"] >= 2
assert len(body["Resources"]) == 1
def test_filter_groups_by_display_name(scim_token: str, idp_style: str) -> None:
"""GET /Groups?filter=displayName eq '...' returns only matching groups."""
name = f"Unique QA Team {idp_style}"
_create_scim_group(scim_token, name, external_id=f"ext-qa-filter-{idp_style}")
resp = ScimClient.get(f'/Groups?filter=displayName eq "{name}"', scim_token)
assert resp.status_code == 200
body = resp.json()
assert body["totalResults"] == 1
assert body["Resources"][0]["displayName"] == name
def test_filter_groups_by_external_id(scim_token: str, idp_style: str) -> None:
"""GET /Groups?filter=externalId eq '...' returns the matching group."""
ext_id = f"ext-unique-group-id-{idp_style}"
_create_scim_group(
scim_token, f"ExtId Filter Group {idp_style}", external_id=ext_id
)
resp = ScimClient.get(f'/Groups?filter=externalId eq "{ext_id}"', scim_token)
assert resp.status_code == 200
body = resp.json()
assert body["totalResults"] == 1
assert body["Resources"][0]["externalId"] == ext_id
def test_replace_group(scim_token: str, idp_style: str) -> None:
"""PUT /Groups/{id} replaces the group resource."""
created = _create_scim_group(
scim_token,
f"Original Name {idp_style}",
external_id=f"ext-replace-g-{idp_style}",
).json()
user = _create_scim_user(
scim_token, f"grp_replace_m_{idp_style}@example.com", f"ext-grm-{idp_style}"
).json()
updated_resource = _make_group_resource(
display_name=f"Renamed Group {idp_style}",
external_id=f"ext-replace-g-{idp_style}",
members=[{"value": user["id"]}],
)
resp = ScimClient.put(f"/Groups/{created['id']}", scim_token, json=updated_resource)
assert resp.status_code == 200
body = resp.json()
assert body["displayName"] == f"Renamed Group {idp_style}"
member_ids = [m["value"] for m in body["members"]]
assert user["id"] in member_ids
def test_replace_group_clears_members(scim_token: str, idp_style: str) -> None:
"""PUT /Groups/{id} with empty members removes all memberships."""
user = _create_scim_user(
scim_token, f"grp_clear_m_{idp_style}@example.com", f"ext-gcm-{idp_style}"
).json()
created = _create_scim_group(
scim_token,
f"Clear Members Group {idp_style}",
external_id=f"ext-clear-g-{idp_style}",
members=[{"value": user["id"]}],
).json()
assert len(created["members"]) == 1
resp = ScimClient.put(
f"/Groups/{created['id']}",
scim_token,
json=_make_group_resource(
f"Clear Members Group {idp_style}", f"ext-clear-g-{idp_style}", members=[]
),
)
assert resp.status_code == 200
assert resp.json()["members"] == []
def test_patch_add_member(scim_token: str, idp_style: str) -> None:
"""PATCH /Groups/{id} with op=add adds a member."""
created = _create_scim_group(
scim_token,
f"Patch Add Group {idp_style}",
external_id=f"ext-patch-add-{idp_style}",
).json()
user = _create_scim_user(
scim_token, f"grp_patch_add_{idp_style}@example.com", f"ext-gpa-{idp_style}"
).json()
resp = ScimClient.patch(
f"/Groups/{created['id']}",
scim_token,
json=_make_patch_request(
[{"op": "add", "path": "members", "value": [{"value": user["id"]}]}],
idp_style,
),
)
assert resp.status_code == 200
member_ids = [m["value"] for m in resp.json()["members"]]
assert user["id"] in member_ids
def test_patch_remove_member(scim_token: str, idp_style: str) -> None:
"""PATCH /Groups/{id} with op=remove removes a specific member."""
user = _create_scim_user(
scim_token, f"grp_patch_rm_{idp_style}@example.com", f"ext-gpr-{idp_style}"
).json()
created = _create_scim_group(
scim_token,
f"Patch Remove Group {idp_style}",
external_id=f"ext-patch-rm-{idp_style}",
members=[{"value": user["id"]}],
).json()
assert len(created["members"]) == 1
resp = ScimClient.patch(
f"/Groups/{created['id']}",
scim_token,
json=_make_patch_request(
[
{
"op": "remove",
"path": f'members[value eq "{user["id"]}"]',
}
],
idp_style,
),
)
assert resp.status_code == 200
assert resp.json()["members"] == []
def test_patch_replace_members(scim_token: str, idp_style: str) -> None:
"""PATCH /Groups/{id} with op=replace on members swaps the entire list."""
user_a = _create_scim_user(
scim_token, f"grp_repl_a_{idp_style}@example.com", f"ext-gra-{idp_style}"
).json()
user_b = _create_scim_user(
scim_token, f"grp_repl_b_{idp_style}@example.com", f"ext-grb-{idp_style}"
).json()
created = _create_scim_group(
scim_token,
f"Patch Replace Group {idp_style}",
external_id=f"ext-patch-repl-{idp_style}",
members=[{"value": user_a["id"]}],
).json()
# Replace member list: swap A for B
resp = ScimClient.patch(
f"/Groups/{created['id']}",
scim_token,
json=_make_patch_request(
[
{
"op": "replace",
"path": "members",
"value": [{"value": user_b["id"]}],
}
],
idp_style,
),
)
assert resp.status_code == 200
member_ids = [m["value"] for m in resp.json()["members"]]
assert user_b["id"] in member_ids
assert user_a["id"] not in member_ids
def test_patch_rename_group(scim_token: str, idp_style: str) -> None:
"""PATCH /Groups/{id} with op=replace on displayName renames the group."""
created = _create_scim_group(
scim_token,
f"Old Group Name {idp_style}",
external_id=f"ext-rename-g-{idp_style}",
).json()
new_name = f"New Group Name {idp_style}"
resp = ScimClient.patch(
f"/Groups/{created['id']}",
scim_token,
json=_make_patch_request(
[{"op": "replace", "path": "displayName", "value": new_name}],
idp_style,
),
)
assert resp.status_code == 200
assert resp.json()["displayName"] == new_name
# Confirm via GET
get_resp = ScimClient.get(f"/Groups/{created['id']}", scim_token)
assert get_resp.json()["displayName"] == new_name
def test_delete_group(scim_token: str, idp_style: str) -> None:
"""DELETE /Groups/{id} removes the group."""
created = _create_scim_group(
scim_token,
f"Delete Me Group {idp_style}",
external_id=f"ext-del-g-{idp_style}",
).json()
resp = ScimClient.delete(f"/Groups/{created['id']}", scim_token)
assert resp.status_code == 204
# Second DELETE returns 404 (group hard-deleted)
resp2 = ScimClient.delete(f"/Groups/{created['id']}", scim_token)
assert resp2.status_code == 404
def test_delete_group_preserves_members(scim_token: str, idp_style: str) -> None:
"""DELETE /Groups/{id} removes memberships but does not deactivate users."""
user = _create_scim_user(
scim_token, f"grp_del_member_{idp_style}@example.com", f"ext-gdm-{idp_style}"
).json()
created = _create_scim_group(
scim_token,
f"Delete With Members {idp_style}",
external_id=f"ext-del-wm-{idp_style}",
members=[{"value": user["id"]}],
).json()
resp = ScimClient.delete(f"/Groups/{created['id']}", scim_token)
assert resp.status_code == 204
# User should still be active and retrievable
user_resp = ScimClient.get(f"/Users/{user['id']}", scim_token)
assert user_resp.status_code == 200
assert user_resp.json()["active"] is True
# ------------------------------------------------------------------
# Error cases
# ------------------------------------------------------------------
def test_create_group_duplicate_name(scim_token: str, idp_style: str) -> None:
"""POST /Groups with an already-taken displayName returns 409."""
name = f"Dup Name Group {idp_style}"
resp1 = _create_scim_group(scim_token, name, external_id=f"ext-dup-g1-{idp_style}")
assert resp1.status_code == 201
resp2 = _create_scim_group(scim_token, name, external_id=f"ext-dup-g2-{idp_style}")
assert resp2.status_code == 409
def test_get_nonexistent_group(scim_token: str) -> None:
"""GET /Groups/{bad-id} returns 404."""
resp = ScimClient.get("/Groups/999999999", scim_token)
assert resp.status_code == 404
def test_create_group_with_invalid_member(scim_token: str, idp_style: str) -> None:
"""POST /Groups with a non-existent member UUID returns 400."""
resp = _create_scim_group(
scim_token,
f"Bad Member Group {idp_style}",
external_id=f"ext-bad-m-{idp_style}",
members=[{"value": "00000000-0000-0000-0000-000000000000"}],
)
assert resp.status_code == 400
assert "not found" in resp.json()["detail"].lower()
def test_patch_add_nonexistent_member(scim_token: str, idp_style: str) -> None:
"""PATCH /Groups/{id} adding a non-existent member returns 400."""
created = _create_scim_group(
scim_token,
f"Patch Bad Member Group {idp_style}",
external_id=f"ext-pbm-{idp_style}",
).json()
resp = ScimClient.patch(
f"/Groups/{created['id']}",
scim_token,
json=_make_patch_request(
[
{
"op": "add",
"path": "members",
"value": [{"value": "00000000-0000-0000-0000-000000000000"}],
}
],
idp_style,
),
)
assert resp.status_code == 400
assert "not found" in resp.json()["detail"].lower()
def test_patch_add_duplicate_member_is_idempotent(
scim_token: str, idp_style: str
) -> None:
"""PATCH /Groups/{id} adding an already-present member succeeds silently."""
user = _create_scim_user(
scim_token, f"grp_dup_add_{idp_style}@example.com", f"ext-gda-{idp_style}"
).json()
created = _create_scim_group(
scim_token,
f"Idempotent Add Group {idp_style}",
external_id=f"ext-idem-g-{idp_style}",
members=[{"value": user["id"]}],
).json()
assert len(created["members"]) == 1
# Add same member again
resp = ScimClient.patch(
f"/Groups/{created['id']}",
scim_token,
json=_make_patch_request(
[{"op": "add", "path": "members", "value": [{"value": user["id"]}]}],
idp_style,
),
)
assert resp.status_code == 200
assert len(resp.json()["members"]) == 1 # still just one member

View File

@@ -1,5 +1,4 @@
import type { IconProps } from "@opal/types";
const SvgCreditCard = ({ size, ...props }: IconProps) => (
<svg
width={size}
@@ -11,13 +10,7 @@ const SvgCreditCard = ({ size, ...props }: IconProps) => (
{...props}
>
<path
d="M14 3.33334H2C1.26362 3.33334 0.666664 3.9303 0.666664 4.66668V11.3333C0.666664 12.0697 1.26362 12.6667 2 12.6667H14C14.7364 12.6667 15.3333 12.0697 15.3333 11.3333V4.66668C15.3333 3.9303 14.7364 3.33334 14 3.33334Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M0.666664 6.66666H15.3333"
d="M14.6667 6V4.00008C14.6667 3.26675 14.0667 2.66675 13.3333 2.66675H2.66668C1.93334 2.66675 1.33334 3.26675 1.33334 4.00008V6M14.6667 6V12.0001C14.6667 12.7334 14.0667 13.3334 13.3333 13.3334H2.66668C1.93334 13.3334 1.33334 12.7334 1.33334 12.0001V6M14.6667 6H1.33334"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"

View File

@@ -1,5 +1,4 @@
import type { IconProps } from "@opal/types";
const SvgNetworkGraph = ({ size, ...props }: IconProps) => (
<svg
width={size}
@@ -10,16 +9,19 @@ const SvgNetworkGraph = ({ size, ...props }: IconProps) => (
stroke="currentColor"
{...props}
>
<path
d="M8 6V10M5.17157 6.82843L3.5 8.5M10.8284 6.82843L12.5 8.5"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="8" cy="4.5" r="2" strokeWidth={1.5} />
<circle cx="2.5" cy="11.5" r="2" strokeWidth={1.5} />
<circle cx="8" cy="12" r="1.5" strokeWidth={1.5} />
<circle cx="13.5" cy="11.5" r="2" strokeWidth={1.5} />
<g clipPath="url(#clip0_2828_22555)">
<path
d="M9.23744 4.48744C9.92086 3.80402 9.92086 2.69598 9.23744 2.01256C8.55402 1.32915 7.44598 1.32915 6.76256 2.01256C6.07915 2.69598 6.07915 3.80402 6.76256 4.48744M9.23744 4.48744C8.89573 4.82915 8.44787 5 8 5M9.23744 4.48744L11.7626 8.01256M6.76256 4.48744C7.10427 4.82915 7.55214 5 8 5M6.76256 4.48744L4.23744 8.01256M8 11C7.0335 11 6.25001 11.7835 6.25001 12.75C6.25001 13.7165 7.03351 14.5 8.00001 14.5C8.9665 14.5 9.75 13.7165 9.75 12.75C9.75 11.7835 8.9665 11 8 11ZM8 11V5M4.23744 8.01256C4.92085 8.69598 4.92422 9.81658 4.2408 10.5C3.55739 11.1834 2.44598 11.1709 1.76256 10.4874C1.07915 9.80402 1.07915 8.69598 1.76256 8.01256C2.44598 7.32915 3.55402 7.32915 4.23744 8.01256ZM11.7626 8.01256C11.0791 8.69598 11.0791 9.80402 11.7626 10.4874C12.446 11.1709 13.554 11.1709 14.2374 10.4874C14.9209 9.80402 14.9209 8.69598 14.2374 8.01256C13.554 7.32915 12.446 7.32915 11.7626 8.01256Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2828_22555">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
export default SvgNetworkGraph;

View File

@@ -1,5 +1,5 @@
"use client";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { AdminPageTitle } from "@/components/admin/Title";
import { SourceCategory, SourceMetadata } from "@/lib/search/interfaces";
import { listSourceMetadata } from "@/lib/sources";
import Button from "@/refresh-components/buttons/Button";
@@ -248,37 +248,61 @@ export default function Page() {
};
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
<>
<AdminPageTitle
icon={SvgUploadCloud}
title="Add Connector"
rightChildren={
farRightElement={
<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"
/>
{dedupedPopular.length > 0 && (
<div className="pt-8">
<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">
<Text as="p" headingH3>
Popular
{category}
</Text>
<div className="flex flex-wrap gap-4 p-4">
{dedupedPopular.map((source) => (
{sources.map((source, sourceInd) => (
<SourceTileTooltipWrapper
preSelect={false}
preSelect={
(searchTerm?.length ?? 0) > 0 &&
categoryInd == 0 &&
sourceInd == 0
}
key={source.internalName}
sourceMetadata={source}
federatedConnectors={federatedConnectors}
@@ -287,33 +311,7 @@ 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>
))}
</>
);
}

View File

@@ -4,8 +4,8 @@ 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";
@@ -127,33 +127,31 @@ export default function Page() {
});
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={SvgOnyxOctagon} title="Agents" separator />
<>
<AdminPageTitle icon={SvgOnyxOctagon} title="Agents" />
<SettingsLayouts.Body>
{isLoading && <ThreeDotsLoader />}
{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}
/>
)}
</SettingsLayouts.Body>
</SettingsLayouts.Root>
{!isLoading && !error && (
<MainContent
personas={personas}
totalItems={totalItems}
currentPage={currentPage}
onPageChange={setCurrentPage}
refreshPersonas={refresh}
/>
)}
</>
);
}

View File

@@ -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";
@@ -233,11 +233,10 @@ function Main() {
export default function Page() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header title="API Keys" icon={SvgKey} separator />
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<>
<AdminPageTitle title="API Keys" icon={SvgKey} />
<Main />
</>
);
}

View File

@@ -4,8 +4,9 @@ import CardSection from "@/components/admin/CardSection";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { SlackTokensForm } from "./SlackTokensForm";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { SvgSlack } from "@opal/icons";
import { SourceIcon } from "@/components/SourceIcon";
import { AdminPageTitle } from "@/components/admin/Title";
import { ValidSources } from "@/lib/types";
export const NewSlackBotForm = () => {
const [formValues] = useState({
@@ -18,19 +19,20 @@ export const NewSlackBotForm = () => {
const router = useRouter();
return (
<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>
<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>
);
};

View File

@@ -1,10 +1,15 @@
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 } from "@/lib/types";
import {
DocumentSetSummary,
SlackChannelConfig,
ValidSources,
} from "@/lib/types";
import BackButton from "@/refresh-components/buttons/BackButton";
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";
@@ -72,28 +77,27 @@ async function EditslackChannelConfigPage(props: {
}
return (
<SettingsLayouts.Root>
<div className="max-w-4xl container">
<InstantSSRAutoRefresh />
<SettingsLayouts.Header
icon={SvgSlack}
<BackButton />
<AdminPageTitle
icon={<SourceIcon sourceType={ValidSources.Slack} iconSize={32} />}
title={
slackChannelConfig.is_default
? "Edit Default Slack Config"
: "Edit Slack Channel Config"
}
separator
backButton
/>
<SettingsLayouts.Body>
<SlackChannelConfigCreationForm
slack_bot_id={slackChannelConfig.slack_bot_id}
documentSets={documentSets}
personas={assistants}
standardAnswerCategoryResponse={eeStandardAnswerCategoryResponse}
existingSlackChannelConfig={slackChannelConfig}
/>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<SlackChannelConfigCreationForm
slack_bot_id={slackChannelConfig.slack_bot_id}
documentSets={documentSets}
personas={assistants}
standardAnswerCategoryResponse={eeStandardAnswerCategoryResponse}
existingSlackChannelConfig={slackChannelConfig}
/>
</div>
);
}

View File

@@ -1,12 +1,13 @@
import { AdminPageTitle } from "@/components/admin/Title";
import { SlackChannelConfigCreationForm } from "../SlackChannelConfigCreationForm";
import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import { DocumentSetSummary } from "@/lib/types";
import { DocumentSetSummary, ValidSources } from "@/lib/types";
import BackButton from "@/refresh-components/buttons/BackButton";
import { fetchAgentsSS } from "@/lib/agentsSS";
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
import { redirect } from "next/navigation";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { SvgSlack } from "@opal/icons";
import { SourceIcon } from "@/components/SourceIcon";
async function NewChannelConfigPage(props: {
params: Promise<{ "bot-id": string }>;
@@ -49,22 +50,20 @@ async function NewChannelConfigPage(props: {
}
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgSlack}
<>
<BackButton />
<AdminPageTitle
icon={<SourceIcon iconSize={32} sourceType={ValidSources.Slack} />}
title="Configure OnyxBot for Slack Channel"
separator
backButton
/>
<SettingsLayouts.Body>
<SlackChannelConfigCreationForm
slack_bot_id={slack_bot_id}
documentSets={documentSets}
personas={agentsResponse[0]}
standardAnswerCategoryResponse={standardAnswerCategoryResponse}
/>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<SlackChannelConfigCreationForm
slack_bot_id={slack_bot_id}
documentSets={documentSets}
personas={agentsResponse[0]}
standardAnswerCategoryResponse={standardAnswerCategoryResponse}
/>
</>
);
}

View File

@@ -3,10 +3,11 @@
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 * as SettingsLayouts from "@/layouts/settings-layouts";
import { SvgSlack } from "@opal/icons";
import { ValidSources } from "@/lib/types";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { DOCS_ADMINS_PATH } from "@/lib/constants";
@@ -76,13 +77,15 @@ const Main = () => {
const Page = () => {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={SvgSlack} title="Slack Bots" separator />
<SettingsLayouts.Body>
<InstantSSRAutoRefresh />
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<>
<AdminPageTitle
icon={<SourceIcon iconSize={36} sourceType={ValidSources.Slack} />}
title="Slack Bots"
/>
<InstantSSRAutoRefresh />
<Main />
</>
);
};

View File

@@ -4,12 +4,13 @@ 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 * as SettingsLayouts from "@/layouts/settings-layouts";
import { AdminPageTitle } from "@/components/admin/Title";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { SvgFileText, SvgLock } from "@opal/icons";
import { SvgLock } from "@opal/icons";
function Main() {
const {
@@ -148,15 +149,12 @@ function Main() {
export default function Page() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgFileText}
<>
<AdminPageTitle
title="Document Processing"
separator
icon={<DocumentIcon2 size={32} className="my-auto" />}
/>
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<Main />
</>
);
}

View File

@@ -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";
@@ -141,15 +141,9 @@ function Main() {
export default function Page() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
title="Search Settings"
icon={SvgSearch}
separator
/>
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<>
<AdminPageTitle title="Search Settings" icon={SvgSearch} />
<Main />
</>
);
}

View File

@@ -1,7 +1,8 @@
"use client";
import { useState, useEffect } from "react";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { AdminPageTitle } from "@/components/admin/Title";
import { FiDownload } from "react-icons/fi";
import { ThreeDotsLoader } from "@/components/Loading";
import {
Table,
@@ -15,7 +16,7 @@ import Button from "@/refresh-components/buttons/Button";
import { Card } from "@/components/ui/card";
import Text from "@/components/ui/text";
import { Spinner } from "@/components/Spinner";
import { SvgDownload, SvgDownloadCloud } from "@opal/icons";
import { SvgDownloadCloud } from "@opal/icons";
function Main() {
const [categories, setCategories] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -115,12 +116,10 @@ function Main() {
const Page = () => {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={SvgDownload} title="Debug Logs" separator />
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<>
<AdminPageTitle icon={<FiDownload size={32} />} title="Debug Logs" />
<Main />
</>
);
};

View File

@@ -1,4 +1,4 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { AdminPageTitle } from "@/components/admin/Title";
import { Explorer } from "./Explorer";
import { fetchValidFilterInfo } from "@/lib/search/utilsSS";
import { SvgZoomIn } from "@opal/icons";
@@ -9,20 +9,17 @@ export default async function Page(props: {
const { connectors, documentSets } = await fetchValidFilterInfo();
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgZoomIn}
<>
<AdminPageTitle
icon={<SvgZoomIn className="stroke-text-04 h-8 w-8" />}
title="Document Explorer"
separator
/>
<SettingsLayouts.Body>
<Explorer
initialSearchValue={searchParams.query}
connectors={connectors}
documentSets={documentSets}
/>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<Explorer
initialSearchValue={searchParams.query}
connectors={connectors}
documentSets={documentSets}
/>
</>
);
}

View File

@@ -4,8 +4,8 @@ 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 * as SettingsLayouts from "@/layouts/settings-layouts";
import { SvgThumbsUp } from "@opal/icons";
const Main = () => {
const {
@@ -61,16 +61,11 @@ const Main = () => {
const Page = () => {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgThumbsUp}
title="Document Feedback"
separator
/>
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<>
<AdminPageTitle icon={SvgThumbsUp} title="Document Feedback" />
<Main />
</>
);
};

View File

@@ -5,8 +5,9 @@ import { ErrorCallout } from "@/components/ErrorCallout";
import { refreshDocumentSets, useDocumentSets } from "../hooks";
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
import { ThreeDotsLoader } from "@/components/Loading";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { SvgBookOpen } from "@opal/icons";
import { AdminPageTitle } from "@/components/admin/Title";
import { BookmarkIcon } from "@/components/icons/icons";
import BackButton from "@/refresh-components/buttons/BackButton";
import CardSection from "@/components/admin/CardSection";
import { DocumentSetCreationForm } from "../DocumentSetCreationForm";
import { useRouter } from "next/navigation";
@@ -68,27 +69,24 @@ function Main({ documentSetId }: { documentSetId: number }) {
}
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgBookOpen}
<div>
<AdminPageTitle
icon={<BookmarkIcon size={32} />}
title={documentSet.name}
separator
backButton
/>
<SettingsLayouts.Body>
<CardSection>
<DocumentSetCreationForm
ccPairs={ccPairs}
userGroups={userGroups}
onClose={() => {
refreshDocumentSets();
router.push("/admin/documents/sets");
}}
existingDocumentSet={documentSet}
/>
</CardSection>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<CardSection>
<DocumentSetCreationForm
ccPairs={ccPairs}
userGroups={userGroups}
onClose={() => {
refreshDocumentSets();
router.push("/admin/documents/sets");
}}
existingDocumentSet={documentSet}
/>
</CardSection>
</div>
);
}
@@ -98,5 +96,11 @@ export default function Page(props: {
const params = use(props.params);
const documentSetId = parseInt(params.documentSetId);
return <Main documentSetId={documentSetId} />;
return (
<>
<BackButton />
<Main documentSetId={documentSetId} />
</>
);
}

View File

@@ -1,10 +1,11 @@
"use client";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { SvgBookOpen } from "@opal/icons";
import { AdminPageTitle } from "@/components/admin/Title";
import { BookmarkIcon } from "@/components/icons/icons";
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";
@@ -57,17 +58,16 @@ function Main() {
const Page = () => {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgBookOpen}
<>
<BackButton />
<AdminPageTitle
icon={<BookmarkIcon size={32} />}
title="New Document Set"
separator
backButton
/>
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<Main />
</>
);
};

View File

@@ -2,7 +2,7 @@
import { ThreeDotsLoader } from "@/components/Loading";
import { PageSelector } from "@/components/PageSelector";
import { InfoIcon } from "@/components/icons/icons";
import { BookmarkIcon, InfoIcon } from "@/components/icons/icons";
import {
Table,
TableHead,
@@ -19,8 +19,7 @@ import { useDocumentSets } from "./hooks";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { deleteDocumentSet } from "./lib";
import { toast } from "@/hooks/useToast";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { SvgBookOpen } from "@opal/icons";
import { AdminPageTitle } from "@/components/admin/Title";
import {
FiAlertTriangle,
FiCheckCircle,
@@ -423,16 +422,11 @@ const Main = () => {
const Page = () => {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgBookOpen}
title="Document Sets"
separator
/>
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<>
<AdminPageTitle icon={<BookmarkIcon size={32} />} title="Document Sets" />
<Main />
</>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { toast } from "@/hooks/useToast";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import EmbeddingModelSelection from "../EmbeddingModelSelectionForm";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import Text from "@/refresh-components/texts/Text";
@@ -481,6 +481,9 @@ export default function EmbeddingForm() {
return (
<div className="mx-auto mb-8 w-full">
<div className="mb-4">
<HealthCheckBanner />
</div>
<div className="mx-auto max-w-4xl">
{formStep == 0 && (
<>

View File

@@ -1,10 +1,10 @@
"use client";
import { NotebookIcon } from "@/components/icons/icons";
import { CCPairIndexingStatusTable } from "./CCPairIndexingStatusTable";
import { SearchAndFilterControls } from "./SearchAndFilterControls";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { AdminPageTitle } from "@/components/admin/Title";
import Link from "next/link";
import { SvgPlug } from "@opal/icons";
import Text from "@/components/ui/text";
import { useConnectorIndexingStatusWithPagination } from "@/lib/hooks";
import { useToastFromQuery } from "@/hooks/useToast";
@@ -213,18 +213,16 @@ export default function Status() {
});
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgPlug}
<>
<AdminPageTitle
icon={<NotebookIcon size={32} />}
title="Existing Connectors"
rightChildren={
farRightElement={
<Button href="/admin/add-connector">Add Connector</Button>
}
separator
/>
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<Main />
</>
);
}

View File

@@ -1,13 +1,14 @@
"use client";
import CardSection from "@/components/admin/CardSection";
import { AdminPageTitle } from "@/components/admin/Title";
import {
DatePickerField,
FieldLabel,
TextArrayField,
TextFormField,
} from "@/components/Field";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { BrainIcon } from "@/components/icons/icons";
import Modal from "@/refresh-components/Modal";
import Button from "@/refresh-components/buttons/Button";
import SwitchField from "@/refresh-components/form/SwitchField";
@@ -29,7 +30,7 @@ import { useIsKGExposed } from "@/app/admin/kg/utils";
import KGEntityTypes from "@/app/admin/kg/KGEntityTypes";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { SvgNetworkGraph, SvgSettings } from "@opal/icons";
import { SvgSettings } from "@opal/icons";
function createDomainField(
name: string,
@@ -323,15 +324,12 @@ export default function Page() {
}
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgNetworkGraph}
<>
<AdminPageTitle
title="Knowledge Graph"
separator
icon={<BrainIcon size={32} className="my-auto" />}
/>
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<Main />
</>
);
}

View File

@@ -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 {
@@ -208,15 +208,9 @@ function Main() {
export default function Page() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
title="Token Rate Limits"
icon={SvgShield}
separator
/>
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<>
<AdminPageTitle title="Token Rate Limits" icon={SvgShield} />
<Main />
</>
);
}

View File

@@ -8,8 +8,8 @@ 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";
@@ -327,12 +327,10 @@ function AddUserButton() {
const Page = () => {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header title="Manage Users" icon={SvgUser} separator />
<SettingsLayouts.Body>
<SearchableTables />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<>
<AdminPageTitle title="Manage Users" icon={SvgUser} />
<SearchableTables />
</>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import AuthFlowContainer from "@/components/auth/AuthFlowContainer";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { useUser } from "@/providers/UserProvider";
import { redirect, useRouter } from "next/navigation";
import type { Route } from "next";
@@ -61,6 +61,10 @@ export default function ImpersonatePage() {
return (
<AuthFlowContainer>
<div className="absolute top-10x w-full">
<HealthCheckBanner />
</div>
<div className="flex flex-col w-full justify-center">
<div className="w-full flex flex-col items-center justify-center">
<Text as="p" headingH3 className="mb-6 text-center">

View File

@@ -1,3 +1,4 @@
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { User } from "@/lib/types";
import {
getCurrentUserSS,
@@ -64,6 +65,7 @@ const Page = async (props: {
return (
<AuthFlowContainer authState="join">
<HealthCheckBanner />
<AuthErrorDisplay searchParams={searchParams} />
<>

View File

@@ -1,3 +1,4 @@
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { User } from "@/lib/types";
import {
getCurrentUserSS,
@@ -104,6 +105,10 @@ export default async function Page(props: PageProps) {
authState="login"
footerContent={ssoLoginFooterContent}
>
<div className="absolute top-10x w-full">
<HealthCheckBanner />
</div>
<LoginPage
authUrl={authUrl}
authTypeMetadata={authTypeMetadata}

View File

@@ -1,3 +1,4 @@
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { User } from "@/lib/types";
import {
getCurrentUserSS,
@@ -62,6 +63,7 @@ const Page = async (props: {
return (
<AuthFlowContainer authState="signup">
<HealthCheckBanner />
<AuthErrorDisplay searchParams={searchParams} />
<>

View File

@@ -1,5 +1,6 @@
"use client";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import Text from "@/components/ui/text";
@@ -62,6 +63,9 @@ export default function Verify({ user }: VerifyProps) {
return (
<main>
<div className="absolute top-10x w-full">
<HealthCheckBanner />
</div>
<div className="min-h-screen flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<Logo folded size={64} className="mx-auto w-fit animate-pulse" />
{!error ? (

View File

@@ -4,7 +4,7 @@ import {
getCurrentUserSS,
} from "@/lib/userSS";
import { redirect } from "next/navigation";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { User } from "@/lib/types";
import Text from "@/components/ui/text";
import { RequestNewVerificationEmail } from "./RequestNewVerificationEmail";
@@ -35,6 +35,9 @@ export default async function Page() {
return (
<main>
<div className="absolute top-10x w-full">
<HealthCheckBanner />
</div>
<div className="min-h-screen flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<Logo folded size={64} className="mx-auto w-fit" />
<div className="flex">

View File

@@ -1,6 +1,6 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { AdminPageTitle } from "@/components/admin/Title";
import BillingInformationPage from "./BillingInformationPage";
import { SvgCreditCard } from "@opal/icons";
import { MdOutlineCreditCard } from "react-icons/md";
export interface BillingInformation {
stripe_subscription_id: string;
@@ -18,15 +18,12 @@ export interface BillingInformation {
export default function page() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgCreditCard}
<>
<AdminPageTitle
title="Billing Information"
separator
icon={<MdOutlineCreditCard size={32} className="my-auto" />}
/>
<SettingsLayouts.Body>
<BillingInformationPage />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<BillingInformationPage />
</>
);
}

View File

@@ -7,8 +7,9 @@ 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";
import * as SettingsLayouts from "@/layouts/settings-layouts";
const Page = (props: { params: Promise<{ groupId: string }> }) => {
const params = use(props.params);
const router = useRouter();
@@ -51,27 +52,22 @@ const Page = (props: { params: Promise<{ groupId: string }> }) => {
}
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgUsers}
title={userGroup.name || "Unknown"}
separator
backButton
/>
<>
<BackButton />
<SettingsLayouts.Body>
{userGroup ? (
<GroupDisplay
users={users.accepted}
ccPairs={ccPairs}
userGroup={userGroup}
refreshUserGroup={refreshUserGroup}
/>
) : (
<div>Unable to fetch User Group :(</div>
)}
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<AdminPageTitle title={userGroup.name || "Unknown"} icon={SvgUsers} />
{userGroup ? (
<GroupDisplay
users={users.accepted}
ccPairs={ccPairs}
userGroup={userGroup}
refreshUserGroup={refreshUserGroup}
/>
) : (
<div>Unable to fetch User Group :(</div>
)}
</>
);
};

View File

@@ -5,12 +5,12 @@ 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";
import * as SettingsLayouts from "@/layouts/settings-layouts";
const Main = () => {
const [showForm, setShowForm] = useState(false);
@@ -74,17 +74,11 @@ const Main = () => {
const Page = () => {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgUsers}
title="Manage User Groups"
separator
/>
<>
<AdminPageTitle title="Manage User Groups" icon={SvgUsers} />
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<Main />
</>
);
};

View File

@@ -1,7 +1,7 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { AdminPageTitle } from "@/components/admin/Title";
import { CUSTOM_ANALYTICS_ENABLED } from "@/lib/constants";
import { Callout } from "@/components/ui/callout";
import { SvgBarChart } from "@opal/icons";
import { FiBarChart2 } from "react-icons/fi";
import Text from "@/components/ui/text";
import { CustomAnalyticsUpdateForm } from "./CustomAnalyticsUpdateForm";
@@ -35,15 +35,13 @@ function Main() {
export default function Page() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgBarChart}
<main className="pt-4 mx-auto container">
<AdminPageTitle
title="Custom Analytics"
separator
icon={<FiBarChart2 size={32} />}
/>
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<Main />
</main>
);
}

View File

@@ -1,20 +1,14 @@
"use client";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { AdminPageTitle } from "@/components/admin/Title";
import { QueryHistoryTable } from "@/app/ee/admin/performance/query-history/QueryHistoryTable";
import { SvgServer } from "@opal/icons";
export default function QueryHistoryPage() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgServer}
title="Query History"
separator
/>
<>
<AdminPageTitle title="Query History" icon={SvgServer} />
<SettingsLayouts.Body>
<QueryHistoryTable />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<QueryHistoryTable />
</>
);
}

View File

@@ -6,38 +6,32 @@ 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 * as SettingsLayouts from "@/layouts/settings-layouts";
export default function AnalyticsPage() {
const [timeRange, setTimeRange] = useTimeRange();
const { personas } = useAdminPersonas();
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgActivity}
title="Usage Statistics"
separator
<>
<AdminPageTitle title="Usage Statistics" icon={SvgActivity} />
<AdminDateRangeSelector
value={timeRange}
onValueChange={(value) => setTimeRange(value as any)}
/>
<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>
<QueryPerformanceChart timeRange={timeRange} />
<FeedbackChart timeRange={timeRange} />
<OnyxBotChart timeRange={timeRange} />
<PersonaMessagesChart
availablePersonas={personas}
timeRange={timeRange}
/>
<Separator />
<UsageReports />
</>
);
}

View File

@@ -1,8 +1,9 @@
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 * as SettingsLayouts from "@/layouts/settings-layouts";
import { SvgClipboard } from "@opal/icons";
import BackButton from "@/refresh-components/buttons/BackButton";
import { ClipboardIcon } from "@/components/icons/icons";
import { StandardAnswer, StandardAnswerCategory } from "@/lib/types";
async function Page(props: { params: Promise<{ id: string }> }) {
@@ -67,20 +68,18 @@ async function Page(props: { params: Promise<{ id: string }> }) {
const standardAnswerCategories =
(await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[];
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgClipboard}
<>
<BackButton />
<AdminPageTitle
title="Edit Standard Answer"
backButton
separator
icon={<ClipboardIcon size={32} />}
/>
<SettingsLayouts.Body>
<StandardAnswerCreationForm
standardAnswerCategories={standardAnswerCategories}
existingStandardAnswer={standardAnswer}
/>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<StandardAnswerCreationForm
standardAnswerCategories={standardAnswerCategories}
existingStandardAnswer={standardAnswer}
/>
</>
);
}

View File

@@ -1,8 +1,9 @@
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 * as SettingsLayouts from "@/layouts/settings-layouts";
import { SvgClipboard } from "@opal/icons";
import BackButton from "@/refresh-components/buttons/BackButton";
import { ClipboardIcon } from "@/components/icons/icons";
import { StandardAnswerCategory } from "@/lib/types";
async function Page() {
@@ -22,19 +23,17 @@ async function Page() {
(await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[];
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgClipboard}
<>
<BackButton />
<AdminPageTitle
title="New Standard Answer"
backButton
separator
icon={<ClipboardIcon size={32} />}
/>
<SettingsLayouts.Body>
<StandardAnswerCreationForm
standardAnswerCategories={standardAnswerCategories}
/>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<StandardAnswerCreationForm
standardAnswerCategories={standardAnswerCategories}
/>
</>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { EditIcon } from "@/components/icons/icons";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { AdminPageTitle } from "@/components/admin/Title";
import { ClipboardIcon, EditIcon } from "@/components/icons/icons";
import { toast } from "@/hooks/useToast";
import { useStandardAnswers, useStandardAnswerCategories } from "./hooks";
import { ThreeDotsLoader } from "@/components/Loading";
@@ -29,7 +29,7 @@ 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 { SvgClipboard, SvgTrash } from "@opal/icons";
import { SvgTrash } from "@opal/icons";
import { Button } from "@opal/components";
const NUM_RESULTS_PER_PAGE = 10;
@@ -417,16 +417,13 @@ const Main = () => {
const Page = () => {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgClipboard}
<>
<AdminPageTitle
icon={<ClipboardIcon size={32} />}
title="Standard Answers"
separator
/>
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<Main />
</>
);
};

View File

@@ -30,7 +30,6 @@ import GatedContentWrapper from "@/components/GatedContentWrapper";
import { TooltipProvider } from "@/components/ui/tooltip";
import { fetchAppSidebarMetadata } from "@/lib/appSidebarSS";
import StatsOverlayLoader from "@/components/dev/StatsOverlayLoader";
import AppHealthBanner from "@/sections/AppHealthBanner";
const inter = Inter({
subsets: ["latin"],
@@ -129,10 +128,7 @@ export default async function RootLayout({
>
<div className="text-text min-h-screen bg-background">
<TooltipProvider>
<PHProvider>
<AppHealthBanner />
{content}
</PHProvider>
<PHProvider>{content}</PHProvider>
</TooltipProvider>
</div>
</ThemeProvider>

View File

@@ -28,23 +28,6 @@ const SETTINGS_LAYOUT_PREFIXES = [
"/admin/discord-bot",
"/admin/theme",
"/admin/configuration/llm",
"/admin/agents",
"/admin/users",
"/admin/token-rate-limits",
"/admin/configuration/search",
"/admin/configuration/document-processing",
"/admin/configuration/code-interpreter",
"/admin/api-key",
"/admin/add-connector",
"/admin/indexing/status",
"/admin/documents",
"/admin/debug",
"/admin/kg",
"/admin/bots",
"/ee/admin/standard-answer",
"/ee/admin/groups",
"/ee/admin/billing",
"/ee/admin/performance",
];
export function ClientLayout({

View File

@@ -1,6 +1,7 @@
"use client";
import { JSX } from "react";
import { HealthCheckBanner } from "../health/healthcheck";
import Separator from "@/refresh-components/Separator";
import type { IconProps } from "@opal/types";
import Text from "@/refresh-components/texts/Text";
@@ -20,6 +21,9 @@ export function AdminPageTitle({
}: AdminPageTitleProps) {
return (
<div className="w-full">
<div className="mb-4">
<HealthCheckBanner />
</div>
<div className="w-full flex flex-row justify-between">
<div className="flex flex-row gap-2">
{typeof Icon === "function" ? (

View File

@@ -5,16 +5,14 @@ import useSWR from "swr";
import Modal from "@/refresh-components/Modal";
import { useCallback, useEffect, useState, useRef } from "react";
import { getSecondsUntilExpiration } from "@/lib/time";
import { refreshToken } from "@/lib/user";
import { User } from "@/lib/types";
import { refreshToken } from "./refreshUtils";
import { NEXT_PUBLIC_CUSTOM_REFRESH_URL } from "@/lib/constants";
import Button from "@/refresh-components/buttons/Button";
import { logout } from "@/lib/user";
import { usePathname, useRouter } from "next/navigation";
import { SvgAlertTriangle, SvgLogOut } from "@opal/icons";
import { Content } from "@opal/layouts";
import { useCurrentUser } from "@/hooks/useCurrentUser";
export default function AppHealthBanner() {
import { SvgLogOut } from "@opal/icons";
export const HealthCheckBanner = () => {
const router = useRouter();
const { error } = useSWR("/api/health", errorHandlingFetcher);
const [expired, setExpired] = useState(false);
@@ -23,7 +21,16 @@ export default function AppHealthBanner() {
const expirationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const refreshIntervalRef = useRef<NodeJS.Timer | null>(null);
const { user, mutateUser, userError } = useCurrentUser();
// Reduce revalidation frequency with dedicated SWR config
const {
data: user,
mutate: mutateUser,
error: userError,
} = useSWR<User>("/api/me", errorHandlingFetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30000, // 30 seconds
});
// Handle 403 errors from the /api/me endpoint
useEffect(() => {
@@ -37,10 +44,10 @@ export default function AppHealthBanner() {
}, [userError, pathname]);
// Function to handle the "Log in" button click
function handleLogin() {
const handleLogin = () => {
setShowLoggedOutModal(false);
router.push("/auth/login");
}
};
// Function to set up expiration timeout
const setupExpirationTimeout = useCallback(
@@ -204,15 +211,16 @@ export default function AppHealthBanner() {
return null;
} else {
return (
<div className="fixed top-0 left-0 z-[101] w-full bg-status-error-01 p-3">
<Content
icon={SvgAlertTriangle}
title="The backend is currently unavailable"
description="If this is your initial setup or you just updated your Onyx deployment, this is likely because the backend is still starting up. Give it a minute or two, and then refresh the page. If that does not work, make sure the backend is setup and/or contact an administrator."
sizePreset="main-content"
variant="section"
/>
<div className="fixed top-0 left-0 z-[101] w-full text-xs mx-auto bg-gradient-to-r from-red-900 to-red-700 p-2 rounded-sm border-hidden text-neutral-50 dark:text-neutral-100">
<p className="font-bold pb-1">The backend is currently unavailable.</p>
<p className="px-1">
If this is your initial setup or you just updated your Onyx
deployment, this is likely because the backend is still starting up.
Give it a minute or two, and then refresh the page. If that does not
work, make sure the backend is setup and/or contact an administrator.
</p>
</div>
);
}
}
};

View File

@@ -0,0 +1,64 @@
export interface CustomRefreshTokenResponse {
access_token: string;
refresh_token: string;
session: {
exp: number;
};
userinfo: {
sub: string;
familyName: string;
givenName: string;
fullName: string;
userId: string;
email: string;
};
}
export function mockedRefreshToken(): CustomRefreshTokenResponse {
/**
* This function mocks the response from a token refresh endpoint.
* It generates a mock access token, refresh token, and user information
* with an expiration time set to 1 hour from now.
* This is useful for testing or development when the actual refresh endpoint is not available.
*/
const mockExp = Date.now() + 3600000; // 1 hour from now in milliseconds
const data: CustomRefreshTokenResponse = {
access_token: "Mock access token",
refresh_token: "Mock refresh token",
session: { exp: mockExp },
userinfo: {
sub: "Mock email",
familyName: "Mock name",
givenName: "Mock name",
fullName: "Mock name",
userId: "Mock User ID",
email: "email@onyx.app",
},
};
return data;
}
export async function refreshToken(
customRefreshUrl: string
): Promise<CustomRefreshTokenResponse | null> {
try {
console.debug("Sending request to custom refresh URL");
// support both absolute and relative
const url = customRefreshUrl.startsWith("http")
? new URL(customRefreshUrl)
: new URL(customRefreshUrl, window.location.origin);
url.searchParams.append("info", "json");
url.searchParams.append("access_token_refresh_interval", "3600");
const response = await fetch(url.toString());
if (!response.ok) {
console.error(`Failed to refresh token: ${await response.text()}`);
return null;
}
return await response.json();
} catch (error) {
console.error("Error refreshing token:", error);
throw error;
}
}

View File

@@ -1,42 +0,0 @@
import useSWR, { type KeyedMutator } from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { User } from "@/lib/types";
/**
* Fetches the current authenticated user via SWR (`/api/me`).
*
* This hook is intentionally configured with conservative revalidation
* settings to avoid hammering the backend on every focus/reconnect event:
*
* - `revalidateOnFocus: false` — tab switches won't trigger a refetch
* - `revalidateOnReconnect: false` — network recovery won't trigger a refetch
* - `dedupingInterval: 30_000` — duplicate requests within 30 s are deduped
*
* The returned `mutateUser` handle lets callers imperatively refetch (e.g.
* after a token refresh) without changing the global SWR config.
*
* @example
* ```ts
* const { user, mutateUser, userError } = useCurrentUser();
* ```
*/
export function useCurrentUser(): {
/** The authenticated user, or `undefined` while loading. */
user: User | undefined;
/** Imperatively revalidate / update the cached user. */
mutateUser: KeyedMutator<User>;
/** The error thrown by the fetcher, if any. */
userError: (Error & { status?: number }) | undefined;
} {
const { data, mutate, error } = useSWR<User>(
"/api/me",
errorHandlingFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30_000,
}
);
return { user: data, mutateUser: mutate, userError: error };
}

View File

@@ -72,44 +72,3 @@ export const basicSignup = async (
});
return response;
};
export interface CustomRefreshTokenResponse {
access_token: string;
refresh_token: string;
session: {
exp: number;
};
userinfo: {
sub: string;
familyName: string;
givenName: string;
fullName: string;
userId: string;
email: string;
};
}
export async function refreshToken(
customRefreshUrl: string
): Promise<CustomRefreshTokenResponse | null> {
try {
console.debug("Sending request to custom refresh URL");
// support both absolute and relative
const url = customRefreshUrl.startsWith("http")
? new URL(customRefreshUrl)
: new URL(customRefreshUrl, window.location.origin);
url.searchParams.append("info", "json");
url.searchParams.append("access_token_refresh_interval", "3600");
const response = await fetch(url.toString());
if (!response.ok) {
console.error(`Failed to refresh token: ${await response.text()}`);
return null;
}
return await response.json();
} catch (error) {
console.error("Error refreshing token:", error);
throw error;
}
}

View File

@@ -1,6 +1,7 @@
"use client";
import { redirect, useRouter, useSearchParams } from "next/navigation";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import {
personaIncludesRetrieval,
getAvailableContextTokens,
@@ -627,7 +628,12 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
// handle error case where no assistants are available
// Only show this after agents have loaded to prevent flash during initial load
if (noAgents && !isLoadingAgents) {
return <NoAgentModal />;
return (
<>
<HealthCheckBanner />
<NoAgentModal />
</>
);
}
const hasStarterMessages = (liveAgent?.starter_messages?.length ?? 0) > 0;
@@ -648,6 +654,8 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
return (
<>
<HealthCheckBanner />
<AppPopup />
{retrievalEnabled && documentSidebarVisible && settings.isMobile && (

View File

@@ -705,6 +705,7 @@ const AppInputBar = React.memo(
{/* Controls that load in when data is ready */}
<div
data-testid="actions-container"
className={cn(
"flex flex-row items-center",
controlsLoading && "invisible"

View File

@@ -29,7 +29,11 @@ const DEFAULT_MASK_SELECTORS: string[] = [
* Default selectors to hide (visibility: hidden) across all screenshots.
* These elements are overlays or ephemeral UI that would cause spurious diffs.
*/
const DEFAULT_HIDE_SELECTORS: string[] = ['[data-testid="toast-container"]'];
const DEFAULT_HIDE_SELECTORS: string[] = [
'[data-testid="toast-container"]',
// TODO: Remove once it loads consistently.
'[data-testid="actions-container"]',
];
interface ScreenshotOptions {
/**