Compare commits

...

17 Commits

Author SHA1 Message Date
Bo-Onyx
fa6ae22d75 chore(hooks): Define Hook Point 2026-03-16 19:04:48 -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
57 changed files with 4396 additions and 1617 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

@@ -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

@@ -0,0 +1,123 @@
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
timeout_seconds: float | None = Field(default=None, gt=0)
@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
status_code: int | None
duration_ms: int | 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,62 @@
from abc import ABC
from abc import abstractmethod
from dataclasses import dataclass
from dataclasses import field
from dataclasses import fields
from typing import Any
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
_REQUIRED_CONSTANT_FIELDS = frozenset(
{
"hook_point",
"display_name",
"description",
"default_timeout_seconds",
"fail_hard_description",
}
)
@dataclass(frozen=True)
class HookPointSpec(ABC):
"""Static metadata and contract for a pipeline hook point.
Each hook point is a concrete subclass of this class. Onyx engineers
own these definitions — customers never touch this code.
Instances are registered in onyx.hooks.registry.REGISTRY and served
via GET /api/admin/hook-points.
Convention for concrete subclasses: declare all class-specific constant fields
with field(init=False, default=...) to prevent them from being overridden at
construction time and corrupting the registry contract.
"""
hook_point: HookPoint
display_name: str
description: str
default_timeout_seconds: float
fail_hard_description: str
docs_url: str | None = None
default_fail_strategy: HookFailStrategy = field(default=HookFailStrategy.HARD)
def __post_init__(self) -> None:
init_fields = {f.name for f in fields(self) if f.init}
violating = _REQUIRED_CONSTANT_FIELDS & init_fields
if violating:
raise TypeError(
f"{type(self).__name__} must declare {sorted(violating)} with "
"field(init=False, default=...) to prevent callers from overriding them."
)
@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,36 @@
from dataclasses import dataclass
from dataclasses import field
from typing import Any
from onyx.db.enums import HookPoint
from onyx.hooks.points.base import HookPointSpec
@dataclass(frozen=True)
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 = field(init=False, default=HookPoint.DOCUMENT_INGESTION)
display_name: str = field(init=False, default="Document Ingestion")
description: str = field(
init=False,
default="Runs during document ingestion. Allows filtering or transforming documents before indexing.",
)
default_timeout_seconds: float = field(init=False, default=30.0)
fail_hard_description: str = field(
init=False,
default="The document will not be indexed.",
)
@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,86 @@
from dataclasses import dataclass
from dataclasses import field
from typing import Any
from onyx.db.enums import HookPoint
from onyx.hooks.points.base import HookPointSpec
@dataclass(frozen=True)
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 = field(init=False, default=HookPoint.QUERY_PROCESSING)
display_name: str = field(init=False, default="Query Processing")
description: str = field(
init=False,
default=(
"Runs on every user query before it enters the pipeline. "
"Allows rewriting, filtering, or rejecting queries."
),
)
default_timeout_seconds: float = field(
init=False, default=5.0
) # user is actively waiting — keep tight
fail_hard_description: str = field(
init=False,
default="The query will be blocked and the user will see an error message.",
)
@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,44 @@
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
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

@@ -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

@@ -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,97 @@
import dataclasses
import pytest
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
from onyx.hooks.points.base import HookPointSpec
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
def test_constants_are_not_overridable() -> None:
# field(init=False) prevents class-specific constants from being overridden
# at construction time, protecting the registry contract.
init_fields = {f.name for f in dataclasses.fields(QueryProcessingSpec) if f.init}
for constant in (
"hook_point",
"display_name",
"description",
"default_timeout_seconds",
"fail_hard_description",
):
assert constant not in init_fields, f"{constant} should not be an init field"
def test_base_class_rejects_subclass_missing_init_false() -> None:
# If a subclass forgets field(init=False), __post_init__ raises TypeError at construction time.
@dataclasses.dataclass(frozen=True)
class BadSpec(HookPointSpec):
# hook_point left as init=True (inherited default) — convention violated
@property
def input_schema(self) -> dict:
return {}
@property
def output_schema(self) -> dict:
return {}
with pytest.raises(TypeError, match="field\\(init=False, default=\\.\\.\\.\\)"):
BadSpec(
hook_point=HookPoint.QUERY_PROCESSING,
display_name="bad",
description="bad",
default_timeout_seconds=1.0,
fail_hard_description="bad",
)

View File

@@ -0,0 +1,48 @@
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 REGISTRY
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 set(REGISTRY.keys()) == set(
HookPoint
), f"Missing specs for: {set(HookPoint) - set(REGISTRY.keys())}"
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 ===
/**