Compare commits

..

18 Commits

Author SHA1 Message Date
Bo-Onyx
26b95e8c82 Add doc 2026-03-17 15:00:50 -07:00
Bo-Onyx
442838d80e chore(hooks): Define Hook Point 2026-03-17 13:58:20 -07:00
Bo-Onyx
11cfc92f15 chore(hook): DB changes (#9337) 2026-03-17 01:04:06 +00:00
Jamison Lahman
c7da99cfd7 chore(playwright): make project name human-readable (#9394) 2026-03-16 17:26:20 -07:00
Jamison Lahman
b384c77863 chore(fe): admin navigation always goes to LLM config page (#9395) 2026-03-16 17:15:50 -07:00
Raunak Bhagat
b0f31cd46b fix(search-ui): center pagination in SearchUI (#9396) 2026-03-16 23:59:17 +00:00
Jamison Lahman
323eb9bbba chore(fe): make sidebar scrollbar flush with edge (#9383)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-16 23:33:40 +00:00
Raunak Bhagat
708e310849 refactor: refreshed Pagination component (#9380) 2026-03-16 23:14:59 +00:00
Wenxi
c25509e212 chore: run identify from backend (#9392) 2026-03-16 23:12:10 +00:00
Nikolas Garza
6af0da41bd test(admin): add E2E Playwright tests for Users page (#9266) 2026-03-16 21:41:24 +00:00
Evan Lohn
b94da25d7c chore: update install script (#9068) 2026-03-16 21:29:56 +00:00
Jamison Lahman
7d443c1b53 chore(ws): ignore port when determining origin in dev (#9382) 2026-03-16 21:24:34 +00:00
Justin Tahara
d6b7b3c68f fix(celery): Limiting connector_hierarchy_fetching jobs (#9381) 2026-03-16 21:14:04 +00:00
Jamison Lahman
f5073d331e chore(tests): fix flaky test_run_with_timeout_raises_on_timeout (#9377) 2026-03-16 19:02:58 +00:00
dependabot[bot]
64c9f6a0d5 chore(deps): bump docker/metadata-action from 5.10.0 to 6.0.0 (#9374)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 11:57:00 -07:00
dependabot[bot]
f5a494f790 chore(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#9375)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 11:56:45 -07:00
dependabot[bot]
8598e9f25d chore(deps): bump actions/checkout from 6.0.1 to 6.0.2 (#9373)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 11:56:26 -07:00
Justin Tahara
3ef8aecc54 test(ui): Add visual regression test for project files with long filenames (#9062) 2026-03-16 18:41:06 +00:00
67 changed files with 4419 additions and 1927 deletions

View File

@@ -455,7 +455,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -529,7 +529,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -607,7 +607,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -668,7 +668,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -750,7 +750,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -836,7 +836,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -894,7 +894,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -967,7 +967,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -1044,7 +1044,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -1105,7 +1105,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ env.REGISTRY_IMAGE }}
flavor: |
@@ -1178,7 +1178,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ env.REGISTRY_IMAGE }}
flavor: |
@@ -1256,7 +1256,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ env.REGISTRY_IMAGE }}
flavor: |
@@ -1317,7 +1317,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -1397,7 +1397,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |
@@ -1480,7 +1480,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |

View File

@@ -105,7 +105,7 @@ jobs:
- name: Upload build artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: desktop-build-${{ matrix.platform }}-${{ github.run_id }}
path: |

View File

@@ -174,7 +174,7 @@ jobs:
- name: Upload Docker logs
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: docker-logs-${{ matrix.test-dir }}
path: docker-logs/

View File

@@ -25,7 +25,7 @@ jobs:
outputs:
modules: ${{ steps.set-modules.outputs.modules }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
- id: set-modules
@@ -39,7 +39,7 @@ jobs:
matrix:
modules: ${{ fromJSON(needs.detect-modules.outputs.modules) }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # zizmor: ignore[cache-poisoning]

View File

@@ -466,7 +466,7 @@ jobs:
- name: Upload logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: docker-all-logs-${{ matrix.edition }}-${{ matrix.test-dir.name }}
path: ${{ github.workspace }}/docker-compose.log
@@ -587,7 +587,7 @@ jobs:
- name: Upload logs (onyx-lite)
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: docker-all-logs-onyx-lite
path: ${{ github.workspace }}/docker-compose-onyx-lite.log
@@ -725,7 +725,7 @@ jobs:
- name: Upload logs (multi-tenant)
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: docker-all-logs-multitenant
path: ${{ github.workspace }}/docker-compose-multitenant.log

View File

@@ -44,7 +44,7 @@ jobs:
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: jest-coverage-${{ github.run_id }}
path: ./web/coverage

View File

@@ -445,7 +445,7 @@ jobs:
run: |
npx playwright test --project ${PROJECT}
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
if: always()
with:
# Includes test results and trace.zip files
@@ -454,7 +454,7 @@ jobs:
retention-days: 30
- name: Upload screenshots
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
if: always()
with:
name: playwright-screenshots-${{ matrix.project }}-${{ github.run_id }}
@@ -534,7 +534,7 @@ jobs:
"s3://${PLAYWRIGHT_S3_BUCKET}/reports/pr-${PR_NUMBER}/${RUN_ID}/${PROJECT}/"
- name: Upload visual diff summary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
if: always()
with:
name: screenshot-diff-summary-${{ matrix.project }}
@@ -543,7 +543,7 @@ jobs:
retention-days: 5
- name: Upload visual diff report artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
if: always()
with:
name: screenshot-diff-report-${{ matrix.project }}-${{ github.run_id }}
@@ -590,7 +590,7 @@ jobs:
- name: Upload logs
if: success() || failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: docker-logs-${{ matrix.project }}-${{ github.run_id }}
path: ${{ github.workspace }}/docker-compose.log
@@ -674,7 +674,7 @@ jobs:
working-directory: ./web
run: npx playwright test --project lite
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
if: always()
with:
name: playwright-test-results-lite-${{ github.run_id }}
@@ -692,7 +692,7 @@ jobs:
- name: Upload logs
if: success() || failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: docker-logs-lite-${{ github.run_id }}
path: ${{ github.workspace }}/docker-compose.log

View File

@@ -122,7 +122,7 @@ jobs:
- name: Upload logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: docker-all-logs
path: ${{ github.workspace }}/docker-compose.log

View File

@@ -319,7 +319,7 @@ jobs:
- name: Upload logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: docker-all-logs-nightly-${{ matrix.provider }}-llm-provider
path: |

View File

@@ -125,7 +125,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ env.REGISTRY_IMAGE }}
flavor: |
@@ -195,7 +195,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ env.REGISTRY_IMAGE }}
flavor: |
@@ -268,7 +268,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
with:
images: ${{ env.REGISTRY_IMAGE }}
flavor: |

View File

@@ -0,0 +1,103 @@
"""add_hook_and_hook_execution_log_tables
Revision ID: 689433b0d8de
Revises: 93a2e195e25c
Create Date: 2026-03-13 11:25:06.547474
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PGUUID
# revision identifiers, used by Alembic.
revision = "689433b0d8de"
down_revision = "93a2e195e25c"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"hook",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column(
"hook_point",
sa.Enum("document_ingestion", "query_processing", native_enum=False),
nullable=False,
),
sa.Column("endpoint_url", sa.Text(), nullable=True),
sa.Column("api_key", sa.LargeBinary(), nullable=True),
sa.Column("is_reachable", sa.Boolean(), nullable=True),
sa.Column(
"fail_strategy",
sa.Enum("hard", "soft", native_enum=False),
nullable=False,
),
sa.Column("timeout_seconds", sa.Float(), nullable=False),
sa.Column(
"is_active", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column(
"deleted", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column("creator_id", PGUUID(as_uuid=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["creator_id"], ["user.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_hook_one_non_deleted_per_point",
"hook",
["hook_point"],
unique=True,
postgresql_where=sa.text("deleted = false"),
)
op.create_table(
"hook_execution_log",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("hook_id", sa.Integer(), nullable=False),
sa.Column(
"is_success",
sa.Boolean(),
nullable=False,
),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("status_code", sa.Integer(), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["hook_id"], ["hook.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_hook_execution_log_hook_id", "hook_execution_log", ["hook_id"])
op.create_index(
"ix_hook_execution_log_created_at", "hook_execution_log", ["created_at"]
)
def downgrade() -> None:
op.drop_index("ix_hook_execution_log_created_at", table_name="hook_execution_log")
op.drop_index("ix_hook_execution_log_hook_id", table_name="hook_execution_log")
op.drop_table("hook_execution_log")
op.drop_index("ix_hook_one_non_deleted_per_point", table_name="hook")
op.drop_table("hook")

View File

@@ -1,3 +1,5 @@
from typing import Any
from ee.onyx.utils.posthog_client import posthog
from onyx.utils.logger import setup_logger
@@ -5,7 +7,7 @@ logger = setup_logger()
def event_telemetry(
distinct_id: str, event: str, properties: dict | None = None
distinct_id: str, event: str, properties: dict[str, Any] | None = None
) -> None:
"""Capture and send an event to PostHog, flushing immediately."""
if not posthog:
@@ -17,3 +19,15 @@ def event_telemetry(
posthog.flush()
except Exception as e:
logger.error(f"Error capturing PostHog event: {e}")
def identify_user(distinct_id: str, properties: dict[str, Any] | None = None) -> None:
"""Create/update a PostHog person profile, flushing immediately."""
if not posthog:
return
try:
posthog.identify(distinct_id, properties)
posthog.flush()
except Exception as e:
logger.error(f"Error identifying PostHog user: {e}")

View File

@@ -19,6 +19,7 @@ from typing import Optional
from typing import Protocol
from typing import Tuple
from typing import TypeVar
from urllib.parse import urlparse
import jwt
from email_validator import EmailNotValidError
@@ -134,6 +135,7 @@ from onyx.redis.redis_pool import retrieve_ws_token_data
from onyx.server.settings.store import load_settings
from onyx.server.utils import BasicAuthenticationError
from onyx.utils.logger import setup_logger
from onyx.utils.telemetry import mt_cloud_identify
from onyx.utils.telemetry import mt_cloud_telemetry
from onyx.utils.telemetry import optional_telemetry
from onyx.utils.telemetry import RecordType
@@ -792,6 +794,12 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
except Exception:
logger.exception("Error deleting anonymous user cookie")
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get()
mt_cloud_identify(
distinct_id=str(user.id),
properties={"email": user.email, "tenant_id": tenant_id},
)
async def on_after_register(
self, user: User, request: Optional[Request] = None
) -> None:
@@ -810,6 +818,12 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
user_count = await get_user_count()
logger.debug(f"Current tenant user count: {user_count}")
# Ensure a PostHog person profile exists for this user.
mt_cloud_identify(
distinct_id=str(user.id),
properties={"email": user.email, "tenant_id": tenant_id},
)
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=str(user.id),
@@ -1659,6 +1673,33 @@ async def _get_user_from_token_data(token_data: dict) -> User | None:
return user
_LOOPBACK_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})
def _is_same_origin(actual: str, expected: str) -> bool:
"""Compare two origins for the WebSocket CSWSH check.
Scheme and hostname must match exactly. Port must also match, except
when the hostname is a loopback address (localhost / 127.0.0.1 / ::1),
where port is ignored. On loopback, all ports belong to the same
operator, so port differences carry no security significance — the
CSWSH threat is remote origins, not local ones.
"""
a = urlparse(actual.rstrip("/"))
e = urlparse(expected.rstrip("/"))
if a.scheme != e.scheme or a.hostname != e.hostname:
return False
if a.hostname in _LOOPBACK_HOSTNAMES:
return True
actual_port = a.port or (443 if a.scheme == "https" else 80)
expected_port = e.port or (443 if e.scheme == "https" else 80)
return actual_port == expected_port
async def current_user_from_websocket(
websocket: WebSocket,
token: str = Query(..., description="WebSocket authentication token"),
@@ -1678,19 +1719,15 @@ async def current_user_from_websocket(
This applies the same auth checks as current_user() for HTTP endpoints.
"""
# Check Origin header to prevent Cross-Site WebSocket Hijacking (CSWSH)
# Browsers always send Origin on WebSocket connections
# Check Origin header to prevent Cross-Site WebSocket Hijacking (CSWSH).
# Browsers always send Origin on WebSocket connections.
origin = websocket.headers.get("origin")
expected_origin = WEB_DOMAIN.rstrip("/")
if not origin:
logger.warning("WS auth: missing Origin header")
raise BasicAuthenticationError(detail="Access denied. Missing origin.")
actual_origin = origin.rstrip("/")
if actual_origin != expected_origin:
logger.warning(
f"WS auth: origin mismatch. Expected {expected_origin}, got {actual_origin}"
)
if not _is_same_origin(origin, WEB_DOMAIN):
logger.warning(f"WS auth: origin mismatch. Expected {WEB_DOMAIN}, got {origin}")
raise BasicAuthenticationError(detail="Access denied. Invalid origin.")
# Validate WS token in Redis (single-use, deleted after retrieval)

View File

@@ -29,6 +29,8 @@ from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import OnyxRedisLocks
from onyx.connectors.factory import ConnectorMissingException
from onyx.connectors.factory import identify_connector_class
from onyx.connectors.factory import instantiate_connector
from onyx.connectors.interfaces import HierarchyConnector
from onyx.connectors.models import HierarchyNode as PydanticHierarchyNode
@@ -55,6 +57,26 @@ logger = setup_logger()
HIERARCHY_FETCH_INTERVAL_SECONDS = 24 * 60 * 60
def _connector_supports_hierarchy_fetching(
cc_pair: ConnectorCredentialPair,
) -> bool:
"""Return True only for connectors whose class implements HierarchyConnector."""
try:
connector_class = identify_connector_class(
cc_pair.connector.source,
)
except ConnectorMissingException as e:
task_logger.warning(
"Skipping hierarchy fetching enqueue for source=%s input_type=%s: %s",
cc_pair.connector.source,
cc_pair.connector.input_type,
str(e),
)
return False
return issubclass(connector_class, HierarchyConnector)
def _is_hierarchy_fetching_due(cc_pair: ConnectorCredentialPair) -> bool:
"""Returns boolean indicating if hierarchy fetching is due for this connector.
@@ -186,7 +208,10 @@ def check_for_hierarchy_fetching(self: Task, *, tenant_id: str) -> int | None:
cc_pair_id=cc_pair_id,
)
if not cc_pair or not _is_hierarchy_fetching_due(cc_pair):
if not cc_pair or not _connector_supports_hierarchy_fetching(cc_pair):
continue
if not _is_hierarchy_fetching_due(cc_pair):
continue
task_id = _try_creating_hierarchy_fetching_task(

View File

@@ -776,9 +776,6 @@ MINI_CHUNK_SIZE = 150
# This is the number of regular chunks per large chunk
LARGE_CHUNK_RATIO = 4
# The number of chunks in an indexing batch
CHUNKS_PER_BATCH = 1000
# Include the document level metadata in each chunk. If the metadata is too long, then it is thrown out
# We don't want the metadata to overwhelm the actual contents of the chunk
SKIP_METADATA_IN_CHUNK = os.environ.get("SKIP_METADATA_IN_CHUNK", "").lower() == "true"

View File

@@ -304,3 +304,13 @@ class LLMModelFlowType(str, PyEnum):
CHAT = "chat"
VISION = "vision"
CONTEXTUAL_RAG = "contextual_rag"
class HookPoint(str, PyEnum):
DOCUMENT_INGESTION = "document_ingestion"
QUERY_PROCESSING = "query_processing"
class HookFailStrategy(str, PyEnum):
HARD = "hard" # exception propagates, pipeline aborts
SOFT = "soft" # log error, return original input, pipeline continues

View File

@@ -64,6 +64,8 @@ from onyx.db.enums import (
BuildSessionStatus,
EmbeddingPrecision,
HierarchyNodeType,
HookFailStrategy,
HookPoint,
IndexingMode,
OpenSearchDocumentMigrationStatus,
OpenSearchTenantMigrationStatus,
@@ -5178,3 +5180,90 @@ class CacheStore(Base):
expires_at: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class Hook(Base):
"""Pairs a HookPoint with a customer-provided API endpoint.
At most one non-deleted Hook per HookPoint is allowed, enforced by a
partial unique index on (hook_point) where deleted=false.
"""
__tablename__ = "hook"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
hook_point: Mapped[HookPoint] = mapped_column(
Enum(HookPoint, native_enum=False), nullable=False
)
endpoint_url: Mapped[str | None] = mapped_column(Text, nullable=True)
api_key: Mapped[SensitiveValue[str] | None] = mapped_column(
EncryptedString(), nullable=True
)
is_reachable: Mapped[bool | None] = mapped_column(
Boolean, nullable=True, default=None
) # null = never validated, true = last check passed, false = last check failed
fail_strategy: Mapped[HookFailStrategy] = mapped_column(
Enum(HookFailStrategy, native_enum=False),
nullable=False,
default=HookFailStrategy.HARD,
)
timeout_seconds: Mapped[float] = mapped_column(Float, nullable=False, default=30.0)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
creator_id: Mapped[UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
creator: Mapped["User | None"] = relationship("User", foreign_keys=[creator_id])
execution_logs: Mapped[list["HookExecutionLog"]] = relationship(
"HookExecutionLog", back_populates="hook", cascade="all, delete-orphan"
)
__table_args__ = (
Index(
"ix_hook_one_non_deleted_per_point",
"hook_point",
unique=True,
postgresql_where=(deleted == False), # noqa: E712
),
)
class HookExecutionLog(Base):
"""Records hook executions for health monitoring and debugging.
Currently only failures are logged; the is_success column exists so
success logging can be added later without a schema change.
Retention: rows older than 30 days are deleted by a nightly Celery task.
"""
__tablename__ = "hook_execution_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
hook_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("hook.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
is_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
hook: Mapped["Hook"] = relationship("Hook", back_populates="execution_logs")

View File

@@ -5,7 +5,6 @@ accidentally reaches the vector DB layer will fail loudly instead of timing
out against a nonexistent Vespa/OpenSearch instance.
"""
from collections.abc import Iterable
from typing import Any
from onyx.context.search.models import IndexFilters
@@ -67,7 +66,7 @@ class DisabledDocumentIndex(DocumentIndex):
# ------------------------------------------------------------------
def index(
self,
chunks: Iterable[DocMetadataAwareIndexChunk], # noqa: ARG002
chunks: list[DocMetadataAwareIndexChunk], # noqa: ARG002
index_batch_params: IndexBatchParams, # noqa: ARG002
) -> set[DocumentInsertionRecord]:
raise RuntimeError(VECTOR_DB_DISABLED_ERROR)

View File

@@ -1,5 +1,4 @@
import abc
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
@@ -207,7 +206,7 @@ class Indexable(abc.ABC):
@abc.abstractmethod
def index(
self,
chunks: Iterable[DocMetadataAwareIndexChunk],
chunks: list[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> set[DocumentInsertionRecord]:
"""
@@ -227,8 +226,8 @@ class Indexable(abc.ABC):
it is done automatically outside of this code.
Parameters:
- chunks: Document chunks with all of the information needed for
indexing to the document index.
- chunks: Document chunks with all of the information needed for indexing to the document
index.
- tenant_id: The tenant id of the user whose chunks are being indexed
- large_chunks_enabled: Whether large chunks are enabled

View File

@@ -1,5 +1,4 @@
import abc
from collections.abc import Iterable
from typing import Self
from pydantic import BaseModel
@@ -210,7 +209,7 @@ class Indexable(abc.ABC):
@abc.abstractmethod
def index(
self,
chunks: Iterable[DocMetadataAwareIndexChunk],
chunks: list[DocMetadataAwareIndexChunk],
indexing_metadata: IndexingMetadata,
) -> list[DocumentInsertionRecord]:
"""Indexes a list of document chunks into the document index.

View File

@@ -1,12 +1,11 @@
import json
from collections.abc import Iterable
from collections import defaultdict
from typing import Any
import httpx
from opensearchpy import NotFoundError
from onyx.access.models import DocumentAccess
from onyx.configs.app_configs import CHUNKS_PER_BATCH
from onyx.configs.app_configs import VERIFY_CREATE_OPENSEARCH_INDEX_ON_INIT_MT
from onyx.configs.chat_configs import NUM_RETURNED_HITS
from onyx.configs.chat_configs import TITLE_CONTENT_RATIO
@@ -347,7 +346,7 @@ class OpenSearchOldDocumentIndex(OldDocumentIndex):
def index(
self,
chunks: Iterable[DocMetadataAwareIndexChunk],
chunks: list[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> set[OldDocumentInsertionRecord]:
"""
@@ -643,8 +642,8 @@ class OpenSearchDocumentIndex(DocumentIndex):
def index(
self,
chunks: Iterable[DocMetadataAwareIndexChunk],
indexing_metadata: IndexingMetadata,
chunks: list[DocMetadataAwareIndexChunk],
indexing_metadata: IndexingMetadata, # noqa: ARG002
) -> list[DocumentInsertionRecord]:
"""Indexes a list of document chunks into the document index.
@@ -669,32 +668,29 @@ class OpenSearchDocumentIndex(DocumentIndex):
document is newly indexed or had already existed and was just
updated.
"""
total_chunks = sum(
cc.new_chunk_cnt
for cc in indexing_metadata.doc_id_to_chunk_cnt_diff.values()
# Group chunks by document ID.
doc_id_to_chunks: dict[str, list[DocMetadataAwareIndexChunk]] = defaultdict(
list
)
for chunk in chunks:
doc_id_to_chunks[chunk.source_document.id].append(chunk)
logger.debug(
f"[OpenSearchDocumentIndex] Indexing {total_chunks} chunks from {len(indexing_metadata.doc_id_to_chunk_cnt_diff)} "
f"[OpenSearchDocumentIndex] Indexing {len(chunks)} chunks from {len(doc_id_to_chunks)} "
f"documents for index {self._index_name}."
)
document_indexing_results: list[DocumentInsertionRecord] = []
deleted_doc_ids: set[str] = set()
# Buffer chunks per document as they arrive from the iterable.
# When the document ID changes flush the buffered chunks.
current_doc_id: str | None = None
current_chunks: list[DocMetadataAwareIndexChunk] = []
def _flush_chunks(doc_chunks: list[DocMetadataAwareIndexChunk]) -> None:
# Try to index per-document.
for _, chunks in doc_id_to_chunks.items():
# Create a batch of OpenSearch-formatted chunks for bulk insertion.
# Since we are doing this in batches, an error occurring midway
# can result in a state where chunks are deleted and not all the
# new chunks have been indexed.
# Do this before deleting existing chunks to reduce the amount of
# time the document index has no content for a given document, and
# to reduce the chance of entering a state where we delete chunks,
# then some error happens, and never successfully index new chunks.
chunk_batch: list[DocumentChunk] = [
_convert_onyx_chunk_to_opensearch_document(chunk)
for chunk in doc_chunks
_convert_onyx_chunk_to_opensearch_document(chunk) for chunk in chunks
]
onyx_document: Document = doc_chunks[0].source_document
onyx_document: Document = chunks[0].source_document
# First delete the doc's chunks from the index. This is so that
# there are no dangling chunks in the index, in the event that the
# new document's content contains fewer chunks than the previous
@@ -703,39 +699,22 @@ class OpenSearchDocumentIndex(DocumentIndex):
# if the chunk count has actually decreased. This assumes that
# overlapping chunks are perfectly overwritten. If we can't
# guarantee that then we need the code as-is.
if onyx_document.id not in deleted_doc_ids:
num_chunks_deleted = self.delete(
onyx_document.id, onyx_document.chunk_count
)
deleted_doc_ids.add(onyx_document.id)
document_indexing_results.append(
DocumentInsertionRecord(
document_id=onyx_document.id,
already_existed=num_chunks_deleted > 0,
)
)
num_chunks_deleted = self.delete(
onyx_document.id, onyx_document.chunk_count
)
# If we see that chunks were deleted we assume the doc already
# existed.
document_insertion_record = DocumentInsertionRecord(
document_id=onyx_document.id,
already_existed=num_chunks_deleted > 0,
)
# Now index. This will raise if a chunk of the same ID exists, which
# we do not expect because we should have deleted all chunks.
self._client.bulk_index_documents(
documents=chunk_batch,
tenant_state=self._tenant_state,
)
for chunk in chunks:
doc_id = chunk.source_document.id
if doc_id != current_doc_id:
if current_chunks:
_flush_chunks(current_chunks)
current_doc_id = doc_id
current_chunks = [chunk]
elif len(current_chunks) >= CHUNKS_PER_BATCH:
_flush_chunks(current_chunks)
current_chunks = [chunk]
else:
current_chunks.append(chunk)
if current_chunks:
_flush_chunks(current_chunks)
document_indexing_results.append(document_insertion_record)
return document_indexing_results

View File

@@ -6,7 +6,6 @@ import re
import time
import urllib
import zipfile
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime
from datetime import timedelta
@@ -462,7 +461,7 @@ class VespaIndex(DocumentIndex):
def index(
self,
chunks: Iterable[DocMetadataAwareIndexChunk],
chunks: list[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> set[OldDocumentInsertionRecord]:
"""

View File

@@ -1,8 +1,6 @@
import concurrent.futures
import logging
import random
from collections.abc import Generator
from collections.abc import Iterable
from typing import Any
from uuid import UUID
@@ -10,7 +8,6 @@ import httpx
from pydantic import BaseModel
from retry import retry
from onyx.configs.app_configs import CHUNKS_PER_BATCH
from onyx.configs.app_configs import RECENCY_BIAS_MULTIPLIER
from onyx.configs.app_configs import RERANK_COUNT
from onyx.configs.chat_configs import DOC_TIME_DECAY
@@ -321,7 +318,7 @@ class VespaDocumentIndex(DocumentIndex):
def index(
self,
chunks: Iterable[DocMetadataAwareIndexChunk],
chunks: list[DocMetadataAwareIndexChunk],
indexing_metadata: IndexingMetadata,
) -> list[DocumentInsertionRecord]:
doc_id_to_chunk_cnt_diff = indexing_metadata.doc_id_to_chunk_cnt_diff
@@ -341,31 +338,22 @@ class VespaDocumentIndex(DocumentIndex):
# Vespa has restrictions on valid characters, yet document IDs come from
# external w.r.t. this class. We need to sanitize them.
#
# Instead of materializing all cleaned chunks upfront, we stream them
# through a generator that cleans IDs and builds the original-ID mapping
# incrementally as chunks flow into Vespa.
def _clean_and_track(
chunks_iter: Iterable[DocMetadataAwareIndexChunk],
id_map: dict[str, str],
seen_ids: set[str],
) -> Generator[DocMetadataAwareIndexChunk, None, None]:
"""Cleans chunk IDs and builds the original-ID mapping
incrementally as chunks flow through, avoiding a separate
materialization pass."""
for chunk in chunks_iter:
original_id = chunk.source_document.id
cleaned = clean_chunk_id_copy(chunk)
cleaned_id = cleaned.source_document.id
# Needed so the final DocumentInsertionRecord returned can have
# the original document ID. cleaned_chunks might not contain IDs
# exactly as callers supplied them.
id_map[cleaned_id] = original_id
seen_ids.add(cleaned_id)
yield cleaned
cleaned_chunks: list[DocMetadataAwareIndexChunk] = [
clean_chunk_id_copy(chunk) for chunk in chunks
]
assert len(cleaned_chunks) == len(
chunks
), "Bug: Cleaned chunks and input chunks have different lengths."
new_document_id_to_original_document_id: dict[str, str] = {}
all_cleaned_doc_ids: set[str] = set()
# Needed so the final DocumentInsertionRecord returned can have the
# original document ID. cleaned_chunks might not contain IDs exactly as
# callers supplied them.
new_document_id_to_original_document_id: dict[str, str] = dict()
for i, cleaned_chunk in enumerate(cleaned_chunks):
old_chunk = chunks[i]
new_document_id_to_original_document_id[
cleaned_chunk.source_document.id
] = old_chunk.source_document.id
existing_docs: set[str] = set()
@@ -421,16 +409,8 @@ class VespaDocumentIndex(DocumentIndex):
executor=executor,
)
# Insert new Vespa documents, streaming through the cleaning
# pipeline so chunks are never fully materialized.
cleaned_chunks = _clean_and_track(
chunks,
new_document_id_to_original_document_id,
all_cleaned_doc_ids,
)
for chunk_batch in batch_generator(
cleaned_chunks, min(BATCH_SIZE, CHUNKS_PER_BATCH)
):
# Insert new Vespa documents.
for chunk_batch in batch_generator(cleaned_chunks, BATCH_SIZE):
batch_index_vespa_chunks(
chunks=chunk_batch,
index_name=self._index_name,
@@ -439,6 +419,10 @@ class VespaDocumentIndex(DocumentIndex):
executor=executor,
)
all_cleaned_doc_ids: set[str] = {
chunk.source_document.id for chunk in cleaned_chunks
}
return [
DocumentInsertionRecord(
document_id=new_document_id_to_original_document_id[cleaned_doc_id],

View File

@@ -0,0 +1,127 @@
from datetime import datetime
from enum import Enum
from typing import Annotated
from typing import Any
from pydantic import BaseModel
from pydantic import Field
from pydantic import field_validator
from pydantic import model_validator
from pydantic import SecretStr
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
NonEmptySecretStr = Annotated[SecretStr, Field(min_length=1)]
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class HookCreateRequest(BaseModel):
name: str = Field(min_length=1)
hook_point: HookPoint
endpoint_url: str = Field(min_length=1)
api_key: NonEmptySecretStr | None = None
fail_strategy: HookFailStrategy | None = None # if None, uses HookPointSpec default
timeout_seconds: float | None = Field(
default=None, gt=0
) # if None, uses HookPointSpec default
@field_validator("name", "endpoint_url")
@classmethod
def no_whitespace_only(cls, v: str) -> str:
if not v.strip():
raise ValueError("cannot be whitespace-only.")
return v
class HookUpdateRequest(BaseModel):
name: str | None = None
endpoint_url: str | None = None
api_key: NonEmptySecretStr | None = None
fail_strategy: HookFailStrategy | None = (
None # if None in model_fields_set, reset to spec default
)
timeout_seconds: float | None = Field(
default=None, gt=0
) # if None in model_fields_set, reset to spec default
@model_validator(mode="after")
def require_at_least_one_field(self) -> "HookUpdateRequest":
if not self.model_fields_set:
raise ValueError("At least one field must be provided for an update.")
if "name" in self.model_fields_set and not (self.name or "").strip():
raise ValueError("name cannot be cleared.")
if (
"endpoint_url" in self.model_fields_set
and not (self.endpoint_url or "").strip()
):
raise ValueError("endpoint_url cannot be cleared.")
return self
# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
class HookPointMetaResponse(BaseModel):
hook_point: HookPoint
display_name: str
description: str
docs_url: str | None
input_schema: dict[str, Any]
output_schema: dict[str, Any]
default_timeout_seconds: float
default_fail_strategy: HookFailStrategy
fail_hard_description: str
class HookResponse(BaseModel):
id: int
name: str
hook_point: HookPoint
# Nullable to match the DB column — endpoint_url is required on creation but
# future hook point types may not use an external endpoint (e.g. built-in handlers).
endpoint_url: str | None
fail_strategy: HookFailStrategy
timeout_seconds: float # always resolved — None from request is replaced with spec default before DB write
is_active: bool
creator_email: str | None
created_at: datetime
updated_at: datetime
class HookValidateResponse(BaseModel):
success: bool
error_message: str | None = None
# ---------------------------------------------------------------------------
# Health models
# ---------------------------------------------------------------------------
class HookHealthStatus(str, Enum):
healthy = "healthy" # green — reachable, no failures in last 1h
degraded = "degraded" # yellow — reachable, failures in last 1h
unreachable = "unreachable" # red — is_reachable=false or null
class HookFailureRecord(BaseModel):
error_message: str | None = None
status_code: int | None = None
duration_ms: int | None = None
created_at: datetime
class HookHealthResponse(BaseModel):
status: HookHealthStatus
recent_failures: list[HookFailureRecord] = Field(
default_factory=list,
description="Last 10 failures, newest first",
max_length=10,
)

View File

View File

@@ -0,0 +1,59 @@
from abc import ABC
from abc import abstractmethod
from typing import Any
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
_REQUIRED_ATTRS = (
"hook_point",
"display_name",
"description",
"default_timeout_seconds",
"fail_hard_description",
"default_fail_strategy",
)
class HookPointSpec(ABC):
"""Static metadata and contract for a pipeline hook point.
This is NOT a regular class meant for direct instantiation by callers.
Each concrete subclass represents exactly one hook point and is instantiated
once at startup, registered in onyx.hooks.registry._REGISTRY. No caller
should ever create instances directly — use get_hook_point_spec() or
get_all_specs() from the registry instead.
Each hook point is a concrete subclass of this class. Onyx engineers
own these definitions — customers never touch this code.
Subclasses must define all attributes as class-level constants.
"""
hook_point: HookPoint
display_name: str
description: str
default_timeout_seconds: float
fail_hard_description: str
default_fail_strategy: HookFailStrategy
docs_url: str | None = None
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
# Skip intermediate abstract subclasses — they may still be partially defined.
if getattr(cls, "__abstractmethods__", None):
return
missing = [attr for attr in _REQUIRED_ATTRS if not hasattr(cls, attr)]
if missing:
raise TypeError(f"{cls.__name__} must define class attributes: {missing}")
@property
@abstractmethod
def input_schema(self) -> dict[str, Any]:
"""JSON schema describing the request payload sent to the customer's endpoint."""
@property
@abstractmethod
def output_schema(self) -> dict[str, Any]:
"""JSON schema describing the expected response from the customer's endpoint."""

View File

@@ -0,0 +1,29 @@
from typing import Any
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
from onyx.hooks.points.base import HookPointSpec
class DocumentIngestionSpec(HookPointSpec):
"""Hook point that runs during document ingestion.
# TODO(@Bo-Onyx): define call site, input/output schema, and timeout budget.
"""
hook_point = HookPoint.DOCUMENT_INGESTION
display_name = "Document Ingestion"
description = "Runs during document ingestion. Allows filtering or transforming documents before indexing."
default_timeout_seconds = 30.0
fail_hard_description = "The document will not be indexed."
default_fail_strategy = HookFailStrategy.HARD
@property
def input_schema(self) -> dict[str, Any]:
# TODO(@Bo-Onyx): define input schema
return {"type": "object", "properties": {}}
@property
def output_schema(self) -> dict[str, Any]:
# TODO(@Bo-Onyx): define output schema
return {"type": "object", "properties": {}}

View File

@@ -0,0 +1,79 @@
from typing import Any
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
from onyx.hooks.points.base import HookPointSpec
class QueryProcessingSpec(HookPointSpec):
"""Hook point that runs on every user query before it enters the pipeline.
Call site: inside handle_stream_message_objects() in
backend/onyx/chat/process_message.py, immediately after message_text is
assigned from the request and before create_new_chat_message() saves it.
This is the earliest possible point in the query pipeline:
- Raw query — unmodified, exactly as the user typed it
- No side effects yet — message has not been saved to DB
- User identity is available for user-specific logic
Supported use cases:
- Query rejection: block queries based on content or user context
- Query rewriting: normalize, expand, or modify the query
- PII removal: scrub sensitive data before the LLM sees it
- Access control: reject queries from certain users or groups
- Query auditing: log or track queries based on business rules
"""
hook_point = HookPoint.QUERY_PROCESSING
display_name = "Query Processing"
description = (
"Runs on every user query before it enters the pipeline. "
"Allows rewriting, filtering, or rejecting queries."
)
default_timeout_seconds = 5.0 # user is actively waiting — keep tight
fail_hard_description = (
"The query will be blocked and the user will see an error message."
)
default_fail_strategy = HookFailStrategy.HARD
@property
def input_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The raw query string exactly as the user typed it.",
},
"user_email": {
"type": ["string", "null"],
"description": "Email of the user submitting the query, or null if unauthenticated.",
},
},
"required": ["query", "user_email"],
"additionalProperties": False,
}
@property
def output_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"query": {
"type": ["string", "null"],
"description": (
"The (optionally modified) query to use. "
"Set to null to reject the query."
),
},
"rejection_message": {
"type": ["string", "null"],
"description": (
"Message shown to the user when query is null. "
"Falls back to a generic message if not provided."
),
},
},
"required": ["query"],
}

View File

@@ -0,0 +1,45 @@
from onyx.db.enums import HookPoint
from onyx.hooks.points.base import HookPointSpec
from onyx.hooks.points.document_ingestion import DocumentIngestionSpec
from onyx.hooks.points.query_processing import QueryProcessingSpec
# Internal: use `monkeypatch.setattr(registry_module, "_REGISTRY", {...})` to override in tests.
_REGISTRY: dict[HookPoint, HookPointSpec] = {
HookPoint.DOCUMENT_INGESTION: DocumentIngestionSpec(),
HookPoint.QUERY_PROCESSING: QueryProcessingSpec(),
}
def validate_registry() -> None:
"""Assert that every HookPoint enum value has a registered spec.
Call once at application startup (e.g. from the FastAPI lifespan hook).
Raises RuntimeError if any hook point is missing a spec.
"""
missing = set(HookPoint) - set(_REGISTRY)
if missing:
raise RuntimeError(
f"Hook point(s) have no registered spec: {missing}. "
"Add an entry to onyx.hooks.registry._REGISTRY."
)
def get_hook_point_spec(hook_point: HookPoint) -> HookPointSpec:
"""Returns the spec for a given hook point.
Raises ValueError if the hook point has no registered spec — this is a
programmer error; every HookPoint enum value must have a corresponding spec
in _REGISTRY.
"""
try:
return _REGISTRY[hook_point]
except KeyError:
raise ValueError(
f"No spec registered for hook point {hook_point!r}. "
"Add an entry to onyx.hooks.registry._REGISTRY."
)
def get_all_specs() -> list[HookPointSpec]:
"""Returns the specs for all registered hook points."""
return list(_REGISTRY.values())

View File

@@ -1,6 +1,5 @@
import time
from collections import defaultdict
from collections.abc import Iterable
from http import HTTPStatus
import httpx
@@ -29,7 +28,7 @@ def _log_insufficient_storage_error(e: Exception) -> None:
def write_chunks_to_vector_db_with_backoff(
document_index: DocumentIndex,
chunks: Iterable[DocMetadataAwareIndexChunk],
chunks: list[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> tuple[list[DocumentInsertionRecord], list[ConnectorFailure]]:
"""Tries to insert all chunks in one large batch. If that batch fails for any reason,

View File

@@ -62,6 +62,7 @@ from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.engine.sql_engine import SqlEngine
from onyx.error_handling.exceptions import register_onyx_exception_handlers
from onyx.file_store.file_store import get_default_file_store
from onyx.hooks.registry import validate_registry
from onyx.server.api_key.api import router as api_key_router
from onyx.server.auth_check import check_router_auth
from onyx.server.documents.cc_pair import router as cc_pair_router
@@ -308,6 +309,7 @@ def validate_no_vector_db_settings() -> None:
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001
validate_no_vector_db_settings()
validate_cache_backend_settings()
validate_registry()
# Set recursion limit
if SYSTEM_RECURSION_LIMIT is not None:

View File

@@ -2,6 +2,7 @@ import contextvars
import threading
import uuid
from enum import Enum
from typing import Any
import requests
@@ -152,7 +153,7 @@ def mt_cloud_telemetry(
tenant_id: str,
distinct_id: str,
event: MilestoneRecordType,
properties: dict | None = None,
properties: dict[str, Any] | None = None,
) -> None:
if not MULTI_TENANT:
return
@@ -173,3 +174,18 @@ def mt_cloud_telemetry(
attribute="event_telemetry",
fallback=noop_fallback,
)(distinct_id, event, all_properties)
def mt_cloud_identify(
distinct_id: str,
properties: dict[str, Any] | None = None,
) -> None:
"""Create/update a PostHog person profile (Cloud only)."""
if not MULTI_TENANT:
return
fetch_versioned_implementation_with_fallback(
module="onyx.utils.telemetry",
attribute="identify_user",
fallback=noop_fallback,
)(distinct_id, properties)

View File

@@ -45,6 +45,21 @@ npx playwright test <TEST_NAME>
Shared fixtures live in `backend/tests/conftest.py`. Test subdirectories can define
their own `conftest.py` for directory-scoped fixtures.
## Running Tests Repeatedly (`pytest-repeat`)
Use `pytest-repeat` to catch flaky tests by running them multiple times:
```bash
# Run a specific test 50 times
pytest --count=50 backend/tests/unit/path/to/test.py::test_name
# Stop on first failure with -x
pytest --count=50 -x backend/tests/unit/path/to/test.py::test_name
# Repeat an entire test file
pytest --count=10 backend/tests/unit/path/to/test_file.py
```
## Best Practices
### Use `enable_ee` fixture instead of inlining

View File

@@ -0,0 +1,120 @@
import pytest
from onyx.auth.users import _is_same_origin
class TestExactMatch:
"""Origins that are textually identical should always match."""
@pytest.mark.parametrize(
"origin",
[
"http://localhost:3000",
"https://app.example.com",
"https://app.example.com:8443",
"http://127.0.0.1:8080",
],
)
def test_identical_origins(self, origin: str) -> None:
assert _is_same_origin(origin, origin)
class TestLoopbackPortRelaxation:
"""On loopback addresses, port differences should be ignored."""
@pytest.mark.parametrize(
"actual,expected",
[
("http://localhost:3001", "http://localhost:3000"),
("http://localhost:8080", "http://localhost:3000"),
("http://localhost", "http://localhost:3000"),
("http://127.0.0.1:3001", "http://127.0.0.1:3000"),
("http://[::1]:3001", "http://[::1]:3000"),
],
)
def test_loopback_different_ports_accepted(
self, actual: str, expected: str
) -> None:
assert _is_same_origin(actual, expected)
@pytest.mark.parametrize(
"actual,expected",
[
("https://localhost:3001", "http://localhost:3000"),
("http://localhost:3001", "https://localhost:3000"),
],
)
def test_loopback_different_scheme_rejected(
self, actual: str, expected: str
) -> None:
assert not _is_same_origin(actual, expected)
def test_loopback_hostname_mismatch_rejected(self) -> None:
assert not _is_same_origin("http://localhost:3001", "http://127.0.0.1:3000")
class TestNonLoopbackStrictPort:
"""Non-loopback origins must match scheme, hostname, AND port."""
def test_different_port_rejected(self) -> None:
assert not _is_same_origin(
"https://app.example.com:8443", "https://app.example.com"
)
def test_different_hostname_rejected(self) -> None:
assert not _is_same_origin("https://evil.com", "https://app.example.com")
def test_different_scheme_rejected(self) -> None:
assert not _is_same_origin("http://app.example.com", "https://app.example.com")
def test_same_port_explicit(self) -> None:
assert _is_same_origin(
"https://app.example.com:443", "https://app.example.com:443"
)
class TestDefaultPortNormalization:
"""Port should be normalized so that omitted default port == explicit default port."""
def test_http_implicit_vs_explicit_80(self) -> None:
assert _is_same_origin("http://example.com", "http://example.com:80")
def test_http_explicit_80_vs_implicit(self) -> None:
assert _is_same_origin("http://example.com:80", "http://example.com")
def test_https_implicit_vs_explicit_443(self) -> None:
assert _is_same_origin("https://example.com", "https://example.com:443")
def test_https_explicit_443_vs_implicit(self) -> None:
assert _is_same_origin("https://example.com:443", "https://example.com")
def test_http_non_default_port_vs_implicit_rejected(self) -> None:
assert not _is_same_origin("http://example.com:8080", "http://example.com")
class TestTrailingSlash:
"""Trailing slashes should not affect comparison."""
def test_trailing_slash_on_actual(self) -> None:
assert _is_same_origin("https://app.example.com/", "https://app.example.com")
def test_trailing_slash_on_expected(self) -> None:
assert _is_same_origin("https://app.example.com", "https://app.example.com/")
def test_trailing_slash_on_both(self) -> None:
assert _is_same_origin("https://app.example.com/", "https://app.example.com/")
class TestCSWSHScenarios:
"""Realistic attack scenarios that must be rejected."""
def test_remote_attacker_rejected(self) -> None:
assert not _is_same_origin("https://evil.com", "http://localhost:3000")
def test_remote_attacker_same_port_rejected(self) -> None:
assert not _is_same_origin("http://evil.com:3000", "http://localhost:3000")
def test_remote_attacker_matching_hostname_different_port(self) -> None:
assert not _is_same_origin(
"https://app.example.com:9999", "https://app.example.com"
)

View File

@@ -0,0 +1,194 @@
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.background.celery.tasks.hierarchyfetching.tasks import (
_connector_supports_hierarchy_fetching,
)
from onyx.background.celery.tasks.hierarchyfetching.tasks import (
check_for_hierarchy_fetching,
)
from onyx.connectors.factory import ConnectorMissingException
from onyx.connectors.interfaces import BaseConnector
from onyx.connectors.interfaces import HierarchyConnector
from onyx.connectors.interfaces import HierarchyOutput
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
TASKS_MODULE = "onyx.background.celery.tasks.hierarchyfetching.tasks"
class _NonHierarchyConnector(BaseConnector):
def load_credentials(self, credentials: dict) -> dict | None: # noqa: ARG002
return None
class _HierarchyCapableConnector(HierarchyConnector):
def load_credentials(self, credentials: dict) -> dict | None: # noqa: ARG002
return None
def load_hierarchy(
self,
start: SecondsSinceUnixEpoch, # noqa: ARG002
end: SecondsSinceUnixEpoch, # noqa: ARG002
) -> HierarchyOutput:
return
yield
def _build_cc_pair_mock() -> MagicMock:
cc_pair = MagicMock()
cc_pair.connector.source = "mock-source"
cc_pair.connector.input_type = "mock-input-type"
return cc_pair
def _build_redis_mock_with_lock() -> tuple[MagicMock, MagicMock]:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = True
lock.owned.return_value = True
redis_client.lock.return_value = lock
return redis_client, lock
@patch(f"{TASKS_MODULE}.identify_connector_class")
def test_connector_supports_hierarchy_fetching_false_for_non_hierarchy_connector(
mock_identify_connector_class: MagicMock,
) -> None:
mock_identify_connector_class.return_value = _NonHierarchyConnector
assert _connector_supports_hierarchy_fetching(_build_cc_pair_mock()) is False
mock_identify_connector_class.assert_called_once_with("mock-source")
@patch(f"{TASKS_MODULE}.task_logger.warning")
@patch(f"{TASKS_MODULE}.identify_connector_class")
def test_connector_supports_hierarchy_fetching_false_when_class_missing(
mock_identify_connector_class: MagicMock,
mock_warning: MagicMock,
) -> None:
mock_identify_connector_class.side_effect = ConnectorMissingException("missing")
assert _connector_supports_hierarchy_fetching(_build_cc_pair_mock()) is False
mock_warning.assert_called_once()
@patch(f"{TASKS_MODULE}.identify_connector_class")
def test_connector_supports_hierarchy_fetching_true_for_supported_connector(
mock_identify_connector_class: MagicMock,
) -> None:
mock_identify_connector_class.return_value = _HierarchyCapableConnector
assert _connector_supports_hierarchy_fetching(_build_cc_pair_mock()) is True
mock_identify_connector_class.assert_called_once_with("mock-source")
@patch(f"{TASKS_MODULE}._try_creating_hierarchy_fetching_task")
@patch(f"{TASKS_MODULE}._is_hierarchy_fetching_due")
@patch(f"{TASKS_MODULE}.get_connector_credential_pair_from_id")
@patch(f"{TASKS_MODULE}.fetch_indexable_standard_connector_credential_pair_ids")
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
@patch(f"{TASKS_MODULE}._connector_supports_hierarchy_fetching")
def test_check_for_hierarchy_fetching_skips_unsupported_connectors(
mock_supports_hierarchy_fetching: MagicMock,
mock_get_redis_client: MagicMock,
mock_get_session: MagicMock,
mock_fetch_cc_pair_ids: MagicMock,
mock_get_cc_pair: MagicMock,
mock_is_due: MagicMock,
mock_try_create_task: MagicMock,
) -> None:
redis_client, lock = _build_redis_mock_with_lock()
mock_get_redis_client.return_value = redis_client
mock_get_session.return_value.__enter__.return_value = MagicMock()
mock_fetch_cc_pair_ids.return_value = [123]
mock_get_cc_pair.return_value = _build_cc_pair_mock()
mock_supports_hierarchy_fetching.return_value = False
mock_is_due.return_value = True
task_app = MagicMock()
with patch.object(check_for_hierarchy_fetching, "app", task_app):
result = check_for_hierarchy_fetching.run(tenant_id="test-tenant")
assert result == 0
mock_is_due.assert_not_called()
mock_try_create_task.assert_not_called()
lock.release.assert_called_once()
@patch(f"{TASKS_MODULE}._try_creating_hierarchy_fetching_task")
@patch(f"{TASKS_MODULE}._is_hierarchy_fetching_due")
@patch(f"{TASKS_MODULE}.get_connector_credential_pair_from_id")
@patch(f"{TASKS_MODULE}.fetch_indexable_standard_connector_credential_pair_ids")
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
@patch(f"{TASKS_MODULE}._connector_supports_hierarchy_fetching")
def test_check_for_hierarchy_fetching_creates_task_for_supported_due_connector(
mock_supports_hierarchy_fetching: MagicMock,
mock_get_redis_client: MagicMock,
mock_get_session: MagicMock,
mock_fetch_cc_pair_ids: MagicMock,
mock_get_cc_pair: MagicMock,
mock_is_due: MagicMock,
mock_try_create_task: MagicMock,
) -> None:
redis_client, lock = _build_redis_mock_with_lock()
cc_pair = _build_cc_pair_mock()
db_session = MagicMock()
mock_get_redis_client.return_value = redis_client
mock_get_session.return_value.__enter__.return_value = db_session
mock_fetch_cc_pair_ids.return_value = [123]
mock_get_cc_pair.return_value = cc_pair
mock_supports_hierarchy_fetching.return_value = True
mock_is_due.return_value = True
mock_try_create_task.return_value = "task-id"
task_app = MagicMock()
with patch.object(check_for_hierarchy_fetching, "app", task_app):
result = check_for_hierarchy_fetching.run(tenant_id="test-tenant")
assert result == 1
mock_is_due.assert_called_once_with(cc_pair)
mock_try_create_task.assert_called_once_with(
celery_app=task_app,
cc_pair=cc_pair,
db_session=db_session,
r=redis_client,
tenant_id="test-tenant",
)
lock.release.assert_called_once()
@patch(f"{TASKS_MODULE}._try_creating_hierarchy_fetching_task")
@patch(f"{TASKS_MODULE}._is_hierarchy_fetching_due")
@patch(f"{TASKS_MODULE}.get_connector_credential_pair_from_id")
@patch(f"{TASKS_MODULE}.fetch_indexable_standard_connector_credential_pair_ids")
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
@patch(f"{TASKS_MODULE}._connector_supports_hierarchy_fetching")
def test_check_for_hierarchy_fetching_skips_supported_connector_when_not_due(
mock_supports_hierarchy_fetching: MagicMock,
mock_get_redis_client: MagicMock,
mock_get_session: MagicMock,
mock_fetch_cc_pair_ids: MagicMock,
mock_get_cc_pair: MagicMock,
mock_is_due: MagicMock,
mock_try_create_task: MagicMock,
) -> None:
redis_client, lock = _build_redis_mock_with_lock()
cc_pair = _build_cc_pair_mock()
mock_get_redis_client.return_value = redis_client
mock_get_session.return_value.__enter__.return_value = MagicMock()
mock_fetch_cc_pair_ids.return_value = [123]
mock_get_cc_pair.return_value = cc_pair
mock_supports_hierarchy_fetching.return_value = True
mock_is_due.return_value = False
task_app = MagicMock()
with patch.object(check_for_hierarchy_fetching, "app", task_app):
result = check_for_hierarchy_fetching.run(tenant_id="test-tenant")
assert result == 0
mock_is_due.assert_called_once_with(cc_pair)
mock_try_create_task.assert_not_called()
lock.release.assert_called_once()

View File

@@ -1,208 +0,0 @@
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.access.models import DocumentAccess
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import TextSection
from onyx.document_index.interfaces_new import IndexingMetadata
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.opensearch_document_index import (
OpenSearchDocumentIndex,
)
from onyx.indexing.models import DocMetadataAwareIndexChunk
def _make_chunk(
doc_id: str,
chunk_id: int,
) -> DocMetadataAwareIndexChunk:
"""Creates a minimal DocMetadataAwareIndexChunk for testing."""
doc = Document(
id=doc_id,
sections=[TextSection(text="test", link="http://test.com")],
source=DocumentSource.FILE,
semantic_identifier="test_doc",
metadata={},
)
access = DocumentAccess.build(
user_emails=[],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=True,
)
return DocMetadataAwareIndexChunk(
chunk_id=chunk_id,
blurb="test",
content="test content",
source_links={0: "http://test.com"},
image_file_id=None,
section_continuation=False,
source_document=doc,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
mini_chunk_texts=None,
large_chunk_id=None,
doc_summary="",
chunk_context="",
contextual_rag_reserved_tokens=0,
embeddings={"full_embedding": [0.1] * 10, "mini_chunk_embeddings": []},
title_embedding=[0.1] * 10,
tenant_id="test_tenant",
access=access,
document_sets=set(),
user_project=[],
personas=[],
boost=0,
aggregated_chunk_boost_factor=1.0,
ancestor_hierarchy_node_ids=[],
)
def _make_index() -> OpenSearchDocumentIndex:
"""Creates an OpenSearchDocumentIndex with a mocked client."""
mock_client = MagicMock()
mock_client.bulk_index_documents = MagicMock()
tenant_state = TenantState(tenant_id="test_tenant", multitenant=False)
index = OpenSearchDocumentIndex.__new__(OpenSearchDocumentIndex)
index._index_name = "test_index"
index._client = mock_client
index._tenant_state = tenant_state
return index
def _make_metadata(doc_id: str, chunk_count: int) -> IndexingMetadata:
return IndexingMetadata(
doc_id_to_chunk_cnt_diff={
doc_id: IndexingMetadata.ChunkCounts(
old_chunk_cnt=0,
new_chunk_cnt=chunk_count,
),
},
)
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_single_doc_under_batch_limit_flushes_once() -> None:
"""A document with fewer chunks than CHUNKS_PER_BATCH should flush once."""
index = _make_index()
doc_id = "doc_1"
num_chunks = 50
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
assert index._client.bulk_index_documents.call_count == 1
batch_arg = index._client.bulk_index_documents.call_args_list[0]
assert len(batch_arg.kwargs["documents"]) == num_chunks
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_single_doc_over_batch_limit_flushes_multiple_times() -> None:
"""A document with more chunks than CHUNKS_PER_BATCH should flush multiple times."""
index = _make_index()
doc_id = "doc_1"
num_chunks = 250
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 250 chunks / 100 per batch = 3 flushes (100 + 100 + 50)
assert index._client.bulk_index_documents.call_count == 3
batch_sizes = [
len(call.kwargs["documents"])
for call in index._client.bulk_index_documents.call_args_list
]
assert batch_sizes == [100, 100, 50]
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_single_doc_exactly_at_batch_limit() -> None:
"""A document with exactly CHUNKS_PER_BATCH chunks should flush once
(the flush happens on the next chunk, not at the boundary)."""
index = _make_index()
doc_id = "doc_1"
num_chunks = 100
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 100 chunks hit the >= check on chunk 101 which doesn't exist,
# so final flush handles all 100
# Actually: the elif fires when len(current_chunks) >= 100, which happens
# when current_chunks has 100 items and the 101st chunk arrives.
# With exactly 100 chunks, the 100th chunk makes len == 99, then appended -> 100.
# No 101st chunk arrives, so the final flush handles all 100.
assert index._client.bulk_index_documents.call_count == 1
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_single_doc_one_over_batch_limit() -> None:
"""101 chunks for one doc: first 100 flushed when the 101st arrives, then
the 101st is flushed at the end."""
index = _make_index()
doc_id = "doc_1"
num_chunks = 101
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
assert index._client.bulk_index_documents.call_count == 2
batch_sizes = [
len(call.kwargs["documents"])
for call in index._client.bulk_index_documents.call_args_list
]
assert batch_sizes == [100, 1]
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_multiple_docs_each_under_limit_flush_per_doc() -> None:
"""Multiple documents each under the batch limit should flush once per document."""
index = _make_index()
chunks = []
for doc_idx in range(3):
doc_id = f"doc_{doc_idx}"
for chunk_idx in range(50):
chunks.append(_make_chunk(doc_id, chunk_idx))
metadata = IndexingMetadata(
doc_id_to_chunk_cnt_diff={
f"doc_{i}": IndexingMetadata.ChunkCounts(old_chunk_cnt=0, new_chunk_cnt=50)
for i in range(3)
},
)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 3 documents = 3 flushes (one per doc boundary + final)
assert index._client.bulk_index_documents.call_count == 3
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_delete_called_once_per_document() -> None:
"""Even with multiple flushes for a single document, delete should only be
called once per document."""
index = _make_index()
doc_id = "doc_1"
num_chunks = 250
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0) as mock_delete:
index.index(chunks, metadata)
mock_delete.assert_called_once_with(doc_id, None)

View File

@@ -0,0 +1,22 @@
from typing import Any
import pytest
from onyx.db.enums import HookPoint
from onyx.hooks.points.base import HookPointSpec
def test_init_subclass_raises_for_missing_attrs() -> None:
with pytest.raises(TypeError, match="must define class attributes"):
class IncompleteSpec(HookPointSpec):
hook_point = HookPoint.QUERY_PROCESSING
# missing display_name, description, etc.
@property
def input_schema(self) -> dict[str, Any]:
return {}
@property
def output_schema(self) -> dict[str, Any]:
return {}

View File

@@ -0,0 +1,86 @@
import pytest
from pydantic import ValidationError
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
from onyx.hooks.models import HookCreateRequest
from onyx.hooks.models import HookUpdateRequest
def test_hook_update_request_rejects_empty() -> None:
# No fields supplied at all
with pytest.raises(ValidationError, match="At least one field must be provided"):
HookUpdateRequest()
def test_hook_update_request_rejects_null_name_when_only_field() -> None:
# Explicitly setting name=None is rejected as name cannot be cleared
with pytest.raises(ValidationError, match="name cannot be cleared"):
HookUpdateRequest(name=None)
def test_hook_update_request_accepts_single_field() -> None:
req = HookUpdateRequest(name="new name")
assert req.name == "new name"
def test_hook_update_request_accepts_partial_fields() -> None:
req = HookUpdateRequest(fail_strategy=HookFailStrategy.SOFT, timeout_seconds=10.0)
assert req.fail_strategy == HookFailStrategy.SOFT
assert req.timeout_seconds == 10.0
assert req.name is None
def test_hook_update_request_rejects_null_name() -> None:
with pytest.raises(ValidationError, match="name cannot be cleared"):
HookUpdateRequest(name=None, fail_strategy=HookFailStrategy.SOFT)
def test_hook_update_request_rejects_empty_name() -> None:
with pytest.raises(ValidationError, match="name cannot be cleared"):
HookUpdateRequest(name="", fail_strategy=HookFailStrategy.SOFT)
def test_hook_update_request_rejects_null_endpoint_url() -> None:
with pytest.raises(ValidationError, match="endpoint_url cannot be cleared"):
HookUpdateRequest(endpoint_url=None, fail_strategy=HookFailStrategy.SOFT)
def test_hook_update_request_rejects_empty_endpoint_url() -> None:
with pytest.raises(ValidationError, match="endpoint_url cannot be cleared"):
HookUpdateRequest(endpoint_url="", fail_strategy=HookFailStrategy.SOFT)
def test_hook_update_request_allows_null_api_key() -> None:
# api_key=null is valid — means "clear the api key"
req = HookUpdateRequest(api_key=None)
assert req.api_key is None
assert "api_key" in req.model_fields_set
def test_hook_update_request_rejects_whitespace_name() -> None:
with pytest.raises(ValidationError, match="name cannot be cleared"):
HookUpdateRequest(name=" ", fail_strategy=HookFailStrategy.SOFT)
def test_hook_update_request_rejects_whitespace_endpoint_url() -> None:
with pytest.raises(ValidationError, match="endpoint_url cannot be cleared"):
HookUpdateRequest(endpoint_url=" ", fail_strategy=HookFailStrategy.SOFT)
def test_hook_create_request_rejects_whitespace_name() -> None:
with pytest.raises(ValidationError, match="whitespace-only"):
HookCreateRequest(
name=" ",
hook_point=HookPoint.QUERY_PROCESSING,
endpoint_url="https://example.com/hook",
)
def test_hook_create_request_rejects_whitespace_endpoint_url() -> None:
with pytest.raises(ValidationError, match="whitespace-only"):
HookCreateRequest(
name="my hook",
hook_point=HookPoint.QUERY_PROCESSING,
endpoint_url=" ",
)

View File

@@ -0,0 +1,54 @@
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
from onyx.hooks.points.query_processing import QueryProcessingSpec
def test_hook_point_is_query_processing() -> None:
assert QueryProcessingSpec().hook_point == HookPoint.QUERY_PROCESSING
def test_default_fail_strategy_is_hard() -> None:
assert QueryProcessingSpec().default_fail_strategy == HookFailStrategy.HARD
def test_default_timeout_seconds() -> None:
# User is actively waiting — 5s is the documented contract for this hook point
assert QueryProcessingSpec().default_timeout_seconds == 5.0
def test_input_schema_required_fields() -> None:
schema = QueryProcessingSpec().input_schema
assert schema["type"] == "object"
required = schema["required"]
assert "query" in required
assert "user_email" in required
def test_input_schema_query_is_string() -> None:
props = QueryProcessingSpec().input_schema["properties"]
assert props["query"]["type"] == "string"
def test_input_schema_user_email_is_nullable() -> None:
props = QueryProcessingSpec().input_schema["properties"]
assert "null" in props["user_email"]["type"]
def test_output_schema_query_is_required() -> None:
schema = QueryProcessingSpec().output_schema
assert "query" in schema["required"]
def test_output_schema_query_is_nullable() -> None:
# null means "reject the query"
props = QueryProcessingSpec().output_schema["properties"]
assert "null" in props["query"]["type"]
def test_output_schema_rejection_message_is_optional() -> None:
schema = QueryProcessingSpec().output_schema
assert "rejection_message" not in schema.get("required", [])
def test_input_schema_no_additional_properties() -> None:
assert QueryProcessingSpec().input_schema.get("additionalProperties") is False

View File

@@ -0,0 +1,47 @@
import pytest
from onyx.db.enums import HookPoint
from onyx.hooks import registry as registry_module
from onyx.hooks.registry import get_all_specs
from onyx.hooks.registry import get_hook_point_spec
from onyx.hooks.registry import validate_registry
def test_registry_covers_all_hook_points() -> None:
"""Every HookPoint enum member must have a registered spec."""
assert {s.hook_point for s in get_all_specs()} == set(
HookPoint
), f"Missing specs for: {set(HookPoint) - {s.hook_point for s in get_all_specs()}}"
def test_get_hook_point_spec_returns_correct_spec() -> None:
for hook_point in HookPoint:
spec = get_hook_point_spec(hook_point)
assert spec.hook_point == hook_point
def test_get_all_specs_returns_all() -> None:
specs = get_all_specs()
assert len(specs) == len(HookPoint)
assert {s.hook_point for s in specs} == set(HookPoint)
def test_get_hook_point_spec_raises_for_unregistered(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""get_hook_point_spec raises ValueError when a hook point has no spec."""
monkeypatch.setattr(registry_module, "_REGISTRY", {})
with pytest.raises(ValueError, match="No spec registered for hook point"):
get_hook_point_spec(HookPoint.QUERY_PROCESSING)
def test_validate_registry_passes() -> None:
validate_registry() # should not raise with the real registry
def test_validate_registry_raises_for_incomplete(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(registry_module, "_REGISTRY", {})
with pytest.raises(RuntimeError, match="Hook point\\(s\\) have no registered spec"):
validate_registry()

View File

@@ -55,3 +55,48 @@ def test_mt_cloud_telemetry_calls_event_telemetry_when_multi_tenant(
MilestoneRecordType.USER_MESSAGE_SENT,
{"origin": "web", "tenant_id": "tenant-1"},
)
def test_mt_cloud_identify_noop_when_not_multi_tenant(monkeypatch: Any) -> None:
fetch_impl = Mock()
monkeypatch.setattr(
telemetry_utils,
"fetch_versioned_implementation_with_fallback",
fetch_impl,
)
monkeypatch.setattr("onyx.utils.telemetry.MULTI_TENANT", False)
telemetry_utils.mt_cloud_identify(
distinct_id="12345678-1234-1234-1234-123456789abc",
properties={"email": "user@example.com"},
)
fetch_impl.assert_not_called()
def test_mt_cloud_identify_calls_identify_user_when_multi_tenant(
monkeypatch: Any,
) -> None:
identify_user = Mock()
fetch_impl = Mock(return_value=identify_user)
monkeypatch.setattr(
telemetry_utils,
"fetch_versioned_implementation_with_fallback",
fetch_impl,
)
monkeypatch.setattr("onyx.utils.telemetry.MULTI_TENANT", True)
telemetry_utils.mt_cloud_identify(
distinct_id="12345678-1234-1234-1234-123456789abc",
properties={"email": "user@example.com"},
)
fetch_impl.assert_called_once_with(
module="onyx.utils.telemetry",
attribute="identify_user",
fallback=telemetry_utils.noop_fallback,
)
identify_user.assert_called_once_with(
"12345678-1234-1234-1234-123456789abc",
{"email": "user@example.com"},
)

View File

@@ -32,15 +32,17 @@ def test_run_with_timeout_raises_on_timeout(slow: float, timeout: float) -> None
"""Test that a function that exceeds timeout raises TimeoutError"""
def slow_function() -> None:
time.sleep(slow) # Sleep for 2 seconds
time.sleep(slow)
start = time.monotonic()
with pytest.raises(TimeoutError) as exc_info:
start = time.time()
run_with_timeout(timeout, slow_function) # Set timeout to 0.1 seconds
end = time.time()
assert end - start >= timeout
assert end - start < (slow + timeout) / 2
run_with_timeout(timeout, slow_function)
elapsed = time.monotonic() - start
assert f"timed out after {timeout} seconds" in str(exc_info.value)
assert elapsed >= timeout
# Should return around the timeout duration, not the full sleep duration
assert elapsed == pytest.approx(timeout, abs=0.8)
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")

View File

@@ -15,8 +15,9 @@
# -f docker-compose.dev.yml up -d --wait
#
# This overlay:
# - Moves Vespa (index), both model servers, code-interpreter, Redis (cache),
# and the background worker to profiles so they do not start by default
# - Moves Vespa (index), both model servers, OpenSearch, MinIO,
# Redis (cache), and the background worker to profiles so they do
# not start by default
# - Makes depends_on references to removed services optional
# - Sets DISABLE_VECTOR_DB=true on the api_server
# - Uses PostgreSQL for caching and auth instead of Redis
@@ -27,7 +28,8 @@
# --profile inference Inference model server
# --profile background Background worker (Celery) — also needs redis
# --profile redis Redis cache
# --profile code-interpreter Code interpreter
# --profile opensearch OpenSearch
# --profile s3-filestore MinIO (S3-compatible file store)
# =============================================================================
name: onyx
@@ -38,6 +40,9 @@ services:
index:
condition: service_started
required: false
opensearch:
condition: service_started
required: false
cache:
condition: service_started
required: false
@@ -84,4 +89,10 @@ services:
inference_model_server:
profiles: ["inference"]
code-interpreter: {}
# OpenSearch is not needed in lite mode (no indexing).
opensearch:
profiles: ["opensearch"]
# MinIO is not needed in lite mode (Postgres handles file storage).
minio:
profiles: ["s3-filestore"]

View File

@@ -1,8 +1,8 @@
#!/bin/bash
set -e
set -euo pipefail
# Expected resource requirements
# Expected resource requirements (overridden below if --lite)
EXPECTED_DOCKER_RAM_GB=10
EXPECTED_DISK_GB=32
@@ -10,6 +10,11 @@ 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
USE_LOCAL_FILES=false # Disabled by default, use --local to skip downloading config files
NO_PROMPT=false
DRY_RUN=false
VERBOSE=false
while [[ $# -gt 0 ]]; do
case $1 in
@@ -25,6 +30,26 @@ while [[ $# -gt 0 ]]; do
INCLUDE_CRAFT=true
shift
;;
--lite)
LITE_MODE=true
shift
;;
--local)
USE_LOCAL_FILES=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 ""
@@ -32,15 +57,23 @@ 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 " --local Use existing config files instead of downloading from GitHub"
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 --local # Re-run using existing config files on disk"
echo " $0 --no-prompt # Non-interactive install with defaults"
exit 0
;;
*)
@@ -51,8 +84,129 @@ 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
# When --lite is passed as a flag, lower resource thresholds early (before the
# resource check). When lite is chosen interactively, the thresholds are adjusted
# inside the new-deployment flow, after the resource check has already passed
# with the standard thresholds — which is the safer direction.
if [[ "$LITE_MODE" = true ]]; then
EXPECTED_DOCKER_RAM_GB=4
EXPECTED_DISK_GB=16
fi
INSTALL_ROOT="${INSTALL_PREFIX:-onyx_data}"
LITE_COMPOSE_FILE="docker-compose.onyx-lite.yml"
# Build the -f flags for docker compose.
# Pass "true" as $1 to auto-detect a previously-downloaded lite overlay
# (used by shutdown/delete-data so users don't need to remember --lite).
# Without the argument, the lite overlay is only included when --lite was
# explicitly passed — preventing install/start from silently staying in
# lite mode just because the file exists on disk from a prior run.
compose_file_args() {
local auto_detect="${1:-false}"
local args="-f docker-compose.yml"
if [[ "$LITE_MODE" = true ]] || { [[ "$auto_detect" = true ]] && [[ -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" ]]; }; then
args="$args -f ${LITE_COMPOSE_FILE}"
fi
echo "$args"
}
# --- 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
}
# Ensures a required file is present. With --local, verifies the file exists on
# disk. Otherwise, downloads it from the given URL. Returns 0 on success, 1 on
# failure (caller should handle the exit).
ensure_file() {
local path="$1"
local url="$2"
local desc="$3"
if [[ "$USE_LOCAL_FILES" = true ]]; then
if [[ -f "$path" ]]; then
print_success "Using existing ${desc}"
return 0
fi
print_error "Required file missing: ${desc} (${path})"
return 1
fi
print_info "Downloading ${desc}..."
if download_file "$url" "$path" 2>/dev/null; then
print_success "${desc} downloaded"
return 0
fi
print_error "Failed to download ${desc}"
print_info "Please ensure you have internet connection and try again"
return 1
}
# --- 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 ""
if [[ -z "$REPLY" ]]; then
REPLY="$default_value"
fi
else
REPLY="$default_value"
fi
}
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -111,7 +265,7 @@ if [ "$SHUTDOWN_MODE" = true ]; then
fi
# Stop containers (without removing them)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml stop)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args true) stop)
if [ $? -eq 0 ]; then
print_success "Onyx containers stopped (paused)"
else
@@ -140,12 +294,17 @@ if [ "$DELETE_DATA_MODE" = true ]; then
echo " • All downloaded files and configurations"
echo " • All user data and documents"
echo ""
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
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
fi
print_info "Removing Onyx containers and volumes..."
@@ -164,7 +323,7 @@ if [ "$DELETE_DATA_MODE" = true ]; then
fi
# Stop and remove containers with volumes
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml down -v)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args true) down -v)
if [ $? -eq 0 ]; then
print_success "Onyx containers and volumes removed"
else
@@ -186,6 +345,117 @@ if [ "$DELETE_DATA_MODE" = true ]; then
exit 0
fi
# --- Auto-install Docker (Linux only) ---
# Runs before the banner so a group-based re-exec doesn't repeat it.
install_docker_linux() {
local distro_id=""
if [[ -f /etc/os-release ]]; then
distro_id="$(. /etc/os-release && echo "${ID:-}")"
fi
case "$distro_id" in
amzn)
print_info "Detected Amazon Linux — installing Docker via package manager..."
if command -v dnf &> /dev/null; then
sudo dnf install -y docker
else
sudo yum install -y docker
fi
;;
*)
print_info "Installing Docker via get.docker.com..."
download_file "https://get.docker.com" /tmp/get-docker.sh
sudo sh /tmp/get-docker.sh
rm -f /tmp/get-docker.sh
;;
esac
sudo systemctl start docker 2>/dev/null || sudo service docker start 2>/dev/null || true
sudo systemctl enable docker 2>/dev/null || true
}
# 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 ""
print_success "Dry run complete (no changes made)"
exit 0
fi
if ! command -v docker &> /dev/null; then
if [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; then
install_docker_linux
if ! command -v docker &> /dev/null; then
print_error "Docker installation failed."
echo " Visit: https://docs.docker.com/get-docker/"
exit 1
fi
print_success "Docker installed successfully"
fi
fi
# --- Auto-install Docker Compose plugin (Linux only) ---
if command -v docker &> /dev/null \
&& ! docker compose version &> /dev/null \
&& ! command -v docker-compose &> /dev/null \
&& { [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; }; then
print_info "Docker Compose not found — installing plugin..."
COMPOSE_ARCH="$(uname -m)"
COMPOSE_URL="https://github.com/docker/compose/releases/latest/download/docker-compose-linux-${COMPOSE_ARCH}"
COMPOSE_DIR="/usr/local/lib/docker/cli-plugins"
COMPOSE_TMP="$(mktemp)"
sudo mkdir -p "$COMPOSE_DIR"
if download_file "$COMPOSE_URL" "$COMPOSE_TMP"; then
sudo mv "$COMPOSE_TMP" "$COMPOSE_DIR/docker-compose"
sudo chmod +x "$COMPOSE_DIR/docker-compose"
if docker compose version &> /dev/null; then
print_success "Docker Compose plugin installed"
else
print_error "Docker Compose plugin installed but not detected."
echo " Visit: https://docs.docker.com/compose/install/"
exit 1
fi
else
rm -f "$COMPOSE_TMP"
print_error "Failed to download Docker Compose plugin."
echo " Visit: https://docs.docker.com/compose/install/"
exit 1
fi
fi
# On Linux, ensure the current user can talk to the Docker daemon without
# sudo. If necessary, add them to the "docker" group and re-exec the
# script under that group so the rest of the install proceeds normally.
if command -v docker &> /dev/null \
&& { [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; } \
&& [[ "$(id -u)" -ne 0 ]] \
&& ! docker info &> /dev/null; then
if [[ "${_ONYX_REEXEC:-}" = "1" ]]; then
print_error "Cannot connect to Docker after group re-exec."
print_info "Log out and back in, then run the script again."
exit 1
fi
if ! getent group docker &> /dev/null; then
sudo groupadd docker
fi
print_info "Adding $USER to the docker group..."
sudo usermod -aG docker "$USER"
print_info "Re-launching with docker group active..."
exec sg docker -c "_ONYX_REEXEC=1 bash $(printf '%q ' "$0" "$@")"
fi
# ASCII Art Banner
echo ""
echo -e "${BLUE}${BOLD}"
@@ -209,8 +479,7 @@ echo "2. Check your system resources (Docker, memory, disk space)"
echo "3. Guide you through deployment options (version, authentication)"
echo ""
# Only prompt for acknowledgment if running interactively
if [ -t 0 ]; then
if is_interactive; then
echo -e "${YELLOW}${BOLD}Please acknowledge and press Enter to continue...${NC}"
read -r
echo ""
@@ -260,41 +529,35 @@ else
exit 1
fi
# Function to compare version numbers
# Returns 0 if $1 <= $2, 1 if $1 > $2
# Handles missing or non-numeric parts gracefully (treats them as 0)
version_compare() {
# Returns 0 if $1 <= $2, 1 if $1 > $2
local version1=$1
local version2=$2
local version1="${1:-0.0.0}"
local version2="${2:-0.0.0}"
# 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)
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)
local v2_major=$(echo $version2 | cut -d. -f1)
local v2_minor=$(echo $version2 | cut -d. -f2)
local v2_patch=$(echo $version2 | 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
# Compare major version
if [ "$v1_major" -lt "$v2_major" ]; then
return 0
elif [ "$v1_major" -gt "$v2_major" ]; then
return 1
fi
if [ "$v1_major" -lt "$v2_major" ]; then return 0
elif [ "$v1_major" -gt "$v2_major" ]; 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
if [ "$v1_minor" -lt "$v2_minor" ]; then return 0
elif [ "$v1_minor" -gt "$v2_minor" ]; then return 1; fi
# Compare patch version
if [ "$v1_patch" -le "$v2_patch" ]; then
return 0
else
return 1
fi
[ "$v1_patch" -le "$v2_patch" ]
}
# Check Docker daemon
@@ -336,10 +599,20 @@ fi
# Convert to GB for display
if [ "$MEMORY_MB" -gt 0 ]; then
MEMORY_GB=$((MEMORY_MB / 1024))
print_info "Docker memory allocation: ~${MEMORY_GB}GB"
MEMORY_GB=$(awk "BEGIN {printf \"%.1f\", $MEMORY_MB / 1024}")
if [ "$(awk "BEGIN {print ($MEMORY_MB >= 1024)}")" = "1" ]; then
MEMORY_DISPLAY="~${MEMORY_GB}GB"
else
MEMORY_DISPLAY="${MEMORY_MB}MB"
fi
if [[ "$OSTYPE" == "darwin"* ]]; then
print_info "Docker memory allocation: ${MEMORY_DISPLAY}"
else
print_info "System memory: ${MEMORY_DISPLAY} (Docker uses host memory directly)"
fi
else
print_warning "Could not determine Docker memory allocation"
print_warning "Could not determine memory allocation"
MEMORY_DISPLAY="unknown"
MEMORY_MB=0
fi
@@ -358,7 +631,7 @@ RESOURCE_WARNING=false
EXPECTED_RAM_MB=$((EXPECTED_DOCKER_RAM_GB * 1024))
if [ "$MEMORY_MB" -gt 0 ] && [ "$MEMORY_MB" -lt "$EXPECTED_RAM_MB" ]; then
print_warning "Docker has less than ${EXPECTED_DOCKER_RAM_GB}GB RAM allocated (found: ~${MEMORY_GB}GB)"
print_warning "Less than ${EXPECTED_DOCKER_RAM_GB}GB RAM available (found: ${MEMORY_DISPLAY})"
RESOURCE_WARNING=true
fi
@@ -369,10 +642,10 @@ fi
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 ""
read -p "Do you want to continue anyway? (y/N): " -n 1 -r
print_warning "Onyx recommends at least ${EXPECTED_DOCKER_RAM_GB}GB RAM and ${EXPECTED_DISK_GB}GB disk space for optimal performance in standard mode."
print_warning "Lite mode requires less resources (1-4GB RAM, 8-16GB disk depending on usage), but does not include a vector database."
echo ""
prompt_yn_or_default "Do you want to continue anyway? (Y/n): " "y"
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "Installation cancelled. Please allocate more resources and try again."
exit 1
@@ -385,117 +658,89 @@ print_step "Creating directory structure"
if [ -d "${INSTALL_ROOT}" ]; then
print_info "Directory structure already exists"
print_success "Using existing ${INSTALL_ROOT} directory"
else
mkdir -p "${INSTALL_ROOT}/deployment"
mkdir -p "${INSTALL_ROOT}/data/nginx/local"
print_success "Directory structure created"
fi
mkdir -p "${INSTALL_ROOT}/deployment"
mkdir -p "${INSTALL_ROOT}/data/nginx/local"
print_success "Directory structure created"
# Download all required files
print_step "Downloading Onyx configuration files"
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"
echo " • env.template - Environment variables template"
echo " • nginx/app.conf.template - Nginx web server configuration"
echo " • nginx/run-nginx.sh - Nginx startup script"
echo " • README.md - Documentation and setup instructions"
echo ""
# Download Docker Compose file
COMPOSE_FILE="${INSTALL_ROOT}/deployment/docker-compose.yml"
print_info "Downloading docker-compose.yml..."
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
# Skip check for dev builds (assume they're recent enough)
if [ "$COMPOSE_VERSION" != "dev" ] && version_compare "$COMPOSE_VERSION" "2.24.0"; then
print_warning "Docker Compose version $COMPOSE_VERSION is older than 2.24.0"
echo ""
print_warning "The docker-compose.yml file uses the newer env_file format that requires Docker Compose 2.24.0 or later."
echo ""
print_info "To use this configuration with your current Docker Compose version, you have two options:"
echo ""
echo "1. Upgrade Docker Compose to version 2.24.0 or later (recommended)"
echo " Visit: https://docs.docker.com/compose/install/"
echo ""
echo "2. Manually replace all env_file sections in docker-compose.yml"
echo " Change from:"
echo " env_file:"
echo " - path: .env"
echo " required: false"
echo " To:"
echo " env_file: .env"
echo ""
print_warning "The installation will continue, but may fail if Docker Compose cannot parse the file."
echo ""
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
fi
print_info "Proceeding with installation despite Docker Compose version compatibility issues..."
fi
else
print_error "Failed to download Docker Compose file"
print_info "Please ensure you have internet connection and try again"
exit 1
fi
# Download env.template file
ENV_TEMPLATE="${INSTALL_ROOT}/deployment/env.template"
print_info "Downloading env.template..."
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"
print_info "Please ensure you have internet connection and try again"
exit 1
fi
# Download nginx config files
# Ensure all required configuration files are present
NGINX_BASE_URL="https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deployment/data/nginx"
# Download app.conf.template
NGINX_CONFIG="${INSTALL_ROOT}/data/nginx/app.conf.template"
print_info "Downloading nginx configuration template..."
if curl -fsSL -o "$NGINX_CONFIG" "$NGINX_BASE_URL/app.conf.template" 2>/dev/null; then
print_success "Nginx configuration template downloaded"
if [[ "$USE_LOCAL_FILES" = true ]]; then
print_step "Verifying existing configuration files"
else
print_error "Failed to download nginx configuration template"
print_info "Please ensure you have internet connection and try again"
exit 1
print_step "Downloading Onyx configuration files"
print_info "This step downloads all necessary configuration files from GitHub..."
fi
# Download run-nginx.sh script
NGINX_RUN_SCRIPT="${INSTALL_ROOT}/data/nginx/run-nginx.sh"
print_info "Downloading nginx startup script..."
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
print_error "Failed to download nginx startup script"
print_info "Please ensure you have internet connection and try again"
exit 1
ensure_file "${INSTALL_ROOT}/deployment/docker-compose.yml" \
"${GITHUB_RAW_URL}/docker-compose.yml" "docker-compose.yml" || exit 1
# Check Docker Compose version compatibility after obtaining docker-compose.yml
if [ "$COMPOSE_VERSION" != "dev" ] && version_compare "$COMPOSE_VERSION" "2.24.0"; then
print_warning "Docker Compose version $COMPOSE_VERSION is older than 2.24.0"
echo ""
print_warning "The docker-compose.yml file uses the newer env_file format that requires Docker Compose 2.24.0 or later."
echo ""
print_info "To use this configuration with your current Docker Compose version, you have two options:"
echo ""
echo "1. Upgrade Docker Compose to version 2.24.0 or later (recommended)"
echo " Visit: https://docs.docker.com/compose/install/"
echo ""
echo "2. Manually replace all env_file sections in docker-compose.yml"
echo " Change from:"
echo " env_file:"
echo " - path: .env"
echo " required: false"
echo " To:"
echo " env_file: .env"
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"
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "Installation cancelled. Please upgrade Docker Compose or manually edit the docker-compose.yml file."
exit 1
fi
print_info "Proceeding with installation despite Docker Compose version compatibility issues..."
fi
# Download README file
README_FILE="${INSTALL_ROOT}/README.md"
print_info "Downloading README.md..."
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"
print_info "Please ensure you have internet connection and try again"
exit 1
# Handle lite overlay: ensure it if --lite, clean up stale copies otherwise
if [[ "$LITE_MODE" = true ]]; then
ensure_file "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" \
"${GITHUB_RAW_URL}/${LITE_COMPOSE_FILE}" "${LITE_COMPOSE_FILE}" || exit 1
elif [[ -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" ]]; then
if [[ -f "${INSTALL_ROOT}/deployment/.env" ]]; then
print_warning "Existing lite overlay found but --lite was not passed."
prompt_yn_or_default "Remove lite overlay and switch to standard mode? (y/N): " "n"
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "Keeping existing lite overlay. Pass --lite to keep using lite mode."
LITE_MODE=true
else
rm -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
print_info "Removed lite overlay (switching to standard mode)"
fi
else
rm -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
print_info "Removed previous lite overlay (switching to standard mode)"
fi
fi
# Create empty local directory marker (if needed)
ensure_file "${INSTALL_ROOT}/deployment/env.template" \
"${GITHUB_RAW_URL}/env.template" "env.template" || exit 1
ensure_file "${INSTALL_ROOT}/data/nginx/app.conf.template" \
"$NGINX_BASE_URL/app.conf.template" "nginx/app.conf.template" || exit 1
ensure_file "${INSTALL_ROOT}/data/nginx/run-nginx.sh" \
"$NGINX_BASE_URL/run-nginx.sh" "nginx/run-nginx.sh" || exit 1
chmod +x "${INSTALL_ROOT}/data/nginx/run-nginx.sh"
ensure_file "${INSTALL_ROOT}/README.md" \
"${GITHUB_RAW_URL}/README.md" "README.md" || exit 1
touch "${INSTALL_ROOT}/data/nginx/local/.gitkeep"
print_success "All configuration files downloaded successfully"
print_success "All configuration files ready"
# Set up deployment configuration
print_step "Setting up deployment configs"
@@ -513,7 +758,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 -f docker-compose.yml ps -q 2>/dev/null | wc -l)
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args true) ps -q 2>/dev/null | wc -l)
if [ "$RUNNING_CONTAINERS" -gt 0 ]; then
print_error "Onyx services are currently running!"
echo ""
@@ -534,7 +779,7 @@ if [ -f "$ENV_FILE" ]; then
echo "• Press Enter to restart with current configuration"
echo "• Type 'update' to update to a newer version"
echo ""
read -p "Choose an option [default: restart]: " -r
prompt_or_default "Choose an option [default: restart]: " ""
echo ""
if [ "$REPLY" = "update" ]; then
@@ -543,26 +788,30 @@ 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
read -p "Enter tag [default: craft-latest]: " -r VERSION
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
VERSION="$REPLY"
else
read -p "Enter tag [default: latest]: " -r VERSION
prompt_or_default "Enter tag [default: latest]: " "latest"
VERSION="$REPLY"
fi
echo ""
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
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
print_info "Selected: craft-latest (Craft enabled)"
elif [ "$VERSION" = "latest" ]; then
print_info "Selected: Latest version"
else
print_info "Selected: $VERSION"
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
# Update .env file with new version
print_info "Updating configuration for version $VERSION..."
if grep -q "^IMAGE_TAG=" "$ENV_FILE"; then
@@ -581,13 +830,67 @@ if [ -f "$ENV_FILE" ]; then
fi
print_success "Configuration updated for upgrade"
else
# Reject restarting a craft deployment in lite mode
EXISTING_TAG=$(grep "^IMAGE_TAG=" "$ENV_FILE" | head -1 | cut -d'=' -f2 | tr -d ' "'"'"'')
if [[ "$LITE_MODE" = true ]] && [[ "${EXISTING_TAG:-}" == craft-* ]]; then
print_error "Cannot restart a craft deployment (${EXISTING_TAG}) with --lite."
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
exit 1
fi
print_info "Keeping existing configuration..."
print_success "Will restart with current settings"
fi
# Ensure COMPOSE_PROFILES is cleared when running in lite mode on an
# existing .env (the template ships with s3-filestore enabled).
if [[ "$LITE_MODE" = true ]] && grep -q "^COMPOSE_PROFILES=.*s3-filestore" "$ENV_FILE" 2>/dev/null; then
sed -i.bak 's/^COMPOSE_PROFILES=.*/COMPOSE_PROFILES=/' "$ENV_FILE" 2>/dev/null || true
print_success "Cleared COMPOSE_PROFILES for lite mode"
fi
else
print_info "No existing .env file found. Setting up new deployment..."
echo ""
# Ask for deployment mode (standard vs lite) unless already set via --lite flag
if [[ "$LITE_MODE" = false ]]; then
print_info "Which deployment mode would you like?"
echo ""
echo " 1) Standard - Full deployment with search, connectors, and RAG"
echo " 2) Lite - Minimal deployment (no Vespa, Redis, or model servers)"
echo " LLM chat, tools, file uploads, and Projects still work"
echo ""
prompt_or_default "Choose a mode (1 or 2) [default: 1]: " "1"
echo ""
case "$REPLY" in
2)
LITE_MODE=true
print_info "Selected: Lite mode"
ensure_file "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" \
"${GITHUB_RAW_URL}/${LITE_COMPOSE_FILE}" "${LITE_COMPOSE_FILE}" || exit 1
;;
*)
print_info "Selected: Standard mode"
;;
esac
else
print_info "Deployment mode: Lite (set via --lite flag)"
fi
# Validate lite + craft combination (could now be set interactively)
if [[ "$LITE_MODE" = true ]] && [[ "$INCLUDE_CRAFT" = true ]]; then
print_error "--include-craft cannot be used with Lite mode."
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
exit 1
fi
# Adjust resource expectations for lite mode
if [[ "$LITE_MODE" = true ]]; then
EXPECTED_DOCKER_RAM_GB=4
EXPECTED_DISK_GB=16
fi
# Ask for version
print_info "Which tag would you like to deploy?"
echo ""
@@ -595,23 +898,21 @@ else
echo "• Press Enter for craft-latest (recommended for Craft)"
echo "• Type a specific tag (e.g., craft-v1.0.0)"
echo ""
read -p "Enter tag [default: craft-latest]: " -r VERSION
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
VERSION="$REPLY"
else
echo "• Press Enter for latest (recommended)"
echo "• Type a specific tag (e.g., v0.1.0)"
echo ""
read -p "Enter tag [default: latest]: " -r VERSION
prompt_or_default "Enter tag [default: latest]: " "latest"
VERSION="$REPLY"
fi
echo ""
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
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
print_info "Selected: craft-latest (Craft enabled)"
elif [ "$VERSION" = "latest" ]; then
print_info "Selected: Latest tag"
else
print_info "Selected: $VERSION"
fi
@@ -645,6 +946,13 @@ else
# Use basic auth by default
AUTH_SCHEMA="basic"
# Reject craft image tags when running in lite mode (must check before writing .env)
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
# Create .env file from template
print_info "Creating .env file with your selections..."
cp "$ENV_TEMPLATE" "$ENV_FILE"
@@ -654,6 +962,13 @@ else
sed -i.bak "s/^IMAGE_TAG=.*/IMAGE_TAG=$VERSION/" "$ENV_FILE"
print_success "IMAGE_TAG set to $VERSION"
# In lite mode, clear COMPOSE_PROFILES so profiled services (MinIO, etc.)
# stay disabled — the template ships with s3-filestore enabled by default.
if [[ "$LITE_MODE" = true ]]; then
sed -i.bak 's/^COMPOSE_PROFILES=.*/COMPOSE_PROFILES=/' "$ENV_FILE" 2>/dev/null || true
print_success "Cleared COMPOSE_PROFILES for lite mode"
fi
# Configure basic authentication (default)
sed -i.bak 's/^AUTH_TYPE=.*/AUTH_TYPE=basic/' "$ENV_FILE" 2>/dev/null || true
print_success "Basic authentication enabled in configuration"
@@ -774,7 +1089,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 -f docker-compose.yml pull --quiet)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) pull --quiet)
if [ $? -eq 0 ]; then
print_success "Docker images downloaded successfully"
else
@@ -788,9 +1103,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 -f docker-compose.yml up -d --pull always --force-recreate)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d --pull always --force-recreate)
else
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d)
fi
if [ $? -ne 0 ]; then
print_error "Failed to start Onyx services"
@@ -812,7 +1127,7 @@ echo ""
# Check for restart loops
print_info "Checking container health status..."
RESTART_ISSUES=false
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null)
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) ps -q 2>/dev/null)
for CONTAINER in $CONTAINERS; do
PROJECT_NAME="$(basename "$INSTALL_ROOT")_deployment_"
@@ -841,7 +1156,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 -f docker-compose.yml logs)"
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD $(compose_file_args) logs)"
echo ""
print_info "If the issue persists, please contact: founders@onyx.app"
@@ -860,8 +1175,12 @@ check_onyx_health() {
echo ""
while [ $attempt -le $max_attempts ]; do
# Check for successful HTTP responses (200, 301, 302, etc.)
local http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port")
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
if echo "$http_code" | grep -qE "^(200|301|302|303|307|308)$"; then
return 0
fi
@@ -917,6 +1236,18 @@ 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"

View File

@@ -45,3 +45,10 @@ export {
EmptyMessageCard,
type EmptyMessageCardProps,
} from "@opal/components/cards/empty-message-card/components";
/* Pagination */
export {
Pagination,
type PaginationProps,
type PaginationSize,
} from "@opal/components/pagination/components";

View File

@@ -0,0 +1,239 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Pagination } from "@opal/components";
import { useState } from "react";
const meta: Meta<typeof Pagination> = {
title: "opal/components/Pagination",
component: Pagination,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Pagination>;
// ===========================================================================
// variant="simple"
// ===========================================================================
export const Simple: Story = {
args: {
variant: "simple",
currentPage: 3,
totalPages: 10,
},
};
export const SimpleSmall: Story = {
args: {
variant: "simple",
currentPage: 2,
totalPages: 8,
size: "sm",
},
};
export const SimpleWithUnits: Story = {
args: {
variant: "simple",
currentPage: 1,
totalPages: 5,
units: "pages",
},
};
export const SimpleArrowsOnly: Story = {
args: {
variant: "simple",
currentPage: 2,
totalPages: 8,
hidePages: true,
},
};
export const SimpleAllSizes: Story = {
render: () => (
<div className="flex flex-col gap-4 items-start">
{(["lg", "md", "sm"] as const).map((size) => (
<div key={size} className="flex flex-col gap-1">
<span className="font-secondary-body text-text-03">
size=&quot;{size}&quot;
</span>
<Pagination
variant="simple"
currentPage={3}
totalPages={10}
size={size}
/>
</div>
))}
</div>
),
};
// ===========================================================================
// variant="count"
// ===========================================================================
export const Count: Story = {
args: {
variant: "count",
pageSize: 10,
totalItems: 95,
currentPage: 2,
totalPages: 10,
},
};
export const CountWithUnits: Story = {
args: {
variant: "count",
pageSize: 25,
totalItems: 203,
currentPage: 1,
totalPages: 9,
units: "items",
},
};
export const CountArrowsOnly: Story = {
args: {
variant: "count",
pageSize: 10,
totalItems: 50,
currentPage: 2,
totalPages: 5,
hidePages: true,
},
};
export const CountAllSizes: Story = {
render: () => (
<div className="flex flex-col gap-4 items-start">
{(["lg", "md", "sm"] as const).map((size) => (
<div key={size} className="flex flex-col gap-1">
<span className="font-secondary-body text-text-03">
size=&quot;{size}&quot;
</span>
<Pagination
variant="count"
pageSize={10}
totalItems={95}
currentPage={3}
totalPages={10}
size={size}
units="items"
/>
</div>
))}
</div>
),
};
// ===========================================================================
// variant="list" (default)
// ===========================================================================
export const List: Story = {
args: {
currentPage: 5,
totalPages: 20,
onChange: () => {},
},
};
export const ListFewPages: Story = {
args: {
currentPage: 2,
totalPages: 4,
onChange: () => {},
},
};
export const ListAllSizes: Story = {
render: () => (
<div className="flex flex-col gap-4 items-start">
{(["lg", "md", "sm"] as const).map((size) => (
<div key={size} className="flex flex-col gap-1">
<span className="font-secondary-body text-text-03">
size=&quot;{size}&quot;
</span>
<Pagination
currentPage={3}
totalPages={10}
onChange={() => {}}
size={size}
/>
</div>
))}
</div>
),
};
// ===========================================================================
// Interactive
// ===========================================================================
function InteractiveSimpleDemo() {
const [page, setPage] = useState(1);
return (
<div className="flex flex-col gap-4 items-start">
<Pagination
variant="simple"
currentPage={page}
totalPages={15}
onChange={setPage}
units="pages"
/>
<span className="font-secondary-body text-text-03">
Current page: {page}
</span>
</div>
);
}
export const InteractiveSimple: Story = {
render: () => <InteractiveSimpleDemo />,
};
function InteractiveListDemo() {
const [page, setPage] = useState(1);
return (
<div className="flex flex-col gap-4 items-start">
<Pagination currentPage={page} totalPages={15} onChange={setPage} />
<span className="font-secondary-body text-text-03">
Current page: {page}
</span>
</div>
);
}
export const InteractiveList: Story = {
render: () => <InteractiveListDemo />,
};
function InteractiveCountDemo() {
const [page, setPage] = useState(1);
const pageSize = 10;
const totalItems = 95;
const totalPages = Math.ceil(totalItems / pageSize);
return (
<div className="flex flex-col gap-4 items-start">
<Pagination
variant="count"
currentPage={page}
totalPages={totalPages}
pageSize={pageSize}
totalItems={totalItems}
onChange={setPage}
units="items"
/>
<span className="font-secondary-body text-text-03">
Current page: {page}
</span>
</div>
);
}
export const InteractiveCount: Story = {
render: () => <InteractiveCountDemo />,
};

View File

@@ -0,0 +1,89 @@
# Pagination
**Import:** `import { Pagination, type PaginationProps } from "@opal/components";`
Page navigation with three display variants and prev/next arrow controls.
## Variants
### `"list"` (default)
Numbered page buttons with ellipsis truncation for large page counts.
```tsx
<Pagination currentPage={3} totalPages={10} onChange={setPage} />
```
### `"simple"`
Compact `currentPage/totalPages` display with prev/next arrows. Can be reduced to just arrows via `hidePages`.
```tsx
// With summary (default)
<Pagination variant="simple" currentPage={1} totalPages={5} onChange={setPage} />
// Arrows only
<Pagination variant="simple" currentPage={1} totalPages={5} onChange={setPage} hidePages />
// With units
<Pagination variant="simple" currentPage={1} totalPages={5} onChange={setPage} units="pages" />
```
### `"count"`
Item-count display (`X~Y of Z`) with prev/next arrows. Designed for table footers.
```tsx
// Basic
<Pagination
variant="count"
pageSize={10}
totalItems={95}
currentPage={2}
totalPages={10}
onChange={setPage}
/>
// With units
<Pagination
variant="count"
pageSize={10}
totalItems={95}
currentPage={2}
totalPages={10}
onChange={setPage}
units="items"
/>
```
## Props (shared)
| Prop | Type | Default | Description |
|---|---|---|---|
| `variant` | `"list" \| "simple" \| "count"` | `"list"` | Display variant |
| `currentPage` | `number` | **(required)** | 1-based current page number |
| `totalPages` | `number` | **(required)** | Total number of pages |
| `onChange` | `(page: number) => void` | — | Called when the page changes |
| `size` | `PaginationSize` | `"lg"` | Button and text sizing |
## Props (variant-specific)
### `"simple"`
| Prop | Type | Default | Description |
|---|---|---|---|
| `hidePages` | `boolean` | `false` | Hides the `currentPage/totalPages` text between arrows |
| `units` | `string` | — | Label after the summary (e.g. `"pages"`), always 4px spacing |
### `"count"`
| Prop | Type | Default | Description |
|---|---|---|---|
| `pageSize` | `number` | **(required)** | Items per page (for range calculation) |
| `totalItems` | `number` | **(required)** | Total item count |
| `hidePages` | `boolean` | `false` | Hides the current page number between arrows |
| `units` | `string` | — | Label after the total (e.g. `"items"`), always 4px spacing |
### `PaginationSize`
`"lg" | "md" | "sm"`

View File

@@ -0,0 +1,537 @@
"use client";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { SvgArrowRight, SvgChevronLeft, SvgChevronRight } from "@opal/icons";
import { sizeVariants } from "@opal/shared";
import type { WithoutStyles } from "@opal/types";
import { cn } from "@opal/utils";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import {
useState,
type ChangeEvent,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type PaginationSize = "lg" | "md" | "sm";
/**
* Compact `currentPage / totalPages` display with prev/next arrows.
*/
interface SimplePaginationProps
extends Omit<WithoutStyles<HTMLAttributes<HTMLDivElement>>, "onChange"> {
variant: "simple";
/** The 1-based current page number. */
currentPage: number;
/** Total number of pages. */
totalPages: number;
/** Called when the page changes. */
onChange?: (page: number) => void;
/** Controls button and text sizing. Default: `"lg"`. */
size?: PaginationSize;
/** Hides the `currentPage/totalPages` summary text between arrows. Default: `false`. */
hidePages?: boolean;
/** Unit label shown after the summary (e.g. `"pages"`). Always has 4px spacing. */
units?: string;
}
/**
* Item-count display (`X~Y of Z`) with prev/next arrows.
* Designed for table footers.
*/
interface CountPaginationProps
extends Omit<WithoutStyles<HTMLAttributes<HTMLDivElement>>, "onChange"> {
variant: "count";
/** The 1-based current page number. */
currentPage: number;
/** Total number of pages. */
totalPages: number;
/** Number of items displayed per page. Used to compute the visible range. */
pageSize: number;
/** Total number of items across all pages. */
totalItems: number;
/** Called when the page changes. */
onChange?: (page: number) => void;
/** Controls button and text sizing. Default: `"lg"`. */
size?: PaginationSize;
/** Hides the current page number between the arrows. Default: `false`. */
hidePages?: boolean;
/** Unit label shown after the total count (e.g. `"items"`). Always has 4px spacing. */
units?: string;
}
/**
* Numbered page buttons with ellipsis truncation for large page counts.
* This is the default variant.
*/
interface ListPaginationProps
extends Omit<WithoutStyles<HTMLAttributes<HTMLDivElement>>, "onChange"> {
variant?: "list";
/** The 1-based current page number. */
currentPage: number;
/** Total number of pages. */
totalPages: number;
/** Called when the page changes. */
onChange: (page: number) => void;
/** Controls button and text sizing. Default: `"lg"`. */
size?: PaginationSize;
}
/**
* Discriminated union of all pagination variants.
* Use `variant` to select between `"simple"`, `"count"`, and `"list"` (default).
*/
type PaginationProps =
| SimplePaginationProps
| CountPaginationProps
| ListPaginationProps;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Computes the page numbers to display.
*
* - <=7 pages: render all pages individually (no ellipsis).
* - >7 pages: always render exactly 7 slots (numbers or ellipsis).
* First and last page are always shown. Ellipsis takes one slot.
*
* Examples for totalPages=20:
* - page 1: `1 2 3 4 5 ... 20`
* - page 4: `1 2 3 4 5 ... 20`
* - page 5: `1 ... 4 5 6 ... 20`
* - page 16: `1 ... 15 16 17 ... 20`
* - page 17: `1 ... 16 17 18 19 20`
* - page 20: `1 ... 16 17 18 19 20`
*/
function getPageNumbers(
currentPage: number,
totalPages: number
): (number | string)[] {
if (totalPages <= 7) {
const pages: number[] = [];
for (let i = 1; i <= totalPages; i++) pages.push(i);
return pages;
}
// Always 7 slots. First and last are always page 1 and totalPages.
// That leaves 5 inner slots.
// Near the start: no start-ellipsis needed
// Slots: 1, 2, 3, 4, 5, ..., totalPages
if (currentPage <= 4) {
return [1, 2, 3, 4, 5, "end-ellipsis", totalPages];
}
// Near the end: no end-ellipsis needed
// Slots: 1, ..., tp-4, tp-3, tp-2, tp-1, tp
if (currentPage >= totalPages - 3) {
return [
1,
"start-ellipsis",
totalPages - 4,
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages,
];
}
// Middle: both ellipses
// Slots: 1, ..., cur-1, cur, cur+1, ..., totalPages
return [
1,
"start-ellipsis",
currentPage - 1,
currentPage,
currentPage + 1,
"end-ellipsis",
totalPages,
];
}
function monoClass(size: PaginationSize): string {
return size === "sm" ? "font-secondary-mono" : "font-main-ui-mono";
}
function textClasses(size: PaginationSize, style: "mono" | "muted"): string {
if (style === "mono") return monoClass(size);
return size === "sm" ? "font-secondary-body" : "font-main-ui-muted";
}
const PAGE_NUMBER_FONT: Record<
PaginationSize,
{ active: string; inactive: string }
> = {
lg: {
active: "font-main-ui-body text-text-04",
inactive: "font-main-ui-muted text-text-02",
},
md: {
active: "font-secondary-action text-text-04",
inactive: "font-secondary-body text-text-02",
},
sm: {
active: "font-secondary-action text-text-04",
inactive: "font-secondary-body text-text-02",
},
};
// ---------------------------------------------------------------------------
// GoToPagePopup
// ---------------------------------------------------------------------------
interface GoToPagePopupProps {
totalPages: number;
onSubmit: (page: number) => void;
children: ReactNode;
}
function GoToPagePopup({ totalPages, onSubmit, children }: GoToPagePopupProps) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const parsed = parseInt(value, 10);
const isValid = !isNaN(parsed) && parsed >= 1 && parsed <= totalPages;
function handleChange(e: ChangeEvent<HTMLInputElement>) {
const raw = e.target.value;
if (raw === "" || /^\d+$/.test(raw)) {
setValue(raw);
}
}
function handleSubmit() {
if (!isValid) return;
onSubmit(parsed);
setOpen(false);
setValue("");
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
handleSubmit();
}
}
return (
<PopoverPrimitive.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setValue("");
}}
>
<PopoverPrimitive.Trigger asChild>{children}</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
className={cn(
"flex items-center gap-1 p-1",
"bg-background-neutral-00 rounded-12 border border-border-01 shadow-md z-popover",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
)}
sideOffset={4}
>
{/* TODO(@raunakab): migrate this input to the opal Input component once inputs have been migrated into Opal */}
<input
type="text"
inputMode="numeric"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Go to page"
autoFocus
className={cn(
"w-[7rem] bg-transparent px-1.5 py-1 rounded-08",
sizeVariants.lg.height,
"border border-border-02 focus:outline-none focus:border-border-04",
"font-main-ui-body",
"text-text-04 placeholder:text-text-02"
)}
/>
<Disabled disabled={!isValid}>
<Button
icon={SvgArrowRight}
size="lg"
onClick={handleSubmit}
tooltip="Go to page"
/>
</Disabled>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}
// ---------------------------------------------------------------------------
// Nav buttons (shared across all variants)
// ---------------------------------------------------------------------------
interface NavButtonsProps {
currentPage: number;
totalPages: number;
onChange: (page: number) => void;
size: PaginationSize;
children?: ReactNode;
}
function NavButtons({
currentPage,
totalPages,
onChange,
size,
children,
}: NavButtonsProps) {
return (
<>
<Disabled disabled={currentPage <= 1}>
<Button
icon={SvgChevronLeft}
onClick={() => onChange(Math.max(1, currentPage - 1))}
size={size}
prominence="tertiary"
tooltip="Previous page"
/>
</Disabled>
{children}
<Disabled disabled={currentPage >= totalPages}>
<Button
icon={SvgChevronRight}
onClick={() => onChange(Math.min(totalPages, currentPage + 1))}
size={size}
prominence="tertiary"
tooltip="Next page"
/>
</Disabled>
</>
);
}
// ---------------------------------------------------------------------------
// PaginationSimple
// ---------------------------------------------------------------------------
function PaginationSimple({
currentPage,
totalPages,
onChange,
size = "lg",
hidePages = false,
units,
...props
}: SimplePaginationProps) {
const handleChange = (page: number) => onChange?.(page);
const label = `${currentPage}/${totalPages}${units ? ` ${units}` : ""}`;
return (
<div {...props} className="flex items-center">
<NavButtons
currentPage={currentPage}
totalPages={totalPages}
onChange={handleChange}
size={size}
>
{!hidePages && (
<GoToPagePopup totalPages={totalPages} onSubmit={handleChange}>
<Button size={size} prominence="tertiary">
{label}
</Button>
</GoToPagePopup>
)}
</NavButtons>
</div>
);
}
// ---------------------------------------------------------------------------
// PaginationCount
// ---------------------------------------------------------------------------
function PaginationCount({
pageSize,
totalItems,
currentPage,
totalPages,
onChange,
size = "lg",
hidePages = false,
units,
...props
}: CountPaginationProps) {
const handleChange = (page: number) => onChange?.(page);
const rangeStart = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const rangeEnd = Math.min(currentPage * pageSize, totalItems);
return (
<div {...props} className="flex items-center gap-1">
{/* Summary: range of total [units] */}
<span
className={cn(
"inline-flex items-center gap-1",
monoClass(size),
"text-text-03"
)}
>
{rangeStart}~{rangeEnd}
<span className={textClasses(size, "muted")}>of</span>
{totalItems}
{units && <span className="ml-1">{units}</span>}
</span>
{/* Buttons: < [page] > */}
<div className="flex items-center">
<NavButtons
currentPage={currentPage}
totalPages={totalPages}
onChange={handleChange}
size={size}
>
{!hidePages && (
<GoToPagePopup totalPages={totalPages} onSubmit={handleChange}>
<Button size={size} prominence="tertiary">
{String(currentPage)}
</Button>
</GoToPagePopup>
)}
</NavButtons>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// PaginationList (default)
// ---------------------------------------------------------------------------
function PaginationList({
currentPage,
totalPages,
onChange,
size = "lg",
...props
}: ListPaginationProps) {
const pageNumbers = getPageNumbers(currentPage, totalPages);
const fonts = PAGE_NUMBER_FONT[size];
return (
<div {...props} className="flex items-center gap-1">
<NavButtons
currentPage={currentPage}
totalPages={totalPages}
onChange={onChange}
size={size}
>
<div className="flex items-center">
{pageNumbers.map((page) => {
if (typeof page === "string") {
return (
<GoToPagePopup
key={page}
totalPages={totalPages}
onSubmit={onChange}
>
<Button
size={size}
prominence="tertiary"
icon={({ className: iconClassName }) => (
<div
className={cn(
iconClassName,
"flex flex-col justify-center",
fonts.inactive
)}
>
...
</div>
)}
/>
</GoToPagePopup>
);
}
const isActive = page === currentPage;
return (
<Button
key={page}
onClick={() => onChange(page)}
size={size}
prominence="tertiary"
interaction={isActive ? "hover" : "rest"}
icon={({ className: iconClassName }) => (
<div
className={cn(
iconClassName,
"flex flex-col justify-center",
isActive ? fonts.active : fonts.inactive
)}
>
{page}
</div>
)}
/>
);
})}
</div>
</NavButtons>
</div>
);
}
// ---------------------------------------------------------------------------
// Pagination (entry point)
// ---------------------------------------------------------------------------
/**
* Page navigation component with three variants:
*
* - `"list"` (default) — Numbered page buttons with ellipsis truncation.
* - `"simple"` — Compact `currentPage / totalPages` with prev/next arrows.
* - `"count"` — Item-count display (`X~Y of Z`) with prev/next arrows.
*
* All variants include a "go to page" popup activated by clicking on the
* page indicator (simple/count) or the ellipsis (list).
*
* @example
* ```tsx
* // List (default)
* <Pagination currentPage={3} totalPages={10} onChange={setPage} />
*
* // Simple
* <Pagination variant="simple" currentPage={1} totalPages={5} onChange={setPage} />
*
* // Count
* <Pagination variant="count" pageSize={10} totalItems={95} currentPage={2} totalPages={10} onChange={setPage} />
* ```
*/
function Pagination(props: PaginationProps) {
const normalized = {
...props,
totalPages: Math.max(1, props.totalPages),
currentPage: Math.max(
1,
Math.min(props.currentPage, Math.max(1, props.totalPages))
),
};
const variant = normalized.variant ?? "list";
switch (variant) {
case "simple":
return <PaginationSimple {...(normalized as SimplePaginationProps)} />;
case "count":
return <PaginationCount {...(normalized as CountPaginationProps)} />;
case "list":
return <PaginationList {...(normalized as ListPaginationProps)} />;
}
}
export { Pagination, type PaginationProps, type PaginationSize };

View File

@@ -13,7 +13,7 @@ import { ThreeDotsLoader } from "@/components/Loading";
import { ErrorCallout } from "@/components/ErrorCallout";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { useState, useEffect } from "react";
import Pagination from "@/refresh-components/Pagination";
import { Pagination } from "@opal/components";
const route = ADMIN_ROUTES.AGENTS;
const PAGE_SIZE = 20;
@@ -90,7 +90,7 @@ function MainContent({
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
onChange={onPageChange}
/>
)}
</>

View File

@@ -7,7 +7,7 @@ import {
SourceMetadata,
} from "@/lib/search/interfaces";
import SearchCard from "@/ee/sections/SearchCard";
import Pagination from "@/refresh-components/Pagination";
import { Pagination } from "@opal/components";
import Separator from "@/refresh-components/Separator";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { IllustrationContent } from "@opal/layouts";
@@ -391,11 +391,13 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
{/* ── Bottom row: Pagination ── */}
{!showEmpty && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
<Section height="fit">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onChange={setCurrentPage}
/>
</Section>
)}
</div>
);

View File

@@ -176,7 +176,10 @@ function AttachmentItemLayout({
<Section flexDirection="row" gap={0.25} padding={0.25}>
<div className={cn("h-[2.25rem] aspect-square rounded-08")}>
<Section>
<div className="attachment-button__icon-wrapper">
<div
className="attachment-button__icon-wrapper"
data-testid="attachment-item-icon-wrapper"
>
<Icon className="attachment-button__icon" />
</div>
</Section>
@@ -187,7 +190,7 @@ function AttachmentItemLayout({
alignItems="center"
gap={1.5}
>
<div className="flex-1 min-w-0">
<div data-testid="attachment-item-title" className="flex-1 min-w-0">
<Content
title={title}
description={description}

View File

@@ -1,57 +0,0 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import Pagination from "./Pagination";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
const meta: Meta<typeof Pagination> = {
title: "refresh-components/Pagination",
component: Pagination,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<Story />
</TooltipPrimitive.Provider>
),
],
};
export default meta;
type Story = StoryObj<typeof Pagination>;
function PaginationDemo({
totalPages,
initialPage = 1,
}: {
totalPages: number;
initialPage?: number;
}) {
const [page, setPage] = React.useState(initialPage);
return (
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
export const Default: Story = {
render: () => <PaginationDemo totalPages={10} />,
};
export const FewPages: Story = {
render: () => <PaginationDemo totalPages={5} />,
};
export const ManyPages: Story = {
render: () => <PaginationDemo totalPages={50} initialPage={25} />,
};
export const FirstPage: Story = {
render: () => <PaginationDemo totalPages={20} initialPage={1} />,
};
export const LastPage: Story = {
render: () => <PaginationDemo totalPages={20} initialPage={20} />,
};

View File

@@ -1,119 +0,0 @@
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { SvgChevronLeft, SvgChevronRight } from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) {
// Generate page numbers to display
function getPageNumbers() {
const pages: (number | string)[] = [];
const maxPagesToShow = 7;
if (totalPages <= maxPagesToShow) {
// Show all pages if total is small
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
// Calculate range around current page
let startPage = Math.max(2, currentPage - 1);
let endPage = Math.min(totalPages - 1, currentPage + 1);
// Adjust range if we're near the start or end
if (currentPage <= 3) {
endPage = 5;
} else if (currentPage >= totalPages - 2) {
startPage = totalPages - 4;
}
// Add ellipsis if needed
if (startPage > 2) {
pages.push("...");
}
// Add middle pages
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
// Add ellipsis if needed
if (endPage < totalPages - 1) {
pages.push("...");
}
// Always show last page
pages.push(totalPages);
}
return pages;
}
const pageNumbers = getPageNumbers();
return (
<Section flexDirection="row" height="auto" gap={0.25}>
{/* Previous button */}
<Disabled disabled={currentPage === 1}>
<Button
onClick={() => onPageChange(currentPage - 1)}
prominence="tertiary"
icon={SvgChevronLeft}
/>
</Disabled>
{/* Page numbers */}
<Section flexDirection="row" height="auto" gap={0} width="fit">
{pageNumbers.map((page, index) => {
if (page === "...") {
return (
<Text key={`ellipsis-${index}`} secondaryBody text03>
...
</Text>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<Button
key={pageNum}
onClick={() => onPageChange(pageNum)}
prominence="tertiary"
interaction={isActive ? "hover" : "rest"}
icon={({ className }) => (
<div className={cn(className, "flex flex-col justify-center")}>
<Text>{pageNum}</Text>
</div>
)}
/>
);
})}
</Section>
{/* Next button */}
<Disabled disabled={currentPage === totalPages}>
<Button
onClick={() => onPageChange(currentPage + 1)}
prominence="tertiary"
icon={SvgChevronRight}
/>
</Disabled>
</Section>
);
}

View File

@@ -1,365 +0,0 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import DataTable from "./DataTable";
import { createTableColumns } from "./columns";
import type { OnyxColumnDef } from "./types";
import Text from "@/refresh-components/texts/Text";
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
interface TeamMember {
id: string;
name: string;
email: string;
role: string;
status: "active" | "invited" | "deactivated";
lastActive: string;
}
const MOCK_MEMBERS: TeamMember[] = [
{
id: "1",
name: "Alice Johnson",
email: "alice@acme.com",
role: "Admin",
status: "active",
lastActive: "2 hours ago",
},
{
id: "2",
name: "Bob Martinez",
email: "bob@acme.com",
role: "Editor",
status: "active",
lastActive: "5 minutes ago",
},
{
id: "3",
name: "Charlie Kim",
email: "charlie@acme.com",
role: "Viewer",
status: "invited",
lastActive: "Never",
},
{
id: "4",
name: "Diana Patel",
email: "diana@acme.com",
role: "Admin",
status: "active",
lastActive: "1 day ago",
},
{
id: "5",
name: "Ethan Lee",
email: "ethan@acme.com",
role: "Editor",
status: "deactivated",
lastActive: "3 weeks ago",
},
{
id: "6",
name: "Fiona Chen",
email: "fiona@acme.com",
role: "Viewer",
status: "active",
lastActive: "10 minutes ago",
},
{
id: "7",
name: "George Wu",
email: "george@acme.com",
role: "Editor",
status: "active",
lastActive: "1 hour ago",
},
{
id: "8",
name: "Hannah Davis",
email: "hannah@acme.com",
role: "Viewer",
status: "invited",
lastActive: "Never",
},
{
id: "9",
name: "Ivan Torres",
email: "ivan@acme.com",
role: "Admin",
status: "active",
lastActive: "30 minutes ago",
},
{
id: "10",
name: "Julia Nguyen",
email: "julia@acme.com",
role: "Editor",
status: "active",
lastActive: "3 hours ago",
},
{
id: "11",
name: "Kevin Brown",
email: "kevin@acme.com",
role: "Viewer",
status: "active",
lastActive: "Yesterday",
},
{
id: "12",
name: "Laura Smith",
email: "laura@acme.com",
role: "Editor",
status: "deactivated",
lastActive: "2 months ago",
},
{
id: "13",
name: "Mike Wilson",
email: "mike@acme.com",
role: "Viewer",
status: "active",
lastActive: "4 hours ago",
},
{
id: "14",
name: "Nina Garcia",
email: "nina@acme.com",
role: "Admin",
status: "active",
lastActive: "Just now",
},
{
id: "15",
name: "Oscar Ramirez",
email: "oscar@acme.com",
role: "Editor",
status: "invited",
lastActive: "Never",
},
];
// ---------------------------------------------------------------------------
// Columns
// ---------------------------------------------------------------------------
function getInitials(member: TeamMember): string {
return member.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase();
}
const tc = createTableColumns<TeamMember>();
const defaultColumns: OnyxColumnDef<TeamMember>[] = [
tc.qualifier({
content: "avatar-user",
getInitials,
selectable: true,
}),
tc.column("name", { header: "Name", weight: 22, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 28, minWidth: 150 }),
tc.column("role", { header: "Role", weight: 15, minWidth: 80 }),
tc.column("status", {
header: "Status",
weight: 15,
minWidth: 80,
cell: (value) => {
const colors: Record<string, string> = {
active: "text-status-success-02",
invited: "text-status-warning-02",
deactivated: "text-status-error-02",
};
return (
<Text mainUiBody className={colors[value]}>
{value.charAt(0).toUpperCase() + value.slice(1)}
</Text>
);
},
}),
tc.column("lastActive", {
header: "Last Active",
weight: 20,
minWidth: 100,
enableSorting: false,
}),
tc.actions(),
];
const columnsWithoutActions: OnyxColumnDef<TeamMember>[] = defaultColumns.slice(
0,
-1
);
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof DataTable> = {
title: "refresh-components/table/DataTable",
component: DataTable,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<div style={{ maxWidth: 960, padding: 16 }}>
<Story />
</div>
</TooltipPrimitive.Provider>
),
],
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof DataTable>;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/** Basic table with all default features: qualifier column, data columns, and actions. */
export const Default: Story = {
render: () => (
<DataTable<TeamMember>
getRowId={(row) => row.id}
data={MOCK_MEMBERS.slice(0, 8)}
columns={defaultColumns}
/>
),
};
/** Table with summary-mode footer showing "Showing X~Y of Z" and list pagination. */
export const WithSummaryFooter: Story = {
render: () => (
<DataTable<TeamMember>
getRowId={(row) => row.id}
data={MOCK_MEMBERS}
columns={defaultColumns}
pageSize={5}
footer={{ mode: "summary" }}
/>
),
};
/** Table with selection-mode footer showing selected count and count pagination. */
export const WithSelectionFooter: Story = {
render: () => (
<DataTable<TeamMember>
getRowId={(row) => row.id}
data={MOCK_MEMBERS}
columns={defaultColumns}
pageSize={5}
footer={{ mode: "selection" }}
/>
),
};
/** Table with initial sorting applied to the "name" column. */
export const WithInitialSorting: Story = {
render: () => (
<DataTable<TeamMember>
getRowId={(row) => row.id}
data={MOCK_MEMBERS}
columns={defaultColumns}
pageSize={5}
footer={{ mode: "summary" }}
initialSorting={[{ id: "name", desc: false }]}
/>
),
};
/** Table with some columns hidden by default via initialColumnVisibility. */
export const WithHiddenColumns: Story = {
render: () => (
<DataTable<TeamMember>
getRowId={(row) => row.id}
data={MOCK_MEMBERS.slice(0, 8)}
columns={defaultColumns}
initialColumnVisibility={{ email: false, lastActive: false }}
/>
),
};
/** Empty table with no data rows. */
export const EmptyState: Story = {
render: () => (
<DataTable<TeamMember>
getRowId={(row) => row.id}
data={[]}
columns={defaultColumns}
footer={{ mode: "summary" }}
/>
),
};
/** Small size variant with denser spacing. */
export const SmallSize: Story = {
render: () => (
<DataTable<TeamMember>
getRowId={(row) => row.id}
data={MOCK_MEMBERS.slice(0, 6)}
columns={defaultColumns}
size="small"
footer={{ mode: "summary" }}
pageSize={5}
/>
),
};
/** Table without actions column (no sorting/visibility popovers). */
export const WithoutActions: Story = {
render: () => (
<DataTable<TeamMember>
getRowId={(row) => row.id}
data={MOCK_MEMBERS.slice(0, 8)}
columns={columnsWithoutActions}
/>
),
};
/** Table with a fixed max height and sticky header for scrollable content. */
export const ScrollableFixedHeight: Story = {
render: () => (
<DataTable<TeamMember>
getRowId={(row) => row.id}
data={MOCK_MEMBERS}
columns={defaultColumns}
height={300}
headerBackground="var(--background-neutral-00)"
pageSize={Infinity}
/>
),
};
/** Table with onRowClick handler instead of default selection toggle. */
export const WithRowClick: Story = {
render: () => {
function Demo() {
const [clicked, setClicked] = React.useState<string | null>(null);
return (
<div>
<DataTable<TeamMember>
getRowId={(row) => row.id}
data={MOCK_MEMBERS.slice(0, 5)}
columns={defaultColumns}
onRowClick={(row) => setClicked(row.name)}
/>
{clicked && (
<Text mainUiBody text03 className="p-4">
Clicked: {clicked}
</Text>
)}
</div>
);
}
return <Demo />;
},
};

View File

@@ -1,183 +0,0 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import Footer from "./Footer";
import { TableSizeProvider } from "./TableSizeContext";
const meta: Meta<typeof Footer> = {
title: "refresh-components/table/Footer",
component: Footer,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<TableSizeProvider size="regular">
<div style={{ maxWidth: 800, padding: 16 }}>
<Story />
</div>
</TableSizeProvider>
</TooltipPrimitive.Provider>
),
],
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof Footer>;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/** Summary mode footer showing "Showing X~Y of Z" with list pagination. */
export const SummaryMode: Story = {
render: function SummaryModeStory() {
const [page, setPage] = React.useState(1);
return (
<Footer
mode="summary"
rangeStart={(page - 1) * 10 + 1}
rangeEnd={Math.min(page * 10, 47)}
totalItems={47}
currentPage={page}
totalPages={5}
onPageChange={setPage}
/>
);
},
};
/** Summary mode on the last page. */
export const SummaryLastPage: Story = {
render: function SummaryLastPageStory() {
const [page, setPage] = React.useState(5);
return (
<Footer
mode="summary"
rangeStart={(page - 1) * 10 + 1}
rangeEnd={Math.min(page * 10, 47)}
totalItems={47}
currentPage={page}
totalPages={5}
onPageChange={setPage}
/>
);
},
};
/** Selection mode with no items selected. */
export const SelectionNone: Story = {
render: function SelectionNoneStory() {
const [page, setPage] = React.useState(1);
return (
<Footer
mode="selection"
multiSelect
selectionState="none"
selectedCount={0}
onClear={() => {}}
pageSize={10}
totalItems={47}
currentPage={page}
totalPages={5}
onPageChange={setPage}
/>
);
},
};
/** Selection mode with some items selected. */
export const SelectionPartial: Story = {
render: function SelectionPartialStory() {
const [page, setPage] = React.useState(1);
return (
<Footer
mode="selection"
multiSelect
selectionState="partial"
selectedCount={3}
onView={() => alert("View selected")}
onClear={() => alert("Clear selection")}
pageSize={10}
totalItems={47}
currentPage={page}
totalPages={5}
onPageChange={setPage}
/>
);
},
};
/** Selection mode with all items selected. */
export const SelectionAll: Story = {
render: function SelectionAllStory() {
const [page, setPage] = React.useState(1);
return (
<Footer
mode="selection"
multiSelect
selectionState="all"
selectedCount={10}
onView={() => alert("View selected")}
onClear={() => alert("Clear selection")}
pageSize={10}
totalItems={47}
currentPage={page}
totalPages={5}
onPageChange={setPage}
/>
);
},
};
/** Single-select mode (no multi-select). */
export const SingleSelect: Story = {
render: function SingleSelectStory() {
const [page, setPage] = React.useState(1);
return (
<Footer
mode="selection"
multiSelect={false}
selectionState="partial"
selectedCount={1}
onClear={() => alert("Clear selection")}
pageSize={10}
totalItems={47}
currentPage={page}
totalPages={5}
onPageChange={setPage}
/>
);
},
};
/** Small size variant. */
export const SmallSize: Story = {
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<TableSizeProvider size="small">
<div style={{ maxWidth: 800, padding: 16 }}>
<Story />
</div>
</TableSizeProvider>
</TooltipPrimitive.Provider>
),
],
render: function SmallSizeStory() {
const [page, setPage] = React.useState(1);
return (
<Footer
mode="summary"
rangeStart={(page - 1) * 10 + 1}
rangeEnd={Math.min(page * 10, 47)}
totalItems={47}
currentPage={page}
totalPages={5}
onPageChange={setPage}
/>
);
},
};

View File

@@ -1,12 +1,16 @@
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@opal/components";
import { Button, Pagination } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import Pagination from "@/refresh-components/table/Pagination";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { SvgEye, SvgXCircle } from "@opal/icons";
import type { ReactNode } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type SelectionState = "none" | "partial" | "all";
@@ -62,9 +66,7 @@ interface FooterSummaryModeProps {
/** Called when the user navigates to a different page. */
onPageChange: (page: number) => void;
/** Optional extra element rendered after the summary text (e.g. a download icon). */
leftExtra?: React.ReactNode;
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
size?: TableSize;
leftExtra?: ReactNode;
className?: string;
}
@@ -75,6 +77,10 @@ interface FooterSummaryModeProps {
*/
export type FooterProps = FooterSelectionModeProps | FooterSummaryModeProps;
// ---------------------------------------------------------------------------
// Footer
// ---------------------------------------------------------------------------
function getSelectionMessage(
state: SelectionState,
multi: boolean,
@@ -93,8 +99,7 @@ function getSelectionMessage(
* `mode: "summary"` for read-only tables.
*/
export default function Footer(props: FooterProps) {
const contextSize = useTableSize();
const resolvedSize = props.size ?? contextSize;
const resolvedSize = useTableSize();
const isSmall = resolvedSize === "small";
return (
<div
@@ -133,21 +138,20 @@ export default function Footer(props: FooterProps) {
<div className="flex items-center gap-2 px-1 py-2">
{props.mode === "selection" ? (
<Pagination
type="count"
variant="count"
pageSize={props.pageSize}
totalItems={props.totalItems}
currentPage={props.currentPage}
totalPages={props.totalPages}
onPageChange={props.onPageChange}
showUnits
onChange={props.onPageChange}
units="items"
size={isSmall ? "sm" : "md"}
/>
) : (
<Pagination
type="list"
currentPage={props.currentPage}
totalPages={props.totalPages}
onPageChange={props.onPageChange}
onChange={props.onPageChange}
size={isSmall ? "md" : "lg"}
/>
)}
@@ -156,6 +160,10 @@ export default function Footer(props: FooterProps) {
);
}
// ---------------------------------------------------------------------------
// Footer — left-side content
// ---------------------------------------------------------------------------
interface SelectionLeftProps {
selectionState: SelectionState;
multiSelect: boolean;

View File

@@ -1,396 +0,0 @@
"use client";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { SvgChevronLeft, SvgChevronRight } from "@opal/icons";
type PaginationSize = "lg" | "md" | "sm";
/**
* Minimal page navigation showing `currentPage / totalPages` with prev/next arrows.
* Use when you only need simple forward/backward navigation.
*/
interface SimplePaginationProps {
type: "simple";
/** The 1-based current page number. */
currentPage: number;
/** Total number of pages. */
totalPages: number;
/** Called when the user navigates to a different page. */
onPageChange: (page: number) => void;
/** When `true`, displays the word "pages" after the page indicator. */
showUnits?: boolean;
/** When `false`, hides the page indicator between the prev/next arrows. Defaults to `true`. */
showPageIndicator?: boolean;
/** Controls button and text sizing. Defaults to `"lg"`. */
size?: PaginationSize;
className?: string;
}
/**
* Item-count pagination showing `currentItems of totalItems` with optional page
* controls and a "Go to" button. Use inside table footers that need to communicate
* how many items the user is viewing.
*/
interface CountPaginationProps {
type: "count";
/** Number of items displayed per page. Used to compute the visible range. */
pageSize: number;
/** Total number of items across all pages. */
totalItems: number;
/** The 1-based current page number. */
currentPage: number;
/** Total number of pages. */
totalPages: number;
/** Called when the user navigates to a different page. */
onPageChange: (page: number) => void;
/** When `false`, hides the page number between the prev/next arrows (arrows still visible). Defaults to `true`. */
showPageIndicator?: boolean;
/** When `true`, renders a "Go to" button. Requires `onGoTo`. */
showGoTo?: boolean;
/** Callback invoked when the "Go to" button is clicked. */
onGoTo?: () => void;
/** When `true`, displays the word "items" after the total count. */
showUnits?: boolean;
/** Controls button and text sizing. Defaults to `"lg"`. */
size?: PaginationSize;
className?: string;
}
/**
* Numbered page-list pagination with clickable page buttons and ellipsis
* truncation for large page counts. Does not support `"sm"` size.
*/
interface ListPaginationProps {
type: "list";
/** The 1-based current page number. */
currentPage: number;
/** Total number of pages. */
totalPages: number;
/** Called when the user navigates to a different page. */
onPageChange: (page: number) => void;
/** When `false`, hides the page buttons between the prev/next arrows. Defaults to `true`. */
showPageIndicator?: boolean;
/** Controls button and text sizing. Defaults to `"lg"`. Only `"lg"` and `"md"` are supported. */
size?: Exclude<PaginationSize, "sm">;
className?: string;
}
/**
* Discriminated union of all pagination variants.
* Use the `type` prop to select between `"simple"`, `"count"`, and `"list"`.
*/
export type PaginationProps =
| SimplePaginationProps
| CountPaginationProps
| ListPaginationProps;
function getPageNumbers(currentPage: number, totalPages: number) {
const pages: (number | string)[] = [];
const maxPagesToShow = 7;
if (totalPages <= maxPagesToShow) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
let startPage = Math.max(2, currentPage - 1);
let endPage = Math.min(totalPages - 1, currentPage + 1);
if (currentPage <= 3) {
endPage = 5;
} else if (currentPage >= totalPages - 2) {
startPage = totalPages - 4;
}
if (startPage > 2) {
if (startPage === 3) {
pages.push(2);
} else {
pages.push("start-ellipsis");
}
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
if (endPage < totalPages - 1) {
if (endPage === totalPages - 2) {
pages.push(totalPages - 1);
} else {
pages.push("end-ellipsis");
}
}
pages.push(totalPages);
}
return pages;
}
function sizedTextProps(isSmall: boolean, variant: "mono" | "muted") {
if (variant === "mono") {
return isSmall ? { secondaryMono: true } : { mainUiMono: true };
}
return isSmall ? { secondaryBody: true } : { mainUiMuted: true };
}
interface NavButtonsProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
size: PaginationSize;
children?: React.ReactNode;
}
function NavButtons({
currentPage,
totalPages,
onPageChange,
size,
children,
}: NavButtonsProps) {
return (
<>
<Disabled disabled={currentPage <= 1}>
<Button
icon={SvgChevronLeft}
onClick={() => onPageChange(currentPage - 1)}
size={size}
prominence="tertiary"
tooltip="Previous page"
/>
</Disabled>
{children}
<Disabled disabled={currentPage >= totalPages}>
<Button
icon={SvgChevronRight}
onClick={() => onPageChange(currentPage + 1)}
size={size}
prominence="tertiary"
tooltip="Next page"
/>
</Disabled>
</>
);
}
/**
* Table pagination component with three variants: `simple`, `count`, and `list`.
* Pass the `type` prop to select the variant, and the component will render the
* appropriate UI.
*/
export default function Pagination(props: PaginationProps) {
const normalized = { ...props, totalPages: Math.max(1, props.totalPages) };
switch (normalized.type) {
case "simple":
return <SimplePaginationInner {...normalized} />;
case "count":
return <CountPaginationInner {...normalized} />;
case "list":
return <ListPaginationInner {...normalized} />;
}
}
function SimplePaginationInner({
currentPage,
totalPages,
onPageChange,
showUnits,
showPageIndicator = true,
size = "lg",
className,
}: SimplePaginationProps) {
const isSmall = size === "sm";
return (
<div className={cn("flex items-center gap-1", className)}>
<NavButtons
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
size={size}
>
{showPageIndicator && (
<>
<Text {...sizedTextProps(isSmall, "mono")} text03>
{currentPage}
<Text as="span" {...sizedTextProps(isSmall, "muted")} text03>
/
</Text>
{totalPages}
</Text>
{showUnits && (
<Text {...sizedTextProps(isSmall, "muted")} text03>
pages
</Text>
)}
</>
)}
</NavButtons>
</div>
);
}
function CountPaginationInner({
pageSize,
totalItems,
currentPage,
totalPages,
onPageChange,
showPageIndicator = true,
showGoTo,
onGoTo,
showUnits,
size = "lg",
className,
}: CountPaginationProps) {
const isSmall = size === "sm";
const rangeStart = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const rangeEnd = Math.min(currentPage * pageSize, totalItems);
const currentItems = `${rangeStart}~${rangeEnd}`;
return (
<div className={cn("flex items-center gap-1", className)}>
<Text {...sizedTextProps(isSmall, "mono")} text03>
{currentItems}
</Text>
<Text {...sizedTextProps(isSmall, "muted")} text03>
of
</Text>
<Text {...sizedTextProps(isSmall, "mono")} text03>
{totalItems}
</Text>
{showUnits && (
<Text {...sizedTextProps(isSmall, "muted")} text03>
items
</Text>
)}
<NavButtons
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
size={size}
>
{showPageIndicator && (
<Text {...sizedTextProps(isSmall, "mono")} text03>
{currentPage}
</Text>
)}
</NavButtons>
{showGoTo && onGoTo && (
<Button onClick={onGoTo} size={size} prominence="tertiary">
Go to
</Button>
)}
</div>
);
}
interface PageNumberIconProps {
className?: string;
pageNum: number;
isActive: boolean;
isLarge: boolean;
}
function PageNumberIcon({
className: iconClassName,
pageNum,
isActive,
isLarge,
}: PageNumberIconProps) {
return (
<div className={cn(iconClassName, "flex flex-col justify-center")}>
{isLarge ? (
<Text
mainUiBody={isActive}
mainUiMuted={!isActive}
text04={isActive}
text02={!isActive}
>
{pageNum}
</Text>
) : (
<Text
secondaryAction={isActive}
secondaryBody={!isActive}
text04={isActive}
text02={!isActive}
>
{pageNum}
</Text>
)}
</div>
);
}
function ListPaginationInner({
currentPage,
totalPages,
onPageChange,
showPageIndicator = true,
size = "lg",
className,
}: ListPaginationProps) {
const pageNumbers = getPageNumbers(currentPage, totalPages);
const isLarge = size === "lg";
return (
<div className={cn("flex items-center gap-1", className)}>
<NavButtons
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
size={size}
>
{showPageIndicator && (
<div className="flex items-center">
{pageNumbers.map((page) => {
if (typeof page === "string") {
return (
<Text
key={page}
mainUiMuted={isLarge}
secondaryBody={!isLarge}
text03
>
...
</Text>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<Button
key={pageNum}
onClick={() => onPageChange(pageNum)}
size={size}
prominence="tertiary"
interaction={isActive ? "hover" : "rest"}
icon={({ className: iconClassName }) => (
<PageNumberIcon
className={iconClassName}
pageNum={pageNum}
isActive={isActive}
isLarge={isLarge}
/>
)}
/>
);
})}
</div>
)}
</NavButtons>
</div>
);
}

View File

@@ -0,0 +1,340 @@
"use client";
import { useState } from "react";
import { Button } from "@opal/components";
import { SvgUserPlus, SvgUserX, SvgXCircle, SvgKey } from "@opal/icons";
import { Disabled } from "@opal/core";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import Text from "@/refresh-components/texts/Text";
import { toast } from "@/hooks/useToast";
import {
deactivateUser,
activateUser,
deleteUser,
cancelInvite,
resetPassword,
} from "./svc";
// ---------------------------------------------------------------------------
// Shared helper
// ---------------------------------------------------------------------------
async function runAction(
action: () => Promise<void>,
successMessage: string,
onDone: () => void,
setIsSubmitting: (v: boolean) => void
) {
setIsSubmitting(true);
try {
await action();
onDone();
toast.success(successMessage);
} catch (err) {
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
}
// ---------------------------------------------------------------------------
// Cancel Invite Modal
// ---------------------------------------------------------------------------
interface CancelInviteModalProps {
email: string;
onClose: () => void;
onMutate: () => void;
}
export function CancelInviteModal({
email,
onClose,
onMutate,
}: CancelInviteModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<ConfirmationModalLayout
icon={(props) => (
<SvgUserX {...props} className="text-action-danger-05" />
)}
title="Cancel Invite"
onClose={isSubmitting ? undefined : onClose}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={() =>
runAction(
() => cancelInvite(email),
"Invite cancelled",
() => {
onMutate();
onClose();
},
setIsSubmitting
)
}
>
Cancel Invite
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{email}
</Text>{" "}
will no longer be able to join Onyx with this invite.
</Text>
</ConfirmationModalLayout>
);
}
// ---------------------------------------------------------------------------
// Deactivate User Modal
// ---------------------------------------------------------------------------
interface DeactivateUserModalProps {
email: string;
onClose: () => void;
onMutate: () => void;
}
export function DeactivateUserModal({
email,
onClose,
onMutate,
}: DeactivateUserModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<ConfirmationModalLayout
icon={(props) => (
<SvgUserX {...props} className="text-action-danger-05" />
)}
title="Deactivate User"
onClose={isSubmitting ? undefined : onClose}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={() =>
runAction(
() => deactivateUser(email),
"User deactivated",
() => {
onMutate();
onClose();
},
setIsSubmitting
)
}
>
Deactivate
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{email}
</Text>{" "}
will immediately lose access to Onyx. Their sessions and agents will be
preserved. Their license seat will be freed. You can reactivate this
account later.
</Text>
</ConfirmationModalLayout>
);
}
// ---------------------------------------------------------------------------
// Activate User Modal
// ---------------------------------------------------------------------------
interface ActivateUserModalProps {
email: string;
onClose: () => void;
onMutate: () => void;
}
export function ActivateUserModal({
email,
onClose,
onMutate,
}: ActivateUserModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<ConfirmationModalLayout
icon={SvgUserPlus}
title="Activate User"
onClose={isSubmitting ? undefined : onClose}
submit={
<Disabled disabled={isSubmitting}>
<Button
onClick={() =>
runAction(
() => activateUser(email),
"User activated",
() => {
onMutate();
onClose();
},
setIsSubmitting
)
}
>
Activate
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{email}
</Text>{" "}
will regain access to Onyx.
</Text>
</ConfirmationModalLayout>
);
}
// ---------------------------------------------------------------------------
// Delete User Modal
// ---------------------------------------------------------------------------
interface DeleteUserModalProps {
email: string;
onClose: () => void;
onMutate: () => void;
}
export function DeleteUserModal({
email,
onClose,
onMutate,
}: DeleteUserModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<ConfirmationModalLayout
icon={(props) => (
<SvgUserX {...props} className="text-action-danger-05" />
)}
title="Delete User"
onClose={isSubmitting ? undefined : onClose}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={() =>
runAction(
() => deleteUser(email),
"User deleted",
() => {
onMutate();
onClose();
},
setIsSubmitting
)
}
>
Delete
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{email}
</Text>{" "}
will be permanently removed from Onyx. All of their session history will
be deleted. Deletion cannot be undone.
</Text>
</ConfirmationModalLayout>
);
}
// ---------------------------------------------------------------------------
// Reset Password Modal
// ---------------------------------------------------------------------------
interface ResetPasswordModalProps {
email: string;
onClose: () => void;
}
export function ResetPasswordModal({
email,
onClose,
}: ResetPasswordModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [newPassword, setNewPassword] = useState<string | null>(null);
const handleClose = () => {
onClose();
setNewPassword(null);
};
return (
<ConfirmationModalLayout
icon={SvgKey}
title={newPassword ? "Password Reset" : "Reset Password"}
onClose={isSubmitting ? undefined : handleClose}
submit={
newPassword ? (
<Button onClick={handleClose}>Done</Button>
) : (
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={async () => {
setIsSubmitting(true);
try {
const result = await resetPassword(email);
setNewPassword(result.new_password);
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Failed to reset password"
);
} finally {
setIsSubmitting(false);
}
}}
>
Reset Password
</Button>
</Disabled>
)
}
>
{newPassword ? (
<div className="flex flex-col gap-2">
<Text as="p" text03>
The password for{" "}
<Text as="span" text05>
{email}
</Text>{" "}
has been reset. Copy the new password below it will not be shown
again.
</Text>
<code className="rounded-sm bg-background-neutral-02 px-3 py-2 text-sm select-all">
{newPassword}
</code>
</div>
) : (
<Text as="p" text03>
This will generate a new random password for{" "}
<Text as="span" text05>
{email}
</Text>
. Their current password will stop working immediately.
</Text>
)}
</ConfirmationModalLayout>
);
}

View File

@@ -166,7 +166,7 @@ export default function UserFilters({
<Popover>
<Popover.Trigger asChild>
<FilterButton
data-testid="filter-role"
aria-label="Filter by role"
leftIcon={SvgUsers}
active={hasRoleFilter}
onClear={() => onRolesChange([])}
@@ -214,7 +214,7 @@ export default function UserFilters({
>
<Popover.Trigger asChild>
<FilterButton
data-testid="filter-group"
aria-label="Filter by group"
leftIcon={SvgUsers}
active={hasGroupFilter}
onClear={() => onGroupsChange([])}
@@ -269,7 +269,7 @@ export default function UserFilters({
<Popover>
<Popover.Trigger asChild>
<FilterButton
data-testid="filter-status"
aria-label="Filter by status"
leftIcon={SvgUsers}
active={hasStatusFilter}
onClear={() => onStatusesChange([])}

View File

@@ -14,20 +14,19 @@ import {
import { Disabled } from "@opal/core";
import Popover from "@/refresh-components/Popover";
import Separator from "@/refresh-components/Separator";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import { Section } from "@/layouts/general-layouts";
import Text from "@/refresh-components/texts/Text";
import { UserStatus } from "@/lib/types";
import { toast } from "@/hooks/useToast";
import {
deactivateUser,
activateUser,
deleteUser,
cancelInvite,
approveRequest,
resetPassword,
} from "./svc";
import { approveRequest } from "./svc";
import EditUserModal from "./EditUserModal";
import {
CancelInviteModal,
DeactivateUserModal,
ActivateUserModal,
DeleteUserModal,
ResetPasswordModal,
} from "./UserActionModals";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
@@ -58,31 +57,19 @@ export default function UserRowActions({
}: UserRowActionsProps) {
const [modal, setModal] = useState<Modal | null>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [newPassword, setNewPassword] = useState<string | null>(null);
async function handleAction(
action: () => Promise<void>,
successMessage: string
) {
setIsSubmitting(true);
try {
await action();
onMutate();
toast.success(successMessage);
setModal(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
}
const openModal = (type: Modal) => {
setPopoverOpen(false);
setModal(type);
};
const closeModal = () => setModal(null);
const closeAndMutate = () => {
setModal(null);
onMutate();
};
// Status-aware action menus
const actionButtons = (() => {
// SCIM-managed users get limited actions — most changes would be
@@ -132,10 +119,17 @@ export default function UserRowActions({
icon={SvgUserCheck}
onClick={() => {
setPopoverOpen(false);
handleAction(
() => approveRequest(user.email),
"Request approved"
);
void (async () => {
try {
await approveRequest(user.email);
onMutate();
toast.success("Request approved");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "An error occurred"
);
}
})();
}}
>
Approve
@@ -176,6 +170,23 @@ export default function UserRowActions({
case UserStatus.INACTIVE:
return (
<>
{user.id && (
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal(Modal.EDIT_GROUPS)}
>
Groups &amp; Roles
</Button>
)}
<Button
prominence="tertiary"
icon={SvgKey}
onClick={() => openModal(Modal.RESET_PASSWORD)}
>
Reset Password
</Button>
<Separator paddingXRem={0.5} />
<Button
prominence="tertiary"
icon={SvgUserPlus}
@@ -223,211 +234,45 @@ export default function UserRowActions({
{modal === Modal.EDIT_GROUPS && user.id && (
<EditUserModal
user={user as UserRow & { id: string }}
onClose={() => setModal(null)}
onClose={closeModal}
onMutate={onMutate}
/>
)}
{modal === Modal.CANCEL_INVITE && (
<ConfirmationModalLayout
icon={(props) => (
<SvgUserX {...props} className="text-action-danger-05" />
)}
title="Cancel Invite"
onClose={isSubmitting ? undefined : () => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={() => {
handleAction(
() => cancelInvite(user.email),
"Invite cancelled"
);
}}
>
Cancel Invite
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will no longer be able to join Onyx with this invite.
</Text>
</ConfirmationModalLayout>
<CancelInviteModal
email={user.email}
onClose={closeModal}
onMutate={onMutate}
/>
)}
{modal === Modal.DEACTIVATE && (
<ConfirmationModalLayout
icon={(props) => (
<SvgUserX {...props} className="text-action-danger-05" />
)}
title="Deactivate User"
onClose={isSubmitting ? undefined : () => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={async () => {
await handleAction(
() => deactivateUser(user.email),
"User deactivated"
);
}}
>
Deactivate
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will immediately lose access to Onyx. Their sessions and agents will
be preserved. Their license seat will be freed. You can reactivate
this account later.
</Text>
</ConfirmationModalLayout>
<DeactivateUserModal
email={user.email}
onClose={closeModal}
onMutate={onMutate}
/>
)}
{modal === Modal.ACTIVATE && (
<ConfirmationModalLayout
icon={SvgUserPlus}
title="Activate User"
onClose={isSubmitting ? undefined : () => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
onClick={async () => {
await handleAction(
() => activateUser(user.email),
"User activated"
);
}}
>
Activate
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will regain access to Onyx.
</Text>
</ConfirmationModalLayout>
<ActivateUserModal
email={user.email}
onClose={closeModal}
onMutate={onMutate}
/>
)}
{modal === Modal.DELETE && (
<ConfirmationModalLayout
icon={(props) => (
<SvgUserX {...props} className="text-action-danger-05" />
)}
title="Delete User"
onClose={isSubmitting ? undefined : () => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={async () => {
await handleAction(
() => deleteUser(user.email),
"User deleted"
);
}}
>
Delete
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will be permanently removed from Onyx. All of their session history
will be deleted. Deletion cannot be undone.
</Text>
</ConfirmationModalLayout>
<DeleteUserModal
email={user.email}
onClose={closeModal}
onMutate={onMutate}
/>
)}
{modal === Modal.RESET_PASSWORD && (
<ConfirmationModalLayout
icon={SvgKey}
title={newPassword ? "Password Reset" : "Reset Password"}
onClose={
isSubmitting
? undefined
: () => {
setModal(null);
setNewPassword(null);
}
}
submit={
newPassword ? (
<Button
onClick={() => {
setModal(null);
setNewPassword(null);
}}
>
Done
</Button>
) : (
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={async () => {
setIsSubmitting(true);
try {
const result = await resetPassword(user.email);
setNewPassword(result.new_password);
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Failed to reset password"
);
} finally {
setIsSubmitting(false);
}
}}
>
Reset Password
</Button>
</Disabled>
)
}
>
{newPassword ? (
<div className="flex flex-col gap-2">
<Text as="p" text03>
The password for{" "}
<Text as="span" text05>
{user.email}
</Text>{" "}
has been reset. Copy the new password below it will not be
shown again.
</Text>
<code className="rounded-sm bg-background-neutral-02 px-3 py-2 text-sm select-all">
{newPassword}
</code>
</div>
) : (
<Text as="p" text03>
This will generate a new random password for{" "}
<Text as="span" text05>
{user.email}
</Text>
. Their current password will stop working immediately.
</Text>
)}
</ConfirmationModalLayout>
<ResetPasswordModal email={user.email} onClose={closeModal} />
)}
</>
);

View File

@@ -598,18 +598,12 @@ const MemoizedAppSidebarInner = memo(
setShowIntroAnimation(true);
}, []);
const vectorDbEnabled =
combinedSettings?.settings?.vector_db_enabled !== false;
const adminDefaultHref = vectorDbEnabled
? "/admin/indexing/status"
: "/admin/agents";
const settingsButton = useMemo(
() => (
<div>
{(isAdmin || isCurator) && (
<SidebarTab
href={adminDefaultHref}
href={isCurator ? "/admin/agents" : "/admin/configuration/llm"}
icon={SvgSettings}
folded={folded}
>
@@ -624,14 +618,7 @@ const MemoizedAppSidebarInner = memo(
/>
</div>
),
[
folded,
isAdmin,
isCurator,
handleShowBuildIntro,
isOnyxCraftEnabled,
adminDefaultHref,
]
[folded, isAdmin, isCurator, handleShowBuildIntro, isOnyxCraftEnabled]
);
return (

View File

@@ -21,8 +21,8 @@ export default function SidebarBody({
scrollKey,
}: SidebarBodyProps) {
return (
<div className="flex flex-col min-h-0 h-full gap-3 px-2">
<div className="flex flex-col gap-1.5">
<div className="flex flex-col min-h-0 h-full gap-3">
<div className="flex flex-col gap-1.5 px-2">
{actionButtons &&
(Array.isArray(actionButtons)
? actionButtons.map((button, index) => (
@@ -30,10 +30,10 @@ export default function SidebarBody({
))
: actionButtons)}
</div>
<OverflowDiv className="gap-3" scrollKey={scrollKey}>
<OverflowDiv className="gap-3 px-2" scrollKey={scrollKey}>
{children}
</OverflowDiv>
{footer}
{footer && <div className="px-2">{footer}</div>}
</div>
);
}

View File

@@ -0,0 +1,324 @@
/**
* Page Object Model for the Admin Users page (/admin/users).
*
* Encapsulates all locators and interactions so specs remain declarative.
*/
import { type Page, type Locator, expect } from "@playwright/test";
/** URL pattern that matches the users data fetch. */
const USERS_API = /\/api\/manage\/users\/(accepted\/all|invited)/;
export class UsersAdminPage {
readonly page: Page;
// Top-level elements
readonly inviteButton: Locator;
readonly searchInput: Locator;
// Filter buttons
readonly accountTypesFilter: Locator;
readonly groupsFilter: Locator;
readonly statusFilter: Locator;
// Table
readonly table: Locator;
readonly tableRows: Locator;
// Pagination & footer
readonly paginationSummary: Locator;
readonly downloadCsvButton: Locator;
constructor(page: Page) {
this.page = page;
this.inviteButton = page.getByRole("button", { name: "Invite Users" });
this.searchInput = page.getByPlaceholder("Search users...");
this.accountTypesFilter = page.getByLabel("Filter by role");
this.groupsFilter = page.getByLabel("Filter by group");
this.statusFilter = page.getByLabel("Filter by status");
this.table = page.getByRole("table");
this.tableRows = page.getByRole("table").locator("tbody tr");
this.paginationSummary = page.getByText(/Showing \d/);
this.downloadCsvButton = page.getByRole("button", {
name: "Download CSV",
});
}
// ---------------------------------------------------------------------------
// Popover helper
// ---------------------------------------------------------------------------
/**
* Returns a locator for the currently open popover / filter dropdown.
* Radix Popover renders its content with `role="dialog"`. Using
* `getByRole("dialog").first()` targets the oldest open dialog, which is
* always the popover during row-action or filter flows (confirmation
* modals open later and would be `.last()`).
*/
get popover(): Locator {
return this.page.getByRole("dialog").first();
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
async goto() {
await this.page.goto("/admin/users");
await expect(this.page.getByText("Users & Requests")).toBeVisible({
timeout: 15000,
});
// Wait for the table to finish loading (pagination summary only appears
// after the async data fetch completes).
await expect(this.paginationSummary).toBeVisible({ timeout: 15000 });
}
// ---------------------------------------------------------------------------
// Waiting helpers
// ---------------------------------------------------------------------------
/** Wait for the users API response that follows a table-refreshing action. */
private async waitForTableRefresh(): Promise<void> {
await this.page.waitForResponse(USERS_API);
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
async search(term: string) {
await this.searchInput.fill(term);
}
async clearSearch() {
await this.searchInput.fill("");
}
// ---------------------------------------------------------------------------
// Filters
// ---------------------------------------------------------------------------
async openAccountTypesFilter() {
await this.accountTypesFilter.click();
await expect(this.popover).toBeVisible();
}
async selectAccountType(label: string) {
await this.popover.getByText(label, { exact: false }).first().click();
}
async openStatusFilter() {
await this.statusFilter.click();
await expect(this.popover).toBeVisible();
}
async selectStatus(label: string) {
await this.popover.getByText(label, { exact: false }).first().click();
}
async openGroupsFilter() {
await this.groupsFilter.click();
await expect(this.popover).toBeVisible();
}
async selectGroup(label: string) {
await this.popover.getByText(label, { exact: false }).first().click();
}
async closePopover() {
await this.page.keyboard.press("Escape");
await expect(this.page.getByRole("dialog")).not.toBeVisible();
}
// ---------------------------------------------------------------------------
// Table interactions
// ---------------------------------------------------------------------------
async getVisibleRowCount(): Promise<number> {
return await this.tableRows.count();
}
/**
* Returns the text content of a specific column across all visible rows.
* Column indices: 0=Name, 1=Groups, 2=Account Type, 3=Status, 4=Last Updated.
*/
async getColumnTexts(columnIndex: number): Promise<string[]> {
const cells = this.tableRows.locator(`td:nth-child(${columnIndex + 2})`);
const count = await cells.count();
const texts: string[] = [];
for (let i = 0; i < count; i++) {
const text = await cells.nth(i).textContent();
if (text) texts.push(text.trim());
}
return texts;
}
getRowByEmail(email: string): Locator {
return this.table.getByRole("row").filter({ hasText: email });
}
/** Click the sort button on a column header. */
async sortByColumn(columnName: string) {
// Column headers are <th> elements. The sort button is a child <button>
// that only appears on hover — hover first to reveal it.
const header = this.table.locator("th").filter({ hasText: columnName });
await header.hover();
await header.locator("button").first().click();
}
// ---------------------------------------------------------------------------
// Pagination
// ---------------------------------------------------------------------------
/** Click a numbered page button in the table footer. */
async goToPage(pageNumber: number) {
const footer = this.page.locator(".table-footer");
await footer
.getByRole("button")
.filter({ hasText: String(pageNumber) })
.click();
}
// ---------------------------------------------------------------------------
// Row actions
// ---------------------------------------------------------------------------
async openRowActions(email: string) {
const row = this.getRowByEmail(email);
const actionsButton = row.getByRole("button").last();
await actionsButton.click();
await expect(this.popover).toBeVisible();
}
async clickRowAction(actionName: string) {
await this.popover.getByText(actionName).first().click();
}
// ---------------------------------------------------------------------------
// Confirmation modals
// ---------------------------------------------------------------------------
/**
* Returns the most recently opened dialog (modal).
* Uses `.last()` because confirmation modals are portaled after row-action
* popovers, and a closing popover (role="dialog") may briefly remain in the
* DOM during its exit animation.
*/
get dialog(): Locator {
return this.page.getByRole("dialog").last();
}
async confirmModalAction(buttonName: string) {
await this.dialog.getByRole("button", { name: buttonName }).first().click();
}
async cancelModal() {
await this.dialog.getByRole("button", { name: "Cancel" }).first().click();
}
async expectToast(message: string | RegExp) {
await expect(this.page.getByText(message)).toBeVisible();
}
// ---------------------------------------------------------------------------
// Invite modal
// ---------------------------------------------------------------------------
/** The email input inside the invite modal. */
get inviteEmailInput(): Locator {
return this.dialog.getByPlaceholder("Add an email and press enter");
}
async openInviteModal() {
await this.inviteButton.click();
await expect(this.dialog.getByText("Invite Users")).toBeVisible();
}
async addInviteEmail(email: string) {
await this.inviteEmailInput.pressSequentially(email, { delay: 20 });
await this.inviteEmailInput.press("Enter");
// Wait for the chip to appear in the dialog
await expect(this.dialog.getByText(email)).toBeVisible();
}
async submitInvite() {
await this.dialog.getByRole("button", { name: "Invite" }).click();
}
// ---------------------------------------------------------------------------
// Inline role editing (Popover + OpenButton + LineItem)
// ---------------------------------------------------------------------------
async openRoleDropdown(email: string) {
const row = this.getRowByEmail(email);
const roleButton = row
.locator("button")
.filter({ hasText: /Basic|Admin|Global Curator|Slack User/ });
await roleButton.click();
await expect(this.popover).toBeVisible();
}
async selectRole(roleName: string) {
await this.popover.getByText(roleName).first().click();
await this.waitForTableRefresh();
}
// ---------------------------------------------------------------------------
// Edit groups modal
// ---------------------------------------------------------------------------
/**
* Stable locator for the edit-groups modal.
*
* We can't use the generic `dialog` getter (`.last()`) here because the
* groups search opens a Radix Popover (also `role="dialog"`) inside the
* modal, which shifts what `.last()` resolves to. Targeting by accessible
* name keeps the reference pinned to the modal itself.
*/
get editGroupsDialog(): Locator {
return this.page.getByRole("dialog", { name: /Edit User/ });
}
/** The search input inside the edit groups modal. */
get groupSearchInput(): Locator {
return this.editGroupsDialog.getByPlaceholder("Search groups to join...");
}
async openEditGroupsModal(email: string) {
await this.openRowActions(email);
await this.clickRowAction("Groups");
await expect(
this.editGroupsDialog.getByText("Edit User's Groups & Roles")
).toBeVisible();
}
async searchGroupsInModal(term: string) {
// Click the input first to open the popover (Radix Popover.Trigger
// wraps the input — fill() alone bypasses the trigger's click handler).
await this.groupSearchInput.click();
await this.groupSearchInput.fill(term);
// The group name appears in the popover dropdown (nested dialog).
// Use page-level search since the popover may be portaled.
await expect(this.page.getByText(term).first()).toBeVisible();
}
async toggleGroupInModal(groupName: string) {
// LineItem renders as a <div>, not <button>.
// The popover dropdown is a nested dialog inside the modal.
await this.page
.getByRole("dialog")
.last()
.getByText(groupName)
.first()
.click();
}
async saveGroupsModal() {
await this.editGroupsDialog
.getByRole("button", { name: "Save Changes" })
.click();
}
}

View File

@@ -0,0 +1,37 @@
/**
* Playwright fixtures for Admin Users page tests.
*
* Provides:
* - Authenticated admin page
* - OnyxApiClient for API-level setup/teardown
* - UsersAdminPage page object
*/
import { test as base, expect, type Page } from "@playwright/test";
import { loginAs } from "@tests/e2e/utils/auth";
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
import { UsersAdminPage } from "./UsersAdminPage";
export const test = base.extend<{
adminPage: Page;
api: OnyxApiClient;
usersPage: UsersAdminPage;
}>({
adminPage: async ({ page }, use) => {
await page.context().clearCookies();
await loginAs(page, "admin");
await use(page);
},
api: async ({ adminPage }, use) => {
const client = new OnyxApiClient(adminPage.request);
await use(client);
},
usersPage: async ({ adminPage }, use) => {
const usersPage = new UsersAdminPage(adminPage);
await use(usersPage);
},
});
export { expect };

View File

@@ -0,0 +1,620 @@
/**
* E2E Tests: Admin Users Page
*
* Tests the full users management page — search, filters, sorting,
* inline role editing, row actions, invite modal, and group management.
*
* Read-only tests (layout, search, filters, sorting, pagination) run against
* whatever users already exist in the database (at minimum 10 from global-setup:
* 2 admins + 8 workers). Mutation tests create their own ephemeral users.
*/
import { test, expect } from "./fixtures";
import { TEST_ADMIN_CREDENTIALS } from "@tests/e2e/constants";
import type { Browser } from "@playwright/test";
import type { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function uniqueEmail(prefix: string): string {
return `e2e-${prefix}-${Date.now()}@test.onyx`;
}
const TEST_PASSWORD = "TestPassword123!";
/** Best-effort cleanup — logs failures instead of silently swallowing them. */
async function softCleanup(fn: () => Promise<unknown>): Promise<void> {
await fn().catch((e) => console.warn("cleanup:", e));
}
/**
* Creates an authenticated API context for beforeAll/afterAll hooks.
* Handles browser context lifecycle so callers only write the setup logic.
*/
async function withApiContext(
browser: Browser,
fn: (api: OnyxApiClient) => Promise<void>
): Promise<void> {
const context = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const api = new OnyxApiClient(context.request);
await fn(api);
} finally {
await context.close();
}
}
// ---------------------------------------------------------------------------
// Page load & layout
// ---------------------------------------------------------------------------
test.describe("Users page — layout", () => {
test("renders page title, invite button, search, and stats bar", async ({
usersPage,
}) => {
await usersPage.goto();
await expect(usersPage.page.getByText("Users & Requests")).toBeVisible();
await expect(usersPage.inviteButton).toBeVisible();
await expect(usersPage.searchInput).toBeVisible();
// Stats bar renders number and label as separate elements
await expect(usersPage.page.getByText("active users")).toBeVisible();
});
test("table renders with correct column headers", async ({ usersPage }) => {
await usersPage.goto();
for (const header of [
"Name",
"Groups",
"Account Type",
"Status",
"Last Updated",
]) {
await expect(
usersPage.table.locator("th").filter({ hasText: header })
).toBeVisible();
}
});
test("pagination shows summary and controls", async ({ usersPage }) => {
await usersPage.goto();
await expect(usersPage.paginationSummary).toBeVisible();
await expect(usersPage.paginationSummary).toContainText("Showing");
});
test("CSV download button is visible in footer", async ({ usersPage }) => {
await usersPage.goto();
await expect(usersPage.downloadCsvButton).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Search (uses existing DB users — at least admin_user@example.com)
// ---------------------------------------------------------------------------
test.describe("Users page — search", () => {
test("search filters table rows by email", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search(TEST_ADMIN_CREDENTIALS.email);
const row = usersPage.getRowByEmail(TEST_ADMIN_CREDENTIALS.email);
await expect(row).toBeVisible();
const rowCount = await usersPage.getVisibleRowCount();
expect(rowCount).toBeGreaterThanOrEqual(1);
});
test("search with no results shows empty state", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search("zzz-no-match-exists-xyz@nowhere.invalid");
await expect(usersPage.page.getByText("No users found")).toBeVisible();
});
test("clearing search restores all results", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search("zzz-no-match-exists-xyz@nowhere.invalid");
await expect(usersPage.page.getByText("No users found")).toBeVisible();
await usersPage.clearSearch();
await expect(usersPage.table).toBeVisible();
const rowCount = await usersPage.getVisibleRowCount();
expect(rowCount).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// Filters (uses existing DB users)
// ---------------------------------------------------------------------------
test.describe("Users page — filters", () => {
test("account types filter shows expected roles", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.openAccountTypesFilter();
await expect(
usersPage.popover.getByText("All Account Types").first()
).toBeVisible();
await expect(usersPage.popover.getByText("Admin").first()).toBeVisible();
await expect(usersPage.popover.getByText("Basic").first()).toBeVisible();
await usersPage.closePopover();
});
test("filtering by Admin role shows only admin users", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.openAccountTypesFilter();
await usersPage.selectAccountType("Admin");
await usersPage.closePopover();
await expect(usersPage.accountTypesFilter).toContainText("Admin");
const rowCount = await usersPage.getVisibleRowCount();
expect(rowCount).toBeGreaterThan(0);
// Every visible row's Account Type column must say "Admin"
const roleTexts = await usersPage.getColumnTexts(2);
for (const role of roleTexts) {
expect(role).toBe("Admin");
}
});
test("status filter for Active shows only active users", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.openStatusFilter();
await usersPage.selectStatus("Active");
await usersPage.closePopover();
await expect(usersPage.statusFilter).toContainText("Active");
const rowCount = await usersPage.getVisibleRowCount();
expect(rowCount).toBeGreaterThan(0);
// Every visible row's Status column must say "Active"
const statusTexts = await usersPage.getColumnTexts(3);
for (const status of statusTexts) {
expect(status).toBe("Active");
}
});
test("resetting filter shows all users again", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.openStatusFilter();
await usersPage.selectStatus("Active");
await usersPage.closePopover();
const filteredCount = await usersPage.getVisibleRowCount();
await usersPage.openStatusFilter();
await usersPage.selectStatus("All Status");
await usersPage.closePopover();
const allCount = await usersPage.getVisibleRowCount();
expect(allCount).toBeGreaterThanOrEqual(filteredCount);
});
});
// ---------------------------------------------------------------------------
// Sorting (uses existing DB users)
// ---------------------------------------------------------------------------
test.describe("Users page — sorting", () => {
test("clicking Name sort twice reverses row order", async ({ usersPage }) => {
await usersPage.goto();
const firstRowBefore = await usersPage.tableRows.first().textContent();
// Click twice — first click may match default order; second guarantees reversal
await usersPage.sortByColumn("Name");
await usersPage.sortByColumn("Name");
const firstRowAfter = await usersPage.tableRows.first().textContent();
expect(firstRowAfter).not.toBe(firstRowBefore);
});
test("clicking Account Type sort twice reorders rows", async ({
usersPage,
}) => {
await usersPage.goto();
const rolesBefore = await usersPage.getColumnTexts(2);
// Click twice to guarantee a different order from default
await usersPage.sortByColumn("Account Type");
await usersPage.sortByColumn("Account Type");
const rolesAfter = await usersPage.getColumnTexts(2);
expect(rolesAfter.length).toBeGreaterThan(0);
expect(rolesAfter).not.toEqual(rolesBefore);
});
});
// ---------------------------------------------------------------------------
// Pagination (uses existing DB users — need > 8 for multi-page)
// ---------------------------------------------------------------------------
test.describe("Users page — pagination", () => {
test("clicking page 2 navigates to second page", async ({ usersPage }) => {
await usersPage.goto();
const summaryBefore = await usersPage.paginationSummary.textContent();
// With 10+ users and page size 8, page 2 should exist
await usersPage.goToPage(2);
await expect(usersPage.paginationSummary).not.toHaveText(summaryBefore!);
// Go back to page 1
await usersPage.goToPage(1);
await expect(usersPage.paginationSummary).toHaveText(summaryBefore!);
});
});
// ---------------------------------------------------------------------------
// Invite users (creates ephemeral data)
// ---------------------------------------------------------------------------
test.describe("Users page — invite users", () => {
test("invite modal opens with correct structure", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.openInviteModal();
await expect(usersPage.dialog.getByText("Invite Users")).toBeVisible();
await expect(usersPage.inviteEmailInput).toBeVisible();
await usersPage.cancelModal();
await expect(usersPage.dialog).not.toBeVisible();
});
test("invite a user and verify Invite Pending status", async ({
usersPage,
api,
}) => {
const email = uniqueEmail("invite");
await usersPage.goto();
await usersPage.openInviteModal();
await usersPage.addInviteEmail(email);
await usersPage.submitInvite();
await usersPage.expectToast(/Invited 1 user/);
// Reload and search
await usersPage.goto();
await usersPage.search(email);
const row = usersPage.getRowByEmail(email);
await expect(row).toBeVisible();
await expect(row).toContainText("Invite Pending");
// Cleanup
await api.cancelInvite(email);
});
test("invite multiple users at once", async ({ usersPage, api }) => {
const email1 = uniqueEmail("multi1");
const email2 = uniqueEmail("multi2");
await usersPage.goto();
await usersPage.openInviteModal();
await usersPage.addInviteEmail(email1);
await usersPage.addInviteEmail(email2);
await usersPage.submitInvite();
await usersPage.expectToast(/Invited 2 users/);
// Cleanup
await api.cancelInvite(email1);
await api.cancelInvite(email2);
});
test("invite modal shows error icon for invalid emails", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.openInviteModal();
await usersPage.addInviteEmail("not-an-email");
// The chip should be rendered with an error state
await expect(usersPage.dialog.getByText("not-an-email")).toBeVisible();
await usersPage.cancelModal();
});
});
// ---------------------------------------------------------------------------
// Row actions — deactivate / activate (creates ephemeral data)
// ---------------------------------------------------------------------------
test.describe("Users page — deactivate & activate", () => {
let testUserEmail: string;
test.beforeAll(async ({ browser }) => {
testUserEmail = uniqueEmail("deact");
await withApiContext(browser, async (api) => {
await api.registerUser(testUserEmail, TEST_PASSWORD);
});
});
test("deactivate and then reactivate a user", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search(testUserEmail);
const row = usersPage.getRowByEmail(testUserEmail);
await expect(row).toBeVisible();
await expect(row).toContainText("Active");
// Deactivate
await usersPage.openRowActions(testUserEmail);
await usersPage.clickRowAction("Deactivate User");
await expect(usersPage.dialog.getByText("Deactivate User")).toBeVisible();
await expect(usersPage.dialog.getByText(testUserEmail)).toBeVisible();
await expect(
usersPage.dialog.getByText("will immediately lose access")
).toBeVisible();
await usersPage.confirmModalAction("Deactivate");
await usersPage.expectToast("User deactivated");
// Verify Inactive
await usersPage.goto();
await usersPage.search(testUserEmail);
const inactiveRow = usersPage.getRowByEmail(testUserEmail);
await expect(inactiveRow).toContainText("Inactive");
// Reactivate
await usersPage.openRowActions(testUserEmail);
await usersPage.clickRowAction("Activate User");
await expect(usersPage.dialog.getByText("Activate User")).toBeVisible();
await usersPage.confirmModalAction("Activate");
await usersPage.expectToast("User activated");
// Verify Active again
await usersPage.goto();
await usersPage.search(testUserEmail);
const reactivatedRow = usersPage.getRowByEmail(testUserEmail);
await expect(reactivatedRow).toContainText("Active");
});
test.afterAll(async ({ browser }) => {
await withApiContext(browser, async (api) => {
await softCleanup(() => api.deactivateUser(testUserEmail));
await softCleanup(() => api.deleteUser(testUserEmail));
});
});
});
// ---------------------------------------------------------------------------
// Row actions — delete user (creates ephemeral data)
// ---------------------------------------------------------------------------
test.describe("Users page — delete user", () => {
test("delete an inactive user", async ({ usersPage, api }) => {
const email = uniqueEmail("delete");
await api.registerUser(email, TEST_PASSWORD);
await api.deactivateUser(email);
await usersPage.goto();
await usersPage.search(email);
const row = usersPage.getRowByEmail(email);
await expect(row).toBeVisible();
await expect(row).toContainText("Inactive");
await usersPage.openRowActions(email);
await usersPage.clickRowAction("Delete User");
await expect(usersPage.dialog.getByText("Delete User")).toBeVisible();
await expect(
usersPage.dialog.getByText("will be permanently removed")
).toBeVisible();
await usersPage.confirmModalAction("Delete");
await usersPage.expectToast("User deleted");
// User gone
await usersPage.goto();
await usersPage.search(email);
await expect(usersPage.page.getByText("No users found")).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Row actions — cancel invite (creates ephemeral data)
// ---------------------------------------------------------------------------
test.describe("Users page — cancel invite", () => {
test("cancel a pending invite", async ({ usersPage, api }) => {
const email = uniqueEmail("cancel-inv");
await api.inviteUsers([email]);
await usersPage.goto();
await usersPage.search(email);
const row = usersPage.getRowByEmail(email);
await expect(row).toBeVisible();
await expect(row).toContainText("Invite Pending");
await usersPage.openRowActions(email);
await usersPage.clickRowAction("Cancel Invite");
await expect(
usersPage.dialog.getByText("Cancel Invite").first()
).toBeVisible();
await usersPage.confirmModalAction("Cancel Invite");
await usersPage.expectToast("Invite cancelled");
// User gone
await usersPage.goto();
await usersPage.search(email);
await expect(usersPage.page.getByText("No users found")).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Inline role editing (creates ephemeral data)
// ---------------------------------------------------------------------------
test.describe("Users page — inline role editing", () => {
let testUserEmail: string;
test.beforeAll(async ({ browser }) => {
testUserEmail = uniqueEmail("role");
await withApiContext(browser, async (api) => {
await api.registerUser(testUserEmail, TEST_PASSWORD);
});
});
test("change user role from Basic to Admin and back", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.search(testUserEmail);
const row = usersPage.getRowByEmail(testUserEmail);
await expect(row).toBeVisible();
// Initially Basic
await expect(row.getByText("Basic")).toBeVisible();
// Change to Admin
await usersPage.openRoleDropdown(testUserEmail);
await usersPage.selectRole("Admin");
await expect(row.getByText("Admin")).toBeVisible();
// Change back to Basic
await usersPage.openRoleDropdown(testUserEmail);
await usersPage.selectRole("Basic");
await expect(row.getByText("Basic")).toBeVisible();
});
test.afterAll(async ({ browser }) => {
await withApiContext(browser, async (api) => {
await softCleanup(() => api.deactivateUser(testUserEmail));
await softCleanup(() => api.deleteUser(testUserEmail));
});
});
});
// ---------------------------------------------------------------------------
// Group management (creates ephemeral data)
// ---------------------------------------------------------------------------
test.describe("Users page — group management", () => {
let testUserEmail: string;
let testGroupId: number;
const groupName = `E2E-UsersTest-${Date.now()}`;
test.beforeAll(async ({ browser }) => {
testUserEmail = uniqueEmail("grp");
await withApiContext(browser, async (api) => {
await api.registerUser(testUserEmail, TEST_PASSWORD);
testGroupId = await api.createUserGroup(groupName);
await api.waitForGroupSync(testGroupId);
});
});
test("add user to group via edit groups modal", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search(testUserEmail);
const row = usersPage.getRowByEmail(testUserEmail);
await expect(row).toBeVisible();
await usersPage.openEditGroupsModal(testUserEmail);
await usersPage.searchGroupsInModal(groupName);
await usersPage.toggleGroupInModal(groupName);
await usersPage.saveGroupsModal();
await usersPage.expectToast("User updated");
// Verify group shows in the row
await usersPage.goto();
await usersPage.search(testUserEmail);
const rowWithGroup = usersPage.getRowByEmail(testUserEmail);
await expect(rowWithGroup).toContainText(groupName);
});
test("remove user from group via edit groups modal", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.search(testUserEmail);
const row = usersPage.getRowByEmail(testUserEmail);
await expect(row).toBeVisible();
await usersPage.openEditGroupsModal(testUserEmail);
// Group shows as joined — click to remove
await usersPage.toggleGroupInModal(groupName);
await usersPage.saveGroupsModal();
await usersPage.expectToast("User updated");
// Verify group removed
await usersPage.goto();
await usersPage.search(testUserEmail);
await expect(usersPage.getRowByEmail(testUserEmail)).not.toContainText(
groupName
);
});
test.afterAll(async ({ browser }) => {
await withApiContext(browser, async (api) => {
await softCleanup(() => api.deleteUserGroup(testGroupId));
await softCleanup(() => api.deactivateUser(testUserEmail));
await softCleanup(() => api.deleteUser(testUserEmail));
});
});
});
// ---------------------------------------------------------------------------
// Stats bar
// ---------------------------------------------------------------------------
test.describe("Users page — stats bar", () => {
test("stats bar shows active users count", async ({ usersPage }) => {
await usersPage.goto();
// Number and label are separate elements; check for the label
await expect(usersPage.page.getByText("active users")).toBeVisible();
});
test("stats bar updates after inviting a user", async ({
usersPage,
api,
}) => {
const email = uniqueEmail("stats");
await usersPage.goto();
await usersPage.openInviteModal();
await usersPage.addInviteEmail(email);
await usersPage.submitInvite();
await usersPage.expectToast(/Invited 1 user/);
// Stats bar should reflect the new invite
await usersPage.goto();
await expect(usersPage.page.getByText("pending invites")).toBeVisible();
// Cleanup
await api.cancelInvite(email);
});
});

View File

@@ -0,0 +1,181 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { loginAsWorkerUser } from "@tests/e2e/utils/auth";
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
import { expectElementScreenshot } from "@tests/e2e/utils/visualRegression";
const PROJECT_NAME = "E2E-PROJECT-FILES-VISUAL";
const ATTACHMENT_ITEM_TITLE_TEST_ID = "attachment-item-title";
const ATTACHMENT_ITEM_ICON_WRAPPER_TEST_ID = "attachment-item-icon-wrapper";
const LONG_FILE_NAME =
"CSE_202_Final_Project_Solution_Regression_Check_Long_Name.txt";
const FILE_CONTENT = "Visual regression test content for long filename cards.";
let projectId: number | null = null;
type Geometry = {
elementLeft: number;
elementRight: number;
elementTop: number;
elementBottom: number;
cardLeft: number;
cardRight: number;
cardTop: number;
cardBottom: number;
};
function getFilesSection(page: Page): Locator {
return page
.locator("div")
.filter({ has: page.getByRole("button", { name: "Add Files" }) })
.filter({ hasText: "Chats in this project can access these files." })
.first();
}
async function uploadFileToProject(
page: Page,
targetProjectId: number,
fileName: string,
content: string
): Promise<void> {
const response = await page.request.post("/api/user/projects/file/upload", {
multipart: {
project_id: String(targetProjectId),
files: {
name: fileName,
mimeType: "text/plain",
buffer: Buffer.from(content, "utf-8"),
},
},
});
expect(response.ok()).toBeTruthy();
}
async function getElementGeometryInCard(
element: Locator
): Promise<Geometry | null> {
return element.evaluate((targetEl) => {
let cardEl: HTMLElement | null = targetEl.parentElement;
while (cardEl) {
const style = window.getComputedStyle(cardEl);
const hasBorder =
parseFloat(style.borderTopWidth) > 0 ||
parseFloat(style.borderLeftWidth) > 0;
const hasRadius = parseFloat(style.borderTopLeftRadius) > 0;
if (hasBorder && hasRadius) {
break;
}
cardEl = cardEl.parentElement;
}
if (!cardEl) {
return null;
}
const elementRect = targetEl.getBoundingClientRect();
const cardRect = cardEl.getBoundingClientRect();
return {
elementLeft: elementRect.left,
elementRight: elementRect.right,
elementTop: elementRect.top,
elementBottom: elementRect.bottom,
cardLeft: cardRect.left,
cardRight: cardRect.right,
cardTop: cardRect.top,
cardBottom: cardRect.bottom,
};
});
}
function expectGeometryWithinCard(geometry: Geometry | null): void {
expect(geometry).not.toBeNull();
expect(geometry!.elementLeft).toBeGreaterThanOrEqual(geometry!.cardLeft - 1);
expect(geometry!.elementRight).toBeLessThanOrEqual(geometry!.cardRight + 1);
expect(geometry!.elementTop).toBeGreaterThanOrEqual(geometry!.cardTop - 1);
expect(geometry!.elementBottom).toBeLessThanOrEqual(geometry!.cardBottom + 1);
}
test.describe("Project Files visual regression", () => {
test.beforeAll(async ({ browser }, workerInfo) => {
const context = await browser.newContext();
const page = await context.newPage();
await loginAsWorkerUser(page, workerInfo.workerIndex);
const client = new OnyxApiClient(page.request);
projectId = await client.createProject(PROJECT_NAME);
await uploadFileToProject(page, projectId, LONG_FILE_NAME, FILE_CONTENT);
await context.close();
});
test.afterAll(async ({ browser }, workerInfo) => {
if (!projectId) {
return;
}
const context = await browser.newContext();
const page = await context.newPage();
await loginAsWorkerUser(page, workerInfo.workerIndex);
const client = new OnyxApiClient(page.request);
await client.deleteProject(projectId);
await context.close();
});
test.beforeEach(async ({ page }, workerInfo) => {
if (projectId === null) {
throw new Error(
"Project setup failed in beforeAll; cannot run visual regression test"
);
}
await page.context().clearCookies();
await loginAsWorkerUser(page, workerInfo.workerIndex);
await page.goto(`/app?projectId=${projectId}`);
await page.waitForLoadState("networkidle");
await expect(
page.getByText("Chats in this project can access these files.")
).toBeVisible();
});
test("long underscore filename stays visually contained in file card", async ({
page,
}) => {
const filesSection = getFilesSection(page);
await expect(filesSection).toBeVisible();
const fileTitle = filesSection
.locator(`[data-testid="${ATTACHMENT_ITEM_TITLE_TEST_ID}"]`)
.filter({ hasText: LONG_FILE_NAME })
.first();
await expect(fileTitle).toBeVisible();
// Wait for deterministic post-processing state before geometry checks/screenshot.
await expect(fileTitle).not.toContainText("Processing...", {
timeout: 30_000,
});
await expect(fileTitle).not.toContainText("Uploading...", {
timeout: 30_000,
});
await expect(fileTitle).toContainText("TXT", { timeout: 30_000 });
const iconWrapper = filesSection
.locator(`[data-testid="${ATTACHMENT_ITEM_ICON_WRAPPER_TEST_ID}"]`)
.first();
await expect(iconWrapper).toBeVisible();
await expectElementScreenshot(filesSection, {
name: "project-files-long-underscore-filename",
});
const iconGeometry = await getElementGeometryInCard(iconWrapper);
const titleGeometry = await getElementGeometryInCard(fileTitle);
expectGeometryWithinCard(iconGeometry);
expectGeometryWithinCard(titleGeometry);
});
});

View File

@@ -588,6 +588,34 @@ export class OnyxApiClient {
return responseData.id;
}
/**
* Polls until a user group has finished syncing (is_up_to_date === true).
* Newly created groups start syncing immediately; many mutation endpoints
* reject requests while the group is still syncing.
*/
async waitForGroupSync(
groupId: number,
timeout: number = 30000
): Promise<void> {
await expect
.poll(
async () => {
const res = await this.get("/manage/admin/user-group");
const groups = await res.json();
const group = groups.find(
(g: { id: number; is_up_to_date: boolean }) => g.id === groupId
);
return group?.is_up_to_date ?? false;
},
{
message: `User group ${groupId} did not finish syncing`,
timeout,
}
)
.toBe(true);
this.log(`User group ${groupId} finished syncing`);
}
/**
* Deletes a user group.
*
@@ -1073,6 +1101,62 @@ export class OnyxApiClient {
);
}
// === User Management Methods ===
async deactivateUser(email: string): Promise<void> {
const response = await this.request.patch(
`${this.baseUrl}/manage/admin/deactivate-user`,
{ data: { user_email: email } }
);
await this.handleResponse(response, `Failed to deactivate user ${email}`);
this.log(`Deactivated user: ${email}`);
}
async activateUser(email: string): Promise<void> {
const response = await this.request.patch(
`${this.baseUrl}/manage/admin/activate-user`,
{ data: { user_email: email } }
);
await this.handleResponse(response, `Failed to activate user ${email}`);
this.log(`Activated user: ${email}`);
}
async deleteUser(email: string): Promise<void> {
const response = await this.request.delete(
`${this.baseUrl}/manage/admin/delete-user`,
{ data: { user_email: email } }
);
await this.handleResponse(response, `Failed to delete user ${email}`);
this.log(`Deleted user: ${email}`);
}
async cancelInvite(email: string): Promise<void> {
const response = await this.request.patch(
`${this.baseUrl}/manage/admin/remove-invited-user`,
{ data: { user_email: email } }
);
await this.handleResponse(response, `Failed to cancel invite for ${email}`);
this.log(`Cancelled invite for: ${email}`);
}
async inviteUsers(emails: string[]): Promise<void> {
const response = await this.put("/manage/admin/users", { emails });
await this.handleResponse(response, `Failed to invite users`);
this.log(`Invited users: ${emails.join(", ")}`);
}
async setPersonalName(name: string): Promise<void> {
const response = await this.request.patch(
`${this.baseUrl}/user/personalization`,
{ data: { name } }
);
await this.handleResponse(
response,
`Failed to set personal name to ${name}`
);
this.log(`Set personal name: ${name}`);
}
// === Chat Session Methods ===
/**