mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-31 04:22:44 +00:00
Compare commits
3 Commits
main
...
bo/hook_ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fd3e84abf | ||
|
|
68aa655112 | ||
|
|
ad7cd27e75 |
6
.github/workflows/deployment.yml
vendored
6
.github/workflows/deployment.yml
vendored
@@ -704,9 +704,6 @@ jobs:
|
||||
NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=true
|
||||
NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK=true
|
||||
NODE_OPTIONS=--max-old-space-size=8192
|
||||
SENTRY_RELEASE=${{ github.sha }}
|
||||
secrets: |
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:cloudweb-cache-amd64
|
||||
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
|
||||
@@ -789,9 +786,6 @@ jobs:
|
||||
NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=true
|
||||
NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK=true
|
||||
NODE_OPTIONS=--max-old-space-size=8192
|
||||
SENTRY_RELEASE=${{ github.sha }}
|
||||
secrets: |
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:cloudweb-cache-arm64
|
||||
type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest
|
||||
|
||||
@@ -35,7 +35,6 @@ jobs:
|
||||
needs: [provider-chat-test]
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
runs-on: ubuntu-slim
|
||||
environment: ci-protected
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -183,7 +183,6 @@ jobs:
|
||||
- cherry-pick-to-latest-release
|
||||
if: needs.resolve-cherry-pick-request.outputs.should_cherrypick == 'true' && needs.resolve-cherry-pick-request.result == 'success' && needs.cherry-pick-to-latest-release.result == 'success'
|
||||
runs-on: ubuntu-slim
|
||||
environment: ci-protected
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -233,7 +232,6 @@ jobs:
|
||||
- cherry-pick-to-latest-release
|
||||
if: always() && needs.resolve-cherry-pick-request.outputs.should_cherrypick == 'true' && (needs.resolve-cherry-pick-request.result == 'failure' || needs.cherry-pick-to-latest-release.result == 'failure')
|
||||
runs-on: ubuntu-slim
|
||||
environment: ci-protected
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
2
.github/workflows/pr-desktop-build.yml
vendored
2
.github/workflows/pr-desktop-build.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Cargo registry and build
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # zizmor: ignore[cache-poisoning]
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # zizmor: ignore[cache-poisoning]
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
|
||||
2
.github/workflows/pr-helm-chart-testing.yml
vendored
2
.github/workflows/pr-helm-chart-testing.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
version: v3.19.0
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@2e2940618cb426dce2999631d543b53cdcfc8527
|
||||
uses: helm/chart-testing-action@b5eebdd9998021f29756c53432f48dab66394810
|
||||
with:
|
||||
uv_version: "0.9.9"
|
||||
|
||||
|
||||
4
.github/workflows/pr-playwright-tests.yml
vendored
4
.github/workflows/pr-playwright-tests.yml
vendored
@@ -284,7 +284,7 @@ jobs:
|
||||
|
||||
- name: Cache playwright cache
|
||||
# zizmor: ignore[cache-poisoning] ephemeral runners; no release artifacts
|
||||
uses: runs-on/cache@a5f51d6f3fece787d03b7b4e981c82538a0654ed # ratchet:runs-on/cache@v4
|
||||
uses: runs-on/cache@50350ad4242587b6c8c2baa2e740b1bc11285ff4 # ratchet:runs-on/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-npm-${{ hashFiles('web/package-lock.json') }}
|
||||
@@ -626,7 +626,7 @@ jobs:
|
||||
|
||||
- name: Cache playwright cache
|
||||
# zizmor: ignore[cache-poisoning] ephemeral runners; no release artifacts
|
||||
uses: runs-on/cache@a5f51d6f3fece787d03b7b4e981c82538a0654ed # ratchet:runs-on/cache@v4
|
||||
uses: runs-on/cache@50350ad4242587b6c8c2baa2e740b1bc11285ff4 # ratchet:runs-on/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-npm-${{ hashFiles('web/package-lock.json') }}
|
||||
|
||||
2
.github/workflows/pr-python-checks.yml
vendored
2
.github/workflows/pr-python-checks.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
- name: Cache mypy cache
|
||||
if: ${{ vars.DISABLE_MYPY_CACHE != 'true' }}
|
||||
uses: runs-on/cache@a5f51d6f3fece787d03b7b4e981c82538a0654ed # ratchet:runs-on/cache@v4
|
||||
uses: runs-on/cache@50350ad4242587b6c8c2baa2e740b1bc11285ff4 # ratchet:runs-on/cache@v4
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: mypy-${{ runner.os }}-${{ github.base_ref || github.event.merge_group.base_ref || 'main' }}-${{ hashFiles('**/*.py', '**/*.pyi', 'pyproject.toml') }}
|
||||
|
||||
1
.github/workflows/pr-python-model-tests.yml
vendored
1
.github/workflows/pr-python-model-tests.yml
vendored
@@ -31,7 +31,6 @@ jobs:
|
||||
- runner=4cpu-linux-arm64
|
||||
- "run-id=${{ github.run_id }}-model-check"
|
||||
- "extras=ecr-cache"
|
||||
environment: ci-protected
|
||||
timeout-minutes: 45
|
||||
|
||||
env:
|
||||
|
||||
1
.github/workflows/preview.yml
vendored
1
.github/workflows/preview.yml
vendored
@@ -15,7 +15,6 @@ permissions:
|
||||
jobs:
|
||||
Deploy-Preview:
|
||||
runs-on: ubuntu-latest
|
||||
environment: ci-protected
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
|
||||
2
.github/workflows/storybook-deploy.yml
vendored
2
.github/workflows/storybook-deploy.yml
vendored
@@ -25,7 +25,6 @@ permissions:
|
||||
jobs:
|
||||
Deploy-Storybook:
|
||||
runs-on: ubuntu-latest
|
||||
environment: ci-protected
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v4
|
||||
@@ -55,7 +54,6 @@ jobs:
|
||||
needs: Deploy-Storybook
|
||||
if: always() && needs.Deploy-Storybook.result == 'failure'
|
||||
runs-on: ubuntu-latest
|
||||
environment: ci-protected
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v4
|
||||
|
||||
1
.github/workflows/sync_foss.yml
vendored
1
.github/workflows/sync_foss.yml
vendored
@@ -9,7 +9,6 @@ on:
|
||||
jobs:
|
||||
sync-foss:
|
||||
runs-on: ubuntu-latest
|
||||
environment: ci-protected
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
1
.github/workflows/tag-nightly.yml
vendored
1
.github/workflows/tag-nightly.yml
vendored
@@ -11,7 +11,6 @@ permissions:
|
||||
jobs:
|
||||
create-and-push-tag:
|
||||
runs-on: ubuntu-slim
|
||||
environment: ci-protected
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
|
||||
@@ -13,7 +13,6 @@ from redis.lock import Lock as RedisLock
|
||||
from ee.onyx.server.tenants.provisioning import setup_tenant
|
||||
from ee.onyx.server.tenants.schema_management import create_schema_if_not_exists
|
||||
from ee.onyx.server.tenants.schema_management import get_current_alembic_version
|
||||
from ee.onyx.server.tenants.schema_management import run_alembic_migrations
|
||||
from onyx.background.celery.apps.app_base import task_logger
|
||||
from onyx.configs.app_configs import TARGET_AVAILABLE_TENANTS
|
||||
from onyx.configs.constants import ONYX_CLOUD_TENANT_ID
|
||||
@@ -30,10 +29,9 @@ from shared_configs.configs import TENANT_ID_PREFIX
|
||||
# Each tenant takes ~80s (alembic migrations), so 5 tenants ≈ 7 minutes.
|
||||
_MAX_TENANTS_PER_RUN = 5
|
||||
|
||||
# Time limits sized for worst-case: provisioning up to _MAX_TENANTS_PER_RUN new tenants
|
||||
# (~90s each) plus migrating up to TARGET_AVAILABLE_TENANTS pool tenants (~90s each).
|
||||
_TENANT_PROVISIONING_SOFT_TIME_LIMIT = 60 * 20 # 20 minutes
|
||||
_TENANT_PROVISIONING_TIME_LIMIT = 60 * 25 # 25 minutes
|
||||
# Time limits sized for worst-case batch: _MAX_TENANTS_PER_RUN × ~90s + buffer.
|
||||
_TENANT_PROVISIONING_SOFT_TIME_LIMIT = 60 * 10 # 10 minutes
|
||||
_TENANT_PROVISIONING_TIME_LIMIT = 60 * 15 # 15 minutes
|
||||
|
||||
|
||||
@shared_task(
|
||||
@@ -93,7 +91,8 @@ def check_available_tenants(self: Task) -> None: # noqa: ARG001
|
||||
batch_size = min(tenants_to_provision, _MAX_TENANTS_PER_RUN)
|
||||
if batch_size < tenants_to_provision:
|
||||
task_logger.info(
|
||||
f"Capping batch to {batch_size} (need {tenants_to_provision}, will catch up next cycle)"
|
||||
f"Capping batch to {batch_size} "
|
||||
f"(need {tenants_to_provision}, will catch up next cycle)"
|
||||
)
|
||||
|
||||
provisioned = 0
|
||||
@@ -104,14 +103,12 @@ def check_available_tenants(self: Task) -> None: # noqa: ARG001
|
||||
provisioned += 1
|
||||
except Exception:
|
||||
task_logger.exception(
|
||||
f"Failed to provision tenant {i + 1}/{batch_size}, continuing with remaining tenants"
|
||||
f"Failed to provision tenant {i + 1}/{batch_size}, "
|
||||
"continuing with remaining tenants"
|
||||
)
|
||||
|
||||
task_logger.info(f"Provisioning complete: {provisioned}/{batch_size} succeeded")
|
||||
|
||||
# Migrate any pool tenants that were provisioned before a new migration was deployed
|
||||
_migrate_stale_pool_tenants()
|
||||
|
||||
except Exception:
|
||||
task_logger.exception("Error in check_available_tenants task")
|
||||
|
||||
@@ -124,46 +121,6 @@ def check_available_tenants(self: Task) -> None: # noqa: ARG001
|
||||
)
|
||||
|
||||
|
||||
def _migrate_stale_pool_tenants() -> None:
|
||||
"""
|
||||
Run alembic upgrade head on all pool tenants. Since alembic upgrade head is
|
||||
idempotent, tenants already at head are a fast no-op. This ensures pool
|
||||
tenants are always current so that signup doesn't hit schema mismatches
|
||||
(e.g. missing columns added after the tenant was pre-provisioned).
|
||||
"""
|
||||
with get_session_with_shared_schema() as db_session:
|
||||
pool_tenants = db_session.query(AvailableTenant).all()
|
||||
tenant_ids = [t.tenant_id for t in pool_tenants]
|
||||
|
||||
if not tenant_ids:
|
||||
return
|
||||
|
||||
task_logger.info(
|
||||
f"Checking {len(tenant_ids)} pool tenant(s) for pending migrations"
|
||||
)
|
||||
|
||||
for tenant_id in tenant_ids:
|
||||
try:
|
||||
run_alembic_migrations(tenant_id)
|
||||
new_version = get_current_alembic_version(tenant_id)
|
||||
with get_session_with_shared_schema() as db_session:
|
||||
tenant = (
|
||||
db_session.query(AvailableTenant)
|
||||
.filter_by(tenant_id=tenant_id)
|
||||
.first()
|
||||
)
|
||||
if tenant and tenant.alembic_version != new_version:
|
||||
task_logger.info(
|
||||
f"Migrated pool tenant {tenant_id}: {tenant.alembic_version} -> {new_version}"
|
||||
)
|
||||
tenant.alembic_version = new_version
|
||||
db_session.commit()
|
||||
except Exception:
|
||||
task_logger.exception(
|
||||
f"Failed to migrate pool tenant {tenant_id}, skipping"
|
||||
)
|
||||
|
||||
|
||||
def pre_provision_tenant() -> bool:
|
||||
"""
|
||||
Pre-provision a new tenant and store it in the NewAvailableTenant table.
|
||||
|
||||
@@ -99,26 +99,6 @@ async def get_or_provision_tenant(
|
||||
tenant_id = await get_available_tenant()
|
||||
|
||||
if tenant_id:
|
||||
# Run migrations to ensure the pre-provisioned tenant schema is current.
|
||||
# Pool tenants may have been created before a new migration was deployed.
|
||||
# Capture as a non-optional local so mypy can type the lambda correctly.
|
||||
_tenant_id: str = tenant_id
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None, lambda: run_alembic_migrations(_tenant_id)
|
||||
)
|
||||
except Exception:
|
||||
# The tenant was already dequeued from the pool — roll it back so
|
||||
# it doesn't end up orphaned (schema exists, but not assigned to anyone).
|
||||
logger.exception(
|
||||
f"Migration failed for pre-provisioned tenant {_tenant_id}; rolling back"
|
||||
)
|
||||
try:
|
||||
await rollback_tenant_provisioning(_tenant_id)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to rollback orphaned tenant {_tenant_id}")
|
||||
raise
|
||||
# If we have a pre-provisioned tenant, assign it to the user
|
||||
await assign_tenant_to_user(tenant_id, email, referral_source)
|
||||
logger.info(f"Assigned pre-provisioned tenant {tenant_id} to user {email}")
|
||||
|
||||
@@ -100,7 +100,6 @@ def get_model_app() -> FastAPI:
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=[StarletteIntegration(), FastApiIntegration()],
|
||||
traces_sample_rate=0.1,
|
||||
release=__version__,
|
||||
)
|
||||
logger.info("Sentry initialized")
|
||||
else:
|
||||
|
||||
@@ -20,7 +20,6 @@ from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx import __version__
|
||||
from onyx.background.celery.apps.task_formatters import CeleryTaskColoredFormatter
|
||||
from onyx.background.celery.apps.task_formatters import CeleryTaskPlainFormatter
|
||||
from onyx.background.celery.celery_utils import celery_is_worker_primary
|
||||
@@ -66,7 +65,6 @@ if SENTRY_DSN:
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=[CeleryIntegration()],
|
||||
traces_sample_rate=0.1,
|
||||
release=__version__,
|
||||
)
|
||||
logger.info("Sentry initialized")
|
||||
else:
|
||||
@@ -517,8 +515,7 @@ def reset_tenant_id(
|
||||
|
||||
|
||||
def wait_for_vespa_or_shutdown(
|
||||
sender: Any, # noqa: ARG001
|
||||
**kwargs: Any, # noqa: ARG001
|
||||
sender: Any, **kwargs: Any # noqa: ARG001
|
||||
) -> None: # noqa: ARG001
|
||||
"""Waits for Vespa to become ready subject to a timeout.
|
||||
Raises WorkerShutdown if the timeout is reached."""
|
||||
|
||||
@@ -9,7 +9,6 @@ from celery import Celery
|
||||
from celery import shared_task
|
||||
from celery import Task
|
||||
|
||||
from onyx import __version__
|
||||
from onyx.background.celery.apps.app_base import task_logger
|
||||
from onyx.background.celery.memory_monitoring import emit_process_memory
|
||||
from onyx.background.celery.tasks.docprocessing.heartbeat import start_heartbeat
|
||||
@@ -138,7 +137,6 @@ def _docfetching_task(
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
traces_sample_rate=0.1,
|
||||
release=__version__,
|
||||
)
|
||||
logger.info("Sentry initialized")
|
||||
else:
|
||||
|
||||
@@ -212,7 +212,6 @@ class DocumentSource(str, Enum):
|
||||
PRODUCTBOARD = "productboard"
|
||||
FILE = "file"
|
||||
CODA = "coda"
|
||||
CANVAS = "canvas"
|
||||
NOTION = "notion"
|
||||
ZULIP = "zulip"
|
||||
LINEAR = "linear"
|
||||
@@ -673,7 +672,6 @@ DocumentSourceDescription: dict[DocumentSource, str] = {
|
||||
DocumentSource.SLAB: "slab data",
|
||||
DocumentSource.PRODUCTBOARD: "productboard data (boards, etc.)",
|
||||
DocumentSource.FILE: "files",
|
||||
DocumentSource.CANVAS: "canvas lms - courses, pages, assignments, and announcements",
|
||||
DocumentSource.CODA: "coda - team workspace with docs, tables, and pages",
|
||||
DocumentSource.NOTION: "notion data - a workspace that combines note-taking, \
|
||||
project management, and collaboration tools into a single, customizable platform",
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
Permissioning / AccessControl logic for Canvas courses.
|
||||
|
||||
CE stub — returns None (no permissions). The EE implementation is loaded
|
||||
at runtime via ``fetch_versioned_implementation``.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
from onyx.connectors.canvas.client import CanvasApiClient
|
||||
from onyx.utils.variable_functionality import fetch_versioned_implementation
|
||||
from onyx.utils.variable_functionality import global_version
|
||||
|
||||
|
||||
def get_course_permissions(
|
||||
canvas_client: CanvasApiClient,
|
||||
course_id: int,
|
||||
) -> ExternalAccess | None:
|
||||
if not global_version.is_ee_version():
|
||||
return None
|
||||
|
||||
ee_get_course_permissions = cast(
|
||||
Callable[[CanvasApiClient, int], ExternalAccess | None],
|
||||
fetch_versioned_implementation(
|
||||
"onyx.external_permissions.canvas.access",
|
||||
"get_course_permissions",
|
||||
),
|
||||
)
|
||||
|
||||
return ee_get_course_permissions(canvas_client, course_id)
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -191,22 +190,3 @@ class CanvasApiClient:
|
||||
if clean_endpoint:
|
||||
final_url += "/" + clean_endpoint
|
||||
return final_url
|
||||
|
||||
def paginate(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Iterator[list[Any]]:
|
||||
"""Yield each page of results, following Link-header pagination.
|
||||
|
||||
Makes the first request with endpoint + params, then follows
|
||||
next_url from Link headers for subsequent pages.
|
||||
"""
|
||||
response, next_url = self.get(endpoint, params=params)
|
||||
while True:
|
||||
if not response:
|
||||
break
|
||||
yield response
|
||||
if not next_url:
|
||||
break
|
||||
response, next_url = self.get(full_url=next_url)
|
||||
|
||||
@@ -1,82 +1,17 @@
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
from typing import Literal
|
||||
from typing import NoReturn
|
||||
from typing import TypeAlias
|
||||
|
||||
from pydantic import BaseModel
|
||||
from retry import retry
|
||||
from typing_extensions import override
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
from onyx.configs.app_configs import INDEX_BATCH_SIZE
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.canvas.access import get_course_permissions
|
||||
from onyx.connectors.canvas.client import CanvasApiClient
|
||||
from onyx.connectors.exceptions import ConnectorValidationError
|
||||
from onyx.connectors.exceptions import CredentialExpiredError
|
||||
from onyx.connectors.exceptions import InsufficientPermissionsError
|
||||
from onyx.connectors.exceptions import UnexpectedValidationError
|
||||
from onyx.connectors.interfaces import CheckpointedConnectorWithPermSync
|
||||
from onyx.connectors.interfaces import CheckpointOutput
|
||||
from onyx.connectors.interfaces import GenerateSlimDocumentOutput
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.interfaces import SlimConnectorWithPermSync
|
||||
from onyx.connectors.models import ConnectorCheckpoint
|
||||
from onyx.connectors.models import ConnectorMissingCredentialError
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import ImageSection
|
||||
from onyx.connectors.models import TextSection
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.file_processing.html_utils import parse_html_page_basic
|
||||
from onyx.indexing.indexing_heartbeat import IndexingHeartbeatInterface
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def _handle_canvas_api_error(e: OnyxError) -> NoReturn:
|
||||
"""Map Canvas API errors to connector framework exceptions."""
|
||||
if e.status_code == 401:
|
||||
raise CredentialExpiredError(
|
||||
"Canvas API token is invalid or expired (HTTP 401)."
|
||||
)
|
||||
elif e.status_code == 403:
|
||||
raise InsufficientPermissionsError(
|
||||
"Canvas API token does not have sufficient permissions (HTTP 403)."
|
||||
)
|
||||
elif e.status_code == 429:
|
||||
raise ConnectorValidationError(
|
||||
"Canvas rate-limit exceeded (HTTP 429). Please try again later."
|
||||
)
|
||||
elif e.status_code >= 500:
|
||||
raise UnexpectedValidationError(
|
||||
f"Unexpected Canvas HTTP error (status={e.status_code}): {e}"
|
||||
)
|
||||
else:
|
||||
raise ConnectorValidationError(
|
||||
f"Canvas API error (status={e.status_code}): {e}"
|
||||
)
|
||||
|
||||
|
||||
class CanvasCourse(BaseModel):
|
||||
id: int
|
||||
name: str | None = None
|
||||
course_code: str | None = None
|
||||
created_at: str | None = None
|
||||
workflow_state: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, payload: dict[str, Any]) -> "CanvasCourse":
|
||||
return cls(
|
||||
id=payload["id"],
|
||||
name=payload.get("name"),
|
||||
course_code=payload.get("course_code"),
|
||||
created_at=payload.get("created_at"),
|
||||
workflow_state=payload.get("workflow_state"),
|
||||
)
|
||||
name: str
|
||||
course_code: str
|
||||
created_at: str
|
||||
workflow_state: str
|
||||
|
||||
|
||||
class CanvasPage(BaseModel):
|
||||
@@ -84,22 +19,10 @@ class CanvasPage(BaseModel):
|
||||
url: str
|
||||
title: str
|
||||
body: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
course_id: int
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, payload: dict[str, Any], course_id: int) -> "CanvasPage":
|
||||
return cls(
|
||||
page_id=payload["page_id"],
|
||||
url=payload["url"],
|
||||
title=payload["title"],
|
||||
body=payload.get("body"),
|
||||
created_at=payload.get("created_at"),
|
||||
updated_at=payload.get("updated_at"),
|
||||
course_id=course_id,
|
||||
)
|
||||
|
||||
|
||||
class CanvasAssignment(BaseModel):
|
||||
id: int
|
||||
@@ -107,23 +30,10 @@ class CanvasAssignment(BaseModel):
|
||||
description: str | None = None
|
||||
html_url: str
|
||||
course_id: int
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
due_at: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, payload: dict[str, Any], course_id: int) -> "CanvasAssignment":
|
||||
return cls(
|
||||
id=payload["id"],
|
||||
name=payload["name"],
|
||||
description=payload.get("description"),
|
||||
html_url=payload["html_url"],
|
||||
course_id=course_id,
|
||||
created_at=payload.get("created_at"),
|
||||
updated_at=payload.get("updated_at"),
|
||||
due_at=payload.get("due_at"),
|
||||
)
|
||||
|
||||
|
||||
class CanvasAnnouncement(BaseModel):
|
||||
id: int
|
||||
@@ -133,17 +43,6 @@ class CanvasAnnouncement(BaseModel):
|
||||
posted_at: str | None = None
|
||||
course_id: int
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, payload: dict[str, Any], course_id: int) -> "CanvasAnnouncement":
|
||||
return cls(
|
||||
id=payload["id"],
|
||||
title=payload["title"],
|
||||
message=payload.get("message"),
|
||||
html_url=payload["html_url"],
|
||||
posted_at=payload.get("posted_at"),
|
||||
course_id=course_id,
|
||||
)
|
||||
|
||||
|
||||
CanvasStage: TypeAlias = Literal["pages", "assignments", "announcements"]
|
||||
|
||||
@@ -173,286 +72,3 @@ class CanvasConnectorCheckpoint(ConnectorCheckpoint):
|
||||
self.current_course_index += 1
|
||||
self.stage = "pages"
|
||||
self.next_url = None
|
||||
|
||||
|
||||
class CanvasConnector(
|
||||
CheckpointedConnectorWithPermSync[CanvasConnectorCheckpoint],
|
||||
SlimConnectorWithPermSync,
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
canvas_base_url: str,
|
||||
batch_size: int = INDEX_BATCH_SIZE,
|
||||
) -> None:
|
||||
self.canvas_base_url = canvas_base_url.rstrip("/").removesuffix("/api/v1")
|
||||
self.batch_size = batch_size
|
||||
self._canvas_client: CanvasApiClient | None = None
|
||||
self._course_permissions_cache: dict[int, ExternalAccess | None] = {}
|
||||
|
||||
@property
|
||||
def canvas_client(self) -> CanvasApiClient:
|
||||
if self._canvas_client is None:
|
||||
raise ConnectorMissingCredentialError("Canvas")
|
||||
return self._canvas_client
|
||||
|
||||
def _get_course_permissions(self, course_id: int) -> ExternalAccess | None:
|
||||
"""Get course permissions with caching."""
|
||||
if course_id not in self._course_permissions_cache:
|
||||
self._course_permissions_cache[course_id] = get_course_permissions(
|
||||
canvas_client=self.canvas_client,
|
||||
course_id=course_id,
|
||||
)
|
||||
return self._course_permissions_cache[course_id]
|
||||
|
||||
@retry(tries=3, delay=1, backoff=2)
|
||||
def _list_courses(self) -> list[CanvasCourse]:
|
||||
"""Fetch all courses accessible to the authenticated user."""
|
||||
logger.debug("Fetching Canvas courses")
|
||||
|
||||
courses: list[CanvasCourse] = []
|
||||
for page in self.canvas_client.paginate(
|
||||
"courses", params={"per_page": "100", "state[]": "available"}
|
||||
):
|
||||
courses.extend(CanvasCourse.from_api(c) for c in page)
|
||||
return courses
|
||||
|
||||
@retry(tries=3, delay=1, backoff=2)
|
||||
def _list_pages(self, course_id: int) -> list[CanvasPage]:
|
||||
"""Fetch all pages for a given course."""
|
||||
logger.debug(f"Fetching pages for course {course_id}")
|
||||
|
||||
pages: list[CanvasPage] = []
|
||||
for page in self.canvas_client.paginate(
|
||||
f"courses/{course_id}/pages",
|
||||
params={"per_page": "100", "include[]": "body", "published": "true"},
|
||||
):
|
||||
pages.extend(CanvasPage.from_api(p, course_id=course_id) for p in page)
|
||||
return pages
|
||||
|
||||
@retry(tries=3, delay=1, backoff=2)
|
||||
def _list_assignments(self, course_id: int) -> list[CanvasAssignment]:
|
||||
"""Fetch all assignments for a given course."""
|
||||
logger.debug(f"Fetching assignments for course {course_id}")
|
||||
|
||||
assignments: list[CanvasAssignment] = []
|
||||
for page in self.canvas_client.paginate(
|
||||
f"courses/{course_id}/assignments",
|
||||
params={"per_page": "100", "published": "true"},
|
||||
):
|
||||
assignments.extend(
|
||||
CanvasAssignment.from_api(a, course_id=course_id) for a in page
|
||||
)
|
||||
return assignments
|
||||
|
||||
@retry(tries=3, delay=1, backoff=2)
|
||||
def _list_announcements(self, course_id: int) -> list[CanvasAnnouncement]:
|
||||
"""Fetch all announcements for a given course."""
|
||||
logger.debug(f"Fetching announcements for course {course_id}")
|
||||
|
||||
announcements: list[CanvasAnnouncement] = []
|
||||
for page in self.canvas_client.paginate(
|
||||
"announcements",
|
||||
params={
|
||||
"per_page": "100",
|
||||
"context_codes[]": f"course_{course_id}",
|
||||
"active_only": "true",
|
||||
},
|
||||
):
|
||||
announcements.extend(
|
||||
CanvasAnnouncement.from_api(a, course_id=course_id) for a in page
|
||||
)
|
||||
return announcements
|
||||
|
||||
def _build_document(
|
||||
self,
|
||||
doc_id: str,
|
||||
link: str,
|
||||
text: str,
|
||||
semantic_identifier: str,
|
||||
doc_updated_at: datetime | None,
|
||||
course_id: int,
|
||||
doc_type: str,
|
||||
) -> Document:
|
||||
"""Build a Document with standard Canvas fields."""
|
||||
return Document(
|
||||
id=doc_id,
|
||||
sections=cast(
|
||||
list[TextSection | ImageSection],
|
||||
[TextSection(link=link, text=text)],
|
||||
),
|
||||
source=DocumentSource.CANVAS,
|
||||
semantic_identifier=semantic_identifier,
|
||||
doc_updated_at=doc_updated_at,
|
||||
metadata={"course_id": str(course_id), "type": doc_type},
|
||||
)
|
||||
|
||||
def _convert_page_to_document(self, page: CanvasPage) -> Document:
|
||||
"""Convert a Canvas page to a Document."""
|
||||
link = f"{self.canvas_base_url}/courses/{page.course_id}/pages/{page.url}"
|
||||
|
||||
text_parts = [page.title]
|
||||
body_text = parse_html_page_basic(page.body) if page.body else ""
|
||||
if body_text:
|
||||
text_parts.append(body_text)
|
||||
|
||||
doc_updated_at = (
|
||||
datetime.fromisoformat(page.updated_at.replace("Z", "+00:00")).astimezone(
|
||||
timezone.utc
|
||||
)
|
||||
if page.updated_at
|
||||
else None
|
||||
)
|
||||
|
||||
document = self._build_document(
|
||||
doc_id=f"canvas-page-{page.course_id}-{page.page_id}",
|
||||
link=link,
|
||||
text="\n\n".join(text_parts),
|
||||
semantic_identifier=page.title or f"Page {page.page_id}",
|
||||
doc_updated_at=doc_updated_at,
|
||||
course_id=page.course_id,
|
||||
doc_type="page",
|
||||
)
|
||||
return document
|
||||
|
||||
def _convert_assignment_to_document(self, assignment: CanvasAssignment) -> Document:
|
||||
"""Convert a Canvas assignment to a Document."""
|
||||
text_parts = [assignment.name]
|
||||
desc_text = (
|
||||
parse_html_page_basic(assignment.description)
|
||||
if assignment.description
|
||||
else ""
|
||||
)
|
||||
if desc_text:
|
||||
text_parts.append(desc_text)
|
||||
if assignment.due_at:
|
||||
due_dt = datetime.fromisoformat(
|
||||
assignment.due_at.replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
text_parts.append(f"Due: {due_dt.strftime('%B %d, %Y %H:%M UTC')}")
|
||||
|
||||
doc_updated_at = (
|
||||
datetime.fromisoformat(
|
||||
assignment.updated_at.replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
if assignment.updated_at
|
||||
else None
|
||||
)
|
||||
|
||||
document = self._build_document(
|
||||
doc_id=f"canvas-assignment-{assignment.course_id}-{assignment.id}",
|
||||
link=assignment.html_url,
|
||||
text="\n\n".join(text_parts),
|
||||
semantic_identifier=assignment.name or f"Assignment {assignment.id}",
|
||||
doc_updated_at=doc_updated_at,
|
||||
course_id=assignment.course_id,
|
||||
doc_type="assignment",
|
||||
)
|
||||
return document
|
||||
|
||||
def _convert_announcement_to_document(
|
||||
self, announcement: CanvasAnnouncement
|
||||
) -> Document:
|
||||
"""Convert a Canvas announcement to a Document."""
|
||||
text_parts = [announcement.title]
|
||||
msg_text = (
|
||||
parse_html_page_basic(announcement.message) if announcement.message else ""
|
||||
)
|
||||
if msg_text:
|
||||
text_parts.append(msg_text)
|
||||
|
||||
doc_updated_at = (
|
||||
datetime.fromisoformat(
|
||||
announcement.posted_at.replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
if announcement.posted_at
|
||||
else None
|
||||
)
|
||||
|
||||
document = self._build_document(
|
||||
doc_id=f"canvas-announcement-{announcement.course_id}-{announcement.id}",
|
||||
link=announcement.html_url,
|
||||
text="\n\n".join(text_parts),
|
||||
semantic_identifier=announcement.title or f"Announcement {announcement.id}",
|
||||
doc_updated_at=doc_updated_at,
|
||||
course_id=announcement.course_id,
|
||||
doc_type="announcement",
|
||||
)
|
||||
return document
|
||||
|
||||
@override
|
||||
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Load and validate Canvas credentials."""
|
||||
access_token = credentials.get("canvas_access_token")
|
||||
if not access_token:
|
||||
raise ConnectorMissingCredentialError("Canvas")
|
||||
|
||||
try:
|
||||
client = CanvasApiClient(
|
||||
bearer_token=access_token,
|
||||
canvas_base_url=self.canvas_base_url,
|
||||
)
|
||||
client.get("courses", params={"per_page": "1"})
|
||||
except ValueError as e:
|
||||
raise ConnectorValidationError(f"Invalid Canvas base URL: {e}")
|
||||
except OnyxError as e:
|
||||
_handle_canvas_api_error(e)
|
||||
|
||||
self._canvas_client = client
|
||||
return None
|
||||
|
||||
@override
|
||||
def validate_connector_settings(self) -> None:
|
||||
"""Validate Canvas connector settings by testing API access."""
|
||||
try:
|
||||
self.canvas_client.get("courses", params={"per_page": "1"})
|
||||
logger.info("Canvas connector settings validated successfully")
|
||||
except OnyxError as e:
|
||||
_handle_canvas_api_error(e)
|
||||
except ConnectorMissingCredentialError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedValidationError(
|
||||
f"Unexpected error during Canvas settings validation: {exc}"
|
||||
)
|
||||
|
||||
@override
|
||||
def load_from_checkpoint(
|
||||
self,
|
||||
start: SecondsSinceUnixEpoch,
|
||||
end: SecondsSinceUnixEpoch,
|
||||
checkpoint: CanvasConnectorCheckpoint,
|
||||
) -> CheckpointOutput[CanvasConnectorCheckpoint]:
|
||||
# TODO(benwu408): implemented in PR3 (checkpoint)
|
||||
raise NotImplementedError
|
||||
|
||||
@override
|
||||
def load_from_checkpoint_with_perm_sync(
|
||||
self,
|
||||
start: SecondsSinceUnixEpoch,
|
||||
end: SecondsSinceUnixEpoch,
|
||||
checkpoint: CanvasConnectorCheckpoint,
|
||||
) -> CheckpointOutput[CanvasConnectorCheckpoint]:
|
||||
# TODO(benwu408): implemented in PR3 (checkpoint)
|
||||
raise NotImplementedError
|
||||
|
||||
@override
|
||||
def build_dummy_checkpoint(self) -> CanvasConnectorCheckpoint:
|
||||
# TODO(benwu408): implemented in PR3 (checkpoint)
|
||||
raise NotImplementedError
|
||||
|
||||
@override
|
||||
def validate_checkpoint_json(
|
||||
self, checkpoint_json: str
|
||||
) -> CanvasConnectorCheckpoint:
|
||||
# TODO(benwu408): implemented in PR3 (checkpoint)
|
||||
raise NotImplementedError
|
||||
|
||||
@override
|
||||
def retrieve_all_slim_docs_perm_sync(
|
||||
self,
|
||||
start: SecondsSinceUnixEpoch | None = None,
|
||||
end: SecondsSinceUnixEpoch | None = None,
|
||||
callback: IndexingHeartbeatInterface | None = None,
|
||||
) -> GenerateSlimDocumentOutput:
|
||||
# TODO(benwu408): implemented in PR4 (perm sync)
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -72,10 +72,6 @@ CONNECTOR_CLASS_MAP = {
|
||||
module_path="onyx.connectors.coda.connector",
|
||||
class_name="CodaConnector",
|
||||
),
|
||||
DocumentSource.CANVAS: ConnectorMapping(
|
||||
module_path="onyx.connectors.canvas.connector",
|
||||
class_name="CanvasConnector",
|
||||
),
|
||||
DocumentSource.NOTION: ConnectorMapping(
|
||||
module_path="onyx.connectors.notion.connector",
|
||||
class_name="NotionConnector",
|
||||
|
||||
@@ -439,7 +439,6 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=[StarletteIntegration(), FastApiIntegration()],
|
||||
traces_sample_rate=0.1,
|
||||
release=__version__,
|
||||
)
|
||||
logger.info("Sentry initialized")
|
||||
else:
|
||||
|
||||
@@ -735,7 +735,7 @@ pyee==13.0.0
|
||||
# via playwright
|
||||
pygithub==2.5.0
|
||||
# via onyx
|
||||
pygments==2.20.0
|
||||
pygments==2.19.2
|
||||
# via rich
|
||||
pyjwt==2.12.0
|
||||
# via
|
||||
|
||||
@@ -349,7 +349,7 @@ pydantic-core==2.33.2
|
||||
# via pydantic
|
||||
pydantic-settings==2.12.0
|
||||
# via mcp
|
||||
pygments==2.20.0
|
||||
pygments==2.19.2
|
||||
# via
|
||||
# ipython
|
||||
# ipython-pygments-lexers
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
"""Tests for Canvas connector — client, credentials, conversion."""
|
||||
"""Tests for Canvas connector — client (PR1)."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.canvas.client import CanvasApiClient
|
||||
from onyx.connectors.canvas.connector import CanvasConnector
|
||||
from onyx.connectors.exceptions import ConnectorValidationError
|
||||
from onyx.connectors.exceptions import CredentialExpiredError
|
||||
from onyx.connectors.exceptions import InsufficientPermissionsError
|
||||
from onyx.connectors.exceptions import UnexpectedValidationError
|
||||
from onyx.connectors.models import ConnectorMissingCredentialError
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -26,77 +18,6 @@ FAKE_BASE_URL = "https://myschool.instructure.com"
|
||||
FAKE_TOKEN = "fake-canvas-token"
|
||||
|
||||
|
||||
def _mock_course(
|
||||
course_id: int = 1,
|
||||
name: str = "Intro to CS",
|
||||
course_code: str = "CS101",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": course_id,
|
||||
"name": name,
|
||||
"course_code": course_code,
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"workflow_state": "available",
|
||||
}
|
||||
|
||||
|
||||
def _build_connector(base_url: str = FAKE_BASE_URL) -> CanvasConnector:
|
||||
"""Build a connector with mocked credential validation."""
|
||||
with patch("onyx.connectors.canvas.client.rl_requests") as mock_req:
|
||||
mock_req.get.return_value = _mock_response(json_data=[_mock_course()])
|
||||
connector = CanvasConnector(canvas_base_url=base_url)
|
||||
connector.load_credentials({"canvas_access_token": FAKE_TOKEN})
|
||||
return connector
|
||||
|
||||
|
||||
def _mock_page(
|
||||
page_id: int = 10,
|
||||
title: str = "Syllabus",
|
||||
updated_at: str = "2025-06-01T12:00:00Z",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"url": "syllabus",
|
||||
"title": title,
|
||||
"body": "<p>Welcome to the course</p>",
|
||||
"created_at": "2025-01-15T00:00:00Z",
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _mock_assignment(
|
||||
assignment_id: int = 20,
|
||||
name: str = "Homework 1",
|
||||
course_id: int = 1,
|
||||
updated_at: str = "2025-06-01T12:00:00Z",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": assignment_id,
|
||||
"name": name,
|
||||
"description": "<p>Solve these problems</p>",
|
||||
"html_url": f"{FAKE_BASE_URL}/courses/{course_id}/assignments/{assignment_id}",
|
||||
"course_id": course_id,
|
||||
"created_at": "2025-01-20T00:00:00Z",
|
||||
"updated_at": updated_at,
|
||||
"due_at": "2025-02-01T23:59:00Z",
|
||||
}
|
||||
|
||||
|
||||
def _mock_announcement(
|
||||
announcement_id: int = 30,
|
||||
title: str = "Class Cancelled",
|
||||
course_id: int = 1,
|
||||
posted_at: str = "2025-06-01T12:00:00Z",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": announcement_id,
|
||||
"title": title,
|
||||
"message": "<p>No class today</p>",
|
||||
"html_url": f"{FAKE_BASE_URL}/courses/{course_id}/discussion_topics/{announcement_id}",
|
||||
"posted_at": posted_at,
|
||||
}
|
||||
|
||||
|
||||
def _mock_response(
|
||||
status_code: int = 200,
|
||||
json_data: Any = None,
|
||||
@@ -404,57 +325,6 @@ class TestGet:
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CanvasApiClient.paginate tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPaginate:
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_single_page(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(
|
||||
json_data=[{"id": 1}, {"id": 2}]
|
||||
)
|
||||
client = CanvasApiClient(
|
||||
bearer_token=FAKE_TOKEN,
|
||||
canvas_base_url=FAKE_BASE_URL,
|
||||
)
|
||||
|
||||
pages = list(client.paginate("courses"))
|
||||
|
||||
assert len(pages) == 1
|
||||
assert pages[0] == [{"id": 1}, {"id": 2}]
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_two_pages(self, mock_requests: MagicMock) -> None:
|
||||
next_link = f'<{FAKE_BASE_URL}/api/v1/courses?page=2>; rel="next"'
|
||||
page1 = _mock_response(json_data=[{"id": 1}], link_header=next_link)
|
||||
page2 = _mock_response(json_data=[{"id": 2}])
|
||||
mock_requests.get.side_effect = [page1, page2]
|
||||
client = CanvasApiClient(
|
||||
bearer_token=FAKE_TOKEN,
|
||||
canvas_base_url=FAKE_BASE_URL,
|
||||
)
|
||||
|
||||
pages = list(client.paginate("courses"))
|
||||
|
||||
assert len(pages) == 2
|
||||
assert pages[0] == [{"id": 1}]
|
||||
assert pages[1] == [{"id": 2}]
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_empty_response(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(json_data=[])
|
||||
client = CanvasApiClient(
|
||||
bearer_token=FAKE_TOKEN,
|
||||
canvas_base_url=FAKE_BASE_URL,
|
||||
)
|
||||
|
||||
pages = list(client.paginate("courses"))
|
||||
|
||||
assert pages == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CanvasApiClient._parse_next_link tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -509,368 +379,3 @@ class TestParseNextLink:
|
||||
|
||||
with pytest.raises(OnyxError, match="must use https"):
|
||||
self.client._parse_next_link(header)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CanvasConnector — credential loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadCredentials:
|
||||
def _assert_load_credentials_raises(
|
||||
self,
|
||||
status_code: int,
|
||||
expected_error: type[Exception],
|
||||
mock_requests: MagicMock,
|
||||
) -> None:
|
||||
"""Helper: assert load_credentials raises expected_error for a given status."""
|
||||
mock_requests.get.return_value = _mock_response(status_code, {})
|
||||
connector = CanvasConnector(canvas_base_url=FAKE_BASE_URL)
|
||||
with pytest.raises(expected_error):
|
||||
connector.load_credentials({"canvas_access_token": FAKE_TOKEN})
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_load_credentials_success(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(json_data=[_mock_course()])
|
||||
connector = CanvasConnector(canvas_base_url=FAKE_BASE_URL)
|
||||
|
||||
result = connector.load_credentials({"canvas_access_token": FAKE_TOKEN})
|
||||
|
||||
assert result is None
|
||||
assert connector._canvas_client is not None
|
||||
|
||||
def test_canvas_client_raises_without_credentials(self) -> None:
|
||||
connector = CanvasConnector(canvas_base_url=FAKE_BASE_URL)
|
||||
|
||||
with pytest.raises(ConnectorMissingCredentialError):
|
||||
_ = connector.canvas_client
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_load_credentials_invalid_token(self, mock_requests: MagicMock) -> None:
|
||||
self._assert_load_credentials_raises(401, CredentialExpiredError, mock_requests)
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_load_credentials_insufficient_permissions(
|
||||
self, mock_requests: MagicMock
|
||||
) -> None:
|
||||
self._assert_load_credentials_raises(
|
||||
403, InsufficientPermissionsError, mock_requests
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CanvasConnector — URL normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConnectorUrlNormalization:
|
||||
def test_strips_api_v1_suffix(self) -> None:
|
||||
connector = _build_connector(base_url=f"{FAKE_BASE_URL}/api/v1")
|
||||
|
||||
result = connector.canvas_base_url
|
||||
expected = FAKE_BASE_URL
|
||||
|
||||
assert result == expected
|
||||
|
||||
def test_strips_trailing_slash(self) -> None:
|
||||
connector = _build_connector(base_url=f"{FAKE_BASE_URL}/")
|
||||
|
||||
result = connector.canvas_base_url
|
||||
expected = FAKE_BASE_URL
|
||||
|
||||
assert result == expected
|
||||
|
||||
def test_no_change_for_clean_url(self) -> None:
|
||||
connector = _build_connector(base_url=FAKE_BASE_URL)
|
||||
|
||||
result = connector.canvas_base_url
|
||||
expected = FAKE_BASE_URL
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CanvasConnector — document conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDocumentConversion:
|
||||
def setup_method(self) -> None:
|
||||
self.connector = _build_connector()
|
||||
|
||||
def test_convert_page_to_document(self) -> None:
|
||||
from onyx.connectors.canvas.connector import CanvasPage
|
||||
|
||||
page = CanvasPage(
|
||||
page_id=10,
|
||||
url="syllabus",
|
||||
title="Syllabus",
|
||||
body="<p>Welcome</p>",
|
||||
created_at="2025-01-15T00:00:00Z",
|
||||
updated_at="2025-06-01T12:00:00Z",
|
||||
course_id=1,
|
||||
)
|
||||
|
||||
doc = self.connector._convert_page_to_document(page)
|
||||
|
||||
expected_id = "canvas-page-1-10"
|
||||
expected_metadata = {"course_id": "1", "type": "page"}
|
||||
expected_updated_at = datetime(2025, 6, 1, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
assert doc.id == expected_id
|
||||
assert doc.source == DocumentSource.CANVAS
|
||||
assert doc.semantic_identifier == "Syllabus"
|
||||
assert doc.metadata == expected_metadata
|
||||
assert doc.sections[0].link is not None
|
||||
assert f"{FAKE_BASE_URL}/courses/1/pages/syllabus" in doc.sections[0].link
|
||||
assert doc.doc_updated_at == expected_updated_at
|
||||
|
||||
def test_convert_page_without_body(self) -> None:
|
||||
from onyx.connectors.canvas.connector import CanvasPage
|
||||
|
||||
page = CanvasPage(
|
||||
page_id=11,
|
||||
url="empty-page",
|
||||
title="Empty Page",
|
||||
body=None,
|
||||
created_at="2025-01-15T00:00:00Z",
|
||||
updated_at="2025-06-01T12:00:00Z",
|
||||
course_id=1,
|
||||
)
|
||||
|
||||
doc = self.connector._convert_page_to_document(page)
|
||||
section_text = doc.sections[0].text
|
||||
assert section_text is not None
|
||||
|
||||
assert "Empty Page" in section_text
|
||||
assert "<p>" not in section_text
|
||||
|
||||
def test_convert_assignment_to_document(self) -> None:
|
||||
from onyx.connectors.canvas.connector import CanvasAssignment
|
||||
|
||||
assignment = CanvasAssignment(
|
||||
id=20,
|
||||
name="Homework 1",
|
||||
description="<p>Solve these</p>",
|
||||
html_url=f"{FAKE_BASE_URL}/courses/1/assignments/20",
|
||||
course_id=1,
|
||||
created_at="2025-01-20T00:00:00Z",
|
||||
updated_at="2025-06-01T12:00:00Z",
|
||||
due_at="2025-02-01T23:59:00Z",
|
||||
)
|
||||
|
||||
doc = self.connector._convert_assignment_to_document(assignment)
|
||||
|
||||
expected_id = "canvas-assignment-1-20"
|
||||
expected_due_text = "Due: February 01, 2025 23:59 UTC"
|
||||
|
||||
assert doc.id == expected_id
|
||||
assert doc.source == DocumentSource.CANVAS
|
||||
assert doc.semantic_identifier == "Homework 1"
|
||||
assert doc.sections[0].text is not None
|
||||
assert expected_due_text in doc.sections[0].text
|
||||
|
||||
def test_convert_assignment_without_description(self) -> None:
|
||||
from onyx.connectors.canvas.connector import CanvasAssignment
|
||||
|
||||
assignment = CanvasAssignment(
|
||||
id=21,
|
||||
name="Quiz 1",
|
||||
description=None,
|
||||
html_url=f"{FAKE_BASE_URL}/courses/1/assignments/21",
|
||||
course_id=1,
|
||||
created_at="2025-01-20T00:00:00Z",
|
||||
updated_at="2025-06-01T12:00:00Z",
|
||||
due_at=None,
|
||||
)
|
||||
|
||||
doc = self.connector._convert_assignment_to_document(assignment)
|
||||
section_text = doc.sections[0].text
|
||||
assert section_text is not None
|
||||
|
||||
assert "Quiz 1" in section_text
|
||||
assert "Due:" not in section_text
|
||||
|
||||
def test_convert_announcement_to_document(self) -> None:
|
||||
from onyx.connectors.canvas.connector import CanvasAnnouncement
|
||||
|
||||
announcement = CanvasAnnouncement(
|
||||
id=30,
|
||||
title="Class Cancelled",
|
||||
message="<p>No class today</p>",
|
||||
html_url=f"{FAKE_BASE_URL}/courses/1/discussion_topics/30",
|
||||
posted_at="2025-06-01T12:00:00Z",
|
||||
course_id=1,
|
||||
)
|
||||
|
||||
doc = self.connector._convert_announcement_to_document(announcement)
|
||||
|
||||
expected_id = "canvas-announcement-1-30"
|
||||
expected_updated_at = datetime(2025, 6, 1, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
assert doc.id == expected_id
|
||||
assert doc.source == DocumentSource.CANVAS
|
||||
assert doc.semantic_identifier == "Class Cancelled"
|
||||
assert doc.doc_updated_at == expected_updated_at
|
||||
|
||||
def test_convert_announcement_without_posted_at(self) -> None:
|
||||
from onyx.connectors.canvas.connector import CanvasAnnouncement
|
||||
|
||||
announcement = CanvasAnnouncement(
|
||||
id=31,
|
||||
title="TBD Announcement",
|
||||
message=None,
|
||||
html_url=f"{FAKE_BASE_URL}/courses/1/discussion_topics/31",
|
||||
posted_at=None,
|
||||
course_id=1,
|
||||
)
|
||||
|
||||
doc = self.connector._convert_announcement_to_document(announcement)
|
||||
|
||||
assert doc.doc_updated_at is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CanvasConnector — validate_connector_settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateConnectorSettings:
|
||||
def _assert_validate_raises(
|
||||
self,
|
||||
status_code: int,
|
||||
expected_error: type[Exception],
|
||||
mock_requests: MagicMock,
|
||||
) -> None:
|
||||
"""Helper: assert validate_connector_settings raises expected_error."""
|
||||
success_resp = _mock_response(json_data=[_mock_course()])
|
||||
fail_resp = _mock_response(status_code, {})
|
||||
mock_requests.get.side_effect = [success_resp, fail_resp]
|
||||
connector = CanvasConnector(canvas_base_url=FAKE_BASE_URL)
|
||||
connector.load_credentials({"canvas_access_token": FAKE_TOKEN})
|
||||
with pytest.raises(expected_error):
|
||||
connector.validate_connector_settings()
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_validate_success(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(json_data=[_mock_course()])
|
||||
connector = _build_connector()
|
||||
|
||||
connector.validate_connector_settings() # should not raise
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_validate_expired_credential(self, mock_requests: MagicMock) -> None:
|
||||
self._assert_validate_raises(401, CredentialExpiredError, mock_requests)
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_validate_insufficient_permissions(self, mock_requests: MagicMock) -> None:
|
||||
self._assert_validate_raises(403, InsufficientPermissionsError, mock_requests)
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_validate_rate_limited(self, mock_requests: MagicMock) -> None:
|
||||
self._assert_validate_raises(429, ConnectorValidationError, mock_requests)
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_validate_unexpected_error(self, mock_requests: MagicMock) -> None:
|
||||
self._assert_validate_raises(500, UnexpectedValidationError, mock_requests)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _list_* pagination tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListCourses:
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_single_page(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(
|
||||
json_data=[_mock_course(1), _mock_course(2, "CS201", "Data Structures")]
|
||||
)
|
||||
connector = _build_connector()
|
||||
|
||||
result = connector._list_courses()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].id == 1
|
||||
assert result[1].id == 2
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_empty_response(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(json_data=[])
|
||||
connector = _build_connector()
|
||||
|
||||
result = connector._list_courses()
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestListPages:
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_single_page(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(
|
||||
json_data=[_mock_page(10), _mock_page(11, "Notes")]
|
||||
)
|
||||
connector = _build_connector()
|
||||
|
||||
result = connector._list_pages(course_id=1)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].page_id == 10
|
||||
assert result[1].page_id == 11
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_empty_response(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(json_data=[])
|
||||
connector = _build_connector()
|
||||
|
||||
result = connector._list_pages(course_id=1)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestListAssignments:
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_single_page(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(
|
||||
json_data=[_mock_assignment(20), _mock_assignment(21, "Quiz 1")]
|
||||
)
|
||||
connector = _build_connector()
|
||||
|
||||
result = connector._list_assignments(course_id=1)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].id == 20
|
||||
assert result[1].id == 21
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_empty_response(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(json_data=[])
|
||||
connector = _build_connector()
|
||||
|
||||
result = connector._list_assignments(course_id=1)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestListAnnouncements:
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_single_page(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(
|
||||
json_data=[_mock_announcement(30), _mock_announcement(31, "Update")]
|
||||
)
|
||||
connector = _build_connector()
|
||||
|
||||
result = connector._list_announcements(course_id=1)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].id == 30
|
||||
assert result[1].id == 31
|
||||
|
||||
@patch("onyx.connectors.canvas.client.rl_requests")
|
||||
def test_empty_response(self, mock_requests: MagicMock) -> None:
|
||||
mock_requests.get.return_value = _mock_response(json_data=[])
|
||||
connector = _build_connector()
|
||||
|
||||
result = connector._list_announcements(course_id=1)
|
||||
|
||||
assert result == []
|
||||
|
||||
@@ -5,7 +5,7 @@ home: https://www.onyx.app/
|
||||
sources:
|
||||
- "https://github.com/onyx-dot-app/onyx"
|
||||
type: application
|
||||
version: 0.4.39
|
||||
version: 0.4.38
|
||||
appVersion: latest
|
||||
annotations:
|
||||
category: Productivity
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,77 +0,0 @@
|
||||
{{- if and .Values.monitoring.serviceMonitors.enabled .Values.vectorDB.enabled }}
|
||||
{{- if gt (int .Values.celery_worker_monitoring.replicaCount) 0 }}
|
||||
---
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "onyx.fullname" . }}-celery-worker-monitoring
|
||||
labels:
|
||||
{{- include "onyx.labels" . | nindent 4 }}
|
||||
{{- with .Values.monitoring.serviceMonitors.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- {{ .Release.Namespace }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Values.celery_worker_monitoring.deploymentLabels.app }}
|
||||
metrics: "true"
|
||||
endpoints:
|
||||
- port: metrics
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
{{- end }}
|
||||
{{- if gt (int .Values.celery_worker_docfetching.replicaCount) 0 }}
|
||||
---
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "onyx.fullname" . }}-celery-worker-docfetching
|
||||
labels:
|
||||
{{- include "onyx.labels" . | nindent 4 }}
|
||||
{{- with .Values.monitoring.serviceMonitors.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- {{ .Release.Namespace }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Values.celery_worker_docfetching.deploymentLabels.app }}
|
||||
metrics: "true"
|
||||
endpoints:
|
||||
- port: metrics
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
{{- end }}
|
||||
{{- if gt (int .Values.celery_worker_docprocessing.replicaCount) 0 }}
|
||||
---
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "onyx.fullname" . }}-celery-worker-docprocessing
|
||||
labels:
|
||||
{{- include "onyx.labels" . | nindent 4 }}
|
||||
{{- with .Values.monitoring.serviceMonitors.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- {{ .Release.Namespace }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Values.celery_worker_docprocessing.deploymentLabels.app }}
|
||||
metrics: "true"
|
||||
endpoints:
|
||||
- port: metrics
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,15 +0,0 @@
|
||||
{{- if .Values.monitoring.grafana.dashboards.enabled }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "onyx.fullname" . }}-indexing-pipeline-dashboard
|
||||
labels:
|
||||
{{- include "onyx.labels" . | nindent 4 }}
|
||||
grafana_dashboard: "1"
|
||||
annotations:
|
||||
grafana_folder: "Onyx"
|
||||
data:
|
||||
onyx-indexing-pipeline.json: |
|
||||
{{- .Files.Get "dashboards/indexing-pipeline.json" | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -256,20 +256,6 @@ tooling:
|
||||
# -- Which client binary to call; change if your image uses a non-default path.
|
||||
psqlBinary: psql
|
||||
|
||||
monitoring:
|
||||
grafana:
|
||||
dashboards:
|
||||
# -- Set to true to deploy Grafana dashboard ConfigMaps for the Onyx indexing pipeline.
|
||||
# Requires kube-prometheus-stack (or equivalent) with the Grafana sidecar enabled and watching this namespace.
|
||||
# The sidecar must be configured with label selector: grafana_dashboard=1
|
||||
enabled: false
|
||||
serviceMonitors:
|
||||
# -- Set to true to deploy ServiceMonitor resources for Celery worker metrics endpoints.
|
||||
# Requires the Prometheus Operator CRDs (included in kube-prometheus-stack).
|
||||
# Use `labels` to match your Prometheus CR's serviceMonitorSelector (e.g. release: onyx-monitoring).
|
||||
enabled: false
|
||||
labels: {}
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: false
|
||||
|
||||
@@ -19,10 +19,6 @@ module "eks" {
|
||||
cluster_endpoint_public_access_cidrs = var.cluster_endpoint_public_access_cidrs
|
||||
enable_cluster_creator_admin_permissions = true
|
||||
|
||||
# Control plane logging
|
||||
cluster_enabled_log_types = var.cluster_enabled_log_types
|
||||
cloudwatch_log_group_retention_in_days = var.cloudwatch_log_group_retention_in_days
|
||||
|
||||
eks_managed_node_group_defaults = {
|
||||
ami_type = "AL2023_x86_64_STANDARD"
|
||||
}
|
||||
|
||||
@@ -161,25 +161,3 @@ variable "rds_db_connect_arn" {
|
||||
description = "Full rds-db:connect ARN to allow (required when enable_rds_iam_for_service_account is true)"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "cluster_enabled_log_types" {
|
||||
type = list(string)
|
||||
description = "EKS control plane log types to enable (valid: api, audit, authenticator, controllerManager, scheduler)"
|
||||
default = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
|
||||
|
||||
validation {
|
||||
condition = alltrue([for t in var.cluster_enabled_log_types : contains(["api", "audit", "authenticator", "controllerManager", "scheduler"], t)])
|
||||
error_message = "Each entry must be one of: api, audit, authenticator, controllerManager, scheduler."
|
||||
}
|
||||
}
|
||||
|
||||
variable "cloudwatch_log_group_retention_in_days" {
|
||||
type = number
|
||||
description = "Number of days to retain EKS control plane logs in CloudWatch (0 = never expire)"
|
||||
default = 30
|
||||
|
||||
validation {
|
||||
condition = contains([0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.cloudwatch_log_group_retention_in_days)
|
||||
error_message = "Must be a valid CloudWatch retention value (0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +54,6 @@ module "postgres" {
|
||||
password = var.postgres_password
|
||||
tags = local.merged_tags
|
||||
enable_rds_iam_auth = var.enable_iam_auth
|
||||
|
||||
backup_retention_period = var.postgres_backup_retention_period
|
||||
backup_window = var.postgres_backup_window
|
||||
}
|
||||
|
||||
module "s3" {
|
||||
@@ -83,10 +80,6 @@ module "eks" {
|
||||
public_cluster_enabled = var.public_cluster_enabled
|
||||
private_cluster_enabled = var.private_cluster_enabled
|
||||
cluster_endpoint_public_access_cidrs = var.cluster_endpoint_public_access_cidrs
|
||||
|
||||
# Control plane logging
|
||||
cluster_enabled_log_types = var.eks_cluster_enabled_log_types
|
||||
cloudwatch_log_group_retention_in_days = var.eks_cloudwatch_log_group_retention_in_days
|
||||
}
|
||||
|
||||
module "waf" {
|
||||
|
||||
@@ -250,34 +250,3 @@ variable "opensearch_subnet_ids" {
|
||||
description = "Subnet IDs for OpenSearch. If empty, uses first 3 private subnets."
|
||||
default = []
|
||||
}
|
||||
|
||||
# RDS Backup Configuration
|
||||
variable "postgres_backup_retention_period" {
|
||||
type = number
|
||||
description = "Number of days to retain automated RDS backups (0 to disable)"
|
||||
default = 7
|
||||
}
|
||||
|
||||
variable "postgres_backup_window" {
|
||||
type = string
|
||||
description = "Preferred UTC time window for automated RDS backups (hh24:mi-hh24:mi)"
|
||||
default = "03:00-04:00"
|
||||
}
|
||||
|
||||
# EKS Control Plane Logging
|
||||
variable "eks_cluster_enabled_log_types" {
|
||||
type = list(string)
|
||||
description = "EKS control plane log types to enable (valid: api, audit, authenticator, controllerManager, scheduler)"
|
||||
default = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
|
||||
}
|
||||
|
||||
variable "eks_cloudwatch_log_group_retention_in_days" {
|
||||
type = number
|
||||
description = "Number of days to retain EKS control plane logs in CloudWatch (0 = never expire)"
|
||||
default = 30
|
||||
|
||||
validation {
|
||||
condition = contains([0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.eks_cloudwatch_log_group_retention_in_days)
|
||||
error_message = "Must be a valid CloudWatch retention value (0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,56 +44,5 @@ resource "aws_db_instance" "this" {
|
||||
publicly_accessible = false
|
||||
deletion_protection = true
|
||||
storage_encrypted = true
|
||||
|
||||
# Automated backups
|
||||
backup_retention_period = var.backup_retention_period
|
||||
backup_window = var.backup_window
|
||||
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
# CloudWatch alarm for CPU utilization monitoring
|
||||
resource "aws_cloudwatch_metric_alarm" "cpu_utilization" {
|
||||
alarm_name = "${var.identifier}-cpu-utilization"
|
||||
alarm_description = "RDS CPU utilization for ${var.identifier}"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = var.cpu_alarm_evaluation_periods
|
||||
metric_name = "CPUUtilization"
|
||||
namespace = "AWS/RDS"
|
||||
period = var.cpu_alarm_period
|
||||
statistic = "Average"
|
||||
threshold = var.cpu_alarm_threshold
|
||||
treat_missing_data = "missing"
|
||||
|
||||
alarm_actions = var.alarm_actions
|
||||
ok_actions = var.alarm_actions
|
||||
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = aws_db_instance.this.identifier
|
||||
}
|
||||
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
# CloudWatch alarm for freeable memory monitoring
|
||||
resource "aws_cloudwatch_metric_alarm" "freeable_memory" {
|
||||
alarm_name = "${var.identifier}-freeable-memory"
|
||||
alarm_description = "RDS freeable memory for ${var.identifier}"
|
||||
comparison_operator = "LessThanThreshold"
|
||||
evaluation_periods = var.memory_alarm_evaluation_periods
|
||||
metric_name = "FreeableMemory"
|
||||
namespace = "AWS/RDS"
|
||||
period = var.memory_alarm_period
|
||||
statistic = "Average"
|
||||
threshold = var.memory_alarm_threshold
|
||||
treat_missing_data = "missing"
|
||||
|
||||
alarm_actions = var.alarm_actions
|
||||
ok_actions = var.alarm_actions
|
||||
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = aws_db_instance.this.identifier
|
||||
}
|
||||
|
||||
tags = var.tags
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
@@ -67,98 +67,3 @@ variable "enable_rds_iam_auth" {
|
||||
description = "Enable AWS IAM database authentication for this RDS instance"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "backup_retention_period" {
|
||||
type = number
|
||||
description = "Number of days to retain automated backups (0 to disable)"
|
||||
default = 7
|
||||
|
||||
validation {
|
||||
condition = var.backup_retention_period >= 0 && var.backup_retention_period <= 35
|
||||
error_message = "backup_retention_period must be between 0 and 35 (AWS RDS limit)."
|
||||
}
|
||||
}
|
||||
|
||||
variable "backup_window" {
|
||||
type = string
|
||||
description = "Preferred UTC time window for automated backups (hh24:mi-hh24:mi)"
|
||||
default = "03:00-04:00"
|
||||
|
||||
validation {
|
||||
condition = can(regex("^([01]\\d|2[0-3]):[0-5]\\d-([01]\\d|2[0-3]):[0-5]\\d$", var.backup_window))
|
||||
error_message = "backup_window must be in hh24:mi-hh24:mi format (e.g. \"03:00-04:00\")."
|
||||
}
|
||||
}
|
||||
|
||||
# CloudWatch CPU alarm configuration
|
||||
variable "cpu_alarm_threshold" {
|
||||
type = number
|
||||
description = "CPU utilization percentage threshold for the CloudWatch alarm"
|
||||
default = 80
|
||||
|
||||
validation {
|
||||
condition = var.cpu_alarm_threshold >= 0 && var.cpu_alarm_threshold <= 100
|
||||
error_message = "cpu_alarm_threshold must be between 0 and 100 (percentage)."
|
||||
}
|
||||
}
|
||||
|
||||
variable "cpu_alarm_evaluation_periods" {
|
||||
type = number
|
||||
description = "Number of consecutive periods the threshold must be breached before alarming"
|
||||
default = 3
|
||||
|
||||
validation {
|
||||
condition = var.cpu_alarm_evaluation_periods >= 1
|
||||
error_message = "cpu_alarm_evaluation_periods must be at least 1."
|
||||
}
|
||||
}
|
||||
|
||||
variable "cpu_alarm_period" {
|
||||
type = number
|
||||
description = "Period in seconds over which the CPU metric is evaluated"
|
||||
default = 300
|
||||
|
||||
validation {
|
||||
condition = var.cpu_alarm_period >= 60 && var.cpu_alarm_period % 60 == 0
|
||||
error_message = "cpu_alarm_period must be a multiple of 60 seconds and at least 60 (CloudWatch requirement)."
|
||||
}
|
||||
}
|
||||
|
||||
variable "memory_alarm_threshold" {
|
||||
type = number
|
||||
description = "Freeable memory threshold in bytes. Alarm fires when memory drops below this value."
|
||||
default = 256000000 # 256 MB
|
||||
|
||||
validation {
|
||||
condition = var.memory_alarm_threshold > 0
|
||||
error_message = "memory_alarm_threshold must be greater than 0."
|
||||
}
|
||||
}
|
||||
|
||||
variable "memory_alarm_evaluation_periods" {
|
||||
type = number
|
||||
description = "Number of consecutive periods the threshold must be breached before alarming"
|
||||
default = 3
|
||||
|
||||
validation {
|
||||
condition = var.memory_alarm_evaluation_periods >= 1
|
||||
error_message = "memory_alarm_evaluation_periods must be at least 1."
|
||||
}
|
||||
}
|
||||
|
||||
variable "memory_alarm_period" {
|
||||
type = number
|
||||
description = "Period in seconds over which the freeable memory metric is evaluated"
|
||||
default = 300
|
||||
|
||||
validation {
|
||||
condition = var.memory_alarm_period >= 60 && var.memory_alarm_period % 60 == 0
|
||||
error_message = "memory_alarm_period must be a multiple of 60 seconds and at least 60 (CloudWatch requirement)."
|
||||
}
|
||||
}
|
||||
|
||||
variable "alarm_actions" {
|
||||
type = list(string)
|
||||
description = "List of ARNs to notify when the alarm transitions state (e.g. SNS topic ARNs)"
|
||||
default = []
|
||||
}
|
||||
|
||||
6
uv.lock
generated
6
uv.lock
generated
@@ -5634,11 +5634,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -73,17 +73,11 @@ ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||
ARG NEXT_PUBLIC_RECAPTCHA_SITE_KEY
|
||||
ENV NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${NEXT_PUBLIC_RECAPTCHA_SITE_KEY}
|
||||
|
||||
ARG SENTRY_RELEASE
|
||||
ENV SENTRY_RELEASE=${SENTRY_RELEASE}
|
||||
|
||||
# Add NODE_OPTIONS argument
|
||||
ARG NODE_OPTIONS
|
||||
|
||||
# SENTRY_AUTH_TOKEN is injected via BuildKit secret mount so it is never written
|
||||
# to any image layer, build cache, or registry manifest.
|
||||
# Use NODE_OPTIONS in the build command
|
||||
RUN --mount=type=secret,id=sentry_auth_token,env=SENTRY_AUTH_TOKEN \
|
||||
NODE_OPTIONS="${NODE_OPTIONS}" npx next build
|
||||
RUN NODE_OPTIONS="${NODE_OPTIONS}" npx next build
|
||||
|
||||
# Step 2. Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
@@ -156,9 +150,6 @@ ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||
ARG NEXT_PUBLIC_RECAPTCHA_SITE_KEY
|
||||
ENV NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${NEXT_PUBLIC_RECAPTCHA_SITE_KEY}
|
||||
|
||||
ARG SENTRY_RELEASE
|
||||
ENV SENTRY_RELEASE=${SENTRY_RELEASE}
|
||||
|
||||
# Default ONYX_VERSION, typically overriden during builds by GitHub Actions.
|
||||
ARG ONYX_VERSION=0.0.0-dev
|
||||
ENV ONYX_VERSION=${ONYX_VERSION}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# SelectCard
|
||||
|
||||
**Import:** `import { SelectCard, type SelectCardProps } from "@opal/components";`
|
||||
|
||||
A stateful interactive card — the card counterpart to [`SelectButton`](../../buttons/select-button/README.md). Built on `Interactive.Stateful` (Slot) with a structural `<div>` that owns padding, rounding, border, and overflow.
|
||||
|
||||
## Relationship to Card
|
||||
|
||||
`Card` is a plain, non-interactive container. `SelectCard` adds stateful interactivity (hover, active, disabled, state-driven colors) by wrapping its root div with `Interactive.Stateful`. The relationship mirrors `Button` (stateless) vs `SelectButton` (stateful).
|
||||
|
||||
## Relationship to SelectButton
|
||||
|
||||
SelectCard and SelectButton share the same call stack:
|
||||
|
||||
```
|
||||
Interactive.Stateful → structural element → content
|
||||
```
|
||||
|
||||
The key differences:
|
||||
|
||||
- SelectCard renders a `<div>` (not `Interactive.Container`) — cards have their own rounding scale (one notch larger than buttons) and don't need Container's height/min-width.
|
||||
- SelectCard has no `foldable` prop — use `Interactive.Foldable` directly inside children.
|
||||
- SelectCard's children are fully composable — use `CardHeaderLayout`, `ContentAction`, `Content`, buttons, etc. inside.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Interactive.Stateful <- variant, state, interaction, disabled, onClick
|
||||
└─ div.opal-select-card <- padding, rounding, border, overflow
|
||||
└─ children (composable)
|
||||
```
|
||||
|
||||
The `Interactive.Stateful` Slot merges onto the div, producing a single DOM element with both `.opal-select-card` and `.interactive` classes plus `data-interactive-*` attributes. This activates the Stateful color matrix for backgrounds and `--interactive-foreground` / `--interactive-foreground-icon` CSS properties for descendants.
|
||||
|
||||
## Props
|
||||
|
||||
Inherits **all** props from `InteractiveStatefulProps` (variant, state, interaction, onClick, href, etc.) plus:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `sizeVariant` | `ContainerSizeVariants` | `"lg"` | Controls padding and border-radius |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
| `children` | `React.ReactNode` | — | Card content |
|
||||
|
||||
### Rounding scale
|
||||
|
||||
Cards use a bumped-up rounding scale compared to buttons:
|
||||
|
||||
| Size | Rounding | Effective radius |
|
||||
|---|---|---|
|
||||
| `lg` | `rounded-16` | 1rem (16px) |
|
||||
| `md`–`sm` | `rounded-12` | 0.75rem (12px) |
|
||||
| `xs`–`2xs` | `rounded-08` | 0.5rem (8px) |
|
||||
| `fit` | `rounded-16` | 1rem (16px) |
|
||||
|
||||
### Recommended variant: `select-card`
|
||||
|
||||
The `select-card` Interactive.Stateful variant is specifically designed for cards. Unlike `select-heavy` (which only changes foreground color between empty and filled), `select-card` gives the filled state a visible background — important on larger surfaces where background carries more of the visual distinction.
|
||||
|
||||
| State | Rest background | Rest foreground |
|
||||
|---|---|---|
|
||||
| `empty` | transparent | `text-04` / icon `text-03` |
|
||||
| `filled` | `background-tint-00` | `text-04` / icon `text-03` |
|
||||
| `selected` | `action-link-01` | `action-link-05` |
|
||||
|
||||
The selected state also gets a `border-action-link-05` via SelectCard's CSS.
|
||||
|
||||
## CSS
|
||||
|
||||
SelectCard's stylesheet (`styles.css`) provides:
|
||||
|
||||
- `w-full overflow-clip border` on all states
|
||||
- `border-action-link-05` when `data-interactive-state="selected"`
|
||||
|
||||
All background and foreground colors come from the Interactive.Stateful CSS, not from SelectCard.
|
||||
|
||||
## Usage
|
||||
|
||||
### Provider selection card
|
||||
|
||||
```tsx
|
||||
import { SelectCard } from "@opal/components";
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
|
||||
<SelectCard variant="select-card" state="selected" onClick={handleClick}>
|
||||
<CardHeaderLayout
|
||||
icon={SvgGlobe}
|
||||
title="Google"
|
||||
description="Search engine"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={<Button icon={SvgCheckSquare} variant="action" prominence="tertiary">Current Default</Button>}
|
||||
bottomRightChildren={
|
||||
<Button icon={SvgSettings} size="sm" prominence="tertiary" />
|
||||
}
|
||||
/>
|
||||
</SelectCard>
|
||||
```
|
||||
|
||||
### Disconnected state (clickable)
|
||||
|
||||
```tsx
|
||||
<SelectCard variant="select-card" state="empty" onClick={handleConnect}>
|
||||
<CardHeaderLayout
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
description="Not configured"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={<Button rightIcon={SvgArrowExchange} prominence="tertiary">Connect</Button>}
|
||||
/>
|
||||
</SelectCard>
|
||||
```
|
||||
|
||||
### With foldable hover-reveal
|
||||
|
||||
```tsx
|
||||
<SelectCard variant="select-card" state="filled">
|
||||
<CardHeaderLayout
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
description="Connected"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<div className="interactive-foldable-host flex items-center">
|
||||
<Interactive.Foldable>
|
||||
<Button rightIcon={SvgArrowRightCircle} prominence="tertiary">
|
||||
Set as Default
|
||||
</Button>
|
||||
</Interactive.Foldable>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SelectCard>
|
||||
```
|
||||
@@ -1,247 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { SelectCard } from "@opal/components";
|
||||
import { Button } from "@opal/components";
|
||||
import { Content } from "@opal/layouts";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgArrowRightCircle,
|
||||
SvgCheckSquare,
|
||||
SvgGlobe,
|
||||
SvgSettings,
|
||||
SvgUnplug,
|
||||
} from "@opal/icons";
|
||||
import { Interactive } from "@opal/core";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import type { Decorator } from "@storybook/react";
|
||||
|
||||
const withTooltipProvider: Decorator = (Story) => (
|
||||
<TooltipPrimitive.Provider>
|
||||
<Story />
|
||||
</TooltipPrimitive.Provider>
|
||||
);
|
||||
|
||||
const STATES = ["empty", "filled", "selected"] as const;
|
||||
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
|
||||
|
||||
const meta = {
|
||||
title: "opal/components/SelectCard",
|
||||
component: SelectCard,
|
||||
tags: ["autodocs"],
|
||||
decorators: [withTooltipProvider],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof SelectCard>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<SelectCard variant="select-card" state="empty">
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title="Google Search"
|
||||
description="Web search provider"
|
||||
/>
|
||||
</div>
|
||||
</SelectCard>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{STATES.map((state) => (
|
||||
<SelectCard key={state} variant="select-card" state={state}>
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title={`State: ${state}`}
|
||||
description="Hover to see interaction states."
|
||||
/>
|
||||
</div>
|
||||
</SelectCard>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Clickable: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state="empty"
|
||||
onClick={() => alert("Card clicked")}
|
||||
>
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title="Clickable Card"
|
||||
description="Click anywhere on this card."
|
||||
/>
|
||||
</div>
|
||||
</SelectCard>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-[28rem]">
|
||||
{/* Disconnected */}
|
||||
<SelectCard variant="select-card" state="empty" onClick={() => {}}>
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title="Disconnected"
|
||||
description="Click to connect."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectCard>
|
||||
|
||||
{/* Connected with foldable */}
|
||||
<SelectCard variant="select-card" state="filled">
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title="Connected"
|
||||
description="Hover to reveal Set as Default."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-end justify-between">
|
||||
<div className="interactive-foldable-host flex items-center">
|
||||
<Interactive.Foldable>
|
||||
<Button prominence="tertiary" rightIcon={SvgArrowRightCircle}>
|
||||
Set as Default
|
||||
</Button>
|
||||
</Interactive.Foldable>
|
||||
</div>
|
||||
<div className="flex flex-row px-1 pb-1">
|
||||
<Button
|
||||
icon={SvgUnplug}
|
||||
tooltip="Disconnect"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
icon={SvgSettings}
|
||||
tooltip="Edit"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectCard>
|
||||
|
||||
{/* Selected */}
|
||||
<SelectCard variant="select-card" state="selected">
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title="Selected"
|
||||
description="Currently the default provider."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-end justify-between">
|
||||
<Button
|
||||
variant="action"
|
||||
prominence="tertiary"
|
||||
icon={SvgCheckSquare}
|
||||
>
|
||||
Current Default
|
||||
</Button>
|
||||
<div className="flex flex-row px-1 pb-1">
|
||||
<Button
|
||||
icon={SvgUnplug}
|
||||
tooltip="Disconnect"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
icon={SvgSettings}
|
||||
tooltip="Edit"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectCard>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const SizeVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{SIZE_VARIANTS.map((size) => (
|
||||
<SelectCard
|
||||
key={size}
|
||||
variant="select-card"
|
||||
state="filled"
|
||||
sizeVariant={size}
|
||||
>
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title={`sizeVariant: ${size}`}
|
||||
description="Shows padding and rounding differences."
|
||||
/>
|
||||
</SelectCard>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const SelectHeavyVariant: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{STATES.map((state) => (
|
||||
<SelectCard key={state} variant="select-heavy" state={state}>
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title={`select-heavy / ${state}`}
|
||||
description="For comparison with select-card variant."
|
||||
/>
|
||||
</div>
|
||||
</SelectCard>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import "@opal/components/cards/select-card/styles.css";
|
||||
import type { ContainerSizeVariants } from "@opal/types";
|
||||
import { containerSizeVariants } from "@opal/shared";
|
||||
import { cn } from "@opal/utils";
|
||||
import { Interactive, type InteractiveStatefulProps } from "@opal/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SelectCardProps = InteractiveStatefulProps & {
|
||||
/**
|
||||
* Size preset — controls padding and border-radius.
|
||||
*
|
||||
* Padding comes from the shared size scale. Rounding follows the same
|
||||
* mapping as `Card` / `Button` / `Interactive.Container`:
|
||||
*
|
||||
* | Size | Rounding |
|
||||
* |------------|--------------|
|
||||
* | `lg` | `rounded-16` |
|
||||
* | `md`–`sm` | `rounded-12` |
|
||||
* | `xs`–`2xs` | `rounded-08` |
|
||||
* | `fit` | `rounded-16` |
|
||||
*
|
||||
* @default "lg"
|
||||
*/
|
||||
sizeVariant?: ContainerSizeVariants;
|
||||
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rounding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const roundingForSize: Record<ContainerSizeVariants, string> = {
|
||||
lg: "rounded-16",
|
||||
md: "rounded-12",
|
||||
sm: "rounded-12",
|
||||
xs: "rounded-08",
|
||||
"2xs": "rounded-08",
|
||||
fit: "rounded-16",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SelectCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A stateful interactive card — the card counterpart to `SelectButton`.
|
||||
*
|
||||
* Built on `Interactive.Stateful` (Slot) → a structural `<div>`. The
|
||||
* Stateful system owns background and foreground colors; the card owns
|
||||
* padding, rounding, border, and overflow.
|
||||
*
|
||||
* Children are fully composable — use `ContentAction`, `Content`, buttons,
|
||||
* `Interactive.Foldable`, etc. inside.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SelectCard variant="select-card" state="selected" onClick={handleClick}>
|
||||
* <ContentAction
|
||||
* icon={SvgGlobe}
|
||||
* title="Google"
|
||||
* description="Search engine"
|
||||
* rightChildren={<Button>Set as Default</Button>}
|
||||
* />
|
||||
* </SelectCard>
|
||||
* ```
|
||||
*/
|
||||
function SelectCard({
|
||||
sizeVariant = "lg",
|
||||
ref,
|
||||
children,
|
||||
...statefulProps
|
||||
}: SelectCardProps) {
|
||||
const { padding } = containerSizeVariants[sizeVariant];
|
||||
const rounding = roundingForSize[sizeVariant];
|
||||
|
||||
return (
|
||||
<Interactive.Stateful {...statefulProps}>
|
||||
<div ref={ref} className={cn("opal-select-card", padding, rounding)}>
|
||||
{children}
|
||||
</div>
|
||||
</Interactive.Stateful>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { SelectCard, type SelectCardProps };
|
||||
@@ -1,9 +0,0 @@
|
||||
/* SelectCard — structural styles; colors handled by Interactive.Stateful */
|
||||
|
||||
.opal-select-card {
|
||||
@apply w-full overflow-clip border;
|
||||
}
|
||||
|
||||
.opal-select-card[data-interactive-state="selected"] {
|
||||
@apply border-action-link-05;
|
||||
}
|
||||
@@ -56,12 +56,6 @@ export {
|
||||
type BorderVariant,
|
||||
} from "@opal/components/cards/card/components";
|
||||
|
||||
/* SelectCard */
|
||||
export {
|
||||
SelectCard,
|
||||
type SelectCardProps,
|
||||
} from "@opal/components/cards/select-card/components";
|
||||
|
||||
/* EmptyMessageCard */
|
||||
export {
|
||||
EmptyMessageCard,
|
||||
|
||||
@@ -24,6 +24,7 @@ type TextFont =
|
||||
| "secondary-body"
|
||||
| "secondary-action"
|
||||
| "secondary-mono"
|
||||
| "secondary-mono-label"
|
||||
| "figure-small-label"
|
||||
| "figure-small-value"
|
||||
| "figure-keystroke";
|
||||
@@ -88,6 +89,7 @@ const FONT_CONFIG: Record<TextFont, string> = {
|
||||
"secondary-body": "font-secondary-body",
|
||||
"secondary-action": "font-secondary-action",
|
||||
"secondary-mono": "font-secondary-mono",
|
||||
"secondary-mono-label": "font-secondary-mono-label",
|
||||
"figure-small-label": "font-figure-small-label",
|
||||
"figure-small-value": "font-figure-small-value",
|
||||
"figure-keystroke": "font-figure-keystroke",
|
||||
|
||||
@@ -8,7 +8,7 @@ Stateful interactive surface primitive for elements that maintain a value state
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `variant` | `"select-light" \| "select-heavy" \| "select-card" \| "select-tinted" \| "select-filter" \| "sidebar-heavy" \| "sidebar-light"` | `"select-heavy"` | Color variant |
|
||||
| `variant` | `"select-light" \| "select-heavy" \| "select-tinted" \| "select-filter" \| "sidebar"` | `"select-heavy"` | Color variant |
|
||||
| `state` | `"empty" \| "filled" \| "selected"` | `"empty"` | Current value state |
|
||||
| `interaction` | `"rest" \| "hover" \| "active"` | `"rest"` | JS-controlled interaction override |
|
||||
| `group` | `string` | — | Tailwind group class for `group-hover:*` |
|
||||
@@ -16,16 +16,6 @@ Stateful interactive surface primitive for elements that maintain a value state
|
||||
| `href` | `string` | — | URL for link behavior |
|
||||
| `target` | `string` | — | Link target (e.g. `"_blank"`) |
|
||||
|
||||
## Variants
|
||||
|
||||
- **`select-light`** — Transparent selected background. For inline toggles.
|
||||
- **`select-heavy`** — Tinted selected background (`action-link-01`). For list rows, model pickers, buttons.
|
||||
- **`select-card`** — Like `select-heavy`, but the filled state gets a visible background (`background-tint-00`) with neutral foreground. Designed for larger surfaces (cards) where background carries more of the visual distinction than foreground color alone.
|
||||
- **`select-tinted`** — Like `select-heavy` but with a tinted rest background (`background-tint-01`).
|
||||
- **`select-filter`** — Like `select-tinted` for empty/filled; selected state uses inverted backgrounds and inverted text.
|
||||
- **`sidebar-heavy`** — Sidebar navigation: muted when unselected, bold when selected.
|
||||
- **`sidebar-light`** — Sidebar navigation: uniformly muted across all states.
|
||||
|
||||
## State attribute
|
||||
|
||||
Uses `data-interactive-state` (not `data-state`) to avoid conflicts with Radix UI, which injects its own `data-state` on trigger elements.
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { ButtonType, WithoutStyles } from "@opal/types";
|
||||
type InteractiveStatefulVariant =
|
||||
| "select-light"
|
||||
| "select-heavy"
|
||||
| "select-card"
|
||||
| "select-tinted"
|
||||
| "select-filter"
|
||||
| "sidebar-heavy"
|
||||
@@ -33,7 +32,6 @@ interface InteractiveStatefulProps
|
||||
*
|
||||
* - `"select-light"` — transparent selected background (for inline toggles)
|
||||
* - `"select-heavy"` — tinted selected background (for list rows, model pickers)
|
||||
* - `"select-card"` — like select-heavy but filled state has a visible background (for cards/larger surfaces)
|
||||
* - `"select-tinted"` — like select-heavy but with a tinted rest background
|
||||
* - `"select-filter"` — like select-tinted for empty/filled; selected state uses inverted tint backgrounds and inverted text (for filter buttons)
|
||||
* - `"sidebar-heavy"` — sidebar navigation items: muted when unselected (text-03/text-02), bold when selected (text-04/text-03)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
Children read the variables with no independent transitions.
|
||||
|
||||
State dimension: `data-interactive-state` = "empty" | "filled" | "selected"
|
||||
Variant dimension: `data-interactive-variant` = "select-light" | "select-heavy" | "select-card" | "select-tinted" | "select-filter" | "sidebar-heavy" | "sidebar-light"
|
||||
Variant dimension: `data-interactive-variant` = "select-light" | "select-heavy" | "select-tinted" | "sidebar"
|
||||
|
||||
Interaction override: `data-interaction="hover"` and `data-interaction="active"`
|
||||
allow JS-controlled visual state overrides.
|
||||
@@ -114,108 +114,6 @@
|
||||
--interactive-foreground-icon: var(--action-link-03);
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
Select-Card — like Select-Heavy but filled has a visible background.
|
||||
Designed for larger surfaces (cards) where background carries more of
|
||||
the visual distinction than foreground color alone.
|
||||
=========================================================================== */
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Select-Card — Empty
|
||||
--------------------------------------------------------------------------- */
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"] {
|
||||
@apply bg-transparent;
|
||||
--interactive-foreground: var(--text-04);
|
||||
--interactive-foreground-icon: var(--text-03);
|
||||
}
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"]:hover:not(
|
||||
[data-disabled]
|
||||
),
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"][data-interaction="hover"]:not(
|
||||
[data-disabled]
|
||||
) {
|
||||
@apply bg-background-tint-02;
|
||||
--interactive-foreground-icon: var(--text-04);
|
||||
}
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"]:active:not(
|
||||
[data-disabled]
|
||||
),
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"][data-interaction="active"]:not(
|
||||
[data-disabled]
|
||||
) {
|
||||
@apply bg-background-neutral-00;
|
||||
--interactive-foreground: var(--text-05);
|
||||
--interactive-foreground-icon: var(--text-05);
|
||||
}
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"][data-disabled] {
|
||||
@apply bg-transparent;
|
||||
--interactive-foreground: var(--text-01);
|
||||
--interactive-foreground-icon: var(--text-01);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Select-Card — Filled (visible background, neutral foreground)
|
||||
--------------------------------------------------------------------------- */
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"] {
|
||||
@apply bg-background-tint-00;
|
||||
--interactive-foreground: var(--text-04);
|
||||
--interactive-foreground-icon: var(--text-03);
|
||||
}
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"]:hover:not(
|
||||
[data-disabled]
|
||||
),
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"][data-interaction="hover"]:not(
|
||||
[data-disabled]
|
||||
) {
|
||||
@apply bg-background-tint-02;
|
||||
--interactive-foreground-icon: var(--text-04);
|
||||
}
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"]:active:not(
|
||||
[data-disabled]
|
||||
),
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"][data-interaction="active"]:not(
|
||||
[data-disabled]
|
||||
) {
|
||||
@apply bg-background-tint-00;
|
||||
--interactive-foreground: var(--text-05);
|
||||
--interactive-foreground-icon: var(--text-05);
|
||||
}
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"][data-disabled] {
|
||||
@apply bg-transparent;
|
||||
--interactive-foreground: var(--text-01);
|
||||
--interactive-foreground-icon: var(--text-01);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Select-Card — Selected
|
||||
--------------------------------------------------------------------------- */
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"] {
|
||||
@apply bg-[var(--action-link-01)];
|
||||
--interactive-foreground: var(--action-link-05);
|
||||
--interactive-foreground-icon: var(--action-link-05);
|
||||
}
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"]:hover:not(
|
||||
[data-disabled]
|
||||
),
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"][data-interaction="hover"]:not(
|
||||
[data-disabled]
|
||||
) {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"]:active:not(
|
||||
[data-disabled]
|
||||
),
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"][data-interaction="active"]:not(
|
||||
[data-disabled]
|
||||
) {
|
||||
@apply bg-background-tint-00;
|
||||
}
|
||||
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"][data-disabled] {
|
||||
@apply bg-transparent;
|
||||
--interactive-foreground: var(--action-link-03);
|
||||
--interactive-foreground-icon: var(--action-link-03);
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
Select-Light — identical to Select-Heavy except selected bg is transparent
|
||||
=========================================================================== */
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgCheckSquare,
|
||||
SvgGlobe,
|
||||
SvgSettings,
|
||||
SvgUnplug,
|
||||
} from "@opal/icons";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import type { Decorator } from "@storybook/react";
|
||||
|
||||
const withTooltipProvider: Decorator = (Story) => (
|
||||
<TooltipPrimitive.Provider>
|
||||
<Story />
|
||||
</TooltipPrimitive.Provider>
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: "Layouts/CardHeaderLayout",
|
||||
component: CardHeaderLayout,
|
||||
tags: ["autodocs"],
|
||||
decorators: [withTooltipProvider],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof CardHeaderLayout>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title="Google Search"
|
||||
description="Web search provider"
|
||||
rightChildren={
|
||||
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithBothSlots: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title="Google Search"
|
||||
description="Currently the default provider."
|
||||
rightChildren={
|
||||
<Button variant="action" prominence="tertiary" icon={SvgCheckSquare}>
|
||||
Current Default
|
||||
</Button>
|
||||
}
|
||||
bottomRightChildren={
|
||||
<>
|
||||
<Button
|
||||
icon={SvgUnplug}
|
||||
tooltip="Disconnect"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
icon={SvgSettings}
|
||||
tooltip="Edit"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const RightChildrenOnly: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title="OpenAI"
|
||||
description="Not configured"
|
||||
rightChildren={
|
||||
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const NoRightChildren: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title="Section Header"
|
||||
description="No actions on the right."
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title="Very Long Provider Name That Should Truncate"
|
||||
description="This is a much longer description that tests how the layout handles overflow when the content area needs to shrink."
|
||||
rightChildren={
|
||||
<Button variant="action" prominence="tertiary" icon={SvgCheckSquare}>
|
||||
Current Default
|
||||
</Button>
|
||||
}
|
||||
bottomRightChildren={
|
||||
<>
|
||||
<Button
|
||||
icon={SvgUnplug}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
tooltip="Disconnect"
|
||||
/>
|
||||
<Button
|
||||
icon={SvgSettings}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
tooltip="Edit"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
# CardHeaderLayout
|
||||
|
||||
**Import:** `import { CardHeaderLayout, type CardHeaderLayoutProps } from "@opal/layouts";`
|
||||
|
||||
A card header layout that pairs a [`Content`](../../content/README.md) block with a right-side column of vertically stacked children.
|
||||
|
||||
## Why CardHeaderLayout?
|
||||
|
||||
[`ContentAction`](../../content-action/README.md) provides a single `rightChildren` slot. Card headers typically need two distinct right-side regions — a primary action on top and secondary actions on the bottom. `CardHeaderLayout` provides this with `rightChildren` and `bottomRightChildren` slots, with no padding or gap between them so the caller has full control over spacing.
|
||||
|
||||
## Props
|
||||
|
||||
Inherits **all** props from [`Content`](../../content/README.md) (icon, title, description, sizePreset, variant, etc.) plus:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `rightChildren` | `ReactNode` | `undefined` | Content rendered to the right of the Content block (top of right column). |
|
||||
| `bottomRightChildren` | `ReactNode` | `undefined` | Content rendered below `rightChildren` in the same column. Laid out as `flex flex-row`. |
|
||||
|
||||
## Layout Structure
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ [Content (p-2, self-start)] [rightChildren] │
|
||||
│ icon + title + description [bottomRightChildren] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Outer wrapper: `flex flex-row items-stretch w-full`
|
||||
- Content area: `flex-1 min-w-0 self-start p-2` — top-aligned with fixed padding
|
||||
- Right column: `flex flex-col items-end justify-between shrink-0` — no padding, no gap
|
||||
- `bottomRightChildren` wrapper: `flex flex-row` — lays children out horizontally
|
||||
|
||||
The right column uses `justify-between` so when both slots are present, `rightChildren` sits at the top and `bottomRightChildren` at the bottom.
|
||||
|
||||
## Usage
|
||||
|
||||
### Card with primary and secondary actions
|
||||
|
||||
```tsx
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgGlobe, SvgSettings, SvgUnplug, SvgCheckSquare } from "@opal/icons";
|
||||
|
||||
<CardHeaderLayout
|
||||
icon={SvgGlobe}
|
||||
title="Google Search"
|
||||
description="Web search provider"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button icon={SvgCheckSquare} variant="action" prominence="tertiary">
|
||||
Current Default
|
||||
</Button>
|
||||
}
|
||||
bottomRightChildren={
|
||||
<>
|
||||
<Button icon={SvgUnplug} size="sm" prominence="tertiary" tooltip="Disconnect" />
|
||||
<Button icon={SvgSettings} size="sm" prominence="tertiary" tooltip="Edit" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### Card with only a connect action
|
||||
|
||||
```tsx
|
||||
<CardHeaderLayout
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
description="Not configured"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### No right children
|
||||
|
||||
```tsx
|
||||
<CardHeaderLayout
|
||||
icon={SvgInfo}
|
||||
title="Section Header"
|
||||
description="Description text"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
/>
|
||||
```
|
||||
|
||||
When both `rightChildren` and `bottomRightChildren` are omitted, the component renders only the padded `Content`.
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Content, type ContentProps } from "@opal/layouts/content/components";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CardHeaderLayoutProps = ContentProps & {
|
||||
/** Content rendered to the right of the Content block. */
|
||||
rightChildren?: React.ReactNode;
|
||||
|
||||
/** Content rendered below `rightChildren` in the same column. */
|
||||
bottomRightChildren?: React.ReactNode;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardHeaderLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A card header layout that pairs a {@link Content} block (with `p-2`)
|
||||
* with a right-side column.
|
||||
*
|
||||
* The right column contains two vertically stacked slots —
|
||||
* `rightChildren` on top, `bottomRightChildren` below — with no
|
||||
* padding or gap between them.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <CardHeaderLayout
|
||||
* icon={SvgGlobe}
|
||||
* title="Google"
|
||||
* description="Search engine"
|
||||
* sizePreset="main-ui"
|
||||
* variant="section"
|
||||
* rightChildren={<Button>Connect</Button>}
|
||||
* bottomRightChildren={
|
||||
* <>
|
||||
* <Button icon={SvgUnplug} size="sm" prominence="tertiary" />
|
||||
* <Button icon={SvgSettings} size="sm" prominence="tertiary" />
|
||||
* </>
|
||||
* }
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function CardHeaderLayout({
|
||||
rightChildren,
|
||||
bottomRightChildren,
|
||||
...contentProps
|
||||
}: CardHeaderLayoutProps) {
|
||||
const hasRight = rightChildren || bottomRightChildren;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 min-w-0 self-start p-2">
|
||||
<Content {...contentProps} />
|
||||
</div>
|
||||
{hasRight && (
|
||||
<div className="flex flex-col items-end shrink-0">
|
||||
{rightChildren && <div className="flex-1">{rightChildren}</div>}
|
||||
{bottomRightChildren && (
|
||||
<div className="flex flex-row">{bottomRightChildren}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { CardHeaderLayout, type CardHeaderLayoutProps };
|
||||
@@ -12,12 +12,6 @@ export {
|
||||
type ContentActionProps,
|
||||
} from "@opal/layouts/content-action/components";
|
||||
|
||||
/* CardHeaderLayout */
|
||||
export {
|
||||
CardHeaderLayout,
|
||||
type CardHeaderLayoutProps,
|
||||
} from "@opal/layouts/cards/header-layout/components";
|
||||
|
||||
/* IllustrationContent */
|
||||
export {
|
||||
IllustrationContent,
|
||||
|
||||
@@ -8,7 +8,6 @@ import * as Sentry from "@sentry/nextjs";
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
release: process.env.SENTRY_RELEASE,
|
||||
// Only capture unhandled exceptions
|
||||
tracesSampleRate: 0,
|
||||
debug: false,
|
||||
|
||||
@@ -7,7 +7,6 @@ import * as Sentry from "@sentry/nextjs";
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
release: process.env.SENTRY_RELEASE,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import useSWR from "swr";
|
||||
import ProviderCard from "@/sections/cards/ProviderCard";
|
||||
import { Select } from "@/refresh-components/cards";
|
||||
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
@@ -228,11 +228,11 @@ export default function ImageGenerationContent() {
|
||||
</Text>
|
||||
<div className="flex flex-col gap-2">
|
||||
{group.providers.map((provider) => (
|
||||
<ProviderCard
|
||||
<Select
|
||||
key={provider.image_provider_id}
|
||||
aria-label={`image-gen-provider-${provider.image_provider_id}`}
|
||||
icon={() => (
|
||||
<ProviderIcon provider={provider.provider_name} size={16} />
|
||||
<ProviderIcon provider={provider.provider_name} size={18} />
|
||||
)}
|
||||
title={provider.title}
|
||||
description={provider.description}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from "next/image";
|
||||
import { useEffect, useMemo, useState, useReducer } from "react";
|
||||
import { InfoIcon } from "@/components/icons/icons";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import ProviderCard from "@/sections/cards/ProviderCard";
|
||||
import { Select } from "@/refresh-components/cards";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Content } from "@opal/layouts";
|
||||
@@ -1091,7 +1091,7 @@ export default function Page() {
|
||||
: "connected";
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
<Select
|
||||
key={`${key}-${providerType}`}
|
||||
icon={() =>
|
||||
logoSrc ? (
|
||||
@@ -1207,7 +1207,7 @@ export default function Page() {
|
||||
CONTENT_PROVIDER_DETAILS[provider.provider_type]?.logoSrc;
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
<Select
|
||||
key={`${provider.provider_type}-${provider.id}`}
|
||||
icon={() =>
|
||||
contentLogoSrc ? (
|
||||
|
||||
@@ -11,7 +11,6 @@ import Text from "@/refresh-components/texts/Text";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { ChatSessionMinimal } from "@/app/ee/admin/performance/usage/types";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { timestampToReadableDate } from "@/lib/dateUtils";
|
||||
import { Dispatch, SetStateAction, useCallback, useState } from "react";
|
||||
import { Feedback, TaskStatus } from "@/lib/types";
|
||||
@@ -102,32 +101,34 @@ function SelectFeedbackType({
|
||||
onValueChange: (value: Feedback | "all") => void;
|
||||
}) {
|
||||
return (
|
||||
<Section alignItems="start" gap={0.25}>
|
||||
<Text as="p" className="font-medium">
|
||||
<div>
|
||||
<Text as="p" className="my-auto mr-2 font-medium mb-1">
|
||||
Feedback Type
|
||||
</Text>
|
||||
<InputSelect
|
||||
value={value}
|
||||
onValueChange={onValueChange as (value: string) => void}
|
||||
>
|
||||
<InputSelect.Trigger />
|
||||
<div className="max-w-sm space-y-6">
|
||||
<InputSelect
|
||||
value={value}
|
||||
onValueChange={onValueChange as (value: string) => void}
|
||||
>
|
||||
<InputSelect.Trigger />
|
||||
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="all" icon={SvgMinusCircle}>
|
||||
Any
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item value="like" icon={SvgThumbsUp}>
|
||||
Like
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item value="dislike" icon={SvgThumbsDown}>
|
||||
Dislike
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item value="mixed" icon={SvgMinus}>
|
||||
Mixed
|
||||
</InputSelect.Item>
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</Section>
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="all" icon={SvgMinusCircle}>
|
||||
Any
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item value="like" icon={SvgThumbsUp}>
|
||||
Like
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item value="dislike" icon={SvgThumbsDown}>
|
||||
Dislike
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item value="mixed" icon={SvgMinus}>
|
||||
Mixed
|
||||
</InputSelect.Item>
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,61 +185,60 @@ function PreviousQueryHistoryExportsModal({
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
<Modal.Body>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Generated At</TableHead>
|
||||
<TableHead>Start Range</TableHead>
|
||||
<TableHead>End Range</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Download</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedTasks.map((task, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
{humanReadableFormatWithTime(task.startTime)}
|
||||
</TableCell>
|
||||
<TableCell>{task.start.toDateString()}</TableCell>
|
||||
<TableCell>{task.end.toDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<ExportBadge status={task.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="default"
|
||||
prominence="tertiary"
|
||||
icon={SvgDownloadCloud}
|
||||
size="sm"
|
||||
disabled={task.status !== "SUCCESS"}
|
||||
tooltip={
|
||||
task.status !== "SUCCESS"
|
||||
? "Export is not yet ready"
|
||||
: undefined
|
||||
}
|
||||
href={
|
||||
task.status === "SUCCESS"
|
||||
? withRequestId(
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-1">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Generated At</TableHead>
|
||||
<TableHead>Start Range</TableHead>
|
||||
<TableHead>End Range</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Download</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedTasks.map((task, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
{humanReadableFormatWithTime(task.startTime)}
|
||||
</TableCell>
|
||||
<TableCell>{task.start.toDateString()}</TableCell>
|
||||
<TableCell>{task.end.toDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<ExportBadge status={task.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{task.status === "SUCCESS" ? (
|
||||
<a
|
||||
className="flex justify-center"
|
||||
href={withRequestId(
|
||||
DOWNLOAD_QUERY_HISTORY_URL,
|
||||
task.taskId
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
>
|
||||
<SvgDownloadCloud className="h-4 w-4 text-action-link-05" />
|
||||
</a>
|
||||
) : (
|
||||
<SvgDownloadCloud className="h-4 w-4 text-action-link-05 opacity-20" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Section>
|
||||
<PageSelector
|
||||
currentPage={taskPage}
|
||||
totalPages={totalTaskPages}
|
||||
onPageChange={setTaskPage}
|
||||
/>
|
||||
</Section>
|
||||
<div className="flex mt-3">
|
||||
<div className="mx-auto">
|
||||
<PageSelector
|
||||
currentPage={taskPage}
|
||||
totalPages={totalTaskPages}
|
||||
onPageChange={setTaskPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
@@ -330,48 +330,48 @@ export function QueryHistoryTable() {
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<Section>
|
||||
<Table className="mt-5">
|
||||
<TableHeader>
|
||||
<Table className="mt-5">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>First User Message</TableHead>
|
||||
<TableHead>First AI Response</TableHead>
|
||||
<TableHead>Feedback</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Persona</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isLoading ? (
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead>First User Message</TableHead>
|
||||
<TableHead>First AI Response</TableHead>
|
||||
<TableHead>Feedback</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Persona</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableCell colSpan={6} className="text-center">
|
||||
<ThreeDotsLoader />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isLoading ? (
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center">
|
||||
<ThreeDotsLoader />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
) : (
|
||||
<TableBody>
|
||||
{chatSessionData?.map((chatSessionMinimal) => (
|
||||
<QueryHistoryTableRow
|
||||
key={chatSessionMinimal.id}
|
||||
chatSessionMinimal={chatSessionMinimal}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</TableBody>
|
||||
) : (
|
||||
<TableBody>
|
||||
{chatSessionData?.map((chatSessionMinimal) => (
|
||||
<QueryHistoryTableRow
|
||||
key={chatSessionMinimal.id}
|
||||
chatSessionMinimal={chatSessionMinimal}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
|
||||
{chatSessionData && (
|
||||
<Section>
|
||||
{chatSessionData && (
|
||||
<div className="mt-3 flex">
|
||||
<div className="mx-auto">
|
||||
<PageSelector
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardSection>
|
||||
|
||||
{showModal && (
|
||||
|
||||
@@ -330,6 +330,14 @@
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
|
||||
.font-secondary-mono-label {
|
||||
font-family: var(--font-dm-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
|
||||
/* FIGURE STYLES */
|
||||
|
||||
.font-figure-small-label {
|
||||
|
||||
42
web/src/hooks/useHookExecutionLogs.ts
Normal file
42
web/src/hooks/useHookExecutionLogs.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import useSWR from "swr";
|
||||
import { fetchExecutionLogs } from "@/refresh-pages/admin/HooksPage/svc";
|
||||
import type { HookExecutionRecord } from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface UseHookExecutionLogsResult {
|
||||
isLoading: boolean;
|
||||
error: Error | undefined;
|
||||
hasRecentErrors: boolean;
|
||||
recentErrors: HookExecutionRecord[];
|
||||
olderErrors: HookExecutionRecord[];
|
||||
}
|
||||
|
||||
export function useHookExecutionLogs(
|
||||
hookId: number,
|
||||
limit = 10
|
||||
): UseHookExecutionLogsResult {
|
||||
const { data, isLoading, error } = useSWR(
|
||||
["hook-execution-logs", hookId, limit],
|
||||
() => fetchExecutionLogs(hookId, limit),
|
||||
{ refreshInterval: 60_000 }
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const recentErrors =
|
||||
data?.filter(
|
||||
(log) => now - new Date(log.created_at).getTime() < ONE_HOUR_MS
|
||||
) ?? [];
|
||||
|
||||
const olderErrors =
|
||||
data?.filter((log) => {
|
||||
const age = now - new Date(log.created_at).getTime();
|
||||
return age >= ONE_HOUR_MS && age < THIRTY_DAYS_MS;
|
||||
}) ?? [];
|
||||
|
||||
const hasRecentErrors = recentErrors.length > 0;
|
||||
|
||||
return { isLoading, error, hasRecentErrors, recentErrors, olderErrors };
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import * as Sentry from "@sentry/nextjs";
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
release: process.env.SENTRY_RELEASE,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
@@ -160,6 +160,31 @@ export const formatDateShort = (dateStr: string | null | undefined): string => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Format an ISO timestamp as "YYYY/MM/DD HH:MM:SS" (24-hour, local time).
|
||||
* Intended for log displays where full precision is needed.
|
||||
*/
|
||||
export function formatDateTimeLog(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(
|
||||
d.getHours()
|
||||
)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO timestamp as "HH:MM:SS" (24-hour, local time).
|
||||
* Intended for compact time-only displays.
|
||||
*/
|
||||
export function formatTimeOnly(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMmDdYyyy(d: string): string {
|
||||
const date = new Date(d);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
|
||||
|
||||
@@ -292,9 +292,10 @@ export interface IconButtonProps
|
||||
secondary?: boolean;
|
||||
tertiary?: boolean;
|
||||
internal?: boolean;
|
||||
small?: boolean;
|
||||
|
||||
// Button size
|
||||
small?: boolean;
|
||||
large?: boolean;
|
||||
|
||||
// Button states
|
||||
transient?: boolean;
|
||||
|
||||
129
web/src/refresh-components/cards/Select.stories.tsx
Normal file
129
web/src/refresh-components/cards/Select.stories.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import Select from "./Select";
|
||||
import { SvgSettings, SvgFolder, SvgSearch } from "@opal/icons";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
const meta: Meta<typeof Select> = {
|
||||
title: "refresh-components/cards/Select",
|
||||
component: Select,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<TooltipPrimitive.Provider>
|
||||
<div style={{ maxWidth: 500 }}>
|
||||
<Story />
|
||||
</div>
|
||||
</TooltipPrimitive.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Select>;
|
||||
|
||||
export const Disconnected: Story = {
|
||||
args: {
|
||||
icon: SvgFolder,
|
||||
title: "Google Drive",
|
||||
description: "Connect to sync your files",
|
||||
status: "disconnected",
|
||||
onConnect: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Connected: Story = {
|
||||
args: {
|
||||
icon: SvgFolder,
|
||||
title: "Google Drive",
|
||||
description: "Connected and syncing",
|
||||
status: "connected",
|
||||
onSelect: () => {},
|
||||
onEdit: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
icon: SvgFolder,
|
||||
title: "Google Drive",
|
||||
description: "Currently the default source",
|
||||
status: "selected",
|
||||
onDeselect: () => {},
|
||||
onEdit: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledState: Story = {
|
||||
args: {
|
||||
icon: SvgFolder,
|
||||
title: "Google Drive",
|
||||
description: "Not available on this plan",
|
||||
status: "disconnected",
|
||||
disabled: true,
|
||||
onConnect: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const MediumSize: Story = {
|
||||
args: {
|
||||
icon: SvgSearch,
|
||||
title: "Elastic Search",
|
||||
description: "Search engine connector",
|
||||
status: "connected",
|
||||
medium: true,
|
||||
onSelect: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomLabels: Story = {
|
||||
args: {
|
||||
icon: SvgSettings,
|
||||
title: "Custom LLM",
|
||||
description: "Your custom model endpoint",
|
||||
status: "connected",
|
||||
connectLabel: "Link",
|
||||
selectLabel: "Make Primary",
|
||||
selectedLabel: "Primary Model",
|
||||
onSelect: () => {},
|
||||
onEdit: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Select
|
||||
icon={SvgFolder}
|
||||
title="Google Drive"
|
||||
description="Connect to sync your files"
|
||||
status="disconnected"
|
||||
onConnect={() => {}}
|
||||
/>
|
||||
<Select
|
||||
icon={SvgSearch}
|
||||
title="Confluence"
|
||||
description="Connected and syncing"
|
||||
status="connected"
|
||||
onSelect={() => {}}
|
||||
onEdit={() => {}}
|
||||
/>
|
||||
<Select
|
||||
icon={SvgSettings}
|
||||
title="Notion"
|
||||
description="Currently the default source"
|
||||
status="selected"
|
||||
onDeselect={() => {}}
|
||||
onEdit={() => {}}
|
||||
/>
|
||||
<Select
|
||||
icon={SvgFolder}
|
||||
title="Sharepoint"
|
||||
description="Not available"
|
||||
status="disconnected"
|
||||
disabled
|
||||
onConnect={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
229
web/src/refresh-components/cards/Select.tsx
Normal file
229
web/src/refresh-components/cards/Select.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import type { IconProps } from "@opal/types";
|
||||
import { cn, noProp } from "@/lib/utils";
|
||||
import { Disabled } from "@opal/core";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Button } from "@opal/components";
|
||||
import SelectButton from "@/refresh-components/buttons/SelectButton";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgArrowRightCircle,
|
||||
SvgCheckSquare,
|
||||
SvgSettings,
|
||||
SvgUnplug,
|
||||
} from "@opal/icons";
|
||||
|
||||
const containerClasses = {
|
||||
selected: "border-action-link-05 bg-action-link-01",
|
||||
connected: "border-border-01 bg-background-tint-00 hover:shadow-00",
|
||||
disconnected: "border-border-01 bg-background-neutral-01 hover:shadow-00",
|
||||
} as const;
|
||||
|
||||
export interface SelectProps
|
||||
extends Omit<React.ComponentPropsWithoutRef<"div">, "title"> {
|
||||
// Content
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
// State
|
||||
status: "disconnected" | "connected" | "selected";
|
||||
|
||||
// Actions
|
||||
onConnect?: () => void;
|
||||
onSelect?: () => void;
|
||||
onDeselect?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
|
||||
// Labels (customizable)
|
||||
connectLabel?: string;
|
||||
selectLabel?: string;
|
||||
selectedLabel?: string;
|
||||
|
||||
// Size
|
||||
large?: boolean;
|
||||
medium?: boolean;
|
||||
|
||||
// Optional
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
onConnect,
|
||||
onSelect,
|
||||
onDeselect,
|
||||
onEdit,
|
||||
onDisconnect,
|
||||
connectLabel = "Connect",
|
||||
selectLabel = "Set as Default",
|
||||
selectedLabel = "Current Default",
|
||||
large = true,
|
||||
medium,
|
||||
className,
|
||||
disabled,
|
||||
...rest
|
||||
}: SelectProps) {
|
||||
const sizeClass = medium ? "h-[3.75rem]" : "min-h-[3.75rem] max-h-[5.25rem]";
|
||||
const containerClass = containerClasses[status];
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const isSelected = status === "selected";
|
||||
const isConnected = status === "connected";
|
||||
const isDisconnected = status === "disconnected";
|
||||
|
||||
const isCardClickable = isDisconnected && onConnect && !disabled;
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (isCardClickable) {
|
||||
onConnect?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Disabled disabled={disabled} allowClick>
|
||||
<div
|
||||
{...rest}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={isCardClickable ? handleCardClick : undefined}
|
||||
className={cn(
|
||||
"flex items-start justify-between gap-3 rounded-16 border p-2 min-w-[17.5rem]",
|
||||
sizeClass,
|
||||
containerClass,
|
||||
isCardClickable &&
|
||||
"cursor-pointer hover:bg-background-tint-01 transition-colors",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left section - Icon, Title, Description */}
|
||||
<div className="flex flex-1 items-start gap-1 p-1">
|
||||
<div className="flex size-5 items-center justify-center px-0.5 shrink-0">
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4",
|
||||
isSelected ? "text-action-text-link-05" : "text-text-02"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text mainUiAction text05>
|
||||
{title}
|
||||
</Text>
|
||||
<Text secondaryBody text03>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section - Actions */}
|
||||
<div className="flex flex-col h-full items-end justify-between gap-1">
|
||||
{/* Disconnected: Show Connect button */}
|
||||
{isDisconnected && (
|
||||
<Disabled disabled={disabled || !onConnect}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={noProp(onConnect)}
|
||||
rightIcon={SvgArrowExchange}
|
||||
>
|
||||
{connectLabel}
|
||||
</Button>
|
||||
</Disabled>
|
||||
)}
|
||||
|
||||
{/* Connected: Show select icon + settings icon */}
|
||||
{isConnected && (
|
||||
<>
|
||||
<Disabled disabled={disabled || !onSelect}>
|
||||
<SelectButton
|
||||
action
|
||||
folded
|
||||
transient={isHovered}
|
||||
onClick={onSelect}
|
||||
rightIcon={SvgArrowRightCircle}
|
||||
>
|
||||
{selectLabel}
|
||||
</SelectButton>
|
||||
</Disabled>
|
||||
<div className="flex px-1 gap-1">
|
||||
{onDisconnect && (
|
||||
<Disabled disabled={disabled}>
|
||||
<Button
|
||||
icon={SvgUnplug}
|
||||
tooltip="Disconnect"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
onClick={noProp(onDisconnect)}
|
||||
aria-label={`Disconnect ${title}`}
|
||||
/>
|
||||
</Disabled>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Disabled disabled={disabled}>
|
||||
<Button
|
||||
icon={SvgSettings}
|
||||
tooltip="Edit"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
onClick={noProp(onEdit)}
|
||||
aria-label={`Edit ${title}`}
|
||||
/>
|
||||
</Disabled>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Selected: Show "Current Default" label + settings icon */}
|
||||
{isSelected && (
|
||||
<>
|
||||
<Disabled disabled={disabled}>
|
||||
<SelectButton
|
||||
action
|
||||
engaged
|
||||
onClick={onDeselect}
|
||||
leftIcon={SvgCheckSquare}
|
||||
>
|
||||
{selectedLabel}
|
||||
</SelectButton>
|
||||
</Disabled>
|
||||
<div className="flex px-1 gap-1">
|
||||
{onDisconnect && (
|
||||
<Disabled disabled={disabled}>
|
||||
<Button
|
||||
icon={SvgUnplug}
|
||||
tooltip="Disconnect"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
onClick={noProp(onDisconnect)}
|
||||
aria-label={`Disconnect ${title}`}
|
||||
/>
|
||||
</Disabled>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Disabled disabled={disabled}>
|
||||
<Button
|
||||
icon={SvgSettings}
|
||||
tooltip="Edit"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
onClick={noProp(onEdit)}
|
||||
aria-label={`Edit ${title}`}
|
||||
/>
|
||||
</Disabled>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Disabled>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export { default as Card } from "./Card";
|
||||
export type { CardProps } from "./Card";
|
||||
export { default as Select } from "./Select";
|
||||
export type { SelectProps } from "./Select";
|
||||
|
||||
@@ -25,6 +25,7 @@ const fonts = {
|
||||
secondaryBody: "font-secondary-body",
|
||||
secondaryAction: "font-secondary-action",
|
||||
secondaryMono: "font-secondary-mono",
|
||||
secondaryMonoLabel: "font-secondary-mono-label",
|
||||
|
||||
// Figure
|
||||
figureSmallLabel: "font-figure-small-label",
|
||||
@@ -75,6 +76,7 @@ export interface TextProps extends Omit<HTMLAttributes<HTMLElement>, "as"> {
|
||||
secondaryBody?: boolean;
|
||||
secondaryAction?: boolean;
|
||||
secondaryMono?: boolean;
|
||||
secondaryMonoLabel?: boolean;
|
||||
figureSmallLabel?: boolean;
|
||||
figureSmallValue?: boolean;
|
||||
figureKeystroke?: boolean;
|
||||
@@ -112,6 +114,7 @@ export default function Text({
|
||||
secondaryBody,
|
||||
secondaryAction,
|
||||
secondaryMono,
|
||||
secondaryMonoLabel,
|
||||
figureSmallLabel,
|
||||
figureSmallValue,
|
||||
figureKeystroke,
|
||||
@@ -160,13 +163,15 @@ export default function Text({
|
||||
? "secondaryAction"
|
||||
: secondaryMono
|
||||
? "secondaryMono"
|
||||
: figureSmallLabel
|
||||
? "figureSmallLabel"
|
||||
: figureSmallValue
|
||||
? "figureSmallValue"
|
||||
: figureKeystroke
|
||||
? "figureKeystroke"
|
||||
: "mainUiBody";
|
||||
: secondaryMonoLabel
|
||||
? "secondaryMonoLabel"
|
||||
: figureSmallLabel
|
||||
? "figureSmallLabel"
|
||||
: figureSmallValue
|
||||
? "figureSmallValue"
|
||||
: figureKeystroke
|
||||
? "figureKeystroke"
|
||||
: "mainUiBody";
|
||||
|
||||
const color = text01
|
||||
? "text01"
|
||||
|
||||
@@ -11,7 +11,6 @@ import Card from "@/refresh-components/cards/Card";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import {
|
||||
SvgCheckCircle,
|
||||
SvgExternalLink,
|
||||
SvgPlug,
|
||||
SvgRefreshCw,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
validateHook,
|
||||
} from "@/refresh-pages/admin/HooksPage/svc";
|
||||
import { getHookPointIcon } from "@/refresh-pages/admin/HooksPage/hookPointIcons";
|
||||
import HookStatusPopover from "@/refresh-pages/admin/HooksPage/HookStatusPopover";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: disconnect confirmation modal
|
||||
@@ -328,7 +328,7 @@ export default function ConnectedHookCard({
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="pl-6 flex items-center gap-1"
|
||||
className="pl-6 flex items-center gap-1 w-fit"
|
||||
>
|
||||
<span className="underline font-secondary-body text-text-03">
|
||||
Documentation
|
||||
@@ -345,21 +345,13 @@ export default function ConnectedHookCard({
|
||||
height="fit"
|
||||
gap={0}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{hook.is_active ? (
|
||||
<>
|
||||
<Text mainUiAction text03>
|
||||
Connected
|
||||
</Text>
|
||||
<SvgCheckCircle
|
||||
size={16}
|
||||
className="text-status-success-05"
|
||||
/>
|
||||
</>
|
||||
<HookStatusPopover hook={hook} spec={spec} isBusy={isBusy} />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
"flex items-center gap-1 p-2",
|
||||
isBusy ? "opacity-50 pointer-events-none" : "cursor-pointer"
|
||||
)}
|
||||
onClick={handleActivate}
|
||||
|
||||
181
web/src/refresh-pages/admin/HooksPage/HookLogsModal.tsx
Normal file
181
web/src/refresh-pages/admin/HooksPage/HookLogsModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { SvgDownload, SvgTextLines } from "@opal/icons";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import { useHookExecutionLogs } from "@/hooks/useHookExecutionLogs";
|
||||
import { formatDateTimeLog } from "@/lib/dateUtils";
|
||||
import { downloadFile } from "@/lib/download";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import type {
|
||||
HookExecutionRecord,
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
|
||||
interface HookLogsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hook: HookResponse;
|
||||
spec: HookPointMeta | undefined;
|
||||
}
|
||||
|
||||
// Section header: "Past Hour ————" or "Older ————"
|
||||
function SectionHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
height="fit"
|
||||
className="py-1"
|
||||
>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{label}
|
||||
</Text>
|
||||
<div className="flex-1 ml-2 border-t border-border-02" />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function LogRow({ log }: { log: HookExecutionRecord }) {
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
gap={0.5}
|
||||
height="fit"
|
||||
className="py-2"
|
||||
>
|
||||
{/* 1. Timestamp */}
|
||||
<span className="shrink-0 text-code-code">
|
||||
<Text font="secondary-mono-label" color="inherit" nowrap>
|
||||
{formatDateTimeLog(log.created_at)}
|
||||
</Text>
|
||||
</span>
|
||||
{/* 2. Error message */}
|
||||
<span className="flex-1 min-w-0 break-all whitespace-pre-wrap text-code-code">
|
||||
<Text font="secondary-mono" color="inherit">
|
||||
{log.error_message ?? "Unknown error"}
|
||||
</Text>
|
||||
</span>
|
||||
{/* 3. Copy button */}
|
||||
<Section width="fit" height="fit" alignItems="center">
|
||||
<CopyIconButton size="xs" getCopyText={() => log.error_message ?? ""} />
|
||||
</Section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HookLogsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
hook,
|
||||
spec,
|
||||
}: HookLogsModalProps) {
|
||||
const { recentErrors, olderErrors, isLoading, error } = useHookExecutionLogs(
|
||||
hook.id,
|
||||
10
|
||||
);
|
||||
|
||||
const totalLines = recentErrors.length + olderErrors.length;
|
||||
const allLogs = [...recentErrors, ...olderErrors];
|
||||
|
||||
function getLogsText() {
|
||||
return allLogs
|
||||
.map(
|
||||
(log) =>
|
||||
`${formatDateTimeLog(log.created_at)} ${
|
||||
log.error_message ?? "Unknown error"
|
||||
}`
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
downloadFile(`${hook.name}-errors.txt`, { content: getLogsText() });
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Header
|
||||
icon={(props) => <SvgTextLines {...props} />}
|
||||
title="Recent Errors"
|
||||
description={`Hook: ${hook.name} • Hook Point: ${
|
||||
spec?.display_name ?? hook.hook_point
|
||||
}`}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
<Modal.Body>
|
||||
{isLoading ? (
|
||||
<Section justifyContent="center" height="fit" className="py-6">
|
||||
<SimpleLoader />
|
||||
</Section>
|
||||
) : error ? (
|
||||
<Text font="main-ui-body" color="text-03">
|
||||
Failed to load logs.
|
||||
</Text>
|
||||
) : totalLines === 0 ? (
|
||||
<Text font="main-ui-body" color="text-03">
|
||||
No errors in the past 30 days.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{recentErrors.length > 0 && (
|
||||
<>
|
||||
<SectionHeader label="Past Hour" />
|
||||
{recentErrors.map((log, idx) => (
|
||||
<LogRow key={log.created_at + String(idx)} log={log} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{olderErrors.length > 0 && (
|
||||
<>
|
||||
<SectionHeader label="Older" />
|
||||
{olderErrors.map((log, idx) => (
|
||||
<LogRow key={log.created_at + String(idx)} log={log} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
alignItems="center"
|
||||
padding={0.5}
|
||||
className="bg-background-tint-01"
|
||||
>
|
||||
<Text font="main-ui-body" color="text-03">
|
||||
{`${totalLines} ${totalLines === 1 ? "line" : "lines"}`}
|
||||
</Text>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
width="fit"
|
||||
gap={0.25}
|
||||
padding={0.25}
|
||||
className="rounded-xl bg-background-tint-00"
|
||||
>
|
||||
<CopyIconButton
|
||||
size="sm"
|
||||
tooltip="Copy"
|
||||
getCopyText={getLogsText}
|
||||
/>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgDownload}
|
||||
tooltip="Download"
|
||||
onClick={handleDownload}
|
||||
/>
|
||||
</Section>
|
||||
</Section>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
340
web/src/refresh-pages/admin/HooksPage/HookStatusPopover.tsx
Normal file
340
web/src/refresh-pages/admin/HooksPage/HookStatusPopover.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeOnly } from "@/lib/dateUtils";
|
||||
import { Text } from "@opal/components";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import {
|
||||
SvgAlertTriangle,
|
||||
SvgCheckCircle,
|
||||
SvgMaximize2,
|
||||
SvgXOctagon,
|
||||
} from "@opal/icons";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import { useHookExecutionLogs } from "@/hooks/useHookExecutionLogs";
|
||||
import HookLogsModal from "@/refresh-pages/admin/HooksPage/HookLogsModal";
|
||||
import type {
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
|
||||
interface HookStatusPopoverProps {
|
||||
hook: HookResponse;
|
||||
spec: HookPointMeta | undefined;
|
||||
isBusy: boolean;
|
||||
}
|
||||
|
||||
export default function HookStatusPopover({
|
||||
hook,
|
||||
spec,
|
||||
isBusy,
|
||||
}: HookStatusPopoverProps) {
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
// true = opened by click (stays until dismissed); false = opened by hover (closes after 1s)
|
||||
const [clickOpened, setClickOpened] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const { hasRecentErrors, recentErrors, isLoading, error } =
|
||||
useHookExecutionLogs(hook.id);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
console.error(
|
||||
"HookStatusPopover: failed to fetch execution logs:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
function clearCloseTimer() {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleClose() {
|
||||
clearCloseTimer();
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
setClickOpened(false);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function handleTriggerMouseEnter() {
|
||||
clearCloseTimer();
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function handleTriggerMouseLeave() {
|
||||
if (!clickOpened) scheduleClose();
|
||||
}
|
||||
|
||||
function handleTriggerClick() {
|
||||
clearCloseTimer();
|
||||
if (open && clickOpened) {
|
||||
// Click while click-opened → close
|
||||
setOpen(false);
|
||||
setClickOpened(false);
|
||||
} else {
|
||||
// Any click → open and pin
|
||||
setOpen(true);
|
||||
setClickOpened(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentMouseEnter() {
|
||||
clearCloseTimer();
|
||||
}
|
||||
|
||||
function handleContentMouseLeave() {
|
||||
if (!clickOpened) scheduleClose();
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
setOpen(false);
|
||||
setClickOpened(false);
|
||||
clearCloseTimer();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<HookLogsModal
|
||||
open={logsOpen}
|
||||
onOpenChange={setLogsOpen}
|
||||
hook={hook}
|
||||
spec={spec}
|
||||
/>
|
||||
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover.Anchor asChild>
|
||||
<div
|
||||
onMouseEnter={handleTriggerMouseEnter}
|
||||
onMouseLeave={handleTriggerMouseLeave}
|
||||
onClick={handleTriggerClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1 cursor-pointer rounded-xl p-2 transition-colors hover:bg-background-neutral-02",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
Connected
|
||||
</Text>
|
||||
{hasRecentErrors ? (
|
||||
<SvgAlertTriangle
|
||||
size={16}
|
||||
className="text-status-warning-05 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<SvgCheckCircle
|
||||
size={16}
|
||||
className="text-status-success-05 shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Anchor>
|
||||
|
||||
<Popover.Content
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
onMouseEnter={handleContentMouseEnter}
|
||||
onMouseLeave={handleContentMouseLeave}
|
||||
>
|
||||
<Section
|
||||
flexDirection="column"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
height="fit"
|
||||
width={hasRecentErrors ? 20 : 12.5}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Section justifyContent="center" height="fit" className="p-3">
|
||||
<SimpleLoader />
|
||||
</Section>
|
||||
) : error ? (
|
||||
<Section justifyContent="center" height="fit" className="p-3">
|
||||
<Text font="secondary-body" color="text-03">
|
||||
Failed to load logs.
|
||||
</Text>
|
||||
</Section>
|
||||
) : hasRecentErrors ? (
|
||||
// Errors state
|
||||
<>
|
||||
{/* Header: "N Errors" (≤3) or "Most Recent Errors" (>3) */}
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
gap={0.25}
|
||||
padding={0.375}
|
||||
height="fit"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<Section
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
width={1.25}
|
||||
height={1.25}
|
||||
className="shrink-0"
|
||||
>
|
||||
<SvgXOctagon size={16} className="text-status-error-05" />
|
||||
</Section>
|
||||
<Section
|
||||
flexDirection="column"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
width="fit"
|
||||
height="fit"
|
||||
gap={0}
|
||||
className="px-0.5"
|
||||
>
|
||||
<Text font="main-ui-action" color="text-04">
|
||||
{recentErrors.length <= 3
|
||||
? `${recentErrors.length} ${
|
||||
recentErrors.length === 1 ? "Error" : "Errors"
|
||||
}`
|
||||
: "Most Recent Errors"}
|
||||
</Text>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
in the past hour
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Separator noPadding className="py-1" />
|
||||
|
||||
{/* Log rows — at most 3, timestamp first then error message */}
|
||||
<Section
|
||||
flexDirection="column"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
gap={0.25}
|
||||
padding={0.25}
|
||||
height="fit"
|
||||
>
|
||||
{recentErrors.slice(0, 3).map((log, idx) => (
|
||||
<Section
|
||||
key={log.created_at + String(idx)}
|
||||
flexDirection="column"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
gap={0.25}
|
||||
padding={0.25}
|
||||
height="fit"
|
||||
>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
alignItems="center"
|
||||
gap={0}
|
||||
height="fit"
|
||||
>
|
||||
<span className="text-code-code">
|
||||
<Text font="secondary-mono-label" color="inherit">
|
||||
{formatTimeOnly(log.created_at)}
|
||||
</Text>
|
||||
</span>
|
||||
<CopyIconButton
|
||||
size="xs"
|
||||
getCopyText={() => log.error_message ?? ""}
|
||||
/>
|
||||
</Section>
|
||||
<span className="break-all">
|
||||
<Text font="secondary-mono" color="text-03">
|
||||
{log.error_message ?? "Unknown error"}
|
||||
</Text>
|
||||
</span>
|
||||
</Section>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{/* View More Lines */}
|
||||
<LineItem
|
||||
muted
|
||||
icon={SvgMaximize2}
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
setLogsOpen(true);
|
||||
}}
|
||||
>
|
||||
View More Lines
|
||||
</LineItem>
|
||||
</>
|
||||
) : (
|
||||
// No errors state
|
||||
<>
|
||||
{/* No Error / in the past hour */}
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
gap={0.25}
|
||||
padding={0.375}
|
||||
height="fit"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<Section
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
width={1.25}
|
||||
height={1.25}
|
||||
className="shrink-0"
|
||||
>
|
||||
<SvgCheckCircle
|
||||
size={16}
|
||||
className="text-status-success-05"
|
||||
/>
|
||||
</Section>
|
||||
<Section
|
||||
flexDirection="column"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
width="fit"
|
||||
height="fit"
|
||||
gap={0}
|
||||
className="px-0.5"
|
||||
>
|
||||
<Text font="main-ui-action" color="text-04">
|
||||
No Error
|
||||
</Text>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
in the past hour
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Separator noPadding className="py-1" />
|
||||
|
||||
{/* View Older Errors */}
|
||||
<LineItem
|
||||
muted
|
||||
icon={SvgMaximize2}
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
setLogsOpen(true);
|
||||
}}
|
||||
>
|
||||
View Older Errors
|
||||
</LineItem>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
HookCreateRequest,
|
||||
HookExecutionRecord,
|
||||
HookResponse,
|
||||
HookUpdateRequest,
|
||||
HookValidateResponse,
|
||||
@@ -24,7 +25,8 @@ async function parseError(res: Response, fallback: string): Promise<Error> {
|
||||
);
|
||||
}
|
||||
return new Error(body?.detail ?? fallback);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error("parseError: failed to parse error response body:", err);
|
||||
return new Error(fallback);
|
||||
}
|
||||
}
|
||||
@@ -94,3 +96,16 @@ export async function validateHook(id: number): Promise<HookValidateResponse> {
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchExecutionLogs(
|
||||
id: number,
|
||||
limit = 20
|
||||
): Promise<HookExecutionRecord[]> {
|
||||
const res = await fetch(
|
||||
`/api/admin/hooks/${id}/execution-logs?limit=${limit}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw await parseError(res, "Failed to fetch execution logs");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
IconProps,
|
||||
OpenAIIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import ProviderCard from "@/sections/cards/ProviderCard";
|
||||
import { Select } from "@/refresh-components/cards";
|
||||
import Message from "@/refresh-components/messages/Message";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { FetchError } from "@/lib/fetcher";
|
||||
@@ -488,7 +488,7 @@ export default function VoiceConfigurationPage() {
|
||||
const Icon = getProviderIcon(model.providerType);
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
<Select
|
||||
key={`${mode}-${model.id}`}
|
||||
aria-label={`voice-${mode}-${model.id}`}
|
||||
icon={Icon}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { Button, SelectCard } from "@opal/components";
|
||||
import { Content, CardHeaderLayout } from "@opal/layouts";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgArrowRightCircle,
|
||||
SvgCheckSquare,
|
||||
SvgSettings,
|
||||
SvgUnplug,
|
||||
} from "@opal/icons";
|
||||
|
||||
type ProviderStatus = "disconnected" | "connected" | "selected";
|
||||
|
||||
interface ProviderCardProps {
|
||||
icon: IconFunctionComponent;
|
||||
title: string;
|
||||
description: string;
|
||||
status: ProviderStatus;
|
||||
onConnect?: () => void;
|
||||
onSelect?: () => void;
|
||||
onDeselect?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
selectedLabel?: string;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
const STATUS_TO_STATE = {
|
||||
disconnected: "empty",
|
||||
connected: "filled",
|
||||
selected: "selected",
|
||||
} as const;
|
||||
|
||||
export default function ProviderCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
onConnect,
|
||||
onSelect,
|
||||
onDeselect,
|
||||
onEdit,
|
||||
onDisconnect,
|
||||
selectedLabel = "Current Default",
|
||||
"aria-label": ariaLabel,
|
||||
}: ProviderCardProps) {
|
||||
const isDisconnected = status === "disconnected";
|
||||
const isConnected = status === "connected";
|
||||
const isSelected = status === "selected";
|
||||
|
||||
return (
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state={STATUS_TO_STATE[status]}
|
||||
sizeVariant="lg"
|
||||
aria-label={ariaLabel}
|
||||
onClick={isDisconnected && onConnect ? onConnect : undefined}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
rightChildren={
|
||||
isDisconnected && onConnect ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
rightIcon={SvgArrowExchange}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConnect();
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
) : isConnected && onSelect ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
rightIcon={SvgArrowRightCircle}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
>
|
||||
Set as Default
|
||||
</Button>
|
||||
) : isSelected ? (
|
||||
<div className="p-2">
|
||||
<Content
|
||||
title={selectedLabel}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgCheckSquare}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
bottomRightChildren={
|
||||
!isDisconnected ? (
|
||||
<div className="flex flex-row px-1 pb-1">
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
icon={SvgUnplug}
|
||||
tooltip="Disconnect"
|
||||
aria-label={`Disconnect ${title}`}
|
||||
prominence="tertiary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDisconnect();
|
||||
}}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
icon={SvgSettings}
|
||||
tooltip="Edit"
|
||||
aria-label={`Edit ${title}`}
|
||||
prominence="tertiary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</SelectCard>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ProviderCardProps, ProviderStatus };
|
||||
Reference in New Issue
Block a user