mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-17 05:32:42 +00:00
Compare commits
22 Commits
feat/admin
...
bo/hook_ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa6ae22d75 | ||
|
|
11cfc92f15 | ||
|
|
c7da99cfd7 | ||
|
|
b384c77863 | ||
|
|
b0f31cd46b | ||
|
|
323eb9bbba | ||
|
|
708e310849 | ||
|
|
c25509e212 | ||
|
|
6af0da41bd | ||
|
|
b94da25d7c | ||
|
|
7d443c1b53 | ||
|
|
d6b7b3c68f | ||
|
|
f5073d331e | ||
|
|
64c9f6a0d5 | ||
|
|
f5a494f790 | ||
|
|
8598e9f25d | ||
|
|
3ef8aecc54 | ||
|
|
eb311c7550 | ||
|
|
13284d9def | ||
|
|
aaa99fcb60 | ||
|
|
5f628da4e8 | ||
|
|
e40f80cfe1 |
30
.github/workflows/deployment.yml
vendored
30
.github/workflows/deployment.yml
vendored
@@ -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: |
|
||||
|
||||
2
.github/workflows/pr-desktop-build.yml
vendored
2
.github/workflows/pr-desktop-build.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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/
|
||||
|
||||
4
.github/workflows/pr-golang-tests.yml
vendored
4
.github/workflows/pr-golang-tests.yml
vendored
@@ -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]
|
||||
|
||||
6
.github/workflows/pr-integration-tests.yml
vendored
6
.github/workflows/pr-integration-tests.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/pr-jest-tests.yml
vendored
2
.github/workflows/pr-jest-tests.yml
vendored
@@ -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
|
||||
|
||||
14
.github/workflows/pr-playwright-tests.yml
vendored
14
.github/workflows/pr-playwright-tests.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/pr-python-model-tests.yml
vendored
2
.github/workflows/pr-python-model-tests.yml
vendored
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
6
.github/workflows/sandbox-deployment.yml
vendored
6
.github/workflows/sandbox-deployment.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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")
|
||||
@@ -118,9 +118,7 @@ JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL", None)
|
||||
SUPER_USERS = json.loads(os.environ.get("SUPER_USERS", "[]"))
|
||||
SUPER_CLOUD_API_KEY = os.environ.get("SUPER_CLOUD_API_KEY", "api_key")
|
||||
|
||||
# The posthog client does not accept empty API keys or hosts however it fails silently
|
||||
# when the capture is called. These defaults prevent Posthog issues from breaking the Onyx app
|
||||
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY") or "FooBar"
|
||||
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY")
|
||||
POSTHOG_HOST = os.environ.get("POSTHOG_HOST") or "https://us.i.posthog.com"
|
||||
POSTHOG_DEBUG_LOGS_ENABLED = (
|
||||
os.environ.get("POSTHOG_DEBUG_LOGS_ENABLED", "").lower() == "true"
|
||||
|
||||
@@ -34,6 +34,9 @@ class PostHogFeatureFlagProvider(FeatureFlagProvider):
|
||||
Returns:
|
||||
True if the feature is enabled for the user, False otherwise.
|
||||
"""
|
||||
if not posthog:
|
||||
return False
|
||||
|
||||
try:
|
||||
posthog.set(
|
||||
distinct_id=user_id,
|
||||
|
||||
@@ -29,7 +29,6 @@ from onyx.configs.app_configs import OPENAI_DEFAULT_API_KEY
|
||||
from onyx.configs.app_configs import OPENROUTER_DEFAULT_API_KEY
|
||||
from onyx.configs.app_configs import VERTEXAI_DEFAULT_CREDENTIALS
|
||||
from onyx.configs.app_configs import VERTEXAI_DEFAULT_LOCATION
|
||||
from onyx.configs.constants import MilestoneRecordType
|
||||
from onyx.db.engine.sql_engine import get_session_with_shared_schema
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
from onyx.db.image_generation import create_default_image_gen_config_from_api_key
|
||||
@@ -59,7 +58,6 @@ from onyx.server.manage.llm.models import LLMProviderUpsertRequest
|
||||
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
|
||||
from onyx.setup import setup_onyx
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.telemetry import mt_cloud_telemetry
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
|
||||
from shared_configs.configs import TENANT_ID_PREFIX
|
||||
@@ -71,7 +69,9 @@ logger = setup_logger()
|
||||
|
||||
|
||||
async def get_or_provision_tenant(
|
||||
email: str, referral_source: str | None = None, request: Request | None = None
|
||||
email: str,
|
||||
referral_source: str | None = None,
|
||||
request: Request | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get existing tenant ID for an email or create a new tenant if none exists.
|
||||
@@ -693,12 +693,6 @@ async def assign_tenant_to_user(
|
||||
|
||||
try:
|
||||
add_users_to_tenant([email], tenant_id)
|
||||
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=email,
|
||||
event=MilestoneRecordType.TENANT_CREATED,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to assign tenant {tenant_id} to user {email}")
|
||||
raise Exception("Failed to assign tenant to user")
|
||||
|
||||
@@ -9,6 +9,7 @@ from ee.onyx.configs.app_configs import POSTHOG_API_KEY
|
||||
from ee.onyx.configs.app_configs import POSTHOG_DEBUG_LOGS_ENABLED
|
||||
from ee.onyx.configs.app_configs import POSTHOG_HOST
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@@ -18,12 +19,19 @@ def posthog_on_error(error: Any, items: Any) -> None:
|
||||
logger.error(f"PostHog error: {error}, items: {items}")
|
||||
|
||||
|
||||
posthog = Posthog(
|
||||
project_api_key=POSTHOG_API_KEY,
|
||||
host=POSTHOG_HOST,
|
||||
debug=POSTHOG_DEBUG_LOGS_ENABLED,
|
||||
on_error=posthog_on_error,
|
||||
)
|
||||
posthog: Posthog | None = None
|
||||
if POSTHOG_API_KEY:
|
||||
posthog = Posthog(
|
||||
project_api_key=POSTHOG_API_KEY,
|
||||
host=POSTHOG_HOST,
|
||||
debug=POSTHOG_DEBUG_LOGS_ENABLED,
|
||||
on_error=posthog_on_error,
|
||||
)
|
||||
elif MULTI_TENANT:
|
||||
logger.warning(
|
||||
"POSTHOG_API_KEY is not set but MULTI_TENANT is enabled — "
|
||||
"PostHog telemetry and feature flags will be disabled"
|
||||
)
|
||||
|
||||
# For cross referencing between cloud and www Onyx sites
|
||||
# NOTE: These clients are separate because they are separate posthog projects.
|
||||
@@ -60,7 +68,7 @@ def capture_and_sync_with_alternate_posthog(
|
||||
logger.error(f"Error capturing marketing posthog event: {e}")
|
||||
|
||||
try:
|
||||
if cloud_user_id := props.get("onyx_cloud_user_id"):
|
||||
if posthog and (cloud_user_id := props.get("onyx_cloud_user_id")):
|
||||
cloud_props = props.copy()
|
||||
cloud_props.pop("onyx_cloud_user_id", None)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from ee.onyx.utils.posthog_client import posthog
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
@@ -5,12 +7,27 @@ 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:
|
||||
return
|
||||
|
||||
logger.info(f"Capturing PostHog event: {distinct_id} {event} {properties}")
|
||||
try:
|
||||
posthog.capture(distinct_id, event, properties)
|
||||
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}")
|
||||
|
||||
@@ -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,12 +818,25 @@ 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=user.email,
|
||||
distinct_id=str(user.id),
|
||||
event=MilestoneRecordType.USER_SIGNED_UP,
|
||||
)
|
||||
|
||||
if user_count == 1:
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=str(user.id),
|
||||
event=MilestoneRecordType.TENANT_CREATED,
|
||||
)
|
||||
|
||||
finally:
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
|
||||
|
||||
@@ -1652,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"),
|
||||
@@ -1671,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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -490,13 +490,13 @@ def handle_stream_message_objects(
|
||||
# Milestone tracking, most devs using the API don't need to understand this
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=user.email if not user.is_anonymous else tenant_id,
|
||||
distinct_id=str(user.id) if not user.is_anonymous else tenant_id,
|
||||
event=MilestoneRecordType.MULTIPLE_ASSISTANTS,
|
||||
)
|
||||
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=user.email if not user.is_anonymous else tenant_id,
|
||||
distinct_id=str(user.id) if not user.is_anonymous else tenant_id,
|
||||
event=MilestoneRecordType.USER_MESSAGE_SENT,
|
||||
properties={
|
||||
"origin": new_msg_req.origin.value,
|
||||
|
||||
@@ -1046,6 +1046,8 @@ POD_NAMESPACE = os.environ.get("POD_NAMESPACE")
|
||||
|
||||
DEV_MODE = os.environ.get("DEV_MODE", "").lower() == "true"
|
||||
|
||||
HOOK_ENABLED = os.environ.get("HOOK_ENABLED", "").lower() == "true"
|
||||
|
||||
INTEGRATION_TESTS_MODE = os.environ.get("INTEGRATION_TESTS_MODE", "").lower() == "true"
|
||||
|
||||
#####
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -35,6 +35,8 @@ class OnyxErrorCode(Enum):
|
||||
INSUFFICIENT_PERMISSIONS = ("INSUFFICIENT_PERMISSIONS", 403)
|
||||
ADMIN_ONLY = ("ADMIN_ONLY", 403)
|
||||
EE_REQUIRED = ("EE_REQUIRED", 403)
|
||||
SINGLE_TENANT_ONLY = ("SINGLE_TENANT_ONLY", 403)
|
||||
ENV_VAR_GATED = ("ENV_VAR_GATED", 403)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation / Bad Request (400)
|
||||
|
||||
0
backend/onyx/hooks/__init__.py
Normal file
0
backend/onyx/hooks/__init__.py
Normal file
26
backend/onyx/hooks/api_dependencies.py
Normal file
26
backend/onyx/hooks/api_dependencies.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from onyx.configs.app_configs import HOOK_ENABLED
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
|
||||
|
||||
def require_hook_enabled() -> None:
|
||||
"""FastAPI dependency that gates all hook management endpoints.
|
||||
|
||||
Hooks are only available in single-tenant / self-hosted deployments with
|
||||
HOOK_ENABLED=true explicitly set. Two layers of protection:
|
||||
1. MULTI_TENANT check — rejects even if HOOK_ENABLED is accidentally set true
|
||||
2. HOOK_ENABLED flag — explicit opt-in by the operator
|
||||
|
||||
Use as: Depends(require_hook_enabled)
|
||||
"""
|
||||
if MULTI_TENANT:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.SINGLE_TENANT_ONLY,
|
||||
"Hooks are not available in multi-tenant deployments",
|
||||
)
|
||||
if not HOOK_ENABLED:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.ENV_VAR_GATED,
|
||||
"Hooks are not enabled. Set HOOK_ENABLED=true to enable.",
|
||||
)
|
||||
123
backend/onyx/hooks/models.py
Normal file
123
backend/onyx/hooks/models.py
Normal 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,
|
||||
)
|
||||
0
backend/onyx/hooks/points/__init__.py
Normal file
0
backend/onyx/hooks/points/__init__.py
Normal file
62
backend/onyx/hooks/points/base.py
Normal file
62
backend/onyx/hooks/points/base.py
Normal 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."""
|
||||
36
backend/onyx/hooks/points/document_ingestion.py
Normal file
36
backend/onyx/hooks/points/document_ingestion.py
Normal 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": {}}
|
||||
86
backend/onyx/hooks/points/query_processing.py
Normal file
86
backend/onyx/hooks/points/query_processing.py
Normal 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"],
|
||||
}
|
||||
44
backend/onyx/hooks/registry.py
Normal file
44
backend/onyx/hooks/registry.py
Normal 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())
|
||||
@@ -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:
|
||||
|
||||
@@ -1319,7 +1319,7 @@ def get_connector_indexing_status(
|
||||
# Track admin page visit for analytics
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=user.email,
|
||||
distinct_id=str(user.id),
|
||||
event=MilestoneRecordType.VISITED_ADMIN_PAGE,
|
||||
)
|
||||
|
||||
@@ -1533,7 +1533,7 @@ def create_connector_from_model(
|
||||
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=user.email,
|
||||
distinct_id=str(user.id),
|
||||
event=MilestoneRecordType.CREATED_CONNECTOR,
|
||||
)
|
||||
|
||||
@@ -1611,7 +1611,7 @@ def create_connector_with_mock_credential(
|
||||
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=user.email,
|
||||
distinct_id=str(user.id),
|
||||
event=MilestoneRecordType.CREATED_CONNECTOR,
|
||||
)
|
||||
return response
|
||||
@@ -1915,9 +1915,7 @@ def submit_connector_request(
|
||||
if not connector_name:
|
||||
raise HTTPException(status_code=400, detail="Connector name cannot be empty")
|
||||
|
||||
# Get user identifier for telemetry
|
||||
user_email = user.email
|
||||
distinct_id = user_email or tenant_id
|
||||
|
||||
# Track connector request via PostHog telemetry (Cloud only)
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
@@ -1925,11 +1923,11 @@ def submit_connector_request(
|
||||
if MULTI_TENANT:
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=distinct_id,
|
||||
distinct_id=str(user.id),
|
||||
event=MilestoneRecordType.REQUESTED_CONNECTOR,
|
||||
properties={
|
||||
"connector_name": connector_name,
|
||||
"user_email": user_email,
|
||||
"user_email": user.email,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ def create_persona(
|
||||
)
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=user.email,
|
||||
distinct_id=str(user.id),
|
||||
event=MilestoneRecordType.CREATED_ASSISTANT,
|
||||
)
|
||||
|
||||
|
||||
@@ -561,7 +561,7 @@ def handle_send_chat_message(
|
||||
tenant_id = get_current_tenant_id()
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=tenant_id if user.is_anonymous else user.email,
|
||||
distinct_id=tenant_id if user.is_anonymous else str(user.id),
|
||||
event=MilestoneRecordType.RAN_QUERY,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -65,7 +65,7 @@ attrs==25.4.0
|
||||
# jsonschema
|
||||
# referencing
|
||||
# zeep
|
||||
authlib==1.6.7
|
||||
authlib==1.6.9
|
||||
# via fastmcp
|
||||
azure-cognitiveservices-speech==1.38.0
|
||||
# via onyx
|
||||
|
||||
@@ -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
|
||||
|
||||
120
backend/tests/unit/onyx/auth/test_is_same_origin.py
Normal file
120
backend/tests/unit/onyx/auth/test_is_same_origin.py
Normal 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"
|
||||
)
|
||||
@@ -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()
|
||||
0
backend/tests/unit/onyx/hooks/__init__.py
Normal file
0
backend/tests/unit/onyx/hooks/__init__.py
Normal file
40
backend/tests/unit/onyx/hooks/test_api_dependencies.py
Normal file
40
backend/tests/unit/onyx/hooks/test_api_dependencies.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Unit tests for the hooks feature gate."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.hooks.api_dependencies import require_hook_enabled
|
||||
|
||||
|
||||
class TestRequireHookEnabled:
|
||||
def test_raises_when_multi_tenant(self) -> None:
|
||||
with (
|
||||
patch("onyx.hooks.api_dependencies.MULTI_TENANT", True),
|
||||
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
|
||||
):
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
require_hook_enabled()
|
||||
assert exc_info.value.error_code is OnyxErrorCode.SINGLE_TENANT_ONLY
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "multi-tenant" in exc_info.value.detail
|
||||
|
||||
def test_raises_when_flag_disabled(self) -> None:
|
||||
with (
|
||||
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
|
||||
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", False),
|
||||
):
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
require_hook_enabled()
|
||||
assert exc_info.value.error_code is OnyxErrorCode.ENV_VAR_GATED
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "HOOK_ENABLED" in exc_info.value.detail
|
||||
|
||||
def test_passes_when_enabled_single_tenant(self) -> None:
|
||||
with (
|
||||
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
|
||||
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
|
||||
):
|
||||
require_hook_enabled() # must not raise
|
||||
86
backend/tests/unit/onyx/hooks/test_models.py
Normal file
86
backend/tests/unit/onyx/hooks/test_models.py
Normal 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=" ",
|
||||
)
|
||||
97
backend/tests/unit/onyx/hooks/test_query_processing_spec.py
Normal file
97
backend/tests/unit/onyx/hooks/test_query_processing_spec.py
Normal 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",
|
||||
)
|
||||
48
backend/tests/unit/onyx/hooks/test_registry.py
Normal file
48
backend/tests/unit/onyx/hooks/test_registry.py
Normal 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()
|
||||
@@ -17,7 +17,7 @@ def test_mt_cloud_telemetry_noop_when_not_multi_tenant(monkeypatch: Any) -> None
|
||||
|
||||
telemetry_utils.mt_cloud_telemetry(
|
||||
tenant_id="tenant-1",
|
||||
distinct_id="user@example.com",
|
||||
distinct_id="12345678-1234-1234-1234-123456789abc",
|
||||
event=MilestoneRecordType.USER_MESSAGE_SENT,
|
||||
properties={"origin": "web"},
|
||||
)
|
||||
@@ -40,7 +40,7 @@ def test_mt_cloud_telemetry_calls_event_telemetry_when_multi_tenant(
|
||||
|
||||
telemetry_utils.mt_cloud_telemetry(
|
||||
tenant_id="tenant-1",
|
||||
distinct_id="user@example.com",
|
||||
distinct_id="12345678-1234-1234-1234-123456789abc",
|
||||
event=MilestoneRecordType.USER_MESSAGE_SENT,
|
||||
properties={"origin": "web"},
|
||||
)
|
||||
@@ -51,7 +51,52 @@ def test_mt_cloud_telemetry_calls_event_telemetry_when_multi_tenant(
|
||||
fallback=telemetry_utils.noop_fallback,
|
||||
)
|
||||
event_telemetry.assert_called_once_with(
|
||||
"user@example.com",
|
||||
"12345678-1234-1234-1234-123456789abc",
|
||||
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"},
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
6
uv.lock
generated
6
uv.lock
generated
@@ -453,14 +453,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.7"
|
||||
version = "1.6.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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";
|
||||
|
||||
239
web/lib/opal/src/components/pagination/Pagination.stories.tsx
Normal file
239
web/lib/opal/src/components/pagination/Pagination.stories.tsx
Normal 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="{size}"
|
||||
</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="{size}"
|
||||
</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="{size}"
|
||||
</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 />,
|
||||
};
|
||||
89
web/lib/opal/src/components/pagination/README.md
Normal file
89
web/lib/opal/src/components/pagination/README.md
Normal 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"`
|
||||
537
web/lib/opal/src/components/pagination/components.tsx
Normal file
537
web/lib/opal/src/components/pagination/components.tsx
Normal 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 };
|
||||
320
web/src/app/admin/agents/PersonaTable.tsx
Normal file
320
web/src/app/admin/agents/PersonaTable.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Persona } from "./interfaces";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Checkbox from "@/refresh-components/inputs/Checkbox";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { UniqueIdentifier } from "@dnd-kit/core";
|
||||
import { DraggableTable } from "@/components/table/DraggableTable";
|
||||
import {
|
||||
deletePersona,
|
||||
personaComparator,
|
||||
togglePersonaFeatured,
|
||||
togglePersonaVisibility,
|
||||
} from "./lib";
|
||||
import { FiEdit2 } from "react-icons/fi";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { Button } from "@opal/components";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import { SvgAlertCircle, SvgTrash } from "@opal/icons";
|
||||
import type { Route } from "next";
|
||||
|
||||
function PersonaTypeDisplay({ persona }: { persona: Persona }) {
|
||||
if (persona.builtin_persona) {
|
||||
return <Text as="p">Built-In</Text>;
|
||||
}
|
||||
|
||||
if (persona.featured) {
|
||||
return <Text as="p">Featured</Text>;
|
||||
}
|
||||
|
||||
if (persona.is_public) {
|
||||
return <Text as="p">Public</Text>;
|
||||
}
|
||||
|
||||
if (persona.groups.length > 0 || persona.users.length > 0) {
|
||||
return <Text as="p">Shared</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text as="p">Personal {persona.owner && <>({persona.owner.email})</>}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonasTable({
|
||||
personas,
|
||||
refreshPersonas,
|
||||
currentPage,
|
||||
pageSize,
|
||||
}: {
|
||||
personas: Persona[];
|
||||
refreshPersonas: () => void;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { refreshUser, isAdmin } = useUser();
|
||||
|
||||
const editablePersonas = useMemo(() => {
|
||||
return personas.filter((p) => !p.builtin_persona);
|
||||
}, [personas]);
|
||||
|
||||
const editablePersonaIds = useMemo(() => {
|
||||
return new Set(editablePersonas.map((p) => p.id.toString()));
|
||||
}, [editablePersonas]);
|
||||
|
||||
const [finalPersonas, setFinalPersonas] = useState<Persona[]>([]);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [personaToDelete, setPersonaToDelete] = useState<Persona | null>(null);
|
||||
const [defaultModalOpen, setDefaultModalOpen] = useState(false);
|
||||
const [personaToToggleDefault, setPersonaToToggleDefault] =
|
||||
useState<Persona | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const editable = editablePersonas.sort(personaComparator);
|
||||
const nonEditable = personas
|
||||
.filter((p) => !editablePersonaIds.has(p.id.toString()))
|
||||
.sort(personaComparator);
|
||||
setFinalPersonas([...editable, ...nonEditable]);
|
||||
}, [editablePersonas, personas, editablePersonaIds]);
|
||||
|
||||
const updatePersonaOrder = async (orderedPersonaIds: UniqueIdentifier[]) => {
|
||||
const reorderedPersonas = orderedPersonaIds.map(
|
||||
(id) => personas.find((persona) => persona.id.toString() === id)!
|
||||
);
|
||||
|
||||
setFinalPersonas(reorderedPersonas);
|
||||
|
||||
// Calculate display_priority based on current page.
|
||||
// Page 1 (items 0-9): priorities 0-9
|
||||
// Page 2 (items 10-19): priorities 10-19, etc.
|
||||
const pageStartIndex = (currentPage - 1) * pageSize;
|
||||
const displayPriorityMap = new Map<UniqueIdentifier, number>();
|
||||
orderedPersonaIds.forEach((personaId, ind) => {
|
||||
displayPriorityMap.set(personaId, pageStartIndex + ind);
|
||||
});
|
||||
|
||||
const response = await fetch("/api/admin/agents/display-priorities", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
display_priority_map: Object.fromEntries(displayPriorityMap),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(`Failed to update persona order - ${await response.text()}`);
|
||||
setFinalPersonas(personas);
|
||||
await refreshPersonas();
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshPersonas();
|
||||
await refreshUser();
|
||||
};
|
||||
|
||||
const openDeleteModal = (persona: Persona) => {
|
||||
setPersonaToDelete(persona);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
setDeleteModalOpen(false);
|
||||
setPersonaToDelete(null);
|
||||
};
|
||||
|
||||
const handleDeletePersona = async () => {
|
||||
if (personaToDelete) {
|
||||
const response = await deletePersona(personaToDelete.id);
|
||||
if (response.ok) {
|
||||
refreshPersonas();
|
||||
closeDeleteModal();
|
||||
} else {
|
||||
toast.error(`Failed to delete persona - ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openDefaultModal = (persona: Persona) => {
|
||||
setPersonaToToggleDefault(persona);
|
||||
setDefaultModalOpen(true);
|
||||
};
|
||||
|
||||
const closeDefaultModal = () => {
|
||||
setDefaultModalOpen(false);
|
||||
setPersonaToToggleDefault(null);
|
||||
};
|
||||
|
||||
const handleToggleDefault = async () => {
|
||||
if (personaToToggleDefault) {
|
||||
const response = await togglePersonaFeatured(
|
||||
personaToToggleDefault.id,
|
||||
personaToToggleDefault.featured
|
||||
);
|
||||
if (response.ok) {
|
||||
refreshPersonas();
|
||||
closeDefaultModal();
|
||||
} else {
|
||||
toast.error(`Failed to update persona - ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{deleteModalOpen && personaToDelete && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgAlertCircle}
|
||||
title="Delete Agent"
|
||||
onClose={closeDeleteModal}
|
||||
submit={<Button onClick={handleDeletePersona}>Delete</Button>}
|
||||
>
|
||||
{`Are you sure you want to delete ${personaToDelete.name}?`}
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
{defaultModalOpen &&
|
||||
personaToToggleDefault &&
|
||||
(() => {
|
||||
const isDefault = personaToToggleDefault.featured;
|
||||
|
||||
const title = isDefault
|
||||
? "Remove Featured Agent"
|
||||
: "Set Featured Agent";
|
||||
const buttonText = isDefault ? "Remove Feature" : "Set as Featured";
|
||||
const text = isDefault
|
||||
? `Are you sure you want to remove the featured status of ${personaToToggleDefault.name}?`
|
||||
: `Are you sure you want to set the featured status of ${personaToToggleDefault.name}?`;
|
||||
const additionalText = isDefault
|
||||
? `Removing "${personaToToggleDefault.name}" as a featured agent will not affect its visibility or accessibility.`
|
||||
: `Setting "${personaToToggleDefault.name}" as a featured agent will make it public and visible to all users. This action cannot be undone.`;
|
||||
|
||||
return (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgAlertCircle}
|
||||
title={title}
|
||||
onClose={closeDefaultModal}
|
||||
submit={
|
||||
<Button onClick={handleToggleDefault}>{buttonText}</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text as="p">{text}</Text>
|
||||
<Text as="p" text03>
|
||||
{additionalText}
|
||||
</Text>
|
||||
</div>
|
||||
</ConfirmationModalLayout>
|
||||
);
|
||||
})()}
|
||||
|
||||
<DraggableTable
|
||||
headers={[
|
||||
"Name",
|
||||
"Description",
|
||||
"Type",
|
||||
"Featured Agent",
|
||||
"Is Visible",
|
||||
"Delete",
|
||||
]}
|
||||
isAdmin={isAdmin}
|
||||
rows={finalPersonas.map((persona) => {
|
||||
const isEditable = editablePersonas.includes(persona);
|
||||
return {
|
||||
id: persona.id.toString(),
|
||||
cells: [
|
||||
<div key="name" className="flex">
|
||||
{!persona.builtin_persona && (
|
||||
<FiEdit2
|
||||
className="mr-1 my-auto cursor-pointer"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/app/agents/edit/${
|
||||
persona.id
|
||||
}?u=${Date.now()}&admin=true` as Route
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<p className="text font-medium whitespace-normal break-none">
|
||||
{persona.name}
|
||||
</p>
|
||||
</div>,
|
||||
<p
|
||||
key="description"
|
||||
className="whitespace-normal break-all max-w-2xl"
|
||||
>
|
||||
{persona.description}
|
||||
</p>,
|
||||
<PersonaTypeDisplay key={persona.id} persona={persona} />,
|
||||
<div
|
||||
key="featured"
|
||||
onClick={() => {
|
||||
openDefaultModal(persona);
|
||||
}}
|
||||
className={`
|
||||
px-1 py-0.5 rounded flex hover:bg-accent-background-hovered cursor-pointer select-none w-fit items-center gap-2
|
||||
`}
|
||||
>
|
||||
<div className="my-auto flex-none w-22">
|
||||
{!persona.featured ? (
|
||||
<div className="text-error">Not Featured</div>
|
||||
) : (
|
||||
"Featured"
|
||||
)}
|
||||
</div>
|
||||
<Checkbox checked={persona.featured} />
|
||||
</div>,
|
||||
<div
|
||||
key="is_visible"
|
||||
onClick={async () => {
|
||||
const response = await togglePersonaVisibility(
|
||||
persona.id,
|
||||
persona.is_visible
|
||||
);
|
||||
if (response.ok) {
|
||||
refreshPersonas();
|
||||
} else {
|
||||
toast.error(
|
||||
`Failed to update persona - ${await response.text()}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
px-1 py-0.5 rounded flex hover:bg-accent-background-hovered cursor-pointer select-none w-fit items-center gap-2
|
||||
`}
|
||||
>
|
||||
<div className="my-auto w-fit">
|
||||
{!persona.is_visible ? (
|
||||
<div className="text-error">Hidden</div>
|
||||
) : (
|
||||
"Visible"
|
||||
)}
|
||||
</div>
|
||||
<Checkbox checked={persona.is_visible} />
|
||||
</div>,
|
||||
<div key="edit" className="flex">
|
||||
<div className="mr-auto my-auto">
|
||||
{!persona.builtin_persona && isEditable ? (
|
||||
<Button
|
||||
icon={SvgTrash}
|
||||
prominence="tertiary"
|
||||
onClick={() => openDeleteModal(persona)}
|
||||
/>
|
||||
) : (
|
||||
<Text as="p">-</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
],
|
||||
staticModifiers: [[1, "lg:w-[250px] xl:w-[400px] 2xl:w-[550px]"]],
|
||||
};
|
||||
})}
|
||||
setRows={updatePersonaOrder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,160 @@
|
||||
export { default } from "@/refresh-pages/admin/AgentsPage";
|
||||
"use client";
|
||||
|
||||
import { PersonasTable } from "./PersonaTable";
|
||||
import Text from "@/components/ui/text";
|
||||
import Title from "@/components/ui/title";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { SubLabel } from "@/components/Field";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import { useAdminPersonas } from "@/hooks/useAdminPersonas";
|
||||
import { Persona } from "./interfaces";
|
||||
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 "@opal/components";
|
||||
|
||||
const route = ADMIN_ROUTES.AGENTS;
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function MainContent({
|
||||
personas,
|
||||
totalItems,
|
||||
currentPage,
|
||||
onPageChange,
|
||||
refreshPersonas,
|
||||
}: {
|
||||
personas: Persona[];
|
||||
totalItems: number;
|
||||
currentPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
refreshPersonas: () => void;
|
||||
}) {
|
||||
// Filter out default/unified assistants.
|
||||
// NOTE: The backend should already exclude them if includeDefault = false is
|
||||
// provided. That change was made with the introduction of pagination; we keep
|
||||
// this filter here for now for backwards compatibility.
|
||||
const customPersonas = personas.filter((persona) => !persona.builtin_persona);
|
||||
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
|
||||
|
||||
// Clamp currentPage when totalItems shrinks (e.g., deleting the last item on a page)
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages && totalPages > 0) {
|
||||
onPageChange(totalPages);
|
||||
}
|
||||
}, [currentPage, totalPages, onPageChange]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text className="mb-2">
|
||||
Agents are a way to build custom search/question-answering experiences
|
||||
for different use cases.
|
||||
</Text>
|
||||
<Text className="mt-2">They allow you to customize:</Text>
|
||||
<div className="text-sm">
|
||||
<ul className="list-disc mt-2 ml-4">
|
||||
<li>
|
||||
The prompt used by your LLM of choice to respond to the user query
|
||||
</li>
|
||||
<li>The documents that are used as context</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Separator />
|
||||
|
||||
<Title>Create an Agent</Title>
|
||||
<CreateButton href="/app/agents/create?admin=true">
|
||||
New Agent
|
||||
</CreateButton>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Title>Existing Agents</Title>
|
||||
{totalItems > 0 ? (
|
||||
<>
|
||||
<SubLabel>
|
||||
Agents will be displayed as options on the Chat / Search
|
||||
interfaces in the order they are displayed below. Agents marked as
|
||||
hidden will not be displayed. Editable agents are shown at the
|
||||
top.
|
||||
</SubLabel>
|
||||
<PersonasTable
|
||||
personas={customPersonas}
|
||||
refreshPersonas={refreshPersonas}
|
||||
currentPage={currentPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-6 p-8 border border-border rounded-lg bg-background-weak text-center">
|
||||
<Text className="text-lg font-medium mb-2">
|
||||
No custom agents yet
|
||||
</Text>
|
||||
<Text className="text-subtle mb-3">
|
||||
Create your first agent to:
|
||||
</Text>
|
||||
<ul className="text-subtle text-sm list-disc text-left inline-block mb-3">
|
||||
<li>Build department-specific knowledge bases</li>
|
||||
<li>Create specialized research agents</li>
|
||||
<li>Set up compliance and policy advisors</li>
|
||||
</ul>
|
||||
<Text className="text-subtle text-sm mb-4">
|
||||
...and so much more!
|
||||
</Text>
|
||||
<CreateButton href="/app/agents/create?admin=true">
|
||||
Create Your First Agent
|
||||
</CreateButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const { personas, totalItems, isLoading, error, refresh } = useAdminPersonas({
|
||||
pageNum: currentPage - 1, // Backend uses 0-indexed pages
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{isLoading && <ThreeDotsLoader />}
|
||||
|
||||
{error && (
|
||||
<ErrorCallout
|
||||
errorTitle="Failed to load agents"
|
||||
errorMsg={
|
||||
error?.info?.message ||
|
||||
error?.info?.detail ||
|
||||
"An unknown error occurred"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<MainContent
|
||||
personas={personas}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
onPageChange={setCurrentPage}
|
||||
refreshPersonas={refresh}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
}
|
||||
|
||||
.tbl-cell-inner[data-size="regular"] {
|
||||
@apply h-[44px] px-1;
|
||||
@apply h-10 px-1;
|
||||
}
|
||||
.tbl-cell-inner[data-size="small"] {
|
||||
@apply h-[36px] px-0.5;
|
||||
@apply h-6 px-0.5;
|
||||
}
|
||||
|
||||
/* ---- TableHead ---- */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
|
||||
import { IS_DEV } from "@/lib/constants";
|
||||
|
||||
// Target format for OpenAI Realtime API
|
||||
const TARGET_SAMPLE_RATE = 24000;
|
||||
const CHUNK_INTERVAL_MS = 250;
|
||||
@@ -245,9 +247,8 @@ class VoiceRecorderSession {
|
||||
const { token } = await tokenResponse.json();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const isDev = window.location.port === "3000";
|
||||
const host = isDev ? "localhost:8080" : window.location.host;
|
||||
const path = isDev
|
||||
const host = IS_DEV ? "localhost:8080" : window.location.host;
|
||||
const path = IS_DEV
|
||||
? "/voice/transcribe/stream"
|
||||
: "/api/voice/transcribe/stream";
|
||||
return `${protocol}//${host}${path}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const IS_DEV = process.env.NODE_ENV === "development";
|
||||
|
||||
export enum AuthType {
|
||||
BASIC = "basic",
|
||||
GOOGLE_OAUTH = "google_oauth",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Plays audio chunks as they arrive for smooth, low-latency playback.
|
||||
*/
|
||||
|
||||
import { IS_DEV } from "@/lib/constants";
|
||||
|
||||
/**
|
||||
* HTTPStreamingTTSPlayer - Uses HTTP streaming with MediaSource Extensions
|
||||
* for smooth, gapless audio playback. This is the recommended approach for
|
||||
@@ -382,9 +384,8 @@ export class WebSocketStreamingTTSPlayer {
|
||||
const { token } = await tokenResponse.json();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const isDev = window.location.port === "3000";
|
||||
const host = isDev ? "localhost:8080" : window.location.host;
|
||||
const path = isDev
|
||||
const host = IS_DEV ? "localhost:8080" : window.location.host;
|
||||
const path = IS_DEV
|
||||
? "/voice/synthesize/stream"
|
||||
: "/api/voice/synthesize/stream";
|
||||
return `${protocol}//${host}${path}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
@@ -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} />,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
},
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SvgOnyxOctagon, SvgPlus } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import Link from "next/link";
|
||||
|
||||
import AgentsTable from "./AgentsPage/AgentsTable";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
title="Agents"
|
||||
icon={SvgOnyxOctagon}
|
||||
rightChildren={
|
||||
<Link href="/app/agents/create?admin=true">
|
||||
<Button icon={SvgPlus}>New Agent</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<AgentsTable />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import {
|
||||
SvgMoreHorizontal,
|
||||
SvgEdit,
|
||||
SvgEye,
|
||||
SvgEyeClosed,
|
||||
SvgStar,
|
||||
SvgTrash,
|
||||
SvgAlertCircle,
|
||||
} from "@opal/icons";
|
||||
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 { toast } from "@/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { deleteAgent, toggleAgentFeatured, toggleAgentVisibility } from "./svc";
|
||||
import type { AgentRow } from "./interfaces";
|
||||
import type { Route } from "next";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum Modal {
|
||||
DELETE = "delete",
|
||||
TOGGLE_FEATURED = "toggleFeatured",
|
||||
}
|
||||
|
||||
interface AgentRowActionsProps {
|
||||
agent: AgentRow;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function AgentRowActions({
|
||||
agent,
|
||||
onMutate,
|
||||
}: AgentRowActionsProps) {
|
||||
const router = useRouter();
|
||||
const [modal, setModal] = useState<Modal | null>(null);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end" width="sm">
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
{!agent.builtin_persona && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgEdit}
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
router.push(
|
||||
`/app/agents/edit/${
|
||||
agent.id
|
||||
}?u=${Date.now()}&admin=true` as Route
|
||||
);
|
||||
}}
|
||||
>
|
||||
Edit Agent
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={agent.is_visible ? SvgEyeClosed : SvgEye}
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
handleAction(
|
||||
() => toggleAgentVisibility(agent.id, agent.is_visible),
|
||||
agent.is_visible ? "Agent hidden" : "Agent visible"
|
||||
);
|
||||
}}
|
||||
>
|
||||
{agent.is_visible ? "Hide Agent" : "Show Agent"}
|
||||
</Button>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgStar}
|
||||
onClick={() => openModal(Modal.TOGGLE_FEATURED)}
|
||||
>
|
||||
{agent.featured ? "Remove Featured" : "Set as Featured"}
|
||||
</Button>
|
||||
{!agent.builtin_persona && (
|
||||
<>
|
||||
<Separator paddingXRem={0.5} />
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
variant="danger"
|
||||
icon={SvgTrash}
|
||||
onClick={() => openModal(Modal.DELETE)}
|
||||
>
|
||||
Delete Agent
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
{modal === Modal.DELETE && (
|
||||
<ConfirmationModalLayout
|
||||
icon={(props) => (
|
||||
<SvgAlertCircle {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
title="Delete Agent"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
handleAction(() => deleteAgent(agent.id), "Agent deleted");
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
Are you sure you want to delete{" "}
|
||||
<Text as="span" text05>
|
||||
{agent.name}
|
||||
</Text>
|
||||
? This action cannot be undone.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === Modal.TOGGLE_FEATURED && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgStar}
|
||||
title={
|
||||
agent.featured ? "Remove Featured Agent" : "Set Featured Agent"
|
||||
}
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleAction(
|
||||
() => toggleAgentFeatured(agent.id, agent.featured),
|
||||
agent.featured
|
||||
? "Agent removed from featured"
|
||||
: "Agent set as featured"
|
||||
);
|
||||
}}
|
||||
>
|
||||
{agent.featured ? "Remove Featured" : "Set as Featured"}
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text as="p" text03>
|
||||
{agent.featured
|
||||
? `Are you sure you want to remove the featured status of "${agent.name}"?`
|
||||
: `Are you sure you want to set "${agent.name}" as a featured agent?`}
|
||||
</Text>
|
||||
<Text as="p" text03>
|
||||
{agent.featured
|
||||
? "Removing featured status will not affect its visibility or accessibility."
|
||||
: "Setting as featured will make this agent public and visible to all users."}
|
||||
</Text>
|
||||
</div>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import DataTable from "@/refresh-components/table/DataTable";
|
||||
import { createTableColumns } from "@/refresh-components/table/columns";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import { Tag } from "@opal/components";
|
||||
import type { MinimalUserSnapshot } from "@/lib/types";
|
||||
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
|
||||
import type { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
|
||||
import { useAdminPersonas } from "@/hooks/useAdminPersonas";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import AgentRowActions from "./AgentRowActions";
|
||||
import { updateAgentDisplayPriorities } from "./svc";
|
||||
import type { AgentRow } from "./interfaces";
|
||||
import type { Persona } from "@/app/admin/agents/interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toAgentRow(persona: Persona): AgentRow {
|
||||
return {
|
||||
id: persona.id,
|
||||
name: persona.name,
|
||||
description: persona.description,
|
||||
is_public: persona.is_public,
|
||||
is_visible: persona.is_visible,
|
||||
featured: persona.featured,
|
||||
builtin_persona: persona.builtin_persona,
|
||||
display_priority: persona.display_priority,
|
||||
owner: persona.owner,
|
||||
groups: persona.groups,
|
||||
users: persona.users,
|
||||
uploaded_image_id: persona.uploaded_image_id,
|
||||
icon_name: persona.icon_name,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderCreatedByColumn(
|
||||
_value: MinimalUserSnapshot | null,
|
||||
row: AgentRow
|
||||
) {
|
||||
if (row.builtin_persona) {
|
||||
return (
|
||||
<Text as="span" mainUiBody text03>
|
||||
System
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text as="span" mainUiBody text03>
|
||||
{row.owner?.email ?? "\u2014"}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAccessColumn(isPublic: boolean) {
|
||||
return (
|
||||
<Tag
|
||||
color={isPublic ? "green" : "gray"}
|
||||
size="sm"
|
||||
title={isPublic ? "Public" : "Private"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tc = createTableColumns<AgentRow>();
|
||||
|
||||
function buildColumns(onMutate: () => void) {
|
||||
return [
|
||||
tc.displayColumn({
|
||||
id: "avatar",
|
||||
cell: (row) => (
|
||||
<AgentAvatar
|
||||
agent={row as unknown as MinimalPersonaSnapshot}
|
||||
size={32}
|
||||
/>
|
||||
),
|
||||
width: { fixed: 48 },
|
||||
enableHiding: false,
|
||||
}),
|
||||
tc.column("name", {
|
||||
header: "Name",
|
||||
weight: 25,
|
||||
minWidth: 150,
|
||||
cell: (value) => (
|
||||
<Text as="span" mainUiBody text05>
|
||||
{value}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("description", {
|
||||
header: "Description",
|
||||
weight: 35,
|
||||
minWidth: 200,
|
||||
cell: (value) => (
|
||||
<Text as="span" mainUiBody text03>
|
||||
{value || "\u2014"}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("owner", {
|
||||
header: "Created By",
|
||||
weight: 20,
|
||||
minWidth: 140,
|
||||
cell: renderCreatedByColumn,
|
||||
}),
|
||||
tc.column("is_public", {
|
||||
header: "Access",
|
||||
weight: 12,
|
||||
minWidth: 100,
|
||||
cell: renderAccessColumn,
|
||||
}),
|
||||
tc.actions({
|
||||
cell: (row) => <AgentRowActions agent={row} onMutate={onMutate} />,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function AgentsTable() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const { personas, isLoading, error, refresh } = useAdminPersonas();
|
||||
|
||||
const columns = useMemo(() => buildColumns(refresh), [refresh]);
|
||||
|
||||
const agentRows: AgentRow[] = useMemo(
|
||||
() => personas.filter((p) => !p.builtin_persona).map(toAgentRow),
|
||||
[personas]
|
||||
);
|
||||
|
||||
const handleReorder = async (
|
||||
_orderedIds: string[],
|
||||
changedOrders: Record<string, number>
|
||||
) => {
|
||||
try {
|
||||
await updateAgentDisplayPriorities(changedOrders);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to update agent order"
|
||||
);
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<SimpleLoader className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Text as="p" secondaryBody text03>
|
||||
Failed to load agents. Please try refreshing the page.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search agents..."
|
||||
leftSearchIcon
|
||||
/>
|
||||
<DataTable
|
||||
data={agentRows}
|
||||
columns={columns}
|
||||
getRowId={(row) => String(row.id)}
|
||||
pageSize={PAGE_SIZE}
|
||||
searchTerm={searchTerm}
|
||||
draggable={{
|
||||
onReorder: handleReorder,
|
||||
}}
|
||||
emptyState={
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="No agents found"
|
||||
description="No agents match the current search."
|
||||
/>
|
||||
}
|
||||
footer={{ mode: "summary" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { MinimalUserSnapshot } from "@/lib/types";
|
||||
|
||||
export interface AgentRow {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
is_public: boolean;
|
||||
is_visible: boolean;
|
||||
featured: boolean;
|
||||
builtin_persona: boolean;
|
||||
display_priority: number | null;
|
||||
owner: MinimalUserSnapshot | null;
|
||||
groups: number[];
|
||||
users: MinimalUserSnapshot[];
|
||||
uploaded_image_id?: string;
|
||||
icon_name?: string;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
async function parseErrorDetail(
|
||||
res: Response,
|
||||
fallback: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.detail ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAgent(agentId: number): Promise<void> {
|
||||
const res = await fetch(`/api/persona/${agentId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to delete agent"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAgentFeatured(
|
||||
agentId: number,
|
||||
currentlyFeatured: boolean
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/admin/persona/${agentId}/featured`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ featured: !currentlyFeatured }),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
await parseErrorDetail(res, "Failed to toggle featured status")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAgentVisibility(
|
||||
agentId: number,
|
||||
currentlyVisible: boolean
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/admin/persona/${agentId}/visible`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ is_visible: !currentlyVisible }),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to toggle visibility"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAgentDisplayPriorities(
|
||||
displayPriorityMap: Record<string, number>
|
||||
): Promise<void> {
|
||||
const res = await fetch("/api/admin/agents/display-priorities", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ display_priority_map: displayPriorityMap }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
await parseErrorDetail(res, "Failed to update agent order")
|
||||
);
|
||||
}
|
||||
}
|
||||
340
web/src/refresh-pages/admin/UsersPage/UserActionModals.tsx
Normal file
340
web/src/refresh-pages/admin/UsersPage/UserActionModals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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([])}
|
||||
|
||||
@@ -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 & 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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
324
web/tests/e2e/admin/users/UsersAdminPage.ts
Normal file
324
web/tests/e2e/admin/users/UsersAdminPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
37
web/tests/e2e/admin/users/fixtures.ts
Normal file
37
web/tests/e2e/admin/users/fixtures.ts
Normal 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 };
|
||||
620
web/tests/e2e/admin/users/users.spec.ts
Normal file
620
web/tests/e2e/admin/users/users.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
181
web/tests/e2e/chat/project_files_visual_regression.spec.ts
Normal file
181
web/tests/e2e/chat/project_files_visual_regression.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 ===
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user