Compare commits

..

4 Commits

Author SHA1 Message Date
Yuhong Sun
d48e7f368e k 2026-03-17 14:25:35 -07:00
Yuhong Sun
82b11a58be mypy 2026-03-17 13:50:42 -07:00
Yuhong Sun
4410d0da6e Fix citation bug 2026-03-17 13:38:13 -07:00
Yuhong Sun
c7544eaddf Utility for uploading files 2026-03-15 16:48:30 -07:00
208 changed files with 13223 additions and 9131 deletions

View File

@@ -10,9 +10,6 @@ inputs:
failed-jobs:
description: "Deprecated alias for details"
required: false
mention:
description: "GitHub username to resolve to a Slack @-mention. Replaces {mention} in details."
required: false
title:
description: "Title for the notification"
required: false
@@ -29,7 +26,6 @@ runs:
SLACK_WEBHOOK_URL: ${{ inputs.webhook-url }}
DETAILS: ${{ inputs.details }}
FAILED_JOBS: ${{ inputs.failed-jobs }}
MENTION_USER: ${{ inputs.mention }}
TITLE: ${{ inputs.title }}
REF_NAME: ${{ inputs.ref-name }}
REPO: ${{ github.repository }}
@@ -56,27 +52,6 @@ runs:
DETAILS="$FAILED_JOBS"
fi
# Resolve {mention} placeholder if a GitHub username was provided.
# Looks up the username in user-mappings.json (co-located with this action)
# and replaces {mention} with <@SLACK_ID> for a Slack @-mention.
# Falls back to the plain GitHub username if not found in the mapping.
if [ -n "$MENTION_USER" ]; then
MAPPINGS_FILE="${GITHUB_ACTION_PATH}/user-mappings.json"
slack_id="$(jq -r --arg gh "$MENTION_USER" 'to_entries[] | select(.value | ascii_downcase == ($gh | ascii_downcase)) | .key' "$MAPPINGS_FILE" 2>/dev/null | head -1)"
if [ -n "$slack_id" ]; then
mention_text="<@${slack_id}>"
else
mention_text="${MENTION_USER}"
fi
DETAILS="${DETAILS//\{mention\}/$mention_text}"
TITLE="${TITLE//\{mention\}/}"
else
DETAILS="${DETAILS//\{mention\}/}"
TITLE="${TITLE//\{mention\}/}"
fi
normalize_multiline() {
printf '%s' "$1" | awk 'BEGIN { ORS=""; first=1 } { if (!first) printf "\\n"; printf "%s", $0; first=0 }'
}

View File

@@ -1,18 +0,0 @@
{
"U05SAGZPEA1": "yuhongsun96",
"U05SAH6UGUD": "Weves",
"U07PWEQB7A5": "evan-onyx",
"U07V1SM68KF": "joachim-danswer",
"U08JZ9N3QNN": "raunakab",
"U08L24NCLJE": "Subash-Mohan",
"U090B9M07B2": "wenxi-onyx",
"U094RASDP0Q": "duo-onyx",
"U096L8ZQ85B": "justin-tahara",
"U09AHV8UBQX": "jessicasingh7",
"U09KAL5T3C2": "nmgarza5",
"U09KPGVQ70R": "acaprau",
"U09QR8KTSJH": "rohoswagger",
"U09RB4NTXA4": "jmelahman",
"U0A6K9VCY6A": "Danelegend",
"U0AGC4KH71A": "Bo-Onyx"
}

View File

@@ -455,7 +455,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
flavor: |
@@ -1178,7 +1178,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
flavor: |
@@ -1256,7 +1256,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
flavor: |
@@ -1317,7 +1317,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
with:
images: ${{ needs.determine-builds.outputs.is-test-run == 'true' && env.RUNS_ON_ECR_CACHE || env.REGISTRY_IMAGE }}
flavor: |

View File

@@ -207,7 +207,7 @@ jobs:
CHERRY_PICK_PR_URL: ${{ needs.cherry-pick-to-latest-release.outputs.cherry_pick_pr_url }}
run: |
source_pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${SOURCE_PR_NUMBER}"
details="*Cherry-pick PR opened successfully.*\\n• author: {mention}\\n• source PR: ${source_pr_url}"
details="*Cherry-pick PR opened successfully.*\\n• source PR: ${source_pr_url}"
if [ -n "${CHERRY_PICK_PR_URL}" ]; then
details="${details}\\n• cherry-pick PR: ${CHERRY_PICK_PR_URL}"
fi
@@ -221,7 +221,6 @@ jobs:
uses: ./.github/actions/slack-notify
with:
webhook-url: ${{ secrets.CHERRY_PICK_PRS_WEBHOOK }}
mention: ${{ needs.resolve-cherry-pick-request.outputs.merged_by }}
details: ${{ steps.success-summary.outputs.details }}
title: "✅ Automated Cherry-Pick PR Opened"
ref-name: ${{ github.event.pull_request.base.ref }}
@@ -276,21 +275,20 @@ jobs:
else
failed_job_label="cherry-pick-to-latest-release"
fi
details="• author: {mention}\\n• ${failed_job_label}\\n• source PR: ${source_pr_url}\\n• reason: ${reason_text}"
failed_jobs="• ${failed_job_label}\\n• source PR: ${source_pr_url}\\n• reason: ${reason_text}"
if [ -n "${MERGE_COMMIT_SHA}" ]; then
details="${details}\\n• merge SHA: ${MERGE_COMMIT_SHA}"
failed_jobs="${failed_jobs}\\n• merge SHA: ${MERGE_COMMIT_SHA}"
fi
if [ -n "${details_excerpt}" ]; then
details="${details}\\n• excerpt: ${details_excerpt}"
failed_jobs="${failed_jobs}\\n• excerpt: ${details_excerpt}"
fi
echo "details=${details}" >> "$GITHUB_OUTPUT"
echo "jobs=${failed_jobs}" >> "$GITHUB_OUTPUT"
- name: Notify #cherry-pick-prs about cherry-pick failure
uses: ./.github/actions/slack-notify
with:
webhook-url: ${{ secrets.CHERRY_PICK_PRS_WEBHOOK }}
mention: ${{ needs.resolve-cherry-pick-request.outputs.merged_by }}
details: ${{ steps.failure-summary.outputs.details }}
details: ${{ steps.failure-summary.outputs.jobs }}
title: "🚨 Automated Cherry-Pick Failed"
ref-name: ${{ github.event.pull_request.base.ref }}

View File

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

View File

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

View File

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

View File

@@ -466,7 +466,7 @@ jobs:
- name: Upload logs
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: docker-all-logs-multitenant
path: ${{ github.workspace }}/docker-compose-multitenant.log

View File

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

View File

@@ -445,7 +445,7 @@ jobs:
run: |
npx playwright test --project ${PROJECT}
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: docker-logs-lite-${{ github.run_id }}
path: ${{ github.workspace }}/docker-compose.log

View File

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

View File

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

View File

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

View File

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

View File

@@ -118,7 +118,9 @@ 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")
POSTHOG_API_KEY = os.environ.get("POSTHOG_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_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"

View File

@@ -34,9 +34,6 @@ 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,

View File

@@ -29,6 +29,7 @@ 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
@@ -58,6 +59,7 @@ 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
@@ -69,9 +71,7 @@ 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,6 +693,12 @@ 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")

View File

@@ -9,7 +9,6 @@ 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()
@@ -19,19 +18,12 @@ def posthog_on_error(error: Any, items: Any) -> None:
logger.error(f"PostHog error: {error}, items: {items}")
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"
)
posthog = Posthog(
project_api_key=POSTHOG_API_KEY,
host=POSTHOG_HOST,
debug=POSTHOG_DEBUG_LOGS_ENABLED,
on_error=posthog_on_error,
)
# For cross referencing between cloud and www Onyx sites
# NOTE: These clients are separate because they are separate posthog projects.
@@ -68,7 +60,7 @@ def capture_and_sync_with_alternate_posthog(
logger.error(f"Error capturing marketing posthog event: {e}")
try:
if posthog and (cloud_user_id := props.get("onyx_cloud_user_id")):
if cloud_user_id := props.get("onyx_cloud_user_id"):
cloud_props = props.copy()
cloud_props.pop("onyx_cloud_user_id", None)

View File

@@ -1,5 +1,3 @@
from typing import Any
from ee.onyx.utils.posthog_client import posthog
from onyx.utils.logger import setup_logger
@@ -7,27 +5,12 @@ logger = setup_logger()
def event_telemetry(
distinct_id: str, event: str, properties: dict[str, Any] | None = None
distinct_id: str, event: str, properties: dict | 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}")

View File

@@ -19,7 +19,6 @@ 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
@@ -135,7 +134,6 @@ 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
@@ -794,12 +792,6 @@ 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:
@@ -818,25 +810,12 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
user_count = await get_user_count()
logger.debug(f"Current tenant user count: {user_count}")
# Ensure a PostHog person profile exists for this user.
mt_cloud_identify(
distinct_id=str(user.id),
properties={"email": user.email, "tenant_id": tenant_id},
)
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=str(user.id),
distinct_id=user.email,
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)
@@ -1673,33 +1652,6 @@ 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"),
@@ -1719,15 +1671,19 @@ 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.")
if not _is_same_origin(origin, WEB_DOMAIN):
logger.warning(f"WS auth: origin mismatch. Expected {WEB_DOMAIN}, got {origin}")
actual_origin = origin.rstrip("/")
if actual_origin != expected_origin:
logger.warning(
f"WS auth: origin mismatch. Expected {expected_origin}, got {actual_origin}"
)
raise BasicAuthenticationError(detail="Access denied. Invalid origin.")
# Validate WS token in Redis (single-use, deleted after retrieval)

View File

@@ -29,8 +29,6 @@ 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
@@ -57,26 +55,6 @@ 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.
@@ -208,10 +186,7 @@ def check_for_hierarchy_fetching(self: Task, *, tenant_id: str) -> int | None:
cc_pair_id=cc_pair_id,
)
if not cc_pair or not _connector_supports_hierarchy_fetching(cc_pair):
continue
if not _is_hierarchy_fetching_due(cc_pair):
if not cc_pair or not _is_hierarchy_fetching_due(cc_pair):
continue
task_id = _try_creating_hierarchy_fetching_task(

View File

@@ -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=str(user.id) if not user.is_anonymous else tenant_id,
distinct_id=user.email if not user.is_anonymous else tenant_id,
event=MilestoneRecordType.MULTIPLE_ASSISTANTS,
)
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=str(user.id) if not user.is_anonymous else tenant_id,
distinct_id=user.email if not user.is_anonymous else tenant_id,
event=MilestoneRecordType.USER_MESSAGE_SENT,
properties={
"origin": new_msg_req.origin.value,
@@ -1046,10 +1046,46 @@ def llm_loop_completion_handle(
)
def remove_answer_citations(answer: str) -> str:
pattern = r"\s*\[\[\d+\]\]\(http[s]?://[^\s]+\)"
_CITATION_LINK_START_PATTERN = re.compile(r"\s*\[\[\d+\]\]\(")
return re.sub(pattern, "", answer)
def _find_markdown_link_end(text: str, destination_start: int) -> int | None:
depth = 0
i = destination_start
while i < len(text):
curr = text[i]
if curr == "\\":
i += 2
continue
if curr == "(":
depth += 1
elif curr == ")":
if depth == 0:
return i
depth -= 1
i += 1
return None
def remove_answer_citations(answer: str) -> str:
stripped_parts: list[str] = []
cursor = 0
while match := _CITATION_LINK_START_PATTERN.search(answer, cursor):
stripped_parts.append(answer[cursor : match.start()])
link_end = _find_markdown_link_end(answer, match.end())
if link_end is None:
stripped_parts.append(answer[match.start() :])
return "".join(stripped_parts)
cursor = link_end + 1
stripped_parts.append(answer[cursor:])
return "".join(stripped_parts)
@log_function_time()

View File

@@ -1046,8 +1046,6 @@ 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"
#####

View File

@@ -511,7 +511,7 @@ def add_credential_to_connector(
user: User,
connector_id: int,
credential_id: int,
cc_pair_name: str,
cc_pair_name: str | None,
access_type: AccessType,
groups: list[int] | None,
auto_sync_options: dict | None = None,

View File

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

View File

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

View File

@@ -501,31 +501,20 @@ def query_vespa(
response = http_client.post(SEARCH_ENDPOINT, json=params)
response.raise_for_status()
except httpx.HTTPError as e:
response_text = (
e.response.text if isinstance(e, httpx.HTTPStatusError) else None
)
status_code = (
e.response.status_code if isinstance(e, httpx.HTTPStatusError) else None
)
yql_value = params.get("yql", "")
yql_length = len(str(yql_value))
# Log each detail on its own line so log collectors capture them
# as separate entries rather than truncating a single multiline msg
error_base = "Failed to query Vespa"
logger.error(
f"Failed to query Vespa | "
f"status={status_code} | "
f"yql_length={yql_length} | "
f"exception={str(e)}"
f"{error_base}:\n"
f"Request URL: {e.request.url}\n"
f"Request Headers: {e.request.headers}\n"
f"Request Payload: {params}\n"
f"Exception: {str(e)}"
+ (
f"\nResponse: {e.response.text}"
if isinstance(e, httpx.HTTPStatusError)
else ""
)
)
if response_text:
logger.error(f"Vespa error response: {response_text[:1000]}")
logger.error(f"Vespa request URL: {e.request.url}")
# Re-raise with diagnostics so callers see what actually went wrong
raise httpx.HTTPError(
f"Failed to query Vespa (status={status_code}, " f"yql_length={yql_length})"
) from e
raise httpx.HTTPError(error_base) from e
response_json: dict[str, Any] = response.json()

View File

@@ -43,22 +43,6 @@ def build_vespa_filters(
return ""
return f"({' or '.join(eq_elems)})"
def _build_weighted_set_filter(key: str, vals: list[str] | None) -> str:
"""Build a Vespa weightedSet filter for large value lists.
Uses Vespa's native weightedSet() operator instead of OR-chained
'contains' clauses. This is critical for fields like
access_control_list where a single user may have tens of thousands
of ACL entries — OR clauses at that scale cause Vespa to reject
the query with HTTP 400."""
if not key or not vals:
return ""
filtered = [val for val in vals if val]
if not filtered:
return ""
items = ", ".join(f'"{val}":1' for val in filtered)
return f"weightedSet({key}, {{{items}}})"
def _build_int_or_filters(key: str, vals: list[int] | None) -> str:
"""For an integer field filter.
Returns a bare clause or ""."""
@@ -173,16 +157,11 @@ def build_vespa_filters(
if filters.tenant_id and MULTI_TENANT:
filter_parts.append(build_tenant_id_filter(filters.tenant_id))
# ACL filters — use weightedSet for efficient matching against the
# access_control_list weightedset<string> field. OR-chaining thousands
# of 'contains' clauses causes Vespa to reject the query (HTTP 400)
# for users with large numbers of external permission groups.
# ACL filters
if filters.access_control_list is not None:
_append(
filter_parts,
_build_weighted_set_filter(
ACCESS_CONTROL_LIST, filters.access_control_list
),
_build_or_filters(ACCESS_CONTROL_LIST, filters.access_control_list),
)
# Source type filters

View File

@@ -35,8 +35,6 @@ 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)

View File

@@ -1,26 +0,0 @@
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.",
)

View File

@@ -1319,7 +1319,7 @@ def get_connector_indexing_status(
# Track admin page visit for analytics
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=str(user.id),
distinct_id=user.email,
event=MilestoneRecordType.VISITED_ADMIN_PAGE,
)
@@ -1533,7 +1533,7 @@ def create_connector_from_model(
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=str(user.id),
distinct_id=user.email,
event=MilestoneRecordType.CREATED_CONNECTOR,
)
@@ -1611,7 +1611,7 @@ def create_connector_with_mock_credential(
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=str(user.id),
distinct_id=user.email,
event=MilestoneRecordType.CREATED_CONNECTOR,
)
return response
@@ -1915,7 +1915,9 @@ 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
@@ -1923,11 +1925,11 @@ def submit_connector_request(
if MULTI_TENANT:
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=str(user.id),
distinct_id=distinct_id,
event=MilestoneRecordType.REQUESTED_CONNECTOR,
properties={
"connector_name": connector_name,
"user_email": user.email,
"user_email": user_email,
},
)

View File

@@ -408,7 +408,7 @@ class FailedConnectorIndexingStatus(BaseModel):
"""Simplified version of ConnectorIndexingStatus for failed indexing attempts"""
cc_pair_id: int
name: str
name: str | None
error_msg: str | None
is_deletable: bool
connector_id: int
@@ -422,7 +422,7 @@ class ConnectorStatus(BaseModel):
"""
cc_pair_id: int
name: str
name: str | None
connector: ConnectorSnapshot
credential: CredentialSnapshot
access_type: AccessType
@@ -453,7 +453,7 @@ class DocsCountOperator(str, Enum):
class ConnectorIndexingStatusLite(BaseModel):
cc_pair_id: int
name: str
name: str | None
source: DocumentSource
access_type: AccessType
cc_pair_status: ConnectorCredentialPairStatus
@@ -488,7 +488,7 @@ class ConnectorCredentialPairIdentifier(BaseModel):
class ConnectorCredentialPairMetadata(BaseModel):
name: str
name: str | None = None
access_type: AccessType
auto_sync_options: dict[str, Any] | None = None
groups: list[int] = Field(default_factory=list)
@@ -501,7 +501,7 @@ class CCStatusUpdateRequest(BaseModel):
class ConnectorCredentialPairDescriptor(BaseModel):
id: int
name: str
name: str | None = None
connector: ConnectorSnapshot
credential: CredentialSnapshot
access_type: AccessType
@@ -511,7 +511,7 @@ class CCPairSummary(BaseModel):
"""Simplified connector-credential pair information with just essential data"""
id: int
name: str
name: str | None
source: DocumentSource
access_type: AccessType

View File

@@ -314,7 +314,7 @@ def create_persona(
)
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=str(user.id),
distinct_id=user.email,
event=MilestoneRecordType.CREATED_ASSISTANT,
)

View File

@@ -81,7 +81,6 @@ from onyx.server.manage.llm.models import VisionProviderResponse
from onyx.server.manage.llm.utils import generate_bedrock_display_name
from onyx.server.manage.llm.utils import generate_ollama_display_name
from onyx.server.manage.llm.utils import infer_vision_support
from onyx.server.manage.llm.utils import is_embedding_model
from onyx.server.manage.llm.utils import is_reasoning_model
from onyx.server.manage.llm.utils import is_valid_bedrock_model
from onyx.server.manage.llm.utils import ModelMetadata
@@ -1375,10 +1374,6 @@ def get_litellm_available_models(
try:
model_details = LitellmModelDetails.model_validate(model)
# Skip embedding models
if is_embedding_model(model_details.id):
continue
results.append(
LitellmFinalModelResponse(
provider_name=model_details.owned_by,

View File

@@ -366,18 +366,3 @@ def extract_vendor_from_model_name(model_name: str, provider: str) -> str | None
return None
return None
def is_embedding_model(model_name: str) -> bool:
"""Checks for if a model is an embedding model"""
from litellm import get_model_info
try:
# get_model_info raises on unknown models
# default to False
model_info = get_model_info(model_name)
except Exception:
return False
is_embedding_mode = model_info.get("mode") == "embedding"
return is_embedding_mode

View File

@@ -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 str(user.id),
distinct_id=tenant_id if user.is_anonymous else user.email,
event=MilestoneRecordType.RAN_QUERY,
)

View File

@@ -1,239 +0,0 @@
from __future__ import annotations
import re
from onyx.context.search.models import SavedSearchDoc
from onyx.context.search.models import SearchDoc
from onyx.server.query_and_chat.placement import Placement
from onyx.server.query_and_chat.streaming_models import AgentResponseDelta
from onyx.server.query_and_chat.streaming_models import AgentResponseStart
from onyx.server.query_and_chat.streaming_models import CitationInfo
from onyx.server.query_and_chat.streaming_models import GeneratedImage
from onyx.server.query_and_chat.streaming_models import ImageGenerationFinal
from onyx.server.query_and_chat.streaming_models import ImageGenerationToolStart
from onyx.server.query_and_chat.streaming_models import OpenUrlDocuments
from onyx.server.query_and_chat.streaming_models import OpenUrlStart
from onyx.server.query_and_chat.streaming_models import OpenUrlUrls
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.query_and_chat.streaming_models import ReasoningDelta
from onyx.server.query_and_chat.streaming_models import ReasoningStart
from onyx.server.query_and_chat.streaming_models import SearchToolDocumentsDelta
from onyx.server.query_and_chat.streaming_models import SearchToolQueriesDelta
from onyx.server.query_and_chat.streaming_models import SearchToolStart
from onyx.server.query_and_chat.streaming_models import SectionEnd
_CANNOT_SHOW_STEP_RESULTS_STR = "[Cannot display step results]"
def _adjust_message_text_for_agent_search_results(
adjusted_message_text: str,
final_documents: list[SavedSearchDoc], # noqa: ARG001
) -> str:
# Remove all [Q<integer>] patterns (sub-question citations)
return re.sub(r"\[Q\d+\]", "", adjusted_message_text)
def _replace_d_citations_with_links(
message_text: str, final_documents: list[SavedSearchDoc]
) -> str:
def replace_citation(match: re.Match[str]) -> str:
d_number = match.group(1)
try:
doc_index = int(d_number) - 1
if 0 <= doc_index < len(final_documents):
doc = final_documents[doc_index]
link = doc.link if doc.link else ""
return f"[[{d_number}]]({link})"
return match.group(0)
except (ValueError, IndexError):
return match.group(0)
return re.sub(r"\[D(\d+)\]", replace_citation, message_text)
def create_message_packets(
message_text: str,
final_documents: list[SavedSearchDoc] | None,
turn_index: int,
is_legacy_agentic: bool = False,
) -> list[Packet]:
packets: list[Packet] = []
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=AgentResponseStart(
final_documents=SearchDoc.from_saved_search_docs(final_documents or []),
),
)
)
adjusted_message_text = message_text
if is_legacy_agentic:
if final_documents is not None:
adjusted_message_text = _adjust_message_text_for_agent_search_results(
message_text, final_documents
)
adjusted_message_text = _replace_d_citations_with_links(
adjusted_message_text, final_documents
)
else:
adjusted_message_text = re.sub(r"\[Q\d+\]", "", message_text)
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=AgentResponseDelta(
content=adjusted_message_text,
),
),
)
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=SectionEnd(),
)
)
return packets
def create_citation_packets(
citation_info_list: list[CitationInfo], turn_index: int
) -> list[Packet]:
packets: list[Packet] = []
# Emit each citation as a separate CitationInfo packet
for citation_info in citation_info_list:
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=citation_info,
)
)
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
return packets
def create_reasoning_packets(reasoning_text: str, turn_index: int) -> list[Packet]:
packets: list[Packet] = []
packets.append(
Packet(placement=Placement(turn_index=turn_index), obj=ReasoningStart())
)
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=ReasoningDelta(
reasoning=reasoning_text,
),
),
)
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
return packets
def create_image_generation_packets(
images: list[GeneratedImage], turn_index: int
) -> list[Packet]:
packets: list[Packet] = []
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=ImageGenerationToolStart(),
)
)
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=ImageGenerationFinal(images=images),
),
)
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
return packets
def create_fetch_packets(
fetch_docs: list[SavedSearchDoc],
urls: list[str],
turn_index: int,
) -> list[Packet]:
packets: list[Packet] = []
# Emit start packet
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=OpenUrlStart(),
)
)
# Emit URLs packet
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=OpenUrlUrls(urls=urls),
)
)
# Emit documents packet
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=OpenUrlDocuments(
documents=SearchDoc.from_saved_search_docs(fetch_docs)
),
)
)
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
return packets
def create_search_packets(
search_queries: list[str],
saved_search_docs: list[SavedSearchDoc],
is_internet_search: bool,
turn_index: int,
) -> list[Packet]:
packets: list[Packet] = []
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=SearchToolStart(
is_internet_search=is_internet_search,
),
)
)
# Emit queries if present
if search_queries:
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=SearchToolQueriesDelta(queries=search_queries),
),
)
# Emit documents if present
if saved_search_docs:
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=SearchToolDocumentsDelta(
documents=SearchDoc.from_saved_search_docs(saved_search_docs)
),
),
)
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
return packets

View File

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

View File

@@ -65,7 +65,7 @@ attrs==25.4.0
# jsonschema
# referencing
# zeep
authlib==1.6.9
authlib==1.6.7
# via fastmcp
azure-cognitiveservices-speech==1.38.0
# via onyx
@@ -737,7 +737,7 @@ pygithub==2.5.0
# via onyx
pygments==2.19.2
# via rich
pyjwt==2.12.0
pyjwt==2.11.0
# via
# fastapi-users
# mcp

View File

@@ -353,7 +353,7 @@ pygments==2.19.2
# via
# ipython
# ipython-pygments-lexers
pyjwt==2.12.0
pyjwt==2.11.0
# via mcp
pyparsing==3.2.5
# via matplotlib

View File

@@ -218,7 +218,7 @@ pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.12.0
# via mcp
pyjwt==2.12.0
pyjwt==2.11.0
# via mcp
python-dateutil==2.8.2
# via

View File

@@ -308,7 +308,7 @@ pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.12.0
# via mcp
pyjwt==2.12.0
pyjwt==2.11.0
# via mcp
python-dateutil==2.8.2
# via

View File

@@ -0,0 +1,471 @@
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import sys
from dataclasses import asdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import TypedDict
from typing import TypeGuard
import aiohttp
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger(__name__)
DEFAULT_API_BASE = "http://localhost:3000"
INTERNAL_SEARCH_TOOL_NAME = "internal_search"
INTERNAL_SEARCH_IN_CODE_TOOL_ID = "SearchTool"
MAX_REQUEST_ATTEMPTS = 5
RETRIABLE_STATUS_CODES = {429, 500, 502, 503, 504}
@dataclass(frozen=True)
class QuestionRecord:
question_id: str
question: str
@dataclass(frozen=True)
class AnswerRecord:
question_id: str
answer: str
document_ids: list[str]
@dataclass(frozen=True)
class FailedQuestionRecord:
question_id: str
error: str
class Citation(TypedDict, total=False):
citation_number: int
document_id: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Submit questions to Onyx chat with internal search forced and write "
"answers to a JSONL file."
)
)
parser.add_argument(
"--questions-file",
type=Path,
required=True,
help="Path to the input questions JSONL file.",
)
parser.add_argument(
"--output-file",
type=Path,
required=True,
help="Path to the output answers JSONL file.",
)
parser.add_argument(
"--api-key",
type=str,
required=True,
help="API key used to authenticate against Onyx.",
)
parser.add_argument(
"--api-base",
type=str,
default=DEFAULT_API_BASE,
help=(
"Frontend base URL for Onyx. If `/api` is omitted, it will be added "
f"automatically. Default: {DEFAULT_API_BASE}"
),
)
parser.add_argument(
"--parallelism",
type=int,
default=1,
help="Number of questions to process in parallel. Default: 1.",
)
parser.add_argument(
"--max-questions",
type=int,
default=None,
help="Optional cap on how many questions to process. Defaults to all.",
)
return parser.parse_args()
def normalize_api_base(api_base: str) -> str:
normalized = api_base.rstrip("/")
if normalized.endswith("/api"):
return normalized
return f"{normalized}/api"
def load_questions(questions_file: Path) -> list[QuestionRecord]:
if not questions_file.exists():
raise FileNotFoundError(f"Questions file not found: {questions_file}")
questions: list[QuestionRecord] = []
with questions_file.open("r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
stripped_line = line.strip()
if not stripped_line:
continue
try:
payload = json.loads(stripped_line)
except json.JSONDecodeError as exc:
raise ValueError(
f"Invalid JSON on line {line_number} of {questions_file}"
) from exc
question_id = payload.get("question_id")
question = payload.get("question")
if not isinstance(question_id, str) or not question_id:
raise ValueError(
f"Line {line_number} is missing a non-empty `question_id`."
)
if not isinstance(question, str) or not question:
raise ValueError(
f"Line {line_number} is missing a non-empty `question`."
)
questions.append(QuestionRecord(question_id=question_id, question=question))
return questions
async def read_json_response(
response: aiohttp.ClientResponse,
) -> dict[str, Any] | list[dict[str, Any]]:
response_text = await response.text()
if response.status >= 400:
raise RuntimeError(
f"Request to {response.url} failed with {response.status}: {response_text}"
)
try:
payload = json.loads(response_text)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"Request to {response.url} returned non-JSON content: {response_text}"
) from exc
if not isinstance(payload, (dict, list)):
raise RuntimeError(
f"Unexpected response payload type from {response.url}: {type(payload)}"
)
return payload
async def request_json_with_retries(
session: aiohttp.ClientSession,
method: str,
url: str,
headers: dict[str, str],
json_payload: dict[str, Any] | None = None,
) -> dict[str, Any] | list[dict[str, Any]]:
backoff_seconds = 1.0
for attempt in range(1, MAX_REQUEST_ATTEMPTS + 1):
try:
async with session.request(
method=method,
url=url,
headers=headers,
json=json_payload,
) as response:
if (
response.status in RETRIABLE_STATUS_CODES
and attempt < MAX_REQUEST_ATTEMPTS
):
response_text = await response.text()
logger.warning(
"Retryable response from %s on attempt %s/%s: %s %s",
url,
attempt,
MAX_REQUEST_ATTEMPTS,
response.status,
response_text,
)
await asyncio.sleep(backoff_seconds)
backoff_seconds *= 2
continue
return await read_json_response(response)
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
if attempt == MAX_REQUEST_ATTEMPTS:
raise RuntimeError(
f"Request to {url} failed after {MAX_REQUEST_ATTEMPTS} attempts."
) from exc
logger.warning(
"Request to %s failed on attempt %s/%s: %s",
url,
attempt,
MAX_REQUEST_ATTEMPTS,
exc,
)
await asyncio.sleep(backoff_seconds)
backoff_seconds *= 2
raise RuntimeError(f"Request to {url} failed unexpectedly.")
def extract_document_ids(citation_info: object) -> list[str]:
if not isinstance(citation_info, list):
return []
sorted_citations = sorted(
(citation for citation in citation_info if _is_valid_citation(citation)),
key=_citation_sort_key,
)
document_ids: list[str] = []
seen_document_ids: set[str] = set()
for citation in sorted_citations:
document_id = citation["document_id"]
if document_id not in seen_document_ids:
seen_document_ids.add(document_id)
document_ids.append(document_id)
return document_ids
def _is_valid_citation(citation: object) -> TypeGuard[Citation]:
return (
isinstance(citation, dict)
and isinstance(citation.get("document_id"), str)
and bool(citation["document_id"])
)
def _citation_sort_key(citation: Citation) -> int:
citation_number = citation.get("citation_number")
if isinstance(citation_number, int):
return citation_number
return sys.maxsize
async def fetch_internal_search_tool_id(
session: aiohttp.ClientSession,
api_base: str,
headers: dict[str, str],
) -> int:
payload = await request_json_with_retries(
session=session,
method="GET",
url=f"{api_base}/tool",
headers=headers,
)
if not isinstance(payload, list):
raise RuntimeError("Expected `/tool` to return a list.")
for tool in payload:
if not isinstance(tool, dict):
continue
if tool.get("in_code_tool_id") == INTERNAL_SEARCH_IN_CODE_TOOL_ID:
tool_id = tool.get("id")
if isinstance(tool_id, int):
return tool_id
for tool in payload:
if not isinstance(tool, dict):
continue
if tool.get("name") == INTERNAL_SEARCH_TOOL_NAME:
tool_id = tool.get("id")
if isinstance(tool_id, int):
return tool_id
raise RuntimeError(
"Could not find the internal search tool in `/tool`. "
"Make sure SearchTool is available for this environment."
)
async def submit_question(
session: aiohttp.ClientSession,
api_base: str,
headers: dict[str, str],
internal_search_tool_id: int,
question_record: QuestionRecord,
) -> AnswerRecord:
payload = {
"message": question_record.question,
"chat_session_info": {"persona_id": 0},
"parent_message_id": None,
"file_descriptors": [],
"allowed_tool_ids": [internal_search_tool_id],
"forced_tool_id": internal_search_tool_id,
"stream": False,
}
response_payload = await request_json_with_retries(
session=session,
method="POST",
url=f"{api_base}/chat/send-chat-message",
headers=headers,
json_payload=payload,
)
if not isinstance(response_payload, dict):
raise RuntimeError(
"Expected `/chat/send-chat-message` to return an object when `stream=false`."
)
answer = response_payload.get("answer_citationless")
if not isinstance(answer, str):
answer = response_payload.get("answer")
if not isinstance(answer, str):
raise RuntimeError(
f"Response for question {question_record.question_id} is missing `answer`."
)
return AnswerRecord(
question_id=question_record.question_id,
answer=answer,
document_ids=extract_document_ids(response_payload.get("citation_info")),
)
async def generate_answers(
questions: list[QuestionRecord],
output_file: Path,
api_base: str,
api_key: str,
parallelism: int,
) -> None:
if parallelism < 1:
raise ValueError("`--parallelism` must be at least 1.")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
timeout = aiohttp.ClientTimeout(
total=None,
connect=30,
sock_connect=30,
sock_read=600,
)
connector = aiohttp.TCPConnector(limit=parallelism)
output_file.parent.mkdir(parents=True, exist_ok=True)
with output_file.open("a", encoding="utf-8") as file:
async with aiohttp.ClientSession(
timeout=timeout, connector=connector
) as session:
internal_search_tool_id = await fetch_internal_search_tool_id(
session=session,
api_base=api_base,
headers=headers,
)
logger.info("Using internal search tool id %s", internal_search_tool_id)
semaphore = asyncio.Semaphore(parallelism)
progress_lock = asyncio.Lock()
write_lock = asyncio.Lock()
completed = 0
successful = 0
failed_questions: list[FailedQuestionRecord] = []
total = len(questions)
async def process_question(question_record: QuestionRecord) -> None:
nonlocal completed
nonlocal successful
try:
async with semaphore:
result = await submit_question(
session=session,
api_base=api_base,
headers=headers,
internal_search_tool_id=internal_search_tool_id,
question_record=question_record,
)
except Exception as exc:
async with progress_lock:
completed += 1
failed_questions.append(
FailedQuestionRecord(
question_id=question_record.question_id,
error=str(exc),
)
)
logger.exception(
"Failed question %s (%s/%s)",
question_record.question_id,
completed,
total,
)
return
async with write_lock:
file.write(json.dumps(asdict(result), ensure_ascii=False))
file.write("\n")
file.flush()
async with progress_lock:
completed += 1
successful += 1
logger.info("Processed %s/%s questions", completed, total)
await asyncio.gather(
*(process_question(question_record) for question_record in questions)
)
if failed_questions:
logger.warning(
"Completed with %s failed questions and %s successful questions.",
len(failed_questions),
successful,
)
for failed_question in failed_questions:
logger.warning(
"Failed question %s: %s",
failed_question.question_id,
failed_question.error,
)
def main() -> None:
args = parse_args()
questions = load_questions(args.questions_file)
api_base = normalize_api_base(args.api_base)
if args.max_questions is not None:
if args.max_questions < 1:
raise ValueError("`--max-questions` must be at least 1 when provided.")
questions = questions[: args.max_questions]
logger.info("Loaded %s questions from %s", len(questions), args.questions_file)
logger.info("Writing answers to %s", args.output_file)
asyncio.run(
generate_answers(
questions=questions,
output_file=args.output_file,
api_base=api_base,
api_key=args.api_key,
parallelism=args.parallelism,
)
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,291 @@
"""
Script to upload files from a directory as individual file connectors in Onyx.
Each file gets its own connector named after the file.
Usage:
python upload_files_as_connectors.py --data-dir /path/to/files --api-key YOUR_KEY
python upload_files_as_connectors.py --data-dir /path/to/files --api-key YOUR_KEY --api-base http://onyxserver:3000
python upload_files_as_connectors.py --data-dir /path/to/files --api-key YOUR_KEY --file-glob '*.zip'
Requires:
pip install requests
"""
import argparse
import fnmatch
import os
import sys
import threading
import time
import requests
REQUEST_TIMEOUT = 900 # 15 minutes
def _elapsed_printer(label: str, stop_event: threading.Event) -> None:
"""Print a live elapsed-time counter until stop_event is set."""
start = time.monotonic()
while not stop_event.wait(timeout=1):
elapsed = int(time.monotonic() - start)
m, s = divmod(elapsed, 60)
print(f"\r {label} ... {m:02d}:{s:02d}", end="", flush=True)
elapsed = int(time.monotonic() - start)
m, s = divmod(elapsed, 60)
print(f"\r {label} ... {m:02d}:{s:02d} done")
def _timed_request(label: str, fn: object) -> requests.Response:
"""Run a request function while displaying a live elapsed timer."""
stop = threading.Event()
t = threading.Thread(target=_elapsed_printer, args=(label, stop), daemon=True)
t.start()
try:
resp = fn() # type: ignore[operator]
finally:
stop.set()
t.join()
return resp
def upload_file(
session: requests.Session, base_url: str, file_path: str
) -> dict | None:
"""Upload a single file and return the response with file_paths and file_names."""
with open(file_path, "rb") as f:
resp = _timed_request(
"Uploading",
lambda: session.post(
f"{base_url}/api/manage/admin/connector/file/upload",
files={"files": (os.path.basename(file_path), f)},
timeout=REQUEST_TIMEOUT,
),
)
if not resp.ok:
print(f" ERROR uploading: {resp.text}")
return None
return resp.json()
def create_connector(
session: requests.Session,
base_url: str,
name: str,
file_paths: list[str],
file_names: list[str],
zip_metadata_file_id: str | None,
) -> int | None:
"""Create a file connector and return its ID."""
resp = _timed_request(
"Creating connector",
lambda: session.post(
f"{base_url}/api/manage/admin/connector",
json={
"name": name,
"source": "file",
"input_type": "load_state",
"connector_specific_config": {
"file_locations": file_paths,
"file_names": file_names,
"zip_metadata_file_id": zip_metadata_file_id,
},
"refresh_freq": None,
"prune_freq": None,
"indexing_start": None,
"access_type": "public",
"groups": [],
},
timeout=REQUEST_TIMEOUT,
),
)
if not resp.ok:
print(f" ERROR creating connector: {resp.text}")
return None
return resp.json()["id"]
def create_credential(
session: requests.Session, base_url: str, name: str
) -> int | None:
"""Create a dummy credential for the file connector."""
resp = session.post(
f"{base_url}/api/manage/credential",
json={
"credential_json": {},
"admin_public": True,
"source": "file",
"curator_public": True,
"groups": [],
"name": name,
},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
print(f" ERROR creating credential: {resp.text}")
return None
return resp.json()["id"]
def link_credential(
session: requests.Session,
base_url: str,
connector_id: int,
credential_id: int,
name: str,
) -> bool:
"""Link the connector to the credential (create CC pair)."""
resp = session.put(
f"{base_url}/api/manage/connector/{connector_id}/credential/{credential_id}",
json={
"name": name,
"access_type": "public",
"groups": [],
"auto_sync_options": None,
"processing_mode": "REGULAR",
},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
print(f" ERROR linking credential: {resp.text}")
return False
return True
def run_connector(
session: requests.Session,
base_url: str,
connector_id: int,
credential_id: int,
) -> bool:
"""Trigger the connector to start indexing."""
resp = session.post(
f"{base_url}/api/manage/admin/connector/run-once",
json={
"connector_id": connector_id,
"credentialIds": [credential_id],
"from_beginning": False,
},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
print(f" ERROR running connector: {resp.text}")
return False
return True
def process_file(session: requests.Session, base_url: str, file_path: str) -> bool:
"""Process a single file through the full connector creation flow."""
file_name = os.path.basename(file_path)
connector_name = file_name
print(f"Processing: {file_name}")
# Step 1: Upload
upload_resp = upload_file(session, base_url, file_path)
if not upload_resp:
return False
# Step 2: Create connector
connector_id = create_connector(
session,
base_url,
name=f"FileConnector-{connector_name}",
file_paths=upload_resp["file_paths"],
file_names=upload_resp["file_names"],
zip_metadata_file_id=upload_resp.get("zip_metadata_file_id"),
)
if connector_id is None:
return False
# Step 3: Create credential
credential_id = create_credential(session, base_url, name=connector_name)
if credential_id is None:
return False
# Step 4: Link connector to credential
if not link_credential(
session, base_url, connector_id, credential_id, connector_name
):
return False
# Step 5: Trigger indexing
if not run_connector(session, base_url, connector_id, credential_id):
return False
print(f" OK (connector_id={connector_id})")
return True
def get_authenticated_session(api_key: str) -> requests.Session:
"""Create a session authenticated with an API key."""
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {api_key}"})
return session
def main() -> None:
parser = argparse.ArgumentParser(
description="Upload files as individual Onyx file connectors."
)
parser.add_argument(
"--data-dir",
required=True,
help="Directory containing files to upload.",
)
parser.add_argument(
"--api-base",
default="http://localhost:3000",
help="Base URL for the Onyx API (default: http://localhost:3000).",
)
parser.add_argument(
"--api-key",
required=True,
help="API key for authentication.",
)
parser.add_argument(
"--file-glob",
default=None,
help="Glob pattern to filter files (e.g. '*.json', '*.zip').",
)
args = parser.parse_args()
data_dir = args.data_dir
base_url = args.api_base.rstrip("/")
api_key = args.api_key
file_glob = args.file_glob
if not os.path.isdir(data_dir):
print(f"Error: {data_dir} is not a directory")
sys.exit(1)
script_path = os.path.realpath(__file__)
files = sorted(
os.path.join(data_dir, f)
for f in os.listdir(data_dir)
if os.path.isfile(os.path.join(data_dir, f))
and os.path.realpath(os.path.join(data_dir, f)) != script_path
and (file_glob is None or fnmatch.fnmatch(f, file_glob))
)
if not files:
print(f"No files found in {data_dir}")
sys.exit(1)
print(f"Found {len(files)} file(s) in {data_dir}\n")
session = get_authenticated_session(api_key)
success = 0
failed = 0
for file_path in files:
if process_file(session, base_url, file_path):
success += 1
else:
failed += 1
# Small delay to avoid overwhelming the server
time.sleep(0.5)
print(f"\nDone: {success} succeeded, {failed} failed out of {len(files)} files.")
if __name__ == "__main__":
main()

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
from onyx.chat.process_message import remove_answer_citations
def test_remove_answer_citations_strips_http_markdown_citation() -> None:
answer = "The answer is Paris [[1]](https://example.com/doc)."
assert remove_answer_citations(answer) == "The answer is Paris."
def test_remove_answer_citations_strips_empty_markdown_citation() -> None:
answer = "The answer is Paris [[1]]()."
assert remove_answer_citations(answer) == "The answer is Paris."
def test_remove_answer_citations_strips_citation_with_parentheses_in_url() -> None:
answer = (
"The answer is Paris "
"[[1]](https://en.wikipedia.org/wiki/Function_(mathematics))."
)
assert remove_answer_citations(answer) == "The answer is Paris."
def test_remove_answer_citations_preserves_non_citation_markdown_links() -> None:
answer = (
"See [reference](https://example.com/Function_(mathematics)) "
"for context [[1]](https://en.wikipedia.org/wiki/Function_(mathematics))."
)
assert (
remove_answer_citations(answer)
== "See [reference](https://example.com/Function_(mathematics)) for context."
)

View File

@@ -1,40 +0,0 @@
"""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

View File

@@ -3,7 +3,6 @@
from onyx.server.manage.llm.utils import generate_bedrock_display_name
from onyx.server.manage.llm.utils import generate_ollama_display_name
from onyx.server.manage.llm.utils import infer_vision_support
from onyx.server.manage.llm.utils import is_embedding_model
from onyx.server.manage.llm.utils import is_reasoning_model
from onyx.server.manage.llm.utils import is_valid_bedrock_model
from onyx.server.manage.llm.utils import strip_openrouter_vendor_prefix
@@ -210,35 +209,3 @@ class TestIsReasoningModel:
is_reasoning_model("anthropic/claude-3-5-sonnet", "Claude 3.5 Sonnet")
is False
)
class TestIsEmbeddingModel:
"""Tests for embedding model detection."""
def test_openai_embedding_ada(self) -> None:
assert is_embedding_model("text-embedding-ada-002") is True
def test_openai_embedding_3_small(self) -> None:
assert is_embedding_model("text-embedding-3-small") is True
def test_openai_embedding_3_large(self) -> None:
assert is_embedding_model("text-embedding-3-large") is True
def test_cohere_embed_model(self) -> None:
assert is_embedding_model("embed-english-v3.0") is True
def test_bedrock_titan_embed(self) -> None:
assert is_embedding_model("amazon.titan-embed-text-v1") is True
def test_gpt4o_not_embedding(self) -> None:
assert is_embedding_model("gpt-4o") is False
def test_gpt4_not_embedding(self) -> None:
assert is_embedding_model("gpt-4") is False
def test_dall_e_not_embedding(self) -> None:
assert is_embedding_model("dall-e-3") is False
def test_unknown_custom_model_not_embedding(self) -> None:
"""Custom/local models not in litellm's model DB should default to False."""
assert is_embedding_model("my-custom-local-model-v1") is False

View File

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

View File

@@ -32,17 +32,15 @@ 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)
time.sleep(slow) # Sleep for 2 seconds
start = time.monotonic()
with pytest.raises(TimeoutError) as exc_info:
run_with_timeout(timeout, slow_function)
elapsed = time.monotonic() - start
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
assert f"timed out after {timeout} seconds" in str(exc_info.value)
assert elapsed >= timeout
# Should return around the timeout duration, not the full sleep duration
assert elapsed == pytest.approx(timeout, abs=0.8)
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")

View File

@@ -44,13 +44,13 @@ class TestBuildVespaFilters:
assert result == f'({SOURCE_TYPE} contains "web") and '
def test_acl(self) -> None:
"""Test with acls — uses weightedSet operator for efficient matching."""
"""Test with acls."""
# Single ACL
filters = IndexFilters(access_control_list=["user1"])
result = build_vespa_filters(filters)
assert (
result
== f'!({HIDDEN}=true) and weightedSet(access_control_list, {{"user1":1}}) and '
== f'!({HIDDEN}=true) and (access_control_list contains "user1") and '
)
# Multiple ACL's
@@ -58,7 +58,7 @@ class TestBuildVespaFilters:
result = build_vespa_filters(filters)
assert (
result
== f'!({HIDDEN}=true) and weightedSet(access_control_list, {{"user2":1, "group2":1}}) and '
== f'!({HIDDEN}=true) and (access_control_list contains "user2" or access_control_list contains "group2") and '
)
def test_tenant_filter(self) -> None:
@@ -250,7 +250,7 @@ class TestBuildVespaFilters:
result = build_vespa_filters(filters)
expected = f"!({HIDDEN}=true) and "
expected += 'weightedSet(access_control_list, {"user1":1, "group1":1}) and '
expected += '(access_control_list contains "user1" or access_control_list contains "group1") and '
expected += f'({SOURCE_TYPE} contains "web") and '
expected += f'({METADATA_LIST} contains "color{INDEX_SEPARATOR}red") and '
# Knowledge scope filters are OR'd together
@@ -290,38 +290,6 @@ class TestBuildVespaFilters:
expected = f'!({HIDDEN}=true) and (({DOCUMENT_SETS} contains "engineering") or ({DOCUMENT_ID} contains "{str(id1)}")) and '
assert expected == result
def test_acl_large_list_uses_weighted_set(self) -> None:
"""Verify that large ACL lists produce a weightedSet clause
instead of OR-chained contains — this is what prevents Vespa
HTTP 400 errors for users with thousands of permission groups."""
acl = [f"external_group:google_drive_{i}" for i in range(10_000)]
acl += ["user_email:user@example.com", "__PUBLIC__"]
filters = IndexFilters(access_control_list=acl)
result = build_vespa_filters(filters)
assert "weightedSet(access_control_list, {" in result
# Must NOT contain OR-chained contains clauses
assert "access_control_list contains" not in result
# All entries should be present
assert '"external_group:google_drive_0":1' in result
assert '"external_group:google_drive_9999":1' in result
assert '"user_email:user@example.com":1' in result
assert '"__PUBLIC__":1' in result
def test_acl_empty_strings_filtered(self) -> None:
"""Empty strings in the ACL list should be filtered out."""
filters = IndexFilters(access_control_list=["user1", "", "group1"])
result = build_vespa_filters(filters)
assert (
result
== f'!({HIDDEN}=true) and weightedSet(access_control_list, {{"user1":1, "group1":1}}) and '
)
# All empty
filters = IndexFilters(access_control_list=["", ""])
result = build_vespa_filters(filters)
assert result == f"!({HIDDEN}=true) and "
def test_empty_or_none_values(self) -> None:
"""Test with empty or None values in filter lists."""
# Empty strings in document set

View File

@@ -15,9 +15,8 @@
# -f docker-compose.dev.yml up -d --wait
#
# This overlay:
# - Moves Vespa (index), both model servers, OpenSearch, MinIO,
# Redis (cache), and the background worker to profiles so they do
# not start by default
# - Moves Vespa (index), both model servers, code-interpreter, 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
@@ -28,8 +27,7 @@
# --profile inference Inference model server
# --profile background Background worker (Celery) — also needs redis
# --profile redis Redis cache
# --profile opensearch OpenSearch
# --profile s3-filestore MinIO (S3-compatible file store)
# --profile code-interpreter Code interpreter
# =============================================================================
name: onyx
@@ -40,9 +38,6 @@ services:
index:
condition: service_started
required: false
opensearch:
condition: service_started
required: false
cache:
condition: service_started
required: false
@@ -89,10 +84,4 @@ services:
inference_model_server:
profiles: ["inference"]
# 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"]
code-interpreter: {}

View File

@@ -1,8 +1,8 @@
#!/bin/bash
set -euo pipefail
set -e
# Expected resource requirements (overridden below if --lite)
# Expected resource requirements
EXPECTED_DOCKER_RAM_GB=10
EXPECTED_DISK_GB=32
@@ -10,11 +10,6 @@ EXPECTED_DISK_GB=32
SHUTDOWN_MODE=false
DELETE_DATA_MODE=false
INCLUDE_CRAFT=false # Disabled by default, use --include-craft to enable
LITE_MODE=false # Disabled by default, use --lite to enable
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
@@ -30,26 +25,6 @@ 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 ""
@@ -57,23 +32,15 @@ while [[ $# -gt 0 ]]; do
echo ""
echo "Options:"
echo " --include-craft Enable Onyx Craft (AI-powered web app building)"
echo " --lite Deploy Onyx Lite (no Vespa, Redis, or model servers)"
echo " --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
;;
*)
@@ -84,129 +51,8 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ "$VERBOSE" = true ]]; then
set -x
fi
if [[ "$LITE_MODE" = true ]] && [[ "$INCLUDE_CRAFT" = true ]]; then
echo "ERROR: --lite and --include-craft cannot be used together."
echo "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
exit 1
fi
# 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'
@@ -265,7 +111,7 @@ if [ "$SHUTDOWN_MODE" = true ]; then
fi
# Stop containers (without removing them)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args true) stop)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml stop)
if [ $? -eq 0 ]; then
print_success "Onyx containers stopped (paused)"
else
@@ -294,17 +140,12 @@ if [ "$DELETE_DATA_MODE" = true ]; then
echo " • All downloaded files and configurations"
echo " • All user data and documents"
echo ""
if is_interactive; then
read -p "Are you sure you want to continue? Type 'DELETE' to confirm: " -r
echo ""
if [ "$REPLY" != "DELETE" ]; then
print_info "Operation cancelled."
exit 0
fi
else
print_error "Cannot confirm destructive operation in non-interactive mode."
print_info "Run interactively or remove the ${INSTALL_ROOT} directory manually."
exit 1
read -p "Are you sure you want to continue? Type 'DELETE' to confirm: " -r
echo ""
if [ "$REPLY" != "DELETE" ]; then
print_info "Operation cancelled."
exit 0
fi
print_info "Removing Onyx containers and volumes..."
@@ -323,7 +164,7 @@ if [ "$DELETE_DATA_MODE" = true ]; then
fi
# Stop and remove containers with volumes
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args true) down -v)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml down -v)
if [ $? -eq 0 ]; then
print_success "Onyx containers and volumes removed"
else
@@ -345,117 +186,6 @@ 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}"
@@ -479,7 +209,8 @@ echo "2. Check your system resources (Docker, memory, disk space)"
echo "3. Guide you through deployment options (version, authentication)"
echo ""
if is_interactive; then
# Only prompt for acknowledgment if running interactively
if [ -t 0 ]; then
echo -e "${YELLOW}${BOLD}Please acknowledge and press Enter to continue...${NC}"
read -r
echo ""
@@ -529,35 +260,41 @@ else
exit 1
fi
# Returns 0 if $1 <= $2, 1 if $1 > $2
# Handles missing or non-numeric parts gracefully (treats them as 0)
# Function to compare version numbers
version_compare() {
local version1="${1:-0.0.0}"
local version2="${2:-0.0.0}"
# Returns 0 if $1 <= $2, 1 if $1 > $2
local version1=$1
local version2=$2
local v1_major v1_minor v1_patch v2_major v2_minor v2_patch
v1_major=$(echo "$version1" | cut -d. -f1)
v1_minor=$(echo "$version1" | cut -d. -f2)
v1_patch=$(echo "$version1" | cut -d. -f3)
v2_major=$(echo "$version2" | cut -d. -f1)
v2_minor=$(echo "$version2" | cut -d. -f2)
v2_patch=$(echo "$version2" | cut -d. -f3)
# Split versions into components
local v1_major=$(echo $version1 | cut -d. -f1)
local v1_minor=$(echo $version1 | cut -d. -f2)
local v1_patch=$(echo $version1 | cut -d. -f3)
# Default non-numeric or empty parts to 0
[[ "$v1_major" =~ ^[0-9]+$ ]] || v1_major=0
[[ "$v1_minor" =~ ^[0-9]+$ ]] || v1_minor=0
[[ "$v1_patch" =~ ^[0-9]+$ ]] || v1_patch=0
[[ "$v2_major" =~ ^[0-9]+$ ]] || v2_major=0
[[ "$v2_minor" =~ ^[0-9]+$ ]] || v2_minor=0
[[ "$v2_patch" =~ ^[0-9]+$ ]] || v2_patch=0
local v2_major=$(echo $version2 | cut -d. -f1)
local v2_minor=$(echo $version2 | cut -d. -f2)
local v2_patch=$(echo $version2 | cut -d. -f3)
if [ "$v1_major" -lt "$v2_major" ]; then return 0
elif [ "$v1_major" -gt "$v2_major" ]; then return 1; fi
# Compare major version
if [ "$v1_major" -lt "$v2_major" ]; then
return 0
elif [ "$v1_major" -gt "$v2_major" ]; then
return 1
fi
if [ "$v1_minor" -lt "$v2_minor" ]; then return 0
elif [ "$v1_minor" -gt "$v2_minor" ]; then return 1; fi
# Compare minor version
if [ "$v1_minor" -lt "$v2_minor" ]; then
return 0
elif [ "$v1_minor" -gt "$v2_minor" ]; then
return 1
fi
[ "$v1_patch" -le "$v2_patch" ]
# Compare patch version
if [ "$v1_patch" -le "$v2_patch" ]; then
return 0
else
return 1
fi
}
# Check Docker daemon
@@ -599,20 +336,10 @@ fi
# Convert to GB for display
if [ "$MEMORY_MB" -gt 0 ]; then
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
MEMORY_GB=$((MEMORY_MB / 1024))
print_info "Docker memory allocation: ~${MEMORY_GB}GB"
else
print_warning "Could not determine memory allocation"
MEMORY_DISPLAY="unknown"
print_warning "Could not determine Docker memory allocation"
MEMORY_MB=0
fi
@@ -631,7 +358,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 "Less than ${EXPECTED_DOCKER_RAM_GB}GB RAM available (found: ${MEMORY_DISPLAY})"
print_warning "Docker has less than ${EXPECTED_DOCKER_RAM_GB}GB RAM allocated (found: ~${MEMORY_GB}GB)"
RESOURCE_WARNING=true
fi
@@ -642,10 +369,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 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."
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
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
@@ -658,89 +385,117 @@ 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"
# Ensure all required configuration files are present
# 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
NGINX_BASE_URL="https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deployment/data/nginx"
if [[ "$USE_LOCAL_FILES" = true ]]; then
print_step "Verifying existing configuration files"
# 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"
else
print_step "Downloading Onyx configuration files"
print_info "This step downloads all necessary configuration files from GitHub..."
print_error "Failed to download nginx configuration template"
print_info "Please ensure you have internet connection and try again"
exit 1
fi
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..."
# 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
fi
# 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
# 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
fi
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
# Create empty local directory marker (if needed)
touch "${INSTALL_ROOT}/data/nginx/local/.gitkeep"
print_success "All configuration files ready"
print_success "All configuration files downloaded successfully"
# Set up deployment configuration
print_step "Setting up deployment configs"
@@ -758,7 +513,7 @@ if [ -d "${INSTALL_ROOT}/deployment" ] && [ -f "${INSTALL_ROOT}/deployment/docke
if [ -n "$COMPOSE_CMD" ]; then
# Check if any containers are running
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args true) ps -q 2>/dev/null | wc -l)
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null | wc -l)
if [ "$RUNNING_CONTAINERS" -gt 0 ]; then
print_error "Onyx services are currently running!"
echo ""
@@ -779,7 +534,7 @@ if [ -f "$ENV_FILE" ]; then
echo "• Press Enter to restart with current configuration"
echo "• Type 'update' to update to a newer version"
echo ""
prompt_or_default "Choose an option [default: restart]: " ""
read -p "Choose an option [default: restart]: " -r
echo ""
if [ "$REPLY" = "update" ]; then
@@ -788,30 +543,26 @@ if [ -f "$ENV_FILE" ]; then
echo "• Press Enter for latest (recommended)"
echo "• Type a specific tag (e.g., v0.1.0)"
echo ""
# If --include-craft was passed, default to craft-latest
if [ "$INCLUDE_CRAFT" = true ]; then
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
VERSION="$REPLY"
read -p "Enter tag [default: craft-latest]: " -r VERSION
else
prompt_or_default "Enter tag [default: latest]: " "latest"
VERSION="$REPLY"
read -p "Enter tag [default: latest]: " -r VERSION
fi
echo ""
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
print_info "Selected: craft-latest (Craft enabled)"
elif [ "$VERSION" = "latest" ]; then
print_info "Selected: Latest version"
if [ -z "$VERSION" ]; then
if [ "$INCLUDE_CRAFT" = true ]; then
VERSION="craft-latest"
print_info "Selected: craft-latest (Craft enabled)"
else
VERSION="latest"
print_info "Selected: Latest version"
fi
else
print_info "Selected: $VERSION"
fi
# 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
@@ -830,67 +581,13 @@ 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 ""
@@ -898,21 +595,23 @@ else
echo "• Press Enter for craft-latest (recommended for Craft)"
echo "• Type a specific tag (e.g., craft-v1.0.0)"
echo ""
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
VERSION="$REPLY"
read -p "Enter tag [default: craft-latest]: " -r VERSION
else
echo "• Press Enter for latest (recommended)"
echo "• Type a specific tag (e.g., v0.1.0)"
echo ""
prompt_or_default "Enter tag [default: latest]: " "latest"
VERSION="$REPLY"
read -p "Enter tag [default: latest]: " -r VERSION
fi
echo ""
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
print_info "Selected: craft-latest (Craft enabled)"
elif [ "$VERSION" = "latest" ]; then
print_info "Selected: Latest tag"
if [ -z "$VERSION" ]; then
if [ "$INCLUDE_CRAFT" = true ]; then
VERSION="craft-latest"
print_info "Selected: craft-latest (Craft enabled)"
else
VERSION="latest"
print_info "Selected: Latest tag"
fi
else
print_info "Selected: $VERSION"
fi
@@ -946,13 +645,6 @@ 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"
@@ -962,13 +654,6 @@ 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"
@@ -1089,7 +774,7 @@ print_step "Pulling Docker images"
print_info "This may take several minutes depending on your internet connection..."
echo ""
print_info "Downloading Docker images (this may take a while)..."
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) pull --quiet)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml pull --quiet)
if [ $? -eq 0 ]; then
print_success "Docker images downloaded successfully"
else
@@ -1103,9 +788,9 @@ print_info "Launching containers..."
echo ""
if [ "$USE_LATEST" = true ]; then
print_info "Force pulling latest images and recreating containers..."
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d --pull always --force-recreate)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d --pull always --force-recreate)
else
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d)
fi
if [ $? -ne 0 ]; then
print_error "Failed to start Onyx services"
@@ -1127,7 +812,7 @@ echo ""
# Check for restart loops
print_info "Checking container health status..."
RESTART_ISSUES=false
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) ps -q 2>/dev/null)
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null)
for CONTAINER in $CONTAINERS; do
PROJECT_NAME="$(basename "$INSTALL_ROOT")_deployment_"
@@ -1156,7 +841,7 @@ if [ "$RESTART_ISSUES" = true ]; then
print_error "Some containers are experiencing issues!"
echo ""
print_info "Please check the logs for more information:"
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD $(compose_file_args) logs)"
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD -f docker-compose.yml logs)"
echo ""
print_info "If the issue persists, please contact: founders@onyx.app"
@@ -1175,12 +860,8 @@ check_onyx_health() {
echo ""
while [ $attempt -le $max_attempts ]; do
local http_code=""
if [[ "$DOWNLOADER" == "curl" ]]; then
http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port" 2>/dev/null || echo "000")
else
http_code=$(wget -q --spider -S "http://localhost:$port" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}' || echo "000")
fi
# Check for successful HTTP responses (200, 301, 302, etc.)
local http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port")
if echo "$http_code" | grep -qE "^(200|301|302|303|307|308)$"; then
return 0
fi
@@ -1236,18 +917,6 @@ print_info "If authentication is enabled, you can create your admin account here
echo " • Visit http://localhost:${HOST_PORT}/auth/signup to create your admin account"
echo " • The first user created will automatically have admin privileges"
echo ""
if [[ "$LITE_MODE" = true ]]; then
echo ""
print_info "Running in Lite mode — the following services are NOT started:"
echo " • Vespa (vector database)"
echo " • Redis (cache)"
echo " • Model servers (embedding/inference)"
echo " • Background workers (Celery)"
echo ""
print_info "Connectors and RAG search are disabled. LLM chat, tools, user file"
print_info "uploads, Projects, Agent knowledge, and code interpreter still work."
fi
echo ""
print_info "Refer to the README in the ${INSTALL_ROOT} directory for more information."
echo ""
print_info "For help or issues, contact: founders@onyx.app"

12
uv.lock generated
View File

@@ -453,14 +453,14 @@ wheels = [
[[package]]
name = "authlib"
version = "1.6.9"
version = "1.6.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -5643,11 +5643,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.12.0"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" },
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[package.optional-dependencies]

View File

@@ -7,7 +7,6 @@ import {
type InteractiveStatefulInteraction,
} from "@opal/core";
import type { SizeVariant, WidthVariant } from "@opal/shared";
import type { InteractiveContainerRoundingVariant } from "@opal/core";
import type { TooltipSide } from "@opal/components";
import type { IconFunctionComponent, IconProps } from "@opal/types";
import { SvgChevronDownSmall } from "@opal/icons";
@@ -81,9 +80,6 @@ type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
/** Which side the tooltip appears on. */
tooltipSide?: TooltipSide;
/** Override the default rounding derived from `size`. */
roundingVariant?: InteractiveContainerRoundingVariant;
};
// ---------------------------------------------------------------------------
@@ -99,7 +95,6 @@ function OpenButton({
justifyContent,
tooltip,
tooltipSide = "top",
roundingVariant: roundingVariantOverride,
interaction,
variant = "select-heavy",
...statefulProps
@@ -137,8 +132,7 @@ function OpenButton({
heightVariant={size}
widthVariant={width}
roundingVariant={
roundingVariantOverride ??
(isLarge ? "default" : size === "2xs" ? "mini" : "compact")
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
}
>
<div

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgCurate = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M8 9L8 14.5M8 9C7.35971 8.35971 6.9055 8 6 8H2.5L2.5 13.5H6C6.9055 13.5 7.35971 13.8597 8 14.5M8 9C8.64029 8.35971 9.09449 8 10 8H13.5L13.5 13.5H10C9.09449 13.5 8.64029 13.8597 8 14.5M10.25 3.75C10.25 4.99264 9.24264 6 8 6C6.75736 6 5.75 4.99264 5.75 3.75C5.75 2.50736 6.75736 1.5 8 1.5C9.24264 1.5 10.25 2.50736 10.25 3.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgCurate;

View File

@@ -54,7 +54,6 @@ export { default as SvgColumn } from "@opal/icons/column";
export { default as SvgCopy } from "@opal/icons/copy";
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
export { default as SvgCpu } from "@opal/icons/cpu";
export { default as SvgCurate } from "@opal/icons/curate";
export { default as SvgCreditCard } from "@opal/icons/credit-card";
export { default as SvgDashboard } from "@opal/icons/dashboard";
export { default as SvgDevKit } from "@opal/icons/dev-kit";
@@ -136,7 +135,6 @@ export { default as SvgPlayCircle } from "@opal/icons/play-circle";
export { default as SvgPlug } from "@opal/icons/plug";
export { default as SvgPlus } from "@opal/icons/plus";
export { default as SvgPlusCircle } from "@opal/icons/plus-circle";
export { default as SvgProgressBars } from "@opal/icons/progress-bars";
export { default as SvgProgressCircle } from "@opal/icons/progress-circle";
export { default as SvgQuestionMarkSmall } from "@opal/icons/question-mark-small";
export { default as SvgQuoteEnd } from "@opal/icons/quote-end";
@@ -178,16 +176,9 @@ export { default as SvgTwoLineSmall } from "@opal/icons/two-line-small";
export { default as SvgUnplug } from "@opal/icons/unplug";
export { default as SvgUploadCloud } from "@opal/icons/upload-cloud";
export { default as SvgUser } from "@opal/icons/user";
export { default as SvgUserCheck } from "@opal/icons/user-check";
export { default as SvgUserEdit } from "@opal/icons/user-edit";
export { default as SvgUserKey } from "@opal/icons/user-key";
export { default as SvgUserManage } from "@opal/icons/user-manage";
export { default as SvgUserMinus } from "@opal/icons/user-minus";
export { default as SvgUserPlus } from "@opal/icons/user-plus";
export { default as SvgUserShield } from "@opal/icons/user-shield";
export { default as SvgUserSpeaker } from "@opal/icons/user-speaker";
export { default as SvgUserSync } from "@opal/icons/user-sync";
export { default as SvgUserX } from "@opal/icons/user-x";
export { default as SvgUsers } from "@opal/icons/users";
export { default as SvgVolume } from "@opal/icons/volume";
export { default as SvgVolumeOff } from "@opal/icons/volume-off";

View File

@@ -1,20 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgProgressBars = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M5.5 2.00003L13.25 2C13.9403 2 14.5 2.55964 14.5 3.25C14.5 3.94036 13.9403 4.5 13.25 4.5L5.5 4.50003M5.5 2.00003L2.74998 2C2.05963 2 1.49998 2.55964 1.49998 3.25C1.49998 3.94036 2.05963 4.5 2.74998 4.5L5.5 4.50003M5.5 2.00003V4.50003M10.5 11.5H13.25C13.9403 11.5 14.5 12.0596 14.5 12.75C14.5 13.4404 13.9403 14 13.25 14H10.5M10.5 11.5H2.74998C2.05963 11.5 1.49998 12.0596 1.49998 12.75C1.49998 13.4404 2.05963 14 2.74999 14H10.5M10.5 11.5V14M8 6.75H13.25C13.9403 6.75 14.5 7.30964 14.5 8C14.5 8.69036 13.9403 9.25 13.25 9.25H8M8 6.75H2.74998C2.05963 6.75 1.49998 7.30964 1.49998 8C1.49998 8.69036 2.05963 9.25 2.74998 9.25H8M8 6.75V9.25"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgProgressBars;

View File

@@ -1,20 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgUserCheck = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M11 14C11 13.6667 11 13.3333 11 13C11 11.3431 9.65684 10 7.99998 10H4.00002C2.34316 10 1 11.3431 1 13C1 13.3333 1 13.6667 1 14M10.75 7.49999L12.25 9L15 6.24999M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserCheck;

View File

@@ -1,20 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgUserEdit = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M1 14C1 13.6667 1 13.3333 1 13C1 11.3431 2.34316 10 4.00002 10H7M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75ZM12.09 8.41421C12.3552 8.149 12.7149 8 13.09 8C13.2757 8 13.4596 8.03658 13.6312 8.10765C13.8028 8.17872 13.9587 8.28289 14.09 8.41421C14.2213 8.54554 14.3255 8.70144 14.3966 8.87302C14.4676 9.0446 14.5042 9.2285 14.5042 9.41421C14.5042 9.59993 14.4676 9.78383 14.3966 9.95541C14.3255 10.127 14.2213 10.2829 14.09 10.4142L10.6667 13.8333L8 14.5L8.66667 11.8333L12.09 8.41421Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserEdit;

View File

@@ -1,20 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgUserKey = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M1 14C1 13.6667 1 13.3333 1 13C1 11.3431 2.34316 10 4.00002 10H8.5M12.625 10C13.6605 10 14.5 9.16053 14.5 8.125C14.5 7.08947 13.6605 6.25 12.625 6.25C11.5895 6.25 10.75 7.08947 10.75 8.125C10.75 9.16053 11.5895 10 12.625 10ZM12.625 10V12.25M12.625 14.5V13.5M12.625 13.5H13.875V12.25H12.625M12.625 13.5V12.25M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserKey;

View File

@@ -1,20 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgUserMinus = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M11 14C11 13.6667 11 13.3333 11 13C11 11.3431 9.65684 10 7.99998 10H4.00002C2.34316 10 1 11.3431 1 13C1 13.3333 1 13.6667 1 14M10.75 7.49999L14.75 7.50007M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserMinus;

View File

@@ -1,20 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgUserShield = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M1 14C1 13.6667 1 13.3333 1 13C1 11.3431 2.34316 10 4.00002 10H7M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75ZM12 14.5C12 14.5 14.5 13.25 14.5 11.375V9L12 8L9.5 9V11.375C9.5 13.25 12 14.5 12 14.5Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserShield;

View File

@@ -1,20 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgUserSpeaker = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M1 14C1 13.6667 1 13.3333 1 13C1 11.3431 2.34316 10 4.00002 10H7.99998C9.65684 10 11 11.3431 11 13C11 13.3333 11 13.6667 11 14H14.5V10L12.7071 8.20711M12 7.5L12.7071 8.20711M12.7071 8.20711C13.0976 7.81658 13.0976 7.18342 12.7071 6.79289C12.3166 6.40237 11.6834 6.40237 11.2929 6.79289C10.9024 7.18342 10.9024 7.81658 11.2929 8.20711C11.6834 8.59763 12.3166 8.59763 12.7071 8.20711ZM8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserSpeaker;

View File

@@ -1,20 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgUserX = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M11 14C11 13.6667 11 13.3333 11 13C11 11.3431 9.65684 10 7.99998 10H4.00002C2.34316 10 1 11.3431 1 13C1 13.3333 1 13.6667 1 14M11.5 8.5L13.25 6.75M13.25 6.75L15 5M13.25 6.75L15 8.5M13.25 6.75L11.5 5M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserX;

86
web/package-lock.json generated
View File

@@ -61,7 +61,7 @@
"mdast-util-find-and-replace": "^3.0.1",
"mime": "^4.1.0",
"motion": "^12.29.0",
"next": "16.1.7",
"next": "16.1.6",
"next-themes": "^0.4.4",
"postcss": "^8.5.6",
"posthog-js": "^1.176.0",
@@ -2896,9 +2896,9 @@
}
},
"node_modules/@next/env": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -2942,9 +2942,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"cpu": [
"arm64"
],
@@ -2958,9 +2958,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"cpu": [
"x64"
],
@@ -2974,9 +2974,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"cpu": [
"arm64"
],
@@ -2990,9 +2990,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"cpu": [
"arm64"
],
@@ -3006,9 +3006,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"cpu": [
"x64"
],
@@ -3022,9 +3022,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"cpu": [
"x64"
],
@@ -3038,9 +3038,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"cpu": [
"arm64"
],
@@ -3054,9 +3054,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"cpu": [
"x64"
],
@@ -10309,9 +10309,7 @@
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.3.3",
"dev": true,
"license": "ISC"
},
@@ -14068,14 +14066,14 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.7",
"@next/env": "16.1.6",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -14087,14 +14085,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.7",
"@next/swc-darwin-x64": "16.1.7",
"@next/swc-linux-arm64-gnu": "16.1.7",
"@next/swc-linux-arm64-musl": "16.1.7",
"@next/swc-linux-x64-gnu": "16.1.7",
"@next/swc-linux-x64-musl": "16.1.7",
"@next/swc-win32-arm64-msvc": "16.1.7",
"@next/swc-win32-x64-msvc": "16.1.7",
"@next/swc-darwin-arm64": "16.1.6",
"@next/swc-darwin-x64": "16.1.6",
"@next/swc-linux-arm64-gnu": "16.1.6",
"@next/swc-linux-arm64-musl": "16.1.6",
"@next/swc-linux-x64-gnu": "16.1.6",
"@next/swc-linux-x64-musl": "16.1.6",
"@next/swc-win32-arm64-msvc": "16.1.6",
"@next/swc-win32-x64-msvc": "16.1.6",
"sharp": "^0.34.4"
},
"peerDependencies": {

View File

@@ -79,7 +79,7 @@
"mdast-util-find-and-replace": "^3.0.1",
"mime": "^4.1.0",
"motion": "^12.29.0",
"next": "16.1.7",
"next": "16.1.6",
"next-themes": "^0.4.4",
"postcss": "^8.5.6",
"posthog-js": "^1.176.0",

View File

@@ -2,9 +2,9 @@
import MCPPageContent from "@/sections/actions/MCPPageContent";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.MCP_ACTIONS;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.MCP_ACTIONS]!;
export default function Main() {
return (

View File

@@ -2,9 +2,9 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import OpenApiPageContent from "@/sections/actions/OpenApiPageContent";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.OPENAPI_ACTIONS;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.OPENAPI_ACTIONS]!;
export default function Main() {
return (

View File

@@ -32,10 +32,7 @@ import { SettingsContext } from "@/providers/SettingsProvider";
import SourceTile from "@/components/SourceTile";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import Text from "@/refresh-components/texts/Text";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.ADD_CONNECTOR;
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
function SourceTileTooltipWrapper({
sourceMetadata,
preSelect,
@@ -127,6 +124,7 @@ function SourceTileTooltipWrapper({
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.ADD_CONNECTOR]!;
const sources = useMemo(() => listSourceMetadata(), []);
const [rawSearchTerm, setSearchTerm] = useState("");

View File

@@ -11,11 +11,10 @@ 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 { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { useState, useEffect } from "react";
import { Pagination } from "@opal/components";
import Pagination from "@/refresh-components/Pagination";
const route = ADMIN_ROUTES.AGENTS;
const PAGE_SIZE = 20;
function MainContent({
@@ -90,7 +89,7 @@ function MainContent({
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onChange={onPageChange}
onPageChange={onPageChange}
/>
)}
</>
@@ -121,6 +120,7 @@ function MainContent({
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.AGENTS]!;
const [currentPage, setCurrentPage] = useState(1);
const { personas, totalItems, isLoading, error, refresh } = useAdminPersonas({
pageNum: currentPage - 1, // Backend uses 0-indexed pages

View File

@@ -32,9 +32,9 @@ import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import Text from "@/refresh-components/texts/Text";
import { SvgEdit, SvgKey, SvgRefreshCw } from "@opal/icons";
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.API_KEYS;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.API_KEYS]!;
function Main() {
const {

View File

@@ -6,12 +6,10 @@ import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import { SlackBotTable } from "./SlackBotTable";
import { useSlackBots } from "./[bot-id]/hooks";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { DOCS_ADMINS_PATH } from "@/lib/constants";
const route = ADMIN_ROUTES.SLACK_BOTS;
function Main() {
const {
data: slackBots,
@@ -77,6 +75,8 @@ function Main() {
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.SLACK_BOTS]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />

View File

@@ -10,9 +10,9 @@ import * as SettingsLayouts from "@/layouts/settings-layouts";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { SvgLock } from "@opal/icons";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.DOCUMENT_PROCESSING;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_PROCESSING]!;
function Main() {
const {

View File

@@ -2,9 +2,9 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import ImageGenerationContent from "./ImageGenerationContent";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.IMAGE_GENERATION;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.IMAGE_GENERATION]!;
export default function Page() {
return (

View File

@@ -19,9 +19,9 @@ import { SettingsContext } from "@/providers/SettingsProvider";
import CardSection from "@/components/admin/CardSection";
import { ErrorCallout } from "@/components/ErrorCallout";
import { useToastFromQuery } from "@/hooks/useToast";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.INDEX_SETTINGS;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.SEARCH_SETTINGS]!;
export interface EmbeddingDetails {
api_key: string;
@@ -131,7 +131,7 @@ function Main() {
<div className="mt-4">
<Button variant="action" href="/admin/embeddings">
Update Index Settings
Update Search Settings
</Button>
</div>
</>

View File

@@ -23,10 +23,10 @@ import {
SvgOnyxLogo,
SvgX,
} from "@opal/icons";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { WebProviderSetupModal } from "@/app/admin/configuration/web-search/WebProviderSetupModal";
const route = ADMIN_ROUTES.WEB_SEARCH;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.WEB_SEARCH]!;
import {
SEARCH_PROVIDERS_URL,
SEARCH_PROVIDER_DETAILS,

View File

@@ -16,9 +16,9 @@ import { Card } from "@/components/ui/card";
import Text from "@/components/ui/text";
import { Spinner } from "@/components/Spinner";
import { SvgDownloadCloud } from "@opal/icons";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.DEBUG;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DEBUG]!;
function Main() {
const [categories, setCategories] = useState<string[]>([]);

View File

@@ -19,9 +19,7 @@ import {
import { createGuildConfig } from "@/app/admin/discord-bot/lib";
import { DiscordGuildsTable } from "@/app/admin/discord-bot/DiscordGuildsTable";
import { BotConfigCard } from "@/app/admin/discord-bot/BotConfigCard";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.DISCORD_BOTS;
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
function DiscordBotContent() {
const { data: guilds, isLoading, error, refreshGuilds } = useDiscordGuilds();
@@ -120,6 +118,8 @@ function DiscordBotContent() {
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DISCORD_BOTS]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header

View File

@@ -3,9 +3,9 @@
import { useState } from "react";
import useSWR from "swr";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.INDEX_MIGRATION;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.INDEX_MIGRATION]!;
import Card from "@/refresh-components/cards/Card";
import { Content, ContentAction } from "@opal/layouts";

View File

@@ -1,13 +1,11 @@
"use client";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { Explorer } from "./Explorer";
import { Connector } from "@/lib/connectors/connectors";
import { DocumentSetSummary } from "@/lib/types";
const route = ADMIN_ROUTES.DOCUMENT_EXPLORER;
interface DocumentExplorerPageProps {
initialSearchValue: string | undefined;
connectors: Connector<any>[];
@@ -19,6 +17,8 @@ export default function DocumentExplorerPage({
connectors,
documentSets,
}: DocumentExplorerPageProps) {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_EXPLORER]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />

View File

@@ -6,9 +6,7 @@ import { DocumentFeedbackTable } from "./DocumentFeedbackTable";
import { numPages, numToDisplay } from "./constants";
import Title from "@/components/ui/title";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.DOCUMENT_FEEDBACK;
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
function Main() {
const {
@@ -63,6 +61,8 @@ function Main() {
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_FEEDBACK]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />

View File

@@ -6,14 +6,12 @@ import { refreshDocumentSets, useDocumentSets } from "../hooks";
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
import { ThreeDotsLoader } from "@/components/Loading";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import CardSection from "@/components/admin/CardSection";
import { DocumentSetCreationForm } from "../DocumentSetCreationForm";
import { useRouter } from "next/navigation";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
const route = ADMIN_ROUTES.DOCUMENT_SETS;
function Main({ documentSetId }: { documentSetId: number }) {
const router = useRouter();
const vectorDbEnabled = useVectorDbEnabled();
@@ -95,6 +93,7 @@ export default function Page(props: {
}) {
const params = use(props.params);
const documentSetId = parseInt(params.documentSetId);
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_SETS]!;
return (
<SettingsLayouts.Root>

View File

@@ -1,7 +1,7 @@
"use client";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { DocumentSetCreationForm } from "../DocumentSetCreationForm";
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
import { ThreeDotsLoader } from "@/components/Loading";
@@ -11,8 +11,6 @@ import { refreshDocumentSets } from "../hooks";
import CardSection from "@/components/admin/CardSection";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
const route = ADMIN_ROUTES.DOCUMENT_SETS;
function Main() {
const router = useRouter();
const vectorDbEnabled = useVectorDbEnabled();
@@ -60,6 +58,8 @@ function Main() {
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_SETS]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header

View File

@@ -20,7 +20,7 @@ import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { deleteDocumentSet } from "./lib";
import { toast } from "@/hooks/useToast";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import {
FiAlertTriangle,
FiCheckCircle,
@@ -43,7 +43,6 @@ import CreateButton from "@/refresh-components/buttons/CreateButton";
import { SourceIcon } from "@/components/SourceIcon";
import Link from "next/link";
const route = ADMIN_ROUTES.DOCUMENT_SETS;
const numToDisplay = 50;
// Component to display federated connectors with consistent styling
@@ -423,6 +422,8 @@ function Main() {
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_SETS]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />

View File

@@ -4,7 +4,7 @@ import { CCPairIndexingStatusTable } from "./CCPairIndexingStatusTable";
import { SearchAndFilterControls } from "./SearchAndFilterControls";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import Link from "next/link";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import Text from "@/components/ui/text";
import { useConnectorIndexingStatusWithPagination } from "@/lib/hooks";
import { useToastFromQuery } from "@/hooks/useToast";
@@ -18,8 +18,6 @@ import { TOGGLED_CONNECTORS_COOKIE_NAME } from "@/lib/constants";
import { ConnectorStaggeredSkeleton } from "./ConnectorRowSkeleton";
import { IndexingStatusRequest } from "@/lib/types";
const route = ADMIN_ROUTES.INDEXING_STATUS;
function Main() {
const vectorDbEnabled = useVectorDbEnabled();
@@ -206,6 +204,8 @@ function Main() {
}
export default function Status() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.INDEXING_STATUS]!;
useToastFromQuery({
"connector-created": {
message: "Connector created successfully",

View File

@@ -31,9 +31,9 @@ import KGEntityTypes from "@/app/admin/kg/KGEntityTypes";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { SvgSettings } from "@opal/icons";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.KNOWLEDGE_GRAPH;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.KNOWLEDGE_GRAPH]!;
function createDomainField(
name: string,

View File

@@ -18,9 +18,9 @@ import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidE
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { SvgGlobe, SvgUser, SvgUsers } from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.TOKEN_RATE_LIMITS;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.TOKEN_RATE_LIMITS]!;
const BASE_URL = "/api/admin/token-rate-limits";
const GLOBAL_TOKEN_FETCH_URL = `${BASE_URL}/global`;
const USER_TOKEN_FETCH_URL = `${BASE_URL}/users`;

View File

@@ -24,8 +24,10 @@ import {
} from "@/app/craft/onboarding/constants";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { buildOnboardingInitialValues as buildInitialValues } from "@/sections/modals/llmConfig/utils";
import { testApiKeyHelper } from "@/sections/modals/llmConfig/svc";
import {
buildInitialValues,
testApiKeyHelper,
} from "@/sections/onboarding/components/llmConnectionHelpers";
import OnboardingInfoPages from "@/app/craft/onboarding/components/OnboardingInfoPages";
import OnboardingUserInfo from "@/app/craft/onboarding/components/OnboardingUserInfo";
import OnboardingLlmSetup, {

View File

@@ -6,11 +6,11 @@ import { useSpecificUserGroup } from "./hook";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorStatus } from "@/lib/hooks";
import useUsers from "@/hooks/useUsers";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
const route = ADMIN_ROUTES.GROUPS;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.GROUPS]!;
function Main({ groupId }: { groupId: string }) {
const vectorDbEnabled = useVectorDbEnabled();

View File

@@ -8,11 +8,11 @@ import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
import useUsers from "@/hooks/useUsers";
import { useUser } from "@/providers/UserProvider";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
const route = ADMIN_ROUTES.GROUPS;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.GROUPS]!;
function Main() {
const [showForm, setShowForm] = useState(false);

View File

@@ -1,11 +1,11 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { CUSTOM_ANALYTICS_ENABLED } from "@/lib/constants";
import { Callout } from "@/components/ui/callout";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import Text from "@/components/ui/text";
import { CustomAnalyticsUpdateForm } from "./CustomAnalyticsUpdateForm";
const route = ADMIN_ROUTES.CUSTOM_ANALYTICS;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.CUSTOM_ANALYTICS]!;
function Main() {
if (!CUSTOM_ANALYTICS_ENABLED) {

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