merge: fix/dark-theme-sidebar
# Conflicts: # .github/workflows/docker-deploy.yml # package.json # pnpm-lock.yaml # web/src/__e2e__/api.servertest.ts # web/src/__e2e__/auth.spec.ts # web/src/__e2e__/create-project.spec.ts # web/src/__tests__/admin-api-key-auth.servertest.ts # web/src/__tests__/annotation-queues-api.servertest.ts # web/src/__tests__/api-auth.servertest.ts # web/src/__tests__/async/annotation-queue-assignments-api.servertest.ts # web/src/__tests__/async/automations-trpc.servertest.ts # web/src/__tests__/async/blob-storage-integration-api.servertest.ts # web/src/__tests__/async/dataset-service.servertest.ts # web/src/__tests__/async/datasets-api.servertest.ts # web/src/__tests__/async/datasets-schema-validation.servertest.ts # web/src/__tests__/async/event-query-builder.servertest.ts # web/src/__tests__/async/ingestion-api.servertest.ts # web/src/__tests__/async/llm-connections-api.servertest.ts # web/src/__tests__/async/mcp-error-formatting.servertest.ts # web/src/__tests__/async/mcp-tools-read.servertest.ts # web/src/__tests__/async/memberships-api.servertest.ts # web/src/__tests__/async/metrics-api.servertest.ts # web/src/__tests__/async/model-pricing-tiers.servertest.ts # web/src/__tests__/async/observation-api.servertest.ts # web/src/__tests__/async/observations-api-v2.servertest.ts # web/src/__tests__/async/observations-api.servertest.ts # web/src/__tests__/async/organizations-api.servertest.ts # web/src/__tests__/async/otel-api.servertest.ts # web/src/__tests__/async/projects-api.servertest.ts # web/src/__tests__/async/prompts.v2.servertest.ts # web/src/__tests__/async/repositories/datastore-resource-errors.servertest.ts # web/src/__tests__/async/repositories/environment-repository.servertest.ts # web/src/__tests__/async/repositories/event-repository.servertest.ts # web/src/__tests__/async/repositories/observation-repository.servertest.ts # web/src/__tests__/async/repositories/trace-repository.servertest.ts # web/src/__tests__/async/scim-api.servertest.ts # web/src/__tests__/async/scores-api-v1.servertest.ts # web/src/__tests__/async/sessions-api.servertest.ts # web/src/__tests__/async/sessions-trpc.servertest.ts # web/src/__tests__/async/traces-api.servertest.ts # web/src/__tests__/async/users-ui-table.servertest.ts # web/src/__tests__/compileChatMessages.servertest.ts # web/src/__tests__/dashboard-router-pivot-table.servertest.ts # web/src/__tests__/media.servertest.ts # web/src/__tests__/model-definitions.servertest.ts # web/src/__tests__/promptCache.servertest.ts # web/src/__tests__/queryBuilder.servertest.ts # web/src/__tests__/queryBuilderDashboards.servertest.ts # web/src/__tests__/rate-limit.servertest.ts # web/src/__tests__/server/api/otel/otelMapping.servertest.ts # web/src/__tests__/server/api/tables/prompts-ui.servertest.ts # web/src/__tests__/table-view-presets.clienttest.ts # web/src/__tests__/test-utils.ts # web/src/__tests__/transformScores.clienttest.ts # web/src/__tests__/withMiddlewares.servertest.ts # web/src/components/ActionButton.tsx # web/src/components/ChatMessages/index.tsx # web/src/components/ChatMessages/types.ts # web/src/components/ChatMessages/utils/createEmptyMessage.ts # web/src/components/ModelParameters/index.tsx # web/src/components/NoDataOrLoading.tsx # web/src/components/TruncatedLabels.tsx # web/src/components/VersionLabel.tsx # web/src/components/date-picker.tsx # web/src/components/date-range-dropdowns.tsx # web/src/components/deleteButton.tsx # web/src/components/editor/CodeMirrorEditor.tsx # web/src/components/grouped-score-badge.tsx # web/src/components/layouts/app-layout/components/ResizableContent.tsx # web/src/components/layouts/app-layout/hooks/useFilteredNavigation.ts # web/src/components/layouts/app-layout/hooks/useLayoutMetadata.ts # web/src/components/layouts/app-layout/utils/navigationFilters.ts # web/src/components/layouts/app-layout/variants/AuthenticatedLayout.tsx # web/src/components/layouts/breadcrumb.tsx # web/src/components/layouts/doc-popup.tsx # web/src/components/layouts/page-header.tsx # web/src/components/nav/app-sidebar.tsx # web/src/components/nav/sidebar-notifications.tsx # web/src/components/onboarding/DatasetItemsOnboarding.tsx # web/src/components/onboarding/SessionsOnboarding.tsx # web/src/components/onboarding/TracesOnboarding.tsx # web/src/components/onboarding/UsersOnboarding.tsx # web/src/components/publish-object-switch.tsx # web/src/components/scores-table-cell.tsx # web/src/components/session/index.tsx # web/src/components/table/data-table-ai-filters.tsx # web/src/components/table/data-table-column-visibility-filter.tsx # web/src/components/table/data-table-controls.tsx # web/src/components/table/data-table-pagination.tsx # web/src/components/table/data-table-toolbar.tsx # web/src/components/table/data-table.tsx # web/src/components/table/filtered-run-pills.tsx # web/src/components/table/key-value-filter-builder.tsx # web/src/components/table/peek.tsx # web/src/components/table/peek/hooks/usePeekData.ts # web/src/components/table/peek/peek-evaluator-config-detail.tsx # web/src/components/table/resizable-filter-layout.tsx # web/src/components/table/table-view-presets/components/data-table-view-presets-drawer.tsx # web/src/components/table/table-view-presets/hooks/useTableViewManager.ts # web/src/components/table/table-view-presets/validation.ts # web/src/components/table/types.ts # web/src/components/table/use-cases/models.tsx # web/src/components/table/use-cases/observations.tsx # web/src/components/table/use-cases/score-configs.tsx # web/src/components/table/use-cases/scores.tsx # web/src/components/table/use-cases/sessions.tsx # web/src/components/table/use-cases/traces.tsx # web/src/components/table/use-cases/useFullTextSearch.tsx # web/src/components/trace2/ObservationPreview.tsx # web/src/components/trace2/TracePage.tsx # web/src/components/trace2/TracePreview.tsx # web/src/components/trace2/components/IOPreview/IOPreview.tsx # web/src/components/trace2/components/IOPreview/IOPreviewJSON.tsx # web/src/components/trace2/components/IOPreview/components/CorrectedOutputField.tsx # web/src/components/trace2/components/IOPreview/components/ToolCallDefinitionCard.tsx # web/src/components/trace2/components/ObservationDetailView/ObservationDetailView.tsx # web/src/components/trace2/components/ObservationDetailView/ObservationDetailViewHeader.tsx # web/src/components/trace2/components/SpanContent.tsx # web/src/components/trace2/components/TraceDetailView/TraceDetailView.tsx # web/src/components/trace2/components/TraceDetailView/TraceDetailViewHeader.tsx # web/src/components/trace2/components/TraceDetailView/TraceMetadataBadges.tsx # web/src/components/trace2/components/TraceLogView/LogViewToolbar.tsx # web/src/components/trace2/components/TraceSettingsDropdown.tsx # web/src/components/trace2/components/_shared/BreakdownToolTip.tsx # web/src/components/trace2/components/_shared/CopyIdsPopover.tsx # web/src/components/trace2/contexts/TraceGraphDataContext.tsx # web/src/components/trace2/lib/helpers.ts # web/src/components/trace2/lib/tree-building.ts # web/src/components/ui/AdvancedJsonViewer/components/JsonKey.tsx # web/src/components/ui/AdvancedJsonViewer/components/JsonValue.tsx # web/src/components/ui/AdvancedJsonViewer/components/MediaButtonGroup.tsx # web/src/components/ui/AdvancedJsonViewer/components/TruncatedString.tsx # web/src/components/ui/HanzoMediaView.tsx # web/src/components/ui/IOTableCell.tsx # web/src/components/ui/PrettyJsonView.tsx # web/src/components/ui/accordion.tsx # web/src/components/ui/chart.tsx # web/src/components/ui/checkbox.tsx # web/src/components/ui/combobox.tsx # web/src/components/ui/dialog.tsx # web/src/components/ui/hover-card.tsx # web/src/components/ui/multi-select-combobox.tsx # web/src/components/ui/progress.tsx # web/src/components/ui/radio-group.tsx # web/src/components/ui/sidebar.tsx # web/src/components/ui/skeleton.tsx # web/src/components/ui/textarea.tsx # web/src/components/ui/time-picker.tsx # web/src/ee/features/admin-api/server/adminApiAuth.ts # web/src/ee/features/admin-api/server/memberships.ts # web/src/ee/features/admin-api/server/organizations/apiKeys/apiKeyById.ts # web/src/ee/features/admin-api/server/organizations/apiKeys/index.ts # web/src/ee/features/admin-api/server/organizations/index.ts # web/src/ee/features/admin-api/server/organizations/organizationById.ts # web/src/ee/features/admin-api/server/projects.ts # web/src/ee/features/admin-api/server/projects/createProject.ts # web/src/ee/features/admin-api/server/projects/projectById/apiKeys/apiKeyById.ts # web/src/ee/features/admin-api/server/projects/projectById/apiKeys/index.ts # web/src/ee/features/admin-api/server/projects/projectById/index.ts # web/src/ee/features/audit-log-viewer/AuditLogsSettingsPage.tsx # web/src/ee/features/audit-log-viewer/OrgAuditLogsSettingsPage.tsx # web/src/ee/features/billing/components/BillingDiscountCodeButton.tsx # web/src/ee/features/billing/components/BillingSettings.tsx # web/src/ee/features/billing/components/SupportOrUpgradePage.tsx # web/src/ee/features/billing/server/cloudBillingRouter.ts # web/src/ee/features/billing/server/spendAlertRouter.ts # web/src/ee/features/billing/server/stripeBillingService.ts # web/src/ee/features/billing/server/stripeWebhookHandler.ts # web/src/ee/features/billing/utils/stripeCatalogue.ts # web/src/ee/features/billing/utils/stripeClientReference.ts # web/src/ee/features/billing/utils/stripeExpand.ts # web/src/ee/features/billing/utils/stripeSubscriptionMetadata.ts # web/src/ee/features/multi-tenant-sso/utils.ts # web/src/ee/features/sso-settings/components/SSOSettings.tsx # web/src/ee/features/ui-customization/uiCustomizationRouter.ts # web/src/features/agents/components/ui/alert.tsx # web/src/features/agents/components/ui/breadcrumb.tsx # web/src/features/agents/components/ui/label.tsx # web/src/features/agents/components/ui/scroll-area.tsx # web/src/features/agents/components/ui/separator.tsx # web/src/features/agents/components/ui/table.tsx # web/src/features/annotation-queues/components/AnnotationQueueItemPage.tsx # web/src/features/annotation-queues/components/AnnotationQueuesItem.tsx # web/src/features/annotation-queues/components/CreateOrEditAnnotationQueueButton.tsx # web/src/features/annotation-queues/components/processors/SessionAnnotationProcessor.tsx # web/src/features/annotation-queues/components/processors/TraceAnnotationProcessor.tsx # web/src/features/annotation-queues/components/shared/AnnotationDrawerSection.tsx # web/src/features/annotation-queues/components/shared/hooks/useAnnotationObjectData.ts # web/src/features/annotation-queues/server/annotationQueueAssignmentsRouter.ts # web/src/features/annotation-queues/server/annotationQueueItemsRouter.ts # web/src/features/audit-log-viewer/AuditLogsSettingsPage.tsx # web/src/features/audit-log-viewer/OrgAuditLogsSettingsPage.tsx # web/src/features/audit-logs/auditLog.ts # web/src/features/auth/components/AuthCloudRegionSwitch.tsx # web/src/features/auth/hooks.ts # web/src/features/auth/lib/createProjectMembershipsOnSignup.ts # web/src/features/automations/components/AutomationDetails.tsx # web/src/features/automations/components/DeleteAutomationButton.tsx # web/src/features/automations/components/actions/BaseActionHandler.ts # web/src/features/automations/components/actions/GitHubDispatchActionHandler.tsx # web/src/features/automations/components/actions/SlackActionHandler.ts # web/src/features/automations/components/actions/WebhookActionForm.tsx # web/src/features/automations/components/automationForm.tsx # web/src/features/automations/server/githubDispatchHelpers.ts # web/src/features/automations/server/webhookHelpers.ts # web/src/features/background-migrations/components/retry-background-migration.tsx # web/src/features/batch-actions/components/AddObservationsToDatasetDialog/DatasetSelectStep.tsx # web/src/features/batch-actions/components/AddObservationsToDatasetDialog/FinalPreviewStep.tsx # web/src/features/batch-actions/components/AddObservationsToDatasetDialog/StatusStep.tsx # web/src/features/batch-actions/components/AddObservationsToDatasetDialog/components/CustomMappingEditor.tsx # web/src/features/batch-actions/components/AddObservationsToDatasetDialog/components/JsonPathInput.tsx # web/src/features/batch-actions/components/AddObservationsToDatasetDialog/components/MappingPreviewPanel.tsx # web/src/features/batch-actions/components/AddObservationsToDatasetDialog/types.ts # web/src/features/batch-actions/components/BatchActionsTable.tsx # web/src/features/batch-actions/server/addToDatasetRouter.ts # web/src/features/batch-actions/server/batchActionRouter.ts # web/src/features/batch-actions/validation.ts # web/src/features/batch-exports/components/BatchExportsTable.tsx # web/src/features/batch-exports/server/batchExport.ts # web/src/features/billing/analytics.ts # web/src/features/billing/checkoutReference.ts # web/src/features/billing/components/BillingActionButtons.tsx # web/src/features/billing/components/BillingDiscountCodeButton.tsx # web/src/features/billing/components/BillingDiscountView.tsx # web/src/features/billing/components/BillingInvoiceTable.tsx # web/src/features/billing/components/BillingSettings.tsx # web/src/features/billing/components/BillingSwitchPlanDialog.tsx # web/src/features/billing/components/BillingUsageChart.tsx # web/src/features/billing/components/PlanSectionModal.tsx # web/src/features/billing/components/SpendAlerts/DeleteSpendAlertDialog.tsx # web/src/features/billing/components/SpendAlerts/SpendAlertDialog.tsx # web/src/features/billing/components/SpendAlerts/SpendAlertsTable.tsx # web/src/features/billing/components/StripeCancellationButton.tsx # web/src/features/billing/components/StripeKeepPlanButton.tsx # web/src/features/billing/components/StripeSwitchPlanButton.tsx # web/src/features/billing/components/overview/BillingOverview.tsx # web/src/features/billing/components/overview/InvoiceHistory.tsx # web/src/features/billing/components/overview/PaymentManagement.tsx # web/src/features/billing/components/useBillingInformation.tsx # web/src/features/billing/server/analytics.ts # web/src/features/billing/server/cloudBillingRouter.ts # web/src/features/billing/server/stripeWebhookApiHandler.ts # web/src/features/billing/utils/stripeProducts.ts # web/src/features/blobstorage-integration/blobstorage-integration-router.ts # web/src/features/blobstorage-integration/types.ts # web/src/features/cloud-status-notification/components/CloudStatusMenu.tsx # web/src/features/column-visibility/hooks/useColumnOrder.ts # web/src/features/column-visibility/hooks/useColumnVisibility.ts # web/src/features/command-k-menu/CommandMenu.tsx # web/src/features/command-k-menu/CommandMenuProvider.tsx # web/src/features/comments/CommentList.tsx # web/src/features/comments/ReactionBar.tsx # web/src/features/comments/ReactionPicker.tsx # web/src/features/dashboard/components/BaseTimeSeriesChart.tsx # web/src/features/dashboard/components/ChartScores.tsx # web/src/features/dashboard/components/LatencyChart.tsx # web/src/features/dashboard/components/LatencyTables.tsx # web/src/features/dashboard/components/ModelCostTable.tsx # web/src/features/dashboard/components/ModelSelector.tsx # web/src/features/dashboard/components/ModelUsageChart.tsx # web/src/features/dashboard/components/ScoresTable.tsx # web/src/features/dashboard/components/SelectDashboardDialog.tsx # web/src/features/dashboard/components/TabTimeSeriesChart.tsx # web/src/features/dashboard/components/TabsComponent.tsx # web/src/features/dashboard/components/Tooltip.tsx # web/src/features/dashboard/components/TracesBarListChart.tsx # web/src/features/dashboard/components/TracesTimeSeriesChart.tsx # web/src/features/dashboard/components/UserChart.tsx # web/src/features/dashboard/components/score-analytics/CategoricalScoreChart.tsx # web/src/features/dashboard/components/score-analytics/NumericScoreHistogram.tsx # web/src/features/dashboard/components/score-analytics/NumericScoreTimeSeriesChart.tsx # web/src/features/dashboard/components/score-analytics/ScoreAnalytics.tsx # web/src/features/dashboard/server/dashboard-router.ts # web/src/features/datasets/components/AnnotationPanel.tsx # web/src/features/datasets/components/CsvUploadDialog.tsx # web/src/features/datasets/components/DatasetActionButton.tsx # web/src/features/datasets/components/DatasetCompareRunsTable.tsx # web/src/features/datasets/components/DatasetForm.tsx # web/src/features/datasets/components/DatasetItemDetailPage.tsx # web/src/features/datasets/components/DatasetItemDiffView.tsx # web/src/features/datasets/components/DatasetItemField.tsx # web/src/features/datasets/components/DatasetItemVersionedContent.tsx # web/src/features/datasets/components/DatasetItemViewModeContent.tsx # web/src/features/datasets/components/DatasetItemsTable.tsx # web/src/features/datasets/components/DatasetRunItemsByRunTable.tsx # web/src/features/datasets/components/DatasetRunsTable.tsx # web/src/features/datasets/components/DatasetSchemaHoverCard.tsx # web/src/features/datasets/components/DatasetVersionHistoryPanel.tsx # web/src/features/datasets/components/MappingCard.tsx # web/src/features/datasets/components/NewDatasetItemForm.tsx # web/src/features/datasets/components/PreviewCsvImport.tsx # web/src/features/datasets/contexts/ActiveCellContext.tsx # web/src/features/datasets/hooks/useCsvImport.ts # web/src/features/datasets/hooks/useDatasetRunAggregateColumns.ts # web/src/features/datasets/lib/convertRunItemDataToUiTableRow.ts # web/src/features/datasets/lib/csv/helpers.ts # web/src/features/datasets/server/dataset-router.ts # web/src/features/entitlements/constants/entitlements.ts # web/src/features/entitlements/hooks.ts # web/src/features/entitlements/server/getPlan.ts # web/src/features/entitlements/server/hasEntitlement.ts # web/src/features/entitlements/server/hasEntitlementLimit.ts # web/src/features/evals/components/deactivate-config.tsx # web/src/features/evals/components/default-eval-model-setup.tsx # web/src/features/evals/components/eval-form-descriptions.tsx # web/src/features/evals/components/eval-templates-table.tsx # web/src/features/evals/components/evaluator-selector.tsx # web/src/features/evals/components/evaluator-table.tsx # web/src/features/evals/components/inner-evaluator-form.tsx # web/src/features/evals/components/maintainer-tooltip.tsx # web/src/features/evals/components/template-selector.tsx # web/src/features/evals/hooks/useEvalConfigMappingData.ts # web/src/features/evals/hooks/useEvalTargetCount.ts # web/src/features/evals/hooks/useEvaluationModel.ts # web/src/features/evals/hooks/useExtractVariables.ts # web/src/features/evals/pages/evaluators.tsx # web/src/features/evals/pages/templates.tsx # web/src/features/evals/server/addDatasetRunItemsToEvalQueue.ts # web/src/features/evals/server/router.ts # web/src/features/evals/types.ts # web/src/features/evals/utils/evaluator-form-utils.ts # web/src/features/events/components/EventsTable.tsx # web/src/features/events/components/EventsViewModeToggle.tsx # web/src/features/events/hooks/useEventsTableData.ts # web/src/features/events/hooks/useEventsTraceData.ts # web/src/features/events/hooks/useObservationListBeta.ts # web/src/features/events/server/eventsRouter.ts # web/src/features/experiments/components/CreateExperimentsForm.tsx # web/src/features/experiments/components/MultiStepExperimentForm.tsx # web/src/features/experiments/components/steps/DatasetStep.tsx # web/src/features/experiments/components/steps/PromptModelStep.tsx # web/src/features/experiments/components/steps/ReviewStep.tsx # web/src/features/experiments/hooks/useExperimentEvaluatorData.ts # web/src/features/experiments/hooks/useExperimentPromptData.ts # web/src/features/experiments/server/router.ts # web/src/features/experiments/utils/evaluatorMappingUtils.ts # web/src/features/feature-flags/available-flags.ts # web/src/features/filters/components/filter-builder.tsx # web/src/features/filters/components/multi-select.tsx # web/src/features/filters/filter-integration.clienttest.ts # web/src/features/filters/hooks/useFilterState.ts # web/src/features/filters/hooks/useSidebarFilterState.tsx # web/src/features/filters/lib/filter-query-encoding-decoding.clienttest.ts # web/src/features/filters/lib/filter-query-encoding.ts # web/src/features/insights-analytics/useInsightsCapture.ts # web/src/features/llm-api-key/server/router.ts # web/src/features/llm-api-key/types.ts # web/src/features/llm-schemas/server/router.ts # web/src/features/llm-tools/server/router.ts # web/src/features/mcp/core/error-formatting.ts # web/src/features/mcp/features/prompts/tools/listPrompts.ts # web/src/features/media/server/getMediaStorageClient.ts # web/src/features/mixpanel-integration/mixpanel-integration-router.ts # web/src/features/models/components/DeleteModelButton.tsx # web/src/features/models/components/ModelSettings.tsx # web/src/features/models/components/PriceBreakdownTooltip.tsx # web/src/features/models/components/PriceUnitSelector.tsx # web/src/features/models/components/UpsertModelFormDialog.tsx # web/src/features/models/components/pricing-tiers/TierAccordionItem.tsx # web/src/features/models/components/pricing-tiers/TierConditionsEditor.tsx # web/src/features/models/validation.ts # web/src/features/natural-language-filters/server/router.ts # web/src/features/navigate-detail-pages/DetailPageNav.tsx # web/src/features/notifications/Notification.tsx # web/src/features/onboarding/components/SurveyStep.tsx # web/src/features/orderBy/hooks/useOrderByState.ts # web/src/features/organizations/components/AIFeatureSwitch.tsx # web/src/features/organizations/components/ProjectOverview.tsx # web/src/features/organizations/server/organizationRouter.ts # web/src/features/playground/page/components/ConfigurationDropdowns.tsx # web/src/features/playground/page/components/JumpToPlaygroundButton.tsx # web/src/features/playground/page/components/MessagePlaceholders.tsx # web/src/features/playground/page/components/MultiWindowPlayground.tsx # web/src/features/playground/page/components/PlaygroundTools/index.tsx # web/src/features/playground/page/components/SaveToPromptButton.tsx # web/src/features/playground/page/components/StructuredOutputSchemaSection.tsx # web/src/features/playground/page/components/Variables.tsx # web/src/features/playground/page/context/index.tsx # web/src/features/playground/page/hooks/useModelParams.ts # web/src/features/playground/page/index.tsx # web/src/features/playground/server/chatCompletionHandler.ts # web/src/features/playground/server/validateChatCompletionBody.ts # web/src/features/posthog-analytics/ServerPosthog.ts # web/src/features/posthog-integration/posthog-integration-router.ts # web/src/features/posthog-integration/types.ts # web/src/features/projects/components/NewProjectForm.tsx # web/src/features/projects/components/TransferProjectButton.tsx # web/src/features/projects/server/projectsRouter.ts # web/src/features/prompts/components/NewPromptForm/index.tsx # web/src/features/prompts/components/PromptSelectionDialog.tsx # web/src/features/prompts/components/ProtectedLabelsSettings.tsx # web/src/features/prompts/components/SetPromptVersionLabels/index.tsx # web/src/features/prompts/components/delete-prompt-version.tsx # web/src/features/prompts/components/delete-prompt.tsx # web/src/features/prompts/components/prompt-detail.tsx # web/src/features/prompts/server/actions/createPrompt.ts # web/src/features/prompts/server/actions/getPromptByName.ts # web/src/features/prompts/server/actions/getPromptsMeta.ts # web/src/features/prompts/server/handlers/promptNameHandler.ts # web/src/features/prompts/server/handlers/promptsHandler.ts # web/src/features/prompts/server/promptChangeEventSourcing.ts # web/src/features/prompts/server/routers/promptRouter.ts # web/src/features/prompts/server/utils/authorizePromptRequest.ts # web/src/features/public-api/components/ApiKeyList.tsx # web/src/features/public-api/components/CreateApiKeyButton.tsx # web/src/features/public-api/components/CreateLLMApiKeyForm.tsx # web/src/features/public-api/components/LLMApiKeyList.tsx # web/src/features/public-api/hooks/useHanzoEnvCode.ts # web/src/features/public-api/server/RateLimitService.ts # web/src/features/public-api/server/apiAuth.ts # web/src/features/public-api/server/createAuthedProjectAPIRoute.ts # web/src/features/public-api/server/dailyMetrics.ts # web/src/features/public-api/server/dataset-run-items.ts # web/src/features/public-api/server/observations.ts # web/src/features/public-api/server/scores-api-service.ts # web/src/features/public-api/server/scores.ts # web/src/features/public-api/server/traces.ts # web/src/features/public-api/server/withMiddlewares.ts # web/src/features/public-api/types/annotation-queues.ts # web/src/features/public-api/types/comments.ts # web/src/features/public-api/types/generations.ts # web/src/features/public-api/types/metrics.ts # web/src/features/public-api/types/observations.ts # web/src/features/public-api/types/sessions.ts # web/src/features/public-api/types/spans.ts # web/src/features/query/dataModel.ts # web/src/features/query/server/queryBuilder.ts # web/src/features/query/server/queryExecutor.ts # web/src/features/rbac/components/CreateProjectMemberButton.tsx # web/src/features/rbac/components/MembersTable.tsx # web/src/features/rbac/components/MembershipInvitesPage.tsx # web/src/features/rbac/components/RoleSelectItem.tsx # web/src/features/rbac/server/allInvitesRoutes.ts # web/src/features/rbac/server/allMembersRoutes.ts # web/src/features/rbac/server/membersRouter.ts # web/src/features/rbac/utils/checkProjectAccess.ts # web/src/features/score-analytics/components/SamplingDetailsHoverCard.tsx # web/src/features/score-analytics/components/ScoreAnalyticsHeader.tsx # web/src/features/score-analytics/components/cards/DistributionBooleanCard.tsx # web/src/features/score-analytics/components/cards/DistributionCategoricalCard.tsx # web/src/features/score-analytics/components/cards/DistributionNumericCard.tsx # web/src/features/score-analytics/components/cards/TimelineChartCard.tsx # web/src/features/score-analytics/components/charts/Heatmap.tsx # web/src/features/score-analytics/components/charts/HeatmapCell.tsx # web/src/features/score-analytics/components/charts/MetricCard.tsx # web/src/features/score-analytics/components/charts/ScoreChartLegendContent.tsx # web/src/features/score-analytics/hooks/useScoreAnalyticsQuery.ts # web/src/features/score-analytics/lib/datastore-time-utils.ts # web/src/features/score-analytics/lib/score-analytics-transformers.ts # web/src/features/score-configs/components/ArchiveScoreConfigButton.tsx # web/src/features/score-configs/components/UpsertScoreConfigDialog.tsx # web/src/features/scores/components/AnnotateDrawer.tsx # web/src/features/scores/components/AnnotationForm.tsx # web/src/features/scores/components/ScoreChart.tsx # web/src/features/scores/components/ScoreRow.tsx # web/src/features/scores/components/TimeseriesChart.tsx # web/src/features/scores/contexts/ScoreCacheContext.tsx # web/src/features/scores/hooks/useScoreColumns.ts # web/src/features/scores/hooks/useScoreConfigSelection.ts # web/src/features/scores/lib/annotationFormHelpers.ts # web/src/features/scores/lib/getDefaultScoreData.ts # web/src/features/scores/lib/scoreColumns.ts # web/src/features/slack/components/ChannelSelector.tsx # web/src/features/slack/components/SlackConnectionCard.tsx # web/src/features/slack/server/oauth-handlers.ts # web/src/features/support-chat/IntroSection.tsx # web/src/features/support-chat/SupportFormSection.tsx # web/src/features/support-chat/trpc/plainRouter.ts # web/src/features/table/components/TableActionDialog.tsx # web/src/features/table/components/TableSelectionManager.tsx # web/src/features/table/server/tableRouter.ts # web/src/features/tag/components/TagCommandItem.tsx # web/src/features/tag/components/TagInput.tsx # web/src/features/tag/components/TagManager.tsx # web/src/features/telemetry/index.ts # web/src/features/trace-graph-view/buildStepData.ts # web/src/features/widgets/chart-library/BigNumber.tsx # web/src/features/widgets/chart-library/HistogramChart.tsx # web/src/features/widgets/chart-library/HorizontalBarChart.tsx # web/src/features/widgets/chart-library/LineChartTimeSeries.tsx # web/src/features/widgets/chart-library/PieChart.tsx # web/src/features/widgets/chart-library/PivotTable.tsx # web/src/features/widgets/chart-library/VerticalBarChart.tsx # web/src/features/widgets/chart-library/VerticalBarChartTimeSeries.tsx # web/src/features/widgets/components/DashboardWidget.tsx # web/src/features/widgets/components/SelectWidgetDialog.tsx # web/src/features/widgets/components/WidgetForm.tsx # web/src/features/widgets/components/WidgetPropertySelectItem.tsx # web/src/features/widgets/components/WidgetTable.tsx # web/src/hooks/useParsedObservation.ts # web/src/hooks/useParsedTrace.ts # web/src/initialize.ts # web/src/observability.config.ts # web/src/pages/_app.tsx # web/src/pages/_document.tsx # web/src/pages/api/admin/evals.ts # web/src/pages/api/admin/organizations/[organizationId]/index.ts # web/src/pages/api/admin/organizations/index.ts # web/src/pages/api/public/annotation-queues/[queueId]/assignments.ts # web/src/pages/api/public/comments/[commentId].ts # web/src/pages/api/public/dataset-items/[datasetItemId].ts # web/src/pages/api/public/dataset-items/index.ts # web/src/pages/api/public/dataset-run-items.ts # web/src/pages/api/public/datasets.ts # web/src/pages/api/public/datasets/[name]/index.ts # web/src/pages/api/public/datasets/[name]/runs/[runName].ts # web/src/pages/api/public/events.ts # web/src/pages/api/public/generations.ts # web/src/pages/api/public/ingestion.ts # web/src/pages/api/public/integrations/blob-storage/[id].ts # web/src/pages/api/public/integrations/blob-storage/index.ts # web/src/pages/api/public/media/[mediaId].ts # web/src/pages/api/public/media/index.ts # web/src/pages/api/public/metrics/index.ts # web/src/pages/api/public/observations/[observationId].ts # web/src/pages/api/public/otel/v1/traces/index.ts # web/src/pages/api/public/projects/[projectId]/apiKeys/[apiKeyId].ts # web/src/pages/api/public/projects/[projectId]/apiKeys/index.ts # web/src/pages/api/public/projects/[projectId]/index.ts # web/src/pages/api/public/projects/[projectId]/memberships/index.ts # web/src/pages/api/public/score-configs/[configId].ts # web/src/pages/api/public/score-configs/index.ts # web/src/pages/api/public/scores/[scoreId].ts # web/src/pages/api/public/scores/index.ts # web/src/pages/api/public/sessions/[sessionId].ts # web/src/pages/api/public/sessions/index.ts # web/src/pages/api/public/spans.ts # web/src/pages/api/public/traces/[traceId].ts # web/src/pages/api/public/v2/metrics.ts # web/src/pages/api/public/v2/scores/[scoreId].ts # web/src/pages/auth/enterprise-sso-required.tsx # web/src/pages/auth/sign-in.tsx # web/src/pages/auth/sign-up.tsx # web/src/pages/project/[projectId]/dashboards/index.tsx # web/src/pages/project/[projectId]/datasets/[datasetId]/compare/charts.tsx # web/src/pages/project/[projectId]/datasets/[datasetId]/runs/[runId].tsx # web/src/pages/project/[projectId]/index.tsx # web/src/pages/project/[projectId]/observations.tsx # web/src/pages/project/[projectId]/observations/new.tsx # web/src/pages/project/[projectId]/prompts/metrics.tsx # web/src/pages/project/[projectId]/sessions.tsx # web/src/pages/project/[projectId]/settings/index.tsx # web/src/pages/project/[projectId]/settings/integrations/blobstorage.tsx # web/src/pages/project/[projectId]/settings/integrations/mixpanel.tsx # web/src/pages/project/[projectId]/settings/integrations/posthog.tsx # web/src/pages/project/[projectId]/settings/models/[modelId].tsx # web/src/pages/project/[projectId]/traces.tsx # web/src/pages/project/[projectId]/traces/setup.tsx # web/src/pages/project/[projectId]/users.tsx # web/src/pages/project/[projectId]/users/[userId].tsx # web/src/pages/project/[projectId]/widgets/new.tsx # web/src/server/api/routers/comments.ts # web/src/server/api/routers/dashboardWidgets.ts # web/src/server/api/routers/generations/db/getAllGenerationsSqlQuery.ts # web/src/server/api/routers/generations/filterOptionsQuery.ts # web/src/server/api/routers/generations/getAllQueries.ts # web/src/server/api/routers/media.ts # web/src/server/api/routers/models.ts # web/src/server/api/routers/observations.ts # web/src/server/api/routers/scoreConfigs.ts # web/src/server/api/routers/scores.ts # web/src/server/api/routers/sessions.ts # web/src/server/api/routers/surveys.ts # web/src/server/api/routers/tableViewPresets.ts # web/src/server/api/routers/traces.ts # web/src/server/api/routers/users.ts # web/src/server/api/trpc.ts # web/src/server/auth.ts # web/src/server/db.ts # web/src/server/utils/cookies.ts # web/src/utils/api.ts # web/src/utils/chatml/types.ts # web/src/utils/shutdown.ts # web/tailwind.config.ts # web/types/global.d.ts
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
name: Docker Build and Deploy to Hanzo Platform
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Environment to deploy to"
|
||||
type: choice
|
||||
options:
|
||||
- production
|
||||
- staging
|
||||
required: true
|
||||
default: production
|
||||
|
||||
concurrency:
|
||||
group: docker-deploy-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DOCKER_IMAGE: hanzoai/console
|
||||
REGISTRY: docker.io
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.DOCKER_IMAGE }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},value=${{ steps.version.outputs.version }}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
NODE_ENV=production
|
||||
|
||||
deploy-production:
|
||||
needs: build
|
||||
if: (github.ref == 'refs/heads/main' && github.event_name == 'push') || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production')
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Trigger Hanzo Platform Deployment
|
||||
run: |
|
||||
# Trigger deployment via Hanzo Platform webhook
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "User-Agent: Go-http-client/1.1" \
|
||||
-d '{
|
||||
"push_data": {
|
||||
"tag": "latest",
|
||||
"pusher": "${{ github.actor }}"
|
||||
},
|
||||
"repository": {
|
||||
"repo_name": "hanzoai/console"
|
||||
}
|
||||
}' \
|
||||
"https://platform.hanzo.ai/api/deploy/${{ secrets.PLATFORM_DEPLOY_TOKEN }}"
|
||||
|
||||
deploy-staging:
|
||||
needs: build
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'staging'
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
steps:
|
||||
- name: Trigger Hanzo Platform Staging Deployment
|
||||
run: |
|
||||
# Trigger staging deployment via Hanzo Platform webhook
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "User-Agent: Go-http-client/1.1" \
|
||||
-d '{
|
||||
"push_data": {
|
||||
"tag": "latest",
|
||||
"pusher": "${{ github.actor }}"
|
||||
},
|
||||
"repository": {
|
||||
"repo_name": "hanzoai/console"
|
||||
}
|
||||
}' \
|
||||
"https://platform.hanzo.ai/api/deploy/${{ secrets.PLATFORM_DEPLOY_TOKEN_STAGING }}"
|
||||
|
||||
notify:
|
||||
needs: [build, deploy-production, deploy-staging]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send notification
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
text: |
|
||||
Console Deployment Status: ${{ job.status }}
|
||||
Version: ${{ needs.build.outputs.version }}
|
||||
Commit: ${{ github.sha }}
|
||||
Author: ${{ github.actor }}
|
||||
Message: ${{ github.event.head_commit.message }}
|
||||
webhook_url: ${{ secrets.SLACK_WEBHOOK }}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
|
||||
+9
-28
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hanzo",
|
||||
"version": "3.155.0",
|
||||
"version": "3.148.0",
|
||||
"author": "engineering@hanzo.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -9,9 +9,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"infra:dev:up": "docker compose -f ./docker-compose.dev.yml up -d --wait",
|
||||
"infra:dev:down": "docker compose -f ./docker-compose.dev.yml down",
|
||||
"infra:dev:prune": "docker compose -f ./docker-compose.dev.yml down -v",
|
||||
"infra:dev:up": "docker compose -f ./compose.dev.yaml up -d --wait",
|
||||
"infra:dev:down": "docker compose -f ./compose.dev.yaml down",
|
||||
"infra:dev:prune": "docker compose -f ./compose.dev.yaml down -v",
|
||||
"db:generate": "turbo run db:generate",
|
||||
"db:migrate": "turbo run db:migrate",
|
||||
"db:seed": "turbo run db:seed",
|
||||
@@ -38,14 +38,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@release-it/bumper": "^7.0.5",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"braces": "3.0.3",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.6.2",
|
||||
"release-it": "^19.0.4",
|
||||
"turbo": "^2.8.10"
|
||||
"turbo": "^2.7.5"
|
||||
},
|
||||
"release-it": {
|
||||
"git": {
|
||||
@@ -91,7 +90,9 @@
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,css}": "prettier --write --experimental-cli"
|
||||
"**/*.{js,jsx,ts,tsx,css}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@9.5.0",
|
||||
"pnpm": {
|
||||
@@ -102,27 +103,7 @@
|
||||
"rollup@^4.0.0": "^4.22.4",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"glob": "^10.5.0",
|
||||
"qs": "6.14.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"vis-network": "^9.1.9",
|
||||
"bullmq": "npm:bullmq@^5.34.10",
|
||||
"@posthog/core": "npm:@hanzo/insights-core@1.23.2"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch",
|
||||
"@hanzo/ui@5.3.39": "patches/@hanzo__ui@5.3.39.patch"
|
||||
"qs": "6.14.1"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@autoform/react": "^4.0.0",
|
||||
"@autoform/shadcn": "^1.0.1",
|
||||
"@autoform/zod": "^5.0.0",
|
||||
"@deck.gl/core": "^9.2.6",
|
||||
"@deck.gl/layers": "^9.2.6",
|
||||
"@deck.gl/react": "^9.2.6",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"dagre": "^0.8.5",
|
||||
"elkjs": "^0.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+5275
-9049
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { v4 } from "uuid";
|
||||
import { JobExecutionStatus, Prisma, prisma } from "@hanzo/console-core/src/db";
|
||||
import { getObservationById, getTraceById, OrgEnrichedApiKey, redis } from "@hanzo/console-core/src/server";
|
||||
import { JobExecutionStatus, Prisma, prisma } from "@hanzo/shared/src/db";
|
||||
import { getObservationById, getTraceById, OrgEnrichedApiKey, redis } from "@hanzo/shared/src/server";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
|
||||
const generateAuth = (username: string, password: string) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test";
|
||||
|
||||
test("should redirect to sign-in if not signed in", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page).toHaveURL(/\/auth\/sign-in(\?.*)?$/);
|
||||
});
|
||||
|
||||
@@ -9,22 +10,11 @@ test("should redirect to home if signed in", async ({ page }) => {
|
||||
await page.goto("/auth/sign-in");
|
||||
await page.fill('input[name="email"]', "demo@hanzo.ai");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
|
||||
await expect(
|
||||
page.locator('button[data-testid="submit-email-password-sign-in-form"]'),
|
||||
).toBeEnabled();
|
||||
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
|
||||
// wait 2 seconds
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const errorElement = page.locator(".text-destructive");
|
||||
const hasError = await errorElement.isVisible().catch(() => false);
|
||||
if (hasError) {
|
||||
const errorText = await errorElement.textContent();
|
||||
throw new Error(`Sign-in failed with error: ${errorText}`);
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
@@ -34,6 +24,7 @@ test("Successfully sign up & able to go to homepage", async ({ page }) => {
|
||||
await page.fill('input[name="email"]', randomEmailAddress());
|
||||
await page.fill('input[type="password"]', "Password2#!");
|
||||
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
// see get started page
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
@@ -44,6 +35,7 @@ test("Successfully sign up & able to go to homepage with uppercase email", async
|
||||
await page.fill('input[name="email"]', "A" + randomEmailAddress());
|
||||
await page.fill('input[type="password"]', "Password3#!");
|
||||
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
// see get started page
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
@@ -69,59 +61,32 @@ test("Unauthenticated user should be redirected to target URL after login", asyn
|
||||
await page.goto("/auth/sign-in");
|
||||
await page.fill('input[name="email"]', "demo@hanzo.ai");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
|
||||
await expect(
|
||||
page.locator('button[data-testid="submit-email-password-sign-in-form"]'),
|
||||
).toBeEnabled();
|
||||
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
|
||||
// wait 2 seconds
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const errorElement = page.locator(".text-destructive");
|
||||
const hasError = await errorElement.isVisible().catch(() => false);
|
||||
if (hasError) {
|
||||
const errorText = await errorElement.textContent();
|
||||
throw new Error(`Sign-in failed with error: ${errorText}`);
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// project id and prompt from seed.ts
|
||||
const promptUrl = "/project/7a88fb47-b4e2-43b8-a06c-a5ce950dc53a/prompts/summary-prompt";
|
||||
|
||||
// Wait for the page to fully render before clicking user menu
|
||||
// The user name appears in the sidebar NavUser component
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByRole("button", { name: /Demo User/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Demo User/ }).click();
|
||||
|
||||
await page.getByRole("menuitem", { name: "Sign out" }).click();
|
||||
await page.getByRole("menuitem", { name: "Sign Out" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/auth\/sign-in(\?.*)?$/);
|
||||
|
||||
await page.goto(promptUrl);
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await expect(page).toHaveURL(/targetPath/);
|
||||
|
||||
await page.fill('input[name="email"]', "demo@hanzo.ai");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
|
||||
await expect(
|
||||
page.locator('button[data-testid="submit-email-password-sign-in-form"]'),
|
||||
).toBeEnabled();
|
||||
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const errorElement2 = page.locator(".text-destructive");
|
||||
const hasError2 = await errorElement2.isVisible().catch(() => false);
|
||||
if (hasError2) {
|
||||
const errorText = await errorElement2.textContent();
|
||||
throw new Error(`Sign-in failed with error: ${errorText}`);
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(promptUrl);
|
||||
});
|
||||
|
||||
@@ -131,22 +96,11 @@ test("Unauthenticated user should not be redirected to non-relative URLs after l
|
||||
|
||||
await page.fill('input[name="email"]', "demo@hanzo.ai");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
|
||||
await expect(
|
||||
page.locator('button[data-testid="submit-email-password-sign-in-form"]'),
|
||||
).toBeEnabled();
|
||||
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const errorElement = page.locator(".text-destructive");
|
||||
const hasError = await errorElement.isVisible().catch(() => false);
|
||||
if (hasError) {
|
||||
const errorText = await errorElement.textContent();
|
||||
throw new Error(`Sign-in failed with error: ${errorText}`);
|
||||
}
|
||||
|
||||
// Expect to be redirected to the home page, not the non-relative URL
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
@@ -160,22 +114,11 @@ test("Unauthenticated user should be redirected to relative URL after login", as
|
||||
|
||||
await page.fill('input[name="email"]', "demo@hanzo.ai");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
|
||||
await expect(
|
||||
page.locator('button[data-testid="submit-email-password-sign-in-form"]'),
|
||||
).toBeEnabled();
|
||||
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const errorElement = page.locator(".text-destructive");
|
||||
const hasError = await errorElement.isVisible().catch(() => false);
|
||||
if (hasError) {
|
||||
const errorText = await errorElement.textContent();
|
||||
throw new Error(`Sign-in failed with error: ${errorText}`);
|
||||
}
|
||||
|
||||
// Expect to be redirected to the relative URL
|
||||
await expect(page).toHaveURL(relativeUrl);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
|
||||
// const checkConsoleErrors = async (page: Page) => {
|
||||
// const errors: string[] = [];
|
||||
@@ -37,44 +37,33 @@ test.describe("Create project", () => {
|
||||
|
||||
// Sign in
|
||||
await page.goto("/auth/sign-in");
|
||||
await page.fill('input[name="email"]', "demo@hanzo.ai");
|
||||
await page.fill('input[name="email"]', "demo@hanzo.com");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
|
||||
await expect(page.locator('button[data-testid="submit-email-password-sign-in-form"]')).toBeEnabled();
|
||||
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const errorElement = page.locator(".text-destructive");
|
||||
const hasError = await errorElement.isVisible().catch(() => false);
|
||||
if (hasError) {
|
||||
const errorText = await errorElement.textContent();
|
||||
throw new Error(`Sign-in failed with error: ${errorText}`);
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// Start create org flow — wait for button to be visible after page load
|
||||
await expect(page.locator('[data-testid="create-organization-btn"]')).toBeVisible();
|
||||
// Start create org flow
|
||||
await page.isVisible('[data-testid="create-organization-btn"]');
|
||||
await page.click('[data-testid="create-organization-btn"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page).toHaveURL("/setup");
|
||||
|
||||
// Create an organization
|
||||
await expect(page.locator("data-testid=new-org-form")).toBeVisible();
|
||||
await page.fill('[data-testid="new-org-name-input"]', "e2e test org");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/organization\/.*\/setup\?orgstep=invite-members/, {
|
||||
timeout: 15000,
|
||||
});
|
||||
await page.waitForTimeout(5000);
|
||||
expect(page.url()).toContain("/organization/");
|
||||
expect(page.url()).toContain("/setup?orgstep=invite-members");
|
||||
|
||||
// Parse the organization ID from the URL using a simpler method
|
||||
const url = new URL(page.url());
|
||||
const organizationId = url.pathname.split("/")[2];
|
||||
console.log("organization", organizationId);
|
||||
|
||||
// Skip add new members step — wait for button to be visible
|
||||
await expect(page.locator('[data-testid="btn-skip-add-members"]')).toBeVisible();
|
||||
// Skip add new members step
|
||||
await page.isVisible('[data-testid="btn-skip-add-members"]');
|
||||
await page.click('[data-testid="btn-skip-add-members"]');
|
||||
expect(page.url()).toContain("/organization/" + organizationId + "/setup?orgstep=create-project");
|
||||
|
||||
@@ -82,16 +71,18 @@ test.describe("Create project", () => {
|
||||
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
|
||||
await page.fill('[data-testid="new-project-name-input"]', "e2e test project");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/project\/.*\/traces/, { timeout: 15000 });
|
||||
await page.waitForTimeout(5000);
|
||||
expect(page.url()).toContain("/project/");
|
||||
expect(page.url()).toContain("/traces");
|
||||
|
||||
const projectUrl = new URL(page.url());
|
||||
const projectId = projectUrl.pathname.split("/")[2];
|
||||
|
||||
// check that the project exists by navigating to its home screen
|
||||
await page.goto("/project/" + projectId);
|
||||
await expect(page).toHaveURL(new RegExp(`/project/${projectId}`));
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain("/project/" + projectId);
|
||||
|
||||
await page.waitForTimeout(10000);
|
||||
const headings = await page.locator("h2").allTextContents();
|
||||
expect(headings).toContain("Home");
|
||||
|
||||
@@ -116,8 +107,11 @@ test.describe("Create project", () => {
|
||||
// const errors = await checkConsoleErrors(page);
|
||||
await signin(page);
|
||||
|
||||
const projectUrl = await getProjectUrlForEmail("demo@hanzo.ai");
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectUrl = await getProjectUrlForEmail("demo@hanzo.com");
|
||||
await page.goto(projectUrl + url, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page).toHaveURL(projectUrl + url);
|
||||
await checkPageHeaderTitle(page, title);
|
||||
|
||||
@@ -137,27 +131,13 @@ const signin = async (page: Page) => {
|
||||
await page.goto("/auth/sign-in");
|
||||
await page.fill('input[name="email"]', "demo@hanzo.ai");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
|
||||
await expect(page.locator('button[data-testid="submit-email-password-sign-in-form"]')).toBeEnabled();
|
||||
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const errorElement = page.locator(".text-destructive");
|
||||
const hasError = await errorElement.isVisible().catch(() => false);
|
||||
if (hasError) {
|
||||
const errorText = await errorElement.textContent();
|
||||
throw new Error(`Sign-in failed with error: ${errorText}`);
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
};
|
||||
|
||||
const checkPageHeaderTitle = async (page: Page, title: string) => {
|
||||
const headerLocator = page.locator('[data-testid="page-header-title"]');
|
||||
await expect(headerLocator).toBeVisible();
|
||||
const pageHeaderTitle = await headerLocator.textContent();
|
||||
const pageHeaderTitle = await page.locator('[data-testid="page-header-title"]').textContent();
|
||||
expect(pageHeaderTitle).toContain(title);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type NextApiRequest } from "next";
|
||||
import { verifyAuth } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { createOrgProjectAndApiKey, hashSecretKey, getDisplaySecretKey } from "@hanzo/console-core/src/server";
|
||||
import { createOrgProjectAndApiKey, hashSecretKey, getDisplaySecretKey } from "@hanzo/shared/src/server";
|
||||
|
||||
describe("Admin API Key Authentication", () => {
|
||||
const ADMIN_API_KEY = "test-admin-key-123";
|
||||
@@ -42,7 +42,7 @@ describe("Admin API Key Authentication", () => {
|
||||
headers: {
|
||||
authorization: auth,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
const result = await verifyAuth(mockReq, false);
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("Admin API Key Authentication", () => {
|
||||
headers: {
|
||||
authorization: "Basic invalid",
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
await expect(verifyAuth(mockReq, false)).rejects.toEqual({
|
||||
status: 401,
|
||||
@@ -88,7 +88,7 @@ describe("Admin API Key Authentication", () => {
|
||||
headers: {
|
||||
authorization: "Basic " + Buffer.from(`${publicKey}:${secretKey}`).toString("base64"),
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
await expect(verifyAuth(mockReq, false)).rejects.toMatchObject({
|
||||
status: 401,
|
||||
@@ -102,10 +102,10 @@ describe("Admin API Key Authentication", () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
authorization: `Bearer ${ADMIN_API_KEY}`,
|
||||
"x-iam-admin-api-key": ADMIN_API_KEY,
|
||||
"x-iam-project-id": projectId,
|
||||
"x-hanzo-admin-api-key": ADMIN_API_KEY,
|
||||
"x-hanzo-project-id": projectId,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
const result = await verifyAuth(mockReq, true);
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("Admin API Key Authentication", () => {
|
||||
headers: {
|
||||
authorization: auth,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
const result = await verifyAuth(mockReq, true);
|
||||
|
||||
@@ -131,12 +131,12 @@ describe("Admin API Key Authentication", () => {
|
||||
expect(result.scope.apiKeyId).not.toBe("ADMIN_API_KEY");
|
||||
});
|
||||
|
||||
it("should fall back to basic auth when no x-iam-admin-api-key header", async () => {
|
||||
it("should fall back to basic auth when no x-hanzo-admin-api-key header", async () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
authorization: auth,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
const result = await verifyAuth(mockReq, true);
|
||||
|
||||
@@ -150,10 +150,10 @@ describe("Admin API Key Authentication", () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
authorization: `Bearer ${ADMIN_API_KEY}`,
|
||||
"x-iam-admin-api-key": ADMIN_API_KEY,
|
||||
"x-iam-project-id": projectId,
|
||||
"x-hanzo-admin-api-key": ADMIN_API_KEY,
|
||||
"x-hanzo-project-id": projectId,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
await expect(verifyAuth(mockReq, true)).rejects.toEqual({
|
||||
status: 403,
|
||||
@@ -169,10 +169,10 @@ describe("Admin API Key Authentication", () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
authorization: "Bearer some-key",
|
||||
"x-iam-admin-api-key": "some-key",
|
||||
"x-iam-project-id": projectId,
|
||||
"x-hanzo-admin-api-key": "some-key",
|
||||
"x-hanzo-project-id": projectId,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
await expect(verifyAuth(mockReq, true)).rejects.toEqual({
|
||||
status: 500,
|
||||
@@ -186,10 +186,10 @@ describe("Admin API Key Authentication", () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
authorization: "Bearer wrong-key",
|
||||
"x-iam-admin-api-key": ADMIN_API_KEY,
|
||||
"x-iam-project-id": projectId,
|
||||
"x-hanzo-admin-api-key": ADMIN_API_KEY,
|
||||
"x-hanzo-project-id": projectId,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
await expect(verifyAuth(mockReq, true)).rejects.toEqual({
|
||||
status: 401,
|
||||
@@ -197,14 +197,14 @@ describe("Admin API Key Authentication", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail with invalid x-iam-admin-api-key header", async () => {
|
||||
it("should fail with invalid x-hanzo-admin-api-key header", async () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
authorization: `Bearer ${ADMIN_API_KEY}`,
|
||||
"x-iam-admin-api-key": "wrong-key",
|
||||
"x-iam-project-id": projectId,
|
||||
"x-hanzo-admin-api-key": "wrong-key",
|
||||
"x-hanzo-project-id": projectId,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
await expect(verifyAuth(mockReq, true)).rejects.toEqual({
|
||||
status: 401,
|
||||
@@ -216,10 +216,10 @@ describe("Admin API Key Authentication", () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
authorization: "Bearer different-key-1",
|
||||
"x-iam-admin-api-key": "different-key-2",
|
||||
"x-iam-project-id": projectId,
|
||||
"x-hanzo-admin-api-key": "different-key-2",
|
||||
"x-hanzo-project-id": projectId,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
await expect(verifyAuth(mockReq, true)).rejects.toEqual({
|
||||
status: 401,
|
||||
@@ -227,17 +227,17 @@ describe("Admin API Key Authentication", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail without x-iam-project-id header", async () => {
|
||||
it("should fail without x-hanzo-project-id header", async () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
authorization: `Bearer ${ADMIN_API_KEY}`,
|
||||
"x-iam-admin-api-key": ADMIN_API_KEY,
|
||||
"x-hanzo-admin-api-key": ADMIN_API_KEY,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
await expect(verifyAuth(mockReq, true)).rejects.toEqual({
|
||||
status: 400,
|
||||
message: "x-iam-project-id header is required for admin API key authentication",
|
||||
message: "x-hanzo-project-id header is required for admin API key authentication",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,10 +245,10 @@ describe("Admin API Key Authentication", () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
authorization: `Bearer ${ADMIN_API_KEY}`,
|
||||
"x-iam-admin-api-key": ADMIN_API_KEY,
|
||||
"x-iam-project-id": "non-existent-project",
|
||||
"x-hanzo-admin-api-key": ADMIN_API_KEY,
|
||||
"x-hanzo-project-id": "non-existent-project",
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
await expect(verifyAuth(mockReq, true)).rejects.toEqual({
|
||||
status: 404,
|
||||
@@ -260,9 +260,9 @@ describe("Admin API Key Authentication", () => {
|
||||
const mockReq = {
|
||||
headers: {
|
||||
authorization: auth,
|
||||
"x-iam-project-id": projectId,
|
||||
"x-hanzo-project-id": projectId,
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
} as NextApiRequest;
|
||||
|
||||
const result = await verifyAuth(mockReq, true);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { makeZodVerifiedAPICall, makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
GetAnnotationQueuesResponse,
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
DeleteAnnotationQueueItemResponse,
|
||||
CreateAnnotationQueueResponse,
|
||||
} from "@/src/features/public-api/types/annotation-queues";
|
||||
import { AnnotationQueueObjectType, AnnotationQueueStatus } from "@hanzo/console-core";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/console-core/src/server";
|
||||
import { AnnotationQueueObjectType, AnnotationQueueStatus } from "@hanzo/shared";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/shared/src/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
describe("Annotation Queues API Endpoints", () => {
|
||||
@@ -231,7 +231,7 @@ describe("Annotation Queues API Endpoints", () => {
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it.skip("should return 405 if the user is on the Hobby plan and has reached the maximum number of annotation queues", async () => {
|
||||
it("should return 405 if the user is on the Hobby plan and has reached the maximum number of annotation queues", async () => {
|
||||
const { auth: hobbyPlanAuth, projectId: hobbyProjectId } = await createOrgProjectAndApiKey({
|
||||
plan: "Hobby",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getDisplaySecretKey, hashSecretKey, OrgEnrichedApiKey } from "@hanzo/console-core/src/server";
|
||||
import { Prisma, type PrismaClient, prisma } from "@hanzo/console-core/src/db";
|
||||
import { getDisplaySecretKey, hashSecretKey, OrgEnrichedApiKey } from "@hanzo/shared/src/server";
|
||||
import { Prisma, type PrismaClient, prisma } from "@hanzo/shared/src/db";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
|
||||
@@ -16,7 +16,7 @@ describe("Authenticate API calls", () => {
|
||||
it("should create new api key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(auth.validKey).toBe(true);
|
||||
@@ -31,7 +31,7 @@ describe("Authenticate API calls", () => {
|
||||
it("should create new api key and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
@@ -42,12 +42,12 @@ describe("Authenticate API calls", () => {
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const auth2 = await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth2.validKey).toBe(true);
|
||||
});
|
||||
|
||||
it("should create new api key with billing data and succeed with new key", async () => {
|
||||
it("should create new api key with stripe data and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.organization.update({
|
||||
@@ -56,7 +56,7 @@ describe("Authenticate API calls", () => {
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
billing: {
|
||||
stripe: {
|
||||
customerId: "cus_test123",
|
||||
activeSubscriptionId: "sub_test123",
|
||||
activeProductId: "prod_test123",
|
||||
@@ -66,17 +66,17 @@ describe("Authenticate API calls", () => {
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const auth = await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
if (auth.validKey) {
|
||||
expect(auth.scope.orgId).toBe("seed-org-id");
|
||||
expect(auth.scope.plan).toBe("oss");
|
||||
expect(auth.scope.plan).toBe("cloud:free");
|
||||
expect(auth.scope.rateLimitOverrides).toEqual([]);
|
||||
}
|
||||
|
||||
@@ -117,17 +117,17 @@ describe("Authenticate API calls", () => {
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const auth = await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
if (auth.validKey) {
|
||||
expect(auth.scope.orgId).toBe("seed-org-id");
|
||||
expect(auth.scope.plan).toBe("oss");
|
||||
expect(auth.scope.plan).toBe("cloud:free");
|
||||
expect(auth.scope.rateLimitOverrides).toEqual([
|
||||
{
|
||||
resource: "ingestion",
|
||||
@@ -156,7 +156,7 @@ describe("Authenticate API calls", () => {
|
||||
it("should fail on wrong api key with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("Authenticate API calls", () => {
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const wrongAuth = await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkx",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
|
||||
);
|
||||
expect(wrongAuth.validKey).toBe(false);
|
||||
});
|
||||
@@ -182,7 +182,7 @@ describe("Authenticate API calls", () => {
|
||||
expect(initialApiKey?.fastHashedSecretKey).toBeNull();
|
||||
|
||||
const auth = await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkx",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
|
||||
);
|
||||
expect(auth.validKey).toBe(false);
|
||||
|
||||
@@ -246,7 +246,7 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
@@ -261,7 +261,7 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
// second will add the key to redis
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const cachedKey2 = await redis.get(`api-key:${apiKey?.fastHashedSecretKey}`);
|
||||
@@ -273,7 +273,7 @@ describe("Authenticate API calls", () => {
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
orgId: "seed-org-id",
|
||||
plan: "oss",
|
||||
plan: "cloud:free",
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "public-api",
|
||||
@@ -320,11 +320,11 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-hz-1234567890" },
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
|
||||
expect(apiKey).not.toBeNull();
|
||||
@@ -334,8 +334,8 @@ describe("Authenticate API calls", () => {
|
||||
const nonScopedKey = {
|
||||
id: "seed-api-key",
|
||||
note: "seeded key",
|
||||
publicKey: "pk-hz-1234567890",
|
||||
displaySecretKey: "sk-hz-...7890",
|
||||
publicKey: "pk-lf-1234567890",
|
||||
displaySecretKey: "sk-lf-...7890",
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
@@ -366,7 +366,7 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
// Auth should still succeed by falling back to Postgres
|
||||
const verification = await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(verification.validKey).toBe(true);
|
||||
@@ -383,7 +383,7 @@ describe("Authenticate API calls", () => {
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
orgId: "seed-org-id",
|
||||
plan: "oss",
|
||||
plan: "cloud:hobby",
|
||||
scope: "PROJECT", // Now the scope is present
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
rateLimitOverrides: [
|
||||
@@ -412,7 +412,7 @@ describe("Authenticate API calls", () => {
|
||||
// key does not exist in database
|
||||
|
||||
const verification = await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(verification.validKey).toBe(false);
|
||||
@@ -427,7 +427,7 @@ describe("Authenticate API calls", () => {
|
||||
// key does not exist in database
|
||||
|
||||
const verification = await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(verification.validKey).toBe(false);
|
||||
@@ -438,7 +438,7 @@ describe("Authenticate API calls", () => {
|
||||
expect(redisValue).toBe('"api-key-non-existent"');
|
||||
|
||||
const verification2 = await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(verification2.validKey).toBe(false);
|
||||
|
||||
@@ -460,17 +460,17 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// second will add the key to redis
|
||||
const auth2 = await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// third will read from redis only
|
||||
await new ApiAuthService(mockPrisma as unknown as PrismaClient, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(auth2.validKey).toBe(true);
|
||||
@@ -478,7 +478,7 @@ describe("Authenticate API calls", () => {
|
||||
// Ensure prisma was not called
|
||||
expect(mockPrisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||
|
||||
const cachedKey = await redis.get("api-key:3a59a2355b705f1daac5f26f75de5dfa35c6162393ab33b0bc6d8ab53afac0c2");
|
||||
const cachedKey = await redis.get("api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227");
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
|
||||
@@ -496,7 +496,7 @@ describe("Authenticate API calls", () => {
|
||||
isIngestionSuspended: expect.anything(),
|
||||
projectId: expect.any(String),
|
||||
orgId: "seed-org-id",
|
||||
plan: "oss",
|
||||
plan: "cloud:hobby",
|
||||
scope: "PROJECT",
|
||||
});
|
||||
});
|
||||
@@ -513,20 +513,20 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// second will add the key to redis
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// third will read from redis only
|
||||
await new ApiAuthService(mockPrisma as unknown as PrismaClient, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const ttl = await redis.ttl("api-key:3a59a2355b705f1daac5f26f75de5dfa35c6162393ab33b0bc6d8ab53afac0c2");
|
||||
const ttl = await redis.ttl("api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227");
|
||||
|
||||
expect(ttl).toBeGreaterThan(env.HANZO_CACHE_API_KEY_TTL_SECONDS - 2);
|
||||
|
||||
@@ -534,10 +534,10 @@ describe("Authenticate API calls", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
await new ApiAuthService(mockPrisma as unknown as PrismaClient, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const ttl2 = await redis.ttl("api-key:3a59a2355b705f1daac5f26f75de5dfa35c6162393ab33b0bc6d8ab53afac0c2");
|
||||
const ttl2 = await redis.ttl("api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227");
|
||||
|
||||
expect(ttl2).toBeGreaterThan(env.HANZO_CACHE_API_KEY_TTL_SECONDS - 2);
|
||||
}, 10000);
|
||||
@@ -547,12 +547,12 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// second will add the key to redis
|
||||
const auth2 = await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(auth2.validKey).toBe(true);
|
||||
@@ -572,7 +572,7 @@ describe("Authenticate API calls", () => {
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
orgId: "seed-org-id",
|
||||
plan: "oss",
|
||||
plan: "cloud:free",
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
scope: "PROJECT",
|
||||
isIngestionSuspended: expect.anything(),
|
||||
@@ -627,11 +627,11 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
// put keys into cache
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
@@ -678,10 +678,10 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
// put keys into cache
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstaHotMTIzNDU2Nzg5MDpzay1oei0xMjM0NTY3ODkw",
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { makeZodVerifiedAPICall, makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/console-core/src/server";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/shared/src/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
CreateAnnotationQueueAssignmentResponse,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/console-core/src/server";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/shared/src/server";
|
||||
import type { Session } from "next-auth";
|
||||
import { v4 } from "uuid";
|
||||
import {
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
type SafeWebhookActionConfig,
|
||||
type WebhookActionConfigWithSecrets,
|
||||
isWebhookAction,
|
||||
} from "@hanzo/console-core";
|
||||
import { encrypt, decrypt } from "@hanzo/console-core/encryption";
|
||||
import { generateWebhookSecret } from "@hanzo/console-core/encryption";
|
||||
} from "@hanzo/shared";
|
||||
import { encrypt, decrypt } from "@hanzo/shared/encryption";
|
||||
import { generateWebhookSecret } from "@hanzo/shared/encryption";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const __orgIds: string[] = [];
|
||||
@@ -36,7 +36,6 @@ async function prepare() {
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
metadata: {},
|
||||
aiFeaturesEnabled: true,
|
||||
projects: [
|
||||
{
|
||||
id: project.id,
|
||||
@@ -50,9 +49,8 @@ async function prepare() {
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
excludeDatastoreRead: false,
|
||||
excludeClickhouseRead: false,
|
||||
templateFlag: true,
|
||||
v4BetaToggleVisible: false,
|
||||
},
|
||||
admin: true,
|
||||
},
|
||||
@@ -635,20 +633,20 @@ describe("automations trpc", () => {
|
||||
|
||||
// Headers should be encrypted for secret ones, plain for others
|
||||
const config = createdAction?.config as WebhookActionConfigWithSecrets;
|
||||
expect(config.requestHeaders!["content-type"].value).toBe("application/json");
|
||||
expect(config.requestHeaders!["x-public"].value).toBe("public-value");
|
||||
expect(config.requestHeaders!["x-api-key"].secret).toBe(true);
|
||||
expect(config.requestHeaders!["x-api-key"].value).not.toBe("secret-key-123"); // Should be encrypted
|
||||
expect(config.requestHeaders!["authorization"].secret).toBe(true);
|
||||
expect(config.requestHeaders!["authorization"].value).not.toBe("Bearer secret-token-456"); // Should be encrypted
|
||||
expect(config.requestHeaders["content-type"].value).toBe("application/json");
|
||||
expect(config.requestHeaders["x-public"].value).toBe("public-value");
|
||||
expect(config.requestHeaders["x-api-key"].secret).toBe(true);
|
||||
expect(config.requestHeaders["x-api-key"].value).not.toBe("secret-key-123"); // Should be encrypted
|
||||
expect(config.requestHeaders["authorization"].secret).toBe(true);
|
||||
expect(config.requestHeaders["authorization"].value).not.toBe("Bearer secret-token-456"); // Should be encrypted
|
||||
|
||||
// Display values should be present
|
||||
expect(config.displayHeaders).toMatchObject({
|
||||
"content-type": { secret: false, value: "application/json" },
|
||||
"x-public": { secret: false, value: "public-value" },
|
||||
});
|
||||
expect(config.displayHeaders!["x-api-key"].value).toBe("secr...-123");
|
||||
expect(config.displayHeaders!["authorization"].value).toBe("Bear...-456");
|
||||
expect(config.displayHeaders["x-api-key"].value).toBe("secr...-123");
|
||||
expect(config.displayHeaders["authorization"].value).toBe("Bear...-456");
|
||||
});
|
||||
|
||||
it("should create automation with secret headers that do not expose values in response", async () => {
|
||||
@@ -706,8 +704,8 @@ describe("automations trpc", () => {
|
||||
"content-type": { secret: false, value: "application/json" },
|
||||
"x-public": { secret: false, value: "public-value" },
|
||||
});
|
||||
expect(config.requestHeaders!["x-api-key"].value).not.toBe("secret-value-123");
|
||||
expect(config.requestHeaders!["authorization"].value).not.toBe("Bearer token-456");
|
||||
expect(config.requestHeaders["x-api-key"].value).not.toBe("secret-value-123");
|
||||
expect(config.requestHeaders["authorization"].value).not.toBe("Bearer token-456");
|
||||
|
||||
// Display values should be present with masked secrets
|
||||
expect(config.displayHeaders).toMatchObject({
|
||||
@@ -1061,13 +1059,13 @@ describe("automations trpc", () => {
|
||||
const config = updatedAction?.config as any;
|
||||
|
||||
// Public headers should remain plain
|
||||
expect(config.requestHeaders!["content-type"].value).toBe("application/json");
|
||||
expect(config.requestHeaders!["x-public"].value).toBe("new-public-value");
|
||||
expect(config.requestHeaders["content-type"].value).toBe("application/json");
|
||||
expect(config.requestHeaders["x-public"].value).toBe("new-public-value");
|
||||
|
||||
// Secret headers should be encrypted
|
||||
expect(config.requestHeaders!["x-secret-key"].value).not.toBe("new-secret-123");
|
||||
expect(config.requestHeaders!["x-Case-KEY"].value).toBe("new-value");
|
||||
expect(config.requestHeaders!["x-case-key"]).toBeUndefined();
|
||||
expect(config.requestHeaders["x-secret-key"].value).not.toBe("new-secret-123");
|
||||
expect(config.requestHeaders["x-Case-KEY"].value).toBe("new-value");
|
||||
expect(config.requestHeaders["x-case-key"]).toBeUndefined();
|
||||
|
||||
// Display values should be present with masked secrets
|
||||
expect(config.displayHeaders).toMatchObject({
|
||||
@@ -1168,10 +1166,10 @@ describe("automations trpc", () => {
|
||||
const config = updatedAction?.config as WebhookActionConfigWithSecrets;
|
||||
|
||||
// x-currently-public should now be encrypted (was plain, now secret)
|
||||
expect(config.requestHeaders!["x-currently-public"].value).not.toBe("now-secret-value");
|
||||
expect(config.requestHeaders["x-currently-public"].value).not.toBe("now-secret-value");
|
||||
|
||||
// x-currently-secret should now be plain (was secret, now public)
|
||||
expect(config.requestHeaders!["x-currently-secret"].value).toBe("now-public-value");
|
||||
expect(config.requestHeaders["x-currently-secret"].value).toBe("now-public-value");
|
||||
|
||||
// Display values should reflect the changes
|
||||
expect(config.displayHeaders).toMatchObject({
|
||||
@@ -1267,7 +1265,7 @@ describe("automations trpc", () => {
|
||||
const config = updatedAction?.config as WebhookActionConfigWithSecrets;
|
||||
|
||||
// x-currently-secret should still be encrypted
|
||||
expect(config.requestHeaders!["x-currently-secret"].value).not.toBe("secret-value");
|
||||
expect(config.requestHeaders["x-currently-secret"].value).not.toBe("secret-value");
|
||||
|
||||
// Display values should reflect the changes
|
||||
expect(config.displayHeaders).toMatchObject({
|
||||
@@ -1368,8 +1366,8 @@ describe("automations trpc", () => {
|
||||
expect(config.url).toBe("https://example.com/new-webhook-url");
|
||||
|
||||
// Secret headers should still be encrypted and preserved
|
||||
expect(config.requestHeaders!["x-api-key"].value).not.toBe("secret-key-123");
|
||||
expect(config.requestHeaders!["authorization"].value).not.toBe("Bearer token-456");
|
||||
expect(config.requestHeaders["x-api-key"].value).not.toBe("secret-key-123");
|
||||
expect(config.requestHeaders["authorization"].value).not.toBe("Bearer token-456");
|
||||
|
||||
// Display values should be preserved
|
||||
expect(config.displayHeaders).toMatchObject({
|
||||
@@ -1464,12 +1462,12 @@ describe("automations trpc", () => {
|
||||
expect(config.headers).toEqual({});
|
||||
|
||||
// Secret header should be encrypted in requestHeaders
|
||||
expect(config.requestHeaders!["x-api-key"].secret).toBe(true);
|
||||
expect(config.requestHeaders!["x-api-key"].value).not.toBe("new-secret-key");
|
||||
expect(config.requestHeaders["x-api-key"].secret).toBe(true);
|
||||
expect(config.requestHeaders["x-api-key"].value).not.toBe("new-secret-key");
|
||||
|
||||
// Public header should remain plain
|
||||
expect(config.requestHeaders!["content-type"].secret).toBe(false);
|
||||
expect(config.requestHeaders!["content-type"].value).toBe("application/json");
|
||||
expect(config.requestHeaders["content-type"].secret).toBe(false);
|
||||
expect(config.requestHeaders["content-type"].value).toBe("application/json");
|
||||
|
||||
// Both headers should be in displayHeaders
|
||||
expect(config.displayHeaders).toMatchObject({
|
||||
@@ -2424,7 +2422,7 @@ describe("automations trpc", () => {
|
||||
});
|
||||
|
||||
// Verify token is NOT returned (users provided it themselves)
|
||||
expect((response as any).webhookSecret).toBeUndefined();
|
||||
expect(response.webhookSecret).toBeUndefined();
|
||||
|
||||
const config = response.action.config as any;
|
||||
expect(config.displayGitHubToken).toMatch(/^ghp_...6$/);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { makeZodVerifiedAPICall, makeAPICall } from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { z } from "zod/v4";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createAndAddApiKeysToDb, createBasicAuthHeader } from "@hanzo/console-core/src/server";
|
||||
import { decrypt } from "@hanzo/console-core/encryption";
|
||||
import { createAndAddApiKeysToDb, createBasicAuthHeader } from "@hanzo/shared/src/server";
|
||||
import { decrypt } from "@hanzo/shared/encryption";
|
||||
|
||||
// Schemas based on Fern schema definition
|
||||
const BlobStorageIntegrationResponseSchema = z.object({
|
||||
@@ -99,8 +99,8 @@ describe("Blob Storage Integrations API", () => {
|
||||
scope: "ORGANIZATION",
|
||||
note: "Test API Key for Blob Storage API",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-hz-blob-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-hz-blob-${randomUUID().substring(0, 8)}`,
|
||||
publicKey: `pk-lf-blob-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-blob-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
testApiKey = orgApiKey.publicKey;
|
||||
@@ -207,8 +207,8 @@ describe("Blob Storage Integrations API", () => {
|
||||
scope: "PROJECT",
|
||||
note: "Project API Key",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-hz-proj-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-hz-proj-${randomUUID().substring(0, 8)}`,
|
||||
publicKey: `pk-lf-proj-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-proj-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -605,8 +605,8 @@ describe("Blob Storage Integrations API", () => {
|
||||
scope: "PROJECT",
|
||||
note: "Project API Key",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-hz-proj-del-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-hz-proj-del-${randomUUID().substring(0, 8)}`,
|
||||
publicKey: `pk-lf-proj-del-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-proj-del-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
getDatasetItemIdsWithRunData,
|
||||
createDatasetItem,
|
||||
createManyDatasetItems,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import { v4 } from "uuid";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { createObservation, createTraceScore, createTrace } from "@hanzo/console-core/src/server";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { createObservation, createTraceScore, createTrace } from "@hanzo/shared/src/server";
|
||||
import { enrichAndMapToDatasetItemId, getRunItemsByRunIdOrItemId } from "@/src/features/datasets/server/service";
|
||||
import { aggregateScores, composeAggregateScoreKey } from "@/src/features/scores/lib/aggregateScores";
|
||||
|
||||
@@ -235,7 +235,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
return {
|
||||
id: run.id,
|
||||
name: run.name,
|
||||
// Use Datastore metrics if available, otherwise use defaults for runs without dataset_run_items_rmt
|
||||
// Use ClickHouse metrics if available, otherwise use defaults for runs without dataset_run_items_rmt
|
||||
countRunItems: run.countRunItems ?? 0,
|
||||
avgTotalCost: run.avgTotalCost ?? null,
|
||||
totalCost: run.totalCost ?? null,
|
||||
@@ -407,7 +407,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
return {
|
||||
id: run.id,
|
||||
name: run.name,
|
||||
// Use Datastore metrics if available, otherwise use defaults for runs without dataset_run_items_rmt
|
||||
// Use ClickHouse metrics if available, otherwise use defaults for runs without dataset_run_items_rmt
|
||||
countRunItems: run.countRunItems ?? 0,
|
||||
avgTotalCost: run.avgTotalCost ?? null,
|
||||
totalCost: run.totalCost ?? null,
|
||||
@@ -1514,7 +1514,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -1562,7 +1562,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -1612,7 +1612,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -1653,7 +1653,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -1694,7 +1694,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -1731,7 +1731,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -1767,7 +1767,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2139,7 +2139,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2219,7 +2219,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2277,7 +2277,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2327,7 +2327,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2377,7 +2377,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItemsFirstPage = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2386,7 +2386,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
datasetItemIds: firstPageDatasetItemIds,
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItemsSecondPage = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2426,7 +2426,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2486,7 +2486,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2547,7 +2547,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2616,7 +2616,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2658,7 +2658,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2704,7 +2704,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2741,7 +2741,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2770,7 +2770,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
@@ -2831,7 +2831,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in datastore
|
||||
// Step 2: Given dataset item ids, lookup dataset run items in clickhouse
|
||||
// Note: for each unique dataset item id and dataset run id combination, we will retrieve a dataset run item
|
||||
const datasetRunItems = await getDatasetRunItemsWithoutIOByItemIds({
|
||||
projectId: projectId,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
process.env.HANZO_DATASET_SERVICE_READ_FROM_VERSIONED_IMPLEMENTATION = "true";
|
||||
process.env.HANZO_DATASET_SERVICE_WRITE_TO_VERSIONED_IMPLEMENTATION = "true";
|
||||
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { makeAPICall, makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
|
||||
import { v4 } from "uuid";
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
createDatasetItemFilterState,
|
||||
createDatasetItem,
|
||||
getDatasetItems,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
|
||||
describe("/api/public/datasets and /api/public/dataset-items API Endpoints", () => {
|
||||
@@ -1429,7 +1429,7 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
}
|
||||
|
||||
// Wrapping the GET run response verification inside a waitForExpect block ensures
|
||||
// the test waits for eventual consistency (from asynchronous writes to Datastore for dataset run items)
|
||||
// the test waits for eventual consistency (from asynchronous writes to ClickHouse for dataset run items)
|
||||
await waitForExpect(async () => {
|
||||
const runItems = await getDatasetRunItemsByDatasetIdCh({
|
||||
projectId,
|
||||
@@ -1592,194 +1592,4 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
|
||||
expect(deleteRes.status).toBe(200);
|
||||
}, 90000);
|
||||
|
||||
it("should support dataset versioning via version query parameter", async () => {
|
||||
const datasetName = `versioned-dataset-${v4()}`;
|
||||
|
||||
// Create dataset
|
||||
const dataset = await makeZodVerifiedAPICall(
|
||||
PostDatasetsV2Response,
|
||||
"POST",
|
||||
"/api/public/v2/datasets",
|
||||
{
|
||||
name: datasetName,
|
||||
description: "Dataset for version testing",
|
||||
},
|
||||
auth,
|
||||
);
|
||||
expect(dataset.status).toBe(200);
|
||||
|
||||
// Create initial dataset item
|
||||
const item1 = await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName,
|
||||
id: "versioned-item-1",
|
||||
input: { value: "version 1" },
|
||||
expectedOutput: { result: "v1" },
|
||||
},
|
||||
auth,
|
||||
);
|
||||
expect(item1.status).toBe(200);
|
||||
|
||||
// Capture timestamp after first item
|
||||
const version1Timestamp = new Date();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100)); // Small delay
|
||||
|
||||
// Update the item to create a new version
|
||||
const item1Updated = await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName,
|
||||
id: "versioned-item-1",
|
||||
input: { value: "version 2" },
|
||||
expectedOutput: { result: "v2" },
|
||||
},
|
||||
auth,
|
||||
);
|
||||
expect(item1Updated.status).toBe(200);
|
||||
|
||||
// Create second item after update
|
||||
const item2 = await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName,
|
||||
id: "versioned-item-2",
|
||||
input: { value: "only in v2+" },
|
||||
},
|
||||
auth,
|
||||
);
|
||||
expect(item2.status).toBe(200);
|
||||
|
||||
// Test 1: Get dataset (version not included in Dataset object)
|
||||
const latestDataset = await makeZodVerifiedAPICall(
|
||||
GetDatasetV2Response,
|
||||
"GET",
|
||||
`/api/public/v2/datasets/${encodeURIComponent(datasetName)}`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
expect(latestDataset.status).toBe(200);
|
||||
expect(latestDataset.body.name).toBe(datasetName);
|
||||
|
||||
// Test 2: Get dataset items at version 1 (should have 1 item with v1 data)
|
||||
const itemsV1 = await makeZodVerifiedAPICall(
|
||||
GetDatasetItemsV1Response,
|
||||
"GET",
|
||||
`/api/public/dataset-items?datasetName=${encodeURIComponent(datasetName)}&version=${version1Timestamp.toISOString()}`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
expect(itemsV1.status).toBe(200);
|
||||
expect(itemsV1.body.data).toHaveLength(1);
|
||||
expect(itemsV1.body.data[0].id).toBe("versioned-item-1");
|
||||
expect(itemsV1.body.data[0].input).toEqual({ value: "version 1" });
|
||||
expect(itemsV1.body.data[0].expectedOutput).toEqual({ result: "v1" });
|
||||
|
||||
// Test 3: Get dataset items without version (should have 2 items, one updated)
|
||||
const itemsLatest = await makeZodVerifiedAPICall(
|
||||
GetDatasetItemsV1Response,
|
||||
"GET",
|
||||
`/api/public/dataset-items?datasetName=${encodeURIComponent(datasetName)}`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
expect(itemsLatest.status).toBe(200);
|
||||
expect(itemsLatest.body.data).toHaveLength(2);
|
||||
const updatedItem = itemsLatest.body.data.find((i) => i.id === "versioned-item-1");
|
||||
expect(updatedItem?.input).toEqual({ value: "version 2" });
|
||||
expect(updatedItem?.expectedOutput).toEqual({ result: "v2" });
|
||||
|
||||
// Test 4: Verify version parameter requires datasetName
|
||||
const invalidVersion = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/dataset-items?version=${version1Timestamp.toISOString()}`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
expect(invalidVersion.status).toBe(400); // Should fail validation
|
||||
}, 90000);
|
||||
|
||||
it("should support creating experiment runs at specific dataset version", async () => {
|
||||
const datasetName = `experiment-version-dataset-${v4()}`;
|
||||
const runName = `experiment-run-${v4()}`;
|
||||
|
||||
// Create dataset
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetsV2Response,
|
||||
"POST",
|
||||
"/api/public/v2/datasets",
|
||||
{
|
||||
name: datasetName,
|
||||
description: "Dataset for experiment version testing",
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
// Create initial dataset item
|
||||
const item = await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName,
|
||||
id: "experiment-item",
|
||||
input: { text: "original" },
|
||||
},
|
||||
auth,
|
||||
);
|
||||
expect(item.status).toBe(200);
|
||||
|
||||
// Capture version timestamp
|
||||
const experimentVersion = new Date();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Update item (creating new version)
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName,
|
||||
id: "experiment-item",
|
||||
input: { text: "updated" },
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
// Create dataset run item with version
|
||||
const runItem = await makeZodVerifiedAPICall(
|
||||
PostDatasetRunItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-run-items",
|
||||
{
|
||||
runName,
|
||||
datasetItemId: item.body.id,
|
||||
traceId: traceId,
|
||||
datasetVersion: experimentVersion,
|
||||
metadata: { test: "versioned experiment" },
|
||||
},
|
||||
auth,
|
||||
);
|
||||
expect(runItem.status).toBe(200);
|
||||
|
||||
// Verify the run was created with version in metadata
|
||||
const dbRun = await prisma.datasetRuns.findFirst({
|
||||
where: {
|
||||
name: runName,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
expect(dbRun).not.toBeNull();
|
||||
expect(dbRun?.metadata).toMatchObject({
|
||||
test: "versioned experiment",
|
||||
dataset_version: experimentVersion.toISOString(),
|
||||
});
|
||||
}, 90000);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @jest-environment node */
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { makeAPICall, makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
|
||||
import { PostDatasetsV2Response, PostDatasetItemsV1Response } from "@/src/features/public-api/types/datasets";
|
||||
import {
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
DatasetItemValidator,
|
||||
getDatasetItems,
|
||||
createDatasetItemFilterState,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
import { validateFieldAgainstSchema } from "@hanzo/console-core";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import { validateFieldAgainstSchema } from "@hanzo/shared";
|
||||
|
||||
process.env.HANZO_DATASET_SERVICE_READ_FROM_VERSIONED_IMPLEMENTATION = "true";
|
||||
process.env.HANZO_DATASET_SERVICE_WRITE_TO_VERSIONED_IMPLEMENTATION = "true";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CTEQueryBuilder, EventsAggregationQueryBuilder } from "@hanzo/console-core/src/server";
|
||||
import { CTEQueryBuilder, EventsAggregationQueryBuilder } from "@hanzo/shared/src/server";
|
||||
|
||||
describe("CTEQueryBuilder", () => {
|
||||
it("should compose multiple CTEs with type-safe references", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getObservationById,
|
||||
getScoreById,
|
||||
getTraceById,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
@@ -106,8 +106,8 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Disabled within test sequence as we're using a datastore version which doesn't support this
|
||||
// it("should replace bad escape sequences on datastore", async () => {
|
||||
// Disabled within test sequence as we're using a clickhouse version which doesn't support this
|
||||
// it("should replace bad escape sequences on clickhouse", async () => {
|
||||
// const entity = {
|
||||
// id: randomUUID(),
|
||||
// type: "trace-create",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { makeAPICall, makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
GetLlmConnectionsV1Response,
|
||||
PutLlmConnectionV1Response,
|
||||
} from "@/src/features/public-api/types/llm-connections";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/console-core/src/server";
|
||||
import { LLMAdapter } from "@hanzo/console-core";
|
||||
import { encrypt } from "@hanzo/console-core/encryption";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/shared/src/server";
|
||||
import { LLMAdapter } from "@hanzo/shared";
|
||||
import { encrypt } from "@hanzo/shared/encryption";
|
||||
|
||||
// Generate truly unique provider names for tests to avoid conflicts
|
||||
const generateUniqueProvider = (baseName: string) =>
|
||||
@@ -1174,7 +1174,7 @@ describe("/api/public/llm-connections API Endpoints", () => {
|
||||
"GET",
|
||||
"/api/public/llm-connections",
|
||||
undefined,
|
||||
"Basic " + Buffer.from("pk-hz-fake-key:").toString("base64"),
|
||||
"Basic " + Buffer.from("pk-lf-fake-key:").toString("base64"),
|
||||
);
|
||||
expect(fakeKeyResponse.status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
// Mock queue operations to avoid Redis dependency in tests
|
||||
jest.mock("@hanzo/console-core/src/server", () => {
|
||||
const actual = jest.requireActual("@hanzo/console-core/src/server");
|
||||
jest.mock("@hanzo/shared/src/server", () => {
|
||||
const actual = jest.requireActual("@hanzo/shared/src/server");
|
||||
return {
|
||||
...actual,
|
||||
// Mock queue getInstance to return a no-op queue
|
||||
@@ -20,13 +20,7 @@ import { ZodError } from "zod/v4";
|
||||
import { z } from "zod/v4";
|
||||
import { formatErrorForUser, wrapErrorHandling } from "@/src/features/mcp/core/error-formatting";
|
||||
import { UserInputError, ApiServerError } from "@/src/features/mcp/core/errors";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
ConsoleNotFoundError,
|
||||
InvalidRequestError,
|
||||
BaseError,
|
||||
} from "@hanzo/console-core";
|
||||
import { UnauthorizedError, ForbiddenError, HanzoNotFoundError, InvalidRequestError, BaseError } from "@hanzo/shared";
|
||||
|
||||
describe("MCP Error Formatting", () => {
|
||||
describe("formatErrorForUser", () => {
|
||||
@@ -174,8 +168,8 @@ describe("MCP Error Formatting", () => {
|
||||
expect(mcpError.message).toContain("permission");
|
||||
});
|
||||
|
||||
it("should format ConsoleNotFoundError with original message", () => {
|
||||
const error = new ConsoleNotFoundError("Prompt not found: chatbot");
|
||||
it("should format HanzoNotFoundError with original message", () => {
|
||||
const error = new HanzoNotFoundError("Prompt not found: chatbot");
|
||||
const mcpError = formatErrorForUser(error);
|
||||
|
||||
expect(mcpError.code).toBe(ErrorCode.InvalidRequest);
|
||||
@@ -388,7 +382,7 @@ describe("MCP Error Formatting", () => {
|
||||
it("should categorize user-fixable errors as InvalidRequest", () => {
|
||||
const userErrors = [
|
||||
new UserInputError("Invalid input"),
|
||||
new ConsoleNotFoundError("Not found"),
|
||||
new HanzoNotFoundError("Not found"),
|
||||
new InvalidRequestError("Bad request"),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
// Mock queue operations to avoid Redis dependency in tests
|
||||
jest.mock("@hanzo/console-core/src/server", () => {
|
||||
const actual = jest.requireActual("@hanzo/console-core/src/server");
|
||||
jest.mock("@hanzo/shared/src/server", () => {
|
||||
const actual = jest.requireActual("@hanzo/shared/src/server");
|
||||
return {
|
||||
...actual,
|
||||
// Mock queue getInstance to return a no-op queue
|
||||
@@ -16,7 +16,6 @@ jest.mock("@hanzo/console-core/src/server", () => {
|
||||
});
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { createMcpTestSetup, createPromptInDb, verifyToolAnnotations } from "./mcp-helpers";
|
||||
|
||||
// Import MCP tool handlers directly
|
||||
@@ -367,130 +366,6 @@ describe("MCP Read Tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should filter by fromUpdatedAt", async () => {
|
||||
const { context, projectId } = await createMcpTestSetup();
|
||||
const oldPrompt = `filter-from-updated-${nanoid()}`;
|
||||
const newPrompt = `filter-from-updated-${nanoid()}`;
|
||||
|
||||
const oldDate = new Date("2026-01-01T00:00:00.000Z");
|
||||
const newDate = new Date("2026-02-01T00:00:00.000Z");
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
name: oldPrompt,
|
||||
prompt: "old",
|
||||
labels: [],
|
||||
tags: [],
|
||||
type: "text",
|
||||
version: 1,
|
||||
config: {},
|
||||
createdBy: "test-user",
|
||||
createdAt: oldDate,
|
||||
updatedAt: oldDate,
|
||||
project: { connect: { id: projectId } },
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
name: newPrompt,
|
||||
prompt: "new",
|
||||
labels: [],
|
||||
tags: [],
|
||||
type: "text",
|
||||
version: 1,
|
||||
config: {},
|
||||
createdBy: "test-user",
|
||||
createdAt: newDate,
|
||||
updatedAt: newDate,
|
||||
project: { connect: { id: projectId } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = (await handleListPrompts(
|
||||
{
|
||||
fromUpdatedAt: "2026-01-15T00:00:00.000Z",
|
||||
page: 1,
|
||||
limit: 100,
|
||||
},
|
||||
context,
|
||||
)) as { data: Array<{ name: string }> };
|
||||
|
||||
const names = result.data.map((p) => p.name);
|
||||
expect(names).toContain(newPrompt);
|
||||
expect(names).not.toContain(oldPrompt);
|
||||
});
|
||||
|
||||
it("should filter by toUpdatedAt", async () => {
|
||||
const { context, projectId } = await createMcpTestSetup();
|
||||
const oldPrompt = `filter-to-updated-${nanoid()}`;
|
||||
const newPrompt = `filter-to-updated-${nanoid()}`;
|
||||
|
||||
const oldDate = new Date("2026-01-01T00:00:00.000Z");
|
||||
const newDate = new Date("2026-02-01T00:00:00.000Z");
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
name: oldPrompt,
|
||||
prompt: "old",
|
||||
labels: [],
|
||||
tags: [],
|
||||
type: "text",
|
||||
version: 1,
|
||||
config: {},
|
||||
createdBy: "test-user",
|
||||
createdAt: oldDate,
|
||||
updatedAt: oldDate,
|
||||
project: { connect: { id: projectId } },
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
name: newPrompt,
|
||||
prompt: "new",
|
||||
labels: [],
|
||||
tags: [],
|
||||
type: "text",
|
||||
version: 1,
|
||||
config: {},
|
||||
createdBy: "test-user",
|
||||
createdAt: newDate,
|
||||
updatedAt: newDate,
|
||||
project: { connect: { id: projectId } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = (await handleListPrompts(
|
||||
{
|
||||
toUpdatedAt: "2026-01-15T00:00:00.000Z",
|
||||
page: 1,
|
||||
limit: 100,
|
||||
},
|
||||
context,
|
||||
)) as { data: Array<{ name: string }> };
|
||||
|
||||
const names = result.data.map((p) => p.name);
|
||||
expect(names).toContain(oldPrompt);
|
||||
expect(names).not.toContain(newPrompt);
|
||||
});
|
||||
|
||||
it("should return error when fromUpdatedAt is after toUpdatedAt", async () => {
|
||||
const { context } = await createMcpTestSetup();
|
||||
|
||||
await expect(
|
||||
handleListPrompts(
|
||||
{
|
||||
fromUpdatedAt: "2026-02-02T00:00:00.000Z",
|
||||
toUpdatedAt: "2026-02-01T00:00:00.000Z",
|
||||
page: 1,
|
||||
limit: 50,
|
||||
},
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow(/fromUpdatedAt.*<=.*toUpdatedAt/i);
|
||||
});
|
||||
|
||||
it("should handle pagination with page and limit", async () => {
|
||||
const { context, projectId } = await createMcpTestSetup();
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { makeZodVerifiedAPICall, makeAPICall } from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { z } from "zod/v4";
|
||||
import { randomUUID } from "crypto";
|
||||
import { Role } from "@hanzo/console-core";
|
||||
import { createAndAddApiKeysToDb, createBasicAuthHeader } from "@hanzo/console-core/src/server";
|
||||
import { Role } from "@hanzo/shared";
|
||||
import { createAndAddApiKeysToDb, createBasicAuthHeader } from "@hanzo/shared/src/server";
|
||||
|
||||
// Schema for membership response
|
||||
const MembershipResponseSchema = z.object({
|
||||
@@ -63,8 +63,8 @@ describe("Memberships APIs", () => {
|
||||
scope: "ORGANIZATION",
|
||||
note: "Test API Key for Memberships API",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-hz-org-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-hz-org-${randomUUID().substring(0, 8)}`,
|
||||
publicKey: `pk-lf-org-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-org-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
testApiKey = apiKey.publicKey;
|
||||
@@ -133,8 +133,8 @@ describe("Memberships APIs", () => {
|
||||
scope: "PROJECT",
|
||||
note: "Test API Key for Memberships API",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-hz-project-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-hz-project-${randomUUID().substring(0, 8)}`,
|
||||
publicKey: `pk-lf-project-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-project-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { makeZodVerifiedAPICall, makeZodVerifiedAPICallSilent, makeAPICall } from "@/src/__tests__/test-utils";
|
||||
import { GetMetricsV1Response } from "@/src/features/public-api/types/metrics";
|
||||
import { createBasicAuthHeader } from "@hanzo/console-core/src/server";
|
||||
import { createBasicAuthHeader } from "@hanzo/shared/src/server";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import { createTrace, createObservation, createTracesCh, createObservationsCh } from "@hanzo/console-core/src/server";
|
||||
import { createTrace, createObservation, createTracesCh, createObservationsCh } from "@hanzo/shared/src/server";
|
||||
|
||||
describe("/api/public/metrics API Endpoint", () => {
|
||||
// Test setup variables
|
||||
@@ -591,7 +591,7 @@ describe("/api/public/metrics API Endpoint", () => {
|
||||
// Verify we got the tagged trace
|
||||
const taggedTraceResult = response.body.data.find((row: any) => row.name === "tagged-trace");
|
||||
expect(taggedTraceResult).toBeDefined();
|
||||
expect(Number(taggedTraceResult!.count_count)).toBe(1);
|
||||
expect(Number(taggedTraceResult.count_count)).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { makeAPICall, makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
|
||||
import { GetModelV1Response, GetModelsV1Response, PostModelsV1Response } from "@/src/features/public-api/types/models";
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
validatePricingTiers,
|
||||
validatePricingMethod,
|
||||
type PricingTierInput,
|
||||
} from "@hanzo/console-core";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/shared/src/server";
|
||||
|
||||
describe("validation methods", () => {
|
||||
describe("validateRegexPattern", () => {
|
||||
@@ -77,7 +77,7 @@ describe("validation methods", () => {
|
||||
it("should reject empty tier array", () => {
|
||||
const result = validatePricingTiers([]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain("At least one pricing tier is required");
|
||||
expect(result.error).toContain("At least one pricing tier is required");
|
||||
});
|
||||
|
||||
it("should reject tiers with no default", () => {
|
||||
@@ -93,9 +93,7 @@ describe("validation methods", () => {
|
||||
|
||||
const result = validatePricingTiers(tiers);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain(
|
||||
"Exactly one pricing tier must have isDefault: true",
|
||||
);
|
||||
expect(result.error).toContain("Exactly one pricing tier must have isDefault: true");
|
||||
});
|
||||
|
||||
it("should reject tiers with multiple defaults", () => {
|
||||
@@ -118,9 +116,7 @@ describe("validation methods", () => {
|
||||
|
||||
const result = validatePricingTiers(tiers);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain(
|
||||
"Only one pricing tier can have isDefault: true",
|
||||
);
|
||||
expect(result.error).toContain("Only one pricing tier can have isDefault: true");
|
||||
});
|
||||
|
||||
it("should reject default tier with non-zero priority", () => {
|
||||
@@ -136,7 +132,7 @@ describe("validation methods", () => {
|
||||
|
||||
const result = validatePricingTiers(tiers);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain("Default pricing tier must have priority: 0");
|
||||
expect(result.error).toContain("Default pricing tier must have priority: 0");
|
||||
});
|
||||
|
||||
it("should reject default tier with conditions", () => {
|
||||
@@ -159,9 +155,7 @@ describe("validation methods", () => {
|
||||
|
||||
const result = validatePricingTiers(tiers);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain(
|
||||
"Default pricing tier must have empty conditions array",
|
||||
);
|
||||
expect(result.error).toContain("Default pricing tier must have empty conditions array");
|
||||
});
|
||||
|
||||
it("should reject duplicate priorities", () => {
|
||||
@@ -205,7 +199,7 @@ describe("validation methods", () => {
|
||||
|
||||
const result = validatePricingTiers(tiers);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain("priorities must be unique");
|
||||
expect(result.error).toContain("priorities must be unique");
|
||||
});
|
||||
|
||||
it("should reject duplicate names", () => {
|
||||
@@ -235,7 +229,7 @@ describe("validation methods", () => {
|
||||
|
||||
const result = validatePricingTiers(tiers);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain("names must be unique");
|
||||
expect(result.error).toContain("names must be unique");
|
||||
});
|
||||
|
||||
it("should reject tier with invalid regex pattern", () => {
|
||||
@@ -265,7 +259,7 @@ describe("validation methods", () => {
|
||||
|
||||
const result = validatePricingTiers(tiers);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain("Invalid regex pattern");
|
||||
expect(result.error).toContain("Invalid regex pattern");
|
||||
});
|
||||
|
||||
it("should reject tier with no prices", () => {
|
||||
@@ -281,7 +275,7 @@ describe("validation methods", () => {
|
||||
|
||||
const result = validatePricingTiers(tiers);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain("must have at least one price defined");
|
||||
expect(result.error).toContain("must have at least one price defined");
|
||||
});
|
||||
|
||||
it("should reject non-default tier with no conditions", () => {
|
||||
@@ -304,9 +298,7 @@ describe("validation methods", () => {
|
||||
|
||||
const result = validatePricingTiers(tiers);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain(
|
||||
'Non-default pricing tier "Custom Tier" must have at least one condition',
|
||||
);
|
||||
expect(result.error).toContain('Non-default pricing tier "Custom Tier" must have at least one condition');
|
||||
});
|
||||
|
||||
it("should reject tiers with mismatched usage keys", () => {
|
||||
@@ -336,10 +328,8 @@ describe("validation methods", () => {
|
||||
|
||||
const result = validatePricingTiers(tiers);
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain(
|
||||
'Pricing tier "High Volume" must have the same usage keys as the default tier',
|
||||
);
|
||||
expect((result as { valid: false; error: string }).error).toContain("Expected: [input, output]");
|
||||
expect(result.error).toContain('Pricing tier "High Volume" must have the same usage keys as the default tier');
|
||||
expect(result.error).toContain("Expected: [input, output]");
|
||||
});
|
||||
|
||||
it("should accept tiers with same keys in different order", () => {
|
||||
@@ -410,7 +400,7 @@ describe("validation methods", () => {
|
||||
hasPricingTiers: true,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain("Cannot provide both");
|
||||
expect(result.error).toContain("Cannot provide both");
|
||||
});
|
||||
|
||||
it("should reject neither flat prices nor pricing tiers", () => {
|
||||
@@ -419,7 +409,7 @@ describe("validation methods", () => {
|
||||
hasPricingTiers: false,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect((result as { valid: false; error: string }).error).toContain("Must provide either");
|
||||
expect(result.error).toContain("Must provide either");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createEvent, createObservation, createTrace, createTracesCh } from "@hanzo/console-core/src/server";
|
||||
import { createEventsCh, createObservationsCh } from "@hanzo/console-core/src/server";
|
||||
import { createEvent, createObservation, createTrace, createTracesCh } from "@hanzo/shared/src/server";
|
||||
import { createEventsCh, createObservationsCh } from "@hanzo/shared/src/server";
|
||||
import { makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
|
||||
import { GetObservationV1Response } from "@/src/features/public-api/types/observations";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEvent, createEventsCh, queryDatastore } from "@hanzo/console-core/src/server";
|
||||
import { createEvent, createEventsCh, queryClickhouse } from "@hanzo/shared/src/server";
|
||||
import { makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
|
||||
import { GetObservationsV2Response } from "@/src/features/public-api/types/observations";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -37,7 +37,7 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
output: "The capital of France is Paris.",
|
||||
metadata: { source: "API" },
|
||||
metadata_names: ["source"],
|
||||
metadata_values: ["API"],
|
||||
metadata_raw_values: ["API"],
|
||||
provided_model_name: "gpt-4",
|
||||
});
|
||||
|
||||
@@ -122,13 +122,48 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
expect(obs?.output).toBe(jsonOutput);
|
||||
});
|
||||
|
||||
it("should return 400 when parseIoAsJson=true", async () => {
|
||||
const { makeAPICall } = await import("@/src/__tests__/test-utils");
|
||||
const response = await makeAPICall("GET", `/api/public/v2/observations?fields=io&parseIoAsJson=true`);
|
||||
it("should parse input/output as JSON when parseIoAsJson=true", async () => {
|
||||
const traceId = randomUUID();
|
||||
const observationId = randomUUID();
|
||||
const timestamp = new Date();
|
||||
const timeValue = timestamp.getTime() * 1000;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty("error");
|
||||
expect(JSON.stringify(response.body)).toContain("parseIoAsJson");
|
||||
// Create observation with JSON input/output
|
||||
const inputData = { question: "What is 2+2?" };
|
||||
const outputData = { answer: 4 };
|
||||
|
||||
const observation = createEvent({
|
||||
id: observationId,
|
||||
span_id: observationId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "test-observation",
|
||||
type: "GENERATION",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue,
|
||||
end_time: timeValue + 1000 * 1000,
|
||||
input: JSON.stringify(inputData),
|
||||
output: JSON.stringify(outputData),
|
||||
});
|
||||
|
||||
await createEventsCh([observation]);
|
||||
|
||||
// Request with parseIoAsJson=true
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetObservationsV2Response,
|
||||
"GET",
|
||||
`/api/public/v2/observations?fields=io&traceId=${traceId}&parseIoAsJson=true`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const obs = response.body.data.find((o: any) => o.id === observationId);
|
||||
expect(obs).toBeDefined();
|
||||
|
||||
// Input and output should be parsed as objects
|
||||
expect(typeof obs?.input).toBe("object");
|
||||
expect(typeof obs?.output).toBe("object");
|
||||
expect(obs?.input).toEqual(inputData);
|
||||
expect(obs?.output).toEqual(outputData);
|
||||
});
|
||||
|
||||
it("should respect limit parameter with default of 50", async () => {
|
||||
@@ -229,11 +264,11 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
|
||||
await createEventsCh([observation1, observation2]);
|
||||
|
||||
// Wait for Datastore to process
|
||||
// Wait for ClickHouse to process
|
||||
await waitForExpect(
|
||||
async () => {
|
||||
const result = await queryDatastore<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events_core WHERE project_id = {projectId: String} AND span_id IN ({ids: Array(String)})`,
|
||||
const result = await queryClickhouse<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events WHERE project_id = {projectId: String} AND span_id IN ({ids: Array(String)})`,
|
||||
params: { projectId, ids: [observationId1, observationId2] },
|
||||
});
|
||||
expect(Number(result[0]?.count)).toBeGreaterThanOrEqual(2);
|
||||
@@ -391,9 +426,9 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue,
|
||||
// Nested metadata: { scope: { name: "api-server" }, region: "us-east" }
|
||||
metadata: { scope: "api-server", region: "us-east" },
|
||||
metadata: { scope: { name: "api-server" }, region: "us-east" },
|
||||
metadata_names: ["scope.name", "region"],
|
||||
metadata_values: ["api-server", "us-east"],
|
||||
metadata_raw_values: ["api-server", "us-east"],
|
||||
});
|
||||
|
||||
const observation2 = createEvent({
|
||||
@@ -406,18 +441,18 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 1000 * 1000,
|
||||
// Nested metadata: { scope: { name: "ui-client" }, region: "us-west" }
|
||||
metadata: { scope: "ui-client", region: "us-west" },
|
||||
metadata: { scope: { name: "ui-client" }, region: "us-west" },
|
||||
metadata_names: ["scope.name", "region"],
|
||||
metadata_values: ["ui-client", "us-west"],
|
||||
metadata_raw_values: ["ui-client", "us-west"],
|
||||
});
|
||||
|
||||
await createEventsCh([observation1, observation2]);
|
||||
|
||||
// Wait for Datastore to process
|
||||
// Wait for ClickHouse to process
|
||||
await waitForExpect(
|
||||
async () => {
|
||||
const result = await queryDatastore<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events_core WHERE project_id = {projectId: String} AND span_id IN ({ids: Array(String)})`,
|
||||
const result = await queryClickhouse<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events WHERE project_id = {projectId: String} AND span_id IN ({ids: Array(String)})`,
|
||||
params: { projectId, ids: [observationId1, observationId2] },
|
||||
});
|
||||
expect(Number(result[0]?.count)).toBeGreaterThanOrEqual(2);
|
||||
@@ -453,7 +488,10 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
});
|
||||
|
||||
maybe("Metadata expansion with expandMetadata parameter", () => {
|
||||
it("should return full metadata values when expandMetadata is specified", async () => {
|
||||
// Cutoff is 200 chars - values longer than 200 chars are truncated by default
|
||||
const METADATA_CUTOFF = 200;
|
||||
|
||||
it("should selectively expand only specified metadata keys", async () => {
|
||||
const traceId = randomUUID();
|
||||
const observationId = randomUUID();
|
||||
const timestamp = new Date();
|
||||
@@ -474,20 +512,20 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
start_time: timeValue,
|
||||
metadata: {
|
||||
expandMe: longValue1,
|
||||
otherKey: longValue2,
|
||||
keepTruncated: longValue2,
|
||||
shortKey: "shortValue",
|
||||
},
|
||||
metadata_names: ["expandMe", "otherKey", "shortKey"],
|
||||
metadata_values: [longValue1, longValue2, "shortValue"],
|
||||
metadata_names: ["expandMe", "keepTruncated", "shortKey"],
|
||||
metadata_raw_values: [longValue1, longValue2, "shortValue"],
|
||||
});
|
||||
|
||||
await createEventsCh([observation]);
|
||||
|
||||
// Wait for Datastore to process
|
||||
// Wait for ClickHouse to process
|
||||
await waitForExpect(
|
||||
async () => {
|
||||
const result = await queryDatastore<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events_core WHERE project_id = {projectId: String} AND span_id = {id: String}`,
|
||||
const result = await queryClickhouse<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events WHERE project_id = {projectId: String} AND span_id = {id: String}`,
|
||||
params: { projectId, id: observationId },
|
||||
});
|
||||
expect(Number(result[0]?.count)).toBeGreaterThanOrEqual(1);
|
||||
@@ -496,7 +534,7 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
10,
|
||||
);
|
||||
|
||||
// Request metadata with expansion - this switches to events_full table
|
||||
// Request metadata with expansion for only 'expandMe' key
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetObservationsV2Response,
|
||||
"GET",
|
||||
@@ -507,13 +545,13 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
const obs = response.body.data.find((o: any) => o.id === observationId);
|
||||
expect(obs).toBeDefined();
|
||||
|
||||
// When expandMetadata is specified, the query uses events_full table which has
|
||||
// full (non-truncated) metadata values. All metadata values are returned in full.
|
||||
// 'expandMe' should be full (300 chars)
|
||||
expect(obs?.metadata?.expandMe?.length).toBe(300);
|
||||
expect(obs?.metadata?.expandMe).toBe(longValue1);
|
||||
|
||||
expect(obs?.metadata?.otherKey?.length).toBe(300);
|
||||
expect(obs?.metadata?.otherKey).toBe(longValue2);
|
||||
// 'keepTruncated' should still be truncated (200 chars)
|
||||
expect(obs?.metadata?.keepTruncated?.length).toBe(METADATA_CUTOFF);
|
||||
expect(obs?.metadata?.keepTruncated).toBe(longValue2.substring(0, METADATA_CUTOFF));
|
||||
|
||||
// 'shortValue' should be present as is
|
||||
expect(obs?.metadata?.shortKey).toBe("shortValue");
|
||||
@@ -536,16 +574,16 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
start_time: timeValue,
|
||||
metadata: { existingKey: "value" },
|
||||
metadata_names: ["existingKey"],
|
||||
metadata_values: ["value"],
|
||||
metadata_raw_values: ["value"],
|
||||
});
|
||||
|
||||
await createEventsCh([observation]);
|
||||
|
||||
// Wait for Datastore to process
|
||||
// Wait for ClickHouse to process
|
||||
await waitForExpect(
|
||||
async () => {
|
||||
const result = await queryDatastore<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events_core WHERE project_id = {projectId: String} AND span_id = {id: String}`,
|
||||
const result = await queryClickhouse<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events WHERE project_id = {projectId: String} AND span_id = {id: String}`,
|
||||
params: { projectId, id: observationId },
|
||||
});
|
||||
expect(Number(result[0]?.count)).toBeGreaterThanOrEqual(1);
|
||||
@@ -578,10 +616,8 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
const timestamp = new Date();
|
||||
const timeValue = timestamp.getTime() * 1000;
|
||||
|
||||
// Create a long metadata value (> 200 chars, the MV truncation limit)
|
||||
// Create a long metadata value (> 200 chars)
|
||||
const longValue = "z".repeat(300);
|
||||
// events_core MV truncates metadata values to 200 chars
|
||||
const METADATA_CUTOFF = 200;
|
||||
|
||||
const observation = createEvent({
|
||||
id: observationId,
|
||||
@@ -594,16 +630,16 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
start_time: timeValue,
|
||||
metadata: { longKey: longValue },
|
||||
metadata_names: ["longKey"],
|
||||
metadata_values: [longValue],
|
||||
metadata_raw_values: [longValue],
|
||||
});
|
||||
|
||||
await createEventsCh([observation]);
|
||||
|
||||
// Wait for Datastore to process
|
||||
// Wait for ClickHouse to process
|
||||
await waitForExpect(
|
||||
async () => {
|
||||
const result = await queryDatastore<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events_core WHERE project_id = {projectId: String} AND span_id = {id: String}`,
|
||||
const result = await queryClickhouse<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events WHERE project_id = {projectId: String} AND span_id = {id: String}`,
|
||||
params: { projectId, id: observationId },
|
||||
});
|
||||
expect(Number(result[0]?.count)).toBeGreaterThanOrEqual(1);
|
||||
@@ -612,7 +648,7 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
10,
|
||||
);
|
||||
|
||||
// Request metadata with empty expandMetadata - should use events_core (truncated)
|
||||
// Request metadata with empty expandMetadata - should use truncated
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetObservationsV2Response,
|
||||
"GET",
|
||||
@@ -623,7 +659,7 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
const obs = response.body.data.find((o: any) => o.id === observationId);
|
||||
expect(obs).toBeDefined();
|
||||
|
||||
// Metadata should still be present but truncated (empty expandMetadata uses events_core)
|
||||
// Metadata should still be present but truncated (empty expandMetadata means no expansion)
|
||||
expect(obs?.metadata?.longKey).toBeDefined();
|
||||
expect(obs?.metadata?.longKey?.length).toBe(METADATA_CUTOFF);
|
||||
expect(obs?.metadata?.longKey).toBe(longValue.substring(0, METADATA_CUTOFF));
|
||||
@@ -631,61 +667,6 @@ describe("/api/public/v2/observations API Endpoint", () => {
|
||||
});
|
||||
|
||||
maybe("Cursor-based pagination", () => {
|
||||
it("should apply LIMIT to query even on first request (no cursor)", async () => {
|
||||
// This test verifies the bug fix - the v2 API should respect limit
|
||||
// even on the first request without a cursor
|
||||
const traceId = randomUUID();
|
||||
const timestamp = new Date();
|
||||
const timeValue = timestamp.getTime() * 1000;
|
||||
|
||||
// Create more observations than the limit
|
||||
const observations = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const obsId = randomUUID();
|
||||
observations.push(
|
||||
createEvent({
|
||||
id: obsId,
|
||||
span_id: obsId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: `limit-test-obs-${i}`,
|
||||
type: "GENERATION",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + i * 1000 * 1000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await createEventsCh(observations);
|
||||
|
||||
// Wait for Datastore to process
|
||||
await waitForExpect(
|
||||
async () => {
|
||||
const result = await queryDatastore<{ count: string }>({
|
||||
query: `SELECT count() as count FROM events_core WHERE project_id = {projectId: String} AND trace_id = {traceId: String}`,
|
||||
params: { projectId, traceId },
|
||||
});
|
||||
expect(Number(result[0]?.count)).toBeGreaterThanOrEqual(10);
|
||||
},
|
||||
5000,
|
||||
10,
|
||||
);
|
||||
|
||||
// Request with limit=5 - should return exactly 5, not all 10
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetObservationsV2Response,
|
||||
"GET",
|
||||
`/api/public/v2/observations?traceId=${traceId}&limit=5`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// With the bug, this returns all 10 (no LIMIT applied)
|
||||
// After fix, this should return exactly 5
|
||||
expect(response.body.data.length).toBe(5);
|
||||
// Should have cursor since there are more results
|
||||
expect(response.body.meta.cursor).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return cursor when results equal limit", async () => {
|
||||
const traceId = randomUUID();
|
||||
const timestamp = new Date();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createObservation, createTrace, createTracesCh, createEvent } from "@hanzo/console-core/src/server";
|
||||
import { createObservationsCh, createEventsCh } from "@hanzo/console-core/src/server";
|
||||
import { createObservation, createTrace, createTracesCh, createEvent } from "@hanzo/shared/src/server";
|
||||
import { createObservationsCh, createEventsCh } from "@hanzo/shared/src/server";
|
||||
import { makeAPICall, makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
|
||||
import { GetObservationsV1Response } from "@/src/features/public-api/types/observations";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -49,7 +49,7 @@ const createObservationData = (
|
||||
output: data.output !== undefined ? data.output : "Hello John",
|
||||
metadata: data.metadata ?? { source: "API", server: "Node" },
|
||||
metadata_names: data.metadata ? Object.keys(data.metadata) : undefined,
|
||||
metadata_values: data.metadata ? Object.values(data.metadata) : undefined,
|
||||
metadata_raw_values: data.metadata ? Object.values(data.metadata) : undefined,
|
||||
provided_model_name: data.provided_model_name,
|
||||
provided_usage_details: data.provided_usage_details,
|
||||
provided_cost_details: data.provided_cost_details,
|
||||
@@ -833,332 +833,4 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
}
|
||||
runAdvancedFilterTestSuite(false); // with observations table
|
||||
});
|
||||
|
||||
// parentObservationId filter tests
|
||||
describe("parentObservationId filter", () => {
|
||||
const runParentObservationIdFilterTestSuite = (useEventsTable: boolean) => {
|
||||
const suiteName = useEventsTable ? "with events table" : "with observations table";
|
||||
const queryParam = useEventsTable ? "?useEventsTable=true&" : "?";
|
||||
|
||||
describe(`${suiteName}`, () => {
|
||||
it("should filter for root observations (no parent) using 'is null'", async () => {
|
||||
const traceId = randomUUID();
|
||||
const timestamp = new Date();
|
||||
const timeValue = useEventsTable ? timestamp.getTime() * 1000 : timestamp.getTime();
|
||||
const timeMultiplier = useEventsTable ? 1000 : 1;
|
||||
|
||||
const createdTrace = createTrace({
|
||||
id: traceId,
|
||||
project_id: projectId,
|
||||
timestamp: timestamp.getTime(),
|
||||
});
|
||||
|
||||
const parentObsId = randomUUID();
|
||||
const childObsId = randomUUID();
|
||||
|
||||
// Create parent (root) observation and child observation
|
||||
await createTracesCh([createdTrace]);
|
||||
|
||||
if (useEventsTable) {
|
||||
await createEventsCh([
|
||||
createEvent({
|
||||
id: parentObsId,
|
||||
span_id: parentObsId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "parent-observation",
|
||||
type: "SPAN",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue,
|
||||
parent_span_id: "", // root observation - empty string for events table
|
||||
}),
|
||||
createEvent({
|
||||
id: childObsId,
|
||||
span_id: childObsId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "child-observation",
|
||||
type: "GENERATION",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 1000 * timeMultiplier,
|
||||
parent_span_id: parentObsId, // has parent
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await createObservationsCh([
|
||||
createObservation({
|
||||
id: parentObsId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "parent-observation",
|
||||
type: "SPAN",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue,
|
||||
parent_observation_id: null, // root observation - NULL for observations table
|
||||
}),
|
||||
createObservation({
|
||||
id: childObsId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "child-observation",
|
||||
type: "GENERATION",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 1000 * timeMultiplier,
|
||||
parent_observation_id: parentObsId, // has parent
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
// Filter for root observations (parentObservationId is null)
|
||||
const filterParam = JSON.stringify([
|
||||
{
|
||||
type: "null",
|
||||
column: "parentObservationId",
|
||||
operator: "is null",
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetObservationsV1Response,
|
||||
"GET",
|
||||
`/api/public/observations${queryParam}traceId=${traceId}&filter=${encodeURIComponent(filterParam)}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
expect(response.body.data[0]?.name).toBe("parent-observation");
|
||||
expect(response.body.data[0]?.id).toBe(parentObsId);
|
||||
});
|
||||
|
||||
it("should filter for non-root observations (has parent) using 'is not null'", async () => {
|
||||
const traceId = randomUUID();
|
||||
const timestamp = new Date();
|
||||
const timeValue = useEventsTable ? timestamp.getTime() * 1000 : timestamp.getTime();
|
||||
const timeMultiplier = useEventsTable ? 1000 : 1;
|
||||
|
||||
const createdTrace = createTrace({
|
||||
id: traceId,
|
||||
project_id: projectId,
|
||||
timestamp: timestamp.getTime(),
|
||||
});
|
||||
|
||||
const parentObsId = randomUUID();
|
||||
const childObsId = randomUUID();
|
||||
|
||||
await createTracesCh([createdTrace]);
|
||||
|
||||
if (useEventsTable) {
|
||||
await createEventsCh([
|
||||
createEvent({
|
||||
id: parentObsId,
|
||||
span_id: parentObsId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "parent-observation",
|
||||
type: "SPAN",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue,
|
||||
parent_span_id: "", // root observation
|
||||
}),
|
||||
createEvent({
|
||||
id: childObsId,
|
||||
span_id: childObsId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "child-observation",
|
||||
type: "GENERATION",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 1000 * timeMultiplier,
|
||||
parent_span_id: parentObsId, // has parent
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await createObservationsCh([
|
||||
createObservation({
|
||||
id: parentObsId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "parent-observation",
|
||||
type: "SPAN",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue,
|
||||
parent_observation_id: null, // root observation
|
||||
}),
|
||||
createObservation({
|
||||
id: childObsId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "child-observation",
|
||||
type: "GENERATION",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 1000 * timeMultiplier,
|
||||
parent_observation_id: parentObsId, // has parent
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
// Filter for non-root observations (parentObservationId is not null)
|
||||
const filterParam = JSON.stringify([
|
||||
{
|
||||
type: "null",
|
||||
column: "parentObservationId",
|
||||
operator: "is not null",
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetObservationsV1Response,
|
||||
"GET",
|
||||
`/api/public/observations${queryParam}traceId=${traceId}&filter=${encodeURIComponent(filterParam)}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
expect(response.body.data[0]?.name).toBe("child-observation");
|
||||
expect(response.body.data[0]?.id).toBe(childObsId);
|
||||
});
|
||||
|
||||
it("should filter by specific parent ID using string equals", async () => {
|
||||
const traceId = randomUUID();
|
||||
const timestamp = new Date();
|
||||
const timeValue = useEventsTable ? timestamp.getTime() * 1000 : timestamp.getTime();
|
||||
const timeMultiplier = useEventsTable ? 1000 : 1;
|
||||
|
||||
const createdTrace = createTrace({
|
||||
id: traceId,
|
||||
project_id: projectId,
|
||||
timestamp: timestamp.getTime(),
|
||||
});
|
||||
|
||||
const parent1Id = randomUUID();
|
||||
const parent2Id = randomUUID();
|
||||
const child1Id = randomUUID();
|
||||
const child2Id = randomUUID();
|
||||
|
||||
await createTracesCh([createdTrace]);
|
||||
|
||||
if (useEventsTable) {
|
||||
await createEventsCh([
|
||||
createEvent({
|
||||
id: parent1Id,
|
||||
span_id: parent1Id,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "parent-1",
|
||||
type: "SPAN",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue,
|
||||
parent_span_id: "",
|
||||
}),
|
||||
createEvent({
|
||||
id: parent2Id,
|
||||
span_id: parent2Id,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "parent-2",
|
||||
type: "SPAN",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 100 * timeMultiplier,
|
||||
parent_span_id: "",
|
||||
}),
|
||||
createEvent({
|
||||
id: child1Id,
|
||||
span_id: child1Id,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "child-of-parent-1",
|
||||
type: "GENERATION",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 1000 * timeMultiplier,
|
||||
parent_span_id: parent1Id,
|
||||
}),
|
||||
createEvent({
|
||||
id: child2Id,
|
||||
span_id: child2Id,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "child-of-parent-2",
|
||||
type: "GENERATION",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 2000 * timeMultiplier,
|
||||
parent_span_id: parent2Id,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await createObservationsCh([
|
||||
createObservation({
|
||||
id: parent1Id,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "parent-1",
|
||||
type: "SPAN",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue,
|
||||
parent_observation_id: null,
|
||||
}),
|
||||
createObservation({
|
||||
id: parent2Id,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "parent-2",
|
||||
type: "SPAN",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 100 * timeMultiplier,
|
||||
parent_observation_id: null,
|
||||
}),
|
||||
createObservation({
|
||||
id: child1Id,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "child-of-parent-1",
|
||||
type: "GENERATION",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 1000 * timeMultiplier,
|
||||
parent_observation_id: parent1Id,
|
||||
}),
|
||||
createObservation({
|
||||
id: child2Id,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "child-of-parent-2",
|
||||
type: "GENERATION",
|
||||
level: "DEFAULT",
|
||||
start_time: timeValue + 2000 * timeMultiplier,
|
||||
parent_observation_id: parent2Id,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
// Filter for children of parent-1 only
|
||||
const filterParam = JSON.stringify([
|
||||
{
|
||||
type: "string",
|
||||
column: "parentObservationId",
|
||||
operator: "=",
|
||||
value: parent1Id,
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetObservationsV1Response,
|
||||
"GET",
|
||||
`/api/public/observations${queryParam}traceId=${traceId}&filter=${encodeURIComponent(filterParam)}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
expect(response.body.data[0]?.name).toBe("child-of-parent-1");
|
||||
expect(response.body.data[0]?.id).toBe(child1Id);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Run parentObservationId filter tests for both implementations
|
||||
if (env.HANZO_ENABLE_EVENTS_TABLE_OBSERVATIONS === "true") {
|
||||
runParentObservationIdFilterTestSuite(true); // with events table
|
||||
}
|
||||
runParentObservationIdFilterTestSuite(false); // with observations table
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { makeZodVerifiedAPICall, makeAPICall } from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { z } from "zod/v4";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createAndAddApiKeysToDb, createBasicAuthHeader } from "@hanzo/console-core/src/server";
|
||||
import { createAndAddApiKeysToDb, createBasicAuthHeader } from "@hanzo/shared/src/server";
|
||||
|
||||
// Schema for organization project response
|
||||
const OrganizationProjectSchema = z.object({
|
||||
@@ -62,7 +62,7 @@ const ApiKeyListSchema = z.object({
|
||||
describe("Admin Organizations API", () => {
|
||||
const ADMIN_API_KEY = process.env.ADMIN_API_KEY;
|
||||
|
||||
describe("POST /v1/admin/organizations", () => {
|
||||
describe("POST /api/admin/organizations", () => {
|
||||
it("should create a new organization with valid admin authentication", async () => {
|
||||
const uniqueOrgName = `Test Org ${randomUUID().substring(0, 8)}`;
|
||||
const metadata = { tier: "testing", users: 5 };
|
||||
@@ -70,7 +70,7 @@ describe("Admin Organizations API", () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
OrganizationResponseSchema,
|
||||
"POST",
|
||||
"/v1/admin/organizations",
|
||||
"/api/admin/organizations",
|
||||
{
|
||||
name: uniqueOrgName,
|
||||
metadata: metadata,
|
||||
@@ -105,7 +105,7 @@ describe("Admin Organizations API", () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
OrganizationResponseSchema,
|
||||
"POST",
|
||||
"/v1/admin/organizations",
|
||||
"/api/admin/organizations",
|
||||
{
|
||||
name: uniqueOrgName,
|
||||
},
|
||||
@@ -133,7 +133,7 @@ describe("Admin Organizations API", () => {
|
||||
it("should return 401 when no authorization header is provided", async () => {
|
||||
const uniqueOrgName = `Test Org ${randomUUID().substring(0, 8)}`;
|
||||
|
||||
const result = await makeAPICall("POST", "/v1/admin/organizations", {
|
||||
const result = await makeAPICall("POST", "/api/admin/organizations", {
|
||||
name: uniqueOrgName,
|
||||
});
|
||||
expect(result.status).toBe(401);
|
||||
@@ -145,7 +145,7 @@ describe("Admin Organizations API", () => {
|
||||
|
||||
const result = await makeAPICall(
|
||||
"POST",
|
||||
"/v1/admin/organizations",
|
||||
"/api/admin/organizations",
|
||||
{
|
||||
name: uniqueOrgName,
|
||||
},
|
||||
@@ -158,7 +158,7 @@ describe("Admin Organizations API", () => {
|
||||
it("should return 400 when organization name is too short", async () => {
|
||||
const result = await makeAPICall(
|
||||
"POST",
|
||||
"/v1/admin/organizations",
|
||||
"/api/admin/organizations",
|
||||
{
|
||||
name: "A", // Short name
|
||||
},
|
||||
@@ -171,7 +171,7 @@ describe("Admin Organizations API", () => {
|
||||
it("should return 400 when organization name is too long", async () => {
|
||||
const result = await makeAPICall(
|
||||
"POST",
|
||||
"/v1/admin/organizations",
|
||||
"/api/admin/organizations",
|
||||
{
|
||||
name: "A".repeat(61), // More than 60 characters
|
||||
},
|
||||
@@ -182,7 +182,7 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /v1/admin/organizations", () => {
|
||||
describe("GET /api/admin/organizations", () => {
|
||||
let testOrgId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -209,7 +209,7 @@ describe("Admin Organizations API", () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
OrganizationsListSchema,
|
||||
"GET",
|
||||
"/v1/admin/organizations",
|
||||
"/api/admin/organizations",
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
200,
|
||||
@@ -232,13 +232,13 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
|
||||
it("should return 401 when no authorization header is provided", async () => {
|
||||
const result = await makeAPICall("GET", "/v1/admin/organizations");
|
||||
const result = await makeAPICall("GET", "/api/admin/organizations");
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.body.error).toContain("Unauthorized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /v1/admin/organizations/[organizationId]", () => {
|
||||
describe("GET /api/admin/organizations/[organizationId]", () => {
|
||||
let testOrgId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -265,7 +265,7 @@ describe("Admin Organizations API", () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
OrganizationResponseSchema,
|
||||
"GET",
|
||||
`/v1/admin/organizations/${testOrgId}`,
|
||||
`/api/admin/organizations/${testOrgId}`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
200,
|
||||
@@ -299,7 +299,7 @@ describe("Admin Organizations API", () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
OrganizationResponseSchema,
|
||||
"GET",
|
||||
`/v1/admin/organizations/${org.id}`,
|
||||
`/api/admin/organizations/${org.id}`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
200,
|
||||
@@ -328,7 +328,7 @@ describe("Admin Organizations API", () => {
|
||||
const nonExistentId = randomUUID();
|
||||
const result = await makeAPICall(
|
||||
"GET",
|
||||
`/v1/admin/organizations/${nonExistentId}`,
|
||||
`/api/admin/organizations/${nonExistentId}`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
);
|
||||
@@ -338,7 +338,7 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /v1/admin/organizations/[organizationId]", () => {
|
||||
describe("PUT /api/admin/organizations/[organizationId]", () => {
|
||||
let testOrgId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -368,7 +368,7 @@ describe("Admin Organizations API", () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
OrganizationResponseSchema,
|
||||
"PUT",
|
||||
`/v1/admin/organizations/${testOrgId}`,
|
||||
`/api/admin/organizations/${testOrgId}`,
|
||||
{
|
||||
name: newName,
|
||||
metadata: newMetadata,
|
||||
@@ -397,7 +397,7 @@ describe("Admin Organizations API", () => {
|
||||
const nonExistentId = randomUUID();
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
`/v1/admin/organizations/${nonExistentId}`,
|
||||
`/api/admin/organizations/${nonExistentId}`,
|
||||
{
|
||||
name: "New Name",
|
||||
},
|
||||
@@ -411,7 +411,7 @@ describe("Admin Organizations API", () => {
|
||||
it("should return 400 when updating with invalid name", async () => {
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
`/v1/admin/organizations/${testOrgId}`,
|
||||
`/api/admin/organizations/${testOrgId}`,
|
||||
{
|
||||
name: "A", // Short name
|
||||
},
|
||||
@@ -423,7 +423,7 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
|
||||
it("should return 401 when no authorization header is provided", async () => {
|
||||
const result = await makeAPICall("PUT", `/v1/admin/organizations/${testOrgId}`, {
|
||||
const result = await makeAPICall("PUT", `/api/admin/organizations/${testOrgId}`, {
|
||||
name: "New Name",
|
||||
});
|
||||
|
||||
@@ -432,7 +432,7 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /v1/admin/organizations/[organizationId]", () => {
|
||||
describe("DELETE /api/admin/organizations/[organizationId]", () => {
|
||||
let testOrgId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -470,7 +470,7 @@ describe("Admin Organizations API", () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
DeleteResponseSchema,
|
||||
"DELETE",
|
||||
`/v1/admin/organizations/${orgId}`,
|
||||
`/api/admin/organizations/${orgId}`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
200,
|
||||
@@ -492,7 +492,7 @@ describe("Admin Organizations API", () => {
|
||||
const nonExistentId = randomUUID();
|
||||
const result = await makeAPICall(
|
||||
"DELETE",
|
||||
`/v1/admin/organizations/${nonExistentId}`,
|
||||
`/api/admin/organizations/${nonExistentId}`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
);
|
||||
@@ -512,7 +512,7 @@ describe("Admin Organizations API", () => {
|
||||
|
||||
const result = await makeAPICall(
|
||||
"DELETE",
|
||||
`/v1/admin/organizations/${testOrgId}`,
|
||||
`/api/admin/organizations/${testOrgId}`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
);
|
||||
@@ -527,14 +527,14 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
|
||||
it("should return 401 when no authorization header is provided", async () => {
|
||||
const result = await makeAPICall("DELETE", `/v1/admin/organizations/${testOrgId}`);
|
||||
const result = await makeAPICall("DELETE", `/api/admin/organizations/${testOrgId}`);
|
||||
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.body.error).toContain("Unauthorized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /v1/admin/organizations/[organizationId]/apiKeys", () => {
|
||||
describe("GET /api/admin/organizations/[organizationId]/apiKeys", () => {
|
||||
let testOrgId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -549,9 +549,9 @@ describe("Admin Organizations API", () => {
|
||||
await prisma.apiKey.create({
|
||||
data: {
|
||||
orgId: testOrgId,
|
||||
publicKey: `pk-hz-test-${randomUUID()}`,
|
||||
publicKey: `pk-lf-test-${randomUUID()}`,
|
||||
hashedSecretKey: "hashed-secret",
|
||||
displaySecretKey: "sk-hz-test...1234",
|
||||
displaySecretKey: "sk-lf-test...1234",
|
||||
note: "Test API Key",
|
||||
scope: "ORGANIZATION",
|
||||
},
|
||||
@@ -576,7 +576,7 @@ describe("Admin Organizations API", () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
ApiKeyListSchema,
|
||||
"GET",
|
||||
`/v1/admin/organizations/${testOrgId}/apiKeys`,
|
||||
`/api/admin/organizations/${testOrgId}/apiKeys`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
200,
|
||||
@@ -592,7 +592,7 @@ describe("Admin Organizations API", () => {
|
||||
const nonExistentId = randomUUID();
|
||||
const result = await makeAPICall(
|
||||
"GET",
|
||||
`/v1/admin/organizations/${nonExistentId}/apiKeys`,
|
||||
`/api/admin/organizations/${nonExistentId}/apiKeys`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
);
|
||||
@@ -602,7 +602,7 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/admin/organizations/[organizationId]/apiKeys", () => {
|
||||
describe("POST /api/admin/organizations/[organizationId]/apiKeys", () => {
|
||||
let testOrgId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -632,7 +632,7 @@ describe("Admin Organizations API", () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
ApiKeyResponseSchema,
|
||||
"POST",
|
||||
`/v1/admin/organizations/${testOrgId}/apiKeys`,
|
||||
`/api/admin/organizations/${testOrgId}/apiKeys`,
|
||||
{
|
||||
note: "Test API Key",
|
||||
},
|
||||
@@ -641,8 +641,8 @@ describe("Admin Organizations API", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.publicKey).toMatch(/^pk-hz-/);
|
||||
expect(response.body.secretKey).toMatch(/^sk-hz-/);
|
||||
expect(response.body.publicKey).toMatch(/^pk-lf-/);
|
||||
expect(response.body.secretKey).toMatch(/^sk-lf-/);
|
||||
expect(response.body.note).toBe("Test API Key");
|
||||
|
||||
// Verify the API key was actually created in the database
|
||||
@@ -658,7 +658,7 @@ describe("Admin Organizations API", () => {
|
||||
const nonExistentId = randomUUID();
|
||||
const result = await makeAPICall(
|
||||
"POST",
|
||||
`/v1/admin/organizations/${nonExistentId}/apiKeys`,
|
||||
`/api/admin/organizations/${nonExistentId}/apiKeys`,
|
||||
{
|
||||
note: "Test API Key",
|
||||
},
|
||||
@@ -670,7 +670,7 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
|
||||
it("should return 401 when no authorization header is provided", async () => {
|
||||
const result = await makeAPICall("POST", `/v1/admin/organizations/${testOrgId}/apiKeys`, {
|
||||
const result = await makeAPICall("POST", `/api/admin/organizations/${testOrgId}/apiKeys`, {
|
||||
note: "Test API Key",
|
||||
});
|
||||
|
||||
@@ -679,7 +679,7 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /v1/admin/organizations/[organizationId]/apiKeys/[apiKeyId]", () => {
|
||||
describe("DELETE /api/admin/organizations/[organizationId]/apiKeys/[apiKeyId]", () => {
|
||||
let testOrgId: string;
|
||||
let testApiKeyId: string;
|
||||
|
||||
@@ -695,9 +695,9 @@ describe("Admin Organizations API", () => {
|
||||
const apiKey = await prisma.apiKey.create({
|
||||
data: {
|
||||
orgId: testOrgId,
|
||||
publicKey: `pk-hz-test-${randomUUID()}`,
|
||||
publicKey: `pk-lf-test-${randomUUID()}`,
|
||||
hashedSecretKey: "hashed-secret",
|
||||
displaySecretKey: "sk-hz-test...1234",
|
||||
displaySecretKey: "sk-lf-test...1234",
|
||||
note: "Test API Key",
|
||||
scope: "ORGANIZATION",
|
||||
},
|
||||
@@ -723,7 +723,7 @@ describe("Admin Organizations API", () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
DeleteResponseSchema,
|
||||
"DELETE",
|
||||
`/v1/admin/organizations/${testOrgId}/apiKeys/${testApiKeyId}`,
|
||||
`/api/admin/organizations/${testOrgId}/apiKeys/${testApiKeyId}`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
200,
|
||||
@@ -745,7 +745,7 @@ describe("Admin Organizations API", () => {
|
||||
const nonExistentId = randomUUID();
|
||||
const result = await makeAPICall(
|
||||
"DELETE",
|
||||
`/v1/admin/organizations/${testOrgId}/apiKeys/${nonExistentId}`,
|
||||
`/api/admin/organizations/${testOrgId}/apiKeys/${nonExistentId}`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
);
|
||||
@@ -758,7 +758,7 @@ describe("Admin Organizations API", () => {
|
||||
const nonExistentId = randomUUID();
|
||||
const result = await makeAPICall(
|
||||
"DELETE",
|
||||
`/v1/admin/organizations/${nonExistentId}/apiKeys/${testApiKeyId}`,
|
||||
`/api/admin/organizations/${nonExistentId}/apiKeys/${testApiKeyId}`,
|
||||
undefined,
|
||||
`Bearer ${ADMIN_API_KEY}`,
|
||||
);
|
||||
@@ -768,7 +768,7 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
|
||||
it("should return 401 when no authorization header is provided", async () => {
|
||||
const result = await makeAPICall("DELETE", `/v1/admin/organizations/${testOrgId}/apiKeys/${testApiKeyId}`);
|
||||
const result = await makeAPICall("DELETE", `/api/admin/organizations/${testOrgId}/apiKeys/${testApiKeyId}`);
|
||||
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.body.error).toContain("Unauthorized");
|
||||
@@ -776,7 +776,7 @@ describe("Admin Organizations API", () => {
|
||||
});
|
||||
|
||||
it("should return 405 for non-supported methods", async () => {
|
||||
const result = await makeAPICall("PATCH", "/v1/admin/organizations", undefined, `Bearer ${ADMIN_API_KEY}`);
|
||||
const result = await makeAPICall("PATCH", "/api/admin/organizations", undefined, `Bearer ${ADMIN_API_KEY}`);
|
||||
expect(result.status).toBe(405);
|
||||
expect(result.body.error).toContain("Method Not Allowed");
|
||||
});
|
||||
@@ -831,8 +831,8 @@ describe("Public Organizations API", () => {
|
||||
scope: "ORGANIZATION",
|
||||
note: "Test API Key for Organizations API",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-hz-org-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-hz-org-${randomUUID().substring(0, 8)}`,
|
||||
publicKey: `pk-lf-org-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-org-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
testApiKey = apiKey.publicKey;
|
||||
@@ -896,8 +896,8 @@ describe("Public Organizations API", () => {
|
||||
scope: "PROJECT",
|
||||
note: "Test Project API Key",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-hz-project-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-hz-project-${randomUUID().substring(0, 8)}`,
|
||||
publicKey: `pk-lf-project-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-project-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -995,8 +995,8 @@ describe("Public Organizations API", () => {
|
||||
scope: "ORGANIZATION",
|
||||
note: "Test API Key for Empty Org",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-hz-empty-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-hz-empty-${randomUUID().substring(0, 8)}`,
|
||||
publicKey: `pk-lf-empty-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-empty-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1026,7 +1026,7 @@ describe("Public Organizations API", () => {
|
||||
data: {
|
||||
name: `Null Metadata Project ${randomUUID().substring(0, 8)}`,
|
||||
orgId: testOrgId,
|
||||
metadata: undefined,
|
||||
metadata: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { makeAPICall } from "@/src/__tests__/test-utils";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import { getObservationById, getObservationByIdFromEventsTable, getTraceById } from "@hanzo/console-core/src/server";
|
||||
import { getObservationById, getObservationByIdFromEventsTable, getTraceById } from "@hanzo/shared/src/server";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
@@ -24,7 +24,7 @@ describe("/api/public/otel/v1/traces API Endpoint", () => {
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: "pk-hz-1234567890" },
|
||||
value: { stringValue: "pk-lf-1234567890" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -94,7 +94,7 @@ describe("/api/public/otel/v1/traces API Endpoint", () => {
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: "pk-hz-1234567890" },
|
||||
value: { stringValue: "pk-lf-1234567890" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -223,7 +223,7 @@ describe("/api/public/otel/v1/traces API Endpoint", () => {
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: "pk-hz-1234567890" },
|
||||
value: { stringValue: "pk-lf-1234567890" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -310,78 +310,6 @@ describe("/api/public/otel/v1/traces API Endpoint", () => {
|
||||
}, 25_000);
|
||||
}, 30_000);
|
||||
|
||||
it("should accept sdk headers in underscore format", async () => {
|
||||
const traceId = randomBytes(16);
|
||||
const spanId = randomBytes(8);
|
||||
|
||||
const payload = {
|
||||
resourceSpans: [
|
||||
{
|
||||
resource: {
|
||||
attributes: [],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "console-sdk",
|
||||
version: "4.0.0",
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: "pk-hz-1234567890" },
|
||||
},
|
||||
],
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId,
|
||||
spanId,
|
||||
name: "underscore-header-span",
|
||||
kind: 1,
|
||||
startTimeUnixNano: {
|
||||
low: 466848096,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: 467248096,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
},
|
||||
attributes: [],
|
||||
status: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await makeAPICall("POST", "/api/public/otel/v1/traces", payload, undefined, {
|
||||
x_console_sdk_name: "python",
|
||||
x_console_sdk_version: "4.0.0",
|
||||
x_console_ingestion_version: "4",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const trace = await getTraceById({
|
||||
projectId,
|
||||
traceId: traceId.toString("hex"),
|
||||
});
|
||||
expect(trace).toBeDefined();
|
||||
|
||||
const observation = await getObservationById({
|
||||
projectId,
|
||||
id: spanId.toString("hex"),
|
||||
});
|
||||
expect(observation).toBeDefined();
|
||||
expect(observation!.name).toBe("underscore-header-span");
|
||||
}, 25_000);
|
||||
}, 30_000);
|
||||
|
||||
it("should transform deployment.environment to lowercase", async () => {
|
||||
const traceId = randomBytes(16);
|
||||
const spanId = randomBytes(8);
|
||||
@@ -405,7 +333,7 @@ describe("/api/public/otel/v1/traces API Endpoint", () => {
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: "pk-hz-1234567890" },
|
||||
value: { stringValue: "pk-lf-1234567890" },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { makeZodVerifiedAPICall, makeAPICall } from "@/src/__tests__/test-utils";
|
||||
import { z } from "zod/v4";
|
||||
import { createAndAddApiKeysToDb, createBasicAuthHeader } from "@hanzo/console-core/src/server";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { createAndAddApiKeysToDb, createBasicAuthHeader } from "@hanzo/shared/src/server";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// Schema for project response
|
||||
@@ -79,12 +79,12 @@ describe("Projects API", () => {
|
||||
// Test variables
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
const projectName = "Seed Project";
|
||||
const projectApiKey = "pk-hz-1234567890";
|
||||
const projectSecretKey = "sk-hz-1234567890";
|
||||
const invalidApiKey = "pk-hz-invalid";
|
||||
const invalidSecretKey = "sk-hz-invalid";
|
||||
const orgApiKey = `pk-hz-org-${randomUUID().substring(0, 8)}`;
|
||||
const orgSecretKey = `sk-hz-org-${randomUUID().substring(0, 8)}`;
|
||||
const projectApiKey = "pk-lf-1234567890";
|
||||
const projectSecretKey = "sk-lf-1234567890";
|
||||
const invalidApiKey = "pk-lf-invalid";
|
||||
const invalidSecretKey = "sk-lf-invalid";
|
||||
const orgApiKey = `pk-lf-org-${randomUUID().substring(0, 8)}`;
|
||||
const orgSecretKey = `sk-lf-org-${randomUUID().substring(0, 8)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
await createAndAddApiKeysToDb({
|
||||
@@ -707,8 +707,8 @@ describe("Projects API", () => {
|
||||
|
||||
it("should create an API key with predefined keys", async () => {
|
||||
const note = `Test API Key with Predefined Keys ${randomUUID().substring(0, 8)}`;
|
||||
const predefinedPublicKey = `pk-hz-${randomUUID()}`;
|
||||
const predefinedSecretKey = `sk-hz-${randomUUID()}`;
|
||||
const predefinedPublicKey = `pk-lf-${randomUUID()}`;
|
||||
const predefinedSecretKey = `sk-lf-${randomUUID()}`;
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
ApiKeyCreationResponseSchema,
|
||||
@@ -742,7 +742,7 @@ describe("Projects API", () => {
|
||||
|
||||
it("should return 400 when only publicKey is provided without secretKey", async () => {
|
||||
const note = `Test API Key ${randomUUID().substring(0, 8)}`;
|
||||
const predefinedPublicKey = `pk-hz-${randomUUID()}`;
|
||||
const predefinedPublicKey = `pk-lf-${randomUUID()}`;
|
||||
|
||||
const result = await makeAPICall(
|
||||
"POST",
|
||||
@@ -760,7 +760,7 @@ describe("Projects API", () => {
|
||||
|
||||
it("should return 400 when only secretKey is provided without publicKey", async () => {
|
||||
const note = `Test API Key ${randomUUID().substring(0, 8)}`;
|
||||
const predefinedSecretKey = `sk-hz-${randomUUID()}`;
|
||||
const predefinedSecretKey = `sk-lf-${randomUUID()}`;
|
||||
|
||||
const result = await makeAPICall(
|
||||
"POST",
|
||||
@@ -776,10 +776,10 @@ describe("Projects API", () => {
|
||||
expect(result.body.message).toContain("Both publicKey and secretKey must be provided together");
|
||||
});
|
||||
|
||||
it("should return 400 when publicKey does not start with pk-hz-", async () => {
|
||||
it("should return 400 when publicKey does not start with pk-lf-", async () => {
|
||||
const note = `Test API Key ${randomUUID().substring(0, 8)}`;
|
||||
const invalidPublicKey = `invalid-${randomUUID()}`;
|
||||
const predefinedSecretKey = `sk-hz-${randomUUID()}`;
|
||||
const predefinedSecretKey = `sk-lf-${randomUUID()}`;
|
||||
|
||||
const result = await makeAPICall(
|
||||
"POST",
|
||||
@@ -796,9 +796,9 @@ describe("Projects API", () => {
|
||||
expect(result.body.message).toContain("publicKey must start with");
|
||||
});
|
||||
|
||||
it("should return 400 when secretKey does not start with sk-hz-", async () => {
|
||||
it("should return 400 when secretKey does not start with sk-lf-", async () => {
|
||||
const note = `Test API Key ${randomUUID().substring(0, 8)}`;
|
||||
const predefinedPublicKey = `pk-hz-${randomUUID()}`;
|
||||
const predefinedPublicKey = `pk-lf-${randomUUID()}`;
|
||||
const invalidSecretKey = `invalid-${randomUUID()}`;
|
||||
|
||||
const result = await makeAPICall(
|
||||
@@ -818,9 +818,9 @@ describe("Projects API", () => {
|
||||
|
||||
it("should return 409 when predefined publicKey already exists", async () => {
|
||||
const note = `Test API Key ${randomUUID().substring(0, 8)}`;
|
||||
const duplicatePublicKey = `pk-hz-${randomUUID()}`;
|
||||
const secretKey1 = `sk-hz-${randomUUID()}`;
|
||||
const secretKey2 = `sk-hz-${randomUUID()}`;
|
||||
const duplicatePublicKey = `pk-lf-${randomUUID()}`;
|
||||
const secretKey1 = `sk-lf-${randomUUID()}`;
|
||||
const secretKey2 = `sk-lf-${randomUUID()}`;
|
||||
|
||||
// Create first API key with predefined keys
|
||||
const firstResponse = await makeAPICall(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { disconnectQueues, makeAPICall } from "@/src/__tests__/test-utils";
|
||||
import { v4 as uuidv4, v4 } from "uuid";
|
||||
import { PromptSchema, type ValidatedPrompt, type ChatMessage, type Prompt, PromptType } from "@hanzo/console-core";
|
||||
import { parsePromptDependencyTags } from "@hanzo/console-core";
|
||||
import { PromptSchema, type ValidatedPrompt, type ChatMessage, type Prompt, PromptType } from "@hanzo/shared";
|
||||
import { parsePromptDependencyTags } from "@hanzo/shared";
|
||||
import { generateId, nanoid } from "ai";
|
||||
|
||||
import { type PromptsMetaResponse } from "@/src/features/prompts/server/actions/getPromptsMeta";
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getObservationById,
|
||||
MAX_PROMPT_NESTING_DEPTH,
|
||||
ChatMessageType,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import { createPrompt } from "@/src/features/prompts/server/actions/createPrompt";
|
||||
@@ -113,7 +113,7 @@ const testPromptEquality = (promptParams: CreatePromptInDBParams, prompt: Prompt
|
||||
expect(prompt.createdBy).toBe(promptParams.createdBy);
|
||||
expect(prompt.config).toEqual(promptParams.config);
|
||||
expect(prompt.tags).toEqual([]);
|
||||
expect((prompt as any).resolutionGraph).toBeNull();
|
||||
expect(prompt.resolutionGraph).toBeNull();
|
||||
};
|
||||
|
||||
describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
@@ -129,6 +129,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
const body = response.body;
|
||||
|
||||
expect(body).toHaveProperty("error");
|
||||
// @ts-expect-error
|
||||
expect(body.error).toContain("Unauthorized");
|
||||
});
|
||||
|
||||
@@ -568,7 +569,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
|
||||
// Verify the placeholder message structure is preserved
|
||||
const messages = validatedPrompt.prompt as ChatMessage[];
|
||||
const placeholderMessage = messages[1] as unknown as {
|
||||
const placeholderMessage = messages[1] as {
|
||||
type: ChatMessageType.Placeholder;
|
||||
name: string;
|
||||
};
|
||||
@@ -596,7 +597,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
const { body, status } = await makeAPICall("GET", `/api/public/prompts?name=${promptName}`, undefined, auth);
|
||||
expect(status).toBe(404);
|
||||
expect(body).toEqual({
|
||||
error: "HanzoCloudNotFoundError",
|
||||
error: "HanzoNotFoundError",
|
||||
message: "Prompt not found",
|
||||
});
|
||||
});
|
||||
@@ -624,7 +625,8 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
|
||||
const { body, status } = await makeAPICall("GET", `${baseURI}/${promptName}`, undefined, auth);
|
||||
expect(status).toBe(404);
|
||||
expect(body.error).toBe("HanzoCloudNotFoundError");
|
||||
// @ts-expect-error
|
||||
expect(body.error).toBe("HanzoNotFoundError");
|
||||
});
|
||||
|
||||
it("should fail if text prompt has message format", async () => {
|
||||
@@ -646,7 +648,8 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
|
||||
const { body, status } = await makeAPICall("GET", `${baseURI}/${promptName}`, undefined, auth);
|
||||
expect(status).toBe(404);
|
||||
expect(body.error).toBe("HanzoCloudNotFoundError");
|
||||
// @ts-expect-error
|
||||
expect(body.error).toBe("HanzoNotFoundError");
|
||||
});
|
||||
|
||||
it("should fail if previous versions have different prompt type", async () => {
|
||||
@@ -686,6 +689,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
);
|
||||
|
||||
expect(postResponse2.status).toBe(400);
|
||||
// @ts-expect-error
|
||||
expect(postResponse2.body.error).toBe("InvalidRequestError");
|
||||
|
||||
// Check if the prompt is still the chat prompt
|
||||
@@ -705,7 +709,8 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
// Check that the text prompt has not been created
|
||||
const getResponse2 = await makeAPICall("GET", `${baseURI}/${promptName}?version=2`, undefined, auth);
|
||||
expect(getResponse2.status).toBe(404);
|
||||
expect(getResponse2.body.error).toBe("HanzoCloudNotFoundError");
|
||||
// @ts-expect-error
|
||||
expect(getResponse2.body.error).toBe("HanzoNotFoundError");
|
||||
});
|
||||
|
||||
it("should correctly handle overwriting labels", async () => {
|
||||
@@ -770,6 +775,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
if (!isPrompt(fetchedProductionPrompt.body)) {
|
||||
throw new Error("Expected body to be a prompt");
|
||||
}
|
||||
// @ts-expect-error
|
||||
expect(fetchedProductionPrompt.body.id).toBe(prompt2.body.id);
|
||||
expect(fetchedProductionPrompt.body.labels).toEqual(["production"]); // Only production label should be present
|
||||
|
||||
@@ -781,6 +787,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
throw new Error("Expected body to be a prompt");
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
expect(fetchedFirstPrompt.body.id).toBe(prompt1.body.id);
|
||||
expect(fetchedFirstPrompt.body.labels).toEqual(["development"]);
|
||||
|
||||
@@ -792,6 +799,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
throw new Error("Expected body to be a prompt");
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
expect(fetchedThirdPrompt.body.id).toBe(prompt3.body.id);
|
||||
expect(fetchedThirdPrompt.body.labels).toEqual(["staging", "latest"]);
|
||||
});
|
||||
@@ -1030,7 +1038,9 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty("error");
|
||||
expect(response.body).toHaveProperty("message");
|
||||
// @ts-expect-error
|
||||
expect(response.body.message).toContain("variables and placeholders must be unique");
|
||||
// @ts-expect-error
|
||||
expect(response.body.message).toContain("userName");
|
||||
});
|
||||
|
||||
@@ -1082,6 +1092,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
expect(v2Response.status).toBe(201);
|
||||
expect(v2Response.body).toHaveProperty("id");
|
||||
expect(v2Response.body).toHaveProperty("version");
|
||||
// @ts-expect-error - Response body type is flexible for testing
|
||||
expect(v2Response.body.version).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1211,6 +1222,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
// Return 200 with empty list if name does not exist
|
||||
const response3 = await makeAPICall("GET", `${baseURI}?name=non-existent`, undefined, auth);
|
||||
expect(response3.status).toBe(200);
|
||||
// @ts-expect-error
|
||||
expect(response3.body.data).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1235,6 +1247,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
// Return 200 with empty list if tag does not exist
|
||||
const response3 = await makeAPICall("GET", `${baseURI}?tag=non-existent`, undefined, auth);
|
||||
expect(response3.status).toBe(200);
|
||||
// @ts-expect-error
|
||||
expect(response3.body.data).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1281,6 +1294,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
// Return 200 with empty list if label does not exist
|
||||
const response3 = await makeAPICall("GET", `${baseURI}?label=non-existent`, undefined, auth);
|
||||
expect(response3.status).toBe(200);
|
||||
// @ts-expect-error
|
||||
expect(response3.body.data).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1882,7 +1896,7 @@ describe("PATCH api/public/v2/prompts/[promptName]/versions/[version]", () => {
|
||||
// Verify the resolution graph is returned with the correct structure
|
||||
const parentId = responseBody.id;
|
||||
|
||||
expect((responseBody as any).resolutionGraph).toEqual({
|
||||
expect(responseBody.resolutionGraph).toEqual({
|
||||
root: {
|
||||
name: "parent-prompt",
|
||||
version: 1,
|
||||
@@ -1925,7 +1939,7 @@ describe("PATCH api/public/v2/prompts/[promptName]/versions/[version]", () => {
|
||||
|
||||
expect(parsedPromptAfterUpdate).toBe("Parent prompt with dependency: I am an updated child prompt");
|
||||
|
||||
expect((responseBodyAfterUpdate as any).resolutionGraph).toEqual({
|
||||
expect(responseBodyAfterUpdate.resolutionGraph).toEqual({
|
||||
root: {
|
||||
name: "parent-prompt",
|
||||
version: 1,
|
||||
@@ -2882,7 +2896,9 @@ describe("PATCH api/public/v2/prompts/[promptName]/versions/[version]", () => {
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toHaveProperty("error");
|
||||
// @ts-expect-error
|
||||
expect(res.body.message).toContain("depending on");
|
||||
// @ts-expect-error
|
||||
expect(res.body.message).toContain(parentName);
|
||||
|
||||
// Verify child was NOT deleted
|
||||
@@ -3006,9 +3022,13 @@ describe("PATCH api/public/v2/prompts/[promptName]/versions/[version]", () => {
|
||||
const res = await makeAPICall("DELETE", `${baseURI}/${encodeURIComponent(childName)}?version=1`, undefined, auth);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
// @ts-expect-error
|
||||
expect(res.body.message).toContain("depending on");
|
||||
// @ts-expect-error - Should mention blocking parent names
|
||||
expect(res.body.message).toContain(parent1Name);
|
||||
// @ts-expect-error
|
||||
expect(res.body.message).toContain(parent3Name);
|
||||
// @ts-expect-error - Should NOT mention parent2 (its dependency still satisfied)
|
||||
expect(res.body.message).not.toContain(parent2Name);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { queryDatastore, queryDatastoreStream, DatastoreResourceError } from "@hanzo/console-core/src/server";
|
||||
import { queryClickhouse, queryClickhouseStream, ClickHouseResourceError } from "@hanzo/shared/src/server";
|
||||
import { fail } from "assert";
|
||||
|
||||
describe("Datastore Resource Error Handling", () => {
|
||||
describe("queryDatastore", () => {
|
||||
describe("ClickHouse Resource Error Handling", () => {
|
||||
describe("queryClickhouse", () => {
|
||||
describe("Error transformation with throwIf", () => {
|
||||
// It is enough to test different block sizes on one error type only
|
||||
[1, 10_000].forEach((blockSize) => {
|
||||
it(`should transform OOM errors to DatastoreResourceError; block size: ${blockSize}`, async () => {
|
||||
it(`should transform OOM errors to ClickHouseResourceError; block size: ${blockSize}`, async () => {
|
||||
let res = Array<any>();
|
||||
try {
|
||||
res = await queryDatastore<any>({
|
||||
res = await queryClickhouse<any>({
|
||||
query: `SELECT throwIf(number >= 2, 'memory limit exceeded: would use 10.23 GiB') AS v FROM system.numbers LIMIT 2000`,
|
||||
datastoreSettings: { max_block_size: `${blockSize}` },
|
||||
clickhouseSettings: { max_block_size: `${blockSize}` },
|
||||
});
|
||||
fail("Should have thrown an error, observed instead " + JSON.stringify(res));
|
||||
} catch (error: any) {
|
||||
expect(error).toBeInstanceOf(DatastoreResourceError);
|
||||
expect(error).toBeInstanceOf(ClickHouseResourceError);
|
||||
expect(error.errorType).toBe("MEMORY_LIMIT");
|
||||
}
|
||||
});
|
||||
@@ -23,46 +23,46 @@ describe("Datastore Resource Error Handling", () => {
|
||||
|
||||
it("should transform OvercommitTracker errors", async () => {
|
||||
try {
|
||||
await queryDatastore({
|
||||
await queryClickhouse({
|
||||
query: `SELECT throwIf(true, 'OvercommitTracker decision: Query was selected to stop by OvercommitTracker')`,
|
||||
});
|
||||
fail("Should have thrown an error");
|
||||
} catch (error: any) {
|
||||
expect(error).toBeInstanceOf(DatastoreResourceError);
|
||||
expect(error).toBeInstanceOf(ClickHouseResourceError);
|
||||
expect(error.errorType).toBe("OVERCOMMIT");
|
||||
}
|
||||
});
|
||||
|
||||
it("should transform timeout errors", async () => {
|
||||
try {
|
||||
await queryDatastore({
|
||||
await queryClickhouse({
|
||||
query: `SELECT throwIf(true, 'Timeout exceeded while reading from socket')`,
|
||||
});
|
||||
fail("Should have thrown an error");
|
||||
} catch (error: any) {
|
||||
expect(error).toBeInstanceOf(DatastoreResourceError);
|
||||
expect(error).toBeInstanceOf(ClickHouseResourceError);
|
||||
expect(error.errorType).toBe("TIMEOUT");
|
||||
}
|
||||
});
|
||||
|
||||
it("should NOT transform regular SQL errors", async () => {
|
||||
await expect(
|
||||
queryDatastore({
|
||||
queryClickhouse({
|
||||
query: `SELECT * FROM non_existent_table_xyz123`,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
try {
|
||||
await queryDatastore({
|
||||
await queryClickhouse({
|
||||
query: `SELECT * FROM non_existent_table_xyz123`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
expect(error).not.toBeInstanceOf(DatastoreResourceError);
|
||||
expect(error).not.toBeInstanceOf(ClickHouseResourceError);
|
||||
}
|
||||
});
|
||||
|
||||
it("should pass through successful queries", async () => {
|
||||
const result = await queryDatastore<{ test_value: Number }>({
|
||||
const result = await queryClickhouse<{ test_value: Number }>({
|
||||
query: "SELECT 1 as test_value",
|
||||
});
|
||||
|
||||
@@ -75,14 +75,14 @@ describe("Datastore Resource Error Handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryDatastoreStream", () => {
|
||||
describe("queryClickhouseStream", () => {
|
||||
// We don't need to test all error types here, just one is enough
|
||||
// to verify streaming works with throwIf and different block sizes.
|
||||
[1, 10_000].forEach((blockSize) => {
|
||||
it(`should transform errors during streaming; block size ${blockSize}`, async () => {
|
||||
const generator = queryDatastoreStream({
|
||||
const generator = queryClickhouseStream({
|
||||
query: `SELECT throwIf(number = 2, 'memory limit exceeded: would use 10.23 GiB') as V FROM numbers(10)`,
|
||||
datastoreSettings: { max_block_size: `${blockSize}` },
|
||||
clickhouseSettings: { max_block_size: `${blockSize}` },
|
||||
});
|
||||
|
||||
let fullResponse = [];
|
||||
@@ -93,11 +93,11 @@ describe("Datastore Resource Error Handling", () => {
|
||||
}
|
||||
fail("Should have thrown an error, observed instead " + JSON.stringify(fullResponse));
|
||||
})(),
|
||||
).rejects.toThrow(DatastoreResourceError);
|
||||
).rejects.toThrow(ClickHouseResourceError);
|
||||
});
|
||||
|
||||
it("should stream successful queries", async () => {
|
||||
const generator = queryDatastoreStream({
|
||||
const generator = queryClickhouseStream({
|
||||
query: "SELECT number FROM system.numbers LIMIT 3",
|
||||
});
|
||||
|
||||
@@ -160,9 +160,9 @@ describe("Datastore Resource Error Handling", () => {
|
||||
errorPatterns.forEach(({ name, errorMessage, shouldBeResourceError, errorType }) => {
|
||||
it(`should correctly classify "${name}"`, () => {
|
||||
const error = new Error(errorMessage);
|
||||
const wrappedError = DatastoreResourceError.wrapIfResourceError(error);
|
||||
const wrappedError = ClickHouseResourceError.wrapIfResourceError(error);
|
||||
const isResourceError = ((err: Error) => {
|
||||
if (err instanceof DatastoreResourceError) {
|
||||
if (err instanceof ClickHouseResourceError) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
@@ -171,7 +171,7 @@ describe("Datastore Resource Error Handling", () => {
|
||||
|
||||
expect(isResourceError).toBe(shouldBeResourceError);
|
||||
|
||||
const resourceError = wrappedError as DatastoreResourceError;
|
||||
const resourceError = wrappedError as ClickHouseResourceError;
|
||||
if (shouldBeResourceError && errorType) {
|
||||
expect(resourceError.errorType).toBe(errorType);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTracesCh, createTrace, getEnvironmentsForProject } from "@hanzo/console-core/src/server";
|
||||
import { createTracesCh, createTrace, getEnvironmentsForProject } from "@hanzo/shared/src/server";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
describe("Datastore Project Repository Test", () => {
|
||||
describe("Clickhouse Project Repository Test", () => {
|
||||
it("should return default if no environments are found", async () => {
|
||||
const projectId = randomUUID();
|
||||
const environments = await getEnvironmentsForProject({ projectId });
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
updateEvents,
|
||||
getTraceByIdFromEventsTable,
|
||||
getObservationsBatchIOFromEventsTable,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { randomUUID } from "crypto";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { type FilterCondition } from "@hanzo/console-core";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import { type FilterCondition } from "@hanzo/shared";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
@@ -29,7 +28,7 @@ function idFilter(id: string): FilterCondition {
|
||||
};
|
||||
}
|
||||
|
||||
describe("Datastore Events Repository Test", () => {
|
||||
describe("Clickhouse Events Repository Test", () => {
|
||||
it("should kill redis connection", () => {
|
||||
// we need at least one test case to avoid hanging
|
||||
// redis connection when everything else is skipped.
|
||||
@@ -568,9 +567,7 @@ describe("Datastore Events Repository Test", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const filteredObservations = result.filter((o) =>
|
||||
([traceId1, traceId2, traceId3] as string[]).includes(o.traceId ?? ""),
|
||||
);
|
||||
const filteredObservations = result.filter((o) => [traceId1, traceId2, traceId3].includes(o.traceId ?? ""));
|
||||
expect(filteredObservations.length).toBe(2);
|
||||
const traceIds = filteredObservations.map((o) => o.traceId).sort();
|
||||
expect(traceIds).toEqual([traceId1, traceId2].sort());
|
||||
@@ -680,9 +677,7 @@ describe("Datastore Events Repository Test", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const filteredObservations = result.filter((o) =>
|
||||
([traceId1, traceId2, traceId3] as string[]).includes(o.traceId ?? ""),
|
||||
);
|
||||
const filteredObservations = result.filter((o) => [traceId1, traceId2, traceId3].includes(o.traceId ?? ""));
|
||||
expect(filteredObservations.length).toBe(2);
|
||||
const names = filteredObservations.map((o) => o.name).sort();
|
||||
expect(names).toEqual(["user-alpha-event", "user-beta-event"]);
|
||||
@@ -866,9 +861,7 @@ describe("Datastore Events Repository Test", () => {
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const filteredObservations = result.filter((o) =>
|
||||
([traceId1, traceId2, traceId3] as string[]).includes(o.traceId ?? ""),
|
||||
);
|
||||
const filteredObservations = result.filter((o) => [traceId1, traceId2, traceId3].includes(o.traceId ?? ""));
|
||||
expect(filteredObservations.length).toBe(1);
|
||||
expect(filteredObservations[0].name).toBe("new-user-1");
|
||||
});
|
||||
@@ -1568,38 +1561,12 @@ describe("Datastore Events Repository Test", () => {
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.bookmarked).toBe(false);
|
||||
|
||||
async function checkTraceIdsBookmarked(traceId: string, bookmarkedExp: boolean) {
|
||||
await waitForExpect(async () => {
|
||||
// Verify events_core
|
||||
const eventTrace = await getTraceByIdFromEventsTable({
|
||||
projectId,
|
||||
traceId: traceId,
|
||||
renderingProps: {
|
||||
truncated: true,
|
||||
shouldJsonParse: false,
|
||||
},
|
||||
});
|
||||
expect(eventTrace).toBeDefined();
|
||||
expect(eventTrace?.bookmarked).toBe(bookmarkedExp);
|
||||
|
||||
// Verify events_full
|
||||
const eventTraceFull = await getTraceByIdFromEventsTable({
|
||||
projectId,
|
||||
traceId: traceId,
|
||||
renderingProps: {
|
||||
truncated: false,
|
||||
shouldJsonParse: true,
|
||||
},
|
||||
});
|
||||
expect(eventTraceFull).toBeDefined();
|
||||
expect(eventTraceFull?.bookmarked).toBe(bookmarkedExp);
|
||||
});
|
||||
}
|
||||
|
||||
// Model setting bookmark as true on the root span
|
||||
await updateEvents(projectId, { traceIds: [traceId], rootOnly: true }, { bookmarked: true });
|
||||
|
||||
await checkTraceIdsBookmarked(traceId, true);
|
||||
result = await getTraceByIdFromEventsTable({ projectId, traceId });
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.bookmarked).toBe(true);
|
||||
|
||||
// Non-root event on bookmarked
|
||||
await createEventsCh([
|
||||
@@ -1619,10 +1586,17 @@ describe("Datastore Events Repository Test", () => {
|
||||
// including the non-root, added above
|
||||
await updateEvents(projectId, { traceIds: [traceId] }, { bookmarked: false });
|
||||
|
||||
await checkTraceIdsBookmarked(traceId, false);
|
||||
result = await getTraceByIdFromEventsTable({ projectId, traceId });
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.bookmarked).toBe(false);
|
||||
|
||||
// Trace id 2 should remain bookmarked
|
||||
await checkTraceIdsBookmarked(traceId2, true);
|
||||
result = await getTraceByIdFromEventsTable({
|
||||
projectId,
|
||||
traceId: traceId2,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.bookmarked).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow to set/unset public", async () => {
|
||||
@@ -1650,45 +1624,23 @@ describe("Datastore Events Repository Test", () => {
|
||||
parent_span_id: "",
|
||||
});
|
||||
|
||||
async function checkTraceIdsPublic(traceId: string, publicExp: boolean) {
|
||||
await waitForExpect(async () => {
|
||||
// Verify events_core
|
||||
const eventTrace = await getTraceByIdFromEventsTable({
|
||||
projectId,
|
||||
traceId: traceId,
|
||||
renderingProps: {
|
||||
truncated: true,
|
||||
shouldJsonParse: false,
|
||||
},
|
||||
});
|
||||
expect(eventTrace).toBeDefined();
|
||||
expect(eventTrace?.public).toBe(publicExp);
|
||||
|
||||
// Verify events_full
|
||||
const eventTraceFull = await getTraceByIdFromEventsTable({
|
||||
projectId,
|
||||
traceId: traceId,
|
||||
renderingProps: {
|
||||
truncated: false,
|
||||
shouldJsonParse: true,
|
||||
},
|
||||
});
|
||||
expect(eventTraceFull).toBeDefined();
|
||||
expect(eventTraceFull?.public).toBe(publicExp);
|
||||
});
|
||||
}
|
||||
|
||||
await createEventsCh([rootEvent, rootEvent2]);
|
||||
|
||||
await checkTraceIdsPublic(traceId, false);
|
||||
var result = await getTraceByIdFromEventsTable({ projectId, traceId });
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.public).toBe(false);
|
||||
|
||||
await updateEvents(projectId, { traceIds: [traceId] }, { public: true });
|
||||
|
||||
await checkTraceIdsPublic(traceId, true);
|
||||
result = await getTraceByIdFromEventsTable({ projectId, traceId });
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.public).toBe(true);
|
||||
|
||||
await updateEvents(projectId, { traceIds: [traceId] }, { public: false });
|
||||
|
||||
await checkTraceIdsPublic(traceId, false);
|
||||
result = await getTraceByIdFromEventsTable({ projectId, traceId });
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.public).toBe(false);
|
||||
|
||||
// Non-root event with public
|
||||
await createEventsCh([
|
||||
@@ -1703,16 +1655,24 @@ describe("Datastore Events Repository Test", () => {
|
||||
parent_span_id: rootSpanId,
|
||||
}),
|
||||
]);
|
||||
|
||||
await checkTraceIdsPublic(traceId, true);
|
||||
result = await getTraceByIdFromEventsTable({ projectId, traceId });
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.public).toBe(true);
|
||||
|
||||
// Clearing public on non-root
|
||||
await updateEvents(projectId, { traceIds: [traceId] }, { public: false });
|
||||
|
||||
await checkTraceIdsPublic(traceId, false);
|
||||
result = await getTraceByIdFromEventsTable({ projectId, traceId });
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.public).toBe(false);
|
||||
|
||||
// Trace id 2 should remain public
|
||||
await checkTraceIdsPublic(traceId2, true);
|
||||
result = await getTraceByIdFromEventsTable({
|
||||
projectId,
|
||||
traceId: traceId2,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.public).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1814,8 +1774,6 @@ describe("Datastore Events Repository Test", () => {
|
||||
const result = await getObservationsBatchIOFromEventsTable({
|
||||
projectId,
|
||||
observations: [],
|
||||
minStartTime: new Date(),
|
||||
maxStartTime: new Date(),
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createObservation, createObservationsCh } from "@hanzo/console-core/src/server";
|
||||
import { getObservationById, getObservationsForTrace } from "@hanzo/console-core/src/server";
|
||||
import { createObservation, createObservationsCh } from "@hanzo/shared/src/server";
|
||||
import { getObservationById, getObservationsForTrace } from "@hanzo/shared/src/server";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
describe("Datastore Observations Repository Test", () => {
|
||||
describe("Clickhouse Observations Repository Test", () => {
|
||||
it("should throw if no observations are found", async () => {
|
||||
await expect(
|
||||
getObservationById({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { checkTraceExistsAndGetTimestamp, createTracesCh } from "@hanzo/console-core/src/server";
|
||||
import { getTraceById, getTracesBySessionId } from "@hanzo/console-core/src/server";
|
||||
import { checkTraceExistsAndGetTimestamp, createTracesCh } from "@hanzo/shared/src/server";
|
||||
import { getTraceById, getTracesBySessionId } from "@hanzo/shared/src/server";
|
||||
import { v4 } from "uuid";
|
||||
import { createObservation, createTrace } from "@hanzo/console-core/src/server";
|
||||
import { createObservationsCh } from "@hanzo/console-core/src/server";
|
||||
import { createObservation, createTrace } from "@hanzo/shared/src/server";
|
||||
import { createObservationsCh } from "@hanzo/shared/src/server";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
describe("Datastore Traces Repository Test", () => {
|
||||
describe("Clickhouse Traces Repository Test", () => {
|
||||
it("should throw if no traces are found", async () => {
|
||||
expect(await getTraceById({ traceId: v4(), projectId: v4() })).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { makeZodVerifiedAPICall, makeAPICall } from "@/src/__tests__/test-utils";
|
||||
import { z } from "zod/v4";
|
||||
import { createAndAddApiKeysToDb, createBasicAuthHeader } from "@hanzo/console-core/src/server";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { createAndAddApiKeysToDb, createBasicAuthHeader } from "@hanzo/shared/src/server";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { randomUUID } from "crypto";
|
||||
import { verifyPassword } from "@/src/features/auth-credentials/lib/credentialsServerUtils";
|
||||
|
||||
@@ -129,12 +129,12 @@ const ResourceTypesResponseSchema = z.object({
|
||||
describe("SCIM API", () => {
|
||||
// Test variables
|
||||
const orgId = "seed-org-id";
|
||||
const orgApiKey = `pk-hz-org-${randomUUID().substring(0, 8)}`;
|
||||
const orgSecretKey = `sk-hz-org-${randomUUID().substring(0, 8)}`;
|
||||
const projectApiKey = "pk-hz-1234567890";
|
||||
const projectSecretKey = "sk-hz-1234567890";
|
||||
const invalidApiKey = "pk-hz-invalid";
|
||||
const invalidSecretKey = "sk-hz-invalid";
|
||||
const orgApiKey = `pk-lf-org-${randomUUID().substring(0, 8)}`;
|
||||
const orgSecretKey = `sk-lf-org-${randomUUID().substring(0, 8)}`;
|
||||
const projectApiKey = "pk-lf-1234567890";
|
||||
const projectSecretKey = "sk-lf-1234567890";
|
||||
const invalidApiKey = "pk-lf-invalid";
|
||||
const invalidSecretKey = "sk-lf-invalid";
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -435,8 +435,8 @@ describe("SCIM API", () => {
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.userName).toBe(uniqueEmail);
|
||||
expect(response.body.name!.formatted).toBe("Test User");
|
||||
expect(response.body.emails![0].value).toBe(uniqueEmail);
|
||||
expect(response.body.name.formatted).toBe("Test User");
|
||||
expect(response.body.emails[0].value).toBe(uniqueEmail);
|
||||
|
||||
testUserId = response.body.id;
|
||||
|
||||
@@ -477,10 +477,10 @@ describe("SCIM API", () => {
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.userName).toBe(uniqueEmail);
|
||||
expect(response.body.name!.formatted).toBe("Test User With Password");
|
||||
expect(response.body.emails![0].value).toBe(uniqueEmail);
|
||||
expect(response.body.name.formatted).toBe("Test User With Password");
|
||||
expect(response.body.emails[0].value).toBe(uniqueEmail);
|
||||
// Password should not be returned in the response
|
||||
expect((response.body as any).password).toBeUndefined();
|
||||
expect(response.body.password).toBeUndefined();
|
||||
|
||||
testUserId = response.body.id;
|
||||
|
||||
@@ -538,7 +538,7 @@ describe("SCIM API", () => {
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.userName).toBe(uniqueEmail);
|
||||
expect(response.body.name!.formatted).toBe("Test User With Role");
|
||||
expect(response.body.name.formatted).toBe("Test User With Role");
|
||||
|
||||
testUserId = response.body.id;
|
||||
|
||||
@@ -618,7 +618,7 @@ describe("SCIM API", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(testUserId);
|
||||
expect(response.body.name!.formatted).toBe("Test User");
|
||||
expect(response.body.name.formatted).toBe("Test User");
|
||||
});
|
||||
|
||||
it("should return 404 when user does not exist", async () => {
|
||||
|
||||
@@ -4,16 +4,16 @@ import {
|
||||
createTrace,
|
||||
createSessionScore,
|
||||
getScoresByIds,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import {
|
||||
createObservationsCh,
|
||||
createScoresCh,
|
||||
createTracesCh,
|
||||
createOrgProjectAndApiKey,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import { makeAPICall, makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
|
||||
import { DeleteScoreResponseV1, GetScoreResponseV1, GetScoresResponseV1 } from "@hanzo/console-core";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { DeleteScoreResponseV1, GetScoreResponseV1, GetScoresResponseV1 } from "@hanzo/shared";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { v4 } from "uuid";
|
||||
import { z } from "zod/v4";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
|
||||
import { v4 } from "uuid";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { createTracesCh, createOrgProjectAndApiKey } from "@hanzo/console-core/src/server";
|
||||
import { createTrace } from "@hanzo/console-core/src/server";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { createTracesCh, createOrgProjectAndApiKey } from "@hanzo/shared/src/server";
|
||||
import { createTrace } from "@hanzo/shared/src/server";
|
||||
import { GetSessionsV1Response, GetSessionV1Response } from "@/src/features/public-api/types/sessions";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { createObservation, createObservationsCh, createTrace, createTracesCh } from "@hanzo/console-core/src/server";
|
||||
import { createObservation, createObservationsCh, createTrace, createTracesCh } from "@hanzo/shared/src/server";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
describe("traces trpc", () => {
|
||||
@@ -23,8 +23,6 @@ describe("traces trpc", () => {
|
||||
role: "OWNER",
|
||||
plan: "cloud:free",
|
||||
cloudConfig: undefined,
|
||||
metadata: {},
|
||||
aiFeaturesEnabled: true,
|
||||
projects: [
|
||||
{
|
||||
id: projectId,
|
||||
@@ -32,22 +30,20 @@ describe("traces trpc", () => {
|
||||
retentionDays: 30,
|
||||
deletedAt: null,
|
||||
name: "Test Project",
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
excludeDatastoreRead: false,
|
||||
excludeClickhouseRead: false,
|
||||
templateFlag: true,
|
||||
v4BetaToggleVisible: false,
|
||||
},
|
||||
admin: true,
|
||||
},
|
||||
environment: {} as any,
|
||||
};
|
||||
|
||||
const ctx = createInnerTRPCContext({ session, headers: {} });
|
||||
const ctx = createInnerTRPCContext({ session });
|
||||
const caller = appRouter.createCaller({ ...ctx, prisma });
|
||||
|
||||
describe("sessions.byIdWithScores", () => {
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
getTraceById,
|
||||
createEvent,
|
||||
type EventRecordInsertType,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
import { createObservationsCh, createTracesCh, createEventsCh } from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import { createObservationsCh, createTracesCh, createEventsCh } from "@hanzo/shared/src/server";
|
||||
import { makeZodVerifiedAPICall, makeZodVerifiedAPICallSilent } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
DeleteTracesV1Response,
|
||||
@@ -1952,119 +1952,6 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe.skip("GET /api/public/traces env var controls", () => {
|
||||
const originalRejectNoDateRange = env.HANZO_API_TRACES_REJECT_NO_DATE_RANGE;
|
||||
const originalDefaultDateRangeDays = env.HANZO_API_TRACES_DEFAULT_DATE_RANGE_DAYS;
|
||||
const originalDefaultFields = env.HANZO_API_TRACES_DEFAULT_FIELDS;
|
||||
|
||||
afterEach(() => {
|
||||
(env as any).HANZO_API_TRACES_REJECT_NO_DATE_RANGE = originalRejectNoDateRange;
|
||||
(env as any).HANZO_API_TRACES_DEFAULT_DATE_RANGE_DAYS = originalDefaultDateRangeDays;
|
||||
(env as any).HANZO_API_TRACES_DEFAULT_FIELDS = originalDefaultFields;
|
||||
});
|
||||
|
||||
it("should return 400 when REJECT_NO_DATE_RANGE=true and no fromTimestamp", async () => {
|
||||
(env as any).HANZO_API_TRACES_REJECT_NO_DATE_RANGE = "true";
|
||||
|
||||
const response = await makeZodVerifiedAPICallSilent(GetTracesV1Response, "GET", "/api/public/traces");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should allow request when REJECT_NO_DATE_RANGE=true and fromTimestamp is provided", async () => {
|
||||
(env as any).HANZO_API_TRACES_REJECT_NO_DATE_RANGE = "true";
|
||||
|
||||
const fromTimestamp = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces?fromTimestamp=${fromTimestamp}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should reject even when DEFAULT_DATE_RANGE_DAYS is also set (rejection takes precedence)", async () => {
|
||||
(env as any).HANZO_API_TRACES_REJECT_NO_DATE_RANGE = "true";
|
||||
(env as any).HANZO_API_TRACES_DEFAULT_DATE_RANGE_DAYS = 7;
|
||||
|
||||
const response = await makeZodVerifiedAPICallSilent(GetTracesV1Response, "GET", "/api/public/traces");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should apply DEFAULT_FIELDS when no fields query param is provided", async () => {
|
||||
(env as any).HANZO_API_TRACES_DEFAULT_FIELDS = "core";
|
||||
|
||||
const traceId = randomUUID();
|
||||
const createdTrace = createTrace({
|
||||
id: traceId,
|
||||
name: "default-fields-test",
|
||||
project_id: projectId,
|
||||
input: JSON.stringify({ prompt: "test" }),
|
||||
output: JSON.stringify({ response: "test response" }),
|
||||
});
|
||||
|
||||
const observation = createObservation({
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "test-obs",
|
||||
start_time: new Date().getTime() - 1000,
|
||||
end_time: new Date().getTime(),
|
||||
});
|
||||
|
||||
const score = createTraceScore({
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
name: "test-score",
|
||||
value: 0.8,
|
||||
});
|
||||
|
||||
await createTracesCh([createdTrace]);
|
||||
await createObservationsCh([observation]);
|
||||
await createScoresCh([score]);
|
||||
|
||||
const response = await makeZodVerifiedAPICall(GetTracesV1Response, "GET", "/api/public/traces");
|
||||
|
||||
const trace = response.body.data.find((t) => t.id === traceId);
|
||||
expect(trace).toBeTruthy();
|
||||
if (!trace) return;
|
||||
|
||||
// With core only, IO/scores/observations/metrics should be defaults
|
||||
expect(trace.input).toBeNull();
|
||||
expect(trace.output).toBeNull();
|
||||
expect(trace.observations).toEqual([]);
|
||||
expect(trace.scores).toEqual([]);
|
||||
expect(trace.totalCost).toBe(-1);
|
||||
expect(trace.latency).toBe(-1);
|
||||
});
|
||||
|
||||
it("should override DEFAULT_FIELDS when explicit fields param is provided", async () => {
|
||||
(env as any).HANZO_API_TRACES_DEFAULT_FIELDS = "core";
|
||||
|
||||
const traceId = randomUUID();
|
||||
const createdTrace = createTrace({
|
||||
id: traceId,
|
||||
name: "explicit-fields-test",
|
||||
project_id: projectId,
|
||||
input: JSON.stringify({ prompt: "test" }),
|
||||
output: JSON.stringify({ response: "test response" }),
|
||||
});
|
||||
|
||||
await createTracesCh([createdTrace]);
|
||||
|
||||
const response = await makeZodVerifiedAPICall(GetTracesV1Response, "GET", "/api/public/traces?fields=core,io");
|
||||
|
||||
const trace = response.body.data.find((t) => t.id === traceId);
|
||||
expect(trace).toBeTruthy();
|
||||
if (!trace) return;
|
||||
|
||||
// Explicit fields=core,io should override the env default of core-only
|
||||
expect(trace.input).toEqual({ prompt: "test" });
|
||||
expect(trace.output).toEqual({ response: "test response" });
|
||||
});
|
||||
});
|
||||
|
||||
// Comprehensive filter column tests - verify all documented filter columns don't crash
|
||||
describe("Filter Columns - Doesn't Fail Tests", () => {
|
||||
const runFilterTests = (useEventsTable: boolean) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createObservation as createObservationObject, createTrace } from "@hanzo/console-core/src/server";
|
||||
import { createObservationsCh as createObservationsInDatastore, createTracesCh } from "@hanzo/console-core/src/server";
|
||||
import { createObservation as createObservationObject, createTrace } from "@hanzo/shared/src/server";
|
||||
import { createObservationsCh as createObservationsInClickhouse, createTracesCh } from "@hanzo/shared/src/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getUserMetrics } from "@hanzo/console-core/src/server";
|
||||
import { getUserMetrics } from "@hanzo/shared/src/server";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("getUserMetrics function", () => {
|
||||
type: "GENERATION",
|
||||
});
|
||||
|
||||
await createObservationsInDatastore([observation1, observation2]);
|
||||
await createObservationsInClickhouse([observation1, observation2]);
|
||||
|
||||
const userMetrics = await getUserMetrics(projectId, [userId], []);
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("getUserMetrics function", () => {
|
||||
type: "GENERATION",
|
||||
});
|
||||
|
||||
await createObservationsInDatastore([observation1, observation2]);
|
||||
await createObservationsInClickhouse([observation1, observation2]);
|
||||
|
||||
const userMetrics = await getUserMetrics(
|
||||
projectId,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { ChatMessageType, compileChatMessages, extractPlaceholderNames } from "@hanzo/console-core";
|
||||
type PromptMessage = { role: string; content: string } | { type: any; name: string };
|
||||
import { ChatMessageType, compileChatMessages, extractPlaceholderNames } from "@hanzo/shared";
|
||||
|
||||
describe("compileChatMessages", () => {
|
||||
it("should compile message placeholders with provided values", () => {
|
||||
// Simulates how message placeholders would be compiled
|
||||
// during execution (e.g., in playground or experiments)
|
||||
|
||||
const promptTemplate: PromptMessage[] = [
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
@@ -42,7 +41,7 @@ describe("compileChatMessages", () => {
|
||||
});
|
||||
|
||||
it("should throw error when placeholder value is missing", () => {
|
||||
const promptTemplate: PromptMessage[] = [
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
@@ -59,7 +58,7 @@ describe("compileChatMessages", () => {
|
||||
});
|
||||
|
||||
it("should allow arbitrary placeholder fill-in values", () => {
|
||||
const promptTemplate: PromptMessage[] = [
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
@@ -100,7 +99,7 @@ describe("compileChatMessages", () => {
|
||||
});
|
||||
|
||||
it("should compile placeholders without applying text substitutions when no variables provided", () => {
|
||||
const promptTemplate: PromptMessage[] = [
|
||||
const promptTemplate = [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant. {{system_var}}",
|
||||
@@ -134,7 +133,7 @@ describe("compileChatMessages", () => {
|
||||
});
|
||||
|
||||
it("should extract all placeholder names from messages", () => {
|
||||
const promptTemplate: PromptMessage[] = [
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "System message" },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
@@ -153,7 +152,7 @@ describe("compileChatMessages", () => {
|
||||
});
|
||||
|
||||
it("should return empty array when no placeholders exist", () => {
|
||||
const promptTemplate: PromptMessage[] = [
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "System message" },
|
||||
{ role: "user", content: "User message" },
|
||||
];
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - Query generation and execution through executeQuery function
|
||||
* - SQL generation by QueryBuilder for various pivot table configurations
|
||||
* - Data transformation from raw query results to pivot table structure
|
||||
* - Integration with Datastore database and error handling
|
||||
* - Integration with ClickHouse database and error handling
|
||||
*
|
||||
* Test Coverage:
|
||||
* - Zero dimension pivot tables (grand total only)
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { createTrace, createTracesCh, createObservation, createObservationsCh } from "@hanzo/console-core/src/server";
|
||||
import { createTrace, createTracesCh, createObservation, createObservationsCh } from "@hanzo/shared/src/server";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import { transformToPivotTable, type DatabaseRow } from "@/src/features/widgets/utils/pivot-table-utils";
|
||||
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
|
||||
@@ -88,7 +88,7 @@ describe("Dashboard Router - Pivot Table Integration", () => {
|
||||
),
|
||||
];
|
||||
|
||||
// Insert traces into Datastore
|
||||
// Insert traces into ClickHouse
|
||||
await createTracesCh(traces);
|
||||
|
||||
// Create observations with different models for each trace
|
||||
@@ -148,7 +148,7 @@ describe("Dashboard Router - Pivot Table Integration", () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Insert observations into Datastore
|
||||
// Insert observations into ClickHouse
|
||||
await createObservationsCh(observations);
|
||||
|
||||
// Calculate test data statistics for verification
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
type GetMediaUploadUrlResponse,
|
||||
GetMediaUploadUrlResponseSchema,
|
||||
} from "@/src/features/media/validation";
|
||||
import { type Media, type ObservationMedia, prisma, type TraceMedia } from "@hanzo/console-core/src/db";
|
||||
import { redis } from "@hanzo/console-core/src/server";
|
||||
import { type Media, type ObservationMedia, prisma, type TraceMedia } from "@hanzo/shared/src/db";
|
||||
import { redis } from "@hanzo/shared/src/server";
|
||||
|
||||
describe("Media Upload API", () => {
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
@@ -117,7 +117,7 @@ describe("Media Upload API", () => {
|
||||
// Upload file
|
||||
const uploadFileResponse = await fetch(getUploadUrlResponse.body.uploadUrl, {
|
||||
method: "PUT",
|
||||
body: fileBytes as unknown as BodyInit,
|
||||
body: fileBytes,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"X-Amz-Checksum-Sha256": sha256Hash,
|
||||
@@ -223,7 +223,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPNG.sha256Hash,
|
||||
contentType: validPNG.contentType,
|
||||
contentLength: BigInt(validPNG.contentLength),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
uploadHttpStatus: 200,
|
||||
uploadHttpError: null,
|
||||
@@ -269,7 +269,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPDF.sha256Hash,
|
||||
contentType: validPDF.contentType,
|
||||
contentLength: BigInt(validPDF.contentLength),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
});
|
||||
expect(result.traceMediaRecord).toBeNull();
|
||||
@@ -312,7 +312,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPNG.sha256Hash,
|
||||
contentType: validPNG.contentType,
|
||||
contentLength: BigInt(100),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
uploadHttpStatus: 403,
|
||||
uploadHttpError: expect.any(String),
|
||||
@@ -342,7 +342,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPNG.sha256Hash,
|
||||
contentType: validPNG.contentType,
|
||||
contentLength: BigInt(validPNG.contentLength),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
uploadHttpStatus: 200,
|
||||
uploadHttpError: null,
|
||||
@@ -387,7 +387,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPNG.sha256Hash,
|
||||
contentType: validPNG.contentType,
|
||||
contentLength: BigInt(validPNG.contentLength),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
uploadHttpStatus: 403,
|
||||
uploadHttpError: expect.any(String),
|
||||
@@ -417,7 +417,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPNG.sha256Hash,
|
||||
contentType: validPNG.contentType,
|
||||
contentLength: BigInt(validPNG.contentLength),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
uploadHttpStatus: 200,
|
||||
uploadHttpError: null,
|
||||
@@ -462,7 +462,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPNG.sha256Hash,
|
||||
contentType: "image/jpeg",
|
||||
contentLength: BigInt(validPNG.contentLength),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
uploadHttpStatus: 403,
|
||||
uploadHttpError: expect.any(String),
|
||||
@@ -492,7 +492,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPNG.sha256Hash,
|
||||
contentType: validPNG.contentType,
|
||||
contentLength: BigInt(validPNG.contentLength),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
uploadHttpStatus: 200,
|
||||
uploadHttpError: null,
|
||||
@@ -537,7 +537,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPNG.sha256Hash,
|
||||
contentType: "image/jpeg",
|
||||
contentLength: BigInt(validPNG.contentLength),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
uploadHttpStatus: 200,
|
||||
uploadHttpError: null,
|
||||
@@ -578,7 +578,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPNG.sha256Hash,
|
||||
contentType: validPNG.contentType,
|
||||
contentLength: BigInt(validPNG.contentLength),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
uploadHttpStatus: 200,
|
||||
uploadHttpError: null,
|
||||
@@ -624,7 +624,7 @@ describe("Media Upload API", () => {
|
||||
sha256Hash: validPNG.sha256Hash,
|
||||
contentType: validPNG.contentType,
|
||||
contentLength: BigInt(validPNG.contentLength),
|
||||
bucketName: env.S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketName: env.HANZO_S3_MEDIA_UPLOAD_BUCKET,
|
||||
bucketPath: expect.any(String),
|
||||
uploadHttpStatus: 200,
|
||||
uploadHttpError: null,
|
||||
@@ -689,7 +689,7 @@ describe("Media Upload API", () => {
|
||||
...validPNG,
|
||||
traceId,
|
||||
field,
|
||||
contentLength: env.S3_MEDIA_MAX_CONTENT_LENGTH + 1,
|
||||
contentLength: env.HANZO_S3_MEDIA_MAX_CONTENT_LENGTH + 1,
|
||||
});
|
||||
|
||||
expect(result.getUploadUrlResponse?.status).toBe(400);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import { makeAPICall, makeZodVerifiedAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
DeleteModelV1Response,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
GetModelsV1Response,
|
||||
PostModelsV1Response,
|
||||
} from "@/src/features/public-api/types/models";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/console-core/src/server";
|
||||
import { createOrgProjectAndApiKey } from "@hanzo/shared/src/server";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
describe("/models API Endpoints", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type PrismaClient, type Prompt } from "@prisma/client";
|
||||
import { PromptService, type redis } from "@hanzo/console-core/src/server"; // Adjust the import path as needed
|
||||
import { PromptService, type redis } from "@hanzo/shared/src/server"; // Adjust the import path as needed
|
||||
|
||||
type Redis = NonNullable<typeof redis>;
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("PromptService", () => {
|
||||
it("should fetch from database if not in cache", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockResolvedValue(null);
|
||||
(mockPrisma.prompt.findFirst as jest.Mock).mockResolvedValue(mockPrompt);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
@@ -82,7 +82,7 @@ describe("PromptService", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst as jest.Mock).toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith("prompt_cache_miss", 1);
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith("prompt:project1:testPrompt:1", JSON.stringify(mockPrompt), "EX", 300);
|
||||
@@ -92,7 +92,7 @@ describe("PromptService", () => {
|
||||
|
||||
it("should not use cache if locked", async () => {
|
||||
mockRedis.exists.mockResolvedValue(1);
|
||||
(mockPrisma.prompt.findFirst as jest.Mock).mockResolvedValue(mockPrompt);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
@@ -103,7 +103,7 @@ describe("PromptService", () => {
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockRedis.getex).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst as jest.Mock).toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("PromptService", () => {
|
||||
});
|
||||
|
||||
it("should not use cache when disabled", async () => {
|
||||
(mockPrisma.prompt.findFirst as jest.Mock).mockResolvedValue(mockPrompt);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
@@ -160,7 +160,7 @@ describe("PromptService", () => {
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockRedis.getex).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst as jest.Mock).toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -171,7 +171,7 @@ describe("PromptService", () => {
|
||||
});
|
||||
|
||||
it("should not use cache with null Redis instance", async () => {
|
||||
(mockPrisma.prompt.findFirst as jest.Mock).mockResolvedValue(mockPrompt);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
@@ -181,7 +181,7 @@ describe("PromptService", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst as jest.Mock).toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -189,7 +189,7 @@ describe("PromptService", () => {
|
||||
describe("getPrompt with Redis errors", () => {
|
||||
it("should fallback to database if Redis.exists throws an error", async () => {
|
||||
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
|
||||
(mockPrisma.prompt.findFirst as jest.Mock).mockResolvedValue(mockPrompt);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
@@ -199,14 +199,14 @@ describe("PromptService", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst as jest.Mock).toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith("prompt_cache_miss", 1);
|
||||
});
|
||||
|
||||
it("should fallback to database if Redis.getex throws an error", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockRejectedValue(new Error("Redis error"));
|
||||
(mockPrisma.prompt.findFirst as jest.Mock).mockResolvedValue(mockPrompt);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
@@ -216,14 +216,14 @@ describe("PromptService", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst as jest.Mock).toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith("prompt_cache_miss", 1);
|
||||
});
|
||||
|
||||
it("should not cache if Redis.set throws an error after database fetch", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockResolvedValue(null);
|
||||
(mockPrisma.prompt.findFirst as jest.Mock).mockResolvedValue(mockPrompt);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
mockRedis.set.mockRejectedValue(new Error("Redis error"));
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
@@ -234,7 +234,7 @@ describe("PromptService", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst as jest.Mock).toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith("prompt_cache_miss", 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,17 +3,13 @@ import { type QueryType } from "@/src/features/query/types";
|
||||
import { executeQuery, validateQuery } from "@/src/features/query/server/queryExecutor";
|
||||
import {
|
||||
createTrace,
|
||||
createEvent,
|
||||
createObservation,
|
||||
createTracesCh,
|
||||
createEventsCh,
|
||||
createObservationsCh,
|
||||
createTraceScore,
|
||||
createScoresCh,
|
||||
datastoreClient,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
describe("queryBuilder", () => {
|
||||
describe("query builder creates executable SQL", () => {
|
||||
@@ -317,7 +313,7 @@ describe("queryBuilder", () => {
|
||||
trace_id: data.traceId,
|
||||
observation_id: data.observationId,
|
||||
name: data.name,
|
||||
value: data.dataType === "NUMERIC" ? data.value || 0 : undefined,
|
||||
value: data.dataType === "NUMERIC" ? data.value || 0 : null,
|
||||
string_value: ["CATEGORICAL", "BOOLEAN"].includes(data.dataType) ? data.stringValue || "" : null,
|
||||
environment: data.environment || "default",
|
||||
source: data.source || "API",
|
||||
@@ -1929,8 +1925,8 @@ describe("queryBuilder", () => {
|
||||
const { query: compiledQuery } = await queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify joins included
|
||||
expect(compiledQuery).toContain("INNER JOIN traces");
|
||||
expect(compiledQuery).toContain("INNER JOIN observations");
|
||||
expect(compiledQuery).toContain("LEFT JOIN traces");
|
||||
expect(compiledQuery).toContain("LEFT JOIN observations");
|
||||
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
@@ -2837,8 +2833,8 @@ describe("queryBuilder", () => {
|
||||
const projectId = randomUUID();
|
||||
|
||||
// Create traces with observations that have different costs
|
||||
const traces: ReturnType<typeof createTrace>[] = [];
|
||||
const observations: ReturnType<typeof createObservation>[] = [];
|
||||
const traces = [];
|
||||
const observations = [];
|
||||
|
||||
// Create trace for cost distribution test
|
||||
const trace = createTrace({
|
||||
@@ -2920,7 +2916,7 @@ describe("queryBuilder", () => {
|
||||
expect(result.data).toHaveLength(1);
|
||||
const histogramData = result.data[0].histogram_totalCost;
|
||||
|
||||
// Datastore histogram returns array of tuples [lower, upper, height]
|
||||
// ClickHouse histogram returns array of tuples [lower, upper, height]
|
||||
expect(Array.isArray(histogramData)).toBe(true);
|
||||
expect(histogramData.length).toBeGreaterThan(0);
|
||||
expect(histogramData.length).toBeLessThanOrEqual(20); // Should not exceed requested bins
|
||||
@@ -2937,7 +2933,7 @@ describe("queryBuilder", () => {
|
||||
expect(height).toBeGreaterThan(0);
|
||||
// Cost values should be in expected range
|
||||
expect(lower).toBeGreaterThanOrEqual(0);
|
||||
expect(upper).toBeLessThanOrEqual(1.1); // Allow some margin for Datastore binning
|
||||
expect(upper).toBeLessThanOrEqual(1.1); // Allow some margin for ClickHouse binning
|
||||
});
|
||||
|
||||
// Verify total count matches our data
|
||||
@@ -3132,7 +3128,7 @@ describe("queryBuilder", () => {
|
||||
expect(compiledQuery.match(/GROUP BY/g)?.length).toBe(2); // Two GROUP BY clauses
|
||||
|
||||
// Should have the JOIN
|
||||
expect(compiledQuery).toContain("INNER JOIN scores");
|
||||
expect(compiledQuery).toContain("LEFT JOIN scores");
|
||||
});
|
||||
|
||||
it("should handle complex multi-aggregation measure (tokensPerSecond)", async () => {
|
||||
@@ -3401,374 +3397,6 @@ describe("queryBuilder", () => {
|
||||
expect(resultWithOpt).toEqual(resultWithoutOpt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairExpand map expansion (v2)", () => {
|
||||
it("should emit ARRAY JOIN clause for pairExpand dimensions", async () => {
|
||||
const projectId = randomUUID();
|
||||
const builder = new QueryBuilder(undefined, "v2");
|
||||
const { query: sql } = await builder.build(
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [{ field: "costType" }],
|
||||
metrics: [{ measure: "costByType", aggregation: "sum" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
projectId,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(sql).toContain("ARRAY JOIN");
|
||||
expect(sql).toContain("mapKeys(events_observations.cost_details) AS costType");
|
||||
expect(sql).toContain("mapValues(events_observations.cost_details) AS cost_value");
|
||||
// ARRAY JOIN must appear before WHERE
|
||||
expect(sql.indexOf("ARRAY JOIN")).toBeLessThan(sql.indexOf("WHERE"));
|
||||
// No inline arrayJoin() function call — must use clause form
|
||||
expect(sql).not.toMatch(/arrayJoin\(mapKeys/);
|
||||
});
|
||||
|
||||
it("should aggregate cost_details by type correctly", async () => {
|
||||
const projectId = randomUUID();
|
||||
|
||||
const events = [
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
type: "GENERATION",
|
||||
cost_details: { input: 10, output: 20, total: 30 },
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
type: "GENERATION",
|
||||
cost_details: { input: 5, output: 15, total: 20 },
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
];
|
||||
await createEventsCh(events);
|
||||
|
||||
const result = await executeQuery(
|
||||
projectId,
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [{ field: "costType" }],
|
||||
metrics: [{ measure: "costByType", aggregation: "sum" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
"v2",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const inputRow = result.find((r) => r["costType"] === "input");
|
||||
expect(Number(inputRow?.["sum_costByType"])).toBeCloseTo(15, 2); // 10 + 5
|
||||
|
||||
const outputRow = result.find((r) => r["costType"] === "output");
|
||||
expect(Number(outputRow?.["sum_costByType"])).toBeCloseTo(35, 2); // 20 + 15
|
||||
|
||||
const totalRow = result.find((r) => r["costType"] === "total");
|
||||
expect(Number(totalRow?.["sum_costByType"])).toBeCloseTo(50, 2); // 30 + 20
|
||||
});
|
||||
|
||||
it("should aggregate usage_details by type correctly", async () => {
|
||||
const projectId = randomUUID();
|
||||
|
||||
const events = [
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
type: "GENERATION",
|
||||
usage_details: { input: 100, output: 200, total: 300 },
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
type: "GENERATION",
|
||||
usage_details: { input: 50, output: 75, total: 125 },
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
];
|
||||
await createEventsCh(events);
|
||||
|
||||
const result = await executeQuery(
|
||||
projectId,
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [{ field: "usageType" }],
|
||||
metrics: [{ measure: "usageByType", aggregation: "sum" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
"v2",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const inputRow = result.find((r) => r["usageType"] === "input");
|
||||
expect(Number(inputRow?.["sum_usageByType"])).toBe(150); // 100 + 50
|
||||
|
||||
const outputRow = result.find((r) => r["usageType"] === "output");
|
||||
expect(Number(outputRow?.["sum_usageByType"])).toBe(275); // 200 + 75
|
||||
});
|
||||
|
||||
it("should produce timeseries with costType dimension and WITH FILL", async () => {
|
||||
const projectId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
const events = [
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
type: "GENERATION",
|
||||
cost_details: { input: 10, output: 20 },
|
||||
start_time: now.getTime() * 1000,
|
||||
}),
|
||||
];
|
||||
await createEventsCh(events);
|
||||
|
||||
const builder = new QueryBuilder(undefined, "v2");
|
||||
const { query: sql } = await builder.build(
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [{ field: "costType" }],
|
||||
metrics: [{ measure: "costByType", aggregation: "sum" }],
|
||||
filters: [],
|
||||
timeDimension: { granularity: "day" },
|
||||
fromTimestamp: new Date(now.getTime() - 2 * 86400000).toISOString(),
|
||||
toTimestamp: new Date(now.getTime() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
projectId,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(sql).toContain("WITH FILL");
|
||||
expect(sql).toContain("time_dimension");
|
||||
// GROUP BY parts may be newline-separated in the generated SQL
|
||||
expect(sql).toContain("GROUP BY costType");
|
||||
expect(sql).toMatch(/GROUP BY costType[\s,]+time_dimension/);
|
||||
|
||||
const result = await executeQuery(
|
||||
projectId,
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [{ field: "costType" }],
|
||||
metrics: [{ measure: "costByType", aggregation: "sum" }],
|
||||
filters: [],
|
||||
timeDimension: { granularity: "day" },
|
||||
fromTimestamp: new Date(now.getTime() - 2 * 86400000).toISOString(),
|
||||
toTimestamp: new Date(now.getTime() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
"v2",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0]).toHaveProperty("time_dimension");
|
||||
expect(result[0]).toHaveProperty("costType");
|
||||
expect(result[0]).toHaveProperty("sum_costByType");
|
||||
});
|
||||
|
||||
it("should respect type filter with pairExpand dimensions", async () => {
|
||||
const projectId = randomUUID();
|
||||
|
||||
const events = [
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
type: "GENERATION",
|
||||
cost_details: { input: 10 },
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
type: "SPAN",
|
||||
cost_details: { input: 999 },
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
];
|
||||
await createEventsCh(events);
|
||||
|
||||
const result = await executeQuery(
|
||||
projectId,
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [{ field: "costType" }],
|
||||
metrics: [{ measure: "costByType", aggregation: "sum" }],
|
||||
filters: [
|
||||
{
|
||||
column: "type",
|
||||
operator: "=",
|
||||
value: "GENERATION",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
"v2",
|
||||
true,
|
||||
);
|
||||
|
||||
const inputRow = result.find((r) => r["costType"] === "input");
|
||||
// Only GENERATION row contributes: 10, not 999
|
||||
expect(Number(inputRow?.["sum_costByType"])).toBeCloseTo(10, 2);
|
||||
});
|
||||
|
||||
it("should aggregate cost_details by type correctly via two-level query path", async () => {
|
||||
// Forces the two-level path by passing useSingleLevelOptimization=false.
|
||||
// Verifies that the bare alias reference in buildInnerDimensionsPart
|
||||
// (not any()) is correct when the pairExpand dim is in GROUP BY.
|
||||
const projectId = randomUUID();
|
||||
|
||||
const events = [
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
type: "GENERATION",
|
||||
cost_details: { input: 10, output: 20, total: 30 },
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
type: "GENERATION",
|
||||
cost_details: { input: 5, output: 15, total: 20 },
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
];
|
||||
await createEventsCh(events);
|
||||
|
||||
const result = await executeQuery(
|
||||
projectId,
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [{ field: "costType" }],
|
||||
metrics: [{ measure: "costByType", aggregation: "sum" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
"v2",
|
||||
false, // force two-level path
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const inputRow = result.find((r) => r["costType"] === "input");
|
||||
expect(Number(inputRow?.["sum_costByType"])).toBeCloseTo(15, 2); // 10 + 5
|
||||
|
||||
const outputRow = result.find((r) => r["costType"] === "output");
|
||||
expect(Number(outputRow?.["sum_costByType"])).toBeCloseTo(35, 2); // 20 + 15
|
||||
|
||||
const totalRow = result.find((r) => r["costType"] === "total");
|
||||
expect(Number(totalRow?.["sum_costByType"])).toBeCloseTo(50, 2); // 30 + 20
|
||||
});
|
||||
|
||||
it("should auto-include costType dimension when only costByType measure is requested", async () => {
|
||||
// Verifies requiresDimension: the query builder must inject the costType
|
||||
// dimension automatically so the ARRAY JOIN is emitted and cost_value is in scope.
|
||||
const projectId = randomUUID();
|
||||
|
||||
const events = [
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
type: "GENERATION",
|
||||
cost_details: { input: 7, output: 3 },
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
];
|
||||
await createEventsCh(events);
|
||||
|
||||
// No costType dimension requested — the builder should add it automatically.
|
||||
const result = await executeQuery(
|
||||
projectId,
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [], // intentionally empty
|
||||
metrics: [{ measure: "costByType", aggregation: "sum" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
"v2",
|
||||
true,
|
||||
);
|
||||
|
||||
// costType should appear in results even though it was not explicitly requested
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0]).toHaveProperty("costType");
|
||||
|
||||
const inputRow = result.find((r) => r["costType"] === "input");
|
||||
expect(Number(inputRow?.["sum_costByType"])).toBeCloseTo(7, 2);
|
||||
|
||||
const outputRow = result.find((r) => r["costType"] === "output");
|
||||
expect(Number(outputRow?.["sum_costByType"])).toBeCloseTo(3, 2);
|
||||
});
|
||||
|
||||
it("should throw when multiple pairExpand dimensions are requested directly", async () => {
|
||||
// Two separate ARRAY JOIN clauses create a cartesian product in Datastore.
|
||||
const projectId = randomUUID();
|
||||
const builder = new QueryBuilder(undefined, "v2");
|
||||
|
||||
await expect(
|
||||
builder.build(
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [{ field: "costType" }, { field: "usageType" }],
|
||||
metrics: [{ measure: "costByType", aggregation: "sum" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
projectId,
|
||||
true,
|
||||
),
|
||||
).rejects.toThrow("Only one pairExpand dimension is supported per query");
|
||||
});
|
||||
|
||||
it("should throw when both costByType and usageByType measures are requested (auto-inject creates two pairExpand dims)", async () => {
|
||||
// Each measure auto-injects its required pairExpand dimension via requiresDimension.
|
||||
// Requesting both triggers the multi-pairExpand guard.
|
||||
const projectId = randomUUID();
|
||||
const builder = new QueryBuilder(undefined, "v2");
|
||||
|
||||
await expect(
|
||||
builder.build(
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [],
|
||||
metrics: [
|
||||
{ measure: "costByType", aggregation: "sum" },
|
||||
{ measure: "usageByType", aggregation: "sum" },
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
projectId,
|
||||
true,
|
||||
),
|
||||
).rejects.toThrow("Only one pairExpand dimension is supported per query");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateQuery", () => {
|
||||
@@ -3907,357 +3535,3 @@ describe("validateQuery", () => {
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("events_traces traceName filter", () => {
|
||||
const isEventsTableV2Enabled = env.HANZO_ENABLE_EVENTS_TABLE_V2_APIS === "true" ? it : it.skip;
|
||||
let hasLegacyEventsTable = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (env.HANZO_ENABLE_EVENTS_TABLE_V2_APIS !== "true") return;
|
||||
|
||||
try {
|
||||
const result = await datastoreClient().query({
|
||||
query: "EXISTS TABLE default.events",
|
||||
format: "TabSeparated",
|
||||
});
|
||||
hasLegacyEventsTable = (await result.text()).trim() === "1";
|
||||
} catch {
|
||||
hasLegacyEventsTable = false;
|
||||
}
|
||||
});
|
||||
|
||||
isEventsTableV2Enabled("should filter events_traces by traceName using aggregation logic", async () => {
|
||||
if (!hasLegacyEventsTable) return;
|
||||
|
||||
const projectId = randomUUID();
|
||||
const traceId1 = randomUUID();
|
||||
const traceId2 = randomUUID();
|
||||
|
||||
// Trace 1: trace_name is empty, root event name is "my-trace"
|
||||
// The aggregation logic should reconstruct this trace's name as "my-trace"
|
||||
const events = [
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
trace_id: traceId1,
|
||||
trace_name: "",
|
||||
name: "my-trace",
|
||||
parent_span_id: "", // root event
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
trace_id: traceId1,
|
||||
trace_name: "",
|
||||
name: "child-observation",
|
||||
parent_span_id: "some-parent", // child event
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
// Trace 2: trace_name is "other-trace"
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
trace_id: traceId2,
|
||||
trace_name: "other-trace",
|
||||
name: "root-event",
|
||||
parent_span_id: "", // root event
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
];
|
||||
await createEventsCh(events);
|
||||
// Allow async insert to settle
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
// Filter by name = "my-trace" — should find trace 1 (via root event name fallback)
|
||||
const result = await executeQuery(
|
||||
projectId,
|
||||
{
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "name",
|
||||
operator: "=",
|
||||
value: "my-trace",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
"v2",
|
||||
);
|
||||
|
||||
// Should return exactly 1 trace (trace 1) with name "my-trace"
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("my-trace");
|
||||
});
|
||||
|
||||
isEventsTableV2Enabled(
|
||||
"should filter events_traces by traceName column via resolveDimension fallback to name filterSql",
|
||||
async () => {
|
||||
if (!hasLegacyEventsTable) return;
|
||||
|
||||
const projectId = randomUUID();
|
||||
const traceId1 = randomUUID();
|
||||
const traceId2 = randomUUID();
|
||||
|
||||
const events = [
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
trace_id: traceId1,
|
||||
trace_name: "target-trace",
|
||||
name: "root-observation",
|
||||
parent_span_id: "",
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
trace_id: traceId2,
|
||||
trace_name: "other-trace",
|
||||
name: "root-observation",
|
||||
parent_span_id: "",
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
];
|
||||
await createEventsCh(events);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
// Filter using "traceName" column (triggers endsWith("Name") fallback)
|
||||
const result = await executeQuery(
|
||||
projectId,
|
||||
{
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "traceName",
|
||||
operator: "=",
|
||||
value: "target-trace",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
"v2",
|
||||
);
|
||||
|
||||
// Should return only the trace with trace_name = "target-trace"
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("target-trace");
|
||||
},
|
||||
);
|
||||
|
||||
isEventsTableV2Enabled(
|
||||
"should filter events_traces by name filterSql combined with a regular dimension filter",
|
||||
async () => {
|
||||
if (!hasLegacyEventsTable) return;
|
||||
|
||||
const projectId = randomUUID();
|
||||
const traceId1 = randomUUID();
|
||||
const traceId2 = randomUUID();
|
||||
const traceId3 = randomUUID();
|
||||
|
||||
const events = [
|
||||
// Trace 1: name="target-trace", environment="production"
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
trace_id: traceId1,
|
||||
trace_name: "target-trace",
|
||||
name: "root-op",
|
||||
parent_span_id: "",
|
||||
environment: "production",
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
// Trace 2: name="target-trace", environment="staging"
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
trace_id: traceId2,
|
||||
trace_name: "target-trace",
|
||||
name: "root-op",
|
||||
parent_span_id: "",
|
||||
environment: "staging",
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
// Trace 3: name="other-trace", environment="production"
|
||||
createEvent({
|
||||
project_id: projectId,
|
||||
trace_id: traceId3,
|
||||
trace_name: "other-trace",
|
||||
name: "root-op",
|
||||
parent_span_id: "",
|
||||
environment: "production",
|
||||
start_time: Date.now() * 1000,
|
||||
}),
|
||||
];
|
||||
await createEventsCh(events);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
// Filter by name (filterSql) AND environment (regular dimension)
|
||||
const result = await executeQuery(
|
||||
projectId,
|
||||
{
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "name",
|
||||
operator: "=",
|
||||
value: "target-trace",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
column: "environment",
|
||||
operator: "=",
|
||||
value: "production",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
toTimestamp: new Date(Date.now() + 86400000).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
"v2",
|
||||
);
|
||||
|
||||
// Should return only trace 1 (matches both name AND environment)
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("target-trace");
|
||||
},
|
||||
);
|
||||
|
||||
describe("useFinal flag on events_core joins", () => {
|
||||
it("should omit FINAL for events_core joins in v2 scores-numeric view", async () => {
|
||||
const projectId = randomUUID();
|
||||
const builder = new QueryBuilder(undefined, "v2");
|
||||
const { query: compiledQuery } = await builder.build(
|
||||
{
|
||||
view: "scores-numeric",
|
||||
dimensions: [{ field: "traceName" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
toTimestamp: new Date(Date.now()).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
|
||||
// events_core should be joined without FINAL (useFinal: false)
|
||||
expect(compiledQuery).toContain("JOIN events_core");
|
||||
expect(compiledQuery).not.toContain("JOIN events_core AS events_traces FINAL");
|
||||
// scores base CTE should still use FINAL
|
||||
expect(compiledQuery).toContain("scores scores_numeric FINAL");
|
||||
});
|
||||
|
||||
it("should omit FINAL for events_observations join in v2 scores-categorical view", async () => {
|
||||
const projectId = randomUUID();
|
||||
const builder = new QueryBuilder(undefined, "v2");
|
||||
const { query: compiledQuery } = await builder.build(
|
||||
{
|
||||
view: "scores-categorical",
|
||||
dimensions: [{ field: "observationName" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
toTimestamp: new Date(Date.now()).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
|
||||
// events_core should be joined without FINAL (useFinal: false)
|
||||
expect(compiledQuery).toContain("JOIN events_core");
|
||||
expect(compiledQuery).not.toContain("JOIN events_core AS events_observations FINAL");
|
||||
});
|
||||
|
||||
it("should keep FINAL for non-events_core joins in v1 scores view", async () => {
|
||||
const projectId = randomUUID();
|
||||
const builder = new QueryBuilder(undefined, "v1");
|
||||
const { query: compiledQuery } = await builder.build(
|
||||
{
|
||||
view: "scores-numeric",
|
||||
dimensions: [{ field: "traceName" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
toTimestamp: new Date(Date.now()).toISOString(),
|
||||
orderBy: null,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
|
||||
// v1 traces join should still use FINAL (useFinal defaults to true)
|
||||
expect(compiledQuery).toContain("JOIN traces FINAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rootEventCondition threshold gating", () => {
|
||||
const tracesV2Query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: "2025-01-01T00:00:00.000Z",
|
||||
toTimestamp: "2025-01-04T00:00:00.000Z", // 3-day window (72 hours)
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
it("should include rootEventCondition subquery when window is within threshold", async () => {
|
||||
const originalValue = env.CONSOLE_ROOT_EVENT_CONDITION_MAX_WINDOW_HOURS;
|
||||
try {
|
||||
// 168 hours (7 days) threshold, 72-hour window → should include subquery
|
||||
(env as any).CONSOLE_ROOT_EVENT_CONDITION_MAX_WINDOW_HOURS = 168;
|
||||
const builder = new QueryBuilder(undefined, "v2");
|
||||
const { query: sql } = await builder.build(tracesV2Query, randomUUID());
|
||||
expect(sql).toContain("IN (SELECT trace_id");
|
||||
} finally {
|
||||
(env as any).CONSOLE_ROOT_EVENT_CONDITION_MAX_WINDOW_HOURS = originalValue;
|
||||
}
|
||||
});
|
||||
|
||||
it("should skip rootEventCondition subquery when window exceeds threshold", async () => {
|
||||
const originalValue = env.CONSOLE_ROOT_EVENT_CONDITION_MAX_WINDOW_HOURS;
|
||||
try {
|
||||
// 24-hour threshold, 72-hour window → should skip subquery
|
||||
(env as any).CONSOLE_ROOT_EVENT_CONDITION_MAX_WINDOW_HOURS = 24;
|
||||
const builder = new QueryBuilder(undefined, "v2");
|
||||
const { query: sql } = await builder.build(tracesV2Query, randomUUID());
|
||||
expect(sql).not.toContain("IN (SELECT trace_id");
|
||||
} finally {
|
||||
(env as any).CONSOLE_ROOT_EVENT_CONDITION_MAX_WINDOW_HOURS = originalValue;
|
||||
}
|
||||
});
|
||||
|
||||
it("should always include rootEventCondition subquery when threshold is 0", async () => {
|
||||
const originalValue = env.CONSOLE_ROOT_EVENT_CONDITION_MAX_WINDOW_HOURS;
|
||||
try {
|
||||
// 0 = always apply, even for a very wide window
|
||||
(env as any).CONSOLE_ROOT_EVENT_CONDITION_MAX_WINDOW_HOURS = 0;
|
||||
const builder = new QueryBuilder(undefined, "v2");
|
||||
const { query: sql } = await builder.build(
|
||||
{
|
||||
...tracesV2Query,
|
||||
fromTimestamp: "2024-01-01T00:00:00.000Z",
|
||||
toTimestamp: "2025-01-01T00:00:00.000Z", // 1-year window
|
||||
},
|
||||
randomUUID(),
|
||||
);
|
||||
expect(sql).toContain("IN (SELECT trace_id");
|
||||
} finally {
|
||||
(env as any).CONSOLE_ROOT_EVENT_CONDITION_MAX_WINDOW_HOURS = originalValue;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { createTrace, createTracesCh, createObservation, createObservationsCh } from "@hanzo/console-core/src/server";
|
||||
import { createTrace, createTracesCh, createObservation, createObservationsCh } from "@hanzo/shared/src/server";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import { executeQuery } from "@/src/features/query/server/queryExecutor";
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("selfServeDashboards", () => {
|
||||
const twoHoursAgo = new Date(now.getTime() - 7200000);
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
|
||||
|
||||
// Time ranges for queries - converted to Datastore DateTime format (YYYY-MM-DD HH:MM:SS.SSS)
|
||||
// Time ranges for queries - converted to ClickHouse DateTime format (YYYY-MM-DD HH:MM:SS.SSS)
|
||||
const defaultFromTime = threeDaysAgo.toISOString();
|
||||
const defaultToTime = new Date(now.getTime() + 3600000).toISOString(); // 1 hour in future
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("selfServeDashboards", () => {
|
||||
),
|
||||
];
|
||||
|
||||
// Insert traces into Datastore
|
||||
// Insert traces into ClickHouse
|
||||
await createTracesCh(traces);
|
||||
|
||||
// Create observations for some of these traces
|
||||
@@ -166,7 +166,7 @@ describe("selfServeDashboards", () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Insert observations into Datastore
|
||||
// Insert observations into ClickHouse
|
||||
await createObservationsCh(observations);
|
||||
|
||||
// Calculate statistics for verification
|
||||
@@ -405,8 +405,8 @@ describe("selfServeDashboards", () => {
|
||||
const modelRow = queryBuilderResult.find((row: any) => row.providedModelName === modelName);
|
||||
|
||||
expect(modelRow).toBeDefined();
|
||||
expect(modelRow!.sum_totalCost).toBeDefined();
|
||||
expect(modelRow!.sum_totalTokens).toBeDefined();
|
||||
expect(modelRow.sum_totalCost).toBeDefined();
|
||||
expect(modelRow.sum_totalTokens).toBeDefined();
|
||||
|
||||
// We could add more specific assertions about the expected costs and tokens
|
||||
// if we had that information calculated from our sample data
|
||||
@@ -443,8 +443,8 @@ describe("selfServeDashboards", () => {
|
||||
expect(queryBuilderResult.length).toBe(2);
|
||||
|
||||
const modelRow = queryBuilderResult[0];
|
||||
expect(modelRow!.providedModelName).toBe("gpt-4-turbo");
|
||||
expect(modelRow!.sum_totalCost).toBe(900);
|
||||
expect(modelRow.providedModelName).toBe("gpt-4-turbo");
|
||||
expect(modelRow.sum_totalCost).toBe(900);
|
||||
expect(Number(modelRow.sum_totalTokens)).toBe(20736);
|
||||
});
|
||||
|
||||
@@ -487,8 +487,8 @@ describe("selfServeDashboards", () => {
|
||||
const modelRow = queryBuilderResult.find((row: any) => row.providedModelName === modelName);
|
||||
|
||||
expect(modelRow).toBeDefined();
|
||||
expect(modelRow!.sum_totalCost).toBeGreaterThan(500);
|
||||
expect(Number(modelRow!.sum_totalTokens)).toBeGreaterThan(10000);
|
||||
expect(modelRow.sum_totalCost).toBeGreaterThan(500);
|
||||
expect(Number(modelRow.sum_totalTokens)).toBeGreaterThan(10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -631,7 +631,7 @@ describe("selfServeDashboards", () => {
|
||||
// We might not have observations for all users, so we'll just check
|
||||
// that the structure is correct for the ones we do have
|
||||
if (userRow) {
|
||||
expect(userRow!.sum_totalCost).toBeDefined();
|
||||
expect(userRow.sum_totalCost).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -669,11 +669,11 @@ describe("selfServeDashboards", () => {
|
||||
const userRow = queryBuilderResult.find((row: any) => row.userId === userId);
|
||||
|
||||
expect(userRow).toBeDefined();
|
||||
expect(userRow!.count_count).toBeDefined();
|
||||
expect(userRow.count_count).toBeDefined();
|
||||
|
||||
// Verify the count matches what we expect based on our sample data
|
||||
// We could add more specific assertions if needed
|
||||
expect(Number(userRow!.count_count)).toBeGreaterThan(0);
|
||||
expect(Number(userRow.count_count)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Verify the total number of traces across all users matches our expected total
|
||||
@@ -717,10 +717,10 @@ describe("selfServeDashboards", () => {
|
||||
// We might not have latency data for all models, so we'll just check
|
||||
// that the structure is correct for the ones we do have
|
||||
if (modelRow) {
|
||||
expect(modelRow!.p50_latency).toBeDefined();
|
||||
expect(modelRow!.p90_latency).toBeDefined();
|
||||
expect(modelRow!.p95_latency).toBeDefined();
|
||||
expect(modelRow!.p99_latency).toBeDefined();
|
||||
expect(modelRow.p50_latency).toBeDefined();
|
||||
expect(modelRow.p90_latency).toBeDefined();
|
||||
expect(modelRow.p95_latency).toBeDefined();
|
||||
expect(modelRow.p99_latency).toBeDefined();
|
||||
|
||||
// Latencies should be positive numbers
|
||||
expect(Number(modelRow.p50_latency)).toBeGreaterThan(0);
|
||||
|
||||
@@ -3,11 +3,6 @@ import { Redis } from "ioredis";
|
||||
|
||||
describe("RateLimitService", () => {
|
||||
const orgId = "seed-org-id";
|
||||
const baseScopeFields = {
|
||||
apiKeyId: "test-api-key-id",
|
||||
publicKey: "pk-test",
|
||||
isIngestionSuspended: false,
|
||||
} as const;
|
||||
let redis: Redis;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -47,7 +42,6 @@ describe("RateLimitService", () => {
|
||||
plan: "cloud:free" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "project" as const,
|
||||
...baseScopeFields,
|
||||
rateLimitOverrides: [],
|
||||
},
|
||||
consumedPoints: 1,
|
||||
@@ -70,7 +64,6 @@ describe("RateLimitService", () => {
|
||||
plan: "cloud:free" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "project" as const,
|
||||
...baseScopeFields,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
@@ -82,8 +75,8 @@ describe("RateLimitService", () => {
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 20,
|
||||
remainingPoints: 19,
|
||||
points: 30,
|
||||
remainingPoints: 29,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
@@ -104,7 +97,6 @@ describe("RateLimitService", () => {
|
||||
plan: "cloud:free" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "project" as const,
|
||||
...baseScopeFields,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
@@ -116,8 +108,8 @@ describe("RateLimitService", () => {
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 20,
|
||||
remainingPoints: 18,
|
||||
points: 30,
|
||||
remainingPoints: 28,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 2,
|
||||
isFirstInDuration: false,
|
||||
@@ -131,7 +123,6 @@ describe("RateLimitService", () => {
|
||||
plan: "cloud:free" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "project" as const,
|
||||
...baseScopeFields,
|
||||
rateLimitOverrides: [{ resource: "public-api" as const, points: 100, durationInSec: 2 }],
|
||||
};
|
||||
|
||||
@@ -174,7 +165,6 @@ describe("RateLimitService", () => {
|
||||
plan: "cloud:free" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "project" as const,
|
||||
...baseScopeFields,
|
||||
rateLimitOverrides: [{ resource: "public-api" as const, points: 5, durationInSec: 60 }],
|
||||
};
|
||||
|
||||
@@ -204,7 +194,6 @@ describe("RateLimitService", () => {
|
||||
plan: "cloud:free" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "project" as const,
|
||||
...baseScopeFields,
|
||||
rateLimitOverrides: [{ resource: "public-api" as const, points: 5, durationInSec: 10 }],
|
||||
};
|
||||
|
||||
@@ -229,7 +218,6 @@ describe("RateLimitService", () => {
|
||||
plan: "cloud:free" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "project" as const,
|
||||
...baseScopeFields,
|
||||
rateLimitOverrides: [{ resource: "public-api" as const, points: 5, durationInSec: 10 }],
|
||||
};
|
||||
|
||||
@@ -247,7 +235,6 @@ describe("RateLimitService", () => {
|
||||
plan: "cloud:free" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "project" as const,
|
||||
...baseScopeFields,
|
||||
rateLimitOverrides: [{ resource: "ingestion" as const, points: null, durationInSec: null }],
|
||||
};
|
||||
|
||||
@@ -284,7 +271,6 @@ describe("RateLimitService", () => {
|
||||
plan: "self-hosted:pro" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "project" as const,
|
||||
...baseScopeFields,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { OtelIngestionProcessor, createIngestionEventSchema } from "@hanzo/console-core/src/server";
|
||||
import { OtelIngestionProcessor, createIngestionEventSchema } from "@hanzo/shared/src/server";
|
||||
|
||||
// Test helper function to maintain backward compatibility with existing tests
|
||||
// This mimics the old convertOtelSpanToIngestionEvent function signature
|
||||
async function convertOtelSpanToIngestionEvent(
|
||||
resourceSpan: any,
|
||||
seenTraces: Set<string>,
|
||||
publicKey?: string,
|
||||
): Promise<Array<{ type: string; body: Record<string, any> }>> {
|
||||
async function convertOtelSpanToIngestionEvent(resourceSpan: any, seenTraces: Set<string>, publicKey?: string) {
|
||||
const processor = new OtelIngestionProcessor({
|
||||
projectId: "test-project",
|
||||
publicKey,
|
||||
@@ -22,7 +18,7 @@ async function convertOtelSpanToIngestionEvent(
|
||||
|
||||
describe("OTel Resource Span Mapping", () => {
|
||||
describe("Hanzo OTEL SDK spans", () => {
|
||||
const publicKey = "pk-hz-1234567890";
|
||||
const publicKey = "pk-lf-1234567890";
|
||||
|
||||
it("should convert LF-OTEL spans to LF-events", async () => {
|
||||
const hanzoOtelSpans = [
|
||||
@@ -60,7 +56,7 @@ describe("OTel Resource Span Mapping", () => {
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: "pk-hz-1234567890" },
|
||||
value: { stringValue: "pk-lf-1234567890" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -387,7 +383,7 @@ describe("OTel Resource Span Mapping", () => {
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: "pk-hz-1234567890" },
|
||||
value: { stringValue: "pk-lf-1234567890" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -3174,120 +3170,10 @@ describe("OTel Resource Span Mapping", () => {
|
||||
};
|
||||
expect(OtelIngestionProcessor.convertNanoTimestampToISO(zeroTimestamp)).toBe("1970-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should return undefined for missing or invalid timestamps", () => {
|
||||
expect(OtelIngestionProcessor.convertNanoTimestampToISO(undefined as any)).toBeUndefined();
|
||||
expect(OtelIngestionProcessor.convertNanoTimestampToISO(null as any)).toBeUndefined();
|
||||
expect(OtelIngestionProcessor.convertNanoTimestampToISO("")).toBeUndefined();
|
||||
expect(OtelIngestionProcessor.convertNanoTimestampToISO("not-a-timestamp")).toBeUndefined();
|
||||
expect(OtelIngestionProcessor.convertNanoTimestampToISO(NaN)).toBeUndefined();
|
||||
expect(OtelIngestionProcessor.convertNanoTimestampToISO(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
expect(
|
||||
OtelIngestionProcessor.convertNanoTimestampToISO({
|
||||
high: 1,
|
||||
} as any),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Missing span timestamps", () => {
|
||||
it("should process spans with no start/end times without throwing", async () => {
|
||||
const resourceSpan = {
|
||||
resource: {
|
||||
attributes: [{ key: "service.name", value: { stringValue: "test" } }],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "test-scope",
|
||||
version: "1.0.0",
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: "2cce18f7e8cd065a0b4e634eef728391",
|
||||
spanId: "57f0255417974100",
|
||||
name: "span-without-time",
|
||||
kind: 1,
|
||||
attributes: [],
|
||||
status: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const processor = new OtelIngestionProcessor({
|
||||
projectId: "test-project",
|
||||
});
|
||||
const eventInputs = processor.processToEvent([resourceSpan]);
|
||||
expect(eventInputs).toHaveLength(1);
|
||||
expect(eventInputs[0].startTimeISO).toBeDefined();
|
||||
expect(eventInputs[0].endTimeISO).toBeDefined();
|
||||
expect(eventInputs[0].startTimeISO).toBe(eventInputs[0].endTimeISO);
|
||||
|
||||
const ingestionEvents = await convertOtelSpanToIngestionEvent(resourceSpan, new Set());
|
||||
|
||||
const trace = ingestionEvents.find((e) => e.type === "trace-create");
|
||||
const observation = ingestionEvents.find((e) => e.type === "span-create");
|
||||
|
||||
expect(trace).toBeDefined();
|
||||
expect(trace?.body.timestamp).toBeDefined();
|
||||
expect(observation).toBeDefined();
|
||||
expect(observation?.body.startTime).toBeDefined();
|
||||
expect(observation?.body.endTime).toBeDefined();
|
||||
expect(observation?.body.startTime).toBe(observation?.body.endTime);
|
||||
});
|
||||
|
||||
it("should use the present timestamp as fallback when only one edge is missing", async () => {
|
||||
const endOnlyTimestamp = {
|
||||
low: 467248096,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
};
|
||||
const expectedISO = OtelIngestionProcessor.convertNanoTimestampToISO(endOnlyTimestamp);
|
||||
|
||||
const resourceSpan = {
|
||||
resource: {
|
||||
attributes: [{ key: "service.name", value: { stringValue: "test" } }],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "test-scope",
|
||||
version: "1.0.0",
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: "2cce18f7e8cd065a0b4e634eef728391",
|
||||
spanId: "57f0255417974100",
|
||||
name: "span-end-only-time",
|
||||
kind: 1,
|
||||
endTimeUnixNano: endOnlyTimestamp,
|
||||
attributes: [],
|
||||
status: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const processor = new OtelIngestionProcessor({
|
||||
projectId: "test-project",
|
||||
});
|
||||
const eventInputs = processor.processToEvent([resourceSpan]);
|
||||
expect(eventInputs).toHaveLength(1);
|
||||
expect(eventInputs[0].startTimeISO).toBe(expectedISO);
|
||||
expect(eventInputs[0].endTimeISO).toBe(expectedISO);
|
||||
|
||||
const ingestionEvents = await convertOtelSpanToIngestionEvent(resourceSpan, new Set());
|
||||
const observation = ingestionEvents.find((e) => e.type === "span-create");
|
||||
expect(observation?.body.startTime).toBe(expectedISO);
|
||||
expect(observation?.body.endTime).toBe(expectedISO);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Trace seen logic", () => {
|
||||
const publicKey = "pk-hz-1234567890";
|
||||
const publicKey = "pk-lf-1234567890";
|
||||
|
||||
it("should create a shallow trace when seenTraces set is empty for non-root span without trace updates", async () => {
|
||||
const otelSpans = [
|
||||
@@ -3591,15 +3477,15 @@ describe("OTel Resource Span Mapping", () => {
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: "hanzo.trace.metadata.console_user_id",
|
||||
key: "hanzo.trace.metadata.hanzo_user_id",
|
||||
value: { stringValue: "user-123" },
|
||||
},
|
||||
{
|
||||
key: "hanzo.trace.metadata.console_session_id",
|
||||
key: "hanzo.trace.metadata.hanzo_session_id",
|
||||
value: { stringValue: "session-456" },
|
||||
},
|
||||
{
|
||||
key: "hanzo.trace.metadata.console_tags",
|
||||
key: "hanzo.trace.metadata.hanzo_tags",
|
||||
value: { stringValue: "tag1,tag2" },
|
||||
},
|
||||
],
|
||||
@@ -3672,15 +3558,15 @@ describe("OTel Resource Span Mapping", () => {
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: "hanzo.observation.metadata.console_user_id",
|
||||
key: "hanzo.observation.metadata.hanzo_user_id",
|
||||
value: { stringValue: "user-789" },
|
||||
},
|
||||
{
|
||||
key: "hanzo.observation.metadata.console_session_id",
|
||||
key: "hanzo.observation.metadata.hanzo_session_id",
|
||||
value: { stringValue: "session-abc" },
|
||||
},
|
||||
{
|
||||
key: "hanzo.observation.metadata.console_tags",
|
||||
key: "hanzo.observation.metadata.hanzo_tags",
|
||||
value: { stringValue: "tag3,tag4" },
|
||||
},
|
||||
],
|
||||
@@ -4133,7 +4019,7 @@ describe("OTel Resource Span Mapping", () => {
|
||||
|
||||
const traceEvent = events.find((e) => e.type === "trace-create");
|
||||
expect(traceEvent).toBeDefined();
|
||||
expect(traceEvent!.body.sessionId).toBe("hanzo-session-123");
|
||||
expect(traceEvent.body.sessionId).toBe("hanzo-session-123");
|
||||
});
|
||||
|
||||
it("should prioritize session.id over gen_ai.conversation.id when both are present", async () => {
|
||||
@@ -4201,7 +4087,7 @@ describe("OTel Resource Span Mapping", () => {
|
||||
|
||||
const traceEvent = events.find((e) => e.type === "trace-create");
|
||||
expect(traceEvent).toBeDefined();
|
||||
expect(traceEvent!.body.sessionId).toBe("session-id-123");
|
||||
expect(traceEvent.body.sessionId).toBe("session-id-123");
|
||||
});
|
||||
|
||||
it("should default to span-create for unknown observation type", async () => {
|
||||
@@ -4595,7 +4481,7 @@ describe("OTel Resource Span Mapping", () => {
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: "pk-hz-1234567890" },
|
||||
value: { stringValue: "pk-lf-1234567890" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -4736,13 +4622,13 @@ describe("OTel Resource Span Mapping", () => {
|
||||
expect(updatedTraceEvent).toBeDefined();
|
||||
|
||||
// original_span_attribute should still exist
|
||||
expect(updatedTraceEvent!.body.metadata?.attributes?.original_span_attribute).toBe("should_be_preserved");
|
||||
expect(updatedTraceEvent.body.metadata?.attributes?.original_span_attribute).toBe("should_be_preserved");
|
||||
|
||||
// new_span_attribute should now exist
|
||||
expect(updatedTraceEvent!.body.metadata?.attributes?.new_span_attribute).toBe("new_value");
|
||||
expect(updatedTraceEvent.body.metadata?.attributes?.new_span_attribute).toBe("new_value");
|
||||
|
||||
// The sessionId should be updated
|
||||
expect(updatedTraceEvent!.body.sessionId).toBe("new-session");
|
||||
expect(updatedTraceEvent.body.sessionId).toBe("new-session");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createObservation, createObservationsCh } from "@hanzo/console-core/src/server";
|
||||
import { createObservation, createObservationsCh } from "@hanzo/shared/src/server";
|
||||
import { pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { getObservationMetricsForPrompts, getObservationsWithPromptName } from "@hanzo/console-core/src/server";
|
||||
import { getObservationMetricsForPrompts, getObservationsWithPromptName } from "@hanzo/shared/src/server";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { validateOrderBy, validateFilters } from "@/src/components/table/table-view-presets/validation";
|
||||
import { type ColumnDefinition, type FilterState, type OrderByState } from "@hanzo/console-core";
|
||||
import { type ColumnDefinition, type FilterState, type OrderByState } from "@hanzo/shared";
|
||||
|
||||
// Mock data for testing
|
||||
const mockColumns = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { prisma } from "@hanzo/console-core/src/db";
|
||||
import { prisma } from "@hanzo/shared/src/db";
|
||||
import {
|
||||
datastoreClient,
|
||||
clickhouseClient,
|
||||
createBasicAuthHeader,
|
||||
getQueue,
|
||||
IngestionQueue,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
OtelIngestionQueue,
|
||||
QueueName,
|
||||
TraceUpsertQueue,
|
||||
} from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared/src/server";
|
||||
import { type z } from "zod/v4";
|
||||
|
||||
export const ensureTestDatabaseExists = async () => {
|
||||
@@ -76,7 +76,7 @@ export const ensureTestDatabaseExists = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Datastore uses default database (no setup needed)
|
||||
// ClickHouse uses default database (no setup needed)
|
||||
};
|
||||
|
||||
export const pruneDatabase = async () => {
|
||||
@@ -96,7 +96,7 @@ export const pruneDatabase = async () => {
|
||||
await prisma.comment.deleteMany();
|
||||
await prisma.media.deleteMany();
|
||||
|
||||
await truncateDatastoreTables();
|
||||
await truncateClickhouseTables();
|
||||
};
|
||||
export const getQueues = () => {
|
||||
const queues: string[] = Object.values(QueueName);
|
||||
@@ -106,7 +106,7 @@ export const getQueues = () => {
|
||||
QueueName.DataRetentionQueue,
|
||||
QueueName.BlobStorageIntegrationQueue,
|
||||
QueueName.DeadLetterRetryQueue,
|
||||
QueueName.InsightsIntegrationQueue,
|
||||
QueueName.PostHogIntegrationQueue,
|
||||
QueueName.CloudFreeTierUsageThresholdQueue,
|
||||
];
|
||||
|
||||
@@ -142,25 +142,25 @@ export const disconnectQueues = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const truncateDatastoreTables = async () => {
|
||||
if (!env.DATASTORE_URL?.includes("localhost:8123") && !env.DATASTORE_URL?.includes("127.0.0.1:8123")) {
|
||||
throw new Error("You cannot prune datastore unless running on localhost.");
|
||||
export const truncateClickhouseTables = async () => {
|
||||
if (!env.CLICKHOUSE_URL?.includes("localhost:8123")) {
|
||||
throw new Error("You cannot prune clickhouse unless running on localhost.");
|
||||
}
|
||||
|
||||
// Additional safety check for test database
|
||||
if (env.DATASTORE_DB === "test") {
|
||||
console.log("Running tests against test Datastore database:", env.DATASTORE_DB);
|
||||
} else if (env.DATASTORE_DB !== "default") {
|
||||
console.log("Running tests against Datastore database:", env.DATASTORE_DB);
|
||||
if (env.CLICKHOUSE_DB === "test") {
|
||||
console.log("Running tests against test ClickHouse database:", env.CLICKHOUSE_DB);
|
||||
} else if (env.CLICKHOUSE_DB !== "default") {
|
||||
console.log("Running tests against ClickHouse database:", env.CLICKHOUSE_DB);
|
||||
}
|
||||
|
||||
await datastoreClient().command({
|
||||
await clickhouseClient().command({
|
||||
query: "TRUNCATE TABLE IF EXISTS observations",
|
||||
});
|
||||
await datastoreClient().command({
|
||||
await clickhouseClient().command({
|
||||
query: "TRUNCATE TABLE IF EXISTS scores",
|
||||
});
|
||||
await datastoreClient().command({
|
||||
await clickhouseClient().command({
|
||||
query: "TRUNCATE TABLE IF EXISTS traces",
|
||||
});
|
||||
};
|
||||
@@ -182,7 +182,7 @@ export type ErrorIngestion = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function makeAPICall<T = Record<string, any>>(
|
||||
export async function makeAPICall<T = IngestionAPIResponse>(
|
||||
method: "POST" | "GET" | "PUT" | "DELETE" | "PATCH",
|
||||
url: string,
|
||||
body?: unknown,
|
||||
@@ -244,7 +244,7 @@ export async function makeZodVerifiedAPICall<T extends z.ZodTypeAny>(
|
||||
`API call (${method} ${url}) did not return valid response, returned status ${status}, body ${JSON.stringify(resBody)}, error ${typeCheckResult.error}`,
|
||||
);
|
||||
}
|
||||
return { body: resBody as z.infer<T>, status };
|
||||
return { body: resBody, status };
|
||||
}
|
||||
|
||||
export async function makeZodVerifiedAPICallSilent<T extends z.ZodTypeAny>(
|
||||
@@ -266,5 +266,5 @@ export async function makeZodVerifiedAPICallSilent<T extends z.ZodTypeAny>(
|
||||
}
|
||||
}
|
||||
|
||||
return { body: resBody as z.infer<T>, status };
|
||||
return { body: resBody, status };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { transformToAnnotationScores } from "@/src/features/scores/lib/transformScores";
|
||||
import { type ScoreDomain, type ScoreAggregate, type ScoreConfigDomain } from "@hanzo/console-core";
|
||||
import { type WithStringifiedMetadata } from "@/src/utils/clientSideDomainTypes";
|
||||
import { type ScoreDomain, type ScoreAggregate, type ScoreConfigDomain } from "@hanzo/shared";
|
||||
|
||||
const mockConfigs: ScoreConfigDomain[] = [
|
||||
{
|
||||
@@ -39,7 +38,6 @@ describe("transformToAnnotationScores - flat scores", () => {
|
||||
dataType: "NUMERIC",
|
||||
value: 8,
|
||||
stringValue: null,
|
||||
longStringValue: "",
|
||||
configId: "config-1",
|
||||
comment: "Good quality",
|
||||
traceId: "trace-1",
|
||||
@@ -63,7 +61,6 @@ describe("transformToAnnotationScores - flat scores", () => {
|
||||
dataType: "CATEGORICAL",
|
||||
value: 1,
|
||||
stringValue: "positive",
|
||||
longStringValue: "positive",
|
||||
configId: "config-2",
|
||||
comment: null,
|
||||
traceId: "trace-1",
|
||||
@@ -82,7 +79,7 @@ describe("transformToAnnotationScores - flat scores", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformToAnnotationScores(flatScores as any, mockConfigs);
|
||||
const result = transformToAnnotationScores(flatScores, mockConfigs);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
@@ -124,7 +121,6 @@ describe("transformToAnnotationScores - flat scores", () => {
|
||||
dataType: "NUMERIC",
|
||||
value: 8,
|
||||
stringValue: null,
|
||||
longStringValue: "",
|
||||
configId: "config-1",
|
||||
comment: null,
|
||||
traceId: "trace-1",
|
||||
@@ -148,7 +144,6 @@ describe("transformToAnnotationScores - flat scores", () => {
|
||||
dataType: "CATEGORICAL",
|
||||
value: 1,
|
||||
stringValue: "positive",
|
||||
longStringValue: "positive",
|
||||
configId: "config-2",
|
||||
comment: null,
|
||||
traceId: "trace-1",
|
||||
@@ -167,7 +162,7 @@ describe("transformToAnnotationScores - flat scores", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformToAnnotationScores(flatScores as any, mockConfigs);
|
||||
const result = transformToAnnotationScores(flatScores, mockConfigs);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.name).toBe("sentiment");
|
||||
@@ -183,7 +178,6 @@ describe("transformToAnnotationScores - flat scores", () => {
|
||||
dataType: "NUMERIC",
|
||||
value: 8,
|
||||
stringValue: null,
|
||||
longStringValue: "",
|
||||
configId: "config-99",
|
||||
comment: null,
|
||||
traceId: "trace-1",
|
||||
@@ -202,7 +196,7 @@ describe("transformToAnnotationScores - flat scores", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformToAnnotationScores(flatScores as any, mockConfigs);
|
||||
const result = transformToAnnotationScores(flatScores, mockConfigs);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/** @jest-environment node */
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import { BaseError, ConsoleNotFoundError, UnauthorizedError, ServiceUnavailableError } from "@hanzo/console-core";
|
||||
import { DatastoreResourceError, logger, traceException } from "@hanzo/console-core/src/server";
|
||||
import { BaseError, HanzoNotFoundError, UnauthorizedError, ServiceUnavailableError } from "@hanzo/shared";
|
||||
import { ClickHouseResourceError, logger, traceException } from "@hanzo/shared/src/server";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { z } from "zod/v4";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
// Mock the logger and traceException
|
||||
jest.mock("@hanzo/console-core/src/server", () => ({
|
||||
...jest.requireActual("@hanzo/console-core/src/server"),
|
||||
jest.mock("@hanzo/shared/src/server", () => ({
|
||||
...jest.requireActual("@hanzo/shared/src/server"),
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
@@ -37,7 +36,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,7 +62,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,9 +79,9 @@ describe("withMiddlewares error handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConsoleNotFoundError handling", () => {
|
||||
it("should handle ConsoleNotFoundError and log as info", async () => {
|
||||
const error = new ConsoleNotFoundError("Resource not found");
|
||||
describe("HanzoNotFoundError handling", () => {
|
||||
it("should handle HanzoNotFoundError and log as info", async () => {
|
||||
const error = new HanzoNotFoundError("Resource not found");
|
||||
|
||||
const handler = withMiddlewares({
|
||||
GET: async () => {
|
||||
@@ -93,7 +92,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,7 +102,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Resource not found",
|
||||
error: "HanzoCloudNotFoundError",
|
||||
error: "HanzoNotFoundError",
|
||||
});
|
||||
// Should log as info, not error
|
||||
expect(logger.info).toHaveBeenCalledWith(error);
|
||||
@@ -124,7 +123,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,7 +151,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -167,10 +166,10 @@ describe("withMiddlewares error handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DatastoreResourceError handling", () => {
|
||||
it("should handle DatastoreResourceError with 422 status", async () => {
|
||||
describe("ClickHouseResourceError handling", () => {
|
||||
it("should handle ClickHouseResourceError with 400 status", async () => {
|
||||
const originalError = new Error("Memory limit exceeded: maximum: 10GB");
|
||||
const resourceError = new DatastoreResourceError("MEMORY_LIMIT", originalError);
|
||||
const resourceError = new ClickHouseResourceError("MEMORY_LIMIT", originalError);
|
||||
|
||||
const handler = withMiddlewares({
|
||||
POST: async () => {
|
||||
@@ -181,17 +180,16 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(422);
|
||||
expect(res._getStatusCode()).toBe(524);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData["message"]).toBeDefined();
|
||||
expect(jsonData["message"]).toContain(DatastoreResourceError.ERROR_ADVICE_MESSAGE);
|
||||
expect(jsonData["error"]).toBe("Unprocessable Content");
|
||||
expect(jsonData["message"]).toContain(ClickHouseResourceError.ERROR_ADVICE_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,7 +210,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -246,7 +244,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -279,7 +277,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -309,7 +307,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -335,7 +333,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -359,7 +357,7 @@ describe("withMiddlewares error handling", () => {
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"x-iam-public-key": "test-key",
|
||||
"x-hanzo-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
import { Lock, AlertCircle, Sparkle } from "lucide-react";
|
||||
import { Button, type ButtonProps } from "@/src/components/ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hanzo/ui";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/src/components/ui/hover-card";
|
||||
import { HoverCardPortal } from "@radix-ui/react-hover-card";
|
||||
import Link from "next/link";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
|
||||
const BUTTON_STATE_MESSAGES = {
|
||||
limitReached: (current: number, max: number) =>
|
||||
@@ -23,7 +23,7 @@ interface ActionButtonProps extends ButtonProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
href?: string;
|
||||
trackingEventName?: Parameters<ReturnType<typeof useInsightsCapture>>[0];
|
||||
trackingEventName?: Parameters<ReturnType<typeof usePostHogClientCapture>>[0];
|
||||
trackingProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProp
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const hasReachedLimit = typeof limit === "number" && limitValue !== undefined && limitValue >= limit;
|
||||
const isDisabled = disabled || !hasAccess || !hasEntitlement || hasReachedLimit;
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@ import { ChevronDownIcon, PlusCircleIcon } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hanzo/ui";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { ChatMessageRole, ChatMessageType, type ChatMessageWithId } from "@hanzo/console-core";
|
||||
import { ChatMessageRole, ChatMessageType, type ChatMessageWithId } from "@hanzo/shared";
|
||||
|
||||
import { ChatMessageComponent } from "./ChatMessageComponent";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatMessage, ChatMessageWithId, PlaceholderMessage } from "@hanzo/console-core";
|
||||
import type { ChatMessage, ChatMessageWithId, PlaceholderMessage } from "@hanzo/shared";
|
||||
|
||||
export type MessagesContext = {
|
||||
messages: ChatMessageWithId[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { type ChatMessage, type ChatMessageWithIdNoPlaceholders } from "@hanzo/console-core";
|
||||
import { type ChatMessage, type ChatMessageWithIdNoPlaceholders } from "@hanzo/shared";
|
||||
|
||||
export function createEmptyMessage(message: ChatMessage): ChatMessageWithIdNoPlaceholders {
|
||||
return {
|
||||
|
||||
@@ -17,13 +17,13 @@ import { cn } from "@/src/utils/tailwind";
|
||||
import {
|
||||
type JSONObject,
|
||||
JSONObjectSchema,
|
||||
LLMAdapter,
|
||||
type LLMAdapter,
|
||||
type supportedModels,
|
||||
type UIModelParams,
|
||||
} from "@hanzo/console-core";
|
||||
} from "@hanzo/shared";
|
||||
import { InfoIcon, Settings2 } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@hanzo/ui";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@hanzo/ui";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||
|
||||
import { LLMApiKeyComponent } from "./LLMApiKeyComponent";
|
||||
import { FormDescription } from "@/src/components/ui/form";
|
||||
@@ -146,21 +146,6 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
tooltip="An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
{modelParams.adapter.value === LLMAdapter.VertexAI && modelParams.maxReasoningTokens && (
|
||||
<ModelParamsSlider
|
||||
title="Max. Reasoning Tokens"
|
||||
modelParamsKey="maxReasoningTokens"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.maxReasoningTokens.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.maxReasoningTokens.value}
|
||||
min={0}
|
||||
max={24576}
|
||||
step={1}
|
||||
tooltip="Maximum tokens for model thinking/reasoning. Set to 0 to disable. Only supported on Gemini 2.5+ models."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
)}
|
||||
<ProviderOptionsInput
|
||||
value={modelParams.providerOptions.value}
|
||||
formDisabled={formDisabled}
|
||||
@@ -501,6 +486,7 @@ const ProviderOptionsInput = ({
|
||||
}}
|
||||
editable={enabled && !formDisabled}
|
||||
mode="json"
|
||||
minHeight="none"
|
||||
lineNumbers={false}
|
||||
/>
|
||||
{error && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { Skeleton } from "@hanzo/ui";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
|
||||
interface NoDataOrLoadingProps {
|
||||
isLoading: boolean;
|
||||
@@ -19,11 +19,11 @@ const NoData = ({ noDataText = "No data", children, className }: NoDataProps) =>
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-3/4 min-h-[9rem] w-full items-center justify-center rounded-md border border-dashed",
|
||||
"flex h-3/4 min-h-[9rem] w-full items-center justify-center rounded-tremor-default border border-dashed",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-muted-foreground">{noDataText}</p>
|
||||
<p className="text-tremor-content">{noDataText}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -32,7 +32,9 @@ const NoData = ({ noDataText = "No data", children, className }: NoDataProps) =>
|
||||
export function NoDataOrLoading({ isLoading, description, href, className }: NoDataOrLoadingProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("flex h-3/4 min-h-[9rem] w-full items-center justify-center rounded-md", className)}>
|
||||
<div
|
||||
className={cn("flex h-3/4 min-h-[9rem] w-full items-center justify-center rounded-tremor-default", className)}
|
||||
>
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hanzo/ui";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/src/components/ui/hover-card";
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
import { PRODUCTION_LABEL, LATEST_PROMPT_LABEL } from "@hanzo/console-core";
|
||||
import { PRODUCTION_LABEL, LATEST_PROMPT_LABEL } from "@hanzo/shared";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
interface TruncatedLabelsProps {
|
||||
|
||||
@@ -14,18 +14,18 @@ import { api } from "@/src/utils/api";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { usePlan } from "@/src/features/entitlements/hooks";
|
||||
import { isSelfHostedPlan, planLabels } from "@hanzo/console-core";
|
||||
import { isSelfHostedPlan, planLabels } from "@hanzo/shared";
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
import { useConsoleCloudRegion } from "@/src/features/organizations/hooks";
|
||||
import { useHanzoCloudRegion } from "@/src/features/organizations/hooks";
|
||||
|
||||
export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
const { isConsoleCloud } = useConsoleCloudRegion();
|
||||
const { isHanzoCloud } = useHanzoCloudRegion();
|
||||
|
||||
const backgroundMigrationStatus = api.backgroundMigrations.status.useQuery(undefined, {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
enabled: !isConsoleCloud, // do not check for updates on Hanzo Cloud
|
||||
enabled: !isHanzoCloud, // do not check for updates on Hanzo Cloud
|
||||
throwOnError: false, // do not render default error message
|
||||
});
|
||||
|
||||
@@ -33,13 +33,13 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
enabled: !isConsoleCloud, // do not check for updates on Hanzo Cloud
|
||||
enabled: !isHanzoCloud, // do not check for updates on Hanzo Cloud
|
||||
throwOnError: false, // do not render default error message
|
||||
});
|
||||
|
||||
const plan = usePlan();
|
||||
|
||||
const selfHostedPlanLabel = !isConsoleCloud
|
||||
const selfHostedPlanLabel = !isHanzoCloud
|
||||
? plan && isSelfHostedPlan(plan)
|
||||
? // self-host plan
|
||||
// TODO: clean up to use planLabels in packages/shared/src/features/entitlements/plans.ts
|
||||
@@ -56,9 +56,9 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
null;
|
||||
|
||||
const showBackgroundMigrationStatus =
|
||||
!isConsoleCloud && backgroundMigrationStatus.data && backgroundMigrationStatus.data.status !== "FINISHED";
|
||||
!isHanzoCloud && backgroundMigrationStatus.data && backgroundMigrationStatus.data.status !== "FINISHED";
|
||||
|
||||
const hasUpdate = !isConsoleCloud && checkUpdate.data && checkUpdate.data.updateType;
|
||||
const hasUpdate = !isHanzoCloud && checkUpdate.data && checkUpdate.data.updateType;
|
||||
|
||||
const color =
|
||||
checkUpdate.data?.updateType === "major"
|
||||
@@ -91,7 +91,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : !isConsoleCloud ? (
|
||||
) : !isHanzoCloud ? (
|
||||
<>
|
||||
<DropdownMenuLabel>This is the latest release</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -112,7 +112,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
Releases
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!isConsoleCloud && (
|
||||
{!isHanzoCloud && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/background-migrations">
|
||||
<ArrowUp10 size={16} className="mr-2" />
|
||||
@@ -139,7 +139,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
Roadmap
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!isConsoleCloud && (
|
||||
{!isHanzoCloud && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="https://hanzo.ai/pricing-self-host" target="_blank">
|
||||
<Info size={16} className="mr-2" />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Calendar as CalendarIcon, X, ChevronDown } from "lucide-react";
|
||||
import { addMinutes } from "date-fns";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Calendar } from "@/src/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@hanzo/ui";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type DateRange as RDPDateRange } from "react-day-picker";
|
||||
import { format } from "date-fns";
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { useEntitlementLimit } from "@/src/features/entitlements/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hanzo/ui";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/src/components/ui/hover-card";
|
||||
import { HoverCardPortal } from "@radix-ui/react-hover-card";
|
||||
|
||||
type BaseDateRangeDropdownProps<T> = {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@hanzo/ui";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { LockIcon, TrashIcon } from "lucide-react";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type ProjectScope } from "@/src/features/rbac/constants/projectAccessRights";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { Label } from "@hanzo/ui";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
|
||||
@@ -28,8 +28,8 @@ type BaseDeleteButtonProps = Omit<DeleteButtonProps, "itemId"> & {
|
||||
variant?: "outline" | "ghost";
|
||||
scope: NonNullable<DeleteButtonProps["scope"]>;
|
||||
invalidateFunc: NonNullable<DeleteButtonProps["invalidateFunc"]>;
|
||||
captureDeleteOpen: (capture: ReturnType<typeof useInsightsCapture>, isTableAction: boolean) => void;
|
||||
captureDeleteSuccess: (capture: ReturnType<typeof useInsightsCapture>, isTableAction: boolean) => void;
|
||||
captureDeleteOpen: (capture: ReturnType<typeof usePostHogClientCapture>, isTableAction: boolean) => void;
|
||||
captureDeleteSuccess: (capture: ReturnType<typeof usePostHogClientCapture>, isTableAction: boolean) => void;
|
||||
entityToDeleteName: string;
|
||||
customDeletePrompt?: string;
|
||||
executeDeleteMutation: (onSuccess: () => void) => Promise<void>;
|
||||
@@ -57,7 +57,7 @@ export function DeleteButton({
|
||||
}: BaseDeleteButtonProps) {
|
||||
const [isDeleted, setIsDeleted] = useState(false);
|
||||
const router = useRouter();
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const [deleteConfirmationInput, setDeleteConfirmationInput] = useState("");
|
||||
|
||||
const hasAccess = useHasProjectAccess({ projectId, scope: scope });
|
||||
@@ -92,12 +92,12 @@ export function DeleteButton({
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<PopoverContent onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
|
||||
<p className="mb-3 max-w-72 text-sm">
|
||||
{customDeletePrompt ??
|
||||
`This action cannot be undone. It removes all the data associated with
|
||||
this ${entityToDeleteName}. If this is the project default, it will be deleted for all users.`}
|
||||
`This action cannot be undone and removes all the data associated with
|
||||
this ${entityToDeleteName}.`}
|
||||
</p>
|
||||
{deleteConfirmation && (
|
||||
<div className="mb-4 grid w-full gap-1.5">
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
UNCLOSED_VARIABLE_REGEX,
|
||||
PromptDependencyRegex,
|
||||
parsePromptDependencyTags,
|
||||
} from "@hanzo/console-core";
|
||||
} from "@hanzo/shared";
|
||||
import { lightTheme } from "@/src/components/editor/light-theme";
|
||||
import { darkTheme } from "@/src/components/editor/dark-theme";
|
||||
|
||||
@@ -168,7 +168,6 @@ export function CodeMirrorEditor({
|
||||
onBlur,
|
||||
mode,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
placeholder,
|
||||
editorRef,
|
||||
}: {
|
||||
@@ -180,13 +179,13 @@ export function CodeMirrorEditor({
|
||||
lineWrapping?: boolean;
|
||||
className?: string;
|
||||
mode: "json" | "text" | "prompt";
|
||||
minHeight?: number | string;
|
||||
maxHeight?: number | string;
|
||||
minHeight: "none" | 30 | 100 | 200;
|
||||
placeholder?: string;
|
||||
editorRef?: React.RefObject<ReactCodeMirrorRef | null>;
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const codeMirrorTheme = resolvedTheme === "dark" ? darkTheme : lightTheme;
|
||||
|
||||
// used to disable linter when field is empty
|
||||
const [linterEnabled, setLinterEnabled] = useState<boolean>(!!value && value !== "");
|
||||
|
||||
@@ -225,26 +224,14 @@ export function CodeMirrorEditor({
|
||||
]),
|
||||
// Extend gutter to full height when minHeight > content height
|
||||
// This also enlarges the text area to minHeight
|
||||
...(!!minHeight
|
||||
? [
|
||||
...(minHeight === "none"
|
||||
? []
|
||||
: [
|
||||
EditorView.theme({
|
||||
".cm-gutter,.cm-content": {
|
||||
minHeight: typeof minHeight === "number" ? `${minHeight}px` : minHeight,
|
||||
},
|
||||
".cm-gutter,.cm-content": { minHeight: `${minHeight}px` },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// Add max height support for very long bodies of text
|
||||
...(!!maxHeight
|
||||
? [
|
||||
EditorView.theme({
|
||||
".cm-scroller": {
|
||||
maxHeight: typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
...(mode === "json" ? [json()] : []),
|
||||
...(mode === "json" && linterEnabled ? [linter(jsonParseLinter())] : []),
|
||||
...(mode === "prompt" ? [promptSupport, promptLinter] : []),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hanzo/ui";
|
||||
import { type LastUserScore, type ScoreDomain } from "@hanzo/console-core";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/src/components/ui/hover-card";
|
||||
import { type LastUserScore, type ScoreDomain } from "@hanzo/shared";
|
||||
import { BracesIcon, MessageCircleMoreIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -6,38 +6,17 @@
|
||||
* Extracted from original layout.tsx (lines 414-493)
|
||||
*/
|
||||
|
||||
import { type PropsWithChildren, useRef, useLayoutEffect } from "react";
|
||||
import { useIsMobile } from "@/src/hooks/use-mobile";
|
||||
import { useSupportDrawer } from "@/src/features/support-chat/SupportDrawerProvider";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const MobileLayout = dynamic(
|
||||
() =>
|
||||
import("@/src/components/layouts/MobileDrawer").then((mod) => ({
|
||||
default: mod.MobileDrawer,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
const DesktopLayout = dynamic(
|
||||
() =>
|
||||
import("@/src/components/layouts/ResizableDesktopLayout").then((mod) => ({
|
||||
default: mod.ResizableDesktopLayout,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
const SupportDrawer = dynamic(
|
||||
() =>
|
||||
import("@/src/features/support-chat/SupportDrawer").then((mod) => ({
|
||||
default: mod.SupportDrawer,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
import { SupportDrawer } from "@/src/features/support-chat/SupportDrawer";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
type ImperativePanelHandle,
|
||||
} from "@/src/components/ui/resizable";
|
||||
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/src/components/ui/drawer";
|
||||
|
||||
/**
|
||||
* Resizable content for support drawer on the right side of the screen (desktop).
|
||||
@@ -47,22 +26,77 @@ const SupportDrawer = dynamic(
|
||||
* remounting children when drawer opens/closes. Uses refs for programmatic control.
|
||||
*/
|
||||
export function ResizableContent({ children }: PropsWithChildren) {
|
||||
const isDesktop = useMediaQuery({ query: "(min-width: 768px)" });
|
||||
const { open } = useSupportDrawer();
|
||||
const { open, setOpen } = useSupportDrawer();
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = !isMobile;
|
||||
|
||||
// 👉 DESKTOP: Always render ResizablePanelGroup to prevent remounting children
|
||||
// Use refs to programmatically control panel sizes when drawer opens/closes
|
||||
const drawerPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const mainPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
|
||||
if (open) {
|
||||
// Open drawer: resize main to 70%, drawer to 30%
|
||||
drawerPanelRef.current?.resize(30);
|
||||
mainPanelRef.current?.resize(70);
|
||||
} else {
|
||||
// Close drawer: resize main to 100%, drawer to 0%
|
||||
drawerPanelRef.current?.resize(0);
|
||||
mainPanelRef.current?.resize(100);
|
||||
}
|
||||
}, [open, isDesktop]);
|
||||
|
||||
// 👉 MOBILE: Render bottom drawer
|
||||
if (!isDesktop) {
|
||||
return <MobileLayout>{children}</MobileLayout>;
|
||||
return (
|
||||
<>
|
||||
<main className="h-full flex-1">{children}</main>
|
||||
|
||||
<Drawer open={open} onOpenChange={setOpen} forceDirection="bottom">
|
||||
<DrawerContent
|
||||
id="support-drawer"
|
||||
className="inset-x-0 bottom-0 top-[calc(var(--banner-offset)+10px)] min-h-screen-with-banner"
|
||||
size="full"
|
||||
>
|
||||
<DrawerHeader className="absolute inset-x-0 top-0 p-0 text-left">
|
||||
<div className="flex w-full items-center justify-center pt-3">
|
||||
<div className="h-2 w-20 rounded-full bg-muted" />
|
||||
</div>
|
||||
{/* sr-only for screen readers and accessibility */}
|
||||
<DrawerTitle className="sr-only">Support</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
A list of resources and options to help you with your questions.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="mt-4 max-h-full">
|
||||
<SupportDrawer showCloseButton={false} className="h-full pb-20" />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 👉 DESKTOP: Always render ResizablePanelGroup to prevent remounting children
|
||||
return (
|
||||
<DesktopLayout
|
||||
mainContent={children}
|
||||
sidebarContent={<SupportDrawer />}
|
||||
open={open}
|
||||
defaultMainSize={70}
|
||||
defaultSidebarSize={30}
|
||||
minMainSize={30}
|
||||
maxSidebarSize={60}
|
||||
/>
|
||||
<ResizablePanelGroup direction="horizontal" className="flex h-full w-full">
|
||||
<ResizablePanel ref={mainPanelRef} defaultSize={100} minSize={30}>
|
||||
<main className="relative h-full w-full overflow-scroll">{children}</main>
|
||||
</ResizablePanel>
|
||||
{open && <ResizableHandle withHandle />}
|
||||
<ResizablePanel
|
||||
ref={drawerPanelRef}
|
||||
defaultSize={0}
|
||||
minSize={0}
|
||||
maxSize={60}
|
||||
collapsible={true}
|
||||
collapsedSize={0}
|
||||
>
|
||||
{open && <SupportDrawer />}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useMemo } from "react";
|
||||
import type { Session, User } from "next-auth";
|
||||
import { useEntitlements } from "@/src/features/entitlements/hooks";
|
||||
import { useUiCustomization } from "@/src/features/ui-customization/useUiCustomization";
|
||||
import { useConsoleCloudRegion } from "@/src/features/organizations/hooks";
|
||||
import { useHanzoCloudRegion } from "@/src/features/organizations/hooks";
|
||||
import { ROUTES, RouteSection, RouteGroup, type Route } from "@/src/components/layouts/routes";
|
||||
import type { NavigationItem } from "@/src/components/layouts/utilities/routes";
|
||||
import { applyNavigationFilters } from "../utils/navigationFilters";
|
||||
@@ -47,9 +47,6 @@ function groupNavigationItems(items: NavigationItem[]): GroupedNavigation {
|
||||
...(grouped[RouteGroup.Observability] || []),
|
||||
...(grouped[RouteGroup.PromptManagement] || []),
|
||||
...(grouped[RouteGroup.Evaluation] || []),
|
||||
...(grouped[RouteGroup.Agents] || []),
|
||||
...(grouped[RouteGroup.KMS] || []),
|
||||
...(grouped[RouteGroup.Base] || []),
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -78,7 +75,7 @@ export function useFilteredNavigation(session: Session | null, organization: Org
|
||||
const router = useRouter();
|
||||
const entitlements = useEntitlements();
|
||||
const uiCustomization = useUiCustomization();
|
||||
const { isConsoleCloud, region } = useConsoleCloudRegion();
|
||||
const { isHanzoCloud, region } = useHanzoCloudRegion();
|
||||
|
||||
const routerProjectId = router.query.projectId as string | undefined;
|
||||
const routerOrganizationId = router.query.organizationId as string | undefined;
|
||||
@@ -90,10 +87,9 @@ export function useFilteredNavigation(session: Session | null, organization: Org
|
||||
routerOrganizationId,
|
||||
session,
|
||||
enableExperimentalFeatures: session?.environment?.enableExperimentalFeatures ?? false,
|
||||
cloudAdmin: Boolean(session?.user?.admin && isConsoleCloud && region !== "DEV"),
|
||||
cloudAdmin: Boolean(session?.user?.admin && isHanzoCloud && region !== "DEV"),
|
||||
entitlements,
|
||||
uiCustomization,
|
||||
isConsoleCloud,
|
||||
currentPath: router.asPath,
|
||||
}),
|
||||
[
|
||||
@@ -103,7 +99,7 @@ export function useFilteredNavigation(session: Session | null, organization: Org
|
||||
entitlements,
|
||||
uiCustomization,
|
||||
router.asPath,
|
||||
isConsoleCloud,
|
||||
isHanzoCloud,
|
||||
region,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useConsoleCloudRegion } from "@/src/features/organizations/hooks";
|
||||
import { useHanzoCloudRegion } from "@/src/features/organizations/hooks";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import type { NavigationItem } from "@/src/components/layouts/utilities/routes";
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { NavigationItem } from "@/src/components/layouts/utilities/routes";
|
||||
* @returns Metadata object with title and icon paths
|
||||
*/
|
||||
export function useLayoutMetadata(activePathName: string | undefined, _navigation: NavigationItem[]) {
|
||||
const { region } = useConsoleCloudRegion();
|
||||
const { region } = useHanzoCloudRegion();
|
||||
|
||||
return useMemo(() => {
|
||||
const basePath = env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
|
||||
@@ -59,23 +59,14 @@ export const filters = {
|
||||
* - Experimental features enabled
|
||||
* - User is cloud admin
|
||||
* - User has specific feature flag
|
||||
* - For v4Beta: also show when user email ends with @hanzo.ai, feature flag is set and we are in a cloud environment
|
||||
*/
|
||||
featureFlags: (route: Route, ctx: NavigationFilterContext): Route | null => {
|
||||
if (route.featureFlag === undefined) return route;
|
||||
|
||||
const hasFlag =
|
||||
ctx.enableExperimentalFeatures || ctx.cloudAdmin || ctx.session?.user?.featureFlags?.[route.featureFlag] === true;
|
||||
// TODO: remove when v4 beta is GA
|
||||
// v4 beta toggle special cases
|
||||
const isV4BetaRoute = route.featureFlag === "v4BetaToggleVisible";
|
||||
const isConsoleTeam = ctx.isConsoleCloud && ctx.session?.user?.email?.endsWith("@hanzo.ai") === true;
|
||||
const hasCloudFlag = ctx.isConsoleCloud && ctx.session?.user?.featureFlags?.[route.featureFlag] === true;
|
||||
// ungated: opted-in users must see toggle to turn it off regardless of env
|
||||
const hasOptedIn = ctx.session?.user?.v4BetaEnabled === true;
|
||||
const isV4BetaVisible = isV4BetaRoute && (isConsoleTeam || hasOptedIn || hasCloudFlag);
|
||||
|
||||
return hasFlag || isV4BetaVisible ? route : null;
|
||||
return hasFlag ? route : null;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,62 +8,14 @@ import type { PropsWithChildren } from "react";
|
||||
import Head from "next/head";
|
||||
import { SidebarProvider, SidebarInset } from "@/src/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/src/components/nav/app-sidebar";
|
||||
import { CommandMenu } from "@/src/features/command-k-menu/CommandMenu";
|
||||
import { Toaster } from "@/src/components/ui/sonner";
|
||||
import { PaymentBannerProvider } from "@/src/features/payment-banner";
|
||||
import { PaymentBanner, PaymentBannerProvider } from "@/src/features/payment-banner";
|
||||
import { ResizableContent } from "../components/ResizableContent";
|
||||
import { ThemeToggle } from "@/src/features/theming/ThemeToggle";
|
||||
import type { Session } from "next-auth";
|
||||
import type { NavigationItem } from "@/src/components/layouts/utilities/routes";
|
||||
import type { RouteGroup } from "@/src/components/layouts/routes";
|
||||
import dynamic from "next/dynamic";
|
||||
// Re-export wrapper that bypasses webpack CJS static analysis issue
|
||||
import { HanzoHeader, useHanzoAuth } from "@/src/components/nav/hanzo-header";
|
||||
|
||||
const CommandMenu = dynamic(
|
||||
() =>
|
||||
import("@/src/features/command-k-menu/CommandMenu").then((mod) => ({
|
||||
default: mod.CommandMenu,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const PaymentBanner = dynamic(
|
||||
() =>
|
||||
import("@/src/features/payment-banner").then((mod) => ({
|
||||
default: mod.PaymentBanner,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const FirstLoginBillingModal = dynamic(
|
||||
() =>
|
||||
import("@/src/features/billing/components/FirstLoginBillingModal").then((mod) => ({
|
||||
default: mod.FirstLoginBillingModal,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
function ConsoleHanzoHeader({ onSignOut }: { onSignOut: () => void }) {
|
||||
const { user, organizations, currentOrgId, switchOrg } = useHanzoAuth();
|
||||
|
||||
return (
|
||||
<HanzoHeader
|
||||
currentApp="Console"
|
||||
currentAppId="console"
|
||||
user={user}
|
||||
organizations={organizations}
|
||||
currentOrgId={currentOrgId}
|
||||
onOrgSwitch={switchOrg}
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grouped navigation structure returned by processNavigation */
|
||||
type GroupedNavigation = {
|
||||
@@ -135,7 +87,6 @@ export function AuthenticatedLayout({ children, session, navigation, metadata, o
|
||||
<PaymentBanner />
|
||||
<div className="flex min-h-0 flex-1 pt-banner-offset">
|
||||
<AppSidebar
|
||||
session={session}
|
||||
navItems={navigation.mainNavigation}
|
||||
secondaryNavItems={navigation.secondaryNavigation}
|
||||
userNavProps={userNavProps}
|
||||
@@ -144,7 +95,6 @@ export function AuthenticatedLayout({ children, session, navigation, metadata, o
|
||||
<ResizableContent>{children}</ResizableContent>
|
||||
<Toaster visibleToasts={1} />
|
||||
<CommandMenu mainNavigation={navigation.navigation} />
|
||||
<FirstLoginBillingModal />
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from "@hanzo/ui";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/src/components/ui/breadcrumb";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -15,7 +21,7 @@ import { ChevronDownIcon, LoaderCircle, PlusIcon, Settings, Slash } from "lucide
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useHasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
|
||||
import { createOrganizationRoute, createProjectRoute } from "@/src/features/setup/setupRoutes";
|
||||
import { isCloudPlan, planLabels } from "@hanzo/console-core";
|
||||
import { isCloudPlan, planLabels } from "@hanzo/shared";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment } from "react";
|
||||
@@ -79,7 +85,7 @@ const BreadcrumbComponent = ({
|
||||
{organization?.name ?? "Organization"}
|
||||
{isCloudPlan(organization?.plan) && organization.id !== env.NEXT_PUBLIC_DEMO_ORG_ID && (
|
||||
<Badge className="ml-1 px-1 py-0 text-xs font-normal" variant="secondary">
|
||||
{planLabels[organization.plan as keyof typeof planLabels]}
|
||||
{planLabels[organization.plan]}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hanzo/ui";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/src/components/ui/hover-card";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { Portal } from "@radix-ui/react-hover-card";
|
||||
import { Info } from "lucide-react";
|
||||
@@ -11,12 +11,12 @@ export type DocPopupProps = {
|
||||
};
|
||||
|
||||
export default function DocPopup({ description, href, className }: DocPopupProps) {
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
openDelay={200}
|
||||
onOpenChange={(open: boolean) => {
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
capture("help_popup:opened", {
|
||||
hfref: href,
|
||||
@@ -44,9 +44,13 @@ export default function DocPopup({ description, href, className }: DocPopupProps
|
||||
</HoverCardTrigger>
|
||||
<Portal>
|
||||
<HoverCardContent>
|
||||
<div className={cn("whitespace-break-spaces text-xs font-normal text-primary sm:pl-0", className)}>
|
||||
{description}
|
||||
</div>
|
||||
{typeof description === "string" ? (
|
||||
<div className={cn("whitespace-break-spaces text-xs font-normal text-primary sm:pl-0", className)}>
|
||||
{description}
|
||||
</div>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</Portal>
|
||||
</HoverCard>
|
||||
@@ -65,7 +69,11 @@ export function Popup({ triggerContent, description }: PopupProps) {
|
||||
<div>{triggerContent}</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="whitespace-break-spaces text-xs font-normal text-primary sm:pl-0">{description}</div>
|
||||
{typeof description === "string" ? (
|
||||
<div className="whitespace-break-spaces text-xs font-normal text-primary sm:pl-0">{description}</div>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import { EnvLabel } from "@/src/components/EnvLabel";
|
||||
import { ItemBadge, type ConsoleItemType } from "@/src/components/ItemBadge";
|
||||
import { ItemBadge, type HanzoItemType } from "@/src/components/ItemBadge";
|
||||
import BreadcrumbComponent from "@/src/components/layouts/breadcrumb";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { SidebarTrigger } from "@/src/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hanzo/ui";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ParsedUrlQuery } from "querystring";
|
||||
import { type ReactNode } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/src/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { useCommandMenu } from "@/src/features/command-k-menu/CommandMenuProvider";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
type TabDefinition = {
|
||||
value: string;
|
||||
@@ -42,9 +31,9 @@ export type PageHeaderProps = {
|
||||
breadcrumb?: { name: string; href?: string }[];
|
||||
actionButtonsLeft?: React.ReactNode; // Right-side actions (buttons, etc.)
|
||||
actionButtonsRight?: React.ReactNode; // Right-side actions (buttons, etc.)
|
||||
help?: { description: React.ReactNode; href?: string; className?: string };
|
||||
help?: { description: string; href?: string; className?: string };
|
||||
titleTooltip?: string;
|
||||
itemType?: ConsoleItemType;
|
||||
itemType?: HanzoItemType;
|
||||
container?: boolean;
|
||||
tabsProps?: PageTabsProps;
|
||||
className?: string;
|
||||
@@ -77,7 +66,7 @@ const PageHeader = ({
|
||||
id="page-header"
|
||||
>
|
||||
<div className="flex flex-col justify-center">
|
||||
{/* Top Row — unified header bar */}
|
||||
{/* Top Row */}
|
||||
<div className="border-b">
|
||||
<div className={cn("flex min-h-11 items-center gap-3 px-3 py-2", container && "lg:container")}>
|
||||
{showSidebarTrigger ? (
|
||||
@@ -88,14 +77,10 @@ const PageHeader = ({
|
||||
<div>
|
||||
<EnvLabel />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BreadcrumbComponent items={breadcrumb} />
|
||||
{breadcrumbBadges}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<HeaderCommandK />
|
||||
<HeaderAccountMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -193,90 +178,4 @@ const PageHeader = ({
|
||||
);
|
||||
};
|
||||
|
||||
function HeaderCommandK() {
|
||||
let cmdMenu: { setOpen: (open: boolean) => void } | undefined;
|
||||
try {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
cmdMenu = useCommandMenu();
|
||||
} catch {
|
||||
// CommandMenuProvider not available
|
||||
}
|
||||
|
||||
if (!cmdMenu) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cmdMenu!.setOpen(true)}
|
||||
className="flex h-8 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm text-muted-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
<kbd className="pointer-events-none ml-1 hidden h-5 select-none items-center gap-0.5 rounded border px-1.5 font-mono text-[10px] sm:inline-flex">
|
||||
<span className="text-[11px]">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderAccountMenu() {
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
if (!user) return null;
|
||||
|
||||
const initials = (user.name ?? user.email ?? "?")
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((w: string) => w[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full ring-1 ring-border transition-colors hover:ring-foreground/20"
|
||||
>
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage src={user.image ?? ""} alt={user.name ?? ""} />
|
||||
<AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="text-sm font-medium">{user.name ?? "User"}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/account/settings">Account Settings</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="https://billing.hanzo.ai" target="_blank" rel="noopener noreferrer">
|
||||
Billing
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="https://hanzo.chat" target="_blank" rel="noopener noreferrer">
|
||||
Chat
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="https://cloud.hanzo.ai" target="_blank" rel="noopener noreferrer">
|
||||
Cloud
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="https://platform.hanzo.ai" target="_blank" rel="noopener noreferrer">
|
||||
Platform
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHeader;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import * as React from "react";
|
||||
import { NavMain, type NavMainItem } from "@/src/components/nav/nav-main";
|
||||
import { NavUser, type UserNavigationProps } from "@/src/components/nav/nav-user";
|
||||
import { OrgProjectSwitcher } from "@/src/components/nav/org-project-switcher";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -24,13 +23,9 @@ import { HanzoLogo } from "@/src/components/HanzoLogo";
|
||||
import { SidebarNotifications } from "@/src/components/nav/sidebar-notifications";
|
||||
import { type RouteGroup } from "@/src/components/layouts/routes";
|
||||
import { ExternalLink, Grid2X2 } from "lucide-react";
|
||||
import { useConsoleCloudRegion } from "@/src/features/organizations/hooks";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
// Cross-app links now live in <HanzoHeader> (see AuthenticatedLayout)
|
||||
import { useHanzoCloudRegion } from "@/src/features/organizations/hooks";
|
||||
|
||||
type AppSidebarProps = {
|
||||
session?: Session;
|
||||
navItems: {
|
||||
grouped: Partial<Record<RouteGroup, NavMainItem[]>> | null;
|
||||
ungrouped: NavMainItem[];
|
||||
@@ -42,28 +37,14 @@ type AppSidebarProps = {
|
||||
userNavProps: UserNavigationProps;
|
||||
} & React.ComponentProps<typeof Sidebar>;
|
||||
|
||||
export function AppSidebar({ session, navItems, secondaryNavItems, userNavProps, ...props }: AppSidebarProps) {
|
||||
const router = useRouter();
|
||||
const routerProjectId = router.query.projectId as string | undefined;
|
||||
|
||||
// Derive current org from the project ID in the URL
|
||||
const organizations = session?.user?.organizations ?? [];
|
||||
const currentOrg = organizations.find((org) => org.projects.some((p) => p.id === routerProjectId));
|
||||
|
||||
export function AppSidebar({ navItems, secondaryNavItems, userNavProps, ...props }: AppSidebarProps) {
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="sidebar" {...props}>
|
||||
<SidebarHeader>
|
||||
<div className="flex min-h-9 items-center gap-2 py-2 pl-2 pr-1 group-data-[collapsible=icon]:p-3">
|
||||
<div className="flex min-h-9 items-center gap-2 py-2 pl-2 pr-0 group-data-[collapsible=icon]:p-3">
|
||||
<HanzoLogo version />
|
||||
</div>
|
||||
<div className="h-1 flex-1 border-b" />
|
||||
{organizations.length > 0 && (
|
||||
<OrgProjectSwitcher
|
||||
organizations={organizations}
|
||||
currentOrgId={currentOrg?.id}
|
||||
currentProjectId={routerProjectId}
|
||||
/>
|
||||
)}
|
||||
<DemoBadge />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
@@ -84,7 +65,7 @@ export function AppSidebar({ session, navItems, secondaryNavItems, userNavProps,
|
||||
|
||||
const DemoBadge = () => {
|
||||
const router = useRouter();
|
||||
const { isConsoleCloud } = useConsoleCloudRegion();
|
||||
const { isHanzoCloud } = useHanzoCloudRegion();
|
||||
const routerProjectId = router.query.projectId as string | undefined;
|
||||
|
||||
if (
|
||||
@@ -92,7 +73,7 @@ const DemoBadge = () => {
|
||||
env.NEXT_PUBLIC_DEMO_ORG_ID &&
|
||||
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
isConsoleCloud
|
||||
isHanzoCloud
|
||||
)
|
||||
)
|
||||
return null;
|
||||
@@ -104,7 +85,7 @@ const DemoBadge = () => {
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Use Demo App to create traces" variant="cta">
|
||||
<Link href="https://hanzo.ai/docs/demo" target="_blank" rel="noopener noreferrer">
|
||||
<Link href="https://hanzo.com/docs/demo" target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span>Use Demo App</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/src/components/ui/card";
|
||||
import { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import useLocalStorage from "../useLocalStorage";
|
||||
import Link from "next/link";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
|
||||
const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const NOTIFICATION_TTL_MS = 14 * 24 * 60 * 60 * 1000; // two weeks
|
||||
|
||||
type SidebarNotification = {
|
||||
id: string; // Add unique ID for each notification
|
||||
@@ -16,100 +17,107 @@ type SidebarNotification = {
|
||||
// defaults to "Learn more" if no linkContent and no linkTitle
|
||||
linkTitle?: string;
|
||||
linkContent?: React.ReactNode;
|
||||
// Time-to-live in milliseconds from createdAt. Defaults to TWO_WEEKS_MS.
|
||||
ttlMs?: number;
|
||||
};
|
||||
|
||||
const notifications: SidebarNotification[] = [
|
||||
{
|
||||
id: "join-discord",
|
||||
title: "Join our Discord",
|
||||
description: "Connect with the community, get help, and share feedback",
|
||||
link: "https://discord.gg/hanzo",
|
||||
linkTitle: "Join Discord",
|
||||
id: "github-star",
|
||||
title: "Star HanzoCloud",
|
||||
description: "See the latest releases and help grow the community on GitHub",
|
||||
link: "https://github.com/hanzoai/cloud",
|
||||
linkContent: (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt="Hanzo Console GitHub stars"
|
||||
src="https://img.shields.io/github/stars/hanzoai/console?label=hanzo&style=social"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "dismissed-sidebar-notifications";
|
||||
|
||||
export function SidebarNotifications() {
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const [currentNotificationIndex, setCurrentNotificationIndex] = useState<number | null>(null);
|
||||
|
||||
const [dismissedNotifications, setDismissedNotifications] = useLocalStorage<string[]>(STORAGE_KEY, []);
|
||||
|
||||
const isExpired = (notif: SidebarNotification) => {
|
||||
if (!notif.createdAt) return false;
|
||||
const created = new Date(notif.createdAt).getTime();
|
||||
const ttl = notif.ttlMs ?? TWO_WEEKS_MS;
|
||||
return Date.now() > created + ttl;
|
||||
return Date.now() > created + NOTIFICATION_TTL_MS;
|
||||
};
|
||||
|
||||
// Find the oldest non-dismissed notification on mount or when dismissed list changes
|
||||
useEffect(() => {
|
||||
const firstAvailableIndex = notifications.findIndex(
|
||||
(notif) => !dismissedNotifications.includes(notif.id) && !isExpired(notif),
|
||||
);
|
||||
|
||||
setCurrentNotificationIndex(firstAvailableIndex === -1 ? null : firstAvailableIndex);
|
||||
}, [dismissedNotifications]);
|
||||
|
||||
const dismissNotification = (id: string) => {
|
||||
setDismissedNotifications([...dismissedNotifications, id]);
|
||||
};
|
||||
|
||||
const activeNotifications = notifications.filter(
|
||||
(notif) => !dismissedNotifications.includes(notif.id) && !isExpired(notif),
|
||||
);
|
||||
|
||||
if (activeNotifications.length === 0) {
|
||||
if (currentNotificationIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentNotification = notifications[currentNotificationIndex];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 group-data-[collapsible=icon]:hidden">
|
||||
{activeNotifications.map((notification) => (
|
||||
<Card key={notification.id} className="relative max-h-60 overflow-hidden rounded-md bg-opacity-50 shadow-none">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1.5 top-2.5 h-5 w-5 p-0"
|
||||
onClick={() => {
|
||||
capture("notification:dismiss_notification", {
|
||||
notification_id: notification.id,
|
||||
});
|
||||
dismissNotification(notification.id);
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<CardHeader className="px-3 pb-0 pr-6 pt-2.5">
|
||||
<CardTitle className="text-sm">{notification.title}</CardTitle>
|
||||
<CardDescription className="mt-1">{notification.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-2.5 pt-1.5">
|
||||
{notification.link &&
|
||||
(notification.linkContent ? (
|
||||
<Link
|
||||
href={notification.link}
|
||||
target="_blank"
|
||||
onClick={() => {
|
||||
capture("notification:click_link", {
|
||||
notification_id: notification.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{notification.linkContent}
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" className="w-full" asChild>
|
||||
<Link
|
||||
href={notification.link}
|
||||
target="_blank"
|
||||
onClick={() => {
|
||||
capture("notification:click_link", {
|
||||
notification_id: notification.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{notification.linkTitle ?? "Learn more"} →
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card className="relative max-h-60 overflow-hidden rounded-md bg-opacity-50 shadow-none group-data-[collapsible=icon]:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1.5 top-2.5 h-5 w-5 p-0"
|
||||
onClick={() => {
|
||||
capture("notification:dismiss_notification", {
|
||||
notification_id: currentNotification.id,
|
||||
});
|
||||
dismissNotification(currentNotification.id);
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<CardHeader className="px-3 pb-0 pr-6 pt-2.5">
|
||||
<CardTitle className="text-sm">{currentNotification.title}</CardTitle>
|
||||
<CardDescription className="mt-1">{currentNotification.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-2.5 pt-1.5">
|
||||
{currentNotification.link &&
|
||||
(currentNotification.linkContent ? (
|
||||
<Link
|
||||
href={currentNotification.link}
|
||||
target="_blank"
|
||||
onClick={() => {
|
||||
capture("notification:click_link", {
|
||||
notification_id: currentNotification.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{currentNotification.linkContent}
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" className="w-full" asChild>
|
||||
<Link
|
||||
href={currentNotification.link}
|
||||
target="_blank"
|
||||
onClick={() => {
|
||||
capture("notification:click_link", {
|
||||
notification_id: currentNotification.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{currentNotification.linkTitle ?? "Learn more"} →
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { CsvUploadDialog } from "@/src/features/datasets/components/CsvUploadDialog";
|
||||
import { NewDatasetItemForm } from "@/src/features/datasets/components/NewDatasetItemForm";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
interface DatasetItemEntryPointRowProps {
|
||||
@@ -57,7 +57,7 @@ const DatasetItemEntryPointRow = ({
|
||||
};
|
||||
|
||||
export const DatasetItemsOnboarding = ({ projectId, datasetId }: { projectId: string; datasetId: string }) => {
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
||||
const [isNewItemDialogOpen, setIsNewItemDialogOpen] = useState(false);
|
||||
|
||||
@@ -121,7 +121,7 @@ export const DatasetItemsOnboarding = ({ projectId, datasetId }: { projectId: st
|
||||
</Dialog>
|
||||
|
||||
<Link
|
||||
href="https://hanzo.ai/docs/evaluation/experiments/datasets#create-items-from-production-data"
|
||||
href="https://hanzo.com/docs/evaluation/experiments/datasets#create-items-from-production-data"
|
||||
target="_blank"
|
||||
>
|
||||
<DatasetItemEntryPointRow
|
||||
@@ -139,7 +139,7 @@ export const DatasetItemsOnboarding = ({ projectId, datasetId }: { projectId: st
|
||||
docPopup={{
|
||||
description:
|
||||
"Creating items from production data is supported on single trace level. Click to view docs for more details.",
|
||||
href: "https://hanzo.ai/docs/evaluation/experiments/datasets#create-items-from-production-data",
|
||||
href: "https://hanzo.com/docs/evaluation/experiments/datasets#create-items-from-production-data",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
import React from "react";
|
||||
import { SplashScreen } from "@/src/components/ui/splash-screen";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { SplashScreen, type ValueProposition } from "@/src/components/ui/splash-screen";
|
||||
import { BarChart4, GitMerge, MessageSquare, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function SessionsOnboarding() {
|
||||
const valuePropositions: ValueProposition[] = [
|
||||
{
|
||||
title: "Group related traces",
|
||||
description:
|
||||
"Sessions allow you to group related traces, such as a conversation or thread, for better organization and analysis",
|
||||
icon: <MessageSquare className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Track user interactions",
|
||||
description: "Monitor how users interact with your application over time",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Analyze conversation flows",
|
||||
description: "Understand the complete flow of multi-turn conversations",
|
||||
icon: <GitMerge className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Session-level metrics",
|
||||
description: "Get aggregated metrics for entire sessions, including costs and token usage",
|
||||
icon: <BarChart4 className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SplashScreen
|
||||
title="You aren't using sessions yet"
|
||||
description="Sessions let you group traces that belong to the same workflow, or conversation."
|
||||
title="Get Started with Sessions"
|
||||
description="Sessions allow you to group related traces together, such as a conversation or thread. Use sessions to track interactions over time and analyze conversation/thread flows."
|
||||
valuePropositions={valuePropositions}
|
||||
gettingStarted={
|
||||
<span>
|
||||
To start using sessions, you need to add a `sessionId` to your traces. See{" "}
|
||||
<Link href="https://hanzo.com/docs/observability/features/sessions" className="underline">
|
||||
documentation
|
||||
</Link>{" "}
|
||||
for more details.
|
||||
</span>
|
||||
}
|
||||
videoSrc="https://static.hanzo.ai/prod-assets/onboarding/sessions-overview-v1.mp4"
|
||||
>
|
||||
<div className="mt-8">
|
||||
<h3 className="mb-4 text-2xl font-semibold">Start using sessions</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
To start using sessions, you need to add a <code>sessionId</code> to your traces.
|
||||
</p>
|
||||
<ActionButton href="https://hanzo.ai/docs/observability/features/sessions" variant="default">
|
||||
Read the docs
|
||||
</ActionButton>
|
||||
</div>
|
||||
</SplashScreen>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,51 @@
|
||||
import React from "react";
|
||||
import { SplashScreen } from "@/src/components/ui/splash-screen";
|
||||
import { TracingSetup } from "@/src/pages/project/[projectId]/traces/setup";
|
||||
import { SplashScreen, type ValueProposition } from "@/src/components/ui/splash-screen";
|
||||
import { setupTracingRoute } from "@/src/features/setup/setupRoutes";
|
||||
import { BarChart4, GitMerge, Search, Zap } from "lucide-react";
|
||||
|
||||
interface TracesOnboardingProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function TracesOnboarding({ projectId }: TracesOnboardingProps) {
|
||||
const valuePropositions: ValueProposition[] = [
|
||||
{
|
||||
title: "Full context capture",
|
||||
description: "Track the complete execution flow including API calls, context, prompts, parallelism and more",
|
||||
icon: <GitMerge className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Cost monitoring",
|
||||
description: "Track model usage and costs across your application",
|
||||
icon: <BarChart4 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Basis for evaluation",
|
||||
description: "Add evaluation scores to identify issues and track metrics over time",
|
||||
icon: <Search className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Open and Multi-modal",
|
||||
description:
|
||||
"Hanzo Cloud traces can include images, audio, and other modalities. You can fully customize them to fit your needs",
|
||||
icon: <Zap className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SplashScreen
|
||||
title="You don't have any traces yet"
|
||||
description="Traces show you how your LLM calls behave in your application: what they cost, how they perform, and where things go wrong. It's the first step towards improving the behavior of your app."
|
||||
title="Get Started with LLM Tracing"
|
||||
description="Traces allow you to track every LLM call and other relevant logic in your app/agent. Nested traces in Hanzo Cloud help to understand what is happening and identify the root cause of problems."
|
||||
valuePropositions={valuePropositions}
|
||||
primaryAction={{
|
||||
label: "Configure Tracing",
|
||||
href: setupTracingRoute(projectId),
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: "View Documentation",
|
||||
href: "https://hanzo.com/docs/observability/overview",
|
||||
}}
|
||||
videoSrc="https://static.hanzo.ai/prod-assets/onboarding/tracing-overview-v1.mp4"
|
||||
>
|
||||
<div className="mt-8">
|
||||
<h3 className="mb-8 text-2xl font-semibold">Get started</h3>
|
||||
<TracingSetup projectId={projectId} hasTracingConfigured={false} />
|
||||
</div>
|
||||
</SplashScreen>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
import React from "react";
|
||||
import { SplashScreen } from "@/src/components/ui/splash-screen";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
|
||||
import { SplashScreen, type ValueProposition } from "@/src/components/ui/splash-screen";
|
||||
import { Users, LineChart, Filter, BarChart4 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
export function UsersOnboarding() {
|
||||
const valuePropositions: ValueProposition[] = [
|
||||
{
|
||||
title: "Track user interactions",
|
||||
description: "Attribute data in Hanzo Cloud to specific users by adding a userId to your traces",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Analyze user behavior",
|
||||
description: "Understand how different users interact with your LLM applications",
|
||||
icon: <LineChart className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Filter by user segments",
|
||||
description: "Compare performance across different user segments to identify patterns",
|
||||
icon: <Filter className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Monitor usage metrics",
|
||||
description: "Track token usage, costs, and other metrics on a per-user basis",
|
||||
icon: <BarChart4 className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SplashScreen
|
||||
title="You aren't tracking users yet"
|
||||
description="Once you add a user ID to your traces, you can correlate costs, evaluations and other LLM Application metrics to better understand how they interact with your LLM applications."
|
||||
title="Get Started with User Tracking"
|
||||
description="Correlate costs, evaluations and other LLM Application metrics to specific users. Start tracking users to better understand how they interact with your LLM applications."
|
||||
valuePropositions={valuePropositions}
|
||||
gettingStarted={
|
||||
<span>
|
||||
To start tracking users, you need to add a `userId` to your traces. See{" "}
|
||||
<Link href="https://hanzo.com/docs/observability/features/users" className="underline">
|
||||
documentation
|
||||
</Link>{" "}
|
||||
for more details.
|
||||
</span>
|
||||
}
|
||||
videoSrc="https://static.hanzo.ai/prod-assets/onboarding/users-overview-v1.mp4"
|
||||
>
|
||||
<div className="mt-8">
|
||||
<h3 className="mb-4 text-2xl font-semibold">Start tracking users</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
To start tracking users, you need to add a <code>userId</code> to your traces.
|
||||
</p>
|
||||
<ActionButton href="https://hanzo.ai/docs/observability/features/users" variant="default">
|
||||
Read the docs
|
||||
</ActionButton>
|
||||
</div>
|
||||
</SplashScreen>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Label } from "@hanzo/ui";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@hanzo/ui";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { copyTextToClipboard } from "@/src/utils/clipboard";
|
||||
@@ -14,7 +14,7 @@ export const PublishTraceSwitch = (props: {
|
||||
isPublic: boolean;
|
||||
size?: "icon" | "icon-xs";
|
||||
}) => {
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: props.projectId,
|
||||
scope: "objects:publish",
|
||||
@@ -50,7 +50,7 @@ export const PublishSessionSwitch = (props: {
|
||||
isPublic: boolean;
|
||||
size?: "icon" | "icon-xs";
|
||||
}) => {
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: props.projectId,
|
||||
scope: "objects:publish",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hanzo/ui";
|
||||
import { type CategoricalAggregate, type AggregatedScoreData } from "@hanzo/console-core";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/src/components/ui/hover-card";
|
||||
import { type CategoricalAggregate, type AggregatedScoreData } from "@hanzo/shared";
|
||||
|
||||
import { numberFormatter } from "@/src/utils/numbers";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
@@ -7,7 +7,7 @@ import { BracesIcon, MessageCircleMore, Copy, Check } from "lucide-react";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { api } from "@/src/utils/api";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { Skeleton } from "@hanzo/ui";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import React from "react";
|
||||
import { copyTextToClipboard } from "@/src/utils/clipboard";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
@@ -26,11 +26,11 @@ const ScoreValueCounts = ({
|
||||
wrap: boolean;
|
||||
}) => {
|
||||
return valueCounts.map(({ value, count }, index) => (
|
||||
<span key={value} className="inline-block">
|
||||
<div key={value} className="flex flex-row">
|
||||
<span className="truncate">{value}</span>
|
||||
<span>{`: ${numberFormatter(count, 0)}`}</span>
|
||||
{index < valueCounts.length - 1 && <span className="mr-1">{wrap ? "" : "; "}</span>}
|
||||
</span>
|
||||
{!wrap && index < valueCounts.length - 1 && <span className="mr-1">{";"}</span>}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -99,12 +99,9 @@ export const ScoresTableCell = ({
|
||||
<div className="group">
|
||||
{aggregate.valueCounts.length > COLLAPSE_CATEGORICAL_SCORES_AFTER ? (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<HoverCardTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer overflow-hidden group-hover:text-accent-dark-blue/55",
|
||||
wrap ? "line-clamp-5" : "text-ellipsis whitespace-nowrap",
|
||||
)}
|
||||
className={cn("flex cursor-pointer group-hover:text-accent-dark-blue/55", wrap ? "flex-col" : "flex-row")}
|
||||
>
|
||||
<ScoreValueCounts
|
||||
valueCounts={aggregate.valueCounts.slice(0, COLLAPSE_CATEGORICAL_SCORES_AFTER)}
|
||||
@@ -113,7 +110,7 @@ export const ScoresTableCell = ({
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="z-20 flex max-h-[40vh] max-w-64 flex-col overflow-y-auto whitespace-normal break-normal text-xs">
|
||||
<ScoreValueCounts valueCounts={aggregate.valueCounts} wrap />
|
||||
<ScoreValueCounts valueCounts={aggregate.valueCounts} wrap={wrap} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IOPreview } from "@/src/components/trace2/components/IOPreview/IOPrevie
|
||||
import { JsonSkeleton } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { type ListEntry, useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import { getNumberFromMap } from "@/src/utils/map-utils";
|
||||
@@ -19,18 +19,12 @@ import { Button } from "@/src/components/ui/button";
|
||||
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Download, ExternalLinkIcon } from "lucide-react";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import Page from "@/src/components/layouts/page";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@hanzo/ui";
|
||||
import { ScrollArea } from "@hanzo/ui";
|
||||
import { Label } from "@hanzo/ui";
|
||||
import {
|
||||
AnnotationQueueObjectType,
|
||||
type ColumnDefinition,
|
||||
type FilterState,
|
||||
type ScoreDomain,
|
||||
TableViewPresetTableName,
|
||||
} from "@hanzo/console-core";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover";
|
||||
import { ScrollArea } from "@/src/components/ui/scroll-area";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import { AnnotationQueueObjectType, type ScoreDomain } from "@hanzo/shared";
|
||||
import { CreateNewAnnotationQueueItem } from "@/src/features/annotation-queues/components/CreateNewAnnotationQueueItem";
|
||||
import { TablePeekView } from "@/src/components/table/peek";
|
||||
import { PeekViewTraceDetail } from "@/src/components/table/peek/peek-trace-detail";
|
||||
@@ -40,71 +34,11 @@ import { LazyTraceRow } from "@/src/components/session/TraceRow";
|
||||
import { useParsedTrace } from "@/src/hooks/useParsedTrace";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { Switch } from "@/src/components/ui/switch";
|
||||
import { LazyTraceEventsRow } from "@/src/components/session/TraceEventsRow";
|
||||
import { observationEventsFilterConfig } from "@/src/features/events/config/filter-config";
|
||||
import { useEventsFilterOptions } from "@/src/features/events/hooks/useEventsFilterOptions";
|
||||
import { decodeAndNormalizeFilters, useSidebarFilterState } from "@/src/features/filters/hooks/useSidebarFilterState";
|
||||
import { StringParam, useQueryParam, withDefault } from "use-query-params";
|
||||
import { PopoverFilterBuilder } from "@/src/features/filters/components/filter-builder";
|
||||
import { useTableViewManager } from "@/src/components/table/table-view-presets/hooks/useTableViewManager";
|
||||
import {
|
||||
TableViewPresetsDrawer,
|
||||
type SystemFilterPreset,
|
||||
SYSTEM_PRESET_ID_PREFIX,
|
||||
} from "@/src/components/table/table-view-presets/components/data-table-view-presets-drawer";
|
||||
import { Separator } from "@hanzo/ui";
|
||||
import { type VisibilityState, type ColumnOrderState } from "@tanstack/react-table";
|
||||
|
||||
// some projects have thousands of users in a session, paginate to avoid rendering all at once
|
||||
const INITIAL_USERS_DISPLAY_COUNT = 10;
|
||||
const USERS_PER_PAGE_IN_POPOVER = 50;
|
||||
|
||||
const SESSION_DETAIL_SYSTEM_PRESETS: SystemFilterPreset[] = [
|
||||
{
|
||||
id: `${SYSTEM_PRESET_ID_PREFIX}last_generation__`,
|
||||
name: "Last Generation in Trace",
|
||||
description: "Shows only the last generation in each trace",
|
||||
filters: [
|
||||
{
|
||||
column: "type",
|
||||
type: "stringOptions",
|
||||
operator: "any of",
|
||||
value: ["GENERATION"],
|
||||
},
|
||||
{
|
||||
column: "positionInTrace",
|
||||
type: "positionInTrace",
|
||||
operator: "=",
|
||||
key: "last",
|
||||
},
|
||||
] satisfies FilterState,
|
||||
},
|
||||
{
|
||||
id: `${SYSTEM_PRESET_ID_PREFIX}root_observation__`,
|
||||
name: "Root Observation",
|
||||
description: "Shows only the root observation of each trace",
|
||||
filters: [
|
||||
{
|
||||
column: "positionInTrace",
|
||||
type: "positionInTrace",
|
||||
operator: "=",
|
||||
key: "root",
|
||||
},
|
||||
] satisfies FilterState,
|
||||
},
|
||||
];
|
||||
|
||||
const areDetailPageListsEqual = (left: ListEntry[] | undefined, right: ListEntry[] | undefined) => {
|
||||
if (left === right) return true;
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
return left.every((entry, index) => {
|
||||
const other = right[index];
|
||||
if (entry.id !== other?.id) return false;
|
||||
if (!entry.params && !other?.params) return true;
|
||||
return JSON.stringify(entry.params) === JSON.stringify(other?.params);
|
||||
});
|
||||
};
|
||||
|
||||
export function SessionUsers({ projectId, users }: { projectId: string; users?: string[] }) {
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
@@ -116,12 +50,7 @@ export function SessionUsers({ projectId, users }: { projectId: string; users?:
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{initialUsers.map((userId: string) => (
|
||||
<Link
|
||||
key={userId}
|
||||
href={`/project/${projectId}/users/${encodeURIComponent(userId ?? "")}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Link key={userId} href={`/project/${projectId}/users/${encodeURIComponent(userId ?? "")}`}>
|
||||
<Badge className="max-w-[300px]">
|
||||
<span className="truncate">User ID: {userId}</span>
|
||||
<ExternalLinkIcon className="ml-1 h-3 w-3" />
|
||||
@@ -147,8 +76,6 @@ export function SessionUsers({ projectId, users }: { projectId: string; users?:
|
||||
key={userId}
|
||||
href={`/project/${projectId}/users/${encodeURIComponent(userId ?? "")}`}
|
||||
className="block hover:bg-accent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge className="max-w-[260px]">
|
||||
<span className="truncate">User ID: {userId}</span>
|
||||
@@ -200,9 +127,9 @@ export const SessionPage: React.FC<{
|
||||
projectId: string;
|
||||
}> = ({ sessionId, projectId }) => {
|
||||
const router = useRouter();
|
||||
const { setDetailPageList, detailPagelists } = useDetailPageLists();
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
const userSession = useSession();
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const utils = api.useUtils();
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const session = api.sessions.byIdWithScores.useQuery(
|
||||
@@ -281,15 +208,17 @@ export const SessionPage: React.FC<{
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!session.isSuccess) return;
|
||||
const nextList = session.data.traces.map((t) => ({
|
||||
id: t.id,
|
||||
params: { timestamp: t.timestamp.toISOString() },
|
||||
}));
|
||||
if (areDetailPageListsEqual(detailPagelists.traces, nextList)) return;
|
||||
setDetailPageList("traces", nextList);
|
||||
if (session.isSuccess) {
|
||||
setDetailPageList(
|
||||
"traces",
|
||||
session.data.traces.map((t) => ({
|
||||
id: t.id,
|
||||
params: { timestamp: t.timestamp.toISOString() },
|
||||
})),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.isSuccess, session.data, detailPagelists.traces]);
|
||||
}, [session.isSuccess, session.data]);
|
||||
|
||||
const sessionCommentCounts = api.comments.getCountByObjectId.useQuery(
|
||||
{
|
||||
@@ -473,445 +402,7 @@ export const SessionPage: React.FC<{
|
||||
expandPeek,
|
||||
resolveDetailNavigationPath,
|
||||
children: <PeekViewTraceDetail projectId={projectId} />,
|
||||
}}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export const SessionEventsPage: React.FC<{
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
}> = ({ sessionId, projectId }) => {
|
||||
const router = useRouter();
|
||||
const { setDetailPageList, detailPagelists } = useDetailPageLists();
|
||||
const userSession = useSession();
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const defaultPresetAppliedRef = useRef(false);
|
||||
|
||||
// TODO: introduce saved default views
|
||||
// Reset default preset flag when session changes (e.g., navigating between sessions)
|
||||
useEffect(() => {
|
||||
defaultPresetAppliedRef.current = false;
|
||||
}, [sessionId]);
|
||||
|
||||
const session = api.sessions.byIdWithScoresFromEvents.useQuery(
|
||||
{
|
||||
sessionId,
|
||||
projectId: projectId,
|
||||
},
|
||||
{
|
||||
retry(failureCount, error) {
|
||||
if (error.data?.code === "UNAUTHORIZED" || error.data?.code === "NOT_FOUND") return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [showCorrections, setShowCorrections] = useLocalStorage("showCorrections", false);
|
||||
|
||||
const sessionCommentCounts = api.comments.getCountByObjectId.useQuery(
|
||||
{
|
||||
projectId,
|
||||
objectId: sessionId,
|
||||
objectType: "SESSION",
|
||||
},
|
||||
{ enabled: session.isSuccess && userSession.status === "authenticated" },
|
||||
);
|
||||
|
||||
const traceCommentCounts = api.comments.getTraceCommentCountsBySessionId.useQuery(
|
||||
{
|
||||
projectId,
|
||||
sessionId,
|
||||
},
|
||||
{ enabled: session.isSuccess && userSession.status === "authenticated" },
|
||||
);
|
||||
|
||||
const tracesQuery = api.sessions.tracesFromEvents.useQuery(
|
||||
{ projectId, sessionId },
|
||||
{
|
||||
enabled: !!projectId && !!sessionId,
|
||||
retry(failureCount, error) {
|
||||
if (error.data?.code === "UNAUTHORIZED" || error.data?.code === "NOT_FOUND") return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { openPeek, closePeek, resolveDetailNavigationPath, expandPeek } = usePeekNavigation({
|
||||
expandConfig: {
|
||||
basePath: `/project/${projectId}/traces`,
|
||||
},
|
||||
queryParams: ["observation", "display", "timestamp"],
|
||||
extractParamsValuesFromRow: (row: any) => ({
|
||||
timestamp: row.timestamp.toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!tracesQuery.isSuccess) return;
|
||||
const nextList = tracesQuery.data.map((t) => ({
|
||||
id: t.id,
|
||||
params: { timestamp: t.timestamp.toISOString() },
|
||||
}));
|
||||
if (areDetailPageListsEqual(detailPagelists.traces, nextList)) return;
|
||||
setDetailPageList("traces", nextList);
|
||||
}, [tracesQuery.isSuccess, tracesQuery.data, setDetailPageList, detailPagelists.traces]);
|
||||
|
||||
// Decode time filters from URL for scoping filter options
|
||||
const [filtersQuery] = useQueryParam("filter", withDefault(StringParam, ""));
|
||||
const timeFiltersForOptions = React.useMemo(() => {
|
||||
const allFilters = decodeAndNormalizeFilters(filtersQuery, observationEventsFilterConfig.columnDefinitions);
|
||||
return allFilters.filter((f) => (f.column === "Start Time" || f.column === "startTime") && f.type === "datetime");
|
||||
}, [filtersQuery]);
|
||||
|
||||
const { filterOptions, isFilterOptionsPending } = useEventsFilterOptions({
|
||||
projectId,
|
||||
oldFilterState: timeFiltersForOptions,
|
||||
});
|
||||
|
||||
const sessionEventsFilterConfig = React.useMemo(() => {
|
||||
return {
|
||||
...observationEventsFilterConfig,
|
||||
tableName: "session-events",
|
||||
columnDefinitions: observationEventsFilterConfig.columnDefinitions,
|
||||
facets: observationEventsFilterConfig.facets
|
||||
.filter((facet) => facet.column !== "sessionId" && facet.column !== "environment")
|
||||
.map((facet) => ({
|
||||
...facet,
|
||||
// Session detail uses a different query path and should not inherit
|
||||
// events-table mutual exclusion behavior.
|
||||
mutuallyExclusiveWith: undefined,
|
||||
})),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filterColumns = React.useMemo<ColumnDefinition[]>(() => {
|
||||
const scoreCategoryOptions = filterOptions.score_categories
|
||||
? Object.entries(filterOptions.score_categories).map(([label, values]) => ({
|
||||
label,
|
||||
values,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return sessionEventsFilterConfig.columnDefinitions
|
||||
.filter(
|
||||
(column) =>
|
||||
column.id !== "sessionId" &&
|
||||
column.id !== "hasParentObservation" &&
|
||||
column.id !== "environment" &&
|
||||
column.id !== "traceId" &&
|
||||
column.id !== "traceName" &&
|
||||
column.id !== "traceTags" &&
|
||||
column.id !== "userId",
|
||||
)
|
||||
.map((column) => {
|
||||
if (column.type === "stringOptions" || column.type === "arrayOptions") {
|
||||
const optionMap: Record<string, typeof column.options | undefined> = {
|
||||
type: filterOptions.type,
|
||||
name: filterOptions.name,
|
||||
level: filterOptions.level,
|
||||
providedModelName: filterOptions.providedModelName,
|
||||
modelId: filterOptions.modelId,
|
||||
promptName: filterOptions.promptName,
|
||||
version: filterOptions.version,
|
||||
experimentDatasetId: filterOptions.experimentDatasetId,
|
||||
experimentId: filterOptions.experimentId,
|
||||
experimentName: filterOptions.experimentName,
|
||||
};
|
||||
|
||||
const options = optionMap[column.id];
|
||||
return options ? { ...column, options } : column;
|
||||
}
|
||||
|
||||
if (column.type === "categoryOptions" && column.id === "score_categories") {
|
||||
return { ...column, options: scoreCategoryOptions };
|
||||
}
|
||||
|
||||
if (column.type === "numberObject" && column.id === "scores_avg") {
|
||||
return filterOptions.scores_avg ? { ...column, keyOptions: filterOptions.scores_avg } : column;
|
||||
}
|
||||
|
||||
return column;
|
||||
});
|
||||
}, [filterOptions, sessionEventsFilterConfig.columnDefinitions]);
|
||||
|
||||
const filterColumnsWithCustomSelect = React.useMemo(
|
||||
() =>
|
||||
filterColumns
|
||||
.filter((column) => column.type === "stringOptions" || column.type === "arrayOptions")
|
||||
.map((column) => column.id),
|
||||
[filterColumns],
|
||||
);
|
||||
|
||||
const queryFilter = useSidebarFilterState(
|
||||
sessionEventsFilterConfig,
|
||||
filterOptions,
|
||||
projectId,
|
||||
isFilterOptionsPending,
|
||||
);
|
||||
|
||||
const visibleFilterState = React.useMemo(
|
||||
() =>
|
||||
queryFilter.filterState.filter(
|
||||
(filter) =>
|
||||
filter.column !== "Session ID" &&
|
||||
filter.column !== "sessionId" &&
|
||||
filter.column !== "Has Parent Observation" &&
|
||||
filter.column !== "hasParentObservation" &&
|
||||
filter.column !== "environment" &&
|
||||
filter.column !== "traceId" &&
|
||||
filter.column !== "traceName" &&
|
||||
filter.column !== "traceTags" &&
|
||||
filter.column !== "userId",
|
||||
),
|
||||
[queryFilter.filterState],
|
||||
);
|
||||
|
||||
// Stub state for Saved Views (no actual table columns in this view)
|
||||
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
const setFiltersWrapper = useCallback((filters: FilterState) => queryFilter.setFilterState(filters), [queryFilter]);
|
||||
|
||||
const { isLoading: isViewLoading, ...viewControllers } = useTableViewManager({
|
||||
tableName: TableViewPresetTableName.SessionDetail,
|
||||
projectId,
|
||||
stateUpdaters: {
|
||||
setColumnOrder,
|
||||
setColumnVisibility,
|
||||
setFilters: setFiltersWrapper,
|
||||
},
|
||||
validationContext: {
|
||||
columns: [],
|
||||
filterColumnDefinition: sessionEventsFilterConfig.columnDefinitions,
|
||||
},
|
||||
currentFilterState: queryFilter.filterState,
|
||||
});
|
||||
|
||||
const applySystemPreset = useCallback(
|
||||
(preset: SystemFilterPreset) => {
|
||||
viewControllers.handleSetViewId(preset.id);
|
||||
queryFilter.setFilterState(preset.filters);
|
||||
},
|
||||
[queryFilter, viewControllers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultPresetAppliedRef.current) return;
|
||||
if (isViewLoading) return; // Wait for view manager to initialize
|
||||
|
||||
// Check if selectedViewId is a system preset
|
||||
const systemPreset = SESSION_DETAIL_SYSTEM_PRESETS.find((p) => p.id === viewControllers.selectedViewId);
|
||||
|
||||
// If it's a non-system saved view, let the view manager handle it
|
||||
if (viewControllers.selectedViewId && !systemPreset) return;
|
||||
|
||||
// If filters already match a system preset, we're done
|
||||
if (queryFilter.filterState.length > 0) return;
|
||||
|
||||
defaultPresetAppliedRef.current = true;
|
||||
|
||||
// Apply the stored system preset or default to first one
|
||||
const presetToApply = systemPreset ?? SESSION_DETAIL_SYSTEM_PRESETS[0];
|
||||
applySystemPreset(presetToApply);
|
||||
}, [applySystemPreset, queryFilter.filterState.length, isViewLoading, viewControllers.selectedViewId]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: tracesQuery.data?.length ?? 0,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 320,
|
||||
overscan: 1,
|
||||
getItemKey: (index) => tracesQuery.data?.[index]?.id ?? index,
|
||||
measureElement: typeof window !== "undefined" ? (element) => element.getBoundingClientRect().height : undefined,
|
||||
});
|
||||
|
||||
if (session.error?.data?.code === "UNAUTHORIZED")
|
||||
return <ErrorPage message="You do not have access to this session." />;
|
||||
|
||||
if (session.error?.data?.code === "NOT_FOUND")
|
||||
return (
|
||||
<ErrorPage
|
||||
title="Session not found"
|
||||
message="The session is either still being processed or has been deleted."
|
||||
additionalButton={{
|
||||
label: "Retry",
|
||||
onClick: () => void window.location.reload(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Page
|
||||
headerProps={{
|
||||
title: sessionId,
|
||||
itemType: "SESSION",
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Sessions",
|
||||
href: `/project/${projectId}/sessions`,
|
||||
},
|
||||
],
|
||||
actionButtonsLeft: (
|
||||
<div className="flex items-center gap-0">
|
||||
<StarSessionToggle
|
||||
key="star"
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
value={session.data?.bookmarked ?? false}
|
||||
size="icon-xs"
|
||||
/>
|
||||
<PublishSessionSwitch
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
isPublic={session.data?.public ?? false}
|
||||
key="publish"
|
||||
size="icon-xs"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
actionButtonsRight: (
|
||||
<>
|
||||
{!router.query.peek && (
|
||||
<DetailPageNav
|
||||
key="nav"
|
||||
currentId={encodeURIComponent(sessionId)}
|
||||
path={(entry) => `/project/${projectId}/sessions/${encodeURIComponent(entry.id)}`}
|
||||
listKey="sessions"
|
||||
/>
|
||||
)}
|
||||
<CommentDrawerButton
|
||||
key="comment"
|
||||
variant="outline"
|
||||
projectId={projectId}
|
||||
objectId={sessionId}
|
||||
objectType="SESSION"
|
||||
count={getNumberFromMap(sessionCommentCounts.data, sessionId)}
|
||||
/>
|
||||
<div className="flex items-start">
|
||||
<AnnotateDrawer
|
||||
projectId={projectId}
|
||||
scoreTarget={{
|
||||
type: "session",
|
||||
sessionId,
|
||||
}}
|
||||
scores={session.data?.scores ?? []}
|
||||
scoreMetadata={{
|
||||
projectId: projectId,
|
||||
environment: session.data?.environment,
|
||||
}}
|
||||
buttonVariant="outline"
|
||||
/>
|
||||
<CreateNewAnnotationQueueItem
|
||||
projectId={projectId}
|
||||
objectId={sessionId}
|
||||
objectType={AnnotationQueueObjectType.SESSION}
|
||||
variant="outline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Switch checked={showCorrections} onCheckedChange={setShowCorrections} className="scale-75" />
|
||||
<span className="text-xs text-muted-foreground">Show corrections</span>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<div className="sticky top-0 z-40 flex flex-wrap items-center gap-2 border-b bg-background p-4">
|
||||
{/* Saved Views */}
|
||||
<TableViewPresetsDrawer
|
||||
viewConfig={{
|
||||
tableName: TableViewPresetTableName.SessionDetail,
|
||||
projectId,
|
||||
controllers: viewControllers,
|
||||
}}
|
||||
currentState={{
|
||||
orderBy: null,
|
||||
filters: queryFilter.filterState,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
searchQuery: "",
|
||||
}}
|
||||
systemFilterPresets={SESSION_DETAIL_SYSTEM_PRESETS}
|
||||
/>
|
||||
|
||||
{/* Filter Builder */}
|
||||
<PopoverFilterBuilder
|
||||
columns={filterColumns}
|
||||
filterState={visibleFilterState}
|
||||
onChange={queryFilter.setFilterState}
|
||||
columnsWithCustomSelect={filterColumnsWithCustomSelect}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
{/* Stats */}
|
||||
<Badge variant="outline">Total traces: {session.data?.countTraces ?? 0}</Badge>
|
||||
{session.data && <Badge variant="outline">Total cost: {usdFormatter(session.data.totalCost ?? 0, 2)}</Badge>}
|
||||
|
||||
{/* Users */}
|
||||
{session.data?.users?.length ? <SessionUsers projectId={projectId} users={session.data.users} /> : null}
|
||||
|
||||
{/* Scores */}
|
||||
<SessionScores scores={session.data?.scores ?? []} />
|
||||
</div>
|
||||
<div ref={parentRef} className="flex-1 overflow-auto p-4">
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const trace = tracesQuery.data?.[virtualItem.index];
|
||||
if (!trace) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<LazyTraceEventsRow
|
||||
ref={virtualizer.measureElement}
|
||||
trace={trace}
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
openPeek={openPeek}
|
||||
traceCommentCounts={traceCommentCounts.data}
|
||||
index={virtualItem.index}
|
||||
showCorrections={showCorrections}
|
||||
filterState={visibleFilterState}
|
||||
onLoad={() => {
|
||||
virtualizer.measureElement(
|
||||
document.querySelector(`[data-index="${virtualItem.index}"]`) as HTMLElement,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TablePeekView
|
||||
peekView={{
|
||||
itemType: "TRACE",
|
||||
detailNavigationKey: "traces",
|
||||
openPeek,
|
||||
closePeek,
|
||||
expandPeek,
|
||||
resolveDetailNavigationPath,
|
||||
children: <PeekViewTraceDetail projectId={projectId} />,
|
||||
tableDataUpdatedAt: session.dataUpdatedAt,
|
||||
}}
|
||||
/>
|
||||
</Page>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Textarea } from "@hanzo/ui";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hanzo/ui";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||
import { Info, ExternalLink } from "lucide-react";
|
||||
import { useQueryProject } from "@/src/features/projects/hooks";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { useHasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type FilterState } from "@hanzo/console-core";
|
||||
import { type FilterState } from "@hanzo/shared";
|
||||
|
||||
interface DataTableAIFiltersProps {
|
||||
onFiltersGenerated: (filters: FilterState) => void;
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useCallback, useMemo, type Dispatch, type SetStateAction } from
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { type ColumnOrderState, type VisibilityState } from "@tanstack/react-table";
|
||||
import { ChevronDown, ChevronRight, Component, Menu, X } from "lucide-react";
|
||||
import { type ConsoleColumnDef } from "@/src/components/table/types";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { type HanzoColumnDef } from "@/src/components/table/types";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import {
|
||||
closestCenter,
|
||||
@@ -27,12 +27,12 @@ import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
} from "@/src/components/ui/drawer";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@hanzo/ui";
|
||||
import { Checkbox } from "@hanzo/ui";
|
||||
import { Separator } from "@hanzo/ui";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/src/components/ui/collapsible";
|
||||
import { Checkbox } from "@/src/components/ui/checkbox";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
|
||||
interface DataTableColumnVisibilityFilterProps<TData, TValue> {
|
||||
columns: ConsoleColumnDef<TData, TValue>[];
|
||||
columns: HanzoColumnDef<TData, TValue>[];
|
||||
columnVisibility: VisibilityState;
|
||||
setColumnVisibility: Dispatch<SetStateAction<VisibilityState>>;
|
||||
columnOrder?: ColumnOrderState;
|
||||
@@ -40,7 +40,7 @@ interface DataTableColumnVisibilityFilterProps<TData, TValue> {
|
||||
}
|
||||
|
||||
const calculateColumnCounts = <TData, TValue>(
|
||||
columns: ConsoleColumnDef<TData, TValue>[],
|
||||
columns: HanzoColumnDef<TData, TValue>[],
|
||||
columnVisibility: VisibilityState,
|
||||
) => {
|
||||
return columns.reduce(
|
||||
@@ -67,7 +67,7 @@ function ColumnVisibilityListItem<TData, TValue>({
|
||||
columnVisibility,
|
||||
isOrderable = false,
|
||||
}: {
|
||||
column: ConsoleColumnDef<TData, TValue>;
|
||||
column: HanzoColumnDef<TData, TValue>;
|
||||
toggleColumn: (columnId: string) => void;
|
||||
columnVisibility: VisibilityState;
|
||||
isOrderable?: boolean;
|
||||
@@ -146,7 +146,7 @@ function GroupVisibilityHeader<TData, TValue>({
|
||||
children,
|
||||
toggleAll,
|
||||
}: {
|
||||
column: ConsoleColumnDef<TData, TValue>;
|
||||
column: HanzoColumnDef<TData, TValue>;
|
||||
groupTotalCount: number;
|
||||
groupVisibleCount: number;
|
||||
isOpen: boolean;
|
||||
@@ -217,11 +217,7 @@ function GroupVisibilityHeader<TData, TValue>({
|
||||
);
|
||||
}
|
||||
|
||||
function setAllColumns<TData, TValue>(
|
||||
columns: ConsoleColumnDef<TData, TValue>[],
|
||||
visible: boolean,
|
||||
groupName?: string,
|
||||
) {
|
||||
function setAllColumns<TData, TValue>(columns: HanzoColumnDef<TData, TValue>[], visible: boolean, groupName?: string) {
|
||||
return (oldVisibility: VisibilityState) => {
|
||||
const newColumnVisibility: VisibilityState = { ...oldVisibility };
|
||||
columns.forEach((col) => {
|
||||
@@ -249,7 +245,7 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
columnOrder,
|
||||
setColumnOrder,
|
||||
}: DataTableColumnVisibilityFilterProps<TData, TValue>) {
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>({});
|
||||
|
||||
const { defaultColumnOrder, defaultColumnVisibility } = useMemo(() => {
|
||||
@@ -279,7 +275,7 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
return newColumnVisibility;
|
||||
});
|
||||
},
|
||||
// eslint disable is because we don't want the insights capture as deps
|
||||
// eslint disable is because we don't want the posthog capture as deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[setColumnVisibility, columnVisibility],
|
||||
);
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
import type React from "react";
|
||||
import { createContext, useContext, useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import useSessionStorage from "@/src/components/useSessionStorage";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { Accordion } from "@hanzo/ui";
|
||||
import { Accordion } from "@/src/components/ui/accordion";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Checkbox } from "@hanzo/ui";
|
||||
import { Checkbox } from "@/src/components/ui/checkbox";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@hanzo/ui";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||
import { Slider } from "@/src/components/ui/slider";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { Label } from "@hanzo/ui";
|
||||
import { Skeleton } from "@hanzo/ui";
|
||||
import { X as IconX, Search, WandSparkles, InfoIcon } from "lucide-react";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { X as IconX, Search, WandSparkles } from "lucide-react";
|
||||
import type {
|
||||
UIFilter,
|
||||
KeyValueFilterEntry,
|
||||
NumericKeyValueFilterEntry,
|
||||
StringKeyValueFilterEntry,
|
||||
TextFilterEntry,
|
||||
PositionInTraceMode,
|
||||
} from "@/src/features/filters/hooks/useSidebarFilterState";
|
||||
import { KeyValueFilterBuilder } from "@/src/components/table/key-value-filter-builder";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@hanzo/ui";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover";
|
||||
import { DataTableAIFilters } from "@/src/components/table/data-table-ai-filters";
|
||||
import { type FilterState } from "@hanzo/console-core";
|
||||
import { useConsoleCloudRegion } from "@/src/features/organizations/hooks";
|
||||
import { type FilterState } from "@hanzo/shared";
|
||||
import { useHanzoCloudRegion } from "@/src/features/organizations/hooks";
|
||||
|
||||
interface ControlsContextType {
|
||||
open: boolean;
|
||||
@@ -90,7 +88,7 @@ interface DataTableControlsProps {
|
||||
}
|
||||
|
||||
export function DataTableControls({ queryFilter, filterWithAI }: DataTableControlsProps) {
|
||||
const { isConsoleCloud } = useConsoleCloudRegion();
|
||||
const { isHanzoCloud } = useHanzoCloudRegion();
|
||||
const [aiPopoverOpen, setAiPopoverOpen] = useState(false);
|
||||
|
||||
const handleFiltersGenerated = useCallback(
|
||||
@@ -132,7 +130,7 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
<TooltipContent>Clear all filters</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{filterWithAI && isConsoleCloud && (
|
||||
{filterWithAI && isHanzoCloud && (
|
||||
<Popover open={aiPopoverOpen} onOpenChange={setAiPopoverOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -165,7 +163,6 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
key={filter.column}
|
||||
filterKey={filter.column}
|
||||
label={filter.label}
|
||||
tooltip={filter.tooltip}
|
||||
expanded={filter.expanded}
|
||||
options={filter.options}
|
||||
counts={filter.counts}
|
||||
@@ -173,7 +170,6 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
value={filter.value}
|
||||
onChange={filter.onChange}
|
||||
onOnlyChange={filter.onOnlyChange}
|
||||
renderIcon={filter.renderIcon}
|
||||
isActive={filter.isActive}
|
||||
onReset={filter.onReset}
|
||||
operator={filter.operator}
|
||||
@@ -181,8 +177,6 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
textFilters={filter.textFilters}
|
||||
onTextFilterAdd={filter.onTextFilterAdd}
|
||||
onTextFilterRemove={filter.onTextFilterRemove}
|
||||
isDisabled={filter.isDisabled}
|
||||
disabledReason={filter.disabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -193,7 +187,6 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
key={filter.column}
|
||||
filterKey={filter.column}
|
||||
label={filter.label}
|
||||
tooltip={filter.tooltip}
|
||||
expanded={filter.expanded}
|
||||
loading={filter.loading}
|
||||
min={filter.min}
|
||||
@@ -201,11 +194,8 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
value={filter.value}
|
||||
onChange={filter.onChange}
|
||||
unit={filter.unit}
|
||||
step={filter.step}
|
||||
isActive={filter.isActive}
|
||||
onReset={filter.onReset}
|
||||
isDisabled={filter.isDisabled}
|
||||
disabledReason={filter.disabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -216,15 +206,12 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
key={filter.column}
|
||||
filterKey={filter.column}
|
||||
label={filter.label}
|
||||
tooltip={filter.tooltip}
|
||||
expanded={filter.expanded}
|
||||
loading={filter.loading}
|
||||
value={filter.value}
|
||||
onChange={filter.onChange}
|
||||
isActive={filter.isActive}
|
||||
onReset={filter.onReset}
|
||||
isDisabled={filter.isDisabled}
|
||||
disabledReason={filter.disabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -235,7 +222,6 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
key={filter.column}
|
||||
filterKey={filter.column}
|
||||
label={filter.label}
|
||||
tooltip={filter.tooltip}
|
||||
expanded={filter.expanded}
|
||||
loading={filter.loading}
|
||||
keyOptions={filter.keyOptions}
|
||||
@@ -245,8 +231,6 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
isActive={filter.isActive}
|
||||
onReset={filter.onReset}
|
||||
keyPlaceholder="Name"
|
||||
isDisabled={filter.isDisabled}
|
||||
disabledReason={filter.disabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -257,7 +241,6 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
key={filter.column}
|
||||
filterKey={filter.column}
|
||||
label={filter.label}
|
||||
tooltip={filter.tooltip}
|
||||
expanded={filter.expanded}
|
||||
loading={filter.loading}
|
||||
keyOptions={filter.keyOptions}
|
||||
@@ -266,8 +249,6 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
isActive={filter.isActive}
|
||||
onReset={filter.onReset}
|
||||
keyPlaceholder="Name"
|
||||
isDisabled={filter.isDisabled}
|
||||
disabledReason={filter.disabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -278,7 +259,6 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
key={filter.column}
|
||||
filterKey={filter.column}
|
||||
label={filter.label}
|
||||
tooltip={filter.tooltip}
|
||||
expanded={filter.expanded}
|
||||
loading={filter.loading}
|
||||
keyOptions={filter.keyOptions}
|
||||
@@ -286,29 +266,6 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
onChange={filter.onChange}
|
||||
isActive={filter.isActive}
|
||||
onReset={filter.onReset}
|
||||
isDisabled={filter.isDisabled}
|
||||
disabledReason={filter.disabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.type === "positionInTrace") {
|
||||
return (
|
||||
<PositionInTraceFacetComponent
|
||||
key={filter.column}
|
||||
filterKey={filter.column}
|
||||
label={filter.label}
|
||||
tooltip={filter.tooltip}
|
||||
expanded={filter.expanded}
|
||||
loading={filter.loading}
|
||||
mode={filter.mode}
|
||||
nthValue={filter.nthValue}
|
||||
onModeChange={filter.onModeChange}
|
||||
onNthValueChange={filter.onNthValueChange}
|
||||
isActive={filter.isActive}
|
||||
onReset={filter.onReset}
|
||||
isDisabled={filter.isDisabled}
|
||||
disabledReason={filter.disabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -323,15 +280,12 @@ export function DataTableControls({ queryFilter, filterWithAI }: DataTableContro
|
||||
|
||||
interface BaseFacetProps {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
children?: React.ReactNode;
|
||||
filterKey: string;
|
||||
filterKeyShort?: string | null;
|
||||
expanded?: boolean;
|
||||
loading?: boolean;
|
||||
isActive?: boolean;
|
||||
isDisabled?: boolean;
|
||||
disabledReason?: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
@@ -341,7 +295,6 @@ interface CategoricalFacetProps extends BaseFacetProps {
|
||||
value: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
onOnlyChange?: (value: string) => void;
|
||||
renderIcon?: (value: string) => React.ReactNode;
|
||||
operator?: "any of" | "all of";
|
||||
onOperatorChange?: (operator: "any of" | "all of") => void;
|
||||
textFilters?: TextFilterEntry[];
|
||||
@@ -355,7 +308,6 @@ interface NumericFacetProps extends BaseFacetProps {
|
||||
value: [number, number];
|
||||
onChange: (value: [number, number]) => void;
|
||||
unit?: string;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
interface StringFacetProps extends BaseFacetProps {
|
||||
@@ -419,69 +371,31 @@ const FilterAccordionContent = ({
|
||||
|
||||
interface FilterAccordionItemProps {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
filterKey: string;
|
||||
filterKeyShort?: string | null;
|
||||
children: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
isDisabled?: boolean;
|
||||
disabledReason?: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export function FilterAccordionItem({
|
||||
label,
|
||||
tooltip,
|
||||
filterKey,
|
||||
filterKeyShort,
|
||||
children,
|
||||
isActive,
|
||||
isDisabled,
|
||||
disabledReason,
|
||||
onReset,
|
||||
}: FilterAccordionItemProps) {
|
||||
return (
|
||||
<FilterAccordionItemPrimitive value={filterKey} className="border-none">
|
||||
<FilterAccordionTrigger
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-normal text-muted-foreground hover:text-foreground hover:no-underline",
|
||||
isDisabled && "cursor-not-allowed text-muted-foreground/60 hover:text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
<FilterAccordionTrigger className="px-3 py-1.5 text-sm font-normal text-muted-foreground hover:text-foreground hover:no-underline">
|
||||
<div className="flex grow items-center gap-1.5 pr-2">
|
||||
{isDisabled && disabledReason ? (
|
||||
<Tooltip delayDuration={80}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex grow items-baseline gap-1">
|
||||
{label}
|
||||
{filterKeyShort && (
|
||||
<code className="hidden font-mono text-xs text-muted-foreground/70">{filterKeyShort}</code>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 text-xs">{disabledReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : tooltip ? (
|
||||
<Tooltip delayDuration={80}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex grow items-center gap-1">
|
||||
{label}
|
||||
<InfoIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{filterKeyShort && (
|
||||
<code className="hidden font-mono text-xs text-muted-foreground/70">{filterKeyShort}</code>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 text-xs">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="flex grow items-baseline gap-1">
|
||||
{label}
|
||||
{filterKeyShort && (
|
||||
<code className="hidden font-mono text-xs text-muted-foreground/70">{filterKeyShort}</code>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex grow items-baseline gap-1">
|
||||
{label}
|
||||
{filterKeyShort && (
|
||||
<code className="hidden font-mono text-xs text-muted-foreground/70">{filterKeyShort}</code>
|
||||
)}
|
||||
</span>
|
||||
{isActive && onReset && (
|
||||
<div
|
||||
role="button"
|
||||
@@ -506,21 +420,13 @@ export function FilterAccordionItem({
|
||||
)}
|
||||
</div>
|
||||
</FilterAccordionTrigger>
|
||||
<FilterAccordionContent className="pb-2">
|
||||
<fieldset
|
||||
disabled={isDisabled}
|
||||
className={cn("m-0 min-w-0 border-0 p-0", isDisabled && "pointer-events-none opacity-60")}
|
||||
>
|
||||
{children}
|
||||
</fieldset>
|
||||
</FilterAccordionContent>
|
||||
<FilterAccordionContent className="pb-2">{children}</FilterAccordionContent>
|
||||
</FilterAccordionItemPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoricalFacet({
|
||||
label,
|
||||
tooltip,
|
||||
filterKey,
|
||||
filterKeyShort,
|
||||
expanded,
|
||||
@@ -530,10 +436,7 @@ export function CategoricalFacet({
|
||||
value,
|
||||
onChange,
|
||||
onOnlyChange,
|
||||
renderIcon,
|
||||
isActive,
|
||||
isDisabled,
|
||||
disabledReason,
|
||||
onReset,
|
||||
operator,
|
||||
onOperatorChange,
|
||||
@@ -583,12 +486,9 @@ export function CategoricalFacet({
|
||||
return (
|
||||
<FilterAccordionItem
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
filterKey={filterKey}
|
||||
filterKeyShort={filterKeyShort}
|
||||
isActive={isActive}
|
||||
isDisabled={isDisabled}
|
||||
disabledReason={disabledReason}
|
||||
onReset={onReset}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
@@ -661,39 +561,7 @@ export function CategoricalFacet({
|
||||
))}
|
||||
</>
|
||||
) : options.length === 0 ? (
|
||||
<div className="py-1 text-xs text-muted-foreground">
|
||||
{filterKey === "sessionId" ? (
|
||||
<span>
|
||||
Sessions group traces together, which is useful for tracing multi-step workflows.{" "}
|
||||
<a
|
||||
href="https://hanzo.ai/docs/observability/features/sessions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
See docs
|
||||
</a>{" "}
|
||||
to learn how to add sessions to your traces.
|
||||
</span>
|
||||
) : filterKey === "name" ? (
|
||||
<span>No trace names found in the given time range.</span>
|
||||
) : filterKey === "tags" ? (
|
||||
<span>
|
||||
Tags let you filter traces according to custom categories (e.g. feature flags).{" "}
|
||||
<a
|
||||
href="https://hanzo.ai/docs/observability/features/tags"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
See docs
|
||||
</a>{" "}
|
||||
to learn how to add tags to your traces.
|
||||
</span>
|
||||
) : (
|
||||
"No options found"
|
||||
)}
|
||||
</div>
|
||||
<div className="py-1 text-center text-xs text-muted-foreground">No options found</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search box for many options */}
|
||||
@@ -721,7 +589,6 @@ export function CategoricalFacet({
|
||||
key={option}
|
||||
id={`${filterKey}-${option}`}
|
||||
label={option}
|
||||
icon={renderIcon?.(option)}
|
||||
count={counts.get(option) || 0}
|
||||
checked={value.includes(option)}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -746,20 +613,6 @@ export function CategoricalFacet({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{filterKey === "environment" && options.length === 1 && options[0]?.toLowerCase() === "default" ? (
|
||||
<div className="mt-2 px-2 text-xs text-muted-foreground">
|
||||
Environments help you separate traces from different contexts (e.g. production, staging).{" "}
|
||||
<a
|
||||
href="https://hanzo.ai/docs/observability/features/environments"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
See docs
|
||||
</a>{" "}
|
||||
on how to add environments to your traces.
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -778,7 +631,6 @@ export function CategoricalFacet({
|
||||
|
||||
export function NumericFacet({
|
||||
label,
|
||||
tooltip,
|
||||
filterKey,
|
||||
filterKeyShort,
|
||||
expanded: _expanded,
|
||||
@@ -788,10 +640,7 @@ export function NumericFacet({
|
||||
value,
|
||||
onChange,
|
||||
unit,
|
||||
step: stepProp,
|
||||
isActive,
|
||||
isDisabled,
|
||||
disabledReason,
|
||||
onReset,
|
||||
}: NumericFacetProps) {
|
||||
const [localValue, setLocalValue] = useState<[number, number]>(value);
|
||||
@@ -862,12 +711,9 @@ export function NumericFacet({
|
||||
return (
|
||||
<FilterAccordionItem
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
filterKey={filterKey}
|
||||
filterKeyShort={filterKeyShort}
|
||||
isActive={isActive}
|
||||
isDisabled={isDisabled}
|
||||
disabledReason={disabledReason}
|
||||
onReset={onReset}
|
||||
>
|
||||
<div className="px-4 py-2">
|
||||
@@ -887,7 +733,7 @@ export function NumericFacet({
|
||||
value={isActive ? localValue[0] : ""}
|
||||
placeholder={String(min)}
|
||||
min={min}
|
||||
step={stepProp ?? "any"}
|
||||
step="any"
|
||||
onChange={handleMinInputChange}
|
||||
className="h-8"
|
||||
/>
|
||||
@@ -905,7 +751,7 @@ export function NumericFacet({
|
||||
value={isActive ? localValue[1] : ""}
|
||||
placeholder={String(max)}
|
||||
min={min}
|
||||
step={stepProp ?? "any"}
|
||||
step="any"
|
||||
onChange={handleMaxInputChange}
|
||||
className="h-8"
|
||||
/>
|
||||
@@ -916,7 +762,7 @@ export function NumericFacet({
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={stepProp ?? (max - min <= 1000 ? 0.01 : 1)}
|
||||
step={max - min <= 1000 ? 0.01 : 1}
|
||||
value={localValue}
|
||||
onValueChange={handleSliderChange}
|
||||
/>
|
||||
@@ -929,7 +775,6 @@ export function NumericFacet({
|
||||
|
||||
export function StringFacet({
|
||||
label,
|
||||
tooltip,
|
||||
filterKey,
|
||||
filterKeyShort,
|
||||
expanded: _expanded,
|
||||
@@ -937,8 +782,6 @@ export function StringFacet({
|
||||
value,
|
||||
onChange,
|
||||
isActive,
|
||||
isDisabled,
|
||||
disabledReason,
|
||||
onReset,
|
||||
}: StringFacetProps) {
|
||||
const [localValue, setLocalValue] = useState<string>(value);
|
||||
@@ -978,12 +821,9 @@ export function StringFacet({
|
||||
return (
|
||||
<FilterAccordionItem
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
filterKey={filterKey}
|
||||
filterKeyShort={filterKeyShort}
|
||||
isActive={isActive}
|
||||
isDisabled={isDisabled}
|
||||
disabledReason={disabledReason}
|
||||
onReset={onReset}
|
||||
>
|
||||
<div className="px-4">
|
||||
@@ -1006,7 +846,6 @@ export function StringFacet({
|
||||
|
||||
export function KeyValueFacet({
|
||||
label,
|
||||
tooltip,
|
||||
filterKey,
|
||||
filterKeyShort,
|
||||
expanded: _expanded,
|
||||
@@ -1016,20 +855,15 @@ export function KeyValueFacet({
|
||||
value,
|
||||
onChange,
|
||||
isActive,
|
||||
isDisabled,
|
||||
disabledReason,
|
||||
onReset,
|
||||
keyPlaceholder,
|
||||
}: KeyValueFacetProps) {
|
||||
return (
|
||||
<FilterAccordionItem
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
filterKey={filterKey}
|
||||
filterKeyShort={filterKeyShort}
|
||||
isActive={isActive}
|
||||
isDisabled={isDisabled}
|
||||
disabledReason={disabledReason}
|
||||
onReset={onReset}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -1050,7 +884,6 @@ export function KeyValueFacet({
|
||||
|
||||
export function NumericKeyValueFacet({
|
||||
label,
|
||||
tooltip,
|
||||
filterKey,
|
||||
filterKeyShort,
|
||||
expanded: _expanded,
|
||||
@@ -1059,20 +892,15 @@ export function NumericKeyValueFacet({
|
||||
value,
|
||||
onChange,
|
||||
isActive,
|
||||
isDisabled,
|
||||
disabledReason,
|
||||
onReset,
|
||||
keyPlaceholder,
|
||||
}: NumericKeyValueFacetProps) {
|
||||
return (
|
||||
<FilterAccordionItem
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
filterKey={filterKey}
|
||||
filterKeyShort={filterKeyShort}
|
||||
isActive={isActive}
|
||||
isDisabled={isDisabled}
|
||||
disabledReason={disabledReason}
|
||||
onReset={onReset}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -1092,7 +920,6 @@ export function NumericKeyValueFacet({
|
||||
|
||||
export function StringKeyValueFacet({
|
||||
label,
|
||||
tooltip,
|
||||
filterKey,
|
||||
filterKeyShort,
|
||||
expanded: _expanded,
|
||||
@@ -1101,20 +928,15 @@ export function StringKeyValueFacet({
|
||||
value,
|
||||
onChange,
|
||||
isActive,
|
||||
isDisabled,
|
||||
disabledReason,
|
||||
onReset,
|
||||
keyPlaceholder,
|
||||
}: StringKeyValueFacetProps) {
|
||||
return (
|
||||
<FilterAccordionItem
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
filterKey={filterKey}
|
||||
filterKeyShort={filterKeyShort}
|
||||
isActive={isActive}
|
||||
isDisabled={isDisabled}
|
||||
disabledReason={disabledReason}
|
||||
onReset={onReset}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -1132,99 +954,6 @@ export function StringKeyValueFacet({
|
||||
);
|
||||
}
|
||||
|
||||
interface PositionInTraceFacetProps extends BaseFacetProps {
|
||||
mode: PositionInTraceMode | null;
|
||||
nthValue: number;
|
||||
onModeChange: (mode: PositionInTraceMode | null) => void;
|
||||
onNthValueChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const POSITION_MODES: {
|
||||
key: PositionInTraceMode;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: "root", label: "1st" },
|
||||
{ key: "last", label: "Last" },
|
||||
{ key: "nthFromStart", label: "Nth from start" },
|
||||
{ key: "nthFromEnd", label: "Nth from end" },
|
||||
];
|
||||
|
||||
function PositionInTraceFacetComponent({
|
||||
label,
|
||||
tooltip,
|
||||
filterKey,
|
||||
filterKeyShort,
|
||||
expanded: _expanded,
|
||||
loading,
|
||||
mode,
|
||||
nthValue,
|
||||
onModeChange,
|
||||
onNthValueChange,
|
||||
isActive,
|
||||
isDisabled,
|
||||
disabledReason,
|
||||
onReset,
|
||||
}: PositionInTraceFacetProps) {
|
||||
const showNthInput = mode === "nthFromStart" || mode === "nthFromEnd";
|
||||
|
||||
return (
|
||||
<FilterAccordionItem
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
filterKey={filterKey}
|
||||
filterKeyShort={filterKeyShort}
|
||||
isActive={isActive}
|
||||
isDisabled={isDisabled}
|
||||
disabledReason={disabledReason}
|
||||
onReset={onReset}
|
||||
>
|
||||
<div className="px-4 py-1">
|
||||
{loading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{POSITION_MODES.map(({ key, label: modeLabel }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onModeChange(mode === key ? null : key)}
|
||||
className={cn(
|
||||
"rounded-md border px-2 py-1 text-xs transition-colors",
|
||||
mode === key
|
||||
? "border-primary bg-primary/10 font-medium text-primary"
|
||||
: "border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
{modeLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showNthInput && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`nth-${filterKey}`} className="text-xs text-muted-foreground">
|
||||
Position:
|
||||
</Label>
|
||||
<Input
|
||||
id={`nth-${filterKey}`}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={nthValue}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v)) onNthValueChange(v);
|
||||
}}
|
||||
className="h-7 w-20 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FilterAccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter mode tabs for switching between Select (checkboxes) and Text (contains) modes
|
||||
interface FilterModeTabsProps {
|
||||
mode: "select" | "text";
|
||||
@@ -1375,7 +1104,6 @@ function TextFilterSection({
|
||||
interface FilterValueCheckboxProps {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
count: number;
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
@@ -1387,7 +1115,6 @@ interface FilterValueCheckboxProps {
|
||||
export function FilterValueCheckbox({
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
count,
|
||||
checked = false,
|
||||
onCheckedChange,
|
||||
@@ -1423,7 +1150,6 @@ export function FilterValueCheckbox({
|
||||
)}
|
||||
onClick={onLabelClick}
|
||||
>
|
||||
{icon ? <span className="mr-2">{icon}</span> : null}
|
||||
<span
|
||||
className={cn("min-w-0 flex-1 truncate text-xs", label === "" && "italic text-muted-foreground")}
|
||||
title={displayTitle}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type Table } from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/src/components/ui/select";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -23,7 +23,7 @@ export function DataTablePagination<TData>({
|
||||
hideTotalCount = false,
|
||||
canJumpPages = true,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||
const [inputState, setInputState] = useState<number | string>(currentPage);
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
type TableViewPresetDomain,
|
||||
type TableViewPresetTableName,
|
||||
type TracingSearchType,
|
||||
} from "@hanzo/console-core";
|
||||
} from "@hanzo/shared";
|
||||
import { type RowSelectionState, type ColumnOrderState, type VisibilityState } from "@tanstack/react-table";
|
||||
import { type ConsoleColumnDef } from "@/src/components/table/types";
|
||||
import { type HanzoColumnDef } from "@/src/components/table/types";
|
||||
import { DataTableRowHeightSwitch, type RowHeight } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { Search, ChevronDown, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { TimeRangePicker } from "@/src/components/date-picker";
|
||||
import { type TimeRange, TABLE_AGGREGATION_OPTIONS } from "@/src/utils/date-range-utils";
|
||||
import { DataTableSelectAllBanner } from "@/src/components/table/data-table-multi-select-actions/data-table-select-all-banner";
|
||||
@@ -78,7 +78,7 @@ interface RefreshConfig {
|
||||
}
|
||||
|
||||
interface DataTableToolbarProps<TData, TValue> {
|
||||
columns: ConsoleColumnDef<TData, TValue>[];
|
||||
columns: HanzoColumnDef<TData, TValue>[];
|
||||
filterColumnDefinition?: ColumnDefinition[];
|
||||
searchConfig?: SearchConfig;
|
||||
actionButtons?: React.ReactNode;
|
||||
@@ -134,7 +134,7 @@ export function DataTableToolbar<TData, TValue>({
|
||||
}: DataTableToolbarProps<TData, TValue>) {
|
||||
const [searchString, setSearchString] = useState(searchConfig?.currentQuery ?? "");
|
||||
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const { open: controlsPanelOpen, setOpen: setControlsPanelOpen } = useDataTableControls();
|
||||
|
||||
// Only show the toggle button when we're using the new sidebar
|
||||
@@ -158,6 +158,7 @@ export function DataTableToolbar<TData, TValue>({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{viewModeToggle}
|
||||
{searchConfig && (
|
||||
<div className="flex max-w-[30rem] flex-shrink-0 items-stretch md:min-w-[24rem]">
|
||||
<div
|
||||
@@ -261,7 +262,6 @@ export function DataTableToolbar<TData, TValue>({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{viewModeToggle}
|
||||
{timeRange && setTimeRange && (
|
||||
<TimeRangePicker
|
||||
timeRange={timeRange}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { type OrderByState } from "@hanzo/console-core";
|
||||
import { type OrderByState } from "@hanzo/shared";
|
||||
import React, { useState, useMemo, useCallback, type CSSProperties } from "react";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { DataTablePagination } from "@/src/components/table/data-table-pagination";
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
type RowHeight,
|
||||
getRowHeightTailwindClass,
|
||||
} from "@/src/components/table/data-table-row-height-switch";
|
||||
import { type ConsoleColumnDef } from "@/src/components/table/types";
|
||||
import { type HanzoColumnDef } from "@/src/components/table/types";
|
||||
import { type ModelTableRow } from "@/src/components/table/use-cases/models";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hanzo/ui";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/src/components/ui/table";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import {
|
||||
type ColumnOrderState,
|
||||
@@ -28,13 +28,13 @@ import {
|
||||
type VisibilityState,
|
||||
type Row,
|
||||
} from "@tanstack/react-table";
|
||||
import { type DataTablePeekViewProps } from "@/src/components/table/peek";
|
||||
import { type DataTablePeekViewProps, TablePeekView } from "@/src/components/table/peek";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { useRouter } from "next/router";
|
||||
import { useColumnSizing } from "@/src/components/table/hooks/useColumnSizing";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ConsoleColumnDef<TData, TValue>[];
|
||||
columns: HanzoColumnDef<TData, TValue>[];
|
||||
data: AsyncTableData<TData[]>;
|
||||
pagination?: {
|
||||
totalCount: number | null; // null if loading
|
||||
@@ -53,13 +53,11 @@ interface DataTableProps<TData, TValue> {
|
||||
orderBy?: OrderByState;
|
||||
setOrderBy?: (s: OrderByState) => void;
|
||||
help?: { description: string; href: string };
|
||||
noResultsMessage?: React.ReactNode;
|
||||
rowHeight?: RowHeight;
|
||||
customRowHeights?: CustomHeights;
|
||||
className?: string;
|
||||
shouldRenderGroupHeaders?: boolean;
|
||||
onRowClick?: (row: TData, event?: React.MouseEvent) => void;
|
||||
/** Used for row click handling and MemoizedTableBody snapshot only. Render <TablePeekView> as a sibling outside DataTable. */
|
||||
peekView?: DataTablePeekViewProps;
|
||||
hidePagination?: boolean;
|
||||
tableName: string;
|
||||
@@ -124,7 +122,6 @@ export function DataTable<TData extends object, TValue>({
|
||||
columnOrder,
|
||||
onColumnOrderChange,
|
||||
help,
|
||||
noResultsMessage,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
rowHeight,
|
||||
@@ -139,7 +136,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const rowheighttw = getRowHeightTailwindClass(rowHeight, customRowHeights);
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const flattedColumnsByGroup = useMemo(() => {
|
||||
const flatColumnsByGroup = new Map<string, string[]>();
|
||||
|
||||
@@ -254,7 +251,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
{tableHeaders.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const columnDef = header.column.columnDef as ConsoleColumnDef<ModelTableRow>;
|
||||
const columnDef = header.column.columnDef as HanzoColumnDef<ModelTableRow>;
|
||||
const sortingEnabled = columnDef.enableSorting;
|
||||
// if the header id does not translate to a valid css variable name, default to 150px as width
|
||||
// may only happen for dynamic columns, as column names are user defined
|
||||
@@ -277,7 +274,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
width,
|
||||
...getCommonPinningStyles(header.column),
|
||||
}}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!setOrderBy || !columnDef.id || !sortingEnabled) {
|
||||
@@ -355,10 +352,10 @@ export function DataTable<TData extends object, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
help={help}
|
||||
noResultsMessage={noResultsMessage}
|
||||
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
|
||||
getRowClassName={getRowClassName}
|
||||
tableSnapshot={{
|
||||
tableDataUpdatedAt: peekView?.tableDataUpdatedAt,
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
rowSelection,
|
||||
@@ -372,7 +369,6 @@ export function DataTable<TData extends object, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
help={help}
|
||||
noResultsMessage={noResultsMessage}
|
||||
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
|
||||
getRowClassName={getRowClassName}
|
||||
/>
|
||||
@@ -380,6 +376,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
{peekView && <TablePeekView peekView={peekView} />}
|
||||
{!hidePagination && pagination !== undefined ? (
|
||||
<div
|
||||
className={cn("sticky bottom-0 z-10 flex w-full justify-end border-t bg-background py-2 pr-2 font-medium")}
|
||||
@@ -412,13 +409,13 @@ interface TableBodyComponentProps<TData> {
|
||||
table: ReturnType<typeof useReactTable<TData>>;
|
||||
rowheighttw?: string;
|
||||
rowHeight?: RowHeight;
|
||||
columns: ConsoleColumnDef<TData, any>[];
|
||||
columns: HanzoColumnDef<TData, any>[];
|
||||
data: AsyncTableData<TData[]>;
|
||||
help?: { description: string; href: string };
|
||||
noResultsMessage?: React.ReactNode;
|
||||
onRowClick?: (row: TData, event?: React.MouseEvent) => void;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
tableSnapshot?: {
|
||||
tableDataUpdatedAt?: number;
|
||||
columnVisibility?: VisibilityState;
|
||||
columnOrder?: ColumnOrderState;
|
||||
rowSelection?: RowSelectionState;
|
||||
@@ -442,8 +439,8 @@ function TableRowComponent<TData>({
|
||||
return (
|
||||
<TableRow
|
||||
data-row-index={row.index}
|
||||
onClick={(e: React.MouseEvent) => onRowClick?.(row.original, e)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
onClick={(e) => onRowClick?.(row.original, e)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onRowClick?.(row.original);
|
||||
}
|
||||
@@ -467,7 +464,6 @@ function TableBodyComponent<TData>({
|
||||
columns,
|
||||
data,
|
||||
help,
|
||||
noResultsMessage,
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
}: TableBodyComponentProps<TData>) {
|
||||
@@ -528,10 +524,8 @@ function TableBodyComponent<TData>({
|
||||
) : (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="h-24">
|
||||
<div className="pointer-events-none absolute left-[50%] flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-center">
|
||||
{noResultsMessage ?? (
|
||||
<>No results. {help && <DocPopup description={help.description} href={help.href} />}</>
|
||||
)}
|
||||
<div className="pointer-events-none absolute left-[50%] flex -translate-y-1/2 items-center justify-center">
|
||||
No results. {help && <DocPopup description={help.description} href={help.href} />}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -559,11 +553,11 @@ function TableBodyComponent<TData>({
|
||||
const MemoizedTableBody = React.memo(TableBodyComponent, (prev, next) => {
|
||||
if (!prev.tableSnapshot || !next.tableSnapshot) return !prev.tableSnapshot && !next.tableSnapshot;
|
||||
|
||||
// Compare actual data arrays from the AsyncTableData prop.
|
||||
// prev.table.options.data won't work — TanStack Table returns a stable mutable instance.
|
||||
const prevDataArr = !prev.data.isLoading && !prev.data.isError ? prev.data.data : undefined;
|
||||
const nextDataArr = !next.data.isLoading && !next.data.isError ? next.data.data : undefined;
|
||||
if (prevDataArr !== nextDataArr) return false;
|
||||
// Check reference equality first (faster)
|
||||
if (prev.tableSnapshot.tableDataUpdatedAt !== next.tableSnapshot.tableDataUpdatedAt) {
|
||||
return false;
|
||||
}
|
||||
if (prev.table.options.data !== next.table.options.data) return false;
|
||||
if (prev.data.isLoading !== next.data.isLoading) return false;
|
||||
if (prev.rowheighttw !== next.rowheighttw) return false;
|
||||
if (prev.rowHeight !== next.rowHeight) return false;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hanzo/ui";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/src/components/ui/hover-card";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { ListFilter } from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
import { type FilterCondition, type FilterState } from "@hanzo/console-core";
|
||||
import { type FilterCondition, type FilterState } from "@hanzo/shared";
|
||||
|
||||
interface FilteredRunPillsProps {
|
||||
projectId: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/src/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@hanzo/ui";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover";
|
||||
import {
|
||||
InputCommand,
|
||||
InputCommandEmpty,
|
||||
@@ -172,7 +172,7 @@ export function KeyValueFilterBuilder(props: KeyValueFilterBuilderProps) {
|
||||
// Combobox for known keys
|
||||
<Popover
|
||||
open={openPopoverIndex === index}
|
||||
onOpenChange={(open: boolean) => setOpenPopoverIndex(open ? index : null)}
|
||||
onOpenChange={(open) => setOpenPopoverIndex(open ? index : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="flex-1 justify-between text-left font-normal">
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/src/components/ui/sheet";
|
||||
import { Expand, ExternalLink } from "lucide-react";
|
||||
import { Separator } from "@hanzo/ui";
|
||||
import { ItemBadge, type ConsoleItemType } from "@/src/components/ItemBadge";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
import { ItemBadge, type HanzoItemType } from "@/src/components/ItemBadge";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { type ListEntry } from "@/src/features/navigate-detail-pages/context";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { memo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { PeekTableStateProvider } from "@/src/components/table/peek/contexts/PeekTableStateContext";
|
||||
|
||||
type PeekViewItemType = Extract<ConsoleItemType, "TRACE" | "DATASET_ITEM" | "RUNNING_EVALUATOR" | "EVALUATOR">;
|
||||
type PeekViewItemType = Extract<HanzoItemType, "TRACE" | "DATASET_ITEM" | "RUNNING_EVALUATOR" | "EVALUATOR">;
|
||||
|
||||
/**
|
||||
* Options to control peek event behavior.
|
||||
@@ -51,6 +50,9 @@ export type DataTablePeekViewProps = {
|
||||
* The content to display in the peek view.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/** The timestamp of the last time the table data was updated */
|
||||
tableDataUpdatedAt: number;
|
||||
};
|
||||
|
||||
export const createPeekEventHandler = (options?: PeekEventControlOptions) => {
|
||||
@@ -137,16 +139,17 @@ function TablePeekViewComponent(props: TablePeekViewProps) {
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<Separator />
|
||||
<PeekTableStateProvider>
|
||||
<div className="flex max-h-full min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-auto" key={itemId}>
|
||||
{peekView.children}
|
||||
</div>
|
||||
<div className="flex max-h-full min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-auto" key={itemId}>
|
||||
{peekView.children}
|
||||
</div>
|
||||
</PeekTableStateProvider>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export const TablePeekView = memo(TablePeekViewComponent);
|
||||
export const TablePeekView = memo(TablePeekViewComponent, (prev, next) => {
|
||||
// TODO LFE-6627: drop tableDataUpdatedAt and allow memoization to work independently of table data updates
|
||||
return prev.peekView.tableDataUpdatedAt === next.peekView.tableDataUpdatedAt;
|
||||
}) as typeof TablePeekViewComponent;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useV4Beta } from "@/src/features/events/hooks/useV4Beta";
|
||||
import { useObservationListBeta } from "@/src/features/events/hooks/useObservationListBeta";
|
||||
import { useEventsTraceData } from "@/src/features/events/hooks/useEventsTraceData";
|
||||
|
||||
type UsePeekDataProps = {
|
||||
@@ -8,12 +8,8 @@ type UsePeekDataProps = {
|
||||
timestamp?: Date;
|
||||
};
|
||||
|
||||
export const usePeekData = ({
|
||||
projectId,
|
||||
traceId,
|
||||
timestamp,
|
||||
}: UsePeekDataProps) => {
|
||||
const { isBetaEnabled } = useV4Beta();
|
||||
export const usePeekData = ({ projectId, traceId, timestamp }: UsePeekDataProps) => {
|
||||
const { isBetaEnabled } = useObservationListBeta();
|
||||
|
||||
// Old path: fetch from traces table (beta OFF)
|
||||
const tracesQuery = api.traces.byIdWithObservationsAndScores.useQuery(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { Skeleton } from "@hanzo/ui";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { CardDescription } from "@/src/components/ui/card";
|
||||
import { EvaluatorForm } from "@/src/features/evals/components/evaluator-form";
|
||||
import { usePeekEvalConfigData } from "@/src/components/table/peek/hooks/usePeekEvalConfigData";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@hanzo/ui";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||
import { HanzoIcon } from "@/src/components/HanzoLogo";
|
||||
import { UserCircle2Icon } from "lucide-react";
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
@@ -15,8 +15,6 @@ import { useState } from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { LegacyEvalCallout } from "@/src/features/evals/components/legacy-eval-callout";
|
||||
import { isLegacyEvalTarget } from "@/src/features/evals/utils/typeHelpers";
|
||||
|
||||
export const PeekViewEvaluatorConfigDetail = ({ projectId }: { projectId: string }) => {
|
||||
const router = useRouter();
|
||||
@@ -42,30 +40,18 @@ export const PeekViewEvaluatorConfigDetail = ({ projectId }: { projectId: string
|
||||
<span className="max-h-fit text-lg font-medium">Configuration</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge type={evalConfig.finalStatus.toLowerCase()} isLive className="max-h-8" />
|
||||
{isLegacyEvalTarget(evalConfig.targetObject) && (
|
||||
<DeactivateEvalConfig projectId={projectId} evalConfig={evalConfig} />
|
||||
)}
|
||||
<DeactivateEvalConfig projectId={projectId} evalConfig={evalConfig} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("text-sm", isEditMode ? "" : "text-muted-foreground")}>Edit Mode</span>
|
||||
<Switch
|
||||
disabled={
|
||||
!hasAccess ||
|
||||
(isLegacyEvalTarget(evalConfig.targetObject) &&
|
||||
evalConfig?.timeScope?.length === 1 &&
|
||||
evalConfig.timeScope[0] === "EXISTING")
|
||||
}
|
||||
disabled={!hasAccess || (evalConfig?.timeScope?.length === 1 && evalConfig.timeScope[0] === "EXISTING")}
|
||||
checked={isEditMode}
|
||||
onCheckedChange={setIsEditMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{evalConfig && evalConfig.targetObject && evalConfig.evalTemplate && evalConfig.finalStatus === "ACTIVE" && (
|
||||
<LegacyEvalCallout projectId={projectId} evalConfigId={evalConfig.id} targetObject={evalConfig.targetObject} />
|
||||
)}
|
||||
|
||||
<CardDescription className="flex items-center text-sm">
|
||||
<span className="mr-2 text-sm font-medium">Referenced Evaluator</span>
|
||||
{evalConfig.evalTemplate && (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type PropsWithChildren, Children } from "react";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import { ResizableDesktopLayout } from "@/src/components/layouts/ResizableDesktopLayout";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/src/components/ui/resizable";
|
||||
import { useDataTableControls } from "./data-table-controls";
|
||||
|
||||
/** Resizable layout for filter sidebar and table content.
|
||||
@@ -13,48 +13,43 @@ export function ResizableFilterLayout({ children }: PropsWithChildren) {
|
||||
const { open, tableName } = useDataTableControls();
|
||||
const isDesktop = useMediaQuery({ query: "(min-width: 768px)" });
|
||||
|
||||
// On mobile, render children as-is (stacked layout)
|
||||
if (!isDesktop) {
|
||||
return <div className="flex flex-1 overflow-hidden">{children}</div>;
|
||||
}
|
||||
|
||||
// Extract filter sidebar and table content from children
|
||||
const childrenArray = Children.toArray(children).filter(Boolean);
|
||||
|
||||
// If there's only one child, it's the table content (no filter sidebar)
|
||||
const hasFilterSidebar = childrenArray.length > 1;
|
||||
const filterSidebar = hasFilterSidebar ? childrenArray[0] : null;
|
||||
const tableContent = hasFilterSidebar
|
||||
? childrenArray.slice(1)
|
||||
: childrenArray;
|
||||
|
||||
// On mobile, render children as-is (stacked layout)
|
||||
if (!isDesktop) {
|
||||
return <div className="flex flex-1 overflow-hidden">{children}</div>;
|
||||
}
|
||||
const tableContent = hasFilterSidebar ? childrenArray.slice(1) : childrenArray;
|
||||
|
||||
const filterDefault = 15;
|
||||
const tableDefault = 85;
|
||||
|
||||
// If sidebar doesn't exist, render only the table content
|
||||
if (!filterSidebar) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">{tableContent}</div>
|
||||
);
|
||||
// If sidebar is collapsed or doesn't exist, render only the table content
|
||||
if (!open || !filterSidebar) {
|
||||
return <div className="flex flex-1 flex-col overflow-hidden">{tableContent}</div>;
|
||||
}
|
||||
|
||||
const autoSaveId = tableName ? `filter-layout-${tableName}` : "filter-layout";
|
||||
|
||||
return (
|
||||
<ResizableDesktopLayout
|
||||
mainContent={
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{tableContent}
|
||||
</div>
|
||||
}
|
||||
sidebarContent={filterSidebar}
|
||||
open={open}
|
||||
defaultMainSize={tableDefault}
|
||||
defaultSidebarSize={filterDefault}
|
||||
minMainSize={50}
|
||||
maxSidebarSize={50}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="flex h-full w-full"
|
||||
autoSaveId={autoSaveId}
|
||||
sidebarPosition="left"
|
||||
/>
|
||||
storage={sessionStorage}
|
||||
>
|
||||
<ResizablePanel defaultSize={filterDefault} minSize={12} maxSize={50}>
|
||||
{filterSidebar}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={tableDefault} minSize={50}>
|
||||
<div className="flex h-full flex-col overflow-hidden">{tableContent}</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
+196
-365
@@ -1,7 +1,5 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { X, Plus, ChevronDown, Link, MoreVertical, Pen, Lock } from "lucide-react";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { HanzoIcon } from "@/src/components/HanzoLogo";
|
||||
import {
|
||||
DrawerTrigger,
|
||||
DrawerContent,
|
||||
@@ -10,7 +8,7 @@ import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
} from "@/src/components/ui/drawer";
|
||||
import { Separator } from "@hanzo/ui";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
import { useViewData } from "@/src/components/table/table-view-presets/hooks/useViewData";
|
||||
import {
|
||||
Command,
|
||||
@@ -31,51 +29,23 @@ import {
|
||||
type FilterState,
|
||||
type TableViewPresetTableName,
|
||||
type TableViewPresetDomain,
|
||||
} from "@hanzo/console-core";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
} from "@hanzo/shared";
|
||||
import { useMemo, useState } from "react";
|
||||
import { DropdownMenuItem, DropdownMenuTrigger } from "@/src/components/ui/dropdown-menu";
|
||||
import { DropdownMenu } from "@/src/components/ui/dropdown-menu";
|
||||
import { DropdownMenuContent } from "@/src/components/ui/dropdown-menu";
|
||||
import { DeleteButton } from "@/src/components/deleteButton";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Popover, PopoverContent } from "@hanzo/ui";
|
||||
import { PopoverTrigger } from "@hanzo/ui";
|
||||
import { Popover, PopoverContent } from "@/src/components/ui/popover";
|
||||
import { PopoverTrigger } from "@/src/components/ui/popover";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/src/components/ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod/v4";
|
||||
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
|
||||
import { useUniqueNameValidation } from "@/src/hooks/useUniqueNameValidation";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { useDefaultViewMutations } from "../hooks/useDefaultViewMutations";
|
||||
import { DropdownMenuSeparator } from "@/src/components/ui/dropdown-menu";
|
||||
|
||||
/**
|
||||
* Prefix for system preset IDs. These are page-specific presets defined in code
|
||||
* (not stored in DB). Using this prefix prevents DB lookups and allows special handling.
|
||||
* Convention: `__hanzo_{preset_name}__`
|
||||
*/
|
||||
export const SYSTEM_PRESET_ID_PREFIX = "__hanzo_";
|
||||
|
||||
/** Check if a view ID is a system preset (defined in code, not stored in DB) */
|
||||
export const isSystemPresetId = (id: string | undefined | null): boolean => !!id?.startsWith(SYSTEM_PRESET_ID_PREFIX);
|
||||
|
||||
/** Recursively remove undefined values for consistent comparison */
|
||||
function normalizeForComparison<T>(obj: T): T {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(normalizeForComparison) as T;
|
||||
}
|
||||
if (obj !== null && typeof obj === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => [k, normalizeForComparison(v)]),
|
||||
) as T;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
interface SystemPreset {
|
||||
id: string;
|
||||
@@ -91,13 +61,6 @@ const SYSTEM_PRESETS: { DEFAULT: SystemPreset } = {
|
||||
},
|
||||
};
|
||||
|
||||
export interface SystemFilterPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
filters: FilterState;
|
||||
}
|
||||
|
||||
interface TableViewPresetsDrawerProps {
|
||||
viewConfig: {
|
||||
tableName: TableViewPresetTableName;
|
||||
@@ -115,15 +78,13 @@ interface TableViewPresetsDrawerProps {
|
||||
columnVisibility: VisibilityState;
|
||||
searchQuery: string;
|
||||
};
|
||||
/** Page-specific system filter presets (e.g. "Last Generation in Trace") */
|
||||
systemFilterPresets?: SystemFilterPreset[];
|
||||
}
|
||||
|
||||
function formatOrderBy(orderBy?: OrderByState) {
|
||||
return orderBy?.column ? `${orderBy.column} ${orderBy.order}` : "none";
|
||||
}
|
||||
|
||||
export function TableViewPresetsDrawer({ viewConfig, currentState, systemFilterPresets }: TableViewPresetsDrawerProps) {
|
||||
export function TableViewPresetsDrawer({ viewConfig, currentState }: TableViewPresetsDrawerProps) {
|
||||
const [searchQuery, setSearchQueryLocal] = useState("");
|
||||
const { tableName, projectId, controllers } = viewConfig;
|
||||
const { handleSetViewId, applyViewState, selectedViewId } = controllers;
|
||||
@@ -131,7 +92,7 @@ export function TableViewPresetsDrawer({ viewConfig, currentState, systemFilterP
|
||||
const { createMutation, updateConfigMutation, updateNameMutation, deleteMutation, generatePermalinkMutation } =
|
||||
useViewMutations({ handleSetViewId });
|
||||
const utils = api.useUtils();
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(z.object({ name: z.string().min(1) })),
|
||||
@@ -145,33 +106,11 @@ export function TableViewPresetsDrawer({ viewConfig, currentState, systemFilterP
|
||||
scope: "TableViewPresets:CUD",
|
||||
});
|
||||
|
||||
const { data: currentDefault } = api.TableViewPresets.getDefault.useQuery(
|
||||
{ projectId, viewName: tableName },
|
||||
{ enabled: !!projectId },
|
||||
);
|
||||
|
||||
const { setViewAsDefault, clearViewDefault, isSettingDefault } = useDefaultViewMutations({ tableName, projectId });
|
||||
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isEditPopoverOpen, setIsEditPopoverOpen] = useState<boolean>(false);
|
||||
const [dropdownId, setDropdownId] = useState<string | null>(null);
|
||||
|
||||
const selectedViewName = useMemo(() => {
|
||||
// Check system filter presets first
|
||||
const systemPreset = systemFilterPresets?.find((p) => p.id === selectedViewId);
|
||||
if (systemPreset) {
|
||||
// Normalize both to handle missing vs undefined property mismatch
|
||||
const normalizedCurrent = normalizeForComparison(currentState.filters);
|
||||
const normalizedPreset = normalizeForComparison(systemPreset.filters);
|
||||
// If filters have been modified from the preset, show "Saved Views" instead
|
||||
if (!isEqual(normalizedCurrent, normalizedPreset)) {
|
||||
return undefined;
|
||||
}
|
||||
return systemPreset.name;
|
||||
}
|
||||
// Then check user presets
|
||||
return TableViewPresetsList?.find((v) => v.id === selectedViewId)?.name;
|
||||
}, [selectedViewId, systemFilterPresets, TableViewPresetsList, currentState.filters]);
|
||||
const selectedViewName = TableViewPresetsList?.find((view) => view.id === selectedViewId)?.name;
|
||||
|
||||
const allViewNames = useMemo(
|
||||
() => TableViewPresetsList?.map((view) => ({ value: view.name })) ?? [],
|
||||
@@ -212,32 +151,6 @@ export function TableViewPresetsDrawer({ viewConfig, currentState, systemFilterP
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSystemFilterPreset = useCallback(
|
||||
(preset: SystemFilterPreset) => {
|
||||
capture("saved_views:system_preset_selected", {
|
||||
tableName,
|
||||
presetId: preset.id,
|
||||
});
|
||||
handleSetViewId(preset.id);
|
||||
applyViewState({
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
filters: preset.filters,
|
||||
columnOrder: [],
|
||||
columnVisibility: {},
|
||||
orderBy: null,
|
||||
searchQuery: "",
|
||||
tableName,
|
||||
projectId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: "",
|
||||
createdByUser: null,
|
||||
} as TableViewPresetDomain);
|
||||
},
|
||||
[capture, tableName, handleSetViewId, applyViewState, projectId],
|
||||
);
|
||||
|
||||
const handleCreateView = (createdView: { name: string }) => {
|
||||
capture("saved_views:create", {
|
||||
tableName,
|
||||
@@ -342,7 +255,7 @@ export function TableViewPresetsDrawer({ viewConfig, currentState, systemFilterP
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
onOpenChange={(open: boolean) => {
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
capture("saved_views:drawer_open", { tableName });
|
||||
} else {
|
||||
@@ -351,8 +264,8 @@ export function TableViewPresetsDrawer({ viewConfig, currentState, systemFilterP
|
||||
}}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline" title={selectedViewName ? `View: ${selectedViewName}` : "Saved Views"}>
|
||||
<span>{selectedViewName ? `View: ${selectedViewName}` : "Saved Views"}</span>
|
||||
<Button variant="outline" title={selectedViewName ?? "Saved Views"}>
|
||||
<span>{selectedViewName ?? "Saved Views"}</span>
|
||||
{selectedViewId ? (
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
) : (
|
||||
@@ -362,11 +275,11 @@ export function TableViewPresetsDrawer({ viewConfig, currentState, systemFilterP
|
||||
</DrawerTrigger>
|
||||
<DrawerContent overlayClassName="bg-primary/10">
|
||||
<div className="mx-auto w-full">
|
||||
<DrawerHeader className="flex flex-row items-center justify-between rounded-sm bg-background px-3 py-1.5">
|
||||
<DrawerHeader className="flex flex-row items-center justify-between rounded-sm bg-background px-3 py-2">
|
||||
<DrawerTitle className="flex flex-row items-center gap-1">
|
||||
Saved Views{" "}
|
||||
Saved Table Views{" "}
|
||||
<a
|
||||
href="https://github.com/orgs/hanzo/discussions/4657"
|
||||
href="https://github.com/orgs/hanzoai/discussions/4657"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
@@ -383,284 +296,202 @@ export function TableViewPresetsDrawer({ viewConfig, currentState, systemFilterP
|
||||
|
||||
<Command className="h-fit rounded-none border-none pb-1 shadow-none">
|
||||
<CommandInput
|
||||
placeholder="Search saved views..."
|
||||
placeholder="Search saved table views..."
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQueryLocal}
|
||||
className="h-9 border-none focus:ring-0"
|
||||
className="h-12 border-none focus:ring-0"
|
||||
/>
|
||||
<CommandList className="max-h-[calc(100vh-150px)]">
|
||||
<CommandEmpty>No saved views found</CommandEmpty>
|
||||
<CommandEmpty>No saved table views found</CommandEmpty>
|
||||
<CommandGroup className="pb-0">
|
||||
{/* System Preset: Hanzo Default - hidden when page-specific presets exist */}
|
||||
{!systemFilterPresets?.length && (
|
||||
<CommandItem
|
||||
key={SYSTEM_PRESETS.DEFAULT.id}
|
||||
onSelect={() => handleSelectView(SYSTEM_PRESETS.DEFAULT.id)}
|
||||
className={cn(
|
||||
"group mt-1 flex cursor-pointer items-center justify-between rounded-md p-2 transition-colors hover:bg-muted/50",
|
||||
selectedViewId === null && "bg-muted",
|
||||
)}
|
||||
title="Reflects your current table settings without applying any saved custom table views"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">{SYSTEM_PRESETS.DEFAULT.name}</span>
|
||||
<span className="w-fit pl-0 text-xs text-muted-foreground">Your working view</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)}
|
||||
{/* System Preset: Hanzo Default */}
|
||||
<CommandItem
|
||||
key={SYSTEM_PRESETS.DEFAULT.id}
|
||||
onSelect={() => handleSelectView(SYSTEM_PRESETS.DEFAULT.id)}
|
||||
className={cn(
|
||||
"group mt-1 flex cursor-pointer items-center justify-between rounded-md p-2 transition-colors hover:bg-muted/50",
|
||||
selectedViewId === null && "bg-muted font-medium",
|
||||
)}
|
||||
title="Reflects your current table settings without applying any saved custom table views"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-muted-foreground">{SYSTEM_PRESETS.DEFAULT.name}</span>
|
||||
<span className="w-fit pl-0 text-xs text-muted-foreground">Your working view</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
{/* Page-specific System Filter Presets */}
|
||||
{systemFilterPresets?.map((preset) => (
|
||||
{/* User Presets */}
|
||||
{TableViewPresetsList?.map((view) => (
|
||||
<CommandItem
|
||||
key={preset.id}
|
||||
onSelect={() => handleSelectSystemFilterPreset(preset)}
|
||||
key={view.id}
|
||||
onSelect={() => handleSelectView(view.id)}
|
||||
className={cn(
|
||||
"group mt-1 flex cursor-pointer items-center justify-between rounded-md p-2 transition-colors hover:bg-muted/50",
|
||||
selectedViewId === preset.id &&
|
||||
isEqual(
|
||||
normalizeForComparison(currentState.filters),
|
||||
normalizeForComparison(preset.filters),
|
||||
) &&
|
||||
"bg-muted",
|
||||
selectedViewId === view.id && "bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<HanzoIcon size={14} />
|
||||
{preset.name}
|
||||
</span>
|
||||
{preset.description && (
|
||||
<span className="w-fit pl-0 text-xs text-muted-foreground">{preset.description}</span>
|
||||
<span className="text-sm font-medium">{view.name}</span>
|
||||
{view.id === selectedViewId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn(
|
||||
"w-fit pl-0 text-xs",
|
||||
hasWriteAccess ? "text-primary-accent" : "text-muted-foreground",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdateViewConfig({
|
||||
name: view.name,
|
||||
});
|
||||
}}
|
||||
disabled={!hasWriteAccess}
|
||||
>
|
||||
Update View
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleGeneratePermalink(view.id);
|
||||
}}
|
||||
className="w-4 opacity-0 group-hover:opacity-100 peer-data-[state=open]:opacity-100"
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
open={dropdownId === view.id}
|
||||
onOpenChange={(open) => {
|
||||
setDropdownId(open ? view.id : null);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="flex flex-col [&>*]:w-full [&>*]:justify-start">
|
||||
<DropdownMenuItem asChild>
|
||||
<Popover
|
||||
key={view.id + "-edit"}
|
||||
open={isEditPopoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsEditPopoverOpen(open);
|
||||
if (open) {
|
||||
form.reset({ name: view.name });
|
||||
capture("saved_views:update_form_open", {
|
||||
tableName,
|
||||
viewId: view.id,
|
||||
});
|
||||
} else {
|
||||
setDropdownId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={!hasWriteAccess}
|
||||
>
|
||||
{hasWriteAccess ? (
|
||||
<Pen className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Edit
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-md mb-3 font-semibold">Edit</h2>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit(view.id))} className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>View name</FormLabel>
|
||||
<FormControl>
|
||||
<Input defaultValue={view.name} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={updateNameMutation.isPending}
|
||||
disabled={!!form.formState.errors.name}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<DeleteButton
|
||||
itemId={view.id}
|
||||
projectId={projectId}
|
||||
scope="TableViewPresets:CUD"
|
||||
entityToDeleteName="saved view"
|
||||
executeDeleteMutation={async () => {
|
||||
await handleDeleteView(view.id);
|
||||
}}
|
||||
isDeleteMutationLoading={deleteMutation.isPending}
|
||||
invalidateFunc={() => {
|
||||
utils.TableViewPresets.invalidate();
|
||||
}}
|
||||
captureDeleteOpen={() =>
|
||||
capture("saved_views:delete_form_open", {
|
||||
tableName,
|
||||
viewId: view.id,
|
||||
})
|
||||
}
|
||||
captureDeleteSuccess={() => {}}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={view.createdByUser?.image ?? undefined}
|
||||
alt={view.createdByUser?.name ?? "User Avatar"}
|
||||
/>
|
||||
<AvatarFallback className="bg-tertiary">
|
||||
{view.createdByUser?.name
|
||||
? view.createdByUser?.name
|
||||
.split(" ")
|
||||
.map((word) => word[0])
|
||||
.slice(0, 2)
|
||||
.concat("")
|
||||
: null}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
{/* Separator between system and user presets */}
|
||||
{systemFilterPresets?.length && TableViewPresetsList?.length ? <Separator className="my-2" /> : null}
|
||||
|
||||
{/* User Presets */}
|
||||
{TableViewPresetsList?.map((view) => {
|
||||
const isUserDefault = currentDefault?.viewId === view.id && currentDefault?.scope === "user";
|
||||
const isProjectDefault = currentDefault?.viewId === view.id && currentDefault?.scope === "project";
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={view.id}
|
||||
onSelect={() => handleSelectView(view.id)}
|
||||
className={cn(
|
||||
"group mt-1 flex cursor-pointer items-center justify-between rounded-md p-2 transition-colors hover:bg-muted/50",
|
||||
selectedViewId === view.id && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{view.name}</span>
|
||||
{isUserDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Your default
|
||||
</Badge>
|
||||
)}
|
||||
{isProjectDefault && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Project default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{view.id === selectedViewId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn(
|
||||
"w-fit pl-0 text-xs",
|
||||
hasWriteAccess ? "text-primary-accent" : "text-muted-foreground",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdateViewConfig({
|
||||
name: view.name,
|
||||
});
|
||||
}}
|
||||
disabled={!hasWriteAccess}
|
||||
>
|
||||
Update view with current filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleGeneratePermalink(view.id);
|
||||
}}
|
||||
className="w-4 opacity-0 group-hover:opacity-100 peer-data-[state=open]:opacity-100"
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
open={dropdownId === view.id}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setDropdownId(open ? view.id : null);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="flex flex-col [&>*]:w-full [&>*]:justify-start">
|
||||
<DropdownMenuItem asChild>
|
||||
<Popover
|
||||
key={view.id + "-edit"}
|
||||
open={isEditPopoverOpen}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setIsEditPopoverOpen(open);
|
||||
if (open) {
|
||||
form.reset({ name: view.name });
|
||||
capture("saved_views:update_form_open", {
|
||||
tableName,
|
||||
viewId: view.id,
|
||||
});
|
||||
} else {
|
||||
setDropdownId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={!hasWriteAccess}
|
||||
>
|
||||
{hasWriteAccess ? (
|
||||
<Pen className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Rename
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<h2 className="text-md mb-3 font-semibold">Edit</h2>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit(view.id))} className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>View name</FormLabel>
|
||||
<FormControl>
|
||||
<Input defaultValue={view.name} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={updateNameMutation.isPending}
|
||||
disabled={!!form.formState.errors.name}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Set as my default */}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isUserDefault) {
|
||||
clearViewDefault("user");
|
||||
} else {
|
||||
setViewAsDefault(view.id, "user");
|
||||
}
|
||||
setDropdownId(null);
|
||||
}}
|
||||
disabled={isSettingDefault}
|
||||
>
|
||||
{isUserDefault ? <>Remove as my default</> : <>Set as my default</>}
|
||||
</DropdownMenuItem>
|
||||
{/* Set as project default - requires write access */}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isProjectDefault) {
|
||||
clearViewDefault("project");
|
||||
} else {
|
||||
setViewAsDefault(view.id, "project");
|
||||
}
|
||||
setDropdownId(null);
|
||||
}}
|
||||
disabled={!hasWriteAccess || isSettingDefault}
|
||||
>
|
||||
{isProjectDefault ? <>Remove as project default</> : <>Set as project default</>}
|
||||
{!hasWriteAccess && <Lock className="ml-auto h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<DeleteButton
|
||||
itemId={view.id}
|
||||
projectId={projectId}
|
||||
scope="TableViewPresets:CUD"
|
||||
entityToDeleteName="saved view"
|
||||
executeDeleteMutation={async () => {
|
||||
await handleDeleteView(view.id);
|
||||
}}
|
||||
isDeleteMutationLoading={deleteMutation.isPending}
|
||||
invalidateFunc={() => {
|
||||
utils.TableViewPresets.invalidate();
|
||||
}}
|
||||
captureDeleteOpen={() =>
|
||||
capture("saved_views:delete_form_open", {
|
||||
tableName,
|
||||
viewId: view.id,
|
||||
})
|
||||
}
|
||||
captureDeleteSuccess={() => {}}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={view.createdByUser?.image ?? undefined}
|
||||
alt={view.createdByUser?.name ?? "User Avatar"}
|
||||
/>
|
||||
<AvatarFallback className="bg-tertiary">
|
||||
{view.createdByUser?.name
|
||||
? view.createdByUser?.name
|
||||
.split(" ")
|
||||
.map((word) => word[0])
|
||||
.slice(0, 2)
|
||||
.concat("")
|
||||
: null}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -687,7 +518,7 @@ export function TableViewPresetsDrawer({ viewConfig, currentState, systemFilterP
|
||||
{/* Create View Dialog */}
|
||||
<Dialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={(open: boolean) => {
|
||||
onOpenChange={(open) => {
|
||||
setIsCreateDialogOpen(open);
|
||||
if (!open) {
|
||||
form.reset({ name: "" });
|
||||
|
||||
@@ -5,20 +5,18 @@ import {
|
||||
type OrderByState,
|
||||
type TableViewPresetDomain,
|
||||
type ColumnDefinition,
|
||||
} from "@hanzo/console-core";
|
||||
import { type DefaultViewScope } from "@hanzo/console-core/src/server";
|
||||
} from "@hanzo/shared";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { type VisibilityState } from "@tanstack/react-table";
|
||||
import { StringParam, withDefault } from "use-query-params";
|
||||
import useSessionStorage from "@/src/components/useSessionStorage";
|
||||
import { useQueryParam } from "use-query-params";
|
||||
import { type ConsoleColumnDef } from "@/src/components/table/types";
|
||||
import { type HanzoColumnDef } from "@/src/components/table/types";
|
||||
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { useInsightsCapture } from "@/src/features/insights-analytics/useInsightsCapture";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { validateOrderBy, validateFilters } from "../validation";
|
||||
import { isSystemPresetId } from "../components/data-table-view-presets-drawer";
|
||||
|
||||
interface TableStateUpdaters {
|
||||
setColumnOrder: (columnOrder: string[]) => void;
|
||||
@@ -33,7 +31,7 @@ interface UseTableStateProps {
|
||||
projectId: string;
|
||||
stateUpdaters: TableStateUpdaters;
|
||||
validationContext?: {
|
||||
columns?: ConsoleColumnDef<any, any>[];
|
||||
columns?: HanzoColumnDef<any, any>[];
|
||||
filterColumnDefinition?: ColumnDefinition[];
|
||||
};
|
||||
currentFilterState?: FilterState;
|
||||
@@ -53,32 +51,21 @@ export function useTableViewManager({
|
||||
const { viewId } = router.query;
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const capture = useInsightsCapture();
|
||||
const capture = usePostHogClientCapture();
|
||||
const pendingFiltersRef = useRef<FilterState | null>(null);
|
||||
|
||||
const [storedViewId, setStoredViewId] = useSessionStorage<string | null>(`${tableName}-${projectId}-viewId`, null);
|
||||
const [selectedViewId, setSelectedViewId] = useQueryParam("viewId", withDefault(StringParam, storedViewId));
|
||||
|
||||
// Query for resolved default view (user > project > null)
|
||||
const { data: resolvedDefault, isLoading: isDefaultLoading } = api.TableViewPresets.getDefault.useQuery(
|
||||
{ projectId, viewName: tableName },
|
||||
{
|
||||
enabled: !!projectId,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
},
|
||||
);
|
||||
|
||||
// Keep track of the viewId in session storage and in the query params
|
||||
const handleSetViewId = useCallback(
|
||||
(viewId: string | null) => {
|
||||
// to ensure immediate sync -> update URL
|
||||
const url = new URL(window.location.href);
|
||||
if (viewId === null) {
|
||||
// When clearing, remove from URL first to prevent sync loop
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("viewId");
|
||||
} else {
|
||||
url.searchParams.set("viewId", viewId);
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
setStoredViewId(viewId);
|
||||
setSelectedViewId(viewId);
|
||||
},
|
||||
@@ -99,81 +86,32 @@ export function useTableViewManager({
|
||||
setOrderByRef.current = setOrderBy;
|
||||
setSearchQueryRef.current = setSearchQuery;
|
||||
|
||||
// Ref to prevent re-dispatching setSelectedViewId while waiting for router.query to catch up.
|
||||
const pendingResolveRef = useRef(false);
|
||||
// Extract primitive for effect dep (rerender-dependencies: avoid object deps)
|
||||
const defaultViewId = resolvedDefault?.viewId;
|
||||
|
||||
// Single resolve effect: walk priority list and either return early (pending) or initialize.
|
||||
//
|
||||
// IMPORTANT: This effect must NOT call handleSetViewId to set a viewId, because
|
||||
// handleSetViewId does replaceState before setSelectedViewId.
|
||||
// use-query-params reads window.location.search to detect changes,
|
||||
// so the prior replaceState makes it think nothing changed (skipUpdateWhenNoChange)
|
||||
// → router.replace is never called → router.query never updates → getById query never fires.
|
||||
// Instead, call setSelectedViewId directly so use-query-params sees a genuine URL change.
|
||||
useEffect(() => {
|
||||
if (isInitialized) return;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const viewIdInUrl = urlParams.get("viewId");
|
||||
|
||||
// If viewId already in router.query and not a system preset → getById query handles it.
|
||||
// Sync to session storage so navigating away and back restores the view.
|
||||
if (viewId && !isSystemPresetId(viewId as string)) {
|
||||
setStoredViewId(viewId as string);
|
||||
return;
|
||||
}
|
||||
|
||||
// Already dispatched a viewId via setSelectedViewId, waiting for router.query to update
|
||||
if (pendingResolveRef.current) return;
|
||||
|
||||
// Clear stale system preset from URL (e.g. navigated from session detail)
|
||||
if (viewId && isSystemPresetId(viewId as string)) {
|
||||
handleSetViewId(null);
|
||||
// fall through to resolve from other sources
|
||||
}
|
||||
|
||||
// Priority 1: Session storage (from a previous visit to this table)
|
||||
if (storedViewId && !isSystemPresetId(storedViewId)) {
|
||||
pendingResolveRef.current = true;
|
||||
// If no viewId in URL but we have one in storage, use that
|
||||
if (!viewIdInUrl && storedViewId) {
|
||||
setSelectedViewId(storedViewId);
|
||||
return; // router.query updates next render → early return above → getById fires
|
||||
}
|
||||
|
||||
// Priority 2: Default view (wait for query to resolve)
|
||||
if (isDefaultLoading) return;
|
||||
|
||||
if (defaultViewId) {
|
||||
if (isSystemPresetId(defaultViewId)) {
|
||||
// System presets don't need data fetching, initialize immediately
|
||||
setSelectedViewId(defaultViewId);
|
||||
setIsInitialized(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
pendingResolveRef.current = true;
|
||||
setStoredViewId(defaultViewId);
|
||||
setSelectedViewId(defaultViewId);
|
||||
return; // router.query updates next render → early return above → getById fires
|
||||
// If there's a viewId in the URL, update our storage
|
||||
else if (viewIdInUrl) {
|
||||
setStoredViewId(viewIdInUrl);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [storedViewId, setStoredViewId, setSelectedViewId]);
|
||||
|
||||
// Priority 3: Nothing to apply
|
||||
setIsInitialized(true);
|
||||
setIsLoading(false);
|
||||
}, [
|
||||
viewId,
|
||||
isInitialized,
|
||||
storedViewId,
|
||||
isDefaultLoading,
|
||||
defaultViewId,
|
||||
handleSetViewId,
|
||||
setStoredViewId,
|
||||
setSelectedViewId,
|
||||
]);
|
||||
|
||||
// Fetch view data if viewId is provided (skip for system presets)
|
||||
const { data: viewData, error: viewError } = api.TableViewPresets.getById.useQuery(
|
||||
// Fetch view data if viewId is provided
|
||||
const {
|
||||
data: viewData,
|
||||
isLoading: isViewLoading,
|
||||
error: viewError,
|
||||
} = api.TableViewPresets.getById.useQuery(
|
||||
{ viewId: viewId as string, projectId },
|
||||
{
|
||||
enabled: !!viewId && !isInitialized && !isSystemPresetId(viewId as string),
|
||||
enabled: !!viewId && !isInitialized,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -264,6 +202,16 @@ export function useTableViewManager({
|
||||
}
|
||||
}, [viewError, isInitialized, setIsLoading, handleSetViewId]);
|
||||
|
||||
// Initialize on mount if no viewId
|
||||
useEffect(() => {
|
||||
if (!isInitialized && !isViewLoading && !viewId) {
|
||||
// No view to load - just mark as initialized
|
||||
// The individual state hooks will have their own defaults
|
||||
setIsInitialized(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isInitialized, isViewLoading, viewId]);
|
||||
|
||||
// Observe when filter state propagates from saved view
|
||||
// After calling setFilters, URL updates async → filterState recalculates → this effect detects completion
|
||||
useEffect(() => {
|
||||
@@ -281,6 +229,5 @@ export function useTableViewManager({
|
||||
applyViewState,
|
||||
handleSetViewId,
|
||||
selectedViewId,
|
||||
defaultViewScope: resolvedDefault?.scope as DefaultViewScope | null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ConsoleColumnDef } from "@/src/components/table/types";
|
||||
import { type FilterState, type ColumnDefinition, type OrderByState } from "@hanzo/console-core";
|
||||
import { type HanzoColumnDef } from "@/src/components/table/types";
|
||||
import { type FilterState, type ColumnDefinition, type OrderByState } from "@hanzo/shared";
|
||||
import { normalizeFilterColumnNames } from "@/src/features/filters/lib/filter-transform";
|
||||
|
||||
/**
|
||||
@@ -7,7 +7,7 @@ import { normalizeFilterColumnNames } from "@/src/features/filters/lib/filter-tr
|
||||
*/
|
||||
export function validateOrderBy(
|
||||
orderBy: OrderByState | null,
|
||||
columns?: ConsoleColumnDef<any, any>[],
|
||||
columns?: HanzoColumnDef<any, any>[],
|
||||
): OrderByState | null {
|
||||
if (!orderBy || !columns || columns.length === 0) return null;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user