mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-06 08:05:49 +00:00
Compare commits
7 Commits
chore/upda
...
nikg/std-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cf2c8bbf8 | ||
|
|
7eabfa125c | ||
|
|
ee18114739 | ||
|
|
f7630f5648 | ||
|
|
e0d91b9ea7 | ||
|
|
2c0a4a60a5 | ||
|
|
3a7d4dad56 |
@@ -4,7 +4,6 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from httpx_oauth.clients.google import GoogleOAuth2
|
||||
|
||||
from ee.onyx.configs.app_configs import LICENSE_ENFORCEMENT_ENABLED
|
||||
from ee.onyx.server.analytics.api import router as analytics_router
|
||||
from ee.onyx.server.auth_check import check_ee_router_auth
|
||||
from ee.onyx.server.billing.api import router as billing_router
|
||||
@@ -153,12 +152,9 @@ def get_application() -> FastAPI:
|
||||
# License management
|
||||
include_router_with_global_prefix_prepended(application, license_router)
|
||||
|
||||
# Unified billing API - available when license system is enabled
|
||||
# Works for both self-hosted and cloud deployments
|
||||
# TODO(ENG-3533): Once frontend migrates to /admin/billing/*, this becomes the
|
||||
# primary billing API and /tenants/* billing endpoints can be removed
|
||||
if LICENSE_ENFORCEMENT_ENABLED:
|
||||
include_router_with_global_prefix_prepended(application, billing_router)
|
||||
# Unified billing API - always registered in EE.
|
||||
# Each endpoint is protected by the `current_admin_user` dependency (admin auth).
|
||||
include_router_with_global_prefix_prepended(application, billing_router)
|
||||
|
||||
if MULTI_TENANT:
|
||||
# Tenant management
|
||||
|
||||
@@ -3,7 +3,6 @@ from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
@@ -53,6 +52,8 @@ from onyx.db.permission_sync_attempt import (
|
||||
from onyx.db.permission_sync_attempt import (
|
||||
get_recent_doc_permission_sync_attempts_for_cc_pair,
|
||||
)
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.redis.redis_connector import RedisConnector
|
||||
from onyx.redis.redis_connector_utils import get_deletion_attempt_snapshot
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
@@ -87,8 +88,9 @@ def get_cc_pair_index_attempts(
|
||||
cc_pair_id, db_session, user, get_editable=False
|
||||
)
|
||||
if not user_has_access:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="CC Pair not found for current user permissions"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"CC Pair not found for current user permissions",
|
||||
)
|
||||
|
||||
total_count = count_index_attempts_for_cc_pair(
|
||||
@@ -123,8 +125,9 @@ def get_cc_pair_permission_sync_attempts(
|
||||
cc_pair_id, db_session, user, get_editable=False
|
||||
)
|
||||
if not user_has_access:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="CC Pair not found for current user permissions"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"CC Pair not found for current user permissions",
|
||||
)
|
||||
|
||||
# Get all permission sync attempts for this cc pair
|
||||
@@ -160,8 +163,9 @@ def get_cc_pair_full_info(
|
||||
cc_pair_id, db_session, user, get_editable=False
|
||||
)
|
||||
if not cc_pair:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="CC Pair not found for current user permissions"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"CC Pair not found for current user permissions",
|
||||
)
|
||||
editable_cc_pair = get_connector_credential_pair_from_id_for_user(
|
||||
cc_pair_id, db_session, user, get_editable=True
|
||||
@@ -264,9 +268,9 @@ def update_cc_pair_status(
|
||||
)
|
||||
|
||||
if not cc_pair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Connection not found for current user's permissions",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"Connection not found for current user's permissions",
|
||||
)
|
||||
|
||||
redis_connector = RedisConnector(tenant_id, cc_pair_id)
|
||||
@@ -339,8 +343,9 @@ def update_cc_pair_name(
|
||||
get_editable=True,
|
||||
)
|
||||
if not cc_pair:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="CC Pair not found for current user's permissions"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"CC Pair not found for current user's permissions",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -351,7 +356,7 @@ def update_cc_pair_name(
|
||||
)
|
||||
except IntegrityError:
|
||||
db_session.rollback()
|
||||
raise HTTPException(status_code=400, detail="Name must be unique")
|
||||
raise OnyxError(OnyxErrorCode.CONFLICT, "Name must be unique")
|
||||
|
||||
|
||||
@router.put("/admin/cc-pair/{cc_pair_id}/property")
|
||||
@@ -368,8 +373,9 @@ def update_cc_pair_property(
|
||||
get_editable=True,
|
||||
)
|
||||
if not cc_pair:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="CC Pair not found for current user's permissions"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"CC Pair not found for current user's permissions",
|
||||
)
|
||||
|
||||
# Can we centralize logic for updating connector properties
|
||||
@@ -387,8 +393,9 @@ def update_cc_pair_property(
|
||||
|
||||
msg = "Pruning frequency updated successfully"
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Property name {update_request.name} is not valid."
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
f"Property name {update_request.name} is not valid.",
|
||||
)
|
||||
|
||||
return StatusResponse(success=True, message=msg, data=cc_pair_id)
|
||||
@@ -407,9 +414,9 @@ def get_cc_pair_last_pruned(
|
||||
get_editable=False,
|
||||
)
|
||||
if not cc_pair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="cc_pair not found for current user's permissions",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"cc_pair not found for current user's permissions",
|
||||
)
|
||||
|
||||
return cc_pair.last_pruned
|
||||
@@ -431,19 +438,16 @@ def prune_cc_pair(
|
||||
get_editable=False,
|
||||
)
|
||||
if not cc_pair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Connection not found for current user's permissions",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"Connection not found for current user's permissions",
|
||||
)
|
||||
|
||||
r = get_redis_client()
|
||||
|
||||
redis_connector = RedisConnector(tenant_id, cc_pair_id)
|
||||
if redis_connector.prune.fenced:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.CONFLICT,
|
||||
detail="Pruning task already in progress.",
|
||||
)
|
||||
raise OnyxError(OnyxErrorCode.CONFLICT, "Pruning task already in progress.")
|
||||
|
||||
logger.info(
|
||||
f"Pruning cc_pair: cc_pair={cc_pair_id} "
|
||||
@@ -455,10 +459,7 @@ def prune_cc_pair(
|
||||
client_app, cc_pair, db_session, r, tenant_id
|
||||
)
|
||||
if not payload_id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Pruning task creation failed.",
|
||||
)
|
||||
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, "Pruning task creation failed.")
|
||||
|
||||
logger.info(f"Pruning queued: cc_pair={cc_pair.id} id={payload_id}")
|
||||
|
||||
@@ -588,20 +589,21 @@ def associate_credential_to_connector(
|
||||
delete_connector(db_session, connector_id)
|
||||
db_session.commit()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Connector validation error: " + str(e)
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CONNECTOR_VALIDATION_FAILED,
|
||||
"Connector validation error: " + str(e),
|
||||
)
|
||||
except IntegrityError as e:
|
||||
logger.error(f"IntegrityError: {e}")
|
||||
delete_connector(db_session, connector_id)
|
||||
db_session.commit()
|
||||
|
||||
raise HTTPException(status_code=400, detail="Name must be unique")
|
||||
raise OnyxError(OnyxErrorCode.CONFLICT, "Name must be unique")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error: {e}")
|
||||
|
||||
raise HTTPException(status_code=500, detail="Unexpected error")
|
||||
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, "Unexpected error")
|
||||
|
||||
|
||||
@router.delete(
|
||||
|
||||
@@ -11,7 +11,6 @@ from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import File
|
||||
from fastapi import Form
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from fastapi import Request
|
||||
from fastapi import Response
|
||||
@@ -115,6 +114,8 @@ from onyx.db.models import IndexAttempt
|
||||
from onyx.db.models import IndexingStatus
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import UserRole
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.file_processing.file_types import PLAIN_TEXT_MIME_TYPE
|
||||
from onyx.file_processing.file_types import WORD_PROCESSING_MIME_TYPE
|
||||
from onyx.file_store.file_store import FileStore
|
||||
@@ -180,7 +181,7 @@ def check_google_app_gmail_credentials_exist(
|
||||
try:
|
||||
return {"client_id": get_google_app_cred(DocumentSource.GMAIL).web.client_id}
|
||||
except KvKeyNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Google App Credentials not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Google App Credentials not found")
|
||||
|
||||
|
||||
@router.put("/admin/connector/gmail/app-credential")
|
||||
@@ -190,7 +191,7 @@ def upsert_google_app_gmail_credentials(
|
||||
try:
|
||||
upsert_google_app_cred(app_credentials, DocumentSource.GMAIL)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
return StatusResponse(
|
||||
success=True, message="Successfully saved Google App Credentials"
|
||||
@@ -206,7 +207,7 @@ def delete_google_app_gmail_credentials(
|
||||
delete_google_app_cred(DocumentSource.GMAIL)
|
||||
cleanup_gmail_credentials(db_session=db_session)
|
||||
except KvKeyNotFoundError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
return StatusResponse(
|
||||
success=True, message="Successfully deleted Google App Credentials"
|
||||
@@ -222,7 +223,7 @@ def check_google_app_credentials_exist(
|
||||
"client_id": get_google_app_cred(DocumentSource.GOOGLE_DRIVE).web.client_id
|
||||
}
|
||||
except KvKeyNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Google App Credentials not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Google App Credentials not found")
|
||||
|
||||
|
||||
@router.put("/admin/connector/google-drive/app-credential")
|
||||
@@ -232,7 +233,7 @@ def upsert_google_app_credentials(
|
||||
try:
|
||||
upsert_google_app_cred(app_credentials, DocumentSource.GOOGLE_DRIVE)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
return StatusResponse(
|
||||
success=True, message="Successfully saved Google App Credentials"
|
||||
@@ -248,7 +249,7 @@ def delete_google_app_credentials(
|
||||
delete_google_app_cred(DocumentSource.GOOGLE_DRIVE)
|
||||
cleanup_google_drive_credentials(db_session=db_session)
|
||||
except KvKeyNotFoundError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
return StatusResponse(
|
||||
success=True, message="Successfully deleted Google App Credentials"
|
||||
@@ -266,9 +267,7 @@ def check_google_service_gmail_account_key_exist(
|
||||
).client_email
|
||||
}
|
||||
except KvKeyNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Google Service Account Key not found"
|
||||
)
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Google Service Account Key not found")
|
||||
|
||||
|
||||
@router.put("/admin/connector/gmail/service-account-key")
|
||||
@@ -278,7 +277,7 @@ def upsert_google_service_gmail_account_key(
|
||||
try:
|
||||
upsert_service_account_key(service_account_key, DocumentSource.GMAIL)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
return StatusResponse(
|
||||
success=True, message="Successfully saved Google Service Account Key"
|
||||
@@ -294,7 +293,7 @@ def delete_google_service_gmail_account_key(
|
||||
delete_service_account_key(DocumentSource.GMAIL)
|
||||
cleanup_gmail_credentials(db_session=db_session)
|
||||
except KvKeyNotFoundError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
return StatusResponse(
|
||||
success=True, message="Successfully deleted Google Service Account Key"
|
||||
@@ -312,9 +311,7 @@ def check_google_service_account_key_exist(
|
||||
).client_email
|
||||
}
|
||||
except KvKeyNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Google Service Account Key not found"
|
||||
)
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Google Service Account Key not found")
|
||||
|
||||
|
||||
@router.put("/admin/connector/google-drive/service-account-key")
|
||||
@@ -324,7 +321,7 @@ def upsert_google_service_account_key(
|
||||
try:
|
||||
upsert_service_account_key(service_account_key, DocumentSource.GOOGLE_DRIVE)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
return StatusResponse(
|
||||
success=True, message="Successfully saved Google Service Account Key"
|
||||
@@ -340,7 +337,7 @@ def delete_google_service_account_key(
|
||||
delete_service_account_key(DocumentSource.GOOGLE_DRIVE)
|
||||
cleanup_google_drive_credentials(db_session=db_session)
|
||||
except KvKeyNotFoundError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
return StatusResponse(
|
||||
success=True, message="Successfully deleted Google Service Account Key"
|
||||
@@ -363,7 +360,7 @@ def upsert_service_account_credential(
|
||||
name="Service Account (uploaded)",
|
||||
)
|
||||
except KvKeyNotFoundError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
# first delete all existing service account credentials
|
||||
delete_service_account_credentials(user, db_session, DocumentSource.GOOGLE_DRIVE)
|
||||
@@ -389,7 +386,7 @@ def upsert_gmail_service_account_credential(
|
||||
primary_admin_email=service_account_credential_request.google_primary_admin,
|
||||
)
|
||||
except KvKeyNotFoundError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
# first delete all existing service account credentials
|
||||
delete_service_account_credentials(user, db_session, DocumentSource.GMAIL)
|
||||
@@ -440,9 +437,9 @@ def save_zip_metadata_to_file_store(
|
||||
json.loads(metadata_bytes)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Unable to load {ONYX_METADATA_FILENAME}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unable to load {ONYX_METADATA_FILENAME}: {e}",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
f"Unable to load {ONYX_METADATA_FILENAME}: {e}",
|
||||
)
|
||||
|
||||
# Save to file store
|
||||
@@ -500,7 +497,7 @@ def upload_files(
|
||||
|
||||
if is_zip_file(file):
|
||||
if seen_zip:
|
||||
raise HTTPException(status_code=400, detail=SEEN_ZIP_DETAIL)
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, SEEN_ZIP_DETAIL)
|
||||
seen_zip = True
|
||||
with zipfile.ZipFile(file.file, "r") as zf:
|
||||
zip_metadata_file_id = save_zip_metadata_to_file_store(
|
||||
@@ -554,7 +551,7 @@ def upload_files(
|
||||
deduped_file_names.append(file.filename)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
return FileUploadResponse(
|
||||
file_paths=deduped_file_paths,
|
||||
file_names=deduped_file_names,
|
||||
@@ -581,9 +578,9 @@ def _fetch_and_check_file_connector_cc_pair_permissions(
|
||||
) -> ConnectorCredentialPair:
|
||||
cc_pair = fetch_connector_credential_pair_for_connector(db_session, connector_id)
|
||||
if cc_pair is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No Connector-Credential Pair found for this connector",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"No Connector-Credential Pair found for this connector",
|
||||
)
|
||||
|
||||
has_requested_access = verify_user_has_access_to_cc_pair(
|
||||
@@ -604,9 +601,9 @@ def _fetch_and_check_file_connector_cc_pair_permissions(
|
||||
):
|
||||
return cc_pair
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Access denied. User cannot manage files for this connector.",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.UNAUTHORIZED,
|
||||
"Access denied. User cannot manage files for this connector.",
|
||||
)
|
||||
|
||||
|
||||
@@ -627,11 +624,12 @@ def list_connector_files(
|
||||
"""List all files in a file connector."""
|
||||
connector = fetch_connector_by_id(connector_id, db_session)
|
||||
if connector is None:
|
||||
raise HTTPException(status_code=404, detail="Connector not found")
|
||||
raise OnyxError(OnyxErrorCode.CONNECTOR_NOT_FOUND, "Connector not found")
|
||||
|
||||
if connector.source != DocumentSource.FILE:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="This endpoint only works with file connectors"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"This endpoint only works with file connectors",
|
||||
)
|
||||
|
||||
_ = _fetch_and_check_file_connector_cc_pair_permissions(
|
||||
@@ -700,11 +698,12 @@ def update_connector_files(
|
||||
files = files or []
|
||||
connector = fetch_connector_by_id(connector_id, db_session)
|
||||
if connector is None:
|
||||
raise HTTPException(status_code=404, detail="Connector not found")
|
||||
raise OnyxError(OnyxErrorCode.CONNECTOR_NOT_FOUND, "Connector not found")
|
||||
|
||||
if connector.source != DocumentSource.FILE:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="This endpoint only works with file connectors"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"This endpoint only works with file connectors",
|
||||
)
|
||||
|
||||
# Get the connector-credential pair for indexing/pruning triggers
|
||||
@@ -720,12 +719,14 @@ def update_connector_files(
|
||||
try:
|
||||
file_ids_list = json.loads(file_ids_to_remove)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid file_ids_to_remove format")
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR, "Invalid file_ids_to_remove format"
|
||||
)
|
||||
|
||||
if not isinstance(file_ids_list, list):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="file_ids_to_remove must be a JSON-encoded list",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"file_ids_to_remove must be a JSON-encoded list",
|
||||
)
|
||||
|
||||
# Get current connector config
|
||||
@@ -750,9 +751,9 @@ def update_connector_files(
|
||||
current_zip_metadata = loaded_metadata
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load existing metadata file: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to load existing connector metadata file",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR,
|
||||
"Failed to load existing connector metadata file",
|
||||
)
|
||||
|
||||
# Upload new files if any
|
||||
@@ -807,9 +808,9 @@ def update_connector_files(
|
||||
|
||||
# Validate that at least one file remains
|
||||
if not final_file_locations:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot remove all files from connector. At least one file must remain.",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"Cannot remove all files from connector. At least one file must remain.",
|
||||
)
|
||||
|
||||
# Merge and filter metadata (remove metadata for deleted files)
|
||||
@@ -852,8 +853,8 @@ def update_connector_files(
|
||||
|
||||
updated_connector = update_connector(connector_id, connector_base, db_session)
|
||||
if updated_connector is None:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to update connector configuration"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR, "Failed to update connector configuration"
|
||||
)
|
||||
|
||||
# Trigger re-indexing for new files and pruning for removed files
|
||||
@@ -1541,7 +1542,7 @@ def create_connector_from_model(
|
||||
return connector_response
|
||||
except ValueError as e:
|
||||
logger.error(f"Error creating connector: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
|
||||
@router.post("/admin/connector-with-mock-credential")
|
||||
@@ -1619,11 +1620,12 @@ def create_connector_with_mock_credential(
|
||||
return response
|
||||
|
||||
except ConnectorValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Connector validation error: " + str(e)
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CONNECTOR_VALIDATION_FAILED,
|
||||
"Connector validation error: " + str(e),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
|
||||
@router.patch("/admin/connector/{connector_id}", tags=PUBLIC_API_TAGS)
|
||||
@@ -1648,12 +1650,13 @@ def update_connector_from_model(
|
||||
)
|
||||
connector_base = connector_data.to_connector_base()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
updated_connector = update_connector(connector_id, connector_base, db_session)
|
||||
if updated_connector is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Connector {connector_id} does not exist"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CONNECTOR_NOT_FOUND,
|
||||
f"Connector {connector_id} does not exist",
|
||||
)
|
||||
|
||||
return ConnectorSnapshot(
|
||||
@@ -1690,7 +1693,7 @@ def delete_connector_by_id(
|
||||
connector_id=connector_id,
|
||||
)
|
||||
except AssertionError:
|
||||
raise HTTPException(status_code=400, detail="Connector is not deletable")
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "Connector is not deletable")
|
||||
|
||||
|
||||
@router.post("/admin/connector/run-once", tags=PUBLIC_API_TAGS)
|
||||
@@ -1711,9 +1714,9 @@ def connector_run_once(
|
||||
run_info.connector_id, db_session
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Connector by id {connector_id} does not exist.",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CONNECTOR_NOT_FOUND,
|
||||
f"Connector by id {connector_id} does not exist.",
|
||||
)
|
||||
|
||||
if not specified_credential_ids:
|
||||
@@ -1722,15 +1725,15 @@ def connector_run_once(
|
||||
if set(specified_credential_ids).issubset(set(possible_credential_ids)):
|
||||
credential_ids = specified_credential_ids
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Not all specified credentials are associated with connector",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"Not all specified credentials are associated with connector",
|
||||
)
|
||||
|
||||
if not credential_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Connector has no valid credentials, cannot create index attempts.",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"Connector has no valid credentials, cannot create index attempts.",
|
||||
)
|
||||
try:
|
||||
num_triggers = trigger_indexing_for_cc_pair(
|
||||
@@ -1741,7 +1744,7 @@ def connector_run_once(
|
||||
db_session,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
logger.info("connector_run_once - running check_for_indexing")
|
||||
|
||||
@@ -1795,8 +1798,8 @@ def gmail_callback(
|
||||
) -> StatusResponse:
|
||||
credential_id_cookie = request.cookies.get(_GMAIL_CREDENTIAL_ID_COOKIE_NAME)
|
||||
if credential_id_cookie is None or not credential_id_cookie.isdigit():
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Request did not pass CSRF verification."
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CSRF_FAILURE, "Request did not pass CSRF verification."
|
||||
)
|
||||
credential_id = int(credential_id_cookie)
|
||||
verify_csrf(credential_id, callback.state)
|
||||
@@ -1809,8 +1812,8 @@ def gmail_callback(
|
||||
GoogleOAuthAuthenticationMethod.UPLOADED,
|
||||
)
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Unable to fetch Gmail access tokens"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR, "Unable to fetch Gmail access tokens"
|
||||
)
|
||||
|
||||
return StatusResponse(success=True, message="Updated Gmail access tokens")
|
||||
@@ -1825,8 +1828,8 @@ def google_drive_callback(
|
||||
) -> StatusResponse:
|
||||
credential_id_cookie = request.cookies.get(_GOOGLE_DRIVE_CREDENTIAL_ID_COOKIE_NAME)
|
||||
if credential_id_cookie is None or not credential_id_cookie.isdigit():
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Request did not pass CSRF verification."
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CSRF_FAILURE, "Request did not pass CSRF verification."
|
||||
)
|
||||
credential_id = int(credential_id_cookie)
|
||||
verify_csrf(credential_id, callback.state)
|
||||
@@ -1840,8 +1843,9 @@ def google_drive_callback(
|
||||
GoogleOAuthAuthenticationMethod.UPLOADED,
|
||||
)
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Unable to fetch Google Drive access tokens"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR,
|
||||
"Unable to fetch Google Drive access tokens",
|
||||
)
|
||||
|
||||
return StatusResponse(success=True, message="Updated Google Drive access tokens")
|
||||
@@ -1881,8 +1885,9 @@ def get_connector_by_id(
|
||||
) -> ConnectorSnapshot | StatusResponse[int]:
|
||||
connector = fetch_connector_by_id(connector_id, db_session)
|
||||
if connector is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Connector {connector_id} does not exist"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CONNECTOR_NOT_FOUND,
|
||||
f"Connector {connector_id} does not exist",
|
||||
)
|
||||
|
||||
return ConnectorSnapshot(
|
||||
@@ -1915,7 +1920,9 @@ def submit_connector_request(
|
||||
connector_name = request_data.connector_name.strip()
|
||||
|
||||
if not connector_name:
|
||||
raise HTTPException(status_code=400, detail="Connector name cannot be empty")
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR, "Connector name cannot be empty"
|
||||
)
|
||||
|
||||
# Get user identifier for telemetry
|
||||
user_email = user.email if user else None
|
||||
|
||||
@@ -4,7 +4,6 @@ from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import File
|
||||
from fastapi import Form
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -28,6 +27,8 @@ from onyx.db.credentials import update_credential
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.models import DocumentSource
|
||||
from onyx.db.models import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.documents.models import CredentialBase
|
||||
from onyx.server.documents.models import CredentialDataUpdateRequest
|
||||
from onyx.server.documents.models import CredentialSnapshot
|
||||
@@ -176,18 +177,18 @@ def create_credential_with_private_key(
|
||||
try:
|
||||
credential_data = json.loads(credential_json)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid JSON in credential_json: {str(e)}",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
f"Invalid JSON in credential_json: {str(e)}",
|
||||
)
|
||||
|
||||
private_key_processor: ProcessPrivateKeyFileProtocol | None = (
|
||||
FILE_TYPE_TO_FILE_PROCESSOR.get(PrivateKeyFileTypes(type_definition_key))
|
||||
)
|
||||
if private_key_processor is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid type definition key for private key file",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"Invalid type definition key for private key file",
|
||||
)
|
||||
private_key_content: str = private_key_processor(uploaded_file)
|
||||
|
||||
@@ -251,9 +252,9 @@ def get_credential_by_id(
|
||||
get_editable=False,
|
||||
)
|
||||
if credential is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Credential {credential_id} does not exist or does not belong to user",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CREDENTIAL_NOT_FOUND,
|
||||
f"Credential {credential_id} does not exist or does not belong to user",
|
||||
)
|
||||
|
||||
return CredentialSnapshot.from_credential_db_model(credential)
|
||||
@@ -275,9 +276,9 @@ def update_credential_data(
|
||||
)
|
||||
|
||||
if credential is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Credential {credential_id} does not exist or does not belong to user",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CREDENTIAL_NOT_FOUND,
|
||||
f"Credential {credential_id} does not exist or does not belong to user",
|
||||
)
|
||||
|
||||
return CredentialSnapshot.from_credential_db_model(credential)
|
||||
@@ -297,18 +298,18 @@ def update_credential_private_key(
|
||||
try:
|
||||
credential_data = json.loads(credential_json)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid JSON in credential_json: {str(e)}",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
f"Invalid JSON in credential_json: {str(e)}",
|
||||
)
|
||||
|
||||
private_key_processor: ProcessPrivateKeyFileProtocol | None = (
|
||||
FILE_TYPE_TO_FILE_PROCESSOR.get(PrivateKeyFileTypes(type_definition_key))
|
||||
)
|
||||
if private_key_processor is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid type definition key for private key file",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"Invalid type definition key for private key file",
|
||||
)
|
||||
private_key_content: str = private_key_processor(uploaded_file)
|
||||
credential_data[field_key] = private_key_content
|
||||
@@ -322,9 +323,9 @@ def update_credential_private_key(
|
||||
)
|
||||
|
||||
if credential is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Credential {credential_id} does not exist or does not belong to user",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CREDENTIAL_NOT_FOUND,
|
||||
f"Credential {credential_id} does not exist or does not belong to user",
|
||||
)
|
||||
|
||||
return CredentialSnapshot.from_credential_db_model(credential)
|
||||
@@ -341,9 +342,9 @@ def update_credential_from_model(
|
||||
credential_id, credential_data, user, db_session
|
||||
)
|
||||
if updated_credential is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Credential {credential_id} does not exist or does not belong to user",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CREDENTIAL_NOT_FOUND,
|
||||
f"Credential {credential_id} does not exist or does not belong to user",
|
||||
)
|
||||
|
||||
# Get credential_json value - use masking for API responses
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -14,6 +13,8 @@ from onyx.db.models import User
|
||||
from onyx.db.search_settings import get_current_search_settings
|
||||
from onyx.document_index.factory import get_default_document_index
|
||||
from onyx.document_index.interfaces import VespaChunkRequest
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.natural_language_processing.utils import get_tokenizer
|
||||
from onyx.prompts.prompt_utils import build_doc_context_str
|
||||
from onyx.server.documents.models import ChunkInfo
|
||||
@@ -43,7 +44,7 @@ def get_document_info(
|
||||
)
|
||||
|
||||
if not inference_chunks:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
raise OnyxError(OnyxErrorCode.DOCUMENT_NOT_FOUND, "Document not found")
|
||||
|
||||
contents = [chunk.content for chunk in inference_chunks]
|
||||
|
||||
@@ -95,7 +96,7 @@ def get_chunk_info(
|
||||
)
|
||||
|
||||
if not inference_chunks:
|
||||
raise HTTPException(status_code=404, detail="Chunk not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Chunk not found")
|
||||
|
||||
chunk_content = inference_chunks[0].content
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import base64
|
||||
from enum import Enum
|
||||
from typing import Protocol
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import UploadFile
|
||||
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.documents.document_utils import validate_pkcs12_content
|
||||
|
||||
|
||||
@@ -31,8 +32,9 @@ def process_sharepoint_private_key_file(file: UploadFile) -> str:
|
||||
"""
|
||||
# First check file extension (basic filter)
|
||||
if not (file.filename and file.filename.lower().endswith(".pfx")):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid file type. Only .pfx files are supported."
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"Invalid file type. Only .pfx files are supported.",
|
||||
)
|
||||
|
||||
# Read file content for validation and processing
|
||||
@@ -40,9 +42,9 @@ def process_sharepoint_private_key_file(file: UploadFile) -> str:
|
||||
|
||||
# Validate file content to prevent extension spoofing attacks
|
||||
if not validate_pkcs12_content(private_key_bytes):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid file content. The uploaded file does not appear to be a valid PKCS#12 (.pfx) file.",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"Invalid file content. The uploaded file does not appear to be a valid PKCS#12 (.pfx) file.",
|
||||
)
|
||||
|
||||
# Convert to base64 if validation passes
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import cast
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from fastapi import Request
|
||||
from pydantic import BaseModel
|
||||
@@ -19,6 +18,8 @@ from onyx.connectors.interfaces import OAuthConnector
|
||||
from onyx.db.credentials import create_credential
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.models import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.server.documents.models import CredentialBase
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -69,12 +70,10 @@ def _get_additional_kwargs(
|
||||
# validate
|
||||
connector_cls.AdditionalOauthKwargs(**additional_kwargs_dict)
|
||||
except ValidationError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Invalid additional kwargs. Got {additional_kwargs_dict}, expected "
|
||||
f"{connector_cls.AdditionalOauthKwargs.model_json_schema()}"
|
||||
),
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
f"Invalid additional kwargs. Got {additional_kwargs_dict}, expected "
|
||||
f"{connector_cls.AdditionalOauthKwargs.model_json_schema()}",
|
||||
)
|
||||
|
||||
return additional_kwargs_dict
|
||||
@@ -97,7 +96,9 @@ def oauth_authorize(
|
||||
oauth_connectors = _discover_oauth_connectors()
|
||||
|
||||
if source not in oauth_connectors:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown OAuth source: {source}")
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR, f"Unknown OAuth source: {source}"
|
||||
)
|
||||
|
||||
connector_cls = oauth_connectors[source]
|
||||
base_url = WEB_DOMAIN
|
||||
@@ -147,7 +148,9 @@ def oauth_callback(
|
||||
oauth_connectors = _discover_oauth_connectors()
|
||||
|
||||
if source not in oauth_connectors:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown OAuth source: {source}")
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR, f"Unknown OAuth source: {source}"
|
||||
)
|
||||
|
||||
connector_cls = oauth_connectors[source]
|
||||
|
||||
@@ -157,7 +160,7 @@ def oauth_callback(
|
||||
bytes, redis_client.get(_OAUTH_STATE_KEY_FMT.format(state=state))
|
||||
)
|
||||
if not oauth_state_bytes:
|
||||
raise HTTPException(status_code=400, detail="Invalid OAuth state")
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "Invalid OAuth state")
|
||||
oauth_state = json.loads(oauth_state_bytes.decode("utf-8"))
|
||||
|
||||
desired_return_url = cast(str, oauth_state[_DESIRED_RETURN_URL_KEY])
|
||||
|
||||
@@ -60,9 +60,11 @@ class Settings(BaseModel):
|
||||
deep_research_enabled: bool | None = None
|
||||
search_ui_enabled: bool | None = None
|
||||
|
||||
# Enterprise features flag - set by license enforcement at runtime
|
||||
# When LICENSE_ENFORCEMENT_ENABLED=true, this reflects license status
|
||||
# When LICENSE_ENFORCEMENT_ENABLED=false, defaults to False
|
||||
# Whether EE features are unlocked for use.
|
||||
# Depends on license status: True when the user has a valid license
|
||||
# (ACTIVE, GRACE_PERIOD, PAYMENT_REMINDER), False when there's no license
|
||||
# or the license is expired (GATED_ACCESS).
|
||||
# This controls UI visibility of EE features (user groups, analytics, RBAC, etc.).
|
||||
ee_features_enabled: bool = False
|
||||
|
||||
temperature_override_enabled: bool | None = False
|
||||
|
||||
@@ -281,9 +281,10 @@ class TestApplyLicenseStatusToSettings:
|
||||
}
|
||||
|
||||
|
||||
class TestSettingsDefaultEEDisabled:
|
||||
"""Verify the Settings model defaults ee_features_enabled to False."""
|
||||
class TestSettingsDefaults:
|
||||
"""Verify Settings model defaults for CE deployments."""
|
||||
|
||||
def test_default_ee_features_disabled(self) -> None:
|
||||
"""CE default: ee_features_enabled is False."""
|
||||
settings = Settings()
|
||||
assert settings.ee_features_enabled is False
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
set -e
|
||||
|
||||
# Expected resource requirements (overridden below if --lite)
|
||||
# Expected resource requirements
|
||||
EXPECTED_DOCKER_RAM_GB=10
|
||||
EXPECTED_DISK_GB=32
|
||||
|
||||
@@ -10,10 +10,6 @@ EXPECTED_DISK_GB=32
|
||||
SHUTDOWN_MODE=false
|
||||
DELETE_DATA_MODE=false
|
||||
INCLUDE_CRAFT=false # Disabled by default, use --include-craft to enable
|
||||
LITE_MODE=false # Disabled by default, use --lite to enable
|
||||
NO_PROMPT=false
|
||||
DRY_RUN=false
|
||||
VERBOSE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
@@ -29,22 +25,6 @@ while [[ $# -gt 0 ]]; do
|
||||
INCLUDE_CRAFT=true
|
||||
shift
|
||||
;;
|
||||
--lite)
|
||||
LITE_MODE=true
|
||||
shift
|
||||
;;
|
||||
--no-prompt)
|
||||
NO_PROMPT=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Onyx Installation Script"
|
||||
echo ""
|
||||
@@ -52,21 +32,15 @@ while [[ $# -gt 0 ]]; do
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --include-craft Enable Onyx Craft (AI-powered web app building)"
|
||||
echo " --lite Deploy Onyx Lite (no Vespa, Redis, or model servers)"
|
||||
echo " --shutdown Stop (pause) Onyx containers"
|
||||
echo " --delete-data Remove all Onyx data (containers, volumes, and files)"
|
||||
echo " --no-prompt Run non-interactively with defaults (for CI/automation)"
|
||||
echo " --dry-run Show what would be done without making changes"
|
||||
echo " --verbose Show detailed output for debugging"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Install Onyx"
|
||||
echo " $0 --lite # Install Onyx Lite (minimal deployment)"
|
||||
echo " $0 --include-craft # Install Onyx with Craft enabled"
|
||||
echo " $0 --shutdown # Pause Onyx services"
|
||||
echo " $0 --delete-data # Completely remove Onyx and all data"
|
||||
echo " $0 --no-prompt # Non-interactive install with defaults"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
@@ -77,116 +51,8 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$VERBOSE" = true ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
if [[ "$LITE_MODE" = true ]] && [[ "$INCLUDE_CRAFT" = true ]]; then
|
||||
echo "ERROR: --lite and --include-craft cannot be used together."
|
||||
echo "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Lite mode needs far fewer resources (no Vespa, Redis, or model servers)
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
EXPECTED_DOCKER_RAM_GB=4
|
||||
EXPECTED_DISK_GB=16
|
||||
fi
|
||||
|
||||
INSTALL_ROOT="${INSTALL_PREFIX:-onyx_data}"
|
||||
|
||||
LITE_COMPOSE_FILE="docker-compose.onyx-lite.yml"
|
||||
|
||||
# Build the -f flags for docker compose. For shutdown/delete-data we auto-detect
|
||||
# whether the lite overlay was previously downloaded; for install we use --lite.
|
||||
compose_file_args() {
|
||||
local args="-f docker-compose.yml"
|
||||
if [[ "$LITE_MODE" = true ]] || [[ -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" ]]; then
|
||||
args="$args -f ${LITE_COMPOSE_FILE}"
|
||||
fi
|
||||
echo "$args"
|
||||
}
|
||||
|
||||
# --- Temp file cleanup ---
|
||||
TMPFILES=()
|
||||
cleanup_tmpfiles() {
|
||||
local f
|
||||
for f in "${TMPFILES[@]:-}"; do
|
||||
rm -rf "$f" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
trap cleanup_tmpfiles EXIT
|
||||
|
||||
mktempfile() {
|
||||
local f
|
||||
f="$(mktemp)"
|
||||
TMPFILES+=("$f")
|
||||
echo "$f"
|
||||
}
|
||||
|
||||
# --- Downloader detection (curl with wget fallback) ---
|
||||
DOWNLOADER=""
|
||||
detect_downloader() {
|
||||
if command -v curl &> /dev/null; then
|
||||
DOWNLOADER="curl"
|
||||
return 0
|
||||
fi
|
||||
if command -v wget &> /dev/null; then
|
||||
DOWNLOADER="wget"
|
||||
return 0
|
||||
fi
|
||||
echo "ERROR: Neither curl nor wget found. Please install one and retry."
|
||||
exit 1
|
||||
}
|
||||
detect_downloader
|
||||
|
||||
download_file() {
|
||||
local url="$1"
|
||||
local output="$2"
|
||||
if [[ "$DOWNLOADER" == "curl" ]]; then
|
||||
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused -o "$output" "$url"
|
||||
else
|
||||
wget -q --tries=3 --timeout=20 -O "$output" "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Interactive prompt helpers ---
|
||||
is_interactive() {
|
||||
[[ "$NO_PROMPT" = false ]] && [[ -t 0 ]]
|
||||
}
|
||||
|
||||
prompt_or_default() {
|
||||
local prompt_text="$1"
|
||||
local default_value="$2"
|
||||
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"
|
||||
if is_interactive; then
|
||||
read -p "$prompt_text" -n 1 -r
|
||||
echo ""
|
||||
else
|
||||
REPLY="$default_value"
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_enter_or_skip() {
|
||||
local prompt_text="$1"
|
||||
if is_interactive; then
|
||||
echo -e "$prompt_text"
|
||||
read -r
|
||||
fi
|
||||
}
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -245,7 +111,7 @@ if [ "$SHUTDOWN_MODE" = true ]; then
|
||||
fi
|
||||
|
||||
# Stop containers (without removing them)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) stop)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml stop)
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Onyx containers stopped (paused)"
|
||||
else
|
||||
@@ -274,17 +140,12 @@ if [ "$DELETE_DATA_MODE" = true ]; then
|
||||
echo " • All downloaded files and configurations"
|
||||
echo " • All user data and documents"
|
||||
echo ""
|
||||
if is_interactive; then
|
||||
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
|
||||
fi
|
||||
else
|
||||
print_error "Cannot confirm destructive operation in non-interactive mode."
|
||||
print_info "Run interactively or remove the ${INSTALL_ROOT} directory manually."
|
||||
exit 1
|
||||
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
|
||||
fi
|
||||
|
||||
print_info "Removing Onyx containers and volumes..."
|
||||
@@ -303,7 +164,7 @@ if [ "$DELETE_DATA_MODE" = true ]; then
|
||||
fi
|
||||
|
||||
# Stop and remove containers with volumes
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) down -v)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml down -v)
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Onyx containers and volumes removed"
|
||||
else
|
||||
@@ -337,13 +198,8 @@ echo " \____/|_| |_|\__, /_/\_\ "
|
||||
echo " __/ | "
|
||||
echo " |___/ "
|
||||
echo -e "${NC}"
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
echo "Welcome to Onyx Lite Installation Script"
|
||||
echo "========================================="
|
||||
else
|
||||
echo "Welcome to Onyx Installation Script"
|
||||
echo "===================================="
|
||||
fi
|
||||
echo "Welcome to Onyx Installation Script"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# User acknowledgment section
|
||||
@@ -351,14 +207,10 @@ echo -e "${YELLOW}${BOLD}This script will:${NC}"
|
||||
echo "1. Download deployment files for Onyx into a new '${INSTALL_ROOT}' directory"
|
||||
echo "2. Check your system resources (Docker, memory, disk space)"
|
||||
echo "3. Guide you through deployment options (version, authentication)"
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}${BOLD}Lite mode:${NC} Vespa, Redis, and model servers will NOT be started."
|
||||
echo "This gives you the core chat experience with lower resource requirements."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if is_interactive; then
|
||||
# Only prompt for acknowledgment if running interactively
|
||||
if [ -t 0 ]; then
|
||||
echo -e "${YELLOW}${BOLD}Please acknowledge and press Enter to continue...${NC}"
|
||||
read -r
|
||||
echo ""
|
||||
@@ -367,26 +219,6 @@ else
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Detect OS (including WSL)
|
||||
IS_WSL=false
|
||||
if [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
IS_WSL=true
|
||||
fi
|
||||
|
||||
# Dry-run: show plan and exit
|
||||
if [[ "$DRY_RUN" = true ]]; then
|
||||
print_info "Dry run mode — showing what would happen:"
|
||||
echo " • Install root: ${INSTALL_ROOT}"
|
||||
echo " • Lite mode: ${LITE_MODE}"
|
||||
echo " • Include Craft: ${INCLUDE_CRAFT}"
|
||||
echo " • OS type: ${OSTYPE:-unknown} (WSL: ${IS_WSL})"
|
||||
echo " • Downloader: ${DOWNLOADER}"
|
||||
echo " • Min RAM: ${EXPECTED_DOCKER_RAM_GB}GB, Min disk: ${EXPECTED_DISK_GB}GB"
|
||||
echo ""
|
||||
print_success "Dry run complete (no changes made)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# GitHub repo base URL - using main branch
|
||||
GITHUB_RAW_URL="https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deployment/docker_compose"
|
||||
|
||||
@@ -428,35 +260,41 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Returns 0 if $1 <= $2, 1 if $1 > $2
|
||||
# Handles missing or non-numeric parts gracefully (treats them as 0)
|
||||
# Function to compare version numbers
|
||||
version_compare() {
|
||||
local version1="${1:-0.0.0}"
|
||||
local version2="${2:-0.0.0}"
|
||||
# Returns 0 if $1 <= $2, 1 if $1 > $2
|
||||
local version1=$1
|
||||
local version2=$2
|
||||
|
||||
local v1_major v1_minor v1_patch v2_major v2_minor v2_patch
|
||||
v1_major=$(echo "$version1" | cut -d. -f1)
|
||||
v1_minor=$(echo "$version1" | cut -d. -f2)
|
||||
v1_patch=$(echo "$version1" | cut -d. -f3)
|
||||
v2_major=$(echo "$version2" | cut -d. -f1)
|
||||
v2_minor=$(echo "$version2" | cut -d. -f2)
|
||||
v2_patch=$(echo "$version2" | cut -d. -f3)
|
||||
# Split versions into components
|
||||
local v1_major=$(echo $version1 | cut -d. -f1)
|
||||
local v1_minor=$(echo $version1 | cut -d. -f2)
|
||||
local v1_patch=$(echo $version1 | cut -d. -f3)
|
||||
|
||||
# Default non-numeric or empty parts to 0
|
||||
[[ "$v1_major" =~ ^[0-9]+$ ]] || v1_major=0
|
||||
[[ "$v1_minor" =~ ^[0-9]+$ ]] || v1_minor=0
|
||||
[[ "$v1_patch" =~ ^[0-9]+$ ]] || v1_patch=0
|
||||
[[ "$v2_major" =~ ^[0-9]+$ ]] || v2_major=0
|
||||
[[ "$v2_minor" =~ ^[0-9]+$ ]] || v2_minor=0
|
||||
[[ "$v2_patch" =~ ^[0-9]+$ ]] || v2_patch=0
|
||||
local v2_major=$(echo $version2 | cut -d. -f1)
|
||||
local v2_minor=$(echo $version2 | cut -d. -f2)
|
||||
local v2_patch=$(echo $version2 | cut -d. -f3)
|
||||
|
||||
if [ "$v1_major" -lt "$v2_major" ]; then return 0
|
||||
elif [ "$v1_major" -gt "$v2_major" ]; then return 1; fi
|
||||
# Compare major version
|
||||
if [ "$v1_major" -lt "$v2_major" ]; then
|
||||
return 0
|
||||
elif [ "$v1_major" -gt "$v2_major" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$v1_minor" -lt "$v2_minor" ]; then return 0
|
||||
elif [ "$v1_minor" -gt "$v2_minor" ]; then return 1; fi
|
||||
# Compare minor version
|
||||
if [ "$v1_minor" -lt "$v2_minor" ]; then
|
||||
return 0
|
||||
elif [ "$v1_minor" -gt "$v2_minor" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
[ "$v1_patch" -le "$v2_patch" ]
|
||||
# Compare patch version
|
||||
if [ "$v1_patch" -le "$v2_patch" ]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check Docker daemon
|
||||
@@ -533,7 +371,8 @@ if [ "$RESOURCE_WARNING" = true ]; then
|
||||
echo ""
|
||||
print_warning "Onyx recommends at least ${EXPECTED_DOCKER_RAM_GB}GB RAM and ${EXPECTED_DISK_GB}GB disk space for optimal performance."
|
||||
echo ""
|
||||
prompt_yn_or_default "Do you want to continue anyway? (y/N): " "y"
|
||||
read -p "Do you want to continue anyway? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_info "Installation cancelled. Please allocate more resources and try again."
|
||||
exit 1
|
||||
@@ -558,9 +397,6 @@ print_info "This step downloads all necessary configuration files from GitHub...
|
||||
echo ""
|
||||
print_info "Downloading the following files:"
|
||||
echo " • docker-compose.yml - Main Docker Compose configuration"
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
echo " • ${LITE_COMPOSE_FILE} - Lite mode overlay"
|
||||
fi
|
||||
echo " • env.template - Environment variables template"
|
||||
echo " • nginx/app.conf.template - Nginx web server configuration"
|
||||
echo " • nginx/run-nginx.sh - Nginx startup script"
|
||||
@@ -570,7 +406,7 @@ echo ""
|
||||
# Download Docker Compose file
|
||||
COMPOSE_FILE="${INSTALL_ROOT}/deployment/docker-compose.yml"
|
||||
print_info "Downloading docker-compose.yml..."
|
||||
if download_file "${GITHUB_RAW_URL}/docker-compose.yml" "$COMPOSE_FILE" 2>/dev/null; then
|
||||
if curl -fsSL -o "$COMPOSE_FILE" "${GITHUB_RAW_URL}/docker-compose.yml" 2>/dev/null; then
|
||||
print_success "Docker Compose file downloaded successfully"
|
||||
|
||||
# Check if Docker Compose version is older than 2.24.0 and show warning
|
||||
@@ -595,7 +431,8 @@ if download_file "${GITHUB_RAW_URL}/docker-compose.yml" "$COMPOSE_FILE" 2>/dev/n
|
||||
echo ""
|
||||
print_warning "The installation will continue, but may fail if Docker Compose cannot parse the file."
|
||||
echo ""
|
||||
prompt_yn_or_default "Do you want to continue anyway? (y/N): " "y"
|
||||
read -p "Do you want to continue anyway? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_info "Installation cancelled. Please upgrade Docker Compose or manually edit the docker-compose.yml file."
|
||||
exit 1
|
||||
@@ -608,23 +445,10 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download lite overlay if --lite was requested
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
LITE_FILE="${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
|
||||
print_info "Downloading ${LITE_COMPOSE_FILE} (lite overlay)..."
|
||||
if download_file "${GITHUB_RAW_URL}/${LITE_COMPOSE_FILE}" "$LITE_FILE" 2>/dev/null; then
|
||||
print_success "Lite overlay downloaded successfully"
|
||||
else
|
||||
print_error "Failed to download lite overlay"
|
||||
print_info "Please ensure you have internet connection and try again"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Download env.template file
|
||||
ENV_TEMPLATE="${INSTALL_ROOT}/deployment/env.template"
|
||||
print_info "Downloading env.template..."
|
||||
if download_file "${GITHUB_RAW_URL}/env.template" "$ENV_TEMPLATE" 2>/dev/null; then
|
||||
if curl -fsSL -o "$ENV_TEMPLATE" "${GITHUB_RAW_URL}/env.template" 2>/dev/null; then
|
||||
print_success "Environment template downloaded successfully"
|
||||
else
|
||||
print_error "Failed to download env.template"
|
||||
@@ -638,7 +462,7 @@ NGINX_BASE_URL="https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deploym
|
||||
# Download app.conf.template
|
||||
NGINX_CONFIG="${INSTALL_ROOT}/data/nginx/app.conf.template"
|
||||
print_info "Downloading nginx configuration template..."
|
||||
if download_file "$NGINX_BASE_URL/app.conf.template" "$NGINX_CONFIG" 2>/dev/null; then
|
||||
if curl -fsSL -o "$NGINX_CONFIG" "$NGINX_BASE_URL/app.conf.template" 2>/dev/null; then
|
||||
print_success "Nginx configuration template downloaded"
|
||||
else
|
||||
print_error "Failed to download nginx configuration template"
|
||||
@@ -649,7 +473,7 @@ fi
|
||||
# Download run-nginx.sh script
|
||||
NGINX_RUN_SCRIPT="${INSTALL_ROOT}/data/nginx/run-nginx.sh"
|
||||
print_info "Downloading nginx startup script..."
|
||||
if download_file "$NGINX_BASE_URL/run-nginx.sh" "$NGINX_RUN_SCRIPT" 2>/dev/null; then
|
||||
if curl -fsSL -o "$NGINX_RUN_SCRIPT" "$NGINX_BASE_URL/run-nginx.sh" 2>/dev/null; then
|
||||
chmod +x "$NGINX_RUN_SCRIPT"
|
||||
print_success "Nginx startup script downloaded and made executable"
|
||||
else
|
||||
@@ -661,7 +485,7 @@ fi
|
||||
# Download README file
|
||||
README_FILE="${INSTALL_ROOT}/README.md"
|
||||
print_info "Downloading README.md..."
|
||||
if download_file "${GITHUB_RAW_URL}/README.md" "$README_FILE" 2>/dev/null; then
|
||||
if curl -fsSL -o "$README_FILE" "${GITHUB_RAW_URL}/README.md" 2>/dev/null; then
|
||||
print_success "README.md downloaded successfully"
|
||||
else
|
||||
print_error "Failed to download README.md"
|
||||
@@ -689,7 +513,7 @@ if [ -d "${INSTALL_ROOT}/deployment" ] && [ -f "${INSTALL_ROOT}/deployment/docke
|
||||
|
||||
if [ -n "$COMPOSE_CMD" ]; then
|
||||
# Check if any containers are running
|
||||
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) ps -q 2>/dev/null | wc -l)
|
||||
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null | wc -l)
|
||||
if [ "$RUNNING_CONTAINERS" -gt 0 ]; then
|
||||
print_error "Onyx services are currently running!"
|
||||
echo ""
|
||||
@@ -710,7 +534,7 @@ if [ -f "$ENV_FILE" ]; then
|
||||
echo "• Press Enter to restart with current configuration"
|
||||
echo "• Type 'update' to update to a newer version"
|
||||
echo ""
|
||||
prompt_or_default "Choose an option [default: restart]: " ""
|
||||
read -p "Choose an option [default: restart]: " -r
|
||||
echo ""
|
||||
|
||||
if [ "$REPLY" = "update" ]; then
|
||||
@@ -719,19 +543,22 @@ if [ -f "$ENV_FILE" ]; then
|
||||
echo "• Press Enter for latest (recommended)"
|
||||
echo "• Type a specific tag (e.g., v0.1.0)"
|
||||
echo ""
|
||||
# If --include-craft was passed, default to craft-latest
|
||||
if [ "$INCLUDE_CRAFT" = true ]; then
|
||||
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
|
||||
VERSION="$REPLY"
|
||||
read -p "Enter tag [default: craft-latest]: " -r VERSION
|
||||
else
|
||||
prompt_or_default "Enter tag [default: latest]: " "latest"
|
||||
VERSION="$REPLY"
|
||||
read -p "Enter tag [default: latest]: " -r VERSION
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
|
||||
print_info "Selected: craft-latest (Craft enabled)"
|
||||
elif [ "$VERSION" = "latest" ]; then
|
||||
print_info "Selected: Latest version"
|
||||
if [ -z "$VERSION" ]; then
|
||||
if [ "$INCLUDE_CRAFT" = true ]; then
|
||||
VERSION="craft-latest"
|
||||
print_info "Selected: craft-latest (Craft enabled)"
|
||||
else
|
||||
VERSION="latest"
|
||||
print_info "Selected: Latest version"
|
||||
fi
|
||||
else
|
||||
print_info "Selected: $VERSION"
|
||||
fi
|
||||
@@ -768,21 +595,23 @@ else
|
||||
echo "• Press Enter for craft-latest (recommended for Craft)"
|
||||
echo "• Type a specific tag (e.g., craft-v1.0.0)"
|
||||
echo ""
|
||||
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
|
||||
VERSION="$REPLY"
|
||||
read -p "Enter tag [default: craft-latest]: " -r VERSION
|
||||
else
|
||||
echo "• Press Enter for latest (recommended)"
|
||||
echo "• Type a specific tag (e.g., v0.1.0)"
|
||||
echo ""
|
||||
prompt_or_default "Enter tag [default: latest]: " "latest"
|
||||
VERSION="$REPLY"
|
||||
read -p "Enter tag [default: latest]: " -r VERSION
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
|
||||
print_info "Selected: craft-latest (Craft enabled)"
|
||||
elif [ "$VERSION" = "latest" ]; then
|
||||
print_info "Selected: Latest tag"
|
||||
if [ -z "$VERSION" ]; then
|
||||
if [ "$INCLUDE_CRAFT" = true ]; then
|
||||
VERSION="craft-latest"
|
||||
print_info "Selected: craft-latest (Craft enabled)"
|
||||
else
|
||||
VERSION="latest"
|
||||
print_info "Selected: Latest tag"
|
||||
fi
|
||||
else
|
||||
print_info "Selected: $VERSION"
|
||||
fi
|
||||
@@ -857,13 +686,6 @@ else
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Reject craft image tags when running in lite mode
|
||||
if [[ "$LITE_MODE" = true ]] && [[ "${VERSION:-}" == craft-* ]]; then
|
||||
print_error "Cannot use a craft image tag (${VERSION}) with --lite."
|
||||
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to check if a port is available
|
||||
is_port_available() {
|
||||
local port=$1
|
||||
@@ -949,7 +771,7 @@ print_step "Pulling Docker images"
|
||||
print_info "This may take several minutes depending on your internet connection..."
|
||||
echo ""
|
||||
print_info "Downloading Docker images (this may take a while)..."
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) pull --quiet)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml pull --quiet)
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Docker images downloaded successfully"
|
||||
else
|
||||
@@ -963,9 +785,9 @@ print_info "Launching containers..."
|
||||
echo ""
|
||||
if [ "$USE_LATEST" = true ]; then
|
||||
print_info "Force pulling latest images and recreating containers..."
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d --pull always --force-recreate)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d --pull always --force-recreate)
|
||||
else
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d)
|
||||
fi
|
||||
if [ $? -ne 0 ]; then
|
||||
print_error "Failed to start Onyx services"
|
||||
@@ -987,7 +809,7 @@ echo ""
|
||||
# Check for restart loops
|
||||
print_info "Checking container health status..."
|
||||
RESTART_ISSUES=false
|
||||
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) ps -q 2>/dev/null)
|
||||
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null)
|
||||
|
||||
for CONTAINER in $CONTAINERS; do
|
||||
PROJECT_NAME="$(basename "$INSTALL_ROOT")_deployment_"
|
||||
@@ -1016,7 +838,7 @@ if [ "$RESTART_ISSUES" = true ]; then
|
||||
print_error "Some containers are experiencing issues!"
|
||||
echo ""
|
||||
print_info "Please check the logs for more information:"
|
||||
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD $(compose_file_args) logs)"
|
||||
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD -f docker-compose.yml logs)"
|
||||
|
||||
echo ""
|
||||
print_info "If the issue persists, please contact: founders@onyx.app"
|
||||
@@ -1035,12 +857,8 @@ check_onyx_health() {
|
||||
echo ""
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
local http_code=""
|
||||
if [[ "$DOWNLOADER" == "curl" ]]; then
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port" 2>/dev/null || echo "000")
|
||||
else
|
||||
http_code=$(wget -q --spider -S "http://localhost:$port" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}' || echo "000")
|
||||
fi
|
||||
# Check for successful HTTP responses (200, 301, 302, etc.)
|
||||
local http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port")
|
||||
if echo "$http_code" | grep -qE "^(200|301|302|303|307|308)$"; then
|
||||
return 0
|
||||
fi
|
||||
@@ -1096,18 +914,6 @@ print_info "If authentication is enabled, you can create your admin account here
|
||||
echo " • Visit http://localhost:${HOST_PORT}/auth/signup to create your admin account"
|
||||
echo " • The first user created will automatically have admin privileges"
|
||||
echo ""
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
echo ""
|
||||
print_info "Running in Lite mode — the following services are NOT started:"
|
||||
echo " • Vespa (vector database)"
|
||||
echo " • Redis (cache)"
|
||||
echo " • Model servers (embedding/inference)"
|
||||
echo " • Background workers (Celery)"
|
||||
echo ""
|
||||
print_info "Connectors and RAG search are disabled. LLM chat, tools, user file"
|
||||
print_info "uploads, Projects, Agent knowledge, and code interpreter still work."
|
||||
fi
|
||||
echo ""
|
||||
print_info "Refer to the README in the ${INSTALL_ROOT} directory for more information."
|
||||
echo ""
|
||||
print_info "For help or issues, contact: founders@onyx.app"
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import TableQualifier from "@/refresh-components/table/TableQualifier";
|
||||
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { QualifierContentType } from "@/refresh-components/table/types";
|
||||
import { SvgCheckCircle } from "@opal/icons";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content type configurations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ContentConfig {
|
||||
label: string;
|
||||
content: QualifierContentType;
|
||||
extraProps: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: ContentConfig[] = [
|
||||
{
|
||||
label: "Simple",
|
||||
content: "simple",
|
||||
extraProps: {},
|
||||
},
|
||||
{
|
||||
label: "Icon",
|
||||
content: "icon",
|
||||
extraProps: { icon: SvgCheckCircle },
|
||||
},
|
||||
{
|
||||
label: "Image",
|
||||
content: "image",
|
||||
extraProps: {
|
||||
imageSrc: "https://picsum.photos/36",
|
||||
imageAlt: "Placeholder",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Avatar Icon",
|
||||
content: "avatar-icon",
|
||||
extraProps: {},
|
||||
},
|
||||
{
|
||||
label: "Avatar User",
|
||||
content: "avatar-user",
|
||||
extraProps: { initials: "AJ" },
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row of qualifier states for a single content type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QualifierRowProps {
|
||||
config: ContentConfig;
|
||||
}
|
||||
|
||||
function QualifierRow({ config }: QualifierRowProps) {
|
||||
const [selectableSelected, setSelectableSelected] = useState(false);
|
||||
const [permanentSelected, setPermanentSelected] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Text mainUiAction text02>
|
||||
{config.label}
|
||||
</Text>
|
||||
|
||||
<div className="flex items-start gap-8">
|
||||
{/* Default */}
|
||||
<div className="flex w-20 flex-col items-center gap-2">
|
||||
<TableQualifier
|
||||
content={config.content}
|
||||
selectable={false}
|
||||
selected={false}
|
||||
disabled={false}
|
||||
{...config.extraProps}
|
||||
/>
|
||||
<Text secondaryBody text04>
|
||||
Default
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Selectable (hover to reveal checkbox) */}
|
||||
<div className="flex w-20 flex-col items-center gap-2">
|
||||
<TableQualifier
|
||||
content={config.content}
|
||||
selectable={true}
|
||||
selected={selectableSelected}
|
||||
disabled={false}
|
||||
onSelectChange={setSelectableSelected}
|
||||
{...config.extraProps}
|
||||
/>
|
||||
<Text secondaryBody text04>
|
||||
Selectable
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Selected */}
|
||||
<div className="flex w-20 flex-col items-center gap-2">
|
||||
<TableQualifier
|
||||
content={config.content}
|
||||
selectable={true}
|
||||
selected={permanentSelected}
|
||||
disabled={false}
|
||||
onSelectChange={setPermanentSelected}
|
||||
{...config.extraProps}
|
||||
/>
|
||||
<Text secondaryBody text04>
|
||||
Selected
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Disabled (unselected) */}
|
||||
<div className="flex w-20 flex-col items-center gap-2">
|
||||
<TableQualifier
|
||||
content={config.content}
|
||||
selectable={true}
|
||||
selected={false}
|
||||
disabled={true}
|
||||
{...config.extraProps}
|
||||
/>
|
||||
<Text secondaryBody text04>
|
||||
Disabled
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Disabled (selected) */}
|
||||
<div className="flex w-20 flex-col items-center gap-2">
|
||||
<TableQualifier
|
||||
content={config.content}
|
||||
selectable={true}
|
||||
selected={true}
|
||||
disabled={true}
|
||||
{...config.extraProps}
|
||||
/>
|
||||
<Text secondaryBody text04>
|
||||
Disabled+Sel
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Size section — all content types at a given size
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SizeSectionProps {
|
||||
size: TableSize;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function SizeSection({ size, title }: SizeSectionProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Text headingH3>{title}</Text>
|
||||
<TableSizeProvider size={size}>
|
||||
<div className="flex flex-col gap-8">
|
||||
{CONTENT_TYPES.map((config) => (
|
||||
<QualifierRow key={`${size}-${config.content}`} config={config} />
|
||||
))}
|
||||
</div>
|
||||
</TableSizeProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function TableQualifierDemoPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-10">
|
||||
<div className="space-y-4">
|
||||
<Text headingH2>TableQualifier Demo</Text>
|
||||
<Text mainContentMuted text03>
|
||||
All content types, sizes, and interactive states. Hover selectable
|
||||
variants to reveal the checkbox; click to toggle.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<SizeSection size="regular" title="Regular (36px)" />
|
||||
<SizeSection size="small" title="Small (28px)" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FileDescriptor } from "@/app/app/interfaces";
|
||||
import "katex/dist/katex.min.css";
|
||||
import MessageSwitcher from "@/app/app/message/MessageSwitcher";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgEdit } from "@opal/icons";
|
||||
@@ -137,6 +138,7 @@ const HumanMessage = React.memo(function HumanMessage({
|
||||
const [content, setContent] = useState(initialContent);
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { isMobile } = useScreenSize();
|
||||
|
||||
// Use nodeId for switching (finding position in siblings)
|
||||
const indexInSiblings = otherMessagesCanSwitchTo?.indexOf(nodeId);
|
||||
@@ -168,119 +170,104 @@ const HumanMessage = React.memo(function HumanMessage({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const copyEditButton = useMemo(
|
||||
() => (
|
||||
<div className="flex flex-row flex-shrink px-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CopyIconButton
|
||||
getCopyText={() => content}
|
||||
prominence="tertiary"
|
||||
data-testid="HumanMessage/copy-button"
|
||||
/>
|
||||
<Button
|
||||
icon={SvgEdit}
|
||||
prominence="tertiary"
|
||||
tooltip="Edit"
|
||||
onClick={() => setIsEditing(true)}
|
||||
data-testid="HumanMessage/edit-button"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[content]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="onyx-human-message"
|
||||
className="group flex flex-col justify-end w-full relative"
|
||||
>
|
||||
<FileDisplay alignBubble files={files || []} />
|
||||
<div className="md:flex md:flex-wrap relative justify-end break-words">
|
||||
{isEditing ? (
|
||||
<MessageEditing
|
||||
content={content}
|
||||
onSubmitEdit={(editedContent) => {
|
||||
// Don't update UI for edits that can't be persisted
|
||||
if (messageId === undefined || messageId === null) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
onEdit?.(editedContent, messageId);
|
||||
setContent(editedContent);
|
||||
{isEditing ? (
|
||||
<MessageEditing
|
||||
content={content}
|
||||
onSubmitEdit={(editedContent) => {
|
||||
// Don't update UI for edits that can't be persisted
|
||||
if (messageId === undefined || messageId === null) {
|
||||
setIsEditing(false);
|
||||
}}
|
||||
onCancelEdit={() => setIsEditing(false)}
|
||||
/>
|
||||
) : typeof content === "string" ? (
|
||||
<>
|
||||
<div className="md:max-w-[37.5rem] flex basis-[100%] md:basis-auto justify-end md:order-1">
|
||||
<div
|
||||
className={
|
||||
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
|
||||
}
|
||||
onCopy={(e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
const text = selection
|
||||
.toString()
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
e.clipboardData.setData("text/plain", text);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
className="inline-block align-middle"
|
||||
mainContentBody
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
{onEdit && !isEditing && (
|
||||
<div className="absolute md:relative right-0 z-content flex flex-row p-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CopyIconButton
|
||||
getCopyText={() => content}
|
||||
prominence="tertiary"
|
||||
data-testid="HumanMessage/copy-button"
|
||||
/>
|
||||
<Button
|
||||
icon={SvgEdit}
|
||||
prominence="tertiary"
|
||||
tooltip="Edit"
|
||||
onClick={() => setIsEditing(true)}
|
||||
data-testid="HumanMessage/edit-button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
return;
|
||||
}
|
||||
onEdit?.(editedContent, messageId);
|
||||
setContent(editedContent);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
onCancelEdit={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
{onEdit && !isMobile && copyEditButton}
|
||||
<div className="md:max-w-[37.5rem]">
|
||||
<div
|
||||
className={cn(
|
||||
"my-auto",
|
||||
onEdit && !isEditing
|
||||
? "opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
: "invisible"
|
||||
)}
|
||||
className={
|
||||
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
|
||||
}
|
||||
onCopy={(e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
const text = selection
|
||||
.toString()
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
e.clipboardData.setData("text/plain", text);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={SvgEdit}
|
||||
onClick={() => setIsEditing(true)}
|
||||
prominence="tertiary"
|
||||
tooltip="Edit"
|
||||
/>
|
||||
<Text
|
||||
as="p"
|
||||
className="inline-block align-middle"
|
||||
mainContentBody
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="ml-auto rounded-lg p-1">{content}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="md:min-w-[100%] flex justify-end order-1 mt-1">
|
||||
{currentMessageInd !== undefined &&
|
||||
onMessageSelection &&
|
||||
otherMessagesCanSwitchTo &&
|
||||
otherMessagesCanSwitchTo.length > 1 && (
|
||||
<MessageSwitcher
|
||||
disableForStreaming={disableSwitchingForStreaming}
|
||||
currentPage={currentMessageInd + 1}
|
||||
totalPages={otherMessagesCanSwitchTo.length}
|
||||
handlePrevious={() => {
|
||||
stopGenerating();
|
||||
const prevMessage = getPreviousMessage();
|
||||
if (prevMessage !== undefined) {
|
||||
onMessageSelection(prevMessage);
|
||||
}
|
||||
}}
|
||||
handleNext={() => {
|
||||
stopGenerating();
|
||||
const nextMessage = getNextMessage();
|
||||
if (nextMessage !== undefined) {
|
||||
onMessageSelection(nextMessage);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end pt-1">
|
||||
{!isEditing && onEdit && isMobile && copyEditButton}
|
||||
{currentMessageInd !== undefined &&
|
||||
onMessageSelection &&
|
||||
otherMessagesCanSwitchTo &&
|
||||
otherMessagesCanSwitchTo.length > 1 && (
|
||||
<MessageSwitcher
|
||||
disableForStreaming={disableSwitchingForStreaming}
|
||||
currentPage={currentMessageInd + 1}
|
||||
totalPages={otherMessagesCanSwitchTo.length}
|
||||
handlePrevious={() => {
|
||||
stopGenerating();
|
||||
const prevMessage = getPreviousMessage();
|
||||
if (prevMessage !== undefined) {
|
||||
onMessageSelection(prevMessage);
|
||||
}
|
||||
}}
|
||||
handleNext={() => {
|
||||
stopGenerating();
|
||||
const nextMessage = getNextMessage();
|
||||
if (nextMessage !== undefined) {
|
||||
onMessageSelection(nextMessage);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
18
web/src/app/ee/EEFeatureRedirect.tsx
Normal file
18
web/src/app/ee/EEFeatureRedirect.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
|
||||
export default function EEFeatureRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
toast.error(
|
||||
"This feature requires a license. Please upgrade your plan to access."
|
||||
);
|
||||
router.replace("/app");
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED } from "@/lib/constants";
|
||||
import { fetchStandardSettingsSS } from "@/components/settings/lib";
|
||||
import EEFeatureRedirect from "@/app/ee/EEFeatureRedirect";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@@ -8,13 +9,7 @@ export default async function AdminLayout({
|
||||
}) {
|
||||
// First check build-time constant (fast path)
|
||||
if (!SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="mx-auto my-auto text-lg font-bold text-red-500">
|
||||
This functionality is only available in the Enterprise Edition :(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <EEFeatureRedirect />;
|
||||
}
|
||||
|
||||
// Then check runtime license status (for license enforcement mode)
|
||||
@@ -31,13 +26,7 @@ export default async function AdminLayout({
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="mx-auto my-auto text-lg font-bold text-red-500">
|
||||
This functionality requires an active Enterprise license.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <EEFeatureRedirect />;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -484,12 +484,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
ref={chatInputBarRef}
|
||||
deepResearchEnabled={deepResearchEnabled}
|
||||
toggleDeepResearch={toggleDeepResearch}
|
||||
toggleDocumentSidebar={() => {}}
|
||||
filterManager={filterManager}
|
||||
llmManager={llmManager}
|
||||
removeDocs={() => {}}
|
||||
retrievalEnabled={retrievalEnabled}
|
||||
selectedDocuments={[]}
|
||||
initialMessage={message}
|
||||
stopGenerating={stopGenerating}
|
||||
onSubmit={handleChatInputSubmit}
|
||||
|
||||
@@ -23,8 +23,7 @@ export interface AppModeProviderProps {
|
||||
export function AppModeProvider({ children }: AppModeProviderProps) {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { user } = useUser();
|
||||
const settings = useSettingsContext();
|
||||
const { isSearchModeAvailable } = settings;
|
||||
const { isSearchModeAvailable } = useSettingsContext();
|
||||
|
||||
const persistedMode = user?.preferences?.default_app_mode;
|
||||
const [appMode, setAppModeState] = useState<AppMode>("chat");
|
||||
|
||||
@@ -11,21 +11,8 @@ import {
|
||||
* Hook to fetch billing information from Stripe.
|
||||
*
|
||||
* Works for both cloud and self-hosted deployments:
|
||||
* - Cloud: fetches from /api/tenants/billing-information (legacy endpoint)
|
||||
* - Cloud: fetches from /api/tenants/billing-information
|
||||
* - Self-hosted: fetches from /api/admin/billing/billing-information
|
||||
*
|
||||
* Returns subscription status, seats, billing period, etc.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data, isLoading, error, refresh } = useBillingInformation();
|
||||
*
|
||||
* if (isLoading) return <Loading />;
|
||||
* if (error) return <Error />;
|
||||
* if (!data || !hasActiveSubscription(data)) return <NoSubscription />;
|
||||
*
|
||||
* return <BillingDetails billing={data} />;
|
||||
* ```
|
||||
*/
|
||||
export function useBillingInformation() {
|
||||
const url = NEXT_PUBLIC_CLOUD_ENABLED
|
||||
@@ -38,16 +25,9 @@ export function useBillingInformation() {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
// Don't auto-retry on errors (circuit breaker will block requests anyway)
|
||||
shouldRetryOnError: false,
|
||||
// Keep previous data while revalidating to prevent UI flashing
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: mutate,
|
||||
};
|
||||
return { data, isLoading, error, refresh: mutate };
|
||||
}
|
||||
|
||||
@@ -7,23 +7,9 @@ import { LicenseStatus } from "@/lib/billing/interfaces";
|
||||
/**
|
||||
* Hook to fetch license status for self-hosted deployments.
|
||||
*
|
||||
* Returns license information including seats, expiry, and status.
|
||||
* Only fetches for self-hosted deployments (cloud uses tenant auth instead).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data, isLoading, error, refresh } = useLicense();
|
||||
*
|
||||
* if (isLoading) return <Loading />;
|
||||
* if (error) return <Error />;
|
||||
* if (!data?.has_license) return <NoLicense />;
|
||||
*
|
||||
* return <LicenseDetails license={data} />;
|
||||
* ```
|
||||
* Skips the fetch on cloud deployments (uses tenant auth instead).
|
||||
*/
|
||||
export function useLicense() {
|
||||
// Only fetch license for self-hosted deployments
|
||||
// Cloud deployments use tenant-based auth, not license files
|
||||
const url = NEXT_PUBLIC_CLOUD_ENABLED ? null : "/api/license";
|
||||
|
||||
const { data, error, mutate, isLoading } = useSWR<LicenseStatus>(
|
||||
@@ -38,20 +24,14 @@ export function useLicense() {
|
||||
}
|
||||
);
|
||||
|
||||
// Return empty state for cloud deployments
|
||||
if (NEXT_PUBLIC_CLOUD_ENABLED) {
|
||||
if (!url) {
|
||||
return {
|
||||
data: null,
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refresh: () => Promise.resolve(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: mutate,
|
||||
};
|
||||
return { data, isLoading, error, refresh: mutate };
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ export interface Settings {
|
||||
// Onyx Craft (Build Mode) feature flag
|
||||
onyx_craft_enabled?: boolean;
|
||||
|
||||
// Enterprise features flag - controlled by license enforcement at runtime
|
||||
// True when user has a valid license, False for community edition
|
||||
// Whether EE features are unlocked (user has a valid enterprise license).
|
||||
// Controls UI visibility of EE features like user groups, analytics, RBAC.
|
||||
ee_features_enabled?: boolean;
|
||||
|
||||
// Seat usage - populated when seat limit is exceeded
|
||||
|
||||
@@ -190,14 +190,17 @@ function AttachmentItemLayout({
|
||||
alignItems="center"
|
||||
gap={1.5}
|
||||
>
|
||||
<Content
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Content
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
widthVariant="full"
|
||||
/>
|
||||
</div>
|
||||
{middleText && (
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Truncated text03 secondaryBody>
|
||||
{middleText}
|
||||
</Truncated>
|
||||
|
||||
@@ -42,8 +42,13 @@ export const NEXT_PUBLIC_CUSTOM_REFRESH_URL =
|
||||
|
||||
// NOTE: this should ONLY be used on the server-side. If used client side,
|
||||
// it will not be accurate (will always be false).
|
||||
// Mirrors backend logic: EE is enabled if EITHER the legacy flag OR license
|
||||
// enforcement is active. LICENSE_ENFORCEMENT_ENABLED defaults to true on the
|
||||
// backend, so we treat undefined as enabled here to match.
|
||||
export const SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED =
|
||||
process.env.ENABLE_PAID_ENTERPRISE_EDITION_FEATURES?.toLowerCase() === "true";
|
||||
process.env.ENABLE_PAID_ENTERPRISE_EDITION_FEATURES?.toLowerCase() ===
|
||||
"true" ||
|
||||
process.env.LICENSE_ENFORCEMENT_ENABLED?.toLowerCase() !== "false";
|
||||
// NOTE: since this is a `NEXT_PUBLIC_` variable, it will be set at
|
||||
// build-time
|
||||
// TODO: consider moving this to an API call so that the api_server
|
||||
|
||||
@@ -51,16 +51,6 @@ function ToastContainer() {
|
||||
}, ANIMATION_DURATION);
|
||||
}, []);
|
||||
|
||||
// NOTE (@raunakab):
|
||||
//
|
||||
// Keep this here for debugging purposes.
|
||||
// useOnMount(() => {
|
||||
// toast.success("Test success toast", { duration: Infinity });
|
||||
// toast.error("Test error toast", { duration: Infinity });
|
||||
// toast.warning("Test warning toast", { duration: Infinity });
|
||||
// toast.info("Test info toast", { duration: Infinity });
|
||||
// });
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
||||
455
web/src/refresh-components/table/DataTable.tsx
Normal file
455
web/src/refresh-components/table/DataTable.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
"use client";
|
||||
"use no memo";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import useDataTable, {
|
||||
toOnyxSortDirection,
|
||||
} from "@/refresh-components/table/hooks/useDataTable";
|
||||
import useColumnWidths from "@/refresh-components/table/hooks/useColumnWidths";
|
||||
import useDraggableRows from "@/refresh-components/table/hooks/useDraggableRows";
|
||||
import Table from "@/refresh-components/table/Table";
|
||||
import TableHeader from "@/refresh-components/table/TableHeader";
|
||||
import TableBody from "@/refresh-components/table/TableBody";
|
||||
import TableRow from "@/refresh-components/table/TableRow";
|
||||
import TableHead from "@/refresh-components/table/TableHead";
|
||||
import TableCell from "@/refresh-components/table/TableCell";
|
||||
import TableQualifier from "@/refresh-components/table/TableQualifier";
|
||||
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
|
||||
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
|
||||
import DragOverlayRow from "@/refresh-components/table/DragOverlayRow";
|
||||
import Footer from "@/refresh-components/table/Footer";
|
||||
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
|
||||
import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibilityPopover";
|
||||
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
|
||||
import type { WidthConfig } from "@/refresh-components/table/hooks/useColumnWidths";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type {
|
||||
DataTableProps,
|
||||
DataTableFooterConfig,
|
||||
OnyxColumnDef,
|
||||
OnyxDataColumn,
|
||||
OnyxQualifierColumn,
|
||||
OnyxActionsColumn,
|
||||
} from "@/refresh-components/table/types";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
|
||||
const noopGetRowId = () => "";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: resolve size-dependent widths and build TanStack columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProcessedColumns<TData> {
|
||||
tanstackColumns: ColumnDef<TData, any>[];
|
||||
widthConfig: WidthConfig;
|
||||
qualifierColumn: OnyxQualifierColumn<TData> | null;
|
||||
/** Map from column ID → OnyxColumnDef for dispatch in render loops. */
|
||||
columnKindMap: Map<string, OnyxColumnDef<TData>>;
|
||||
}
|
||||
|
||||
function processColumns<TData>(
|
||||
columns: OnyxColumnDef<TData>[],
|
||||
size: TableSize
|
||||
): ProcessedColumns<TData> {
|
||||
const tanstackColumns: ColumnDef<TData, any>[] = [];
|
||||
const fixedColumnIds = new Set<string>();
|
||||
const columnWeights: Record<string, number> = {};
|
||||
const columnMinWidths: Record<string, number> = {};
|
||||
const columnKindMap = new Map<string, OnyxColumnDef<TData>>();
|
||||
let qualifierColumn: OnyxQualifierColumn<TData> | null = null;
|
||||
|
||||
for (const col of columns) {
|
||||
const resolvedWidth =
|
||||
typeof col.width === "function" ? col.width(size) : col.width;
|
||||
|
||||
// Clone def to avoid mutating the caller's column definitions
|
||||
const clonedDef: ColumnDef<TData, any> = {
|
||||
...col.def,
|
||||
id: col.id,
|
||||
size:
|
||||
"fixed" in resolvedWidth ? resolvedWidth.fixed : resolvedWidth.weight,
|
||||
};
|
||||
|
||||
tanstackColumns.push(clonedDef);
|
||||
|
||||
const id = col.id;
|
||||
columnKindMap.set(id, col);
|
||||
|
||||
if ("fixed" in resolvedWidth) {
|
||||
fixedColumnIds.add(id);
|
||||
} else {
|
||||
columnWeights[id] = resolvedWidth.weight;
|
||||
columnMinWidths[id] = resolvedWidth.minWidth ?? 50;
|
||||
}
|
||||
|
||||
if (col.kind === "qualifier") qualifierColumn = col;
|
||||
}
|
||||
|
||||
return {
|
||||
tanstackColumns,
|
||||
widthConfig: { fixedColumnIds, columnWeights, columnMinWidths },
|
||||
qualifierColumn,
|
||||
columnKindMap,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataTable component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Config-driven table component that wires together `useDataTable`,
|
||||
* `useColumnWidths`, and `useDraggableRows` automatically.
|
||||
*
|
||||
* Full flexibility via the column definitions from `createTableColumns()`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const tc = createTableColumns<TeamMember>();
|
||||
* const columns = [
|
||||
* tc.qualifier({ content: "avatar-user", getInitials: (r) => r.initials }),
|
||||
* tc.column("name", { header: "Name", weight: 23, minWidth: 120 }),
|
||||
* tc.column("email", { header: "Email", weight: 28 }),
|
||||
* tc.actions(),
|
||||
* ];
|
||||
*
|
||||
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
|
||||
* ```
|
||||
*/
|
||||
export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
pageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
draggable,
|
||||
footer,
|
||||
size = "regular",
|
||||
onRowClick,
|
||||
height,
|
||||
headerBackground,
|
||||
} = props;
|
||||
|
||||
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
|
||||
|
||||
// 1. Process columns (memoized on columns + size)
|
||||
const { tanstackColumns, widthConfig, qualifierColumn, columnKindMap } =
|
||||
useMemo(() => processColumns(columns, size), [columns, size]);
|
||||
|
||||
// 2. Call useDataTable
|
||||
const {
|
||||
table,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
setPage,
|
||||
pageSize: resolvedPageSize,
|
||||
selectionState,
|
||||
selectedCount,
|
||||
clearSelection,
|
||||
toggleAllPageRowsSelected,
|
||||
isAllPageRowsSelected,
|
||||
} = useDataTable({
|
||||
data,
|
||||
columns: tanstackColumns,
|
||||
pageSize: effectivePageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
});
|
||||
|
||||
// 3. Call useColumnWidths
|
||||
const { containerRef, columnWidths, createResizeHandler } = useColumnWidths({
|
||||
headers: table.getHeaderGroups()[0]?.headers ?? [],
|
||||
...widthConfig,
|
||||
});
|
||||
|
||||
// 4. Call useDraggableRows (conditional)
|
||||
const draggableReturn = useDraggableRows({
|
||||
data,
|
||||
getRowId: draggable?.getRowId ?? noopGetRowId,
|
||||
enabled: !!draggable && table.getState().sorting.length === 0,
|
||||
onReorder: draggable?.onReorder,
|
||||
});
|
||||
|
||||
const hasDraggable = !!draggable;
|
||||
const rowVariant = hasDraggable ? "table" : "list";
|
||||
|
||||
const isSelectable =
|
||||
qualifierColumn != null && qualifierColumn.selectable !== false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderContent() {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="overflow-x-auto"
|
||||
ref={containerRef}
|
||||
style={{
|
||||
...(height != null
|
||||
? {
|
||||
maxHeight:
|
||||
typeof height === "number" ? `${height}px` : height,
|
||||
overflowY: "auto" as const,
|
||||
}
|
||||
: undefined),
|
||||
...(headerBackground
|
||||
? ({
|
||||
"--table-header-bg": headerBackground,
|
||||
} as React.CSSProperties)
|
||||
: undefined),
|
||||
}}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, headerIndex) => {
|
||||
const colDef = columnKindMap.get(header.id);
|
||||
|
||||
// Qualifier header
|
||||
if (colDef?.kind === "qualifier") {
|
||||
if (qualifierColumn?.header === false) {
|
||||
return (
|
||||
<QualifierContainer key={header.id} type="head" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<QualifierContainer key={header.id} type="head">
|
||||
<TableQualifier
|
||||
content={
|
||||
qualifierColumn?.headerContentType ?? "simple"
|
||||
}
|
||||
selectable={isSelectable}
|
||||
selected={isSelectable && isAllPageRowsSelected}
|
||||
onSelectChange={
|
||||
isSelectable
|
||||
? (checked) =>
|
||||
toggleAllPageRowsSelected(checked)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</QualifierContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Actions header
|
||||
if (colDef?.kind === "actions") {
|
||||
const actionsDef = colDef as OnyxActionsColumn<TData>;
|
||||
return (
|
||||
<ActionsContainer key={header.id} type="head">
|
||||
{actionsDef.showColumnVisibility !== false && (
|
||||
<ColumnVisibilityPopover
|
||||
table={table}
|
||||
columnVisibility={
|
||||
table.getState().columnVisibility
|
||||
}
|
||||
size={size}
|
||||
/>
|
||||
)}
|
||||
{actionsDef.showSorting !== false && (
|
||||
<SortingPopover
|
||||
table={table}
|
||||
sorting={table.getState().sorting}
|
||||
size={size}
|
||||
footerText={actionsDef.sortingFooterText}
|
||||
/>
|
||||
)}
|
||||
</ActionsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Data / Display header
|
||||
const canSort = header.column.getCanSort();
|
||||
const sortDir = header.column.getIsSorted();
|
||||
const nextHeader = headerGroup.headers[headerIndex + 1];
|
||||
const canResize =
|
||||
header.column.getCanResize() &&
|
||||
!!nextHeader &&
|
||||
!widthConfig.fixedColumnIds.has(nextHeader.id);
|
||||
|
||||
const dataCol =
|
||||
colDef?.kind === "data"
|
||||
? (colDef as OnyxDataColumn<TData>)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
width={columnWidths[header.id]}
|
||||
sorted={
|
||||
canSort ? toOnyxSortDirection(sortDir) : undefined
|
||||
}
|
||||
onSort={
|
||||
canSort
|
||||
? () => header.column.toggleSorting()
|
||||
: undefined
|
||||
}
|
||||
icon={dataCol?.icon}
|
||||
resizable={canResize}
|
||||
onResizeStart={
|
||||
canResize
|
||||
? createResizeHandler(header.id, nextHeader.id)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody
|
||||
dndSortable={hasDraggable ? draggableReturn : undefined}
|
||||
renderDragOverlay={
|
||||
hasDraggable
|
||||
? (activeId) => {
|
||||
const row = table
|
||||
.getRowModel()
|
||||
.rows.find(
|
||||
(r) => draggable!.getRowId(r.original) === activeId
|
||||
);
|
||||
if (!row) return null;
|
||||
return <DragOverlayRow row={row} variant={rowVariant} />;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const rowId = hasDraggable
|
||||
? draggable!.getRowId(row.original)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
variant={rowVariant}
|
||||
sortableId={rowId}
|
||||
selected={row.getIsSelected()}
|
||||
onClick={() => {
|
||||
if (onRowClick) {
|
||||
onRowClick(row.original);
|
||||
} else if (isSelectable) {
|
||||
row.toggleSelected();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const cellColDef = columnKindMap.get(cell.column.id);
|
||||
|
||||
// Qualifier cell
|
||||
if (cellColDef?.kind === "qualifier") {
|
||||
const qDef = cellColDef as OnyxQualifierColumn<TData>;
|
||||
return (
|
||||
<QualifierContainer
|
||||
key={cell.id}
|
||||
type="cell"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TableQualifier
|
||||
content={qDef.content}
|
||||
initials={qDef.getInitials?.(row.original)}
|
||||
icon={qDef.getIcon?.(row.original)}
|
||||
imageSrc={qDef.getImageSrc?.(row.original)}
|
||||
selectable={isSelectable}
|
||||
selected={isSelectable && row.getIsSelected()}
|
||||
onSelectChange={
|
||||
isSelectable
|
||||
? (checked) => {
|
||||
row.toggleSelected(checked);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</QualifierContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Actions cell
|
||||
if (cellColDef?.kind === "actions") {
|
||||
return (
|
||||
<ActionsContainer key={cell.id} type="cell">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</ActionsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Data / Display cell
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{footer && renderFooter(footer)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFooter(footerConfig: DataTableFooterConfig) {
|
||||
if (footerConfig.mode === "selection") {
|
||||
return (
|
||||
<Footer
|
||||
mode="selection"
|
||||
multiSelect={footerConfig.multiSelect !== false}
|
||||
selectionState={selectionState}
|
||||
selectedCount={selectedCount}
|
||||
onClear={footerConfig.onClear ?? clearSelection}
|
||||
onView={footerConfig.onView}
|
||||
pageSize={resolvedPageSize}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary mode
|
||||
const rangeStart =
|
||||
totalItems === 0
|
||||
? 0
|
||||
: !isFinite(resolvedPageSize)
|
||||
? 1
|
||||
: (currentPage - 1) * resolvedPageSize + 1;
|
||||
const rangeEnd = !isFinite(resolvedPageSize)
|
||||
? totalItems
|
||||
: Math.min(currentPage * resolvedPageSize, totalItems);
|
||||
|
||||
return (
|
||||
<Footer
|
||||
mode="summary"
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TableSizeProvider size={size}>{renderContent()}</TableSizeProvider>;
|
||||
}
|
||||
317
web/src/refresh-components/table/README.md
Normal file
317
web/src/refresh-components/table/README.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# DataTable
|
||||
|
||||
Config-driven table built on [TanStack Table](https://tanstack.com/table). Handles column sizing (weight-based proportional distribution), drag-and-drop row reordering, pagination, row selection, column visibility, and sorting out of the box.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```tsx
|
||||
import DataTable from "@/refresh-components/table/DataTable";
|
||||
import { createTableColumns } from "@/refresh-components/table/columns";
|
||||
|
||||
interface Person {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// Define columns at module scope (stable reference, no re-renders)
|
||||
const tc = createTableColumns<Person>();
|
||||
const columns = [
|
||||
tc.qualifier(),
|
||||
tc.column("name", { header: "Name", weight: 30, minWidth: 120 }),
|
||||
tc.column("email", { header: "Email", weight: 40, minWidth: 150 }),
|
||||
tc.column("role", { header: "Role", weight: 30, minWidth: 80 }),
|
||||
tc.actions(),
|
||||
];
|
||||
|
||||
function PeopleTable({ data }: { data: Person[] }) {
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
pageSize={10}
|
||||
footer={{ mode: "selection" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Column Builder API
|
||||
|
||||
`createTableColumns<TData>()` returns a typed builder with four methods. Each returns an `OnyxColumnDef<TData>` that you pass to the `columns` prop.
|
||||
|
||||
### `tc.qualifier(config?)`
|
||||
|
||||
Leading column for avatars, icons, images, or checkboxes.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `content` | `"simple" \| "icon" \| "image" \| "avatar-icon" \| "avatar-user"` | `"simple"` | Body row content type |
|
||||
| `headerContentType` | same as `content` | `"simple"` | Header row content type |
|
||||
| `getInitials` | `(row: TData) => string` | - | Extract initials (for `"avatar-user"`) |
|
||||
| `getIcon` | `(row: TData) => IconFunctionComponent` | - | Extract icon (for `"icon"` / `"avatar-icon"`) |
|
||||
| `getImageSrc` | `(row: TData) => string` | - | Extract image src (for `"image"`) |
|
||||
| `selectable` | `boolean` | `true` | Show selection checkboxes |
|
||||
| `header` | `boolean` | `true` | Render qualifier content in the header |
|
||||
|
||||
Width is fixed: 56px at `"regular"` size, 40px at `"small"`.
|
||||
|
||||
```ts
|
||||
tc.qualifier({
|
||||
content: "avatar-user",
|
||||
getInitials: (row) => row.initials,
|
||||
})
|
||||
```
|
||||
|
||||
### `tc.column(accessor, config)`
|
||||
|
||||
Data column with sorting, resizing, and hiding. The `accessor` is a type-safe deep key into `TData`.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `header` | `string` | **required** | Column header label |
|
||||
| `cell` | `(value: TValue, row: TData) => ReactNode` | renders value as string | Custom cell renderer |
|
||||
| `enableSorting` | `boolean` | `true` | Allow sorting |
|
||||
| `enableResizing` | `boolean` | `true` | Allow column resize |
|
||||
| `enableHiding` | `boolean` | `true` | Allow hiding via actions popover |
|
||||
| `icon` | `(sorted: SortDirection) => IconFunctionComponent` | - | Override the sort indicator icon |
|
||||
| `weight` | `number` | `20` | Proportional width weight |
|
||||
| `minWidth` | `number` | `50` | Minimum width in pixels |
|
||||
|
||||
```ts
|
||||
tc.column("email", {
|
||||
header: "Email",
|
||||
weight: 28,
|
||||
minWidth: 150,
|
||||
cell: (value) => <Content sizePreset="main-ui" variant="body" title={value} prominence="muted" />,
|
||||
})
|
||||
```
|
||||
|
||||
### `tc.displayColumn(config)`
|
||||
|
||||
Non-accessor column for custom content (e.g. computed values, action buttons per row).
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `string` | **required** | Unique column ID |
|
||||
| `header` | `string` | - | Optional header label |
|
||||
| `cell` | `(row: TData) => ReactNode` | **required** | Cell renderer |
|
||||
| `width` | `ColumnWidth` | **required** | `{ weight, minWidth? }` or `{ fixed }` |
|
||||
| `enableHiding` | `boolean` | `true` | Allow hiding |
|
||||
|
||||
```ts
|
||||
tc.displayColumn({
|
||||
id: "fullName",
|
||||
header: "Full Name",
|
||||
cell: (row) => `${row.firstName} ${row.lastName}`,
|
||||
width: { weight: 25, minWidth: 100 },
|
||||
})
|
||||
```
|
||||
|
||||
### `tc.actions(config?)`
|
||||
|
||||
Fixed-width column rendered at the trailing edge. Houses column visibility and sorting popovers in the header.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `showColumnVisibility` | `boolean` | `true` | Show the column visibility popover |
|
||||
| `showSorting` | `boolean` | `true` | Show the sorting popover |
|
||||
| `sortingFooterText` | `string` | - | Footer text inside the sorting popover |
|
||||
|
||||
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
|
||||
|
||||
```ts
|
||||
tc.actions({
|
||||
sortingFooterText: "Everyone will see agents in this order.",
|
||||
})
|
||||
```
|
||||
|
||||
## DataTable Props
|
||||
|
||||
`DataTableProps<TData>`:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `data` | `TData[]` | **required** | Row data |
|
||||
| `columns` | `OnyxColumnDef<TData>[]` | **required** | Columns from `createTableColumns()` |
|
||||
| `pageSize` | `number` | `10` (with footer) or `data.length` (without) | Rows per page. `Infinity` disables pagination |
|
||||
| `initialSorting` | `SortingState` | `[]` | TanStack sorting state |
|
||||
| `initialColumnVisibility` | `VisibilityState` | `{}` | Map of column ID to `false` to hide initially |
|
||||
| `draggable` | `DataTableDraggableConfig<TData>` | - | Enable drag-and-drop (see below) |
|
||||
| `footer` | `DataTableFooterConfig` | - | Footer mode (see below) |
|
||||
| `size` | `"regular" \| "small"` | `"regular"` | Table density variant |
|
||||
| `onRowClick` | `(row: TData) => void` | toggles selection | Called on row click, replaces default selection toggle |
|
||||
| `height` | `number \| string` | - | Max height for scrollable body (header stays pinned). `300` or `"50vh"` |
|
||||
| `headerBackground` | `string` | - | CSS color for the sticky header (prevents content showing through) |
|
||||
|
||||
## Footer Config
|
||||
|
||||
The `footer` prop accepts a discriminated union on `mode`.
|
||||
|
||||
### Selection mode
|
||||
|
||||
For tables with selectable rows. Shows a selection message + count pagination.
|
||||
|
||||
```ts
|
||||
footer={{
|
||||
mode: "selection",
|
||||
multiSelect: true, // default true
|
||||
onView: () => { ... }, // optional "View" button
|
||||
onClear: () => { ... }, // optional "Clear" button (falls back to default clearSelection)
|
||||
}}
|
||||
```
|
||||
|
||||
### Summary mode
|
||||
|
||||
For read-only tables. Shows "Showing X~Y of Z" + list pagination.
|
||||
|
||||
```ts
|
||||
footer={{ mode: "summary" }}
|
||||
```
|
||||
|
||||
## Draggable Config
|
||||
|
||||
Enable drag-and-drop row reordering. DnD is automatically disabled when column sorting is active.
|
||||
|
||||
```ts
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
draggable={{
|
||||
getRowId: (row) => row.id,
|
||||
onReorder: (ids, changedOrders) => {
|
||||
// ids: new ordered array of all row IDs
|
||||
// changedOrders: { [id]: newIndex } for rows that moved
|
||||
setItems(ids.map((id) => items.find((r) => r.id === id)!));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
|---|---|---|
|
||||
| `getRowId` | `(row: TData) => string` | Extract a unique string ID from each row |
|
||||
| `onReorder` | `(ids: string[], changedOrders: Record<string, number>) => void \| Promise<void>` | Called after a successful reorder |
|
||||
|
||||
## Sizing
|
||||
|
||||
The `size` prop (`"regular"` or `"small"`) affects:
|
||||
|
||||
- Qualifier column width (56px vs 40px)
|
||||
- Actions column width (88px vs 20px)
|
||||
- Footer text styles and pagination size
|
||||
- All child components via `TableSizeContext`
|
||||
|
||||
Column widths can be responsive to size using a function:
|
||||
|
||||
```ts
|
||||
// In types.ts, width accepts:
|
||||
width: ColumnWidth | ((size: TableSize) => ColumnWidth)
|
||||
|
||||
// Example (this is what qualifier/actions use internally):
|
||||
width: (size) => size === "small" ? { fixed: 40 } : { fixed: 56 }
|
||||
```
|
||||
|
||||
### Width system
|
||||
|
||||
Data columns use **weight-based proportional distribution**. A column with `weight: 40` gets twice the space of one with `weight: 20`. When the container is narrower than the sum of `minWidth` values, columns clamp to their minimums.
|
||||
|
||||
Fixed columns (`{ fixed: N }`) take exactly N pixels and don't participate in proportional distribution.
|
||||
|
||||
Resizing uses **splitter semantics**: dragging a column border grows that column and shrinks its neighbor by the same amount, keeping total width constant.
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Scrollable table with pinned header
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
data={allRows}
|
||||
columns={columns}
|
||||
height={300}
|
||||
headerBackground="var(--background-tint-00)"
|
||||
/>
|
||||
```
|
||||
|
||||
### Hidden columns on load
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
initialColumnVisibility={{ department: false, joinDate: false }}
|
||||
footer={{ mode: "selection" }}
|
||||
/>
|
||||
```
|
||||
|
||||
### Icon-based data column
|
||||
|
||||
```tsx
|
||||
const STATUS_ICONS = {
|
||||
active: SvgCheckCircle,
|
||||
pending: SvgClock,
|
||||
inactive: SvgAlertCircle,
|
||||
} as const;
|
||||
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
minWidth: 80,
|
||||
cell: (value) => (
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
icon={STATUS_ICONS[value]}
|
||||
title={value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
/>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
### Non-selectable qualifier with icons
|
||||
|
||||
```ts
|
||||
tc.qualifier({
|
||||
content: "icon",
|
||||
getIcon: (row) => row.icon,
|
||||
selectable: false,
|
||||
header: false,
|
||||
})
|
||||
```
|
||||
|
||||
### Small variant in a bordered container
|
||||
|
||||
```tsx
|
||||
<div className="border border-border-01 rounded-lg overflow-hidden">
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
size="small"
|
||||
pageSize={10}
|
||||
footer={{ mode: "selection" }}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Custom row click handler
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
onRowClick={(row) => router.push(`/users/${row.id}`)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Source Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `DataTable.tsx` | Main component |
|
||||
| `columns.ts` | `createTableColumns` builder |
|
||||
| `types.ts` | All TypeScript interfaces |
|
||||
| `hooks/useDataTable.ts` | TanStack table wrapper hook |
|
||||
| `hooks/useColumnWidths.ts` | Weight-based width system |
|
||||
| `hooks/useDraggableRows.ts` | DnD hook (`@dnd-kit`) |
|
||||
| `Footer.tsx` | Selection / Summary footer modes |
|
||||
| `TableSizeContext.tsx` | Size context provider |
|
||||
@@ -830,12 +830,8 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
ref={chatInputBarRef}
|
||||
deepResearchEnabled={deepResearchEnabled}
|
||||
toggleDeepResearch={toggleDeepResearch}
|
||||
toggleDocumentSidebar={toggleDocumentSidebar}
|
||||
filterManager={filterManager}
|
||||
llmManager={llmManager}
|
||||
removeDocs={() => setSelectedDocuments([])}
|
||||
retrievalEnabled={retrievalEnabled}
|
||||
selectedDocuments={selectedDocuments}
|
||||
initialMessage={
|
||||
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) ||
|
||||
""
|
||||
|
||||
@@ -173,19 +173,21 @@ export function FileCard({
|
||||
removeFile && doneUploading ? () => removeFile(file.id) : undefined
|
||||
}
|
||||
>
|
||||
<div className="max-w-[12rem]">
|
||||
<div className="min-w-0 max-w-[12rem]">
|
||||
<Interactive.Container border heightVariant="fit">
|
||||
<AttachmentItemLayout
|
||||
icon={isProcessing ? SimpleLoader : SvgFileText}
|
||||
title={file.name}
|
||||
description={
|
||||
isProcessing
|
||||
? file.status === UserFileStatus.UPLOADING
|
||||
? "Uploading..."
|
||||
: "Processing..."
|
||||
: typeLabel
|
||||
}
|
||||
/>
|
||||
<div className="[&_.opal-content-md-body]:min-w-0 [&_.opal-content-md-title]:break-all">
|
||||
<AttachmentItemLayout
|
||||
icon={isProcessing ? SimpleLoader : SvgFileText}
|
||||
title={file.name}
|
||||
description={
|
||||
isProcessing
|
||||
? file.status === UserFileStatus.UPLOADING
|
||||
? "Uploading..."
|
||||
: "Processing..."
|
||||
: typeLabel
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Spacer horizontal rem={0.5} />
|
||||
</Interactive.Container>
|
||||
</div>
|
||||
|
||||
@@ -16,16 +16,18 @@ import { FilterManager, LlmManager, useFederatedConnectors } from "@/lib/hooks";
|
||||
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
|
||||
import useFilter from "@/hooks/useFilter";
|
||||
import useCCPairs from "@/hooks/useCCPairs";
|
||||
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { ChatState } from "@/app/app/interfaces";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import { useAppMode } from "@/providers/AppModeProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { getFormattedDateRangeString } from "@/lib/dateUtils";
|
||||
import { truncateString, cn, isImageFile } from "@/lib/utils";
|
||||
import { cn, isImageFile } from "@/lib/utils";
|
||||
import { Disabled } from "@/refresh-components/Disabled";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { SettingsContext } from "@/providers/SettingsProvider";
|
||||
import {
|
||||
SettingsContext,
|
||||
useVectorDbEnabled,
|
||||
} from "@/providers/SettingsProvider";
|
||||
import { useProjectsContext } from "@/providers/ProjectsContext";
|
||||
import { FileCard } from "@/sections/cards/FileCard";
|
||||
import {
|
||||
@@ -40,9 +42,6 @@ import {
|
||||
} from "@/app/app/services/actionUtils";
|
||||
import {
|
||||
SvgArrowUp,
|
||||
SvgCalendar,
|
||||
SvgFiles,
|
||||
SvgFileText,
|
||||
SvgGlobe,
|
||||
SvgHourglass,
|
||||
SvgPlus,
|
||||
@@ -51,64 +50,22 @@ import {
|
||||
SvgStop,
|
||||
SvgX,
|
||||
} from "@opal/icons";
|
||||
import { Button, OpenButton } from "@opal/components";
|
||||
import { Button } from "@opal/components";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
|
||||
const LINE_HEIGHT = 24;
|
||||
const MIN_INPUT_HEIGHT = 44;
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
|
||||
export interface SourceChipProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
truncateTitle?: boolean;
|
||||
}
|
||||
|
||||
export function SourceChip({
|
||||
icon,
|
||||
title,
|
||||
onRemove,
|
||||
onClick,
|
||||
truncateTitle = true,
|
||||
}: SourceChipProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick ? onClick : undefined}
|
||||
className={cn(
|
||||
"flex-none flex items-center px-1 bg-background-neutral-01 text-xs text-text-04 border border-border-01 rounded-08 box-border gap-x-1 h-6",
|
||||
onClick && "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{truncateTitle ? truncateString(title, 20) : title}
|
||||
{onRemove && (
|
||||
<SvgX
|
||||
size={12}
|
||||
className="text-text-01 ml-auto cursor-pointer"
|
||||
onClick={(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AppInputBarHandle {
|
||||
reset: () => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export interface AppInputBarProps {
|
||||
removeDocs: () => void;
|
||||
selectedDocuments: OnyxDocument[];
|
||||
initialMessage?: string;
|
||||
stopGenerating: () => void;
|
||||
onSubmit: (message: string) => void;
|
||||
@@ -120,10 +77,8 @@ export interface AppInputBarProps {
|
||||
// agents
|
||||
selectedAgent: MinimalPersonaSnapshot | undefined;
|
||||
|
||||
toggleDocumentSidebar: () => void;
|
||||
handleFileUpload: (files: File[]) => void;
|
||||
filterManager: FilterManager;
|
||||
retrievalEnabled: boolean;
|
||||
deepResearchEnabled: boolean;
|
||||
setPresentingDocument?: (document: MinimalOnyxDocument) => void;
|
||||
toggleDeepResearch: () => void;
|
||||
@@ -137,18 +92,13 @@ export interface AppInputBarProps {
|
||||
|
||||
const AppInputBar = React.memo(
|
||||
({
|
||||
retrievalEnabled,
|
||||
removeDocs,
|
||||
toggleDocumentSidebar,
|
||||
filterManager,
|
||||
selectedDocuments,
|
||||
initialMessage = "",
|
||||
stopGenerating,
|
||||
onSubmit,
|
||||
chatState,
|
||||
currentSessionFileTokenCount,
|
||||
availableContextTokens,
|
||||
// agents
|
||||
selectedAgent,
|
||||
|
||||
handleFileUpload,
|
||||
@@ -165,6 +115,9 @@ const AppInputBar = React.memo(
|
||||
// Internal message state - kept local to avoid parent re-renders on every keystroke
|
||||
const [message, setMessage] = useState(initialMessage);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const textAreaWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const filesWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const filesContentRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { user } = useUser();
|
||||
const { isClassifying, classification } = useQueryController();
|
||||
@@ -178,6 +131,16 @@ const AppInputBar = React.memo(
|
||||
textAreaRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
// Sync non-empty prop changes to internal state (e.g. NRFPage reads URL params
|
||||
// after mount). Intentionally skips empty strings — clearing is handled via the
|
||||
// imperative ref.reset() method, not by passing initialMessage="".
|
||||
useEffect(() => {
|
||||
if (initialMessage) {
|
||||
setMessage(initialMessage);
|
||||
}
|
||||
}, [initialMessage]);
|
||||
|
||||
const { appMode } = useAppMode();
|
||||
const appFocus = useAppFocus();
|
||||
const isSearchMode =
|
||||
@@ -227,46 +190,39 @@ const AppInputBar = React.memo(
|
||||
|
||||
const combinedSettings = useContext(SettingsContext);
|
||||
|
||||
// Track previous message to detect when lines might decrease
|
||||
const prevMessageRef = useRef("");
|
||||
|
||||
// Auto-resize textarea based on content
|
||||
// TODO(@raunakab): Replace this useEffect with CSS `field-sizing: content` once
|
||||
// Firefox ships it unflagged (currently behind `layout.css.field-sizing.enabled`).
|
||||
// Auto-resize textarea based on content (chat mode only).
|
||||
// Reset to min-height first so scrollHeight reflects actual content size,
|
||||
// then clamp between min and max. This handles both growing and shrinking.
|
||||
useEffect(() => {
|
||||
if (isSearchMode) return;
|
||||
const wrapper = textAreaWrapperRef.current;
|
||||
const textarea = textAreaRef.current;
|
||||
if (textarea) {
|
||||
const prevLineCount = (prevMessageRef.current.match(/\n/g) || [])
|
||||
.length;
|
||||
const currLineCount = (message.match(/\n/g) || []).length;
|
||||
const lineRemoved = currLineCount < prevLineCount;
|
||||
prevMessageRef.current = message;
|
||||
if (!wrapper || !textarea) return;
|
||||
|
||||
if (message.length === 0) {
|
||||
textarea.style.height = `${MIN_INPUT_HEIGHT}px`;
|
||||
return;
|
||||
} else if (lineRemoved) {
|
||||
const linesRemoved = prevLineCount - currLineCount;
|
||||
textarea.style.height = `${Math.max(
|
||||
MIN_INPUT_HEIGHT,
|
||||
Math.min(
|
||||
textarea.scrollHeight - LINE_HEIGHT * linesRemoved,
|
||||
MAX_INPUT_HEIGHT
|
||||
)
|
||||
)}px`;
|
||||
} else {
|
||||
textarea.style.height = `${Math.min(
|
||||
textarea.scrollHeight,
|
||||
MAX_INPUT_HEIGHT
|
||||
)}px`;
|
||||
}
|
||||
}
|
||||
wrapper.style.height = `${MIN_INPUT_HEIGHT}px`;
|
||||
wrapper.style.height = `${Math.min(
|
||||
Math.max(textarea.scrollHeight, MIN_INPUT_HEIGHT),
|
||||
MAX_INPUT_HEIGHT
|
||||
)}px`;
|
||||
}, [message, isSearchMode]);
|
||||
|
||||
// Animate attached files wrapper to its content height so CSS transitions
|
||||
// can interpolate between concrete pixel values (0px ↔ Npx).
|
||||
const showFiles = !isSearchMode && currentMessageFiles.length > 0;
|
||||
useEffect(() => {
|
||||
if (initialMessage) {
|
||||
setMessage(initialMessage);
|
||||
const wrapper = filesWrapperRef.current;
|
||||
const content = filesContentRef.current;
|
||||
if (!wrapper || !content) return;
|
||||
|
||||
if (showFiles) {
|
||||
// Measure the inner content's actual height, then add padding (p-1 = 8px total)
|
||||
const PADDING = 8;
|
||||
wrapper.style.height = `${content.offsetHeight + PADDING}px`;
|
||||
} else {
|
||||
wrapper.style.height = "0px";
|
||||
}
|
||||
}, [initialMessage]);
|
||||
}, [showFiles, currentMessageFiles]);
|
||||
|
||||
function handlePaste(event: React.ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
@@ -294,8 +250,7 @@ const AppInputBar = React.memo(
|
||||
);
|
||||
|
||||
const { activePromptShortcuts } = usePromptShortcuts();
|
||||
const vectorDbEnabled =
|
||||
combinedSettings?.settings.vector_db_enabled !== false;
|
||||
const vectorDbEnabled = useVectorDbEnabled();
|
||||
const { ccPairs, isLoading: ccPairsLoading } = useCCPairs(vectorDbEnabled);
|
||||
const { data: federatedConnectorsData, isLoading: federatedLoading } =
|
||||
useFederatedConnectors();
|
||||
@@ -412,7 +367,9 @@ const AppInputBar = React.memo(
|
||||
combinedSettings?.settings?.deep_research_enabled,
|
||||
]);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
function handleKeyDownForPromptShortcuts(
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>
|
||||
) {
|
||||
if (!user?.preferences?.shortcut_enabled || !showPrompts) return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
@@ -447,6 +404,171 @@ const AppInputBar = React.memo(
|
||||
}
|
||||
}
|
||||
|
||||
const chatControls = (
|
||||
<div
|
||||
{...(isSearchMode ? { inert: true } : {})}
|
||||
className={cn(
|
||||
"flex justify-between items-center w-full",
|
||||
isSearchMode
|
||||
? "opacity-0 p-0 h-0 overflow-hidden pointer-events-none"
|
||||
: "opacity-100 p-1 h-[2.75rem] pointer-events-auto",
|
||||
"transition-all duration-150"
|
||||
)}
|
||||
>
|
||||
{/* Bottom left controls */}
|
||||
<div className="flex flex-row items-center">
|
||||
{/* (+) button - always visible */}
|
||||
<FilePickerPopover
|
||||
onFileClick={handleFileClick}
|
||||
onPickRecent={(file: ProjectFile) => {
|
||||
// Check if file with same ID already exists
|
||||
if (
|
||||
!currentMessageFiles.some(
|
||||
(existingFile) => existingFile.file_id === file.file_id
|
||||
)
|
||||
) {
|
||||
setCurrentMessageFiles((prev) => [...prev, file]);
|
||||
}
|
||||
}}
|
||||
onUnpickRecent={(file: ProjectFile) => {
|
||||
setCurrentMessageFiles((prev) =>
|
||||
prev.filter(
|
||||
(existingFile) => existingFile.file_id !== file.file_id
|
||||
)
|
||||
);
|
||||
}}
|
||||
handleUploadChange={handleUploadChange}
|
||||
trigger={(open) => (
|
||||
<Button
|
||||
icon={SvgPlusCircle}
|
||||
tooltip="Attach Files"
|
||||
transient={open}
|
||||
disabled={disabled}
|
||||
prominence="tertiary"
|
||||
/>
|
||||
)}
|
||||
selectedFileIds={currentMessageFiles.map((f) => f.id)}
|
||||
/>
|
||||
|
||||
{/* Controls that load in when data is ready */}
|
||||
<div
|
||||
data-testid="actions-container"
|
||||
className={cn(
|
||||
"flex flex-row items-center",
|
||||
controlsLoading && "invisible"
|
||||
)}
|
||||
>
|
||||
{selectedAgent && selectedAgent.tools.length > 0 && (
|
||||
<ActionsPopover
|
||||
selectedAgent={selectedAgent}
|
||||
filterManager={filterManager}
|
||||
availableSources={memoizedAvailableSources}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{onToggleTabReading ? (
|
||||
<Button
|
||||
icon={SvgGlobe}
|
||||
onClick={onToggleTabReading}
|
||||
variant="select"
|
||||
selected={tabReadingEnabled}
|
||||
foldable={!tabReadingEnabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tabReadingEnabled
|
||||
? currentTabUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(currentTabUrl).hostname;
|
||||
} catch {
|
||||
return currentTabUrl;
|
||||
}
|
||||
})()
|
||||
: "Reading tab..."
|
||||
: "Read this tab"}
|
||||
</Button>
|
||||
) : (
|
||||
showDeepResearch && (
|
||||
<Button
|
||||
icon={SvgHourglass}
|
||||
onClick={toggleDeepResearch}
|
||||
variant="select"
|
||||
selected={deepResearchEnabled}
|
||||
foldable={!deepResearchEnabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
Deep Research
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{selectedAgent &&
|
||||
forcedToolIds.length > 0 &&
|
||||
forcedToolIds.map((toolId) => {
|
||||
const tool = selectedAgent.tools.find(
|
||||
(tool) => tool.id === toolId
|
||||
);
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={toolId}
|
||||
icon={getIconForAction(tool)}
|
||||
onClick={() => {
|
||||
setForcedToolIds(
|
||||
forcedToolIds.filter((id) => id !== toolId)
|
||||
);
|
||||
}}
|
||||
variant="select"
|
||||
selected
|
||||
disabled={disabled}
|
||||
>
|
||||
{tool.display_name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom right controls */}
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div
|
||||
data-testid="AppInputBar/llm-popover-trigger"
|
||||
className={cn(controlsLoading && "invisible")}
|
||||
>
|
||||
<LLMPopover
|
||||
llmManager={llmManager}
|
||||
requiresImageInput={hasImageFiles}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
id="onyx-chat-input-send-button"
|
||||
icon={
|
||||
isClassifying
|
||||
? SimpleLoader
|
||||
: chatState === "input"
|
||||
? SvgArrowUp
|
||||
: SvgStop
|
||||
}
|
||||
disabled={
|
||||
(chatState === "input" && !message) ||
|
||||
hasUploadingFiles ||
|
||||
isClassifying
|
||||
}
|
||||
onClick={() => {
|
||||
if (chatState == "streaming") {
|
||||
stopGenerating();
|
||||
} else if (message) {
|
||||
onSubmit(message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Disabled disabled={disabled} allowClick>
|
||||
<div
|
||||
@@ -467,8 +589,17 @@ const AppInputBar = React.memo(
|
||||
)}
|
||||
>
|
||||
{/* Attached Files */}
|
||||
{currentMessageFiles.length > 0 && (
|
||||
<div className="p-2 rounded-t-16 flex flex-wrap gap-1">
|
||||
<div
|
||||
ref={filesWrapperRef}
|
||||
{...(!showFiles ? { inert: true } : {})}
|
||||
className={cn(
|
||||
"transition-all duration-150",
|
||||
showFiles
|
||||
? "opacity-100 p-1"
|
||||
: "opacity-0 p-0 overflow-hidden pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<div ref={filesContentRef} className="flex flex-wrap gap-1">
|
||||
{currentMessageFiles.map((file) => (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
@@ -480,76 +611,61 @@ const AppInputBar = React.memo(
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center w-full",
|
||||
isSearchMode && "p-1"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center w-full">
|
||||
<Popover
|
||||
open={user?.preferences?.shortcut_enabled && showPrompts}
|
||||
onOpenChange={setShowPrompts}
|
||||
>
|
||||
<Popover.Anchor asChild>
|
||||
<textarea
|
||||
onPaste={handlePaste}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
ref={textAreaRef}
|
||||
id="onyx-chat-input-textarea"
|
||||
className={cn(
|
||||
"w-full",
|
||||
"outline-none",
|
||||
"bg-transparent",
|
||||
"resize-none",
|
||||
"placeholder:text-text-03",
|
||||
"whitespace-pre-wrap",
|
||||
"break-word",
|
||||
"overscroll-contain",
|
||||
"px-3",
|
||||
isSearchMode
|
||||
? "h-[40px] py-2.5 overflow-hidden"
|
||||
: [
|
||||
"h-[44px]", // Fixed initial height to prevent flash - useEffect will adjust as needed
|
||||
"overflow-y-auto",
|
||||
"pb-2",
|
||||
"pt-3",
|
||||
]
|
||||
)}
|
||||
autoFocus
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
role="textarea"
|
||||
aria-multiline
|
||||
placeholder={
|
||||
isSearchMode
|
||||
? "Search connected sources"
|
||||
: "How can I help you today"
|
||||
}
|
||||
value={message}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!showPrompts &&
|
||||
!event.shiftKey &&
|
||||
!(event.nativeEvent as any).isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
message &&
|
||||
!disabled &&
|
||||
!isClassifying &&
|
||||
!hasUploadingFiles
|
||||
) {
|
||||
onSubmit(message);
|
||||
}
|
||||
<div
|
||||
ref={textAreaWrapperRef}
|
||||
className="px-3 py-2 flex-1 flex h-[2.75rem]"
|
||||
>
|
||||
<textarea
|
||||
id="onyx-chat-input-textarea"
|
||||
role="textarea"
|
||||
ref={textAreaRef}
|
||||
onPaste={handlePaste}
|
||||
onKeyDownCapture={handleKeyDownForPromptShortcuts}
|
||||
onChange={handleInputChange}
|
||||
className={cn(
|
||||
"p-[2px] w-full h-full outline-none bg-transparent resize-none placeholder:text-text-03 whitespace-pre-wrap break-words",
|
||||
"overflow-y-auto"
|
||||
)}
|
||||
autoFocus
|
||||
rows={1}
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
aria-multiline={true}
|
||||
placeholder={
|
||||
isSearchMode
|
||||
? "Search connected sources"
|
||||
: "How can I help you today?"
|
||||
}
|
||||
}}
|
||||
suppressContentEditableWarning={true}
|
||||
disabled={disabled}
|
||||
/>
|
||||
value={message}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!showPrompts &&
|
||||
!event.shiftKey &&
|
||||
!(event.nativeEvent as any).isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
message &&
|
||||
!disabled &&
|
||||
!isClassifying &&
|
||||
!hasUploadingFiles
|
||||
) {
|
||||
onSubmit(message);
|
||||
}
|
||||
}
|
||||
}}
|
||||
suppressContentEditableWarning={true}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Anchor>
|
||||
|
||||
<Popover.Content
|
||||
@@ -616,214 +732,7 @@ const AppInputBar = React.memo(
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source chips */}
|
||||
{(selectedDocuments.length > 0 ||
|
||||
filterManager.timeRange ||
|
||||
filterManager.selectedDocumentSets.length > 0) && (
|
||||
<div className="flex gap-x-.5 px-2">
|
||||
<div className="flex gap-x-1 px-2 overflow-visible overflow-x-scroll items-end miniscroll">
|
||||
{filterManager.timeRange && (
|
||||
<SourceChip
|
||||
truncateTitle={false}
|
||||
key="time-range"
|
||||
icon={<SvgCalendar size={12} />}
|
||||
title={`${getFormattedDateRangeString(
|
||||
filterManager.timeRange.from,
|
||||
filterManager.timeRange.to
|
||||
)}`}
|
||||
onRemove={() => {
|
||||
filterManager.setTimeRange(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filterManager.selectedDocumentSets.length > 0 &&
|
||||
filterManager.selectedDocumentSets.map((docSet, index) => (
|
||||
<SourceChip
|
||||
key={`doc-set-${index}`}
|
||||
icon={<SvgFiles size={16} />}
|
||||
title={docSet}
|
||||
onRemove={() => {
|
||||
filterManager.setSelectedDocumentSets(
|
||||
filterManager.selectedDocumentSets.filter(
|
||||
(ds) => ds !== docSet
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{selectedDocuments.length > 0 && (
|
||||
<SourceChip
|
||||
key="selected-documents"
|
||||
onClick={() => {
|
||||
toggleDocumentSidebar();
|
||||
}}
|
||||
icon={<SvgFileText size={16} />}
|
||||
title={`${selectedDocuments.length} selected`}
|
||||
onRemove={removeDocs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearchMode && (
|
||||
<div className="flex justify-between items-center w-full p-1 min-h-[40px]">
|
||||
{/* Bottom left controls */}
|
||||
<div className="flex flex-row items-center">
|
||||
{/* (+) button - always visible */}
|
||||
<FilePickerPopover
|
||||
onFileClick={handleFileClick}
|
||||
onPickRecent={(file: ProjectFile) => {
|
||||
// Check if file with same ID already exists
|
||||
if (
|
||||
!currentMessageFiles.some(
|
||||
(existingFile) => existingFile.file_id === file.file_id
|
||||
)
|
||||
) {
|
||||
setCurrentMessageFiles((prev) => [...prev, file]);
|
||||
}
|
||||
}}
|
||||
onUnpickRecent={(file: ProjectFile) => {
|
||||
setCurrentMessageFiles((prev) =>
|
||||
prev.filter(
|
||||
(existingFile) => existingFile.file_id !== file.file_id
|
||||
)
|
||||
);
|
||||
}}
|
||||
handleUploadChange={handleUploadChange}
|
||||
trigger={(open) => (
|
||||
<Button
|
||||
icon={SvgPlusCircle}
|
||||
tooltip="Attach Files"
|
||||
transient={open}
|
||||
disabled={disabled}
|
||||
prominence="tertiary"
|
||||
/>
|
||||
)}
|
||||
selectedFileIds={currentMessageFiles.map((f) => f.id)}
|
||||
/>
|
||||
|
||||
{/* Controls that load in when data is ready */}
|
||||
<div
|
||||
data-testid="actions-container"
|
||||
className={cn(
|
||||
"flex flex-row items-center",
|
||||
controlsLoading && "invisible"
|
||||
)}
|
||||
>
|
||||
{selectedAgent && selectedAgent.tools.length > 0 && (
|
||||
<ActionsPopover
|
||||
selectedAgent={selectedAgent}
|
||||
filterManager={filterManager}
|
||||
availableSources={memoizedAvailableSources}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{onToggleTabReading ? (
|
||||
<Button
|
||||
icon={SvgGlobe}
|
||||
onClick={onToggleTabReading}
|
||||
variant="select"
|
||||
selected={tabReadingEnabled}
|
||||
foldable={!tabReadingEnabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tabReadingEnabled
|
||||
? currentTabUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(currentTabUrl).hostname;
|
||||
} catch {
|
||||
return currentTabUrl;
|
||||
}
|
||||
})()
|
||||
: "Reading tab..."
|
||||
: "Read this tab"}
|
||||
</Button>
|
||||
) : (
|
||||
showDeepResearch && (
|
||||
<Button
|
||||
icon={SvgHourglass}
|
||||
onClick={toggleDeepResearch}
|
||||
variant="select"
|
||||
selected={deepResearchEnabled}
|
||||
foldable={!deepResearchEnabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
Deep Research
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{selectedAgent &&
|
||||
forcedToolIds.length > 0 &&
|
||||
forcedToolIds.map((toolId) => {
|
||||
const tool = selectedAgent.tools.find(
|
||||
(tool) => tool.id === toolId
|
||||
);
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={toolId}
|
||||
icon={getIconForAction(tool)}
|
||||
onClick={() => {
|
||||
setForcedToolIds(
|
||||
forcedToolIds.filter((id) => id !== toolId)
|
||||
);
|
||||
}}
|
||||
variant="select"
|
||||
selected
|
||||
disabled={disabled}
|
||||
>
|
||||
{tool.display_name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom right controls */}
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{/* LLM popover - loads when ready */}
|
||||
<div
|
||||
data-testid="AppInputBar/llm-popover-trigger"
|
||||
className={cn(controlsLoading && "invisible")}
|
||||
>
|
||||
<LLMPopover
|
||||
llmManager={llmManager}
|
||||
requiresImageInput={hasImageFiles}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
id="onyx-chat-input-send-button"
|
||||
icon={
|
||||
isClassifying
|
||||
? SimpleLoader
|
||||
: chatState === "input"
|
||||
? SvgArrowUp
|
||||
: SvgStop
|
||||
}
|
||||
disabled={
|
||||
(chatState === "input" && !message) ||
|
||||
hasUploadingFiles ||
|
||||
isClassifying
|
||||
}
|
||||
onClick={() => {
|
||||
if (chatState == "streaming") {
|
||||
stopGenerating();
|
||||
} else if (message) {
|
||||
onSubmit(message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chatControls}
|
||||
</div>
|
||||
</Disabled>
|
||||
);
|
||||
|
||||
@@ -116,8 +116,6 @@ function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_DOCS: [] = [];
|
||||
|
||||
/**
|
||||
* Floating ChatInputBar below the AgentViewerModal.
|
||||
* On submit, navigates to the agent's chat with the message pre-filled.
|
||||
@@ -137,14 +135,10 @@ function AgentChatInput({ agent, onSubmit }: AgentChatInputProps) {
|
||||
chatState="input"
|
||||
filterManager={filterManager}
|
||||
selectedAgent={agent}
|
||||
selectedDocuments={EMPTY_DOCS}
|
||||
removeDocs={() => {}}
|
||||
stopGenerating={() => {}}
|
||||
handleFileUpload={() => {}}
|
||||
toggleDocumentSidebar={() => {}}
|
||||
currentSessionFileTokenCount={0}
|
||||
availableContextTokens={Infinity}
|
||||
retrievalEnabled={false}
|
||||
deepResearchEnabled={false}
|
||||
toggleDeepResearch={() => {}}
|
||||
disabled={false}
|
||||
|
||||
20
web/tests/e2e/admin/ee_feature_redirect.spec.ts
Normal file
20
web/tests/e2e/admin/ee_feature_redirect.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
|
||||
|
||||
test.describe("EE Feature Redirect", () => {
|
||||
test("redirects to /chat with toast when EE features are not licensed", async ({
|
||||
page,
|
||||
eeEnabled,
|
||||
}) => {
|
||||
test.skip(eeEnabled, "Redirect only happens without Enterprise license");
|
||||
|
||||
await page.goto("/admin/theme");
|
||||
|
||||
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
|
||||
|
||||
const toastContainer = page.getByTestId("toast-container");
|
||||
await expect(toastContainer).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
toastContainer.getByText(/only accessible with a paid license/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
|
||||
import { loginAs } from "@tests/e2e/utils/auth";
|
||||
|
||||
test.describe("Appearance Theme Settings @exclusive", () => {
|
||||
@@ -12,24 +12,21 @@ test.describe("Appearance Theme Settings @exclusive", () => {
|
||||
consentPrompt: "I agree to the terms",
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.beforeEach(async ({ page, eeEnabled }) => {
|
||||
test.skip(
|
||||
!eeEnabled,
|
||||
"Enterprise license not active — skipping theme tests"
|
||||
);
|
||||
|
||||
// Fresh session — the eeEnabled fixture already logged in to check the
|
||||
// setting, so clear cookies and re-login for a clean test state.
|
||||
await page.context().clearCookies();
|
||||
await loginAs(page, "admin");
|
||||
|
||||
// Navigate first so localStorage is accessible (API-based login
|
||||
// doesn't navigate, leaving the page on about:blank).
|
||||
await page.goto("/admin/theme");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Skip the entire test when Enterprise features are not licensed.
|
||||
// The /admin/theme page is gated behind ee_features_enabled and
|
||||
// renders a license-required message instead of the settings form.
|
||||
const eeLocked = page.getByText(
|
||||
"This functionality requires an active Enterprise license."
|
||||
);
|
||||
if (await eeLocked.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
test.skip(true, "Enterprise license not active — skipping theme tests");
|
||||
}
|
||||
await expect(
|
||||
page.locator('[data-label="application-name-input"]')
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Clear localStorage to ensure consent modal shows
|
||||
await page.evaluate(() => {
|
||||
|
||||
43
web/tests/e2e/fixtures/eeFeatures.ts
Normal file
43
web/tests/e2e/fixtures/eeFeatures.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Playwright fixture that detects EE (Enterprise Edition) license state.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
|
||||
*
|
||||
* test("my EE-gated test", async ({ page, eeEnabled }) => {
|
||||
* test.skip(!eeEnabled, "Requires active Enterprise license");
|
||||
* // ... rest of test
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* The fixture:
|
||||
* - Authenticates as admin
|
||||
* - Fetches /api/settings to check ee_features_enabled
|
||||
* - Provides a boolean to the test BEFORE any navigation happens
|
||||
*
|
||||
* This lets tests call test.skip() synchronously at the top, which is the
|
||||
* correct Playwright pattern — never navigate then decide to skip.
|
||||
*/
|
||||
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import { loginAs } from "@tests/e2e/utils/auth";
|
||||
|
||||
export const test = base.extend<{
|
||||
/** Whether EE features are enabled (valid enterprise license). */
|
||||
eeEnabled: boolean;
|
||||
}>({
|
||||
eeEnabled: async ({ page }, use) => {
|
||||
await loginAs(page, "admin");
|
||||
const res = await page.request.get("/api/settings");
|
||||
if (!res.ok()) {
|
||||
// Fail open — if we can't determine, assume EE is not enabled
|
||||
await use(false);
|
||||
return;
|
||||
}
|
||||
const settings = await res.json();
|
||||
await use(settings.ee_features_enabled === true);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
Reference in New Issue
Block a user