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:
2026-06-01 18:44:18 -07:00
575 changed files with 20563 additions and 24921 deletions
+145
View File
@@ -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
View File
@@ -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"
}
}
+5275 -9049
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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) => {
+10 -67
View File
@@ -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);
});
+20 -40
View File
@@ -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",
});
+46 -46
View File
@@ -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,
},
});
+5 -77
View File
@@ -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();
});
+15 -15
View File
@@ -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
+15 -15
View File
@@ -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", () => {
+15 -15
View File
@@ -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);
});
});
+9 -735
View File
@@ -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);
+4 -18
View File
@@ -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 = [
+19 -19
View File
@@ -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);
});
+25 -27
View File
@@ -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",
},
});
+4 -4
View File
@@ -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 -2
View File
@@ -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 -1
View File
@@ -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 {
+5 -19
View File
@@ -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 && (
+6 -4
View File
@@ -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>
);
+2 -2
View File
@@ -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 {
+11 -11
View File
@@ -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" />
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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> = {
+9 -9
View File
@@ -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">
+8 -21
View File
@@ -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] : []),
+2 -2
View File
@@ -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>
+9 -3
View File
@@ -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" />
+16 -8
View File
@@ -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>
);
+6 -107
View File
@@ -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;
+6 -25
View File
@@ -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&amp;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"} &rarr;
</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"} &rarr;
</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>
/>
);
}
+5 -5
View File
@@ -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",
+9 -12
View File
@@ -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>
) : (
+20 -529
View File
@@ -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],
);
+23 -297
View File
@@ -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}
+22 -28
View File
@@ -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">
+14 -11
View File
@@ -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>
);
}
@@ -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