Compare commits

..

1 Commits

Author SHA1 Message Date
Jamison Lahman
bfdeb65bbb feat(ux): handle when chat session id cannot be found 2026-03-20 16:44:34 -07:00
127 changed files with 1502 additions and 4080 deletions

View File

@@ -44,7 +44,7 @@ jobs:
fetch-tags: true
- name: Setup uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
with:
version: "0.9.9"
enable-cache: false
@@ -165,7 +165,7 @@ jobs:
fetch-depth: 0
- name: Setup uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
with:
version: "0.9.9"
# NOTE: This isn't caching much and zizmor suggests this could be poisoned, so disable.
@@ -307,7 +307,7 @@ jobs:
xdg-utils
- name: setup node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v6.2.0
with:
node-version: 24
package-manager-cache: false

View File

@@ -114,7 +114,7 @@ jobs:
ref: main
- name: Install the latest version of uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false
version: "0.9.9"

View File

@@ -50,7 +50,7 @@ jobs:
persist-credentials: false
- name: Setup node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238
with:
node-version: 24
cache: "npm" # zizmor: ignore[cache-poisoning]

View File

@@ -28,7 +28,7 @@ jobs:
persist-credentials: false
- name: Setup node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v4
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v4
with:
node-version: 22
cache: "npm" # zizmor: ignore[cache-poisoning] test-only workflow; no deploy artifacts

View File

@@ -272,7 +272,7 @@ jobs:
- name: Setup node
# zizmor: ignore[cache-poisoning] ephemeral runners; no release artifacts
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v4
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v4
with:
node-version: 22
cache: "npm" # zizmor: ignore[cache-poisoning]
@@ -471,7 +471,7 @@ jobs:
- name: Install the latest version of uv
if: always()
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false
version: "0.9.9"
@@ -614,7 +614,7 @@ jobs:
- name: Setup node
# zizmor: ignore[cache-poisoning] ephemeral runners; no release artifacts
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v4
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v4
with:
node-version: 22
cache: "npm" # zizmor: ignore[cache-poisoning]

View File

@@ -73,7 +73,7 @@ jobs:
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Build and load
uses: docker/bake-action@82490499d2e5613fcead7e128237ef0b0ea210f7 # ratchet:docker/bake-action@v7.0.0
uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # ratchet:docker/bake-action@v6
env:
TAG: model-server-${{ github.run_id }}
with:

View File

@@ -30,7 +30,7 @@ jobs:
- name: Setup Terraform
uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # ratchet:hashicorp/setup-terraform@v4.0.0
- name: Setup node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v6
with: # zizmor: ignore[cache-poisoning]
node-version: 22
cache: "npm"

View File

@@ -22,7 +22,7 @@ jobs:
persist-credentials: false
- name: Setup node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v4
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v4
with:
node-version: 22
cache: "npm"

View File

@@ -26,7 +26,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false
version: "0.9.9"

View File

@@ -26,7 +26,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false
version: "0.9.9"

View File

@@ -32,7 +32,7 @@ jobs:
persist-credentials: false
- name: Setup node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v4
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v4
with:
node-version: 22
cache: "npm"

View File

@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false
version: "0.9.9"

View File

@@ -25,6 +25,9 @@ from onyx.redis.redis_pool import get_redis_client
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import TENANT_ID_PREFIX
# Default number of pre-provisioned tenants to maintain
DEFAULT_TARGET_AVAILABLE_TENANTS = 5
# Soft time limit for tenant pre-provisioning tasks (in seconds)
_TENANT_PROVISIONING_SOFT_TIME_LIMIT = 60 * 5 # 5 minutes
# Hard time limit for tenant pre-provisioning tasks (in seconds)
@@ -55,7 +58,7 @@ def check_available_tenants(self: Task) -> None: # noqa: ARG001
r = get_redis_client(tenant_id=ONYX_CLOUD_TENANT_ID)
lock_check: RedisLock = r.lock(
OnyxRedisLocks.CHECK_AVAILABLE_TENANTS_LOCK,
timeout=_TENANT_PROVISIONING_TIME_LIMIT,
timeout=_TENANT_PROVISIONING_SOFT_TIME_LIMIT,
)
# These tasks should never overlap
@@ -71,7 +74,9 @@ def check_available_tenants(self: Task) -> None: # noqa: ARG001
num_available_tenants = db_session.query(AvailableTenant).count()
# Get the target number of available tenants
num_minimum_available_tenants = TARGET_AVAILABLE_TENANTS
num_minimum_available_tenants = getattr(
TARGET_AVAILABLE_TENANTS, "value", DEFAULT_TARGET_AVAILABLE_TENANTS
)
# Calculate how many new tenants we need to provision
if num_available_tenants < num_minimum_available_tenants:
@@ -93,12 +98,7 @@ def check_available_tenants(self: Task) -> None: # noqa: ARG001
task_logger.exception("Error in check_available_tenants task")
finally:
try:
lock_check.release()
except Exception:
task_logger.warning(
"Could not release check lock (likely expired), continuing"
)
lock_check.release()
def pre_provision_tenant() -> None:
@@ -113,7 +113,7 @@ def pre_provision_tenant() -> None:
r = get_redis_client(tenant_id=ONYX_CLOUD_TENANT_ID)
lock_provision: RedisLock = r.lock(
OnyxRedisLocks.CLOUD_PRE_PROVISION_TENANT_LOCK,
timeout=_TENANT_PROVISIONING_TIME_LIMIT,
timeout=_TENANT_PROVISIONING_SOFT_TIME_LIMIT,
)
# Allow multiple pre-provisioning tasks to run, but ensure they don't overlap
@@ -185,9 +185,4 @@ def pre_provision_tenant() -> None:
except Exception:
task_logger.exception(f"Error during rollback for tenant: {tenant_id}")
finally:
try:
lock_provision.release()
except Exception:
task_logger.warning(
"Could not release provision lock (likely expired), continuing"
)
lock_provision.release()

View File

@@ -157,11 +157,7 @@ def fetch_logo_helper(db_session: Session) -> Response: # noqa: ARG001
detail="No logo file found",
)
else:
return Response(
content=onyx_file.data,
media_type=onyx_file.mime_type,
headers={"Cache-Control": "no-cache"},
)
return Response(content=onyx_file.data, media_type=onyx_file.mime_type)
def fetch_logotype_helper(db_session: Session) -> Response: # noqa: ARG001

View File

@@ -9,12 +9,12 @@ from onyx.configs.app_configs import AUTO_LLM_UPDATE_INTERVAL_SECONDS
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.app_configs import ENABLE_OPENSEARCH_INDEXING_FOR_ONYX
from onyx.configs.app_configs import ENTERPRISE_EDITION_ENABLED
from onyx.configs.app_configs import HOOK_ENABLED
from onyx.configs.app_configs import SCHEDULED_EVAL_DATASET_NAMES
from onyx.configs.constants import ONYX_CLOUD_CELERY_TASK_PREFIX
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.hooks.utils import HOOKS_AVAILABLE
from shared_configs.configs import MULTI_TENANT
# choosing 15 minutes because it roughly gives us enough time to process many tasks
@@ -362,7 +362,7 @@ if not MULTI_TENANT:
tasks_to_schedule.extend(beat_task_templates)
if HOOKS_AVAILABLE:
if not MULTI_TENANT and HOOK_ENABLED:
tasks_to_schedule.append(
{
"name": "hook-execution-log-cleanup",

View File

@@ -30,8 +30,6 @@ from onyx.file_processing.extract_file_text import extract_file_text
from onyx.file_store.file_store import get_default_file_store
from onyx.file_store.models import ChatFileType
from onyx.file_store.models import FileDescriptor
from onyx.file_store.utils import plaintext_file_name_for_id
from onyx.file_store.utils import store_plaintext
from onyx.kg.models import KGException
from onyx.kg.setup.kg_default_entity_definitions import (
populate_missing_default_entity_types__commit,
@@ -291,33 +289,6 @@ def process_kg_commands(
raise KGException("KG setup done")
def _get_or_extract_plaintext(
file_id: str,
extract_fn: Callable[[], str],
) -> str:
"""Load cached plaintext for a file, or extract and store it.
Tries to read pre-stored plaintext from the file store. On a miss,
calls extract_fn to produce the text, then stores the result so
future calls skip the expensive extraction.
"""
file_store = get_default_file_store()
plaintext_key = plaintext_file_name_for_id(file_id)
# Try cached plaintext first.
try:
plaintext_io = file_store.read_file(plaintext_key, mode="b")
return plaintext_io.read().decode("utf-8")
except Exception:
logger.exception(f"Error when reading file, id={file_id}")
# Cache miss — extract and store.
content_text = extract_fn()
if content_text:
store_plaintext(file_id, content_text)
return content_text
@log_function_time(print_only=True)
def load_chat_file(
file_descriptor: FileDescriptor, db_session: Session
@@ -332,23 +303,12 @@ def load_chat_file(
file_type = ChatFileType(file_descriptor["type"])
if file_type.is_text_file():
file_id = file_descriptor["id"]
def _extract() -> str:
return extract_file_text(
try:
content_text = extract_file_text(
file=file_io,
file_name=file_descriptor.get("name") or "",
break_on_unprocessable=False,
)
# Use the user_file_id as cache key when available (matches what
# the celery indexing worker stores), otherwise fall back to the
# file store id (covers code-interpreter-generated files, etc.).
user_file_id_str = file_descriptor.get("user_file_id")
cache_key = user_file_id_str or file_id
try:
content_text = _get_or_extract_plaintext(cache_key, _extract)
except Exception as e:
logger.warning(
f"Failed to retrieve content for file {file_descriptor['id']}: {str(e)}"

View File

@@ -88,9 +88,8 @@ WEB_CONNECTOR_MAX_SCROLL_ATTEMPTS = 20
IFRAME_TEXT_LENGTH_THRESHOLD = 700
# Message indicating JavaScript is disabled, which often appears when scraping fails
JAVASCRIPT_DISABLED_MESSAGE = "You have JavaScript disabled in your browser"
# Grace period after page navigation to allow bot-detection challenges
# and SPA content rendering to complete
PAGE_RENDER_TIMEOUT_MS = 5000
# Grace period after page navigation to allow bot-detection challenges to complete
BOT_DETECTION_GRACE_PERIOD_MS = 5000
# Define common headers that mimic a real browser
DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
@@ -548,15 +547,7 @@ class WebConnector(LoadConnector):
)
# Give the page a moment to start rendering after navigation commits.
# Allows CloudFlare and other bot-detection challenges to complete.
page.wait_for_timeout(PAGE_RENDER_TIMEOUT_MS)
# Wait for network activity to settle so SPAs that fetch content
# asynchronously after the initial JS bundle have time to render.
try:
# A bit of extra time to account for long-polling, websockets, etc.
page.wait_for_load_state("networkidle", timeout=PAGE_RENDER_TIMEOUT_MS)
except TimeoutError:
pass
page.wait_for_timeout(BOT_DETECTION_GRACE_PERIOD_MS)
last_modified = (
page_response.header_value("Last-Modified") if page_response else None
@@ -585,7 +576,7 @@ class WebConnector(LoadConnector):
# (e.g., CloudFlare protection keeps making requests)
try:
page.wait_for_load_state(
"networkidle", timeout=PAGE_RENDER_TIMEOUT_MS
"networkidle", timeout=BOT_DETECTION_GRACE_PERIOD_MS
)
except TimeoutError:
# If networkidle times out, just give it a moment for content to render

View File

@@ -583,67 +583,6 @@ def get_latest_index_attempt_for_cc_pair_id(
return db_session.execute(stmt).scalar_one_or_none()
def get_latest_successful_index_attempt_for_cc_pair_id(
db_session: Session,
connector_credential_pair_id: int,
secondary_index: bool = False,
) -> IndexAttempt | None:
"""Returns the most recent successful index attempt for the given cc pair,
filtered to the current (or future) search settings.
Uses MAX(id) semantics to match get_latest_index_attempts_by_status."""
status = IndexModelStatus.FUTURE if secondary_index else IndexModelStatus.PRESENT
stmt = (
select(IndexAttempt)
.where(
IndexAttempt.connector_credential_pair_id == connector_credential_pair_id,
IndexAttempt.status.in_(
[IndexingStatus.SUCCESS, IndexingStatus.COMPLETED_WITH_ERRORS]
),
)
.join(SearchSettings)
.where(SearchSettings.status == status)
.order_by(desc(IndexAttempt.id))
.limit(1)
)
return db_session.execute(stmt).scalar_one_or_none()
def get_latest_successful_index_attempts_parallel(
secondary_index: bool = False,
) -> Sequence[IndexAttempt]:
"""Batch version: returns the latest successful index attempt per cc pair.
Covers both SUCCESS and COMPLETED_WITH_ERRORS (matching is_successful())."""
model_status = (
IndexModelStatus.FUTURE if secondary_index else IndexModelStatus.PRESENT
)
with get_session_with_current_tenant() as db_session:
latest_ids = (
select(
IndexAttempt.connector_credential_pair_id,
func.max(IndexAttempt.id).label("max_id"),
)
.join(SearchSettings, IndexAttempt.search_settings_id == SearchSettings.id)
.where(
SearchSettings.status == model_status,
IndexAttempt.status.in_(
[IndexingStatus.SUCCESS, IndexingStatus.COMPLETED_WITH_ERRORS]
),
)
.group_by(IndexAttempt.connector_credential_pair_id)
.subquery()
)
stmt = select(IndexAttempt).join(
latest_ids,
(
IndexAttempt.connector_credential_pair_id
== latest_ids.c.connector_credential_pair_id
)
& (IndexAttempt.id == latest_ids.c.max_id),
)
return db_session.execute(stmt).scalars().all()
def count_index_attempts_for_cc_pair(
db_session: Session,
cc_pair_id: int,

View File

@@ -2,7 +2,6 @@ import time
from sqlalchemy.orm import Session
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.app_configs import VESPA_NUM_ATTEMPTS_ON_STARTUP
from onyx.configs.constants import KV_REINDEX_KEY
from onyx.db.connector_credential_pair import get_connector_credential_pairs
@@ -150,9 +149,6 @@ def check_and_perform_index_swap(db_session: Session) -> SearchSettings | None:
Returns None if search settings did not change, or the old search settings if they
did change.
"""
if DISABLE_VECTOR_DB:
return None
# Default CC-pair created for Ingestion API unused here
all_cc_pairs = get_connector_credential_pairs(db_session)
cc_pair_count = max(len(all_cc_pairs) - 1, 0)

View File

@@ -88,7 +88,6 @@ class OnyxErrorCode(Enum):
SERVICE_UNAVAILABLE = ("SERVICE_UNAVAILABLE", 503)
BAD_GATEWAY = ("BAD_GATEWAY", 502)
LLM_PROVIDER_ERROR = ("LLM_PROVIDER_ERROR", 502)
HOOK_EXECUTION_FAILED = ("HOOK_EXECUTION_FAILED", 502)
GATEWAY_TIMEOUT = ("GATEWAY_TIMEOUT", 504)
def __init__(self, code: str, status_code: int) -> None:

View File

@@ -23,55 +23,45 @@ from onyx.utils.timing import log_function_time
logger = setup_logger()
def plaintext_file_name_for_id(file_id: str) -> str:
"""Generate a consistent file name for storing plaintext content of a file."""
return f"plaintext_{file_id}"
def user_file_id_to_plaintext_file_name(user_file_id: UUID) -> str:
"""Generate a consistent file name for storing plaintext content of a user file."""
return f"plaintext_{user_file_id}"
def store_plaintext(file_id: str, plaintext_content: str) -> bool:
def store_user_file_plaintext(user_file_id: UUID, plaintext_content: str) -> bool:
"""
Store plaintext content for a file in the file store.
Store plaintext content for a user file in the file store.
Args:
file_id: The ID of the file (user_file or artifact_file)
user_file_id: The ID of the user file
plaintext_content: The plaintext content to store
Returns:
bool: True if storage was successful, False otherwise
"""
# Skip empty content
if not plaintext_content:
return False
plaintext_file_name = plaintext_file_name_for_id(file_id)
# Get plaintext file name
plaintext_file_name = user_file_id_to_plaintext_file_name(user_file_id)
try:
file_store = get_default_file_store()
file_content = BytesIO(plaintext_content.encode("utf-8"))
file_store.save_file(
content=file_content,
display_name=f"Plaintext for {file_id}",
display_name=f"Plaintext for user file {user_file_id}",
file_origin=FileOrigin.PLAINTEXT_CACHE,
file_type="text/plain",
file_id=plaintext_file_name,
)
return True
except Exception as e:
logger.warning(f"Failed to store plaintext for {file_id}: {e}")
logger.warning(f"Failed to store plaintext for user file {user_file_id}: {e}")
return False
# --- Convenience wrappers for callers that use user-file UUIDs ---
def user_file_id_to_plaintext_file_name(user_file_id: UUID) -> str:
"""Generate a consistent file name for storing plaintext content of a user file."""
return plaintext_file_name_for_id(str(user_file_id))
def store_user_file_plaintext(user_file_id: UUID, plaintext_content: str) -> bool:
"""Store plaintext content for a user file (delegates to :func:`store_plaintext`)."""
return store_plaintext(str(user_file_id), plaintext_content)
def load_chat_file_by_id(file_id: str) -> InMemoryChatFile:
"""Load a file directly from the file store using its file_record ID.

View File

@@ -1,330 +0,0 @@
"""Hook executor — calls a customer's external HTTP endpoint for a given hook point.
Usage (Celery tasks and FastAPI handlers):
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload={"query": "...", "user_email": "...", "chat_session_id": "..."},
)
if isinstance(result, HookSkipped):
# no active hook configured — continue with original behavior
...
elif isinstance(result, HookSoftFailed):
# hook failed but fail strategy is SOFT — continue with original behavior
...
else:
# result is the response payload dict from the customer's endpoint
...
is_reachable update policy
--------------------------
``is_reachable`` on the Hook row is updated selectively — only when the outcome
carries meaningful signal about physical reachability:
NetworkError (DNS, connection refused) → False (cannot reach the server)
HTTP 401 / 403 → False (api_key revoked or invalid)
TimeoutException → None (server may be slow, skip write)
Other HTTP errors (4xx / 5xx) → None (server responded, skip write)
Unknown exception → None (no signal, skip write)
Non-JSON / non-dict response → None (server responded, skip write)
Success (2xx, valid dict) → True (confirmed reachable)
None means "leave the current value unchanged" — no DB round-trip is made.
DB session design
-----------------
The executor uses three sessions:
1. Caller's session (db_session) — used only for the hook lookup read. All
needed fields are extracted from the Hook object before the HTTP call, so
the caller's session is not held open during the external HTTP request.
2. Log session — a separate short-lived session opened after the HTTP call
completes to write the HookExecutionLog row on failure. Success runs are
not recorded. Committed independently of everything else.
3. Reachable session — a second short-lived session to update is_reachable on
the Hook. Kept separate from the log session so a concurrent hook deletion
(which causes update_hook__no_commit to raise OnyxError(NOT_FOUND)) cannot
prevent the execution log from being written. This update is best-effort.
"""
import json
import time
from typing import Any
import httpx
from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
from onyx.db.hook import create_hook_execution_log__no_commit
from onyx.db.hook import get_non_deleted_hook_by_hook_point
from onyx.db.hook import update_hook__no_commit
from onyx.db.models import Hook
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.utils import HOOKS_AVAILABLE
from onyx.utils.logger import setup_logger
logger = setup_logger()
class HookSkipped:
"""No active hook configured for this hook point."""
class HookSoftFailed:
"""Hook was called but failed with SOFT fail strategy — continuing."""
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
class _HttpOutcome(BaseModel):
"""Structured result of an HTTP hook call, returned by _process_response."""
is_success: bool
updated_is_reachable: (
bool | None
) # True/False = write to DB, None = unchanged (skip write)
status_code: int | None
error_message: str | None
response_payload: dict[str, Any] | None
def _lookup_hook(
db_session: Session,
hook_point: HookPoint,
) -> Hook | HookSkipped:
"""Return the active Hook or HookSkipped if hooks are unavailable/unconfigured.
No HTTP call is made and no DB writes are performed for any HookSkipped path.
There is nothing to log and no reachability information to update.
"""
if not HOOKS_AVAILABLE:
return HookSkipped()
hook = get_non_deleted_hook_by_hook_point(
db_session=db_session, hook_point=hook_point
)
if hook is None or not hook.is_active:
return HookSkipped()
if not hook.endpoint_url:
return HookSkipped()
return hook
def _process_response(
*,
response: httpx.Response | None,
exc: Exception | None,
timeout: float,
) -> _HttpOutcome:
"""Process the result of an HTTP call and return a structured outcome.
Called after the client.post() try/except. If post() raised, exc is set and
response is None. Otherwise response is set and exc is None. Handles
raise_for_status(), JSON decoding, and the dict shape check.
"""
if exc is not None:
if isinstance(exc, httpx.NetworkError):
msg = f"Hook network error (endpoint unreachable): {exc}"
logger.warning(msg, exc_info=exc)
return _HttpOutcome(
is_success=False,
updated_is_reachable=False,
status_code=None,
error_message=msg,
response_payload=None,
)
if isinstance(exc, httpx.TimeoutException):
msg = f"Hook timed out after {timeout}s: {exc}"
logger.warning(msg, exc_info=exc)
return _HttpOutcome(
is_success=False,
updated_is_reachable=None, # timeout doesn't indicate unreachability
status_code=None,
error_message=msg,
response_payload=None,
)
msg = f"Hook call failed: {exc}"
logger.exception(msg, exc_info=exc)
return _HttpOutcome(
is_success=False,
updated_is_reachable=None, # unknown error — don't make assumptions
status_code=None,
error_message=msg,
response_payload=None,
)
if response is None:
raise ValueError(
"exactly one of response or exc must be non-None; both are None"
)
status_code = response.status_code
try:
response.raise_for_status()
except httpx.HTTPStatusError as e:
msg = f"Hook returned HTTP {e.response.status_code}: {e.response.text}"
logger.warning(msg, exc_info=e)
# 401/403 means the api_key has been revoked or is invalid — mark unreachable
# so the operator knows to update it. All other HTTP errors keep is_reachable
# as-is (server is up, the request just failed for application reasons).
auth_failed = e.response.status_code in (401, 403)
return _HttpOutcome(
is_success=False,
updated_is_reachable=False if auth_failed else None,
status_code=status_code,
error_message=msg,
response_payload=None,
)
try:
response_payload = response.json()
except (json.JSONDecodeError, httpx.DecodingError) as e:
msg = f"Hook returned non-JSON response: {e}"
logger.warning(msg, exc_info=e)
return _HttpOutcome(
is_success=False,
updated_is_reachable=None, # server responded — reachability unchanged
status_code=status_code,
error_message=msg,
response_payload=None,
)
if not isinstance(response_payload, dict):
msg = f"Hook returned non-dict JSON (got {type(response_payload).__name__})"
logger.warning(msg)
return _HttpOutcome(
is_success=False,
updated_is_reachable=None, # server responded — reachability unchanged
status_code=status_code,
error_message=msg,
response_payload=None,
)
return _HttpOutcome(
is_success=True,
updated_is_reachable=True,
status_code=status_code,
error_message=None,
response_payload=response_payload,
)
def _persist_result(
*,
hook_id: int,
outcome: _HttpOutcome,
duration_ms: int,
) -> None:
"""Write the execution log on failure and optionally update is_reachable, each
in its own session so a failure in one does not affect the other."""
# Only write the execution log on failure — success runs are not recorded.
# Must not be skipped if the is_reachable update fails (e.g. hook concurrently
# deleted between the initial lookup and here).
if not outcome.is_success:
try:
with get_session_with_current_tenant() as log_session:
create_hook_execution_log__no_commit(
db_session=log_session,
hook_id=hook_id,
is_success=False,
error_message=outcome.error_message,
status_code=outcome.status_code,
duration_ms=duration_ms,
)
log_session.commit()
except Exception:
logger.exception(
f"Failed to persist hook execution log for hook_id={hook_id}"
)
# Update is_reachable separately — best-effort, non-critical.
# None means the value is unchanged (set by the caller to skip the no-op write).
# update_hook__no_commit can raise OnyxError(NOT_FOUND) if the hook was
# concurrently deleted, so keep this isolated from the log write above.
if outcome.updated_is_reachable is not None:
try:
with get_session_with_current_tenant() as reachable_session:
update_hook__no_commit(
db_session=reachable_session,
hook_id=hook_id,
is_reachable=outcome.updated_is_reachable,
)
reachable_session.commit()
except Exception:
logger.warning(f"Failed to update is_reachable for hook_id={hook_id}")
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def execute_hook(
*,
db_session: Session,
hook_point: HookPoint,
payload: dict[str, Any],
) -> dict[str, Any] | HookSkipped | HookSoftFailed:
"""Execute the hook for the given hook point synchronously."""
hook = _lookup_hook(db_session, hook_point)
if isinstance(hook, HookSkipped):
return hook
timeout = hook.timeout_seconds
hook_id = hook.id
fail_strategy = hook.fail_strategy
endpoint_url = hook.endpoint_url
current_is_reachable: bool | None = hook.is_reachable
if not endpoint_url:
raise ValueError(
f"hook_id={hook_id} is active but has no endpoint_url — "
"active hooks without an endpoint_url must be rejected by _lookup_hook"
)
start = time.monotonic()
response: httpx.Response | None = None
exc: Exception | None = None
try:
api_key: str | None = (
hook.api_key.get_value(apply_mask=False) if hook.api_key else None
)
headers: dict[str, str] = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
with httpx.Client(timeout=timeout) as client:
response = client.post(endpoint_url, json=payload, headers=headers)
except Exception as e:
exc = e
duration_ms = int((time.monotonic() - start) * 1000)
outcome = _process_response(response=response, exc=exc, timeout=timeout)
# Skip the is_reachable write when the value would not change — avoids a
# no-op DB round-trip on every call when the hook is already in the expected state.
if outcome.updated_is_reachable == current_is_reachable:
outcome = outcome.model_copy(update={"updated_is_reachable": None})
_persist_result(hook_id=hook_id, outcome=outcome, duration_ms=duration_ms)
if not outcome.is_success:
if fail_strategy == HookFailStrategy.HARD:
raise OnyxError(
OnyxErrorCode.HOOK_EXECUTION_FAILED,
outcome.error_message or "Hook execution failed.",
)
logger.warning(
f"Hook execution failed (soft fail) for hook_id={hook_id}: {outcome.error_message}"
)
return HookSoftFailed()
if outcome.response_payload is None:
raise ValueError(
f"response_payload is None for successful hook call (hook_id={hook_id})"
)
return outcome.response_payload

View File

@@ -42,8 +42,12 @@ class HookUpdateRequest(BaseModel):
name: str | None = None
endpoint_url: str | None = None
api_key: NonEmptySecretStr | None = None
fail_strategy: HookFailStrategy | None = None
timeout_seconds: float | None = Field(default=None, gt=0)
fail_strategy: HookFailStrategy | None = (
None # if None in model_fields_set, reset to spec default
)
timeout_seconds: float | None = Field(
default=None, gt=0
) # if None in model_fields_set, reset to spec default
@model_validator(mode="after")
def require_at_least_one_field(self) -> "HookUpdateRequest":
@@ -56,14 +60,6 @@ class HookUpdateRequest(BaseModel):
and not (self.endpoint_url or "").strip()
):
raise ValueError("endpoint_url cannot be cleared.")
if "fail_strategy" in self.model_fields_set and self.fail_strategy is None:
raise ValueError(
"fail_strategy cannot be null; omit the field to leave it unchanged."
)
if "timeout_seconds" in self.model_fields_set and self.timeout_seconds is None:
raise ValueError(
"timeout_seconds cannot be null; omit the field to leave it unchanged."
)
return self
@@ -94,28 +90,38 @@ class HookResponse(BaseModel):
fail_strategy: HookFailStrategy
timeout_seconds: float # always resolved — None from request is replaced with spec default before DB write
is_active: bool
is_reachable: bool | None
creator_email: str | None
created_at: datetime
updated_at: datetime
class HookValidateStatus(str, Enum):
passed = "passed" # server responded (any status except 401/403)
auth_failed = "auth_failed" # server responded with 401 or 403
timeout = (
"timeout" # TCP connected, but read/write timed out (server exists but slow)
)
cannot_connect = "cannot_connect" # could not connect to the server
class HookValidateResponse(BaseModel):
status: HookValidateStatus
success: bool
error_message: str | None = None
class HookExecutionRecord(BaseModel):
# ---------------------------------------------------------------------------
# Health models
# ---------------------------------------------------------------------------
class HookHealthStatus(str, Enum):
healthy = "healthy" # green — reachable, no failures in last 1h
degraded = "degraded" # yellow — reachable, failures in last 1h
unreachable = "unreachable" # red — is_reachable=false or null
class HookFailureRecord(BaseModel):
error_message: str | None = None
status_code: int | None = None
duration_ms: int | None = None
created_at: datetime
class HookHealthResponse(BaseModel):
status: HookHealthStatus
recent_failures: list[HookFailureRecord] = Field(
default_factory=list,
description="Last 10 failures, newest first",
max_length=10,
)

View File

@@ -1,7 +1,6 @@
from abc import ABC
from abc import abstractmethod
from typing import Any
from typing import ClassVar
from pydantic import BaseModel
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
@@ -14,25 +13,22 @@ _REQUIRED_ATTRS = (
"default_timeout_seconds",
"fail_hard_description",
"default_fail_strategy",
"payload_model",
"response_model",
)
class HookPointSpec:
class HookPointSpec(ABC):
"""Static metadata and contract for a pipeline hook point.
This is NOT a regular class meant for direct instantiation by callers.
Each concrete subclass represents exactly one hook point and is instantiated
once at startup, registered in onyx.hooks.registry._REGISTRY. Prefer
get_hook_point_spec() or get_all_specs() from the registry over direct
instantiation.
once at startup, registered in onyx.hooks.registry._REGISTRY. No caller
should ever create instances directly — use get_hook_point_spec() or
get_all_specs() from the registry instead.
Each hook point is a concrete subclass of this class. Onyx engineers
own these definitions — customers never touch this code.
Subclasses must define all attributes as class-level constants.
payload_model and response_model must be Pydantic BaseModel subclasses;
input_schema and output_schema are derived from them automatically.
"""
hook_point: HookPoint
@@ -43,33 +39,21 @@ class HookPointSpec:
default_fail_strategy: HookFailStrategy
docs_url: str | None = None
payload_model: ClassVar[type[BaseModel]]
response_model: ClassVar[type[BaseModel]]
# Computed once at class definition time from payload_model / response_model.
input_schema: ClassVar[dict[str, Any]]
output_schema: ClassVar[dict[str, Any]]
def __init_subclass__(cls, **kwargs: object) -> None:
"""Enforce that every concrete subclass declares all required class attributes.
Called automatically by Python whenever a class inherits from HookPointSpec.
Abstract subclasses (those still carrying unimplemented abstract methods) are
skipped — they are intermediate base classes and may not yet define everything.
Only fully concrete subclasses are validated, ensuring a clear TypeError at
import time rather than a confusing AttributeError at runtime.
"""
super().__init_subclass__(**kwargs)
# Skip intermediate abstract subclasses — they may still be partially defined.
if getattr(cls, "__abstractmethods__", None):
return
missing = [attr for attr in _REQUIRED_ATTRS if not hasattr(cls, attr)]
if missing:
raise TypeError(f"{cls.__name__} must define class attributes: {missing}")
for attr in ("payload_model", "response_model"):
val = getattr(cls, attr, None)
if val is None or not (
isinstance(val, type) and issubclass(val, BaseModel)
):
raise TypeError(
f"{cls.__name__}.{attr} must be a Pydantic BaseModel subclass, got {val!r}"
)
cls.input_schema = cls.payload_model.model_json_schema()
cls.output_schema = cls.response_model.model_json_schema()
@property
@abstractmethod
def input_schema(self) -> dict[str, Any]:
"""JSON schema describing the request payload sent to the customer's endpoint."""
@property
@abstractmethod
def output_schema(self) -> dict[str, Any]:
"""JSON schema describing the expected response from the customer's endpoint."""

View File

@@ -1,19 +1,10 @@
from pydantic import BaseModel
from typing import Any
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
from onyx.hooks.points.base import HookPointSpec
# TODO(@Bo-Onyx): define payload and response fields
class DocumentIngestionPayload(BaseModel):
pass
class DocumentIngestionResponse(BaseModel):
pass
class DocumentIngestionSpec(HookPointSpec):
"""Hook point that runs during document ingestion.
@@ -27,5 +18,12 @@ class DocumentIngestionSpec(HookPointSpec):
fail_hard_description = "The document will not be indexed."
default_fail_strategy = HookFailStrategy.HARD
payload_model = DocumentIngestionPayload
response_model = DocumentIngestionResponse
@property
def input_schema(self) -> dict[str, Any]:
# TODO(@Bo-Onyx): define input schema
return {"type": "object", "properties": {}}
@property
def output_schema(self) -> dict[str, Any]:
# TODO(@Bo-Onyx): define output schema
return {"type": "object", "properties": {}}

View File

@@ -1,39 +1,10 @@
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field
from typing import Any
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
from onyx.hooks.points.base import HookPointSpec
class QueryProcessingPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
query: str = Field(description="The raw query string exactly as the user typed it.")
user_email: str | None = Field(
description="Email of the user submitting the query, or null if unauthenticated."
)
chat_session_id: str = Field(
description="UUID of the chat session. Always present — the session is guaranteed to exist by the time this hook fires."
)
class QueryProcessingResponse(BaseModel):
# Intentionally permissive — customer endpoints may return extra fields.
query: str | None = Field(
default=None,
description=(
"The query to use in the pipeline. "
"Null, empty string, or absent = reject the query."
),
)
rejection_message: str | None = Field(
default=None,
description="Message shown to the user when the query is rejected. Falls back to a generic message if not provided.",
)
class QueryProcessingSpec(HookPointSpec):
"""Hook point that runs on every user query before it enters the pipeline.
@@ -66,5 +37,47 @@ class QueryProcessingSpec(HookPointSpec):
)
default_fail_strategy = HookFailStrategy.HARD
payload_model = QueryProcessingPayload
response_model = QueryProcessingResponse
@property
def input_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The raw query string exactly as the user typed it.",
},
"user_email": {
"type": ["string", "null"],
"description": "Email of the user submitting the query, or null if unauthenticated.",
},
"chat_session_id": {
"type": "string",
"description": "UUID of the chat session. Always present — the session is guaranteed to exist by the time this hook fires.",
},
},
"required": ["query", "user_email", "chat_session_id"],
"additionalProperties": False,
}
@property
def output_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"query": {
"type": ["string", "null"],
"description": (
"The (optionally modified) query to use. "
"Set to null to reject the query."
),
},
"rejection_message": {
"type": ["string", "null"],
"description": (
"Message shown to the user when query is null. "
"Falls back to a generic message if not provided."
),
},
},
"required": ["query"],
}

View File

@@ -1,5 +0,0 @@
from onyx.configs.app_configs import HOOK_ENABLED
from shared_configs.configs import MULTI_TENANT
# True only when hooks are available: single-tenant deployment with HOOK_ENABLED=true.
HOOKS_AVAILABLE: bool = HOOK_ENABLED and not MULTI_TENANT

View File

@@ -77,7 +77,6 @@ from onyx.server.features.default_assistant.api import (
)
from onyx.server.features.document_set.api import router as document_set_router
from onyx.server.features.hierarchy.api import router as hierarchy_router
from onyx.server.features.hooks.api import router as hook_router
from onyx.server.features.input_prompt.api import (
admin_router as admin_input_prompt_router,
)
@@ -454,7 +453,6 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
register_onyx_exception_handlers(application)
include_router_with_global_prefix_prepended(application, hook_router)
include_router_with_global_prefix_prepended(application, password_router)
include_router_with_global_prefix_prepended(application, chat_router)
include_router_with_global_prefix_prepended(application, query_router)

View File

@@ -43,9 +43,6 @@ from onyx.db.index_attempt import count_index_attempt_errors_for_cc_pair
from onyx.db.index_attempt import count_index_attempts_for_cc_pair
from onyx.db.index_attempt import get_index_attempt_errors_for_cc_pair
from onyx.db.index_attempt import get_latest_index_attempt_for_cc_pair_id
from onyx.db.index_attempt import (
get_latest_successful_index_attempt_for_cc_pair_id,
)
from onyx.db.index_attempt import get_paginated_index_attempts_for_cc_pair_id
from onyx.db.indexing_coordination import IndexingCoordination
from onyx.db.models import IndexAttempt
@@ -193,11 +190,6 @@ def get_cc_pair_full_info(
only_finished=False,
)
latest_successful_attempt = get_latest_successful_index_attempt_for_cc_pair_id(
db_session=db_session,
connector_credential_pair_id=cc_pair_id,
)
# Get latest permission sync attempt for status
latest_permission_sync_attempt = None
if cc_pair.access_type == AccessType.SYNC:
@@ -215,11 +207,6 @@ def get_cc_pair_full_info(
cc_pair_id=cc_pair_id,
),
last_index_attempt=latest_attempt,
last_successful_index_time=(
latest_successful_attempt.time_started
if latest_successful_attempt
else None
),
latest_deletion_attempt=get_deletion_attempt_snapshot(
connector_id=cc_pair.connector_id,
credential_id=cc_pair.credential_id,

View File

@@ -3,7 +3,6 @@ import math
import mimetypes
import os
import zipfile
from datetime import datetime
from io import BytesIO
from typing import Any
from typing import cast
@@ -110,9 +109,6 @@ from onyx.db.federated import fetch_all_federated_connectors_parallel
from onyx.db.index_attempt import get_index_attempts_for_cc_pair
from onyx.db.index_attempt import get_latest_index_attempts_by_status
from onyx.db.index_attempt import get_latest_index_attempts_parallel
from onyx.db.index_attempt import (
get_latest_successful_index_attempts_parallel,
)
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import FederatedConnector
from onyx.db.models import IndexAttempt
@@ -1162,26 +1158,21 @@ def get_connector_indexing_status(
),
(),
),
# Get most recent successful index attempts
(
lambda: get_latest_successful_index_attempts_parallel(
request.secondary_index,
),
(),
),
]
if user and user.role == UserRole.ADMIN:
# For Admin users, we already got all the cc pair in editable_cc_pairs
# its not needed to get them again
(
editable_cc_pairs,
federated_connectors,
latest_index_attempts,
latest_finished_index_attempts,
latest_successful_index_attempts,
) = run_functions_tuples_in_parallel(parallel_functions)
non_editable_cc_pairs = []
else:
parallel_functions.append(
# Get non-editable connector/credential pairs
(
lambda: get_connector_credential_pairs_for_user_parallel(
user, False, None, True, True, False, True, request.source
@@ -1195,7 +1186,6 @@ def get_connector_indexing_status(
federated_connectors,
latest_index_attempts,
latest_finished_index_attempts,
latest_successful_index_attempts,
non_editable_cc_pairs,
) = run_functions_tuples_in_parallel(parallel_functions)
@@ -1207,9 +1197,6 @@ def get_connector_indexing_status(
latest_finished_index_attempts = cast(
list[IndexAttempt], latest_finished_index_attempts
)
latest_successful_index_attempts = cast(
list[IndexAttempt], latest_successful_index_attempts
)
document_count_info = get_document_counts_for_all_cc_pairs(db_session)
@@ -1219,48 +1206,42 @@ def get_connector_indexing_status(
for connector_id, credential_id, cnt in document_count_info
}
def _attempt_lookup(
attempts: list[IndexAttempt],
) -> dict[int, IndexAttempt]:
return {attempt.connector_credential_pair_id: attempt for attempt in attempts}
cc_pair_to_latest_index_attempt: dict[tuple[int, int], IndexAttempt] = {
(
attempt.connector_credential_pair.connector_id,
attempt.connector_credential_pair.credential_id,
): attempt
for attempt in latest_index_attempts
}
cc_pair_to_latest_index_attempt = _attempt_lookup(latest_index_attempts)
cc_pair_to_latest_finished_index_attempt = _attempt_lookup(
latest_finished_index_attempts
)
cc_pair_to_latest_successful_index_attempt = _attempt_lookup(
latest_successful_index_attempts
)
cc_pair_to_latest_finished_index_attempt: dict[tuple[int, int], IndexAttempt] = {
(
attempt.connector_credential_pair.connector_id,
attempt.connector_credential_pair.credential_id,
): attempt
for attempt in latest_finished_index_attempts
}
def build_connector_indexing_status(
cc_pair: ConnectorCredentialPair,
is_editable: bool,
) -> ConnectorIndexingStatusLite | None:
# TODO remove this to enable ingestion API
if cc_pair.name == "DefaultCCPair":
return None
latest_attempt = cc_pair_to_latest_index_attempt.get(cc_pair.id)
latest_finished_attempt = cc_pair_to_latest_finished_index_attempt.get(
cc_pair.id
latest_attempt = cc_pair_to_latest_index_attempt.get(
(cc_pair.connector_id, cc_pair.credential_id)
)
latest_successful_attempt = cc_pair_to_latest_successful_index_attempt.get(
cc_pair.id
latest_finished_attempt = cc_pair_to_latest_finished_index_attempt.get(
(cc_pair.connector_id, cc_pair.credential_id)
)
doc_count = cc_pair_to_document_cnt.get(
(cc_pair.connector_id, cc_pair.credential_id), 0
)
return _get_connector_indexing_status_lite(
cc_pair,
latest_attempt,
latest_finished_attempt,
(
latest_successful_attempt.time_started
if latest_successful_attempt
else None
),
is_editable,
doc_count,
cc_pair, latest_attempt, latest_finished_attempt, is_editable, doc_count
)
# Process editable cc_pairs
@@ -1421,7 +1402,6 @@ def _get_connector_indexing_status_lite(
cc_pair: ConnectorCredentialPair,
latest_index_attempt: IndexAttempt | None,
latest_finished_index_attempt: IndexAttempt | None,
last_successful_index_time: datetime | None,
is_editable: bool,
document_cnt: int,
) -> ConnectorIndexingStatusLite | None:
@@ -1455,7 +1435,7 @@ def _get_connector_indexing_status_lite(
else None
),
last_status=latest_index_attempt.status if latest_index_attempt else None,
last_success=last_successful_index_time,
last_success=cc_pair.last_successful_index_time,
docs_indexed=document_cnt,
latest_index_attempt_docs_indexed=(
latest_index_attempt.total_docs_indexed if latest_index_attempt else None

View File

@@ -330,7 +330,6 @@ class CCPairFullInfo(BaseModel):
num_docs_indexed: int, # not ideal, but this must be computed separately
is_editable_for_current_user: bool,
indexing: bool,
last_successful_index_time: datetime | None = None,
last_permission_sync_attempt_status: PermissionSyncStatus | None = None,
permission_syncing: bool = False,
last_permission_sync_attempt_finished: datetime | None = None,
@@ -383,7 +382,9 @@ class CCPairFullInfo(BaseModel):
creator_email=(
cc_pair_model.creator.email if cc_pair_model.creator else None
),
last_indexed=last_successful_index_time,
last_indexed=(
last_index_attempt.time_started if last_index_attempt else None
),
last_pruned=cc_pair_model.last_pruned,
last_full_permission_sync=cls._get_last_full_permission_sync(cc_pair_model),
overall_indexing_speed=overall_indexing_speed,

View File

@@ -1,453 +0,0 @@
import httpx
from fastapi import APIRouter
from fastapi import Depends
from fastapi import Query
from sqlalchemy.orm import Session
from onyx.auth.users import current_admin_user
from onyx.auth.users import User
from onyx.db.constants import UNSET
from onyx.db.constants import UnsetType
from onyx.db.engine.sql_engine import get_session
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.hook import create_hook__no_commit
from onyx.db.hook import delete_hook__no_commit
from onyx.db.hook import get_hook_by_id
from onyx.db.hook import get_hook_execution_logs
from onyx.db.hook import get_hooks
from onyx.db.hook import update_hook__no_commit
from onyx.db.models import Hook
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.api_dependencies import require_hook_enabled
from onyx.hooks.models import HookCreateRequest
from onyx.hooks.models import HookExecutionRecord
from onyx.hooks.models import HookPointMetaResponse
from onyx.hooks.models import HookResponse
from onyx.hooks.models import HookUpdateRequest
from onyx.hooks.models import HookValidateResponse
from onyx.hooks.models import HookValidateStatus
from onyx.hooks.registry import get_all_specs
from onyx.hooks.registry import get_hook_point_spec
from onyx.utils.logger import setup_logger
from onyx.utils.url import SSRFException
from onyx.utils.url import validate_outbound_http_url
logger = setup_logger()
# ---------------------------------------------------------------------------
# SSRF protection
# ---------------------------------------------------------------------------
def _check_ssrf_safety(endpoint_url: str) -> None:
"""Raise OnyxError if endpoint_url could be used for SSRF.
Delegates to validate_outbound_http_url with https_only=True.
"""
try:
validate_outbound_http_url(endpoint_url, https_only=True)
except (SSRFException, ValueError) as e:
raise OnyxError(OnyxErrorCode.INVALID_INPUT, str(e))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _hook_to_response(hook: Hook, creator_email: str | None = None) -> HookResponse:
return HookResponse(
id=hook.id,
name=hook.name,
hook_point=hook.hook_point,
endpoint_url=hook.endpoint_url,
fail_strategy=hook.fail_strategy,
timeout_seconds=hook.timeout_seconds,
is_active=hook.is_active,
is_reachable=hook.is_reachable,
creator_email=(
creator_email
if creator_email is not None
else (hook.creator.email if hook.creator else None)
),
created_at=hook.created_at,
updated_at=hook.updated_at,
)
def _get_hook_or_404(
db_session: Session,
hook_id: int,
include_creator: bool = False,
) -> Hook:
hook = get_hook_by_id(
db_session=db_session,
hook_id=hook_id,
include_creator=include_creator,
)
if hook is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, f"Hook {hook_id} not found.")
return hook
def _raise_for_validation_failure(validation: HookValidateResponse) -> None:
"""Raise an appropriate OnyxError for a non-passed validation result."""
if validation.status == HookValidateStatus.auth_failed:
raise OnyxError(OnyxErrorCode.CREDENTIAL_INVALID, validation.error_message)
if validation.status == HookValidateStatus.timeout:
raise OnyxError(
OnyxErrorCode.GATEWAY_TIMEOUT,
f"Endpoint validation failed: {validation.error_message}",
)
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
f"Endpoint validation failed: {validation.error_message}",
)
def _validate_endpoint(
endpoint_url: str,
api_key: str | None,
timeout_seconds: float,
) -> HookValidateResponse:
"""Check whether endpoint_url is reachable by sending an empty POST request.
We use POST since hook endpoints expect POST requests. The server will typically
respond with 4xx (missing/invalid body) — that is fine. Any HTTP response means
the server is up and routable. A 401/403 response returns auth_failed
(not reachable — indicates the api_key is invalid).
Timeout handling:
- ConnectTimeout: TCP handshake never completed → cannot_connect.
- ReadTimeout / WriteTimeout: TCP was established, server responded slowly → timeout
(operator should consider increasing timeout_seconds).
- All other exceptions → cannot_connect.
"""
_check_ssrf_safety(endpoint_url)
headers: dict[str, str] = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
with httpx.Client(timeout=timeout_seconds, follow_redirects=False) as client:
response = client.post(endpoint_url, headers=headers)
if response.status_code in (401, 403):
return HookValidateResponse(
status=HookValidateStatus.auth_failed,
error_message=f"Authentication failed (HTTP {response.status_code})",
)
return HookValidateResponse(status=HookValidateStatus.passed)
except httpx.TimeoutException as exc:
# ConnectTimeout: TCP handshake never completed → cannot_connect.
# ReadTimeout / WriteTimeout: TCP was established, server just responded slowly → timeout.
if isinstance(exc, httpx.ConnectTimeout):
logger.warning(
"Hook endpoint validation: connect timeout for %s",
endpoint_url,
exc_info=exc,
)
return HookValidateResponse(
status=HookValidateStatus.cannot_connect, error_message=str(exc)
)
logger.warning(
"Hook endpoint validation: read/write timeout for %s",
endpoint_url,
exc_info=exc,
)
return HookValidateResponse(
status=HookValidateStatus.timeout,
error_message="Endpoint timed out — consider increasing timeout_seconds.",
)
except Exception as exc:
logger.warning(
"Hook endpoint validation: connection error for %s",
endpoint_url,
exc_info=exc,
)
return HookValidateResponse(
status=HookValidateStatus.cannot_connect, error_message=str(exc)
)
# ---------------------------------------------------------------------------
# Routers
# ---------------------------------------------------------------------------
router = APIRouter(prefix="/admin/hooks")
# ---------------------------------------------------------------------------
# Hook endpoints
# ---------------------------------------------------------------------------
@router.get("/specs")
def get_hook_point_specs(
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
) -> list[HookPointMetaResponse]:
return [
HookPointMetaResponse(
hook_point=spec.hook_point,
display_name=spec.display_name,
description=spec.description,
docs_url=spec.docs_url,
input_schema=spec.input_schema,
output_schema=spec.output_schema,
default_timeout_seconds=spec.default_timeout_seconds,
default_fail_strategy=spec.default_fail_strategy,
fail_hard_description=spec.fail_hard_description,
)
for spec in get_all_specs()
]
@router.get("")
def list_hooks(
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> list[HookResponse]:
hooks = get_hooks(db_session=db_session, include_creator=True)
return [_hook_to_response(h) for h in hooks]
@router.post("")
def create_hook(
req: HookCreateRequest,
user: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookResponse:
"""Create a new hook. The endpoint is validated before persisting — creation fails if
the endpoint cannot be reached or the api_key is invalid. Hooks are created inactive;
use POST /{hook_id}/activate once ready to receive traffic."""
spec = get_hook_point_spec(req.hook_point)
api_key = req.api_key.get_secret_value() if req.api_key else None
validation = _validate_endpoint(
endpoint_url=req.endpoint_url,
api_key=api_key,
timeout_seconds=req.timeout_seconds or spec.default_timeout_seconds,
)
if validation.status != HookValidateStatus.passed:
_raise_for_validation_failure(validation)
hook = create_hook__no_commit(
db_session=db_session,
name=req.name,
hook_point=req.hook_point,
endpoint_url=req.endpoint_url,
api_key=api_key,
fail_strategy=req.fail_strategy or spec.default_fail_strategy,
timeout_seconds=req.timeout_seconds or spec.default_timeout_seconds,
creator_id=user.id,
)
hook.is_reachable = True
db_session.commit()
return _hook_to_response(hook, creator_email=user.email)
@router.get("/{hook_id}")
def get_hook(
hook_id: int,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookResponse:
hook = _get_hook_or_404(db_session, hook_id, include_creator=True)
return _hook_to_response(hook)
@router.patch("/{hook_id}")
def update_hook(
hook_id: int,
req: HookUpdateRequest,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookResponse:
"""Update hook fields. If endpoint_url, api_key, or timeout_seconds changes, the
endpoint is re-validated using the effective values. For active hooks the update is
rejected on validation failure, keeping live traffic unaffected. For inactive hooks
the update goes through regardless and is_reachable is updated to reflect the result.
Note: if an active hook's endpoint is currently down, even a timeout_seconds-only
increase will be rejected. The recovery flow is: deactivate → update → reactivate.
"""
# api_key: UNSET = no change, None = clear, value = update
api_key: str | None | UnsetType
if "api_key" not in req.model_fields_set:
api_key = UNSET
elif req.api_key is None:
api_key = None
else:
api_key = req.api_key.get_secret_value()
endpoint_url_changing = "endpoint_url" in req.model_fields_set
api_key_changing = not isinstance(api_key, UnsetType)
timeout_changing = "timeout_seconds" in req.model_fields_set
validated_is_reachable: bool | None = None
if endpoint_url_changing or api_key_changing or timeout_changing:
existing = _get_hook_or_404(db_session, hook_id)
effective_url: str = (
req.endpoint_url if endpoint_url_changing else existing.endpoint_url # type: ignore[assignment] # endpoint_url is required on create and cannot be cleared on update
)
effective_api_key: str | None = (
(api_key if not isinstance(api_key, UnsetType) else None)
if api_key_changing
else (
existing.api_key.get_value(apply_mask=False)
if existing.api_key
else None
)
)
effective_timeout: float = (
req.timeout_seconds if timeout_changing else existing.timeout_seconds # type: ignore[assignment] # req.timeout_seconds is non-None when timeout_changing (validated by HookUpdateRequest)
)
validation = _validate_endpoint(
endpoint_url=effective_url,
api_key=effective_api_key,
timeout_seconds=effective_timeout,
)
if existing.is_active and validation.status != HookValidateStatus.passed:
_raise_for_validation_failure(validation)
validated_is_reachable = validation.status == HookValidateStatus.passed
hook = update_hook__no_commit(
db_session=db_session,
hook_id=hook_id,
name=req.name,
endpoint_url=(req.endpoint_url if endpoint_url_changing else UNSET),
api_key=api_key,
fail_strategy=req.fail_strategy,
timeout_seconds=req.timeout_seconds,
is_reachable=validated_is_reachable,
include_creator=True,
)
db_session.commit()
return _hook_to_response(hook)
@router.delete("/{hook_id}")
def delete_hook(
hook_id: int,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> None:
delete_hook__no_commit(db_session=db_session, hook_id=hook_id)
db_session.commit()
@router.post("/{hook_id}/activate")
def activate_hook(
hook_id: int,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookResponse:
hook = _get_hook_or_404(db_session, hook_id)
if not hook.endpoint_url:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT, "Hook has no endpoint URL configured."
)
api_key = hook.api_key.get_value(apply_mask=False) if hook.api_key else None
validation = _validate_endpoint(
endpoint_url=hook.endpoint_url,
api_key=api_key,
timeout_seconds=hook.timeout_seconds,
)
if validation.status != HookValidateStatus.passed:
# Persist is_reachable=False in a separate session so the request
# session has no commits on the failure path and the transaction
# boundary stays clean.
if hook.is_reachable is not False:
with get_session_with_current_tenant() as side_session:
update_hook__no_commit(
db_session=side_session, hook_id=hook_id, is_reachable=False
)
side_session.commit()
_raise_for_validation_failure(validation)
hook = update_hook__no_commit(
db_session=db_session,
hook_id=hook_id,
is_active=True,
is_reachable=True,
include_creator=True,
)
db_session.commit()
return _hook_to_response(hook)
@router.post("/{hook_id}/validate")
def validate_hook(
hook_id: int,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookValidateResponse:
hook = _get_hook_or_404(db_session, hook_id)
if not hook.endpoint_url:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT, "Hook has no endpoint URL configured."
)
api_key = hook.api_key.get_value(apply_mask=False) if hook.api_key else None
validation = _validate_endpoint(
endpoint_url=hook.endpoint_url,
api_key=api_key,
timeout_seconds=hook.timeout_seconds,
)
validation_passed = validation.status == HookValidateStatus.passed
if hook.is_reachable != validation_passed:
update_hook__no_commit(
db_session=db_session, hook_id=hook_id, is_reachable=validation_passed
)
db_session.commit()
return validation
@router.post("/{hook_id}/deactivate")
def deactivate_hook(
hook_id: int,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookResponse:
hook = update_hook__no_commit(
db_session=db_session,
hook_id=hook_id,
is_active=False,
include_creator=True,
)
db_session.commit()
return _hook_to_response(hook)
# ---------------------------------------------------------------------------
# Execution log endpoints
# ---------------------------------------------------------------------------
@router.get("/{hook_id}/execution-logs")
def list_hook_execution_logs(
hook_id: int,
limit: int = Query(default=10, ge=1, le=100),
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> list[HookExecutionRecord]:
_get_hook_or_404(db_session, hook_id)
logs = get_hook_execution_logs(db_session=db_session, hook_id=hook_id, limit=limit)
return [
HookExecutionRecord(
error_message=log.error_message,
status_code=log.status_code,
duration_ms=log.duration_ms,
created_at=log.created_at,
)
for log in logs
]

View File

@@ -5,7 +5,6 @@ from fastapi import Depends
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from onyx import __version__ as onyx_version
from onyx.auth.users import current_admin_user
from onyx.auth.users import current_user
from onyx.auth.users import is_user_admin
@@ -80,7 +79,6 @@ def fetch_settings(
needs_reindexing=needs_reindexing,
onyx_craft_enabled=onyx_craft_enabled_for_user,
vector_db_enabled=not DISABLE_VECTOR_DB,
version=onyx_version,
)

View File

@@ -104,5 +104,3 @@ class UserSettings(Settings):
# False when DISABLE_VECTOR_DB is set — connectors, RAG search, and
# document sets are unavailable.
vector_db_enabled: bool = True
# Application version, read from the ONYX_VERSION env var at startup.
version: str | None = None

View File

@@ -396,7 +396,6 @@ def construct_tools(
tool_definition=saved_tool.mcp_input_schema or {},
connection_config=connection_config,
user_email=user_email,
user_id=str(user.id),
user_oauth_token=mcp_user_oauth_token,
additional_headers=additional_mcp_headers,
)

View File

@@ -1,8 +1,6 @@
import json
from typing import Any
from mcp.client.auth import OAuthClientProvider
from onyx.chat.emitter import Emitter
from onyx.db.enums import MCPAuthenticationType
from onyx.db.enums import MCPTransport
@@ -49,7 +47,6 @@ class MCPTool(Tool[None]):
tool_definition: dict[str, Any],
connection_config: MCPConnectionConfig | None = None,
user_email: str = "",
user_id: str = "",
user_oauth_token: str | None = None,
additional_headers: dict[str, str] | None = None,
) -> None:
@@ -59,7 +56,6 @@ class MCPTool(Tool[None]):
self.mcp_server = mcp_server
self.connection_config = connection_config
self.user_email = user_email
self._user_id = user_id
self._user_oauth_token = user_oauth_token
self._additional_headers = additional_headers or {}
@@ -202,42 +198,12 @@ class MCPTool(Tool[None]):
llm_facing_response=llm_facing_response,
)
# For OAuth servers, construct OAuthClientProvider so the MCP SDK
# can refresh expired tokens automatically
auth: OAuthClientProvider | None = None
if (
self.mcp_server.auth_type == MCPAuthenticationType.OAUTH
and self.connection_config is not None
and self._user_id
):
if self.mcp_server.transport == MCPTransport.SSE:
logger.warning(
f"MCP tool '{self._name}': OAuth token refresh is not supported "
f"for SSE transport — auth provider will be ignored. "
f"Re-authentication may be required after token expiry."
)
else:
from onyx.server.features.mcp.api import UNUSED_RETURN_PATH
from onyx.server.features.mcp.api import make_oauth_provider
# user_id is the requesting user's UUID; safe here because
# UNUSED_RETURN_PATH ensures redirect_handler raises immediately
# and user_id is never consulted for Redis state lookups.
auth = make_oauth_provider(
self.mcp_server,
self._user_id,
UNUSED_RETURN_PATH,
self.connection_config.id,
None,
)
tool_result = call_mcp_tool(
self.mcp_server.server_url,
self._name,
llm_kwargs,
connection_headers=headers,
transport=self.mcp_server.transport or MCPTransport.STREAMABLE_HTTP,
auth=auth,
)
logger.info(f"MCP tool '{self._name}' executed successfully")
@@ -282,7 +248,6 @@ class MCPTool(Tool[None]):
"invalid token",
"invalid api key",
"invalid credentials",
"please reconnect to the server",
]
is_auth_error = any(

View File

@@ -140,20 +140,10 @@ def _validate_and_resolve_url(url: str) -> tuple[str, str, int]:
return validated_ip, hostname, port
def validate_outbound_http_url(
url: str,
*,
allow_private_network: bool = False,
https_only: bool = False,
) -> str:
def validate_outbound_http_url(url: str, *, allow_private_network: bool = False) -> str:
"""
Validate a URL that will be used by backend outbound HTTP calls.
Args:
url: The URL to validate.
allow_private_network: If True, skip private/reserved IP checks.
https_only: If True, reject http:// URLs (only https:// is allowed).
Returns:
A normalized URL string with surrounding whitespace removed.
@@ -167,12 +157,7 @@ def validate_outbound_http_url(
parsed = urlparse(normalized_url)
if https_only:
if parsed.scheme != "https":
raise SSRFException(
f"Invalid URL scheme '{parsed.scheme}'. Only https is allowed."
)
elif parsed.scheme not in ("http", "https"):
if parsed.scheme not in ("http", "https"):
raise SSRFException(
f"Invalid URL scheme '{parsed.scheme}'. Only http and https are allowed."
)

View File

@@ -368,10 +368,9 @@ class TestMCPPassThroughOAuth:
def mock_call_mcp_tool(
server_url: str, # noqa: ARG001
tool_name: str, # noqa: ARG001
arguments: dict[str, Any], # noqa: ARG001
kwargs: dict[str, Any], # noqa: ARG001
connection_headers: dict[str, str],
transport: MCPTransport, # noqa: ARG001
auth: Any = None, # noqa: ARG001
) -> dict[str, Any]:
captured_headers.update(connection_headers)
return mocked_response

View File

@@ -14,7 +14,6 @@ from __future__ import annotations
import os
import subprocess
import sys
import time
import uuid
from collections.abc import Generator
@@ -29,9 +28,6 @@ _BACKEND_DIR = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
)
_DROP_SCHEMA_MAX_RETRIES = 3
_DROP_SCHEMA_RETRY_DELAY_SEC = 2
# ---------------------------------------------------------------------------
# Helpers
@@ -54,39 +50,6 @@ def _run_script(
)
def _force_drop_schema(engine: Engine, schema: str) -> None:
"""Terminate backends using *schema* then drop it, retrying on deadlock.
Background Celery workers may discover test schemas (they match the
``tenant_`` prefix) and hold locks on tables inside them. A bare
``DROP SCHEMA … CASCADE`` can deadlock with those workers, so we
first kill their connections and retry if we still hit a deadlock.
"""
for attempt in range(_DROP_SCHEMA_MAX_RETRIES):
try:
with engine.connect() as conn:
conn.execute(
text(
"""
SELECT pg_terminate_backend(l.pid)
FROM pg_locks l
JOIN pg_class c ON c.oid = l.relation
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = :schema
AND l.pid != pg_backend_pid()
"""
),
{"schema": schema},
)
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
conn.commit()
return
except Exception:
if attempt == _DROP_SCHEMA_MAX_RETRIES - 1:
raise
time.sleep(_DROP_SCHEMA_RETRY_DELAY_SEC)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -141,7 +104,9 @@ def tenant_schema_at_head(
yield schema
_force_drop_schema(engine, schema)
with engine.connect() as conn:
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
conn.commit()
@pytest.fixture
@@ -158,7 +123,9 @@ def tenant_schema_empty(engine: Engine) -> Generator[str, None, None]:
yield schema
_force_drop_schema(engine, schema)
with engine.connect() as conn:
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
conn.commit()
@pytest.fixture
@@ -183,7 +150,9 @@ def tenant_schema_bad_rev(engine: Engine) -> Generator[str, None, None]:
yield schema
_force_drop_schema(engine, schema)
with engine.connect() as conn:
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
conn.commit()
# ---------------------------------------------------------------------------

View File

@@ -1,237 +0,0 @@
"""
Integration tests for the "Last Indexed" time displayed on both the
per-connector detail page and the all-connectors listing page.
Expected behavior: "Last Indexed" = time_started of the most recent
successful index attempt for the cc pair, regardless of pagination.
Edge cases:
1. First page of index attempts is entirely errors — last_indexed should
still reflect the older successful attempt beyond page 1.
2. Credential swap — successful attempts, then failures after a
"credential change"; last_indexed should reflect the most recent
successful attempt.
3. Mix of statuses — only the most recent successful attempt matters.
4. COMPLETED_WITH_ERRORS counts as a success for last_indexed purposes.
"""
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from onyx.db.models import IndexingStatus
from onyx.server.documents.models import CCPairFullInfo
from onyx.server.documents.models import ConnectorIndexingStatusLite
from tests.integration.common_utils.managers.cc_pair import CCPairManager
from tests.integration.common_utils.managers.connector import ConnectorManager
from tests.integration.common_utils.managers.credential import CredentialManager
from tests.integration.common_utils.managers.index_attempt import IndexAttemptManager
from tests.integration.common_utils.managers.user import UserManager
from tests.integration.common_utils.test_models import DATestCCPair
from tests.integration.common_utils.test_models import DATestUser
def _wait_for_real_success(
cc_pair: DATestCCPair,
admin: DATestUser,
) -> None:
"""Wait for the initial index attempt to complete successfully."""
CCPairManager.wait_for_indexing_completion(
cc_pair,
after=datetime(2000, 1, 1, tzinfo=timezone.utc),
user_performing_action=admin,
timeout=120,
)
def _get_detail(cc_pair_id: int, admin: DATestUser) -> CCPairFullInfo:
result = CCPairManager.get_single(cc_pair_id, admin)
assert result is not None
return result
def _get_listing(cc_pair_id: int, admin: DATestUser) -> ConnectorIndexingStatusLite:
result = CCPairManager.get_indexing_status_by_id(cc_pair_id, admin)
assert result is not None
return result
def test_last_indexed_first_page_all_errors(reset: None) -> None: # noqa: ARG001
"""When the first page of index attempts is entirely errors but an
older successful attempt exists, both the detail page and the listing
page should still show the time of that successful attempt.
The detail page UI uses page size 8. We insert 10 failed attempts
more recent than the initial success to push the success off page 1.
"""
admin = UserManager.create(name="admin_first_page_errors")
cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin)
_wait_for_real_success(cc_pair, admin)
# Baseline: last_success should be set from the initial successful run
listing_before = _get_listing(cc_pair.id, admin)
assert listing_before.last_success is not None
# 10 recent failures push the success off page 1
IndexAttemptManager.create_test_index_attempts(
num_attempts=10,
cc_pair_id=cc_pair.id,
status=IndexingStatus.FAILED,
error_msg="simulated failure",
base_time=datetime.now(tz=timezone.utc),
)
detail = _get_detail(cc_pair.id, admin)
listing = _get_listing(cc_pair.id, admin)
assert (
detail.last_indexed is not None
), "Detail page last_indexed is None even though a successful attempt exists"
assert (
listing.last_success is not None
), "Listing page last_success is None even though a successful attempt exists"
# Both surfaces must agree
assert detail.last_indexed == listing.last_success, (
f"Detail last_indexed={detail.last_indexed} != "
f"listing last_success={listing.last_success}"
)
def test_last_indexed_credential_swap_scenario(reset: None) -> None: # noqa: ARG001
"""Perform an actual credential swap: create connector + cred1 (cc_pair_1),
wait for success, then associate a new cred2 with the same connector
(cc_pair_2), wait for that to succeed, and inject failures on cc_pair_2.
cc_pair_2's last_indexed must reflect cc_pair_2's own success, not
cc_pair_1's older one. Both the detail page and listing page must agree.
"""
admin = UserManager.create(name="admin_cred_swap")
connector = ConnectorManager.create(user_performing_action=admin)
cred1 = CredentialManager.create(user_performing_action=admin)
cc_pair_1 = CCPairManager.create(
connector_id=connector.id,
credential_id=cred1.id,
user_performing_action=admin,
)
_wait_for_real_success(cc_pair_1, admin)
cred2 = CredentialManager.create(user_performing_action=admin, name="swapped-cred")
cc_pair_2 = CCPairManager.create(
connector_id=connector.id,
credential_id=cred2.id,
user_performing_action=admin,
)
_wait_for_real_success(cc_pair_2, admin)
listing_after_swap = _get_listing(cc_pair_2.id, admin)
assert listing_after_swap.last_success is not None
IndexAttemptManager.create_test_index_attempts(
num_attempts=10,
cc_pair_id=cc_pair_2.id,
status=IndexingStatus.FAILED,
error_msg="credential expired",
base_time=datetime.now(tz=timezone.utc),
)
detail = _get_detail(cc_pair_2.id, admin)
listing = _get_listing(cc_pair_2.id, admin)
assert detail.last_indexed is not None
assert listing.last_success is not None
assert detail.last_indexed == listing.last_success, (
f"Detail last_indexed={detail.last_indexed} != "
f"listing last_success={listing.last_success}"
)
def test_last_indexed_mixed_statuses(reset: None) -> None: # noqa: ARG001
"""Mix of in_progress, failed, and successful attempts. Only the most
recent successful attempt's time matters."""
admin = UserManager.create(name="admin_mixed")
cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin)
_wait_for_real_success(cc_pair, admin)
now = datetime.now(tz=timezone.utc)
# Success 5 hours ago
IndexAttemptManager.create_test_index_attempts(
num_attempts=1,
cc_pair_id=cc_pair.id,
status=IndexingStatus.SUCCESS,
base_time=now - timedelta(hours=5),
)
# Failures 3 hours ago
IndexAttemptManager.create_test_index_attempts(
num_attempts=3,
cc_pair_id=cc_pair.id,
status=IndexingStatus.FAILED,
error_msg="transient failure",
base_time=now - timedelta(hours=3),
)
# In-progress 1 hour ago
IndexAttemptManager.create_test_index_attempts(
num_attempts=1,
cc_pair_id=cc_pair.id,
status=IndexingStatus.IN_PROGRESS,
base_time=now - timedelta(hours=1),
)
detail = _get_detail(cc_pair.id, admin)
listing = _get_listing(cc_pair.id, admin)
assert detail.last_indexed is not None
assert listing.last_success is not None
assert detail.last_indexed == listing.last_success, (
f"Detail last_indexed={detail.last_indexed} != "
f"listing last_success={listing.last_success}"
)
def test_last_indexed_completed_with_errors(reset: None) -> None: # noqa: ARG001
"""COMPLETED_WITH_ERRORS is treated as a successful attempt (matching
IndexingStatus.is_successful()). When it is the most recent "success"
and later attempts all failed, both surfaces should reflect its time."""
admin = UserManager.create(name="admin_completed_errors")
cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin)
_wait_for_real_success(cc_pair, admin)
now = datetime.now(tz=timezone.utc)
# COMPLETED_WITH_ERRORS 2 hours ago
IndexAttemptManager.create_test_index_attempts(
num_attempts=1,
cc_pair_id=cc_pair.id,
status=IndexingStatus.COMPLETED_WITH_ERRORS,
base_time=now - timedelta(hours=2),
)
# 10 failures after — push everything else off page 1
IndexAttemptManager.create_test_index_attempts(
num_attempts=10,
cc_pair_id=cc_pair.id,
status=IndexingStatus.FAILED,
error_msg="post-partial failure",
base_time=now,
)
detail = _get_detail(cc_pair.id, admin)
listing = _get_listing(cc_pair.id, admin)
assert (
detail.last_indexed is not None
), "COMPLETED_WITH_ERRORS should count as a success for last_indexed"
assert (
listing.last_success is not None
), "COMPLETED_WITH_ERRORS should count as a success for last_success"
assert detail.last_indexed == listing.last_success, (
f"Detail last_indexed={detail.last_indexed} != "
f"listing last_success={listing.last_success}"
)

View File

@@ -1,5 +1,3 @@
import csv
import io
import os
from datetime import datetime
from datetime import timedelta
@@ -141,12 +139,12 @@ def test_chat_history_csv_export(
assert headers["Content-Type"] == "text/csv; charset=utf-8"
assert "Content-Disposition" in headers
# Use csv.reader to properly handle newlines inside quoted fields
csv_rows = list(csv.reader(io.StringIO(csv_content)))
assert len(csv_rows) == 3 # Header + 2 QA pairs
assert csv_rows[0][0] == "chat_session_id"
assert "user_message" in csv_rows[0]
assert "ai_response" in csv_rows[0]
# Verify CSV content
csv_lines = csv_content.strip().split("\n")
assert len(csv_lines) == 3 # Header + 2 QA pairs
assert "chat_session_id" in csv_content
assert "user_message" in csv_content
assert "ai_response" in csv_content
assert "What was the Q1 revenue?" in csv_content
assert "What about Q2 revenue?" in csv_content
@@ -158,5 +156,5 @@ def test_chat_history_csv_export(
end_time=past_end,
user_performing_action=admin_user,
)
csv_rows = list(csv.reader(io.StringIO(csv_content)))
assert len(csv_rows) == 1 # Only header, no data rows
csv_lines = csv_content.strip().split("\n")
assert len(csv_lines) == 1 # Only header, no data rows

View File

@@ -1,5 +1,6 @@
from typing import Any
import pytest
from pydantic import BaseModel
from onyx.db.enums import HookPoint
from onyx.hooks.points.base import HookPointSpec
@@ -10,10 +11,12 @@ def test_init_subclass_raises_for_missing_attrs() -> None:
class IncompleteSpec(HookPointSpec):
hook_point = HookPoint.QUERY_PROCESSING
# missing display_name, description, payload_model, response_model, etc.
# missing display_name, description, etc.
class _Payload(BaseModel):
pass
@property
def input_schema(self) -> dict[str, Any]:
return {}
payload_model = _Payload
response_model = _Payload
@property
def output_schema(self) -> dict[str, Any]:
return {}

View File

@@ -1,541 +0,0 @@
"""Unit tests for the hook executor."""
import json
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import patch
import httpx
import pytest
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.executor import execute_hook
from onyx.hooks.executor import HookSkipped
from onyx.hooks.executor import HookSoftFailed
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_PAYLOAD: dict[str, Any] = {"query": "test", "user_email": "u@example.com"}
_RESPONSE_PAYLOAD: dict[str, Any] = {"rewritten_query": "better test"}
def _make_hook(
*,
is_active: bool = True,
endpoint_url: str | None = "https://hook.example.com/query",
api_key: MagicMock | None = None,
timeout_seconds: float = 5.0,
fail_strategy: HookFailStrategy = HookFailStrategy.SOFT,
hook_id: int = 1,
is_reachable: bool | None = None,
) -> MagicMock:
hook = MagicMock()
hook.is_active = is_active
hook.endpoint_url = endpoint_url
hook.api_key = api_key
hook.timeout_seconds = timeout_seconds
hook.id = hook_id
hook.fail_strategy = fail_strategy
hook.is_reachable = is_reachable
return hook
def _make_api_key(value: str) -> MagicMock:
api_key = MagicMock()
api_key.get_value.return_value = value
return api_key
def _make_response(
*,
status_code: int = 200,
json_return: Any = _RESPONSE_PAYLOAD,
json_side_effect: Exception | None = None,
) -> MagicMock:
"""Build a response mock with controllable json() behaviour."""
response = MagicMock()
response.status_code = status_code
if json_side_effect is not None:
response.json.side_effect = json_side_effect
else:
response.json.return_value = json_return
return response
def _setup_client(
mock_client_cls: MagicMock,
*,
response: MagicMock | None = None,
side_effect: Exception | None = None,
) -> MagicMock:
"""Wire up the httpx.Client mock and return the inner client.
If side_effect is an httpx.HTTPStatusError, it is raised from
raise_for_status() (matching real httpx behaviour) and post() returns a
response mock with the matching status_code set. All other exceptions are
raised directly from post().
"""
mock_client = MagicMock()
if isinstance(side_effect, httpx.HTTPStatusError):
error_response = MagicMock()
error_response.status_code = side_effect.response.status_code
error_response.raise_for_status.side_effect = side_effect
mock_client.post = MagicMock(return_value=error_response)
else:
mock_client.post = MagicMock(
side_effect=side_effect, return_value=response if not side_effect else None
)
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
return mock_client
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def db_session() -> MagicMock:
return MagicMock()
# ---------------------------------------------------------------------------
# Early-exit guards (no HTTP call, no DB writes)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"hooks_available,hook",
[
# HOOKS_AVAILABLE=False exits before the DB lookup — hook is irrelevant.
pytest.param(False, None, id="hooks_not_available"),
pytest.param(True, None, id="hook_not_found"),
pytest.param(True, _make_hook(is_active=False), id="hook_inactive"),
pytest.param(True, _make_hook(endpoint_url=None), id="no_endpoint_url"),
],
)
def test_early_exit_returns_skipped_with_no_db_writes(
db_session: MagicMock,
hooks_available: bool,
hook: MagicMock | None,
) -> None:
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", hooks_available),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch("onyx.hooks.executor.update_hook__no_commit") as mock_update,
patch("onyx.hooks.executor.create_hook_execution_log__no_commit") as mock_log,
):
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
assert isinstance(result, HookSkipped)
mock_update.assert_not_called()
mock_log.assert_not_called()
# ---------------------------------------------------------------------------
# Successful HTTP call
# ---------------------------------------------------------------------------
def test_success_returns_payload_and_sets_reachable(db_session: MagicMock) -> None:
hook = _make_hook()
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch("onyx.hooks.executor.get_session_with_current_tenant"),
patch("onyx.hooks.executor.update_hook__no_commit") as mock_update,
patch("onyx.hooks.executor.create_hook_execution_log__no_commit") as mock_log,
patch("httpx.Client") as mock_client_cls,
):
_setup_client(mock_client_cls, response=_make_response())
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
assert result == _RESPONSE_PAYLOAD
_, update_kwargs = mock_update.call_args
assert update_kwargs["is_reachable"] is True
mock_log.assert_not_called()
def test_success_skips_reachable_write_when_already_true(db_session: MagicMock) -> None:
"""Deduplication guard: a hook already at is_reachable=True that succeeds
must not trigger a DB write."""
hook = _make_hook(is_reachable=True)
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch("onyx.hooks.executor.get_session_with_current_tenant"),
patch("onyx.hooks.executor.update_hook__no_commit") as mock_update,
patch("onyx.hooks.executor.create_hook_execution_log__no_commit"),
patch("httpx.Client") as mock_client_cls,
):
_setup_client(mock_client_cls, response=_make_response())
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
assert result == _RESPONSE_PAYLOAD
mock_update.assert_not_called()
def test_non_dict_json_response_is_a_failure(db_session: MagicMock) -> None:
"""response.json() returning a non-dict (e.g. list) must be treated as failure.
The server responded, so is_reachable is not updated."""
hook = _make_hook(fail_strategy=HookFailStrategy.SOFT)
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch("onyx.hooks.executor.get_session_with_current_tenant"),
patch("onyx.hooks.executor.update_hook__no_commit") as mock_update,
patch("onyx.hooks.executor.create_hook_execution_log__no_commit") as mock_log,
patch("httpx.Client") as mock_client_cls,
):
_setup_client(
mock_client_cls,
response=_make_response(json_return=["unexpected", "list"]),
)
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
assert isinstance(result, HookSoftFailed)
_, log_kwargs = mock_log.call_args
assert log_kwargs["is_success"] is False
assert "non-dict" in (log_kwargs["error_message"] or "")
mock_update.assert_not_called()
def test_json_decode_failure_is_a_failure(db_session: MagicMock) -> None:
"""response.json() raising must be treated as failure with SOFT strategy.
The server responded, so is_reachable is not updated."""
hook = _make_hook(fail_strategy=HookFailStrategy.SOFT)
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch("onyx.hooks.executor.get_session_with_current_tenant"),
patch("onyx.hooks.executor.update_hook__no_commit") as mock_update,
patch("onyx.hooks.executor.create_hook_execution_log__no_commit") as mock_log,
patch("httpx.Client") as mock_client_cls,
):
_setup_client(
mock_client_cls,
response=_make_response(
json_side_effect=json.JSONDecodeError("not JSON", "", 0)
),
)
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
assert isinstance(result, HookSoftFailed)
_, log_kwargs = mock_log.call_args
assert log_kwargs["is_success"] is False
assert "non-JSON" in (log_kwargs["error_message"] or "")
mock_update.assert_not_called()
# ---------------------------------------------------------------------------
# HTTP failure paths
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"exception,fail_strategy,expected_type,expected_is_reachable",
[
# NetworkError → is_reachable=False
pytest.param(
httpx.ConnectError("refused"),
HookFailStrategy.SOFT,
HookSoftFailed,
False,
id="connect_error_soft",
),
pytest.param(
httpx.ConnectError("refused"),
HookFailStrategy.HARD,
OnyxError,
False,
id="connect_error_hard",
),
# 401/403 → is_reachable=False (api_key revoked)
pytest.param(
httpx.HTTPStatusError(
"401",
request=MagicMock(),
response=MagicMock(status_code=401, text="Unauthorized"),
),
HookFailStrategy.SOFT,
HookSoftFailed,
False,
id="auth_401_soft",
),
pytest.param(
httpx.HTTPStatusError(
"403",
request=MagicMock(),
response=MagicMock(status_code=403, text="Forbidden"),
),
HookFailStrategy.HARD,
OnyxError,
False,
id="auth_403_hard",
),
# TimeoutException → no is_reachable write (None)
pytest.param(
httpx.TimeoutException("timeout"),
HookFailStrategy.SOFT,
HookSoftFailed,
None,
id="timeout_soft",
),
pytest.param(
httpx.TimeoutException("timeout"),
HookFailStrategy.HARD,
OnyxError,
None,
id="timeout_hard",
),
# Other HTTP errors → no is_reachable write (None)
pytest.param(
httpx.HTTPStatusError(
"500",
request=MagicMock(),
response=MagicMock(status_code=500, text="error"),
),
HookFailStrategy.SOFT,
HookSoftFailed,
None,
id="http_status_error_soft",
),
pytest.param(
httpx.HTTPStatusError(
"500",
request=MagicMock(),
response=MagicMock(status_code=500, text="error"),
),
HookFailStrategy.HARD,
OnyxError,
None,
id="http_status_error_hard",
),
],
)
def test_http_failure_paths(
db_session: MagicMock,
exception: Exception,
fail_strategy: HookFailStrategy,
expected_type: type,
expected_is_reachable: bool | None,
) -> None:
hook = _make_hook(fail_strategy=fail_strategy)
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch("onyx.hooks.executor.get_session_with_current_tenant"),
patch("onyx.hooks.executor.update_hook__no_commit") as mock_update,
patch("onyx.hooks.executor.create_hook_execution_log__no_commit"),
patch("httpx.Client") as mock_client_cls,
):
_setup_client(mock_client_cls, side_effect=exception)
if expected_type is OnyxError:
with pytest.raises(OnyxError) as exc_info:
execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
assert exc_info.value.error_code is OnyxErrorCode.HOOK_EXECUTION_FAILED
else:
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
assert isinstance(result, expected_type)
if expected_is_reachable is None:
mock_update.assert_not_called()
else:
mock_update.assert_called_once()
_, kwargs = mock_update.call_args
assert kwargs["is_reachable"] is expected_is_reachable
# ---------------------------------------------------------------------------
# Authorization header
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"api_key_value,expect_auth_header",
[
pytest.param("secret-token", True, id="api_key_present"),
pytest.param(None, False, id="api_key_absent"),
],
)
def test_authorization_header(
db_session: MagicMock,
api_key_value: str | None,
expect_auth_header: bool,
) -> None:
api_key = _make_api_key(api_key_value) if api_key_value else None
hook = _make_hook(api_key=api_key)
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch("onyx.hooks.executor.get_session_with_current_tenant"),
patch("onyx.hooks.executor.update_hook__no_commit"),
patch("onyx.hooks.executor.create_hook_execution_log__no_commit"),
patch("httpx.Client") as mock_client_cls,
):
mock_client = _setup_client(mock_client_cls, response=_make_response())
execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
_, call_kwargs = mock_client.post.call_args
if expect_auth_header:
assert call_kwargs["headers"]["Authorization"] == f"Bearer {api_key_value}"
else:
assert "Authorization" not in call_kwargs["headers"]
# ---------------------------------------------------------------------------
# Persist session failure
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"http_exception,expected_result",
[
pytest.param(None, _RESPONSE_PAYLOAD, id="success_path"),
pytest.param(httpx.ConnectError("refused"), OnyxError, id="hard_fail_path"),
],
)
def test_persist_session_failure_is_swallowed(
db_session: MagicMock,
http_exception: Exception | None,
expected_result: Any,
) -> None:
"""DB session failure in _persist_result must not mask the real return value or OnyxError."""
hook = _make_hook(fail_strategy=HookFailStrategy.HARD)
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch(
"onyx.hooks.executor.get_session_with_current_tenant",
side_effect=RuntimeError("DB unavailable"),
),
patch("httpx.Client") as mock_client_cls,
):
_setup_client(
mock_client_cls,
response=_make_response() if not http_exception else None,
side_effect=http_exception,
)
if expected_result is OnyxError:
with pytest.raises(OnyxError) as exc_info:
execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
assert exc_info.value.error_code is OnyxErrorCode.HOOK_EXECUTION_FAILED
else:
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
assert result == expected_result
def test_is_reachable_failure_does_not_prevent_log(db_session: MagicMock) -> None:
"""is_reachable update failing (e.g. concurrent hook deletion) must not
prevent the execution log from being written.
Simulates the production failure path: update_hook__no_commit raises
OnyxError(NOT_FOUND) as it would if the hook was concurrently deleted
between the initial lookup and the reachable update.
"""
hook = _make_hook(fail_strategy=HookFailStrategy.SOFT)
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch("onyx.hooks.executor.get_session_with_current_tenant"),
patch(
"onyx.hooks.executor.update_hook__no_commit",
side_effect=OnyxError(OnyxErrorCode.NOT_FOUND, "hook deleted"),
),
patch("onyx.hooks.executor.create_hook_execution_log__no_commit") as mock_log,
patch("httpx.Client") as mock_client_cls,
):
_setup_client(mock_client_cls, side_effect=httpx.ConnectError("refused"))
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
)
assert isinstance(result, HookSoftFailed)
mock_log.assert_called_once()

View File

@@ -37,20 +37,18 @@ def test_input_schema_query_is_string() -> None:
def test_input_schema_user_email_is_nullable() -> None:
props = QueryProcessingSpec().input_schema["properties"]
# Pydantic v2 emits anyOf for nullable fields
assert any(s.get("type") == "null" for s in props["user_email"]["anyOf"])
assert "null" in props["user_email"]["type"]
def test_output_schema_query_is_optional() -> None:
# query defaults to None (absent = reject); not required in the schema
def test_output_schema_query_is_required() -> None:
schema = QueryProcessingSpec().output_schema
assert "query" not in schema.get("required", [])
assert "query" in schema["required"]
def test_output_schema_query_is_nullable() -> None:
# null means "reject the query"; Pydantic v2 emits anyOf for nullable fields
# null means "reject the query"
props = QueryProcessingSpec().output_schema["properties"]
assert any(s.get("type") == "null" for s in props["query"]["anyOf"])
assert "null" in props["query"]["type"]
def test_output_schema_rejection_message_is_optional() -> None:

View File

@@ -1,278 +0,0 @@
"""Unit tests for onyx.server.features.hooks.api helpers.
Covers:
- _check_ssrf_safety: scheme enforcement and private-IP blocklist
- _validate_endpoint: httpx exception → HookValidateStatus mapping
ConnectTimeout → cannot_connect (TCP handshake never completed)
ConnectError → cannot_connect (DNS / TLS failure)
ReadTimeout et al. → timeout (TCP connected, server slow)
Any other exc → cannot_connect
- _raise_for_validation_failure: HookValidateStatus → OnyxError mapping
"""
from unittest.mock import MagicMock
from unittest.mock import patch
import httpx
import pytest
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.models import HookValidateResponse
from onyx.hooks.models import HookValidateStatus
from onyx.server.features.hooks.api import _check_ssrf_safety
from onyx.server.features.hooks.api import _raise_for_validation_failure
from onyx.server.features.hooks.api import _validate_endpoint
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_URL = "https://example.com/hook"
_API_KEY = "secret"
_TIMEOUT = 5.0
def _mock_response(status_code: int) -> MagicMock:
response = MagicMock()
response.status_code = status_code
return response
# ---------------------------------------------------------------------------
# _check_ssrf_safety
# ---------------------------------------------------------------------------
class TestCheckSsrfSafety:
def _call(self, url: str) -> None:
_check_ssrf_safety(url)
# --- scheme checks ---
def test_https_is_allowed(self) -> None:
with patch("onyx.utils.url.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
self._call("https://example.com/hook") # must not raise
@pytest.mark.parametrize(
"url", ["http://example.com/hook", "ftp://example.com/hook"]
)
def test_non_https_scheme_rejected(self, url: str) -> None:
with pytest.raises(OnyxError) as exc_info:
self._call(url)
assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT
assert "https" in (exc_info.value.detail or "").lower()
# --- private IP blocklist ---
@pytest.mark.parametrize(
"ip",
[
pytest.param("127.0.0.1", id="loopback"),
pytest.param("10.0.0.1", id="RFC1918-A"),
pytest.param("172.16.0.1", id="RFC1918-B"),
pytest.param("192.168.1.1", id="RFC1918-C"),
pytest.param("169.254.169.254", id="link-local-IMDS"),
pytest.param("100.64.0.1", id="shared-address-space"),
pytest.param("::1", id="IPv6-loopback"),
pytest.param("fc00::1", id="IPv6-ULA"),
pytest.param("fe80::1", id="IPv6-link-local"),
],
)
def test_private_ip_is_blocked(self, ip: str) -> None:
with (
patch("onyx.utils.url.socket.getaddrinfo") as mock_dns,
pytest.raises(OnyxError) as exc_info,
):
mock_dns.return_value = [(None, None, None, None, (ip, 0))]
self._call("https://internal.example.com/hook")
assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT
assert ip in (exc_info.value.detail or "")
def test_public_ip_is_allowed(self) -> None:
with patch("onyx.utils.url.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
self._call("https://example.com/hook") # must not raise
def test_dns_resolution_failure_raises(self) -> None:
import socket
with (
patch(
"onyx.utils.url.socket.getaddrinfo",
side_effect=socket.gaierror("name not found"),
),
pytest.raises(OnyxError) as exc_info,
):
self._call("https://no-such-host.example.com/hook")
assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT
# ---------------------------------------------------------------------------
# _validate_endpoint
# ---------------------------------------------------------------------------
class TestValidateEndpoint:
def _call(self, *, api_key: str | None = _API_KEY) -> HookValidateResponse:
# Bypass SSRF check — tested separately in TestCheckSsrfSafety.
with patch("onyx.server.features.hooks.api._check_ssrf_safety"):
return _validate_endpoint(
endpoint_url=_URL,
api_key=api_key,
timeout_seconds=_TIMEOUT,
)
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_2xx_returns_passed(self, mock_client_cls: MagicMock) -> None:
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
_mock_response(200)
)
assert self._call().status == HookValidateStatus.passed
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_5xx_returns_passed(self, mock_client_cls: MagicMock) -> None:
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
_mock_response(500)
)
assert self._call().status == HookValidateStatus.passed
@patch("onyx.server.features.hooks.api.httpx.Client")
@pytest.mark.parametrize("status_code", [401, 403])
def test_401_403_returns_auth_failed(
self, mock_client_cls: MagicMock, status_code: int
) -> None:
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
_mock_response(status_code)
)
result = self._call()
assert result.status == HookValidateStatus.auth_failed
assert str(status_code) in (result.error_message or "")
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_4xx_non_auth_returns_passed(self, mock_client_cls: MagicMock) -> None:
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
_mock_response(422)
)
assert self._call().status == HookValidateStatus.passed
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_connect_timeout_returns_cannot_connect(
self, mock_client_cls: MagicMock
) -> None:
mock_client_cls.return_value.__enter__.return_value.post.side_effect = (
httpx.ConnectTimeout("timed out")
)
assert self._call().status == HookValidateStatus.cannot_connect
@patch("onyx.server.features.hooks.api.httpx.Client")
@pytest.mark.parametrize(
"exc",
[
httpx.ReadTimeout("read timeout"),
httpx.WriteTimeout("write timeout"),
httpx.PoolTimeout("pool timeout"),
],
)
def test_read_write_pool_timeout_returns_timeout(
self, mock_client_cls: MagicMock, exc: httpx.TimeoutException
) -> None:
mock_client_cls.return_value.__enter__.return_value.post.side_effect = exc
assert self._call().status == HookValidateStatus.timeout
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_connect_error_returns_cannot_connect(
self, mock_client_cls: MagicMock
) -> None:
# Covers DNS failures, TLS errors, and other connection-level errors.
mock_client_cls.return_value.__enter__.return_value.post.side_effect = (
httpx.ConnectError("name resolution failed")
)
assert self._call().status == HookValidateStatus.cannot_connect
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_arbitrary_exception_returns_cannot_connect(
self, mock_client_cls: MagicMock
) -> None:
mock_client_cls.return_value.__enter__.return_value.post.side_effect = (
ConnectionRefusedError("refused")
)
assert self._call().status == HookValidateStatus.cannot_connect
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_api_key_sent_as_bearer(self, mock_client_cls: MagicMock) -> None:
mock_post = mock_client_cls.return_value.__enter__.return_value.post
mock_post.return_value = _mock_response(200)
self._call(api_key="mykey")
_, kwargs = mock_post.call_args
assert kwargs["headers"]["Authorization"] == "Bearer mykey"
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_no_api_key_omits_auth_header(self, mock_client_cls: MagicMock) -> None:
mock_post = mock_client_cls.return_value.__enter__.return_value.post
mock_post.return_value = _mock_response(200)
self._call(api_key=None)
_, kwargs = mock_post.call_args
assert "Authorization" not in kwargs["headers"]
# ---------------------------------------------------------------------------
# _raise_for_validation_failure
# ---------------------------------------------------------------------------
class TestRaiseForValidationFailure:
@pytest.mark.parametrize(
"status, expected_code",
[
(HookValidateStatus.auth_failed, OnyxErrorCode.CREDENTIAL_INVALID),
(HookValidateStatus.timeout, OnyxErrorCode.GATEWAY_TIMEOUT),
(HookValidateStatus.cannot_connect, OnyxErrorCode.BAD_GATEWAY),
],
)
def test_raises_correct_error_code(
self, status: HookValidateStatus, expected_code: OnyxErrorCode
) -> None:
validation = HookValidateResponse(status=status, error_message="some error")
with pytest.raises(OnyxError) as exc_info:
_raise_for_validation_failure(validation)
assert exc_info.value.error_code == expected_code
def test_auth_failed_passes_error_message_directly(self) -> None:
validation = HookValidateResponse(
status=HookValidateStatus.auth_failed, error_message="bad credentials"
)
with pytest.raises(OnyxError) as exc_info:
_raise_for_validation_failure(validation)
assert exc_info.value.detail == "bad credentials"
@pytest.mark.parametrize(
"status", [HookValidateStatus.timeout, HookValidateStatus.cannot_connect]
)
def test_timeout_and_cannot_connect_wrap_error_message(
self, status: HookValidateStatus
) -> None:
validation = HookValidateResponse(status=status, error_message="raw error")
with pytest.raises(OnyxError) as exc_info:
_raise_for_validation_failure(validation)
assert exc_info.value.detail == "Endpoint validation failed: raw error"
# ---------------------------------------------------------------------------
# HookValidateStatus enum string values (API contract)
# ---------------------------------------------------------------------------
class TestHookValidateStatusValues:
@pytest.mark.parametrize(
"status, expected",
[
(HookValidateStatus.passed, "passed"),
(HookValidateStatus.auth_failed, "auth_failed"),
(HookValidateStatus.timeout, "timeout"),
(HookValidateStatus.cannot_connect, "cannot_connect"),
],
)
def test_string_values(self, status: HookValidateStatus, expected: str) -> None:
assert status == expected

View File

@@ -489,18 +489,20 @@ services:
- "${HOST_PORT_80:-80}:80"
- "${HOST_PORT:-3000}:80" # allow for localhost:3000 usage, since that is the norm
volumes:
- ../data/nginx:/nginx-templates:ro
- ../data/nginx:/etc/nginx/conf.d
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
# The specified script waits for the api_server to start up.
# Without this we've seen issues where nginx shows no error logs but
# does not recieve any traffic
# NOTE: we have to use dos2unix to remove Carriage Return chars from the file
# in order to make this work on both Unix-like systems and windows
command: >
/bin/sh -c "rm -f /etc/nginx/conf.d/default.conf
&& cp -a /nginx-templates/. /etc/nginx/conf.d/
&& sed 's/\r$//' /etc/nginx/conf.d/run-nginx.sh > /tmp/run-nginx.sh
&& chmod +x /tmp/run-nginx.sh
&& /tmp/run-nginx.sh app.conf.template"
/bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh
&& /etc/nginx/conf.d/run-nginx.sh app.conf.template"
minio:
image: minio/minio:RELEASE.2025-07-23T15-54-02Z-cpuv1

View File

@@ -290,20 +290,25 @@ services:
- "80:80"
- "443:443"
volumes:
- ../data/nginx:/nginx-templates:ro
- ../data/nginx:/etc/nginx/conf.d
- ../data/certbot/conf:/etc/letsencrypt
- ../data/certbot/www:/var/www/certbot
# sleep a little bit to allow the web_server / api_server to start up.
# Without this we've seen issues where nginx shows no error logs but
# does not recieve any traffic
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
# The specified script waits for the api_server to start up.
# Without this we've seen issues where nginx shows no error logs but
# does not recieve any traffic
# NOTE: we have to use dos2unix to remove Carriage Return chars from the file
# in order to make this work on both Unix-like systems and windows
command: >
/bin/sh -c "rm -f /etc/nginx/conf.d/default.conf
&& cp -a /nginx-templates/. /etc/nginx/conf.d/
&& sed 's/\r$//' /etc/nginx/conf.d/run-nginx.sh > /tmp/run-nginx.sh
&& chmod +x /tmp/run-nginx.sh
&& /tmp/run-nginx.sh app.conf.template.prod"
/bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh
&& /etc/nginx/conf.d/run-nginx.sh app.conf.template.prod"
env_file:
- .env.nginx
environment:

View File

@@ -314,19 +314,21 @@ services:
- "80:80"
- "443:443"
volumes:
- ../data/nginx:/nginx-templates:ro
- ../data/nginx:/etc/nginx/conf.d
- ../data/sslcerts:/etc/nginx/sslcerts
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
# The specified script waits for the api_server to start up.
# Without this we've seen issues where nginx shows no error logs but
# does not recieve any traffic
# NOTE: we have to use dos2unix to remove Carriage Return chars from the file
# in order to make this work on both Unix-like systems and windows
command: >
/bin/sh -c "rm -f /etc/nginx/conf.d/default.conf
&& cp -a /nginx-templates/. /etc/nginx/conf.d/
&& sed 's/\r$//' /etc/nginx/conf.d/run-nginx.sh > /tmp/run-nginx.sh
&& chmod +x /tmp/run-nginx.sh
&& /tmp/run-nginx.sh app.conf.template.prod.no-letsencrypt"
/bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh
&& /etc/nginx/conf.d/run-nginx.sh app.conf.template.prod.no-letsencrypt"
env_file:
- .env.nginx
environment:

View File

@@ -333,20 +333,25 @@ services:
- "80:80"
- "443:443"
volumes:
- ../data/nginx:/nginx-templates:ro
- ../data/nginx:/etc/nginx/conf.d
- ../data/certbot/conf:/etc/letsencrypt
- ../data/certbot/www:/var/www/certbot
# sleep a little bit to allow the web_server / api_server to start up.
# Without this we've seen issues where nginx shows no error logs but
# does not recieve any traffic
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
# The specified script waits for the api_server to start up.
# Without this we've seen issues where nginx shows no error logs but
# does not recieve any traffic
# NOTE: we have to use dos2unix to remove Carriage Return chars from the file
# in order to make this work on both Unix-like systems and windows
command: >
/bin/sh -c "rm -f /etc/nginx/conf.d/default.conf
&& cp -a /nginx-templates/. /etc/nginx/conf.d/
&& sed 's/\r$//' /etc/nginx/conf.d/run-nginx.sh > /tmp/run-nginx.sh
&& chmod +x /tmp/run-nginx.sh
&& /tmp/run-nginx.sh app.conf.template.prod"
/bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh
&& /etc/nginx/conf.d/run-nginx.sh app.conf.template.prod"
env_file:
- .env.nginx
environment:

View File

@@ -202,18 +202,20 @@ services:
ports:
- "${NGINX_PORT:-3000}:80" # allow for localhost:3000 usage, since that is the norm
volumes:
- ../data/nginx:/nginx-templates:ro
- ../data/nginx:/etc/nginx/conf.d
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
# The specified script waits for the api_server to start up.
# Without this we've seen issues where nginx shows no error logs but
# does not recieve any traffic
# NOTE: we have to use dos2unix to remove Carriage Return chars from the file
# in order to make this work on both Unix-like systems and windows
command: >
/bin/sh -c "rm -f /etc/nginx/conf.d/default.conf
&& cp -a /nginx-templates/. /etc/nginx/conf.d/
&& sed 's/\r$//' /etc/nginx/conf.d/run-nginx.sh > /tmp/run-nginx.sh
&& chmod +x /tmp/run-nginx.sh
&& /tmp/run-nginx.sh app.conf.template"
/bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh
&& /etc/nginx/conf.d/run-nginx.sh app.conf.template"
minio:
image: minio/minio:RELEASE.2025-07-23T15-54-02Z-cpuv1

View File

@@ -477,10 +477,7 @@ services:
- "${HOST_PORT_80:-80}:80"
- "${HOST_PORT:-3000}:80" # allow for localhost:3000 usage, since that is the norm
volumes:
# Mount templates read-only; the startup command copies them into
# the writable /etc/nginx/conf.d/ inside the container. This avoids
# "Permission denied" errors on Windows Docker bind mounts.
- ../data/nginx:/nginx-templates:ro
- ../data/nginx:/etc/nginx/conf.d
# PRODUCTION: Add SSL certificate volumes for HTTPS support:
# - ../data/certbot/conf:/etc/letsencrypt
# - ../data/certbot/www:/var/www/certbot
@@ -492,13 +489,12 @@ services:
# The specified script waits for the api_server to start up.
# Without this we've seen issues where nginx shows no error logs but
# does not receive any traffic
# NOTE: we have to use dos2unix to remove Carriage Return chars from the file
# in order to make this work on both Unix-like systems and windows
# PRODUCTION: Change to app.conf.template.prod for production nginx config
command: >
/bin/sh -c "rm -f /etc/nginx/conf.d/default.conf
&& cp -a /nginx-templates/. /etc/nginx/conf.d/
&& sed 's/\r$//' /etc/nginx/conf.d/run-nginx.sh > /tmp/run-nginx.sh
&& chmod +x /tmp/run-nginx.sh
&& /tmp/run-nginx.sh app.conf.template"
/bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh
&& /etc/nginx/conf.d/run-nginx.sh app.conf.template"
cache:
image: redis:7.4-alpine

View File

@@ -2,7 +2,7 @@
# Usage: .\install.ps1 [OPTIONS]
# Remote (with params):
# & ([scriptblock]::Create((irm https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deployment/docker_compose/install.ps1))) -Lite -NoPrompt
# Remote (defaults only, configure via interaction during script):
# Remote (defaults only):
# irm https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deployment/docker_compose/install.ps1 | iex
param(
@@ -57,7 +57,11 @@ function Print-Step {
}
function Test-Interactive {
return -not $NoPrompt
if ($NoPrompt) { return $false }
try {
if ([Console]::IsInputRedirected) { return $false }
return $true
} catch { return [Environment]::UserInteractive }
}
function Prompt-OrDefault {
@@ -70,8 +74,8 @@ function Prompt-OrDefault {
function Confirm-Action {
param([string]$Description)
$reply = (Prompt-OrDefault "Install $Description? (Y/n) [default: Y]" "Y").Trim().ToLower()
if ($reply -match '^n') {
$reply = Prompt-OrDefault "Install $Description? (Y/n) [default: Y]" "Y"
if ($reply -match '^[Nn]') {
Print-Warning "Skipping: $Description"
return $false
}
@@ -85,12 +89,12 @@ function Prompt-VersionTag {
Write-Host " - Type a specific tag (e.g., craft-v1.0.0)"
$version = Prompt-OrDefault "Enter tag [default: craft-latest]" "craft-latest"
} else {
Write-Host " - Press Enter for edge (recommended)"
Write-Host " - Press Enter for latest (recommended)"
Write-Host " - Type a specific tag (e.g., v0.1.0)"
$version = Prompt-OrDefault "Enter tag [default: edge]" "edge"
$version = Prompt-OrDefault "Enter tag [default: latest]" "latest"
}
if ($script:IncludeCraftMode -and $version -eq "craft-latest") { Print-Info "Selected: craft-latest (Craft enabled)" }
elseif ($version -eq "edge") { Print-Info "Selected: edge (latest nightly)" }
elseif ($version -eq "latest") { Print-Info "Selected: Latest tag" }
else { Print-Info "Selected: $version" }
return $version
}
@@ -99,16 +103,16 @@ function Prompt-DeploymentMode {
param([string]$LiteOverlayPath)
if ($script:LiteMode) { Print-Info "Deployment mode: Lite (set via -Lite flag)"; return }
Print-Info "Which deployment mode would you like?"
Write-Host " 1) Lite - Minimal deployment (no Vespa, Redis, or model servers)"
Write-Host " 1) Standard - Full deployment with search, connectors, and RAG"
Write-Host " 2) Lite - Minimal deployment (no Vespa, Redis, or model servers)"
Write-Host " LLM chat, tools, file uploads, and Projects still work"
Write-Host " 2) Standard - Full deployment with search, connectors, and RAG"
$modeChoice = Prompt-OrDefault "Choose a mode (1 or 2) [default: 1]" "1"
if ($modeChoice -eq "2") {
Print-Info "Selected: Standard mode"
} else {
$script:LiteMode = $true
Print-Info "Selected: Lite mode"
if (-not (Ensure-OnyxFile $LiteOverlayPath "$($script:GitHubRawUrl)/$($script:LiteComposeFile)" $script:LiteComposeFile)) { exit 1 }
} else {
Print-Info "Selected: Standard mode"
}
}
@@ -354,8 +358,7 @@ function Invoke-OnyxShutdown {
return
}
if (-not (Initialize-ComposeCommand)) { Print-OnyxError "Docker Compose not found."; exit 1 }
$stopArgs = @("stop")
$result = Invoke-Compose -AutoDetect @stopArgs
$result = Invoke-Compose -AutoDetect stop
if ($result -ne 0) { Print-OnyxError "Failed to stop containers"; exit 1 }
Print-Success "Onyx containers stopped (paused)"
}
@@ -364,7 +367,7 @@ function Invoke-OnyxDeleteData {
Write-Host "`n=== WARNING: This will permanently delete all Onyx data ===`n" -ForegroundColor Red
Print-Warning "This action will remove all Onyx containers, volumes, files, and user data."
if (Test-Interactive) {
$confirm = Prompt-OrDefault "Type 'DELETE' to confirm" ""
$confirm = Read-Host "Type 'DELETE' to confirm"
if ($confirm -ne "DELETE") { Print-Info "Operation cancelled."; return }
} else {
Print-OnyxError "Cannot confirm destructive operation in non-interactive mode."
@@ -372,8 +375,7 @@ function Invoke-OnyxDeleteData {
}
$deployDir = Join-Path $script:InstallRoot "deployment"
if ((Test-Path (Join-Path $deployDir "docker-compose.yml")) -and (Initialize-ComposeCommand)) {
$downArgs = @("down", "-v")
$result = Invoke-Compose -AutoDetect @downArgs
$result = Invoke-Compose -AutoDetect down -v
if ($result -eq 0) { Print-Success "Containers and volumes removed" }
else { Print-OnyxError "Failed to remove containers" }
}
@@ -720,7 +722,6 @@ function Invoke-WslInstall {
# Ensure WSL2 is available
Invoke-NativeQuiet { wsl --status }
if ($LASTEXITCODE -ne 0) {
if (-not (Confirm-Action "WSL2 (Windows Subsystem for Linux)")) { exit 1 }
Print-Info "Installing WSL2..."
try {
$proc = Start-Process wsl -ArgumentList "--install", "--no-distribution" -Wait -PassThru -NoNewWindow
@@ -807,7 +808,7 @@ function Main {
if (Test-Interactive) {
Write-Host "`nPlease acknowledge and press Enter to continue..." -ForegroundColor Yellow
$null = Prompt-OrDefault "" ""
Read-Host | Out-Null
} else {
Write-Host "`nRunning in non-interactive mode - proceeding automatically..." -ForegroundColor Yellow
}
@@ -903,8 +904,8 @@ function Main {
if ($resourceWarning) {
Print-Warning "Onyx recommends at least $($script:ExpectedDockerRamGB)GB RAM and $($script:ExpectedDiskGB)GB disk for standard mode."
Print-Warning "Lite mode requires less (1-4GB RAM, 8-16GB disk) but has no vector database."
$reply = (Prompt-OrDefault "Do you want to continue anyway? (Y/n)" "y").Trim().ToLower()
if ($reply -notmatch '^y') { Print-Info "Installation cancelled."; exit 1 }
$reply = Prompt-OrDefault "Do you want to continue anyway? (Y/n)" "y"
if ($reply -notmatch '^[Yy]') { Print-Info "Installation cancelled."; exit 1 }
Print-Info "Proceeding despite resource limitations..."
}
@@ -926,13 +927,22 @@ function Main {
if ($composeVersion -ne "unknown" -and (Compare-SemVer $composeVersion "2.24.0") -lt 0) {
Print-Warning "Docker Compose $composeVersion is older than 2.24.0 (required for env_file format)."
Print-Info "Update Docker Desktop or install a newer Docker Compose. Installation may fail."
$reply = (Prompt-OrDefault "Continue anyway? (Y/n)" "y").Trim().ToLower()
if ($reply -notmatch '^y') { exit 1 }
$reply = Prompt-OrDefault "Continue anyway? (Y/n)" "y"
if ($reply -notmatch '^[Yy]') { exit 1 }
}
$liteOverlayPath = Join-Path $deploymentDir $script:LiteComposeFile
if ($script:LiteMode) {
if (-not (Ensure-OnyxFile $liteOverlayPath "$($script:GitHubRawUrl)/$($script:LiteComposeFile)" $script:LiteComposeFile)) { exit 1 }
} elseif (Test-Path $liteOverlayPath) {
if (Test-Path (Join-Path $deploymentDir ".env")) {
Print-Warning "Existing lite overlay found but -Lite was not passed."
$reply = Prompt-OrDefault "Remove lite overlay and switch to standard mode? (y/N)" "n"
if ($reply -match '^[Yy]') { Remove-Item -Force $liteOverlayPath; Print-Info "Switched to standard mode" }
else { $script:LiteMode = $true; Print-Info "Keeping lite mode" }
} else {
Remove-Item -Force $liteOverlayPath
}
}
$envTemplateDest = Join-Path $deploymentDir "env.template"
@@ -952,8 +962,7 @@ function Main {
# Check if services are already running
if ((Test-Path $composeDest) -and (Initialize-ComposeCommand)) {
$running = @()
$psArgs = @("ps", "-q")
try { $running = @(Invoke-Compose -AutoDetect @psArgs 2>$null | Where-Object { $_ }) } catch { }
try { $running = @(Invoke-Compose -AutoDetect ps -q 2>$null | Where-Object { $_ }) } catch { }
if ($running.Count -gt 0) {
Print-OnyxError "Onyx services are currently running!"
Print-Info "Run '.\install.ps1 -Shutdown' first, then re-run this script."
@@ -1019,12 +1028,6 @@ function Main {
Print-Info "You can customize .env later for OAuth/SAML, AI models, domain settings, and Craft."
}
# Clean up stale lite overlay if standard mode was selected
if (-not $script:LiteMode -and (Test-Path $liteOverlayPath)) {
Remove-Item -Force $liteOverlayPath
Print-Info "Removed previous lite overlay (switching to standard mode)"
}
# ── Step 6: Check Ports ───────────────────────────────────────────────
Print-Step "Checking for available ports"
$availablePort = Find-AvailablePort 3000
@@ -1034,7 +1037,7 @@ function Main {
Print-Success "Using port $availablePort for nginx"
$currentImageTag = Get-EnvFileValue -Path $envFile -Key "IMAGE_TAG"
$useLatest = ($currentImageTag -eq "edge" -or $currentImageTag -eq "latest" -or $currentImageTag -match '^craft-')
$useLatest = ($currentImageTag -eq "latest" -or $currentImageTag -match '^craft-')
if ($useLatest) { Print-Info "Using '$currentImageTag' tag - will force pull and recreate containers" }
# For pinned version tags, re-download config files from that tag so the
@@ -1066,9 +1069,8 @@ function Main {
# ── Step 8: Start Services ────────────────────────────────────────────
Print-Step "Starting Onyx services"
Print-Info "Launching containers..."
$upArgs = @("up", "-d")
if ($useLatest) { $upArgs += @("--pull", "always", "--force-recreate") }
$upResult = Invoke-Compose @upArgs
if ($useLatest) { $upResult = Invoke-Compose up -d --pull always --force-recreate }
else { $upResult = Invoke-Compose up -d }
if ($upResult -ne 0) { Print-OnyxError "Failed to start Onyx services"; exit 1 }
# ── Step 9: Container Health ──────────────────────────────────────────
@@ -1076,8 +1078,7 @@ function Main {
Start-Sleep -Seconds 10
$restartIssues = $false
$containerIds = @()
$psArgs = @("ps", "-q")
try { $containerIds = @(Invoke-Compose @psArgs 2>$null | Where-Object { $_ }) } catch { }
try { $containerIds = @(Invoke-Compose ps -q 2>$null | Where-Object { $_ }) } catch { }
foreach ($cid in $containerIds) {
if ([string]::IsNullOrWhiteSpace($cid)) { continue }

View File

@@ -96,8 +96,8 @@ fi
# When --lite is passed as a flag, lower resource thresholds early (before the
# resource check). When lite is chosen interactively, the thresholds are adjusted
# after the resource check has already passed with the standard thresholds —
# which is the safer direction.
# inside the new-deployment flow, after the resource check has already passed
# with the standard thresholds — which is the safer direction.
if [[ "$LITE_MODE" = true ]]; then
EXPECTED_DOCKER_RAM_GB=4
EXPECTED_DISK_GB=16
@@ -110,6 +110,9 @@ LITE_COMPOSE_FILE="docker-compose.onyx-lite.yml"
# Build the -f flags for docker compose.
# Pass "true" as $1 to auto-detect a previously-downloaded lite overlay
# (used by shutdown/delete-data so users don't need to remember --lite).
# Without the argument, the lite overlay is only included when --lite was
# explicitly passed — preventing install/start from silently staying in
# lite mode just because the file exists on disk from a prior run.
compose_file_args() {
local auto_detect="${1:-false}"
local args="-f docker-compose.yml"
@@ -174,42 +177,34 @@ ensure_file() {
# --- Interactive prompt helpers ---
is_interactive() {
[[ "$NO_PROMPT" = false ]] && [[ -r /dev/tty ]] && [[ -w /dev/tty ]]
}
read_prompt_line() {
local prompt_text="$1"
if ! is_interactive; then
REPLY=""
return
fi
[[ -n "$prompt_text" ]] && printf "%s" "$prompt_text" > /dev/tty
IFS= read -r REPLY < /dev/tty || REPLY=""
}
read_prompt_char() {
local prompt_text="$1"
if ! is_interactive; then
REPLY=""
return
fi
[[ -n "$prompt_text" ]] && printf "%s" "$prompt_text" > /dev/tty
IFS= read -r -n 1 REPLY < /dev/tty || REPLY=""
printf "\n" > /dev/tty
[[ "$NO_PROMPT" = false ]] && [[ -t 0 ]]
}
prompt_or_default() {
local prompt_text="$1"
local default_value="$2"
read_prompt_line "$prompt_text"
[[ -z "$REPLY" ]] && REPLY="$default_value"
if is_interactive; then
read -p "$prompt_text" -r REPLY
if [[ -z "$REPLY" ]]; then
REPLY="$default_value"
fi
else
REPLY="$default_value"
fi
}
prompt_yn_or_default() {
local prompt_text="$1"
local default_value="$2"
read_prompt_char "$prompt_text"
[[ -z "$REPLY" ]] && REPLY="$default_value"
if is_interactive; then
read -p "$prompt_text" -n 1 -r
echo ""
if [[ -z "$REPLY" ]]; then
REPLY="$default_value"
fi
else
REPLY="$default_value"
fi
}
confirm_action() {
@@ -310,8 +305,8 @@ if [ "$DELETE_DATA_MODE" = true ]; then
echo " • All user data and documents"
echo ""
if is_interactive; then
prompt_or_default "Are you sure you want to continue? Type 'DELETE' to confirm: " ""
echo "" > /dev/tty
read -p "Are you sure you want to continue? Type 'DELETE' to confirm: " -r
echo ""
if [ "$REPLY" != "DELETE" ]; then
print_info "Operation cancelled."
exit 0
@@ -505,7 +500,7 @@ echo ""
if is_interactive; then
echo -e "${YELLOW}${BOLD}Please acknowledge and press Enter to continue...${NC}"
read_prompt_line ""
read -r
echo ""
else
echo -e "${YELLOW}${BOLD}Running in non-interactive mode - proceeding automatically...${NC}"
@@ -750,48 +745,25 @@ if [ "$COMPOSE_VERSION" != "dev" ] && version_compare "$COMPOSE_VERSION" "2.24.0
print_info "Proceeding with installation despite Docker Compose version compatibility issues..."
fi
# Ask for deployment mode (standard vs lite) unless already set via --lite flag
if [[ "$LITE_MODE" = false ]]; then
print_info "Which deployment mode would you like?"
echo ""
echo " 1) Lite - Minimal deployment (no Vespa, Redis, or model servers)"
echo " LLM chat, tools, file uploads, and Projects still work"
echo " 2) Standard - Full deployment with search, connectors, and RAG"
echo ""
prompt_or_default "Choose a mode (1 or 2) [default: 1]: " "1"
echo ""
case "$REPLY" in
2)
print_info "Selected: Standard mode"
;;
*)
LITE_MODE=true
print_info "Selected: Lite mode"
;;
esac
else
print_info "Deployment mode: Lite (set via --lite flag)"
fi
if [[ "$LITE_MODE" = true ]] && [[ "$INCLUDE_CRAFT" = true ]]; then
print_error "--include-craft cannot be used with Lite mode."
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
exit 1
fi
if [[ "$LITE_MODE" = true ]]; then
EXPECTED_DOCKER_RAM_GB=4
EXPECTED_DISK_GB=16
fi
# Handle lite overlay file based on selected mode
# Handle lite overlay: ensure it if --lite, clean up stale copies otherwise
if [[ "$LITE_MODE" = true ]]; then
ensure_file "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" \
"${GITHUB_RAW_URL}/${LITE_COMPOSE_FILE}" "${LITE_COMPOSE_FILE}" || exit 1
elif [[ -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" ]]; then
rm -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
print_info "Removed previous lite overlay (switching to standard mode)"
if [[ -f "${INSTALL_ROOT}/deployment/.env" ]]; then
print_warning "Existing lite overlay found but --lite was not passed."
prompt_yn_or_default "Remove lite overlay and switch to standard mode? (y/N): " "n"
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "Keeping existing lite overlay. Pass --lite to keep using lite mode."
LITE_MODE=true
else
rm -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
print_info "Removed lite overlay (switching to standard mode)"
fi
else
rm -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
print_info "Removed previous lite overlay (switching to standard mode)"
fi
fi
ensure_file "${INSTALL_ROOT}/deployment/env.template" \
@@ -854,22 +826,22 @@ if [ -f "$ENV_FILE" ]; then
if [ "$REPLY" = "update" ]; then
print_info "Update selected. Which tag would you like to deploy?"
echo ""
echo "• Press Enter for edge (recommended)"
echo "• Press Enter for latest (recommended)"
echo "• Type a specific tag (e.g., v0.1.0)"
echo ""
if [ "$INCLUDE_CRAFT" = true ]; then
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
VERSION="$REPLY"
else
prompt_or_default "Enter tag [default: edge]: " "edge"
prompt_or_default "Enter tag [default: latest]: " "latest"
VERSION="$REPLY"
fi
echo ""
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
print_info "Selected: craft-latest (Craft enabled)"
elif [ "$VERSION" = "edge" ]; then
print_info "Selected: edge (latest nightly)"
elif [ "$VERSION" = "latest" ]; then
print_info "Selected: Latest version"
else
print_info "Selected: $VERSION"
fi
@@ -921,6 +893,45 @@ else
print_info "No existing .env file found. Setting up new deployment..."
echo ""
# Ask for deployment mode (standard vs lite) unless already set via --lite flag
if [[ "$LITE_MODE" = false ]]; then
print_info "Which deployment mode would you like?"
echo ""
echo " 1) Standard - Full deployment with search, connectors, and RAG"
echo " 2) Lite - Minimal deployment (no Vespa, Redis, or model servers)"
echo " LLM chat, tools, file uploads, and Projects still work"
echo ""
prompt_or_default "Choose a mode (1 or 2) [default: 1]: " "1"
echo ""
case "$REPLY" in
2)
LITE_MODE=true
print_info "Selected: Lite mode"
ensure_file "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" \
"${GITHUB_RAW_URL}/${LITE_COMPOSE_FILE}" "${LITE_COMPOSE_FILE}" || exit 1
;;
*)
print_info "Selected: Standard mode"
;;
esac
else
print_info "Deployment mode: Lite (set via --lite flag)"
fi
# Validate lite + craft combination (could now be set interactively)
if [[ "$LITE_MODE" = true ]] && [[ "$INCLUDE_CRAFT" = true ]]; then
print_error "--include-craft cannot be used with Lite mode."
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
exit 1
fi
# Adjust resource expectations for lite mode
if [[ "$LITE_MODE" = true ]]; then
EXPECTED_DOCKER_RAM_GB=4
EXPECTED_DISK_GB=16
fi
# Ask for version
print_info "Which tag would you like to deploy?"
echo ""
@@ -931,18 +942,18 @@ else
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
VERSION="$REPLY"
else
echo "• Press Enter for edge (recommended)"
echo "• Press Enter for latest (recommended)"
echo "• Type a specific tag (e.g., v0.1.0)"
echo ""
prompt_or_default "Enter tag [default: edge]: " "edge"
prompt_or_default "Enter tag [default: latest]: " "latest"
VERSION="$REPLY"
fi
echo ""
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
print_info "Selected: craft-latest (Craft enabled)"
elif [ "$VERSION" = "edge" ]; then
print_info "Selected: edge (latest nightly)"
elif [ "$VERSION" = "latest" ]; then
print_info "Selected: Latest tag"
else
print_info "Selected: $VERSION"
fi
@@ -1100,15 +1111,15 @@ fi
export HOST_PORT=$AVAILABLE_PORT
print_success "Using port $AVAILABLE_PORT for nginx"
# Determine if we're using a floating tag (edge, latest, craft-*) that should force pull
# Determine if we're using the latest tag or a craft tag (both should force pull)
# Read IMAGE_TAG from .env file and remove any quotes or whitespace
CURRENT_IMAGE_TAG=$(grep "^IMAGE_TAG=" "$ENV_FILE" | head -1 | cut -d'=' -f2 | tr -d ' "'"'"'')
if [ "$CURRENT_IMAGE_TAG" = "edge" ] || [ "$CURRENT_IMAGE_TAG" = "latest" ] || [[ "$CURRENT_IMAGE_TAG" == craft-* ]]; then
if [ "$CURRENT_IMAGE_TAG" = "latest" ] || [[ "$CURRENT_IMAGE_TAG" == craft-* ]]; then
USE_LATEST=true
if [[ "$CURRENT_IMAGE_TAG" == craft-* ]]; then
print_info "Using craft tag '$CURRENT_IMAGE_TAG' - will force pull and recreate containers"
else
print_info "Using '$CURRENT_IMAGE_TAG' tag - will force pull and recreate containers"
print_info "Using 'latest' tag - will force pull and recreate containers"
fi
else
USE_LATEST=false

View File

@@ -127,7 +127,6 @@ Inputs (common):
- `name` (default `onyx`), `region` (default `us-west-2`), `tags`
- `postgres_username`, `postgres_password`
- `create_vpc` (default true) or existing VPC details and `s3_vpc_endpoint_id`
- WAF controls such as `waf_allowed_ip_cidrs`, `waf_common_rule_set_count_rules`, rate limits, geo restrictions, and logging retention
### `vpc`
- Builds a VPC sized for EKS with multiple private and public subnets

View File

@@ -88,8 +88,6 @@ module "waf" {
tags = local.merged_tags
# WAF configuration with sensible defaults
allowed_ip_cidrs = var.waf_allowed_ip_cidrs
common_rule_set_count_rules = var.waf_common_rule_set_count_rules
rate_limit_requests_per_5_minutes = var.waf_rate_limit_requests_per_5_minutes
api_rate_limit_requests_per_5_minutes = var.waf_api_rate_limit_requests_per_5_minutes
geo_restriction_countries = var.waf_geo_restriction_countries

View File

@@ -117,18 +117,6 @@ variable "waf_rate_limit_requests_per_5_minutes" {
default = 2000
}
variable "waf_allowed_ip_cidrs" {
type = list(string)
description = "Optional IPv4 CIDR ranges allowed through the WAF. Leave empty to disable IP allowlisting."
default = []
}
variable "waf_common_rule_set_count_rules" {
type = list(string)
description = "Subrules within AWSManagedRulesCommonRuleSet to override to COUNT instead of BLOCK."
default = []
}
variable "waf_api_rate_limit_requests_per_5_minutes" {
type = number
description = "Rate limit for API requests per 5 minutes per IP address"

View File

@@ -1,20 +1,6 @@
locals {
name = var.name
tags = var.tags
ip_allowlist_enabled = length(var.allowed_ip_cidrs) > 0
managed_rule_priority = local.ip_allowlist_enabled ? 1 : 0
}
resource "aws_wafv2_ip_set" "allowed_ips" {
count = local.ip_allowlist_enabled ? 1 : 0
name = "${local.name}-allowed-ips"
description = "IP allowlist for ${local.name}"
scope = "REGIONAL"
ip_address_version = "IPV4"
addresses = var.allowed_ip_cidrs
tags = local.tags
name = var.name
tags = var.tags
}
# AWS WAFv2 Web ACL
@@ -27,38 +13,10 @@ resource "aws_wafv2_web_acl" "main" {
allow {}
}
dynamic "rule" {
for_each = local.ip_allowlist_enabled ? [1] : []
content {
name = "BlockRequestsOutsideAllowedIPs"
priority = 1
action {
block {}
}
statement {
not_statement {
statement {
ip_set_reference_statement {
arn = aws_wafv2_ip_set.allowed_ips[0].arn
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "BlockRequestsOutsideAllowedIPsMetric"
sampled_requests_enabled = true
}
}
}
# AWS Managed Rules - Core Rule Set
rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 1 + local.managed_rule_priority
priority = 1
override_action {
none {}
@@ -68,16 +26,6 @@ resource "aws_wafv2_web_acl" "main" {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
dynamic "rule_action_override" {
for_each = var.common_rule_set_count_rules
content {
name = rule_action_override.value
action_to_use {
count {}
}
}
}
}
}
@@ -91,7 +39,7 @@ resource "aws_wafv2_web_acl" "main" {
# AWS Managed Rules - Known Bad Inputs
rule {
name = "AWSManagedRulesKnownBadInputsRuleSet"
priority = 2 + local.managed_rule_priority
priority = 2
override_action {
none {}
@@ -114,7 +62,7 @@ resource "aws_wafv2_web_acl" "main" {
# Rate Limiting Rule
rule {
name = "RateLimitRule"
priority = 3 + local.managed_rule_priority
priority = 3
action {
block {}
@@ -139,7 +87,7 @@ resource "aws_wafv2_web_acl" "main" {
for_each = length(var.geo_restriction_countries) > 0 ? [1] : []
content {
name = "GeoRestrictionRule"
priority = 4 + local.managed_rule_priority
priority = 4
action {
block {}
@@ -162,7 +110,7 @@ resource "aws_wafv2_web_acl" "main" {
# IP Rate Limiting
rule {
name = "APIRateLimitRule"
priority = 5 + local.managed_rule_priority
priority = 5
action {
block {}
@@ -185,7 +133,7 @@ resource "aws_wafv2_web_acl" "main" {
# SQL Injection Protection
rule {
name = "AWSManagedRulesSQLiRuleSet"
priority = 6 + local.managed_rule_priority
priority = 6
override_action {
none {}
@@ -208,7 +156,7 @@ resource "aws_wafv2_web_acl" "main" {
# Anonymous IP Protection
rule {
name = "AWSManagedRulesAnonymousIpList"
priority = 7 + local.managed_rule_priority
priority = 7
override_action {
none {}

View File

@@ -9,18 +9,6 @@ variable "tags" {
default = {}
}
variable "allowed_ip_cidrs" {
type = list(string)
description = "Optional IPv4 CIDR ranges allowed to reach the application. Leave empty to disable IP allowlisting."
default = []
}
variable "common_rule_set_count_rules" {
type = list(string)
description = "Subrules within AWSManagedRulesCommonRuleSet to override to COUNT instead of BLOCK."
default = []
}
variable "rate_limit_requests_per_5_minutes" {
type = number
description = "Rate limit for requests per 5 minutes per IP address"

View File

@@ -3839,9 +3839,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true,
"license": "ISC"
},

View File

@@ -144,7 +144,6 @@ module.exports = {
"**/src/app/**/services/*.test.ts",
"**/src/app/**/utils/*.test.ts",
"**/src/app/**/hooks/*.test.ts", // Pure packet processor tests
"**/src/hooks/**/*.test.ts",
"**/src/refresh-components/**/*.test.ts",
"**/src/refresh-pages/**/*.test.ts",
"**/src/sections/**/*.test.ts",

View File

@@ -1,30 +1,33 @@
"use client";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
interface ActionsContainerProps {
type: "head" | "cell";
children: React.ReactNode;
size?: TableSize;
/** Pass-through click handler (e.g. stopPropagation on body cells). */
onClick?: (e: React.MouseEvent) => void;
children: React.ReactNode;
}
export default function ActionsContainer({
type,
children,
size,
onClick,
}: ActionsContainerProps) {
const size = useTableSize();
const contextSize = useTableSize();
const resolvedSize = size ?? contextSize;
const Tag = type === "head" ? "th" : "td";
return (
<Tag
className="tbl-actions"
data-type={type}
data-size={size}
data-size={resolvedSize}
onClick={onClick}
>
<div className="flex h-full items-center justify-end">{children}</div>
<div className="flex h-full items-center justify-center">{children}</div>
</Tag>
);
}

View File

@@ -8,7 +8,6 @@ import {
type SortingState,
} from "@tanstack/react-table";
import { Button, LineItemButton } from "@opal/components";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import { SvgArrowUpDown, SvgSortOrder, SvgCheck } from "@opal/icons";
import Popover from "@/refresh-components/Popover";
import Divider from "@/refresh-components/Divider";
@@ -21,6 +20,7 @@ import Text from "@/refresh-components/texts/Text";
interface SortingPopoverProps<TData extends RowData = RowData> {
table: Table<TData>;
sorting: SortingState;
size?: "md" | "lg";
footerText?: string;
ascendingLabel?: string;
descendingLabel?: string;
@@ -29,11 +29,11 @@ interface SortingPopoverProps<TData extends RowData = RowData> {
function SortingPopover<TData extends RowData>({
table,
sorting,
size = "lg",
footerText,
ascendingLabel = "Ascending",
descendingLabel = "Descending",
}: SortingPopoverProps<TData>) {
const size = useTableSize();
const [open, setOpen] = useState(false);
const sortableColumns = table
.getAllLeafColumns()
@@ -158,6 +158,7 @@ function SortingPopover<TData extends RowData>({
// ---------------------------------------------------------------------------
interface CreateSortingColumnOptions {
size?: "md" | "lg";
footerText?: string;
ascendingLabel?: string;
descendingLabel?: string;
@@ -176,6 +177,7 @@ function createSortingColumn<TData>(
<SortingPopover
table={table}
sorting={table.getState().sorting}
size={options?.size}
footerText={options?.footerText}
ascendingLabel={options?.ascendingLabel}
descendingLabel={options?.descendingLabel}

View File

@@ -8,7 +8,6 @@ import {
type VisibilityState,
} from "@tanstack/react-table";
import { Button, LineItemButton, Tag } from "@opal/components";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import { SvgColumn, SvgCheck } from "@opal/icons";
import Popover from "@/refresh-components/Popover";
import Divider from "@/refresh-components/Divider";
@@ -20,13 +19,14 @@ import Divider from "@/refresh-components/Divider";
interface ColumnVisibilityPopoverProps<TData extends RowData = RowData> {
table: Table<TData>;
columnVisibility: VisibilityState;
size?: "md" | "lg";
}
function ColumnVisibilityPopover<TData extends RowData>({
table,
columnVisibility,
size = "lg",
}: ColumnVisibilityPopoverProps<TData>) {
const size = useTableSize();
const [open, setOpen] = useState(false);
// User-defined columns only (exclude internal qualifier/actions)
@@ -87,7 +87,13 @@ function ColumnVisibilityPopover<TData extends RowData>({
// Column definition factory
// ---------------------------------------------------------------------------
function createColumnVisibilityColumn<TData>(): ColumnDef<TData, unknown> {
interface CreateColumnVisibilityColumnOptions {
size?: "md" | "lg";
}
function createColumnVisibilityColumn<TData>(
options?: CreateColumnVisibilityColumnOptions
): ColumnDef<TData, unknown> {
return {
id: "__columnVisibility",
size: 44,
@@ -98,6 +104,7 @@ function createColumnVisibilityColumn<TData>(): ColumnDef<TData, unknown> {
<ColumnVisibilityPopover
table={table}
columnVisibility={table.getState().columnVisibility}
size={options?.size}
/>
),
cell: () => null,

View File

@@ -57,11 +57,9 @@ function DragOverlayRowInner<TData>({
<QualifierContainer key={cell.id} type="cell">
<TableQualifier
content={qualifierColumn.content}
icon={qualifierColumn.getContent?.(row.original)}
initials={qualifierColumn.getInitials?.(row.original)}
icon={qualifierColumn.getIcon?.(row.original)}
imageSrc={qualifierColumn.getImageSrc?.(row.original)}
imageAlt={qualifierColumn.getImageAlt?.(row.original)}
background={qualifierColumn.background}
iconSize={qualifierColumn.iconSize}
selectable={isSelectable}
selected={isSelectable && row.getIsSelected()}
/>

View File

@@ -1,8 +1,10 @@
"use client";
import { cn } from "@opal/utils";
import { Button, Pagination, SelectButton } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import { SvgEye, SvgXCircle } from "@opal/icons";
import type { ReactNode } from "react";
@@ -43,6 +45,9 @@ interface FooterSelectionModeProps {
onPageChange: (page: number) => void;
/** Unit label for count pagination. @default "items" */
units?: string;
/** Controls overall footer sizing. `"lg"` (default) or `"md"`. */
size?: TableSize;
className?: string;
}
/**
@@ -68,6 +73,7 @@ interface FooterSummaryModeProps {
leftExtra?: ReactNode;
/** Unit label for the summary text, e.g. "users". */
units?: string;
className?: string;
}
/**
@@ -104,7 +110,11 @@ export default function Footer(props: FooterProps) {
const isSmall = resolvedSize === "md";
return (
<div
className="table-footer flex w-full items-center justify-between border-t border-border-01"
className={cn(
"table-footer",
"flex w-full items-center justify-between border-t border-border-01",
props.className
)}
data-size={resolvedSize}
>
{/* Left side */}

View File

@@ -1,10 +1,10 @@
"use client";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
interface QualifierContainerProps {
type: "head" | "cell";
children?: React.ReactNode;
size?: TableSize;
/** Pass-through click handler (e.g. stopPropagation on body cells). */
onClick?: (e: React.MouseEvent) => void;
}
@@ -12,9 +12,11 @@ interface QualifierContainerProps {
export default function QualifierContainer({
type,
children,
size,
onClick,
}: QualifierContainerProps) {
const resolvedSize = useTableSize();
const contextSize = useTableSize();
const resolvedSize = size ?? contextSize;
const Tag = type === "head" ? "th" : "td";

View File

@@ -7,7 +7,6 @@ row selection, drag-and-drop reordering, and server-side mode.
```tsx
import { Table, createTableColumns } from "@opal/components";
import { SvgUser } from "@opal/icons";
interface User {
id: string;
@@ -19,10 +18,11 @@ interface User {
const tc = createTableColumns<User>();
const columns = [
tc.qualifier({ content: "icon", getContent: () => SvgUser }),
tc.qualifier({ content: "avatar-user", getInitials: (r) => r.name?.[0] ?? "?" }),
tc.column("email", {
header: "Name",
weight: 22,
minWidth: 140,
cell: (email, row) => <span>{row.name ?? email}</span>,
}),
tc.column("status", {
@@ -40,7 +40,7 @@ function UsersTable({ users }: { users: User[] }) {
columns={columns}
getRowId={(r) => r.id}
pageSize={10}
footer={{}}
footer={{ mode: "summary" }}
/>
);
}
@@ -55,7 +55,7 @@ function UsersTable({ users }: { users: User[] }) {
| `getRowId` | `(row: TData) => string` | required | Unique row identifier |
| `pageSize` | `number` | `10` | Rows per page (`Infinity` disables pagination) |
| `size` | `"md" \| "lg"` | `"lg"` | Density variant |
| `footer` | `DataTableFooterConfig` | — | Footer configuration (mode is derived from `selectionBehavior`) |
| `footer` | `DataTableFooterConfig` | — | Footer mode (`"selection"` or `"summary"`) |
| `initialSorting` | `SortingState` | — | Initial sort state |
| `initialColumnVisibility` | `VisibilityState` | — | Initial column visibility |
| `draggable` | `DataTableDraggableConfig` | — | Enable drag-and-drop reordering |
@@ -63,6 +63,7 @@ function UsersTable({ users }: { users: User[] }) {
| `onRowClick` | `(row: TData) => void` | — | Row click handler |
| `searchTerm` | `string` | — | Global text filter |
| `height` | `number \| string` | — | Max scrollable height |
| `headerBackground` | `string` | — | Sticky header background |
| `serverSide` | `ServerSideConfig` | — | Server-side pagination/sorting/filtering |
| `emptyState` | `ReactNode` | — | Empty state content |
@@ -75,8 +76,7 @@ function UsersTable({ users }: { users: User[] }) {
- `tc.displayColumn(opts)` — non-accessor custom column
- `tc.actions(opts)` — trailing actions column with visibility/sorting popovers
## Footer
## Footer Modes
The footer mode is derived automatically from `selectionBehavior`:
- **Selection footer** (when `selectionBehavior` is `"single-select"` or `"multi-select"`) — shows selection count, optional view/clear buttons, count pagination
- **Summary footer** (when `selectionBehavior` is `"no-select"` or omitted) — shows "Showing X\~Y of Z", list pagination, optional extra element
- **`"selection"`** — shows selection count, optional view/clear buttons, count pagination
- **`"summary"`** — shows "Showing X~Y of Z", list pagination, optional extra element

View File

@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Table, createTableColumns } from "@opal/components";
import { SvgUser } from "@opal/icons";
// ---------------------------------------------------------------------------
// Sample data
@@ -109,14 +108,17 @@ const tc = createTableColumns<User>();
const columns = [
tc.qualifier({
content: "icon",
getContent: () => SvgUser,
background: true,
content: "avatar-user",
getInitials: (r) =>
r.name
.split(" ")
.map((n) => n[0])
.join(""),
}),
tc.column("name", { header: "Name", weight: 25 }),
tc.column("email", { header: "Email", weight: 30 }),
tc.column("role", { header: "Role", weight: 15 }),
tc.column("status", { header: "Status", weight: 15 }),
tc.column("name", { header: "Name", weight: 25, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 30, minWidth: 160 }),
tc.column("role", { header: "Role", weight: 15, minWidth: 80 }),
tc.column("status", { header: "Status", weight: 15, minWidth: 80 }),
tc.actions(),
];
@@ -140,7 +142,7 @@ export const Default: Story = {
columns={columns}
getRowId={(r) => r.id}
pageSize={8}
footer={{}}
footer={{ mode: "summary" }}
/>
),
};

View File

@@ -1,20 +1,24 @@
import { cn } from "@opal/utils";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
interface TableCellProps
extends WithoutStyles<React.TdHTMLAttributes<HTMLTableCellElement>> {
children: React.ReactNode;
size?: TableSize;
/** Explicit pixel width for the cell. */
width?: number;
}
export default function TableCell({
size,
width,
children,
...props
}: TableCellProps) {
const resolvedSize = useTableSize();
const contextSize = useTableSize();
const resolvedSize = size ?? contextSize;
return (
<td
className="tbl-cell overflow-hidden"

View File

@@ -1,8 +1,5 @@
"use client";
import React from "react";
import { cn } from "@opal/utils";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
import type { ExtremaSizeVariants, SizeVariants } from "@opal/types";
@@ -12,15 +9,20 @@ import type { ExtremaSizeVariants, SizeVariants } from "@opal/types";
type TableSize = Extract<SizeVariants, "md" | "lg">;
type TableVariant = "rows" | "cards";
type TableQualifier = "simple" | "avatar" | "icon";
type SelectionBehavior = "no-select" | "single-select" | "multi-select";
interface TableProps
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
ref?: React.Ref<HTMLTableElement>;
/** Size preset for the table. @default "lg" */
size?: TableSize;
/** Visual row variant. @default "cards" */
variant?: TableVariant;
/** Row selection behavior. @default "no-select" */
selectionBehavior?: SelectionBehavior;
/** Leading qualifier column type. @default null */
qualifier?: TableQualifier;
/** Height behavior. `"fit"` = shrink to content, `"full"` = fill available space. */
heightVariant?: ExtremaSizeVariants;
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
@@ -36,21 +38,23 @@ interface TableProps
function Table({
ref,
size = "lg",
variant = "cards",
selectionBehavior = "no-select",
qualifier = "simple",
heightVariant,
width,
...props
}: TableProps) {
const size = useTableSize();
return (
<table
ref={ref}
className={cn("border-separate border-spacing-0", !width && "min-w-full")}
style={{ width }}
style={{ tableLayout: "fixed", width }}
data-size={size}
data-variant={variant}
data-selection={selectionBehavior}
data-qualifier={qualifier}
data-height={heightVariant}
{...props}
/>
@@ -58,4 +62,10 @@ function Table({
}
export default Table;
export type { TableProps, TableSize, TableVariant, SelectionBehavior };
export type {
TableProps,
TableSize,
TableVariant,
TableQualifier,
SelectionBehavior,
};

View File

@@ -1,6 +1,7 @@
import { cn } from "@opal/utils";
import Text from "@/refresh-components/texts/Text";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
import { Button } from "@opal/components";
import { SvgChevronDown, SvgChevronUp, SvgHandle, SvgSort } from "@opal/icons";
@@ -29,6 +30,8 @@ interface TableHeadCustomProps {
icon?: (sorted: SortDirection) => IconFunctionComponent;
/** Text alignment for the column. Defaults to `"left"`. */
alignment?: "left" | "center" | "right";
/** Cell density. `"md"` uses tighter padding for denser layouts. */
size?: TableSize;
/** Column width in pixels. Applied as an inline style on the `<th>`. */
width?: number;
/** When `true`, shows a bottom border on hover. Defaults to `true`. */
@@ -78,11 +81,13 @@ export default function TableHead({
resizable,
onResizeStart,
alignment = "left",
size,
width,
bottomBorder = true,
...thProps
}: TableHeadProps) {
const resolvedSize = useTableSize();
const contextSize = useTableSize();
const resolvedSize = size ?? contextSize;
const isSmall = resolvedSize === "md";
return (
<th
@@ -92,7 +97,9 @@ export default function TableHead({
data-size={resolvedSize}
data-bottom-border={bottomBorder || undefined}
>
<div className="flex items-center gap-1">
<div
className={cn("flex items-center gap-1", alignmentFlexClass[alignment])}
>
<div className="table-head-label">
<Text
mainUiAction={!isSmall}

View File

@@ -3,13 +3,19 @@
import React from "react";
import { cn } from "@opal/utils";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import { SvgUser } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import type { QualifierContentType } from "@opal/components/table/types";
import Checkbox from "@/refresh-components/inputs/Checkbox";
import Text from "@/refresh-components/texts/Text";
interface TableQualifierProps {
className?: string;
/** Content type displayed in the qualifier */
content: QualifierContentType;
/** Size variant */
size?: TableSize;
/** Disables interaction */
disabled?: boolean;
/** Whether to show a selection checkbox overlay */
@@ -18,35 +24,54 @@ interface TableQualifierProps {
selected?: boolean;
/** Called when the checkbox is toggled */
onSelectChange?: (selected: boolean) => void;
/** Icon component to render (for "icon" content). */
/** Icon component to render (for "icon" content type) */
icon?: IconFunctionComponent;
/** Image source URL (for "image" content). */
/** Image source URL (for "image" content type) */
imageSrc?: string;
/** Image alt text (for "image" content). */
/** Image alt text */
imageAlt?: string;
/** Show a tinted background container behind the content. */
background?: boolean;
/** Icon size preset. `"lg"` = 28/24, `"md"` = 20/16. @default "md" */
iconSize?: "lg" | "md";
/** User initials (for "avatar-user" content type) */
initials?: string;
}
const iconSizesMap = {
lg: { lg: 28, md: 24 },
md: { lg: 20, md: 16 },
const iconSizes = {
lg: 16,
md: 14,
} as const;
function getOverlayStyles(selected: boolean, disabled: boolean) {
function getQualifierStyles(selected: boolean, disabled: boolean) {
if (disabled) {
return selected ? "flex bg-action-link-00" : "hidden";
return {
container: "bg-background-neutral-03",
icon: "stroke-text-02",
overlay: selected ? "flex bg-action-link-00" : "hidden",
overlayImage: selected ? "flex bg-mask-01 backdrop-blur-02" : "hidden",
};
}
if (selected) {
return "flex bg-action-link-00";
return {
container: "bg-action-link-00",
icon: "stroke-text-03",
overlay: "flex bg-action-link-00",
overlayImage: "flex bg-mask-01 backdrop-blur-02",
};
}
return "flex opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 bg-background-tint-01";
return {
container: "bg-background-tint-01",
icon: "stroke-text-03",
overlay:
"flex opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 bg-background-tint-01",
overlayImage:
"flex opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 bg-mask-01 group-hover/row:backdrop-blur-02 group-focus-within/row:backdrop-blur-02",
};
}
function TableQualifier({
className,
content,
size,
disabled = false,
selectable = false,
selected = false,
@@ -54,68 +79,100 @@ function TableQualifier({
icon: Icon,
imageSrc,
imageAlt = "",
background = false,
iconSize: iconSizePreset = "md",
initials,
}: TableQualifierProps) {
const resolvedSize = useTableSize();
const iconSize = iconSizesMap[iconSizePreset][resolvedSize];
const overlayStyles = getOverlayStyles(selected, disabled);
const contextSize = useTableSize();
const resolvedSize = size ?? contextSize;
const isRound = content === "avatar-icon" || content === "avatar-user";
const iconSize = iconSizes[resolvedSize];
const styles = getQualifierStyles(selected, disabled);
function renderContent() {
switch (content) {
case "icon":
return Icon ? <Icon size={iconSize} /> : null;
return Icon ? <Icon size={iconSize} className={styles.icon} /> : null;
case "simple":
return null;
case "image":
return imageSrc ? (
<img
src={imageSrc}
alt={imageAlt}
className="h-full w-full rounded-08 object-cover"
className={cn(
"h-full w-full object-cover",
isRound ? "rounded-full" : "rounded-08"
)}
/>
) : null;
case "simple":
case "avatar-icon":
return <SvgUser size={iconSize} className={styles.icon} />;
case "avatar-user":
return (
<div
className={cn(
"flex items-center justify-center rounded-full bg-background-neutral-inverted-00",
resolvedSize === "lg" ? "h-7 w-7" : "h-6 w-6"
)}
>
<Text
inverted
secondaryAction
text05
className="select-none uppercase"
>
{initials}
</Text>
</div>
);
default:
return null;
}
}
const inner = renderContent();
const showBackground = background && content !== "simple";
return (
<div
className={cn(
"group relative inline-flex shrink-0 items-center justify-center",
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
disabled ? "cursor-not-allowed" : "cursor-default"
disabled ? "cursor-not-allowed" : "cursor-default",
className
)}
>
{showBackground ? (
{/* Inner qualifier container — no background for "simple" */}
{content !== "simple" && (
<div
className={cn(
"flex items-center justify-center overflow-hidden rounded-08 transition-colors",
"flex items-center justify-center overflow-hidden transition-colors",
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
disabled
? "bg-background-neutral-03"
: selected
? "bg-action-link-00"
: "bg-background-tint-01"
isRound ? "rounded-full" : "rounded-08",
styles.container,
content === "image" && disabled && !selected && "opacity-50"
)}
>
{inner}
{renderContent()}
</div>
) : (
inner
)}
{/* Selection overlay */}
{selectable && (
<div
className={cn(
"absolute inset-0 items-center justify-center rounded-08",
content === "simple" ? "flex" : overlayStyles
"absolute inset-0 items-center justify-center",
content === "simple"
? "flex"
: isRound
? "rounded-full"
: "rounded-08",
content === "simple"
? "flex"
: content === "image"
? styles.overlayImage
: styles.overlay
)}
>
<Checkbox

View File

@@ -2,6 +2,7 @@
import { cn } from "@opal/utils";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -11,7 +12,7 @@ import { SvgHandle } from "@opal/icons";
// Types
// ---------------------------------------------------------------------------
export interface TableRowProps
interface TableRowProps
extends WithoutStyles<React.HTMLAttributes<HTMLTableRowElement>> {
ref?: React.Ref<HTMLTableRowElement>;
selected?: boolean;
@@ -21,6 +22,8 @@ export interface TableRowProps
sortableId?: string;
/** Show drag handle overlay. Defaults to true when sortableId is set. */
showDragHandle?: boolean;
/** Size variant for the drag handle */
size?: TableSize;
}
// ---------------------------------------------------------------------------
@@ -30,13 +33,15 @@ export interface TableRowProps
function SortableTableRow({
sortableId,
showDragHandle = true,
size,
selected,
disabled,
ref: _externalRef,
children,
...props
}: TableRowProps) {
const resolvedSize = useTableSize();
const contextSize = useTableSize();
const resolvedSize = size ?? contextSize;
const {
attributes,
@@ -100,9 +105,10 @@ function SortableTableRow({
// Main component
// ---------------------------------------------------------------------------
export default function TableRow({
function TableRow({
sortableId,
showDragHandle,
size,
selected,
disabled,
ref,
@@ -113,6 +119,7 @@ export default function TableRow({
<SortableTableRow
sortableId={sortableId}
showDragHandle={showDragHandle}
size={size}
selected={selected}
disabled={disabled}
ref={ref}
@@ -131,3 +138,6 @@ export default function TableRow({
/>
);
}
export default TableRow;
export type { TableRowProps };

View File

@@ -25,16 +25,18 @@ import type { SortDirection } from "@opal/components/table/TableHead";
interface QualifierConfig<TData> {
/** Content type for body-row `<TableQualifier>`. @default "simple" */
content?: QualifierContentType;
/** Return the icon component to render for a row (for "icon" content). */
getContent?: (row: TData) => IconFunctionComponent;
/** Return the image URL to render for a row (for "image" content). */
/** Content type for the header `<TableQualifier>`. @default "simple" */
headerContentType?: QualifierContentType;
/** Extract initials from a row (for "avatar-user" content). */
getInitials?: (row: TData) => string;
/** Extract icon from a row (for "icon" / "avatar-icon" content). */
getIcon?: (row: TData) => IconFunctionComponent;
/** Extract image src from a row (for "image" content). */
getImageSrc?: (row: TData) => string;
/** Return the image alt text for a row (for "image" content). @default "" */
getImageAlt?: (row: TData) => string;
/** Show a tinted background container behind the content. @default false */
background?: boolean;
/** Icon size preset. `"lg"` = 28/24, `"md"` = 20/16. @default "md" */
iconSize?: "lg" | "md";
/** Whether to show selection checkboxes on the qualifier. @default true */
selectable?: boolean;
/** Whether to render qualifier content in the header. @default true */
header?: boolean;
}
// ---------------------------------------------------------------------------
@@ -56,6 +58,8 @@ interface DataColumnConfig<TData, TValue> {
icon?: (sorted: SortDirection) => IconFunctionComponent;
/** Column weight for proportional distribution. @default 20 */
weight?: number;
/** Minimum column width in pixels. @default 50 */
minWidth?: number;
}
// ---------------------------------------------------------------------------
@@ -128,9 +132,9 @@ interface TableColumnsBuilder<TData> {
* ```ts
* const tc = createTableColumns<TeamMember>();
* const columns = [
* tc.qualifier({ content: "icon", getContent: (r) => UserIcon }),
* tc.column("name", { header: "Name", weight: 23 }),
* tc.column("email", { header: "Email", weight: 28 }),
* 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, minWidth: 150 }),
* tc.actions(),
* ];
* ```
@@ -158,11 +162,12 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
width: (size: TableSize) =>
size === "md" ? { fixed: 36 } : { fixed: 44 },
content,
getContent: config?.getContent,
headerContentType: config?.headerContentType,
getInitials: config?.getInitials,
getIcon: config?.getIcon,
getImageSrc: config?.getImageSrc,
getImageAlt: config?.getImageAlt,
background: config?.background,
iconSize: config?.iconSize,
selectable: config?.selectable,
header: config?.header,
};
},
@@ -178,6 +183,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
enableHiding = true,
icon,
weight = 20,
minWidth = 50,
} = config;
const def = helper.accessor(accessor as any, {
@@ -195,7 +201,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
kind: "data",
id: accessor as string,
def,
width: { weight, minWidth: Math.max(header.length * 8 + 40, 80) },
width: { weight, minWidth },
icon,
};
},

View File

@@ -39,12 +39,15 @@ import type {
import type { TableSize } from "@opal/components/table/TableSizeContext";
// ---------------------------------------------------------------------------
// SelectionBehavior
// Qualifier × SelectionBehavior
// ---------------------------------------------------------------------------
type Qualifier = "simple" | "avatar" | "icon";
type SelectionBehavior = "no-select" | "single-select" | "multi-select";
export type DataTableProps<TData> = BaseDataTableProps<TData> & {
/** Leading qualifier column type. @default "simple" */
qualifier?: Qualifier;
/** Row selection behavior. @default "no-select" */
selectionBehavior?: SelectionBehavior;
};
@@ -128,8 +131,8 @@ function processColumns<TData>(
* ```tsx
* const tc = createTableColumns<TeamMember>();
* const columns = [
* tc.qualifier({ content: "icon", getContent: (r) => UserIcon }),
* tc.column("name", { header: "Name", weight: 23 }),
* 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(),
* ];
@@ -149,11 +152,13 @@ export function Table<TData>(props: DataTableProps<TData>) {
footer,
size = "lg",
variant = "cards",
qualifier = "simple",
selectionBehavior = "no-select",
onSelectionChange,
onRowClick,
searchTerm,
height,
headerBackground,
serverSide,
emptyState,
} = props;
@@ -161,15 +166,11 @@ export function Table<TData>(props: DataTableProps<TData>) {
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
// Whether the qualifier column should exist in the DOM.
// Derived from the column definitions: if a qualifier column exists with
// content !== "simple", always show it. If content === "simple" (or no
// qualifier column defined), show only for multi-select (checkboxes).
const qualifierColDef = columns.find(
(c): c is OnyxQualifierColumn<TData> => c.kind === "qualifier"
);
// "simple" only gets a qualifier column for multi-select (checkboxes).
// "simple" + no-select/single-select = no qualifier column — single-select
// uses row-level background coloring instead.
const hasQualifierColumn =
(qualifierColDef != null && qualifierColDef.content !== "simple") ||
selectionBehavior === "multi-select";
qualifier !== "simple" || selectionBehavior === "multi-select";
// 1. Process columns (memoized on columns + size)
const { tanstackColumns, widthConfig, qualifierColumn, columnKindMap } =
@@ -348,9 +349,15 @@ export function Table<TData>(props: DataTableProps<TData>) {
overflowY: "auto" as const,
}
: undefined),
...(headerBackground
? ({
"--table-header-bg": headerBackground,
} as React.CSSProperties)
: undefined),
}}
>
<TableElement
size={size}
variant={variant}
selectionBehavior={selectionBehavior}
width={
@@ -412,12 +419,14 @@ export function Table<TData>(props: DataTableProps<TData>) {
columnVisibility={
table.getState().columnVisibility
}
size={size}
/>
)}
{actionsDef.showSorting !== false && (
<SortingPopover
table={table}
sorting={table.getState().sorting}
size={size}
footerText={actionsDef.sortingFooterText}
/>
)}
@@ -532,6 +541,12 @@ export function Table<TData>(props: DataTableProps<TData>) {
if (cellColDef?.kind === "qualifier") {
const qDef = cellColDef as OnyxQualifierColumn<TData>;
// Resolve content based on the qualifier prop:
// - "simple" renders nothing (checkbox only when selectable)
// - "avatar"/"icon" render from column config
const qualifierContent =
qualifier === "simple" ? "simple" : qDef.content;
return (
<QualifierContainer
key={cell.id}
@@ -539,12 +554,10 @@ export function Table<TData>(props: DataTableProps<TData>) {
onClick={(e) => e.stopPropagation()}
>
<TableQualifier
content={qDef.content}
icon={qDef.getContent?.(row.original)}
content={qualifierContent}
initials={qDef.getInitials?.(row.original)}
icon={qDef.getIcon?.(row.original)}
imageSrc={qDef.getImageSrc?.(row.original)}
imageAlt={qDef.getImageAlt?.(row.original)}
background={qDef.background}
iconSize={qDef.iconSize}
selectable={showQualifierCheckbox}
selected={
showQualifierCheckbox && row.getIsSelected()

View File

@@ -277,7 +277,7 @@ function createSplitterResizeHandler(
* const { containerRef, columnWidths, createResizeHandler } = useColumnWidths({
* headers: table.getHeaderGroups()[0].headers,
* fixedColumnIds: new Set(["actions"]),
* columnMinWidths: { name: 72, status: 80 },
* columnMinWidths: { name: 120, status: 80 },
* });
* ```
*/

View File

@@ -25,7 +25,8 @@
/* ---- TableHead ---- */
.table-head {
@apply relative;
@apply relative sticky top-0 z-20;
background: var(--table-header-bg, transparent);
}
.table-head[data-size="lg"] {
@apply px-2 py-1;
@@ -129,7 +130,8 @@ table[data-variant="cards"] .tbl-row:has(:focus-visible) > td {
/* ---- QualifierContainer ---- */
.tbl-qualifier[data-type="head"] {
@apply w-px whitespace-nowrap py-1;
@apply w-px whitespace-nowrap py-1 sticky top-0 z-20;
background: var(--table-header-bg, transparent);
}
.tbl-qualifier[data-type="head"][data-size="md"] {
@apply py-0.5;
@@ -145,10 +147,11 @@ table[data-variant="cards"] .tbl-row:has(:focus-visible) > td {
/* ---- ActionsContainer ---- */
.tbl-actions {
@apply w-px whitespace-nowrap px-1;
@apply sticky right-0 w-px whitespace-nowrap px-1;
}
.tbl-actions[data-type="head"] {
@apply px-2 py-1;
@apply z-30 sticky top-0 px-2 py-1;
background: var(--table-header-bg, transparent);
}
/* ---- Footer ---- */

View File

@@ -30,7 +30,12 @@ export type ColumnWidth = DataColumnWidth | FixedColumnWidth;
// Column kind discriminant
// ---------------------------------------------------------------------------
export type QualifierContentType = "simple" | "icon" | "image";
export type QualifierContentType =
| "icon"
| "simple"
| "image"
| "avatar-icon"
| "avatar-user";
export type OnyxColumnKind = "qualifier" | "data" | "display" | "actions";
@@ -51,16 +56,18 @@ export interface OnyxQualifierColumn<TData> extends OnyxColumnBase<TData> {
kind: "qualifier";
/** Content type for body-row `<TableQualifier>`. */
content: QualifierContentType;
/** Return the icon component to render for a row (for "icon" content). */
getContent?: (row: TData) => IconFunctionComponent;
/** Return the image URL to render for a row (for "image" content). */
/** Content type for the header `<TableQualifier>`. @default "simple" */
headerContentType?: QualifierContentType;
/** Extract initials from a row (for "avatar-user" content). */
getInitials?: (row: TData) => string;
/** Extract icon from a row (for "icon" / "avatar-icon" content). */
getIcon?: (row: TData) => IconFunctionComponent;
/** Extract image src from a row (for "image" content). */
getImageSrc?: (row: TData) => string;
/** Return the image alt text for a row (for "image" content). @default "" */
getImageAlt?: (row: TData) => string;
/** Show a tinted background container behind the content. @default false */
background?: boolean;
/** Icon size preset. Use `"lg"` for avatars, `"md"` for regular icons. @default "md" */
iconSize?: "lg" | "md";
/** Whether to show selection checkboxes on the qualifier. @default true */
selectable?: boolean;
/** Whether to render qualifier content in the header. @default true */
header?: boolean;
}
/** Data column — accessor-based column with sorting/resizing. */
@@ -167,6 +174,9 @@ export interface DataTableProps<TData> {
* Accepts a pixel number (e.g. `300`) or a CSS value string (e.g. `"50vh"`).
*/
height?: number | string;
/** Background color for the sticky header row, preventing rows from showing
* through when scrolling. Accepts any CSS color value. */
headerBackground?: string;
/**
* Enable server-side mode. When provided:
* - TanStack uses manualPagination/manualSorting/manualFiltering

View File

@@ -159,7 +159,6 @@ export { default as SvgSort } from "@opal/icons/sort";
export { default as SvgSortOrder } from "@opal/icons/sort-order";
export { default as SvgSparkle } from "@opal/icons/sparkle";
export { default as SvgStar } from "@opal/icons/star";
export { default as SvgStarOff } from "@opal/icons/star-off";
export { default as SvgStep1 } from "@opal/icons/step1";
export { default as SvgStep2 } from "@opal/icons/step2";
export { default as SvgStep3 } from "@opal/icons/step3";

View File

@@ -1,22 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgStarOff = ({ 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="M1 1L5.56196 5.56196M15 15L5.56196 5.56196M5.56196 5.56196L1.33333 6.18004L4.66666 9.42671L3.88 14.0134L8 11.8467L12.12 14.0134L11.7267 11.72M12.1405 8.64051L14.6667 6.18004L10.06 5.50671L8 1.33337L6.95349 3.45349"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgStarOff;

456
web/package-lock.json generated
View File

@@ -170,65 +170,25 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
"integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.1.1",
"@csstools/css-color-parser": "^4.0.2",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.2.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz",
"integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
"license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
@@ -661,23 +621,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
@@ -691,13 +638,13 @@
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
@@ -711,17 +658,17 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
@@ -735,21 +682,21 @@
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.1.1"
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
@@ -763,41 +710,16 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz",
"integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
@@ -811,7 +733,7 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
}
},
"node_modules/@date-fns/tz": {
@@ -1637,24 +1559,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"license": "MIT",
@@ -8443,16 +8347,6 @@
"node": ">=12.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"license": "MIT",
@@ -9031,20 +8925,6 @@
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"dev": true,
@@ -9060,6 +8940,20 @@
"node": ">=4"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"license": "MIT"
@@ -9169,17 +9063,17 @@
"license": "BSD-2-Clause"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">=18"
}
},
"node_modules/data-view-buffer": {
@@ -10415,9 +10309,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true,
"license": "ISC"
},
@@ -10533,6 +10427,7 @@
},
"node_modules/fsevents": {
"version": "2.3.2",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -11115,16 +11010,16 @@
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">=18"
}
},
"node_modules/html-escaper": {
@@ -11148,6 +11043,30 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/http-proxy-agent/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
@@ -11173,6 +11092,19 @@
"node": ">=10.17.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/identity-obj-proxy": {
"version": "3.0.0",
"dev": true,
@@ -12735,36 +12667,35 @@
}
},
"node_modules/jsdom": {
"version": "29.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
"integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
"@asamuzakjp/dom-selector": "^7.0.3",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7",
"parse5": "^8.0.0",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.24.5",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
@@ -12775,27 +12706,28 @@
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"node_modules/jsdom/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"license": "MIT",
"engines": {
"node": "20 || >=22"
"node": ">= 14"
}
},
"node_modules/jsdom/node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"node_modules/jsdom/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
"agent-base": "^7.1.2",
"debug": "4"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
"engines": {
"node": ">= 14"
}
},
"node_modules/jsesc": {
@@ -13386,13 +13318,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "6.0.0",
"license": "MIT"
@@ -14695,6 +14620,13 @@
"node": ">=8"
}
},
"node_modules/nwsapi": {
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"license": "MIT",
@@ -16201,6 +16133,7 @@
"node_modules/require-from-string": {
"version": "2.0.2",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16343,6 +16276,13 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"funding": [
@@ -16413,6 +16353,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -17431,22 +17378,22 @@
}
},
"node_modules/tldts": {
"version": "7.0.27",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
"integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==",
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.27"
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.27",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz",
"integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==",
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
@@ -17470,29 +17417,29 @@
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/trim-lines": {
@@ -17799,16 +17746,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici": {
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -18310,13 +18247,13 @@
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
"node": ">=12"
}
},
"node_modules/webpack": {
@@ -18409,34 +18346,47 @@
"node": ">=4.0"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"dev": true,
"license": "MIT"
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">=18"
}
},
"node_modules/which": {

View File

@@ -153,9 +153,6 @@
"overrides": {
"react-is": "^19.0.0-rc-69d4b800-20241021",
"@types/react": "19.2.10",
"@types/react-dom": "19.2.3",
"jest-environment-jsdom": {
"jsdom": "^29.0.1"
}
"@types/react-dom": "19.2.3"
}
}

View File

@@ -626,7 +626,10 @@ function Main({ ccPairId }: { ccPairId: number }) {
<div className="w-[200px]">
<div className="text-sm font-medium mb-1">Last Indexed</div>
<div className="text-sm text-text-default">
{timeAgo(ccPair?.last_indexed) ?? "-"}
{timeAgo(
indexAttempts?.find((attempt) => attempt.status === "success")
?.time_started
) ?? "-"}
</div>
</div>

View File

@@ -45,7 +45,7 @@ function MemoryTagWithTooltip({
<MemoriesModal
initialTargetMemoryId={memoryId}
initialTargetIndex={memoryIndex}
highlightOnOpen
highlightFirstOnOpen
/>
</memoriesModal.Provider>
{memoriesModal.isOpen ? (
@@ -56,14 +56,8 @@ function MemoryTagWithTooltip({
side="bottom"
className="bg-background-neutral-00 text-text-01 shadow-md max-w-[17.5rem] p-1"
tooltip={
<Section
flexDirection="column"
alignItems="start"
padding={0.25}
gap={0.25}
height="auto"
>
<div className="p-1">
<Section flexDirection="column" gap={0.25} height="auto">
<div className="p-1 w-full">
<Text as="p" secondaryBody text03>
{memoryText}
</Text>
@@ -72,7 +66,6 @@ function MemoryTagWithTooltip({
icon={SvgAddLines}
title={operationLabel}
sizePreset="secondary"
paddingVariant="sm"
variant="body"
prominence="muted"
rightChildren={

View File

@@ -103,7 +103,7 @@ export const MemoryToolRenderer: MessageRenderer<MemoryToolPacket, {}> = ({
<MemoriesModal
initialTargetMemoryId={memoryId}
initialTargetIndex={index}
highlightOnOpen
highlightFirstOnOpen
/>
</memoriesModal.Provider>
{memoryText ? (

View File

@@ -7,8 +7,11 @@ import { processRawChatHistory } from "@/app/app/services/lib";
import { getLatestMessageChain } from "@/app/app/services/messageTree";
import HumanMessage from "@/app/app/message/HumanMessage";
import AgentMessage from "@/app/app/message/messageComponents/AgentMessage";
import { Callout } from "@/components/ui/callout";
import OnyxInitializingLoader from "@/components/OnyxInitializingLoader";
import { Section } from "@/layouts/general-layouts";
import { IllustrationContent } from "@opal/layouts";
import SvgNotFound from "@opal/illustrations/not-found";
import { Button } from "@opal/components";
import { Persona } from "@/app/admin/agents/interfaces";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import PreviewModal from "@/sections/modals/PreviewModal";
@@ -33,12 +36,17 @@ export default function SharedChatDisplay({
if (!chatSession) {
return (
<div className="min-h-full w-full">
<div className="mx-auto w-fit pt-8">
<Callout type="danger" title="Shared Chat Not Found">
Did not find a shared chat with the specified ID.
</Callout>
</div>
<div className="h-full w-full flex flex-col items-center justify-center">
<Section flexDirection="column" alignItems="center" gap={1}>
<IllustrationContent
illustration={SvgNotFound}
title="Shared chat not found"
description="Did not find a shared chat with the specified ID."
/>
<Button href="/app" prominence="secondary">
Start a new chat
</Button>
</Section>
</div>
);
}
@@ -51,12 +59,17 @@ export default function SharedChatDisplay({
if (firstMessage === undefined) {
return (
<div className="min-h-full w-full">
<div className="mx-auto w-fit pt-8">
<Callout type="danger" title="Shared Chat Not Found">
No messages found in shared chat.
</Callout>
</div>
<div className="h-full w-full flex flex-col items-center justify-center">
<Section flexDirection="column" alignItems="center" gap={1}>
<IllustrationContent
illustration={SvgNotFound}
title="Shared chat not found"
description="No messages found in shared chat."
/>
<Button href="/app" prominence="secondary">
Start a new chat
</Button>
</Section>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { AuthTypeMetadata } from "@/hooks/useAuthTypeMetadata";
import { AuthTypeMetadata } from "@/lib/userSS";
import LoginText from "@/app/auth/login/LoginText";
import SignInButton from "@/app/auth/login/SignInButton";
import EmailPasswordForm from "./EmailPasswordForm";

View File

@@ -26,7 +26,6 @@ import { SvgEdit } from "@opal/icons";
interface AppearanceThemeSettingsProps {
selectedLogo: File | null;
setSelectedLogo: (file: File | null) => void;
logoVersion: number;
charLimits: {
application_name: number;
custom_greeting_message: number;
@@ -46,7 +45,7 @@ export const AppearanceThemeSettings = forwardRef<
AppearanceThemeSettingsRef,
AppearanceThemeSettingsProps
>(function AppearanceThemeSettings(
{ selectedLogo, setSelectedLogo, logoVersion, charLimits },
{ selectedLogo, setSelectedLogo, charLimits },
ref
) {
const { values, errors, setFieldValue } = useFormikContext<any>();
@@ -175,15 +174,15 @@ export const AppearanceThemeSettings = forwardRef<
};
}, [logoObjectUrl]);
const logoSrc = useMemo(() => {
const getLogoSrc = () => {
if (logoObjectUrl) {
return logoObjectUrl;
}
if (values.use_custom_logo) {
return `/api/enterprise-settings/logo?v=${logoVersion}`;
return `/api/enterprise-settings/logo?u=${Date.now()}`;
}
return undefined;
}, [logoObjectUrl, values.use_custom_logo, logoVersion]);
};
// Determine which tabs should be enabled
const hasLogo = Boolean(selectedLogo || values.use_custom_logo);
@@ -303,7 +302,7 @@ export const AppearanceThemeSettings = forwardRef<
<FormField.Label>Application Logo</FormField.Label>
<FormField.Control>
<InputImage
src={logoSrc}
src={getLogoSrc()}
onEdit={handleLogoEdit}
onDrop={(file) => {
setSelectedLogo(file);
@@ -342,7 +341,7 @@ export const AppearanceThemeSettings = forwardRef<
greeting_message={
values.custom_greeting_message || "Welcome to Acme Chat"
}
logoSrc={logoSrc}
logoSrc={getLogoSrc()}
highlightTarget={highlightTarget}
/>

View File

@@ -14,7 +14,7 @@ import { toast } from "@/hooks/useToast";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import { EnterpriseSettings } from "@/interfaces/settings";
import { mutate } from "swr";
import { useRouter } from "next/navigation";
const route = ADMIN_ROUTES.THEME;
@@ -29,9 +29,9 @@ const CHAR_LIMITS = {
};
export default function ThemePage() {
const router = useRouter();
const settings = useContext(SettingsContext);
const [selectedLogo, setSelectedLogo] = useState<File | null>(null);
const [logoVersion, setLogoVersion] = useState(0);
const appearanceSettingsRef = useRef<AppearanceThemeSettingsRef>(null);
if (!settings) {
@@ -54,7 +54,7 @@ export default function ThemePage() {
}),
});
if (response.ok) {
await mutate("/api/enterprise-settings");
router.refresh();
return true;
} else {
const errorMsg = (await response.json()).detail;
@@ -150,8 +150,6 @@ export default function ThemePage() {
validationSchema={validationSchema}
validateOnChange={false}
onSubmit={async (values, formikHelpers) => {
let logoUploaded = false;
// Handle logo upload if a new logo was selected
if (selectedLogo) {
const formData = new FormData();
@@ -168,7 +166,6 @@ export default function ThemePage() {
}
// Only clear the selected logo after a successful upload
setSelectedLogo(null);
logoUploaded = true;
values.use_custom_logo = true;
}
@@ -196,9 +193,6 @@ export default function ThemePage() {
// dirty comparisons reflect the newly-saved values.
if (success) {
formikHelpers.resetForm({ values });
if (logoUploaded) {
setLogoVersion((v) => v + 1);
}
toast.success("Appearance settings saved successfully!");
}
@@ -251,7 +245,6 @@ export default function ThemePage() {
ref={appearanceSettingsRef}
selectedLogo={selectedLogo}
setSelectedLogo={setSelectedLogo}
logoVersion={logoVersion}
charLimits={CHAR_LIMITS}
/>
</SettingsLayouts.Body>

View File

@@ -1,23 +1,36 @@
import "./globals.css";
import { GTM_ENABLED, MODAL_ROOT_ID } from "@/lib/constants";
import {
fetchEnterpriseSettingsSS,
fetchSettingsSS,
} from "@/components/settings/lib";
import {
CUSTOM_ANALYTICS_ENABLED,
GTM_ENABLED,
SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED,
NEXT_PUBLIC_CLOUD_ENABLED,
MODAL_ROOT_ID,
} from "@/lib/constants";
import { Metadata } from "next";
import { Inter } from "next/font/google";
import { EnterpriseSettings, ApplicationStatus } from "@/interfaces/settings";
import AppProvider from "@/providers/AppProvider";
import DynamicMetadata from "@/providers/DynamicMetadata";
import { PHProvider } from "./providers";
import { getAuthTypeMetadataSS, getCurrentUserSS } from "@/lib/userSS";
import { Suspense } from "react";
import PostHogPageView from "./PostHogPageView";
import Script from "next/script";
import { Hanken_Grotesk } from "next/font/google";
import { WebVitals } from "./web-vitals";
import { ThemeProvider } from "next-themes";
import CloudError from "@/components/errorPages/CloudErrorPage";
import Error from "@/components/errorPages/ErrorPage";
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";
import CustomAnalyticsScript from "@/providers/CustomAnalyticsScript";
import ProductGatingWrapper from "@/providers/ProductGatingWrapper";
const inter = Inter({
subsets: ["latin"],
@@ -31,23 +44,45 @@ const hankenGrotesk = Hanken_Grotesk({
display: "swap",
});
export const metadata: Metadata = {
title: "Onyx",
description: "Question answering for your documents",
};
export async function generateMetadata(): Promise<Metadata> {
let logoLocation = "/onyx.ico";
let enterpriseSettings: EnterpriseSettings | null = null;
if (SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) {
enterpriseSettings = await (await fetchEnterpriseSettingsSS()).json();
logoLocation =
enterpriseSettings && enterpriseSettings.use_custom_logo
? "/api/enterprise-settings/logo"
: "/onyx.ico";
}
return {
title: enterpriseSettings?.application_name || "Onyx",
description: "Question answering for your documents",
icons: {
icon: logoLocation,
},
};
}
// force-dynamic prevents Next.js from statically prerendering pages at build
// time — many child routes use cookies() which requires dynamic rendering.
// This is safe because the layout itself has no server-side data fetching;
// all data is fetched client-side via SWR in the provider tree.
export const dynamic = "force-dynamic";
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
const [combinedSettings, user, authTypeMetadata] = await Promise.all([
fetchSettingsSS(),
getCurrentUserSS(),
getAuthTypeMetadataSS(),
]);
const { folded } = await fetchAppSidebarMetadata(user);
const productGating =
combinedSettings?.settings.application_status ?? ApplicationStatus.ACTIVE;
const getPageContent = async (content: React.ReactNode) => (
<html
lang="en"
className={`${inter.variable} ${hankenGrotesk.variable}`}
@@ -58,6 +93,15 @@ export default function RootLayout({
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, interactive-widget=resizes-content"
/>
{CUSTOM_ANALYTICS_ENABLED &&
combinedSettings?.customAnalyticsScript && (
<script
type="text/javascript"
dangerouslySetInnerHTML={{
__html: combinedSettings.customAnalyticsScript,
}}
/>
)}
{GTM_ENABLED && (
<Script
@@ -87,20 +131,7 @@ export default function RootLayout({
<TooltipProvider>
<PHProvider>
<AppHealthBanner />
<AppProvider>
<DynamicMetadata />
<CustomAnalyticsScript />
<Suspense fallback={null}>
<PostHogPageView />
</Suspense>
<div id={MODAL_ROOT_ID} className="h-screen w-screen">
<ProductGatingWrapper>{children}</ProductGatingWrapper>
</div>
{process.env.NEXT_PUBLIC_POSTHOG_KEY && <WebVitals />}
{process.env.NEXT_PUBLIC_ENABLE_STATS === "true" && (
<StatsOverlayLoader />
)}
</AppProvider>
{content}
</PHProvider>
</TooltipProvider>
</div>
@@ -108,4 +139,45 @@ export default function RootLayout({
</body>
</html>
);
if (!combinedSettings) {
return getPageContent(
NEXT_PUBLIC_CLOUD_ENABLED ? <CloudError /> : <Error />
);
}
// When gated, wrap children in GatedContentWrapper which checks the path
// client-side and shows AccessRestrictedPage for non-billing paths.
//
// Trade-off: Server components still render and attempt API calls before the
// client-side check runs. This is safe because the backend license enforcement
// middleware returns 402 for all non-allowlisted API calls, preventing data
// leakage. The user sees a brief loading state before being redirected.
const content =
productGating === ApplicationStatus.GATED_ACCESS ||
productGating === ApplicationStatus.SEAT_LIMIT_EXCEEDED ? (
<GatedContentWrapper>{children}</GatedContentWrapper>
) : (
children
);
return getPageContent(
<AppProvider
authTypeMetadata={authTypeMetadata}
user={user}
settings={combinedSettings}
folded={folded}
>
<Suspense fallback={null}>
<PostHogPageView />
</Suspense>
<div id={MODAL_ROOT_ID} className="h-screen w-screen">
{content}
</div>
{process.env.NEXT_PUBLIC_POSTHOG_KEY && <WebVitals />}
{process.env.NEXT_PUBLIC_ENABLE_STATS === "true" && (
<StatsOverlayLoader />
)}
</AppProvider>
);
}

View File

@@ -3,9 +3,8 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import NewTeamModal from "@/components/modals/NewTeamModal";
import NewTenantModal from "@/sections/modals/NewTenantModal";
import { NewTenantInfo } from "@/lib/types";
import { User, NewTenantInfo } from "@/lib/types";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import { useUser } from "@/providers/UserProvider";
type ModalContextType = {
showNewTeamModal: boolean;
@@ -28,27 +27,23 @@ export const useModalContext = () => {
export const ModalProvider: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const { user } = useUser();
user: User | null;
}> = ({ children, user }) => {
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
const [newTenantInfo, setNewTenantInfo] = useState<NewTenantInfo | null>(
null
user?.tenant_info?.new_tenant || null
);
const [invitationInfo, setInvitationInfo] = useState<NewTenantInfo | null>(
null
user?.tenant_info?.invitation || null
);
// Sync modal states with user info — clear when backend no longer has the data
// Initialize modal states based on user info
useEffect(() => {
if (user?.tenant_info?.new_tenant) {
setNewTenantInfo(user.tenant_info.new_tenant);
} else {
setNewTenantInfo(null);
}
if (user?.tenant_info?.invitation) {
setInvitationInfo(user.tenant_info.invitation);
} else {
setInvitationInfo(null);
}
}, [user?.tenant_info]);

View File

@@ -118,16 +118,17 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
settings.deep_research_enabled = true;
}
const webVersion = getWebVersion();
const combinedSettings: CombinedSettings = {
settings,
enterpriseSettings,
customAnalyticsScript,
webVersion: settings.version ?? getWebVersion(),
webVersion,
webDomain: HOST_URL,
// Server-side default; the real value is computed client-side in
// SettingsProvider where connector data is available via useCCPairs.
isSearchModeAvailable: settings.search_ui_enabled !== false,
settingsLoading: false,
};
return combinedSettings;

View File

@@ -1,53 +0,0 @@
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { AuthType, NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
export interface AuthTypeMetadata {
authType: AuthType;
autoRedirect: boolean;
requiresVerification: boolean;
anonymousUserEnabled: boolean | null;
passwordMinLength: number;
hasUsers: boolean;
oauthEnabled: boolean;
}
const DEFAULT_AUTH_TYPE_METADATA: AuthTypeMetadata = {
authType: NEXT_PUBLIC_CLOUD_ENABLED ? AuthType.CLOUD : AuthType.BASIC,
autoRedirect: false,
requiresVerification: false,
anonymousUserEnabled: null,
passwordMinLength: 0,
hasUsers: false,
oauthEnabled: false,
};
export function useAuthTypeMetadata(): {
authTypeMetadata: AuthTypeMetadata;
isLoading: boolean;
error: Error | undefined;
} {
const { data, error, isLoading } = useSWR<AuthTypeMetadata>(
"/api/auth/type",
errorHandlingFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30_000,
}
);
if (NEXT_PUBLIC_CLOUD_ENABLED && data) {
return {
authTypeMetadata: { ...data, authType: AuthType.CLOUD },
isLoading,
error,
};
}
return {
authTypeMetadata: data ?? DEFAULT_AUTH_TYPE_METADATA,
isLoading,
error,
};
}

View File

@@ -61,6 +61,11 @@ interface UseChatSessionControllerProps {
}) => Promise<void>;
}
export type SessionFetchError = {
type: "not_found" | "access_denied" | "unknown";
detail: string;
} | null;
export default function useChatSessionController({
existingChatSessionId,
searchParams,
@@ -80,6 +85,8 @@ export default function useChatSessionController({
const [currentSessionFileTokenCount, setCurrentSessionFileTokenCount] =
useState<number>(0);
const [projectFiles, setProjectFiles] = useState<ProjectFile[]>([]);
const [sessionFetchError, setSessionFetchError] =
useState<SessionFetchError>(null);
// Store actions
const updateSessionAndMessageTree = useChatSessionStore(
(state) => state.updateSessionAndMessageTree
@@ -151,6 +158,8 @@ export default function useChatSessionController({
}
async function initialSessionFetch() {
setSessionFetchError(null);
if (existingChatSessionId === null) {
// Clear the current session in the store to show intro messages
setCurrentSession(null);
@@ -178,9 +187,42 @@ export default function useChatSessionController({
setCurrentSession(existingChatSessionId);
setIsFetchingChatMessages(existingChatSessionId, true);
const response = await fetch(
`/api/chat/get-chat-session/${existingChatSessionId}`
);
let response: Response;
try {
response = await fetch(
`/api/chat/get-chat-session/${existingChatSessionId}`
);
} catch (error) {
setIsFetchingChatMessages(existingChatSessionId, false);
console.error("Failed to fetch chat session", {
chatSessionId: existingChatSessionId,
error,
});
setSessionFetchError({
type: "unknown",
detail: "Failed to load chat session. Please check your connection.",
});
return;
}
if (!response.ok) {
setIsFetchingChatMessages(existingChatSessionId, false);
let detail = "An unexpected error occurred.";
try {
const errorBody = await response.json();
detail = errorBody.detail || detail;
} catch {
// ignore parse errors
}
const type =
response.status === 404
? "not_found"
: response.status === 403
? "access_denied"
: "unknown";
setSessionFetchError({ type, detail });
return;
}
const session = await response.json();
const chatSession = session as BackendChatSession;
@@ -356,5 +398,6 @@ export default function useChatSessionController({
currentSessionFileTokenCount,
onMessageSelection,
projectFiles,
sessionFetchError,
};
}

View File

@@ -1,6 +1,6 @@
"use client";
import useSWR, { mutate } from "swr";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { UserGroup } from "@/lib/types";
import { useContext } from "react";
@@ -37,35 +37,21 @@ import { SettingsContext } from "@/providers/SettingsProvider";
*/
export default function useGroups() {
const combinedSettings = useContext(SettingsContext);
const settingsLoading = combinedSettings?.settingsLoading ?? false;
const isPaidEnterpriseFeaturesEnabled =
!settingsLoading &&
combinedSettings &&
combinedSettings.enterpriseSettings !== null;
combinedSettings && combinedSettings.enterpriseSettings !== null;
const GROUPS_URL = "/api/manage/admin/user-group";
const { data, error, isLoading } = useSWR<UserGroup[]>(
isPaidEnterpriseFeaturesEnabled ? GROUPS_URL : null,
const { data, error, mutate, isLoading } = useSWR<UserGroup[]>(
isPaidEnterpriseFeaturesEnabled ? "/api/manage/admin/user-group" : null,
errorHandlingFetcher
);
const refreshGroups = () => mutate(GROUPS_URL);
if (settingsLoading) {
return {
data: undefined,
isLoading: true,
error: undefined,
refreshGroups,
};
}
// If enterprise features are not enabled, return empty array
if (!isPaidEnterpriseFeaturesEnabled) {
return {
data: [],
isLoading: false,
error: undefined,
refreshGroups,
refreshGroups: () => {},
};
}
@@ -73,6 +59,6 @@ export default function useGroups() {
data,
isLoading,
error,
refreshGroups,
refreshGroups: mutate,
};
}

View File

@@ -1,238 +0,0 @@
import useSWR from "swr";
import {
useSettings,
useEnterpriseSettings,
useCustomAnalyticsScript,
} from "@/hooks/useSettings";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { ApplicationStatus, QueryHistoryType } from "@/interfaces/settings";
jest.mock("swr", () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock("@/lib/fetcher", () => ({
errorHandlingFetcher: jest.fn(),
}));
jest.mock("@/lib/constants", () => ({
EE_ENABLED: false,
}));
const mockUseSWR = useSWR as jest.MockedFunction<typeof useSWR>;
describe("useSettings", () => {
beforeEach(() => {
mockUseSWR.mockReset();
});
test("returns DEFAULT_SETTINGS when SWR data is undefined", () => {
mockUseSWR.mockReturnValue({
data: undefined,
error: undefined,
isLoading: true,
mutate: jest.fn(),
isValidating: false,
} as any);
const result = useSettings();
expect(result.settings).toEqual({
auto_scroll: true,
application_status: ApplicationStatus.ACTIVE,
gpu_enabled: false,
maximum_chat_retention_days: null,
notifications: [],
needs_reindexing: false,
anonymous_user_enabled: false,
invite_only_enabled: false,
deep_research_enabled: true,
temperature_override_enabled: true,
query_history_type: QueryHistoryType.NORMAL,
});
expect(result.isLoading).toBe(true);
});
test("returns fetched settings when SWR has data", () => {
const mockSettings = {
auto_scroll: false,
application_status: ApplicationStatus.ACTIVE,
gpu_enabled: true,
maximum_chat_retention_days: 30,
notifications: [],
needs_reindexing: false,
anonymous_user_enabled: false,
invite_only_enabled: false,
deep_research_enabled: true,
temperature_override_enabled: true,
query_history_type: QueryHistoryType.NORMAL,
};
mockUseSWR.mockReturnValue({
data: mockSettings,
error: undefined,
isLoading: false,
mutate: jest.fn(),
isValidating: false,
} as any);
const result = useSettings();
expect(result.settings).toBe(mockSettings);
expect(result.isLoading).toBe(false);
expect(result.error).toBeUndefined();
});
test("fetches from /api/settings with correct SWR config", () => {
mockUseSWR.mockReturnValue({
data: undefined,
error: undefined,
isLoading: true,
mutate: jest.fn(),
isValidating: false,
} as any);
useSettings();
expect(mockUseSWR).toHaveBeenCalledWith(
"/api/settings",
errorHandlingFetcher,
expect.objectContaining({
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30_000,
errorRetryInterval: 5_000,
})
);
});
});
describe("useEnterpriseSettings", () => {
beforeEach(() => {
mockUseSWR.mockReset();
});
test("passes null key when EE is disabled at both build and runtime", () => {
mockUseSWR.mockReturnValue({
data: undefined,
error: undefined,
isLoading: false,
mutate: jest.fn(),
isValidating: false,
} as any);
const result = useEnterpriseSettings(false);
expect(mockUseSWR).toHaveBeenCalledWith(
null,
errorHandlingFetcher,
expect.any(Object)
);
expect(result.enterpriseSettings).toBeNull();
expect(result.isLoading).toBe(false);
});
test("fetches from /api/enterprise-settings when runtime EE is enabled", () => {
mockUseSWR.mockReturnValue({
data: undefined,
error: undefined,
isLoading: true,
mutate: jest.fn(),
isValidating: false,
} as any);
useEnterpriseSettings(true);
expect(mockUseSWR).toHaveBeenCalledWith(
"/api/enterprise-settings",
errorHandlingFetcher,
expect.any(Object)
);
});
test("uses referential equality for compare to ensure logo cache-busters update", () => {
mockUseSWR.mockReturnValue({
data: undefined,
error: undefined,
isLoading: true,
mutate: jest.fn(),
isValidating: false,
} as any);
useEnterpriseSettings(true);
const swrConfig = mockUseSWR.mock.calls[0]![2] as any;
expect(swrConfig.compare).toBeDefined();
// Same reference should be equal
const obj = { use_custom_logo: true };
expect(swrConfig.compare(obj, obj)).toBe(true);
// Different references with same values should NOT be equal
// (this is the key behavior — SWR's default deep compare would return true)
const a = { use_custom_logo: true };
const b = { use_custom_logo: true };
expect(swrConfig.compare(a, b)).toBe(false);
});
test("returns enterprise settings when SWR has data", () => {
const mockEnterprise = {
application_name: "Acme Corp",
use_custom_logo: true,
};
mockUseSWR.mockReturnValue({
data: mockEnterprise,
error: undefined,
isLoading: false,
mutate: jest.fn(),
isValidating: false,
} as any);
const result = useEnterpriseSettings(true);
expect(result.enterpriseSettings).toBe(mockEnterprise);
expect(result.isLoading).toBe(false);
});
});
describe("useCustomAnalyticsScript", () => {
beforeEach(() => {
mockUseSWR.mockReset();
});
test("returns null when EE is disabled", () => {
mockUseSWR.mockReturnValue({
data: undefined,
error: undefined,
isLoading: false,
mutate: jest.fn(),
isValidating: false,
} as any);
const result = useCustomAnalyticsScript(false);
expect(mockUseSWR).toHaveBeenCalledWith(
null,
errorHandlingFetcher,
expect.any(Object)
);
expect(result).toBeNull();
});
test("returns script content when available", () => {
const script = "console.log('analytics');";
mockUseSWR.mockReturnValue({
data: script,
error: undefined,
isLoading: false,
mutate: jest.fn(),
isValidating: false,
} as any);
const result = useCustomAnalyticsScript(true);
expect(result).toBe(script);
});
});

View File

@@ -1,102 +0,0 @@
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import {
Settings,
EnterpriseSettings,
ApplicationStatus,
QueryHistoryType,
} from "@/interfaces/settings";
import { EE_ENABLED } from "@/lib/constants";
// Longer retry delay for critical settings fetches — avoids rapid error→success
// flicker in the SettingsProvider error boundary when there's a transient blip.
const SETTINGS_ERROR_RETRY_INTERVAL = 5_000;
const DEFAULT_SETTINGS = {
auto_scroll: true,
application_status: ApplicationStatus.ACTIVE,
gpu_enabled: false,
maximum_chat_retention_days: null,
notifications: [],
needs_reindexing: false,
anonymous_user_enabled: false,
invite_only_enabled: false,
deep_research_enabled: true,
temperature_override_enabled: true,
query_history_type: QueryHistoryType.NORMAL,
} satisfies Settings;
export function useSettings(): {
settings: Settings;
isLoading: boolean;
error: Error | undefined;
} {
const { data, error, isLoading } = useSWR<Settings>(
"/api/settings",
errorHandlingFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30_000,
errorRetryInterval: SETTINGS_ERROR_RETRY_INTERVAL,
}
);
return {
settings: data ?? DEFAULT_SETTINGS,
isLoading,
error,
};
}
export function useEnterpriseSettings(eeEnabledRuntime: boolean): {
enterpriseSettings: EnterpriseSettings | null;
isLoading: boolean;
error: Error | undefined;
} {
// Gate on the build-time flag OR the runtime ee_features_enabled from
// /api/settings. The build-time flag (NEXT_PUBLIC_ENABLE_PAID_EE_FEATURES)
// may be unset even when the server enables EE via LICENSE_ENFORCEMENT_ENABLED,
// so the runtime check is needed as a fallback.
const shouldFetch = EE_ENABLED || eeEnabledRuntime;
const { data, error, isLoading } = useSWR<EnterpriseSettings>(
shouldFetch ? "/api/enterprise-settings" : null,
errorHandlingFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30_000,
errorRetryInterval: SETTINGS_ERROR_RETRY_INTERVAL,
// Referential equality instead of SWR's default deep comparison.
// The logo image can change without the settings JSON changing
// (same use_custom_logo: true), so we need every mutate() call
// to propagate a new reference so cache-busters recalculate.
compare: (a, b) => a === b,
}
);
return {
enterpriseSettings: data ?? null,
isLoading: shouldFetch ? isLoading : false,
error,
};
}
export function useCustomAnalyticsScript(
eeEnabledRuntime: boolean
): string | null {
const shouldFetch = EE_ENABLED || eeEnabledRuntime;
const { data } = useSWR<string>(
shouldFetch ? "/api/enterprise-settings/custom-analytics-script" : null,
errorHandlingFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 60_000,
}
);
return data ?? null;
}

View File

@@ -1,6 +1,6 @@
"use client";
import useSWR, { mutate } from "swr";
import useSWR from "swr";
import { useContext } from "react";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { SettingsContext } from "@/providers/SettingsProvider";
@@ -15,35 +15,20 @@ export interface MinimalUserGroupSnapshot {
export default function useShareableGroups() {
const combinedSettings = useContext(SettingsContext);
const settingsLoading = combinedSettings?.settingsLoading ?? false;
const isPaidEnterpriseFeaturesEnabled =
!settingsLoading &&
combinedSettings &&
combinedSettings.enterpriseSettings !== null;
combinedSettings && combinedSettings.enterpriseSettings !== null;
const SHAREABLE_GROUPS_URL = "/api/manage/user-groups/minimal";
const { data, error, isLoading } = useSWR<MinimalUserGroupSnapshot[]>(
isPaidEnterpriseFeaturesEnabled ? SHAREABLE_GROUPS_URL : null,
const { data, error, mutate, isLoading } = useSWR<MinimalUserGroupSnapshot[]>(
isPaidEnterpriseFeaturesEnabled ? "/api/manage/user-groups/minimal" : null,
errorHandlingFetcher
);
const refreshShareableGroups = () => mutate(SHAREABLE_GROUPS_URL);
if (settingsLoading) {
return {
data: undefined,
isLoading: true,
error: undefined,
refreshShareableGroups,
};
}
if (!isPaidEnterpriseFeaturesEnabled) {
return {
data: [],
isLoading: false,
error: undefined,
refreshShareableGroups,
refreshShareableGroups: () => {},
};
}
@@ -51,6 +36,6 @@ export default function useShareableGroups() {
data,
isLoading,
error,
refreshShareableGroups,
refreshShareableGroups: mutate,
};
}

Some files were not shown because too many files have changed in this diff Show More