@oneuptime/common 12.0.5 → 12.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/AIRun.ts +33 -0
- package/Models/DatabaseModels/DataSource.ts +744 -0
- package/Models/DatabaseModels/Index.ts +3 -0
- package/Models/DatabaseModels/ProjectCallSMSConfig.ts +10 -2
- package/Models/DatabaseModels/User.ts +2 -0
- package/Models/DatabaseModels/UserOnCallLog.ts +2 -0
- package/Models/DatabaseModels/UserOnCallLogTimeline.ts +2 -0
- package/Models/DatabaseModels/UserSession.ts +2 -0
- package/Models/DatabaseModels/UserTotpAuth.ts +6 -4
- package/Models/DatabaseModels/UserWebAuthn.ts +5 -3
- package/Server/API/AIInsightAPI.ts +47 -4
- package/Server/API/AIInvestigationAPI.ts +25 -0
- package/Server/API/DashboardAPI.ts +176 -492
- package/Server/API/DataSourceAPI.ts +268 -0
- package/Server/API/StatusPageAPI.ts +11 -1
- package/Server/API/UserAPI.ts +119 -0
- package/Server/Infrastructure/ClickhouseConfig.ts +59 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786303472848-AddDataSourceTable.ts +39 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786600000000-AddInvestigationAnalysisTldr.ts +24 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786700000000-DropProjectCallSMSConfigCredentialUniques.ts +76 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
- package/Server/Infrastructure/Queue.ts +26 -0
- package/Server/Infrastructure/QueueWorker.ts +17 -0
- package/Server/Middleware/TelemetryIngest.ts +8 -3
- package/Server/Services/AIInsightService.ts +132 -12
- package/Server/Services/AIRunService.ts +34 -0
- package/Server/Services/AIService.ts +17 -2
- package/Server/Services/AlertService.ts +38 -32
- package/Server/Services/AnalyticsDatabaseService.ts +8 -4
- package/Server/Services/DataSourceService.ts +200 -0
- package/Server/Services/HostService.ts +92 -0
- package/Server/Services/IncidentService.ts +45 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/MonitorService.ts +49 -0
- package/Server/Services/MonitorStatusService.ts +166 -0
- package/Server/Services/OpenTelemetryIngestService.ts +255 -184
- package/Server/Services/RumSessionPinService.ts +7 -21
- package/Server/Services/StatusPageSubscriberService.ts +13 -0
- package/Server/Services/UserService.ts +280 -9
- package/Server/Static/Vendor/README.md +62 -0
- package/Server/Static/Vendor/fonts/InterVariable.woff2 +0 -0
- package/Server/Static/Vendor/highlight/highlight.min.js +1244 -0
- package/Server/Static/Vendor/highlight/languages/apache.min.js +15 -0
- package/Server/Static/Vendor/highlight/languages/bash.min.js +21 -0
- package/Server/Static/Vendor/highlight/languages/c.min.js +41 -0
- package/Server/Static/Vendor/highlight/languages/cpp.min.js +47 -0
- package/Server/Static/Vendor/highlight/languages/csharp.min.js +49 -0
- package/Server/Static/Vendor/highlight/languages/css.min.js +31 -0
- package/Server/Static/Vendor/highlight/languages/diff.min.js +9 -0
- package/Server/Static/Vendor/highlight/languages/dns.min.js +11 -0
- package/Server/Static/Vendor/highlight/languages/dockerfile.min.js +8 -0
- package/Server/Static/Vendor/highlight/languages/go.min.js +20 -0
- package/Server/Static/Vendor/highlight/languages/graphql.min.js +12 -0
- package/Server/Static/Vendor/highlight/languages/http.min.js +14 -0
- package/Server/Static/Vendor/highlight/languages/ini.min.js +15 -0
- package/Server/Static/Vendor/highlight/languages/java.min.js +38 -0
- package/Server/Static/Vendor/highlight/languages/javascript.min.js +81 -0
- package/Server/Static/Vendor/highlight/languages/json.min.js +8 -0
- package/Server/Static/Vendor/highlight/languages/kotlin.min.js +46 -0
- package/Server/Static/Vendor/highlight/languages/lua.min.js +15 -0
- package/Server/Static/Vendor/highlight/languages/makefile.min.js +14 -0
- package/Server/Static/Vendor/highlight/languages/nginx.min.js +21 -0
- package/Server/Static/Vendor/highlight/languages/perl.min.js +41 -0
- package/Server/Static/Vendor/highlight/languages/php.min.js +59 -0
- package/Server/Static/Vendor/highlight/languages/powershell.min.js +39 -0
- package/Server/Static/Vendor/highlight/languages/python.min.js +42 -0
- package/Server/Static/Vendor/highlight/languages/r.min.js +26 -0
- package/Server/Static/Vendor/highlight/languages/ruby.min.js +54 -0
- package/Server/Static/Vendor/highlight/languages/rust.min.js +27 -0
- package/Server/Static/Vendor/highlight/languages/scala.min.js +28 -0
- package/Server/Static/Vendor/highlight/languages/sql.min.js +21 -0
- package/Server/Static/Vendor/highlight/languages/swift.min.js +68 -0
- package/Server/Static/Vendor/highlight/languages/typescript.min.js +99 -0
- package/Server/Static/Vendor/highlight/languages/xml.min.js +29 -0
- package/Server/Static/Vendor/highlight/languages/yaml.min.js +28 -0
- package/Server/Static/Vendor/highlight/styles/github-dark.min.css +10 -0
- package/Server/Static/Vendor/highlight/styles/vs2015.min.css +1 -0
- package/Server/Static/Vendor/tailwind/tailwind-3.4.5.js +21028 -0
- package/Server/Types/Database/Permissions/BasePermission.ts +7 -0
- package/Server/Types/Database/Permissions/DeletePermission.ts +2 -0
- package/Server/Types/Database/Permissions/ReadPermission.ts +3 -0
- package/Server/Types/Database/Permissions/TenantPermission.ts +101 -16
- package/Server/Utils/AI/CodeFix/FixRunBudget.ts +2 -2
- package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +90 -11
- package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +1 -0
- package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +1 -0
- package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.ts +125 -0
- package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.ts +25 -15
- package/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.ts +256 -57
- package/Server/Utils/AI/SRE/Insights/InsightStore.ts +76 -18
- package/Server/Utils/AI/SRE/InvestigationTldr.ts +352 -0
- package/Server/Utils/AnalyticsDatabase/InsertDedupContext.ts +7 -2
- package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +1793 -0
- package/Server/Utils/Dashboard/PublicDashboardViewConfig.ts +65 -0
- package/Server/Utils/DataSource/ConnectorRegistry.ts +53 -0
- package/Server/Utils/DataSource/Connectors/ClickHouseConnector.ts +338 -0
- package/Server/Utils/DataSource/Connectors/ElasticsearchConnector.ts +717 -0
- package/Server/Utils/DataSource/Connectors/LokiConnector.ts +606 -0
- package/Server/Utils/DataSource/Connectors/MySqlConnector.ts +307 -0
- package/Server/Utils/DataSource/Connectors/PostgresConnector.ts +260 -0
- package/Server/Utils/DataSource/Connectors/PrometheusConnector.ts +555 -0
- package/Server/Utils/DataSource/Connectors/RestApiConnector.ts +597 -0
- package/Server/Utils/DataSource/Connectors/SqlServerConnector.ts +318 -0
- package/Server/Utils/DataSource/EgressGuard.ts +446 -0
- package/Server/Utils/DataSource/HttpFetch.ts +221 -0
- package/Server/Utils/DataSource/SqlQueryGuard.ts +383 -0
- package/Server/Utils/DataSource/SqlResultShaper.ts +372 -0
- package/Server/Utils/DataSource/Types.ts +93 -0
- package/Server/Utils/Database/PostgresErrorTranslator.ts +172 -17
- package/Server/Utils/InProcessMemo.ts +141 -0
- package/Server/Utils/InstanceHealth/TelemetryIngestion.ts +628 -0
- package/Server/Utils/Monitor/MonitorMetricUtil.ts +58 -0
- package/Server/Utils/Monitor/MonitorResource.ts +14 -0
- package/Server/Utils/Response.ts +12 -1
- package/Server/Utils/SSRFProtection.ts +237 -0
- package/Server/Utils/StartServer.ts +8 -0
- package/Server/Utils/StatusPageSubscriberWebhook.ts +30 -0
- package/Server/Utils/Telemetry/CaptureSpan.ts +30 -18
- package/Server/Utils/Telemetry/IngestionTimestamp.ts +75 -0
- package/Server/Utils/Telemetry/ResourceHeartbeat.ts +99 -7
- package/Server/Utils/Telemetry/Telemetry.ts +112 -1
- package/Server/Utils/Telemetry.ts +104 -3
- package/Server/Utils/VendorAssets.ts +165 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +28 -5
- package/Server/Utils/Workspace/Slack/Slack.ts +5 -0
- package/Server/Views/Partials/Head.ejs +21 -7
- package/Tests/App/AdminDashboard/AdminProjectsIdFilter.test.tsx +207 -0
- package/Tests/App/AdminDashboard/AdminUsersIdFilter.test.tsx +216 -0
- package/Tests/App/Dashboard/AIInsightViewActions.test.tsx +422 -0
- package/Tests/App/Dashboard/AddWidgetModal.test.tsx +10 -3
- package/Tests/App/Dashboard/AlertsSideMenu.test.tsx +387 -0
- package/Tests/App/Dashboard/ChatActivityFeed.test.tsx +199 -0
- package/Tests/App/Dashboard/DataSourceChartSyntheticConfig.test.tsx +736 -0
- package/Tests/App/Dashboard/DataSourceQueryEditor.test.tsx +868 -0
- package/Tests/App/Dashboard/DataSourceWidgetCatalog.test.ts +440 -0
- package/Tests/App/Dashboard/DataSourceWidgetFetching.test.tsx +2109 -0
- package/Tests/App/Dashboard/DataSourceWidgetViews.test.tsx +726 -0
- package/Tests/App/Dashboard/IncidentsSideMenu.test.tsx +447 -0
- package/Tests/App/Dashboard/InvestigationPanel.test.tsx +514 -3
- package/Tests/App/Dashboard/ProductMenuBreadcrumbs.test.tsx +391 -0
- package/Tests/App/Dashboard/ScheduledMaintenanceSideMenu.test.tsx +326 -0
- package/Tests/App/Dashboard/SideMenuHarness.tsx +187 -0
- package/Tests/App/Dashboard/WidgetCatalog.test.ts +6 -1
- package/Tests/App/StatusPage/StatusPageResourceExplorer.test.tsx +1858 -0
- package/Tests/Models/DatabaseModels/DataSourceModelSecrets.test.ts +70 -0
- package/Tests/Server/API/AIInvestigationAPI.test.ts +187 -0
- package/Tests/Server/API/DashboardPublicDataSourceStrip.test.ts +686 -0
- package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +585 -823
- package/Tests/Server/API/DataSourceAPIRoutes.test.ts +486 -0
- package/Tests/Server/API/StatusPageSubscriberOwnership.test.ts +291 -0
- package/Tests/Server/API/UserAuthenticationAPI.test.ts +641 -0
- package/Tests/Server/Infrastructure/ClickhouseKeepAliveOptions.test.ts +441 -0
- package/Tests/Server/Middleware/TelemetryIngestTokenLog.test.ts +140 -0
- package/Tests/Server/Services/AIInsightService.test.ts +266 -11
- package/Tests/Server/Services/AIRunAnalysisTldrPersistence.test.ts +160 -0
- package/Tests/Server/Services/AIServiceDailyBudget.test.ts +4 -0
- package/Tests/Server/Services/AlertMetricResourceAttributes.test.ts +352 -0
- package/Tests/Server/Services/DataSourceServiceSettings.test.ts +244 -0
- package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +397 -0
- package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +41 -2
- package/Tests/Server/Services/IncidentMetricResourceAttributes.test.ts +408 -0
- package/Tests/Server/Services/MonitorStatusServiceDeleteReferenceCleanup.test.ts +351 -0
- package/Tests/Server/Services/MonitorStatusServiceValidation.test.ts +171 -0
- package/Tests/Server/Services/OpenTelemetryResourceRetentionMemo.test.ts +398 -0
- package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +19 -0
- package/Tests/Server/Services/ProjectCallSMSConfigCredentialUniqueness.test.ts +305 -0
- package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +36 -1
- package/Tests/Server/Services/RumSessionPinUniqueViolationRecovery.test.ts +163 -0
- package/Tests/Server/Services/UserAuthenticationService.test.ts +973 -0
- package/Tests/Server/Types/Database/ObjectIdColumnQuery.test.ts +262 -0
- package/Tests/Server/Types/Database/Permissions/TenantPermission.test.ts +358 -0
- package/Tests/Server/Utils/AI/FixRunBudget.test.ts +2 -2
- package/Tests/Server/Utils/AI/Insights/ExceptionSpikeDetector.test.ts +6 -1
- package/Tests/Server/Utils/AI/Insights/InsightStore.test.ts +121 -4
- package/Tests/Server/Utils/AI/Insights/NewExceptionDetector.test.ts +361 -10
- package/Tests/Server/Utils/AI/InvestigationSettlement.test.ts +292 -0
- package/Tests/Server/Utils/AI/InvestigationTldr.test.ts +623 -0
- package/Tests/Server/Utils/AI/InvestigationTldrWiring.test.ts +98 -0
- package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +857 -0
- package/Tests/Server/Utils/Dashboard/PublicDashboardViewConfig.test.ts +480 -0
- package/Tests/Server/Utils/DataSource/ClickHouseConnector.test.ts +759 -0
- package/Tests/Server/Utils/DataSource/ConnectorRegistry.test.ts +37 -0
- package/Tests/Server/Utils/DataSource/EgressGuard.test.ts +645 -0
- package/Tests/Server/Utils/DataSource/ElasticsearchConnector.test.ts +1206 -0
- package/Tests/Server/Utils/DataSource/HttpFetch.test.ts +616 -0
- package/Tests/Server/Utils/DataSource/LokiConnector.test.ts +970 -0
- package/Tests/Server/Utils/DataSource/MySqlConnector.test.ts +935 -0
- package/Tests/Server/Utils/DataSource/PostgresConnector.test.ts +854 -0
- package/Tests/Server/Utils/DataSource/PrometheusConnector.test.ts +881 -0
- package/Tests/Server/Utils/DataSource/RestApiConnector.test.ts +1020 -0
- package/Tests/Server/Utils/DataSource/SqlQueryGuard.test.ts +425 -0
- package/Tests/Server/Utils/DataSource/SqlResultShaper.test.ts +555 -0
- package/Tests/Server/Utils/DataSource/SqlServerConnector.test.ts +934 -0
- package/Tests/Server/Utils/Database/PostgresErrorTranslator.test.ts +228 -7
- package/Tests/Server/Utils/InProcessMemo.test.ts +292 -0
- package/Tests/Server/Utils/InstanceHealth/TelemetryIngestion.test.ts +960 -0
- package/Tests/Server/Utils/MicrosoftTeamsWebhookUrlValidation.test.ts +103 -0
- package/Tests/Server/Utils/Monitor/MonitorMetricResourceAttributes.test.ts +336 -0
- package/Tests/Server/Utils/OfflineAssetHygiene.test.ts +521 -0
- package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +143 -0
- package/Tests/Server/Utils/SSRFProtection.test.ts +193 -0
- package/Tests/Server/Utils/StatusPageSubscriberWebhook.test.ts +101 -0
- package/Tests/Server/Utils/TailwindBuildCopy.test.ts +225 -0
- package/Tests/Server/Utils/Telemetry/ResourceHeartbeatL1Memo.test.ts +355 -0
- package/Tests/Server/Utils/Telemetry/TelemetryFanInWriter.test.ts +128 -0
- package/Tests/Server/Utils/Telemetry/TelemetryUtilAttributeKeys.test.ts +381 -0
- package/Tests/Server/Utils/Telemetry.test.ts +87 -0
- package/Tests/Server/Utils/VendorAssetIntegrity.test.ts +343 -0
- package/Tests/Server/Utils/VendorAssets.test.ts +671 -0
- package/Tests/Server/Views/OfflineRender.test.ts +297 -0
- package/Tests/Types/Dashboard/DataSourceComponentTypes.test.ts +618 -0
- package/Tests/Types/DateClickhouseFormat.test.ts +192 -0
- package/Tests/Types/Password.test.ts +363 -0
- package/Tests/UI/Components/ConfirmModal.test.tsx +7 -0
- package/Tests/UI/Components/ErrorBoundary.test.tsx +53 -0
- package/Tests/UI/Components/Modal.test.tsx +281 -1
- package/Tests/UI/Components/ObjectIdFilterPipeline.test.tsx +396 -0
- package/Tests/UI/Components/StatusPage/ResourceGroupNavigator.test.tsx +1147 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +116 -1
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Tests/Utils/Analytics.test.ts +23 -0
- package/Tests/Utils/Dashboard/Components/DashboardDataSourceComponents.test.ts +640 -0
- package/Tests/Utils/DataSource/DataSourceQueryText.test.ts +138 -0
- package/Tests/Utils/DataSource/DataSourceValueReducer.test.ts +526 -0
- package/Tests/Utils/Metrics/MetricResourceAttributeUtil.test.ts +691 -0
- package/Tests/Utils/StatusPage/GroupHierarchyView.test.ts +1452 -0
- package/Tests/Utils/StatusPage/ResourceExplorer.test.ts +931 -0
- package/Tests/Utils/Telemetry/CaptureSpanExportGating.test.ts +288 -0
- package/Tests/Utils/Telemetry/IngestionTimestamp.test.ts +170 -0
- package/Types/AI/AIInsightEvidence.ts +6 -0
- package/Types/Analytics/RevenueEvent.ts +31 -0
- package/Types/Dashboard/DashboardComponentType.ts +31 -0
- package/Types/Dashboard/DashboardComponents/DashboardDataSourceChartComponent.ts +27 -0
- package/Types/Dashboard/DashboardComponents/DashboardDataSourceGaugeComponent.ts +32 -0
- package/Types/Dashboard/DashboardComponents/DashboardDataSourceTableComponent.ts +30 -0
- package/Types/Dashboard/DashboardComponents/DashboardDataSourceValueComponent.ts +55 -0
- package/Types/DataSource/DataSourceLimits.ts +62 -0
- package/Types/DataSource/DataSourceQueryConfig.ts +41 -0
- package/Types/DataSource/DataSourceTableResult.ts +38 -0
- package/Types/DataSource/DataSourceType.ts +213 -0
- package/Types/Date.ts +43 -3
- package/Types/Password.ts +64 -0
- package/Types/Permission.ts +46 -0
- package/Types/UserAuthenticationStatus.ts +34 -0
- package/UI/Components/Button/Button.tsx +1 -1
- package/UI/Components/ErrorBoundary.tsx +459 -108
- package/UI/Components/Icon/Icon.tsx +10 -9
- package/UI/Components/Modal/ConfirmModal.tsx +6 -1
- package/UI/Components/Modal/Modal.tsx +267 -35
- package/UI/Components/Modal/ModalFooter.tsx +6 -1
- package/UI/Components/MoreMenu/MoreMenu.tsx +19 -4
- package/UI/Components/MoreMenu/MoreMenuItem.tsx +6 -1
- package/UI/Components/StatusPage/ResourceGrid.tsx +314 -0
- package/UI/Components/StatusPage/ResourceGroupNavigator.tsx +808 -0
- package/UI/Components/StatusPage/ResourceList.tsx +570 -0
- package/UI/Styles/Theme.css +18 -0
- package/UI/Utils/API/RequestOptions.ts +7 -0
- package/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.ts +1 -0
- package/UI/Utils/ModelAPI/ModelAPI.ts +1 -0
- package/UI/Utils/TestDataSource.ts +61 -0
- package/UI/esbuild-config.js +45 -0
- package/Utils/Analytics.ts +15 -0
- package/Utils/Dashboard/Components/DashboardDataSourceChartComponent.ts +88 -0
- package/Utils/Dashboard/Components/DashboardDataSourceGaugeComponent.ts +123 -0
- package/Utils/Dashboard/Components/DashboardDataSourceShared.ts +16 -0
- package/Utils/Dashboard/Components/DashboardDataSourceTableComponent.ts +72 -0
- package/Utils/Dashboard/Components/DashboardDataSourceValueComponent.ts +126 -0
- package/Utils/Dashboard/Components/Index.ts +28 -0
- package/Utils/DataSource/DataSourceQueryText.ts +94 -0
- package/Utils/DataSource/DataSourceValueReducer.ts +156 -0
- package/Utils/Metrics/MetricResourceAttributeUtil.ts +380 -0
- package/Utils/NetworkDiscovery/ScanTargetUtil.ts +8 -6
- package/Utils/StatusPage/GroupHierarchyView.ts +931 -0
- package/Utils/StatusPage/ResourceExplorer.ts +853 -0
- package/build/dist/Models/DatabaseModels/AIRun.js +34 -0
- package/build/dist/Models/DatabaseModels/AIRun.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DataSource.js +774 -0
- package/build/dist/Models/DatabaseModels/DataSource.js.map +1 -0
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ProjectCallSMSConfig.js +14 -4
- package/build/dist/Models/DatabaseModels/ProjectCallSMSConfig.js.map +1 -1
- package/build/dist/Models/DatabaseModels/User.js +2 -0
- package/build/dist/Models/DatabaseModels/User.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserOnCallLog.js +2 -0
- package/build/dist/Models/DatabaseModels/UserOnCallLog.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +2 -0
- package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserSession.js +2 -0
- package/build/dist/Models/DatabaseModels/UserSession.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTotpAuth.js +6 -4
- package/build/dist/Models/DatabaseModels/UserTotpAuth.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserWebAuthn.js +5 -3
- package/build/dist/Models/DatabaseModels/UserWebAuthn.js.map +1 -1
- package/build/dist/Server/API/AIInsightAPI.js +34 -4
- package/build/dist/Server/API/AIInsightAPI.js.map +1 -1
- package/build/dist/Server/API/AIInvestigationAPI.js +23 -0
- package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +119 -433
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/DataSourceAPI.js +173 -0
- package/build/dist/Server/API/DataSourceAPI.js.map +1 -0
- package/build/dist/Server/API/StatusPageAPI.js +11 -1
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/API/UserAPI.js +94 -2
- package/build/dist/Server/API/UserAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/ClickhouseConfig.js +49 -0
- package/build/dist/Server/Infrastructure/ClickhouseConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786303472848-AddDataSourceTable.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786303472848-AddDataSourceTable.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786600000000-AddInvestigationAnalysisTldr.js +19 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786600000000-AddInvestigationAnalysisTldr.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786700000000-DropProjectCallSMSConfigCredentialUniques.js +64 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786700000000-DropProjectCallSMSConfigCredentialUniques.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/Queue.js +27 -0
- package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
- package/build/dist/Server/Infrastructure/QueueWorker.js +13 -0
- package/build/dist/Server/Infrastructure/QueueWorker.js.map +1 -1
- package/build/dist/Server/Middleware/TelemetryIngest.js +8 -3
- package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
- package/build/dist/Server/Services/AIInsightService.js +110 -10
- package/build/dist/Server/Services/AIInsightService.js.map +1 -1
- package/build/dist/Server/Services/AIRunService.js +34 -0
- package/build/dist/Server/Services/AIRunService.js.map +1 -1
- package/build/dist/Server/Services/AIService.js +16 -2
- package/build/dist/Server/Services/AIService.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +28 -35
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +8 -4
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DataSourceService.js +159 -0
- package/build/dist/Server/Services/DataSourceService.js.map +1 -0
- package/build/dist/Server/Services/HostService.js +74 -0
- package/build/dist/Server/Services/HostService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +35 -24
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +45 -0
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/MonitorStatusService.js +129 -0
- package/build/dist/Server/Services/MonitorStatusService.js.map +1 -1
- package/build/dist/Server/Services/OpenTelemetryIngestService.js +222 -159
- package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
- package/build/dist/Server/Services/RumSessionPinService.js +7 -15
- package/build/dist/Server/Services/RumSessionPinService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageSubscriberService.js +10 -0
- package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +241 -10
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/BasePermission.js +6 -0
- package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/DeletePermission.js +1 -0
- package/build/dist/Server/Types/Database/Permissions/DeletePermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/ReadPermission.js +2 -0
- package/build/dist/Server/Types/Database/Permissions/ReadPermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +68 -12
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
- package/build/dist/Server/Utils/AI/CodeFix/FixRunBudget.js +2 -2
- package/build/dist/Server/Utils/AI/CodeFix/FixRunBudget.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +68 -8
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +1 -0
- package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +1 -0
- package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.js +97 -0
- package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.js.map +1 -0
- package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js +17 -15
- package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js +178 -54
- package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/Insights/InsightStore.js +65 -16
- package/build/dist/Server/Utils/AI/SRE/Insights/InsightStore.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/InvestigationTldr.js +303 -0
- package/build/dist/Server/Utils/AI/SRE/InvestigationTldr.js.map +1 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js.map +1 -1
- package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +1261 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardViewConfig.js +47 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardViewConfig.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/ConnectorRegistry.js +42 -0
- package/build/dist/Server/Utils/DataSource/ConnectorRegistry.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/Connectors/ClickHouseConnector.js +187 -0
- package/build/dist/Server/Utils/DataSource/Connectors/ClickHouseConnector.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/Connectors/ElasticsearchConnector.js +493 -0
- package/build/dist/Server/Utils/DataSource/Connectors/ElasticsearchConnector.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/Connectors/LokiConnector.js +399 -0
- package/build/dist/Server/Utils/DataSource/Connectors/LokiConnector.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/Connectors/MySqlConnector.js +195 -0
- package/build/dist/Server/Utils/DataSource/Connectors/MySqlConnector.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/Connectors/PostgresConnector.js +149 -0
- package/build/dist/Server/Utils/DataSource/Connectors/PostgresConnector.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/Connectors/PrometheusConnector.js +362 -0
- package/build/dist/Server/Utils/DataSource/Connectors/PrometheusConnector.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/Connectors/RestApiConnector.js +342 -0
- package/build/dist/Server/Utils/DataSource/Connectors/RestApiConnector.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/Connectors/SqlServerConnector.js +186 -0
- package/build/dist/Server/Utils/DataSource/Connectors/SqlServerConnector.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/EgressGuard.js +305 -0
- package/build/dist/Server/Utils/DataSource/EgressGuard.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/HttpFetch.js +134 -0
- package/build/dist/Server/Utils/DataSource/HttpFetch.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/SqlQueryGuard.js +331 -0
- package/build/dist/Server/Utils/DataSource/SqlQueryGuard.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/SqlResultShaper.js +280 -0
- package/build/dist/Server/Utils/DataSource/SqlResultShaper.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/Types.js +2 -0
- package/build/dist/Server/Utils/DataSource/Types.js.map +1 -0
- package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js +103 -9
- package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js.map +1 -1
- package/build/dist/Server/Utils/InProcessMemo.js +113 -0
- package/build/dist/Server/Utils/InProcessMemo.js.map +1 -0
- package/build/dist/Server/Utils/InstanceHealth/TelemetryIngestion.js +406 -0
- package/build/dist/Server/Utils/InstanceHealth/TelemetryIngestion.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js +34 -0
- package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +14 -0
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +9 -1
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/SSRFProtection.js +192 -0
- package/build/dist/Server/Utils/SSRFProtection.js.map +1 -0
- package/build/dist/Server/Utils/StartServer.js +7 -0
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/StatusPageSubscriberWebhook.js +24 -0
- package/build/dist/Server/Utils/StatusPageSubscriberWebhook.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/CaptureSpan.js +25 -11
- package/build/dist/Server/Utils/Telemetry/CaptureSpan.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/IngestionTimestamp.js +32 -0
- package/build/dist/Server/Utils/Telemetry/IngestionTimestamp.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js +92 -5
- package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/Telemetry.js +92 -6
- package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry.js +93 -6
- package/build/dist/Server/Utils/Telemetry.js.map +1 -1
- package/build/dist/Server/Utils/VendorAssets.js +121 -0
- package/build/dist/Server/Utils/VendorAssets.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +25 -4
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/Slack/Slack.js +5 -0
- package/build/dist/Server/Utils/Workspace/Slack/Slack.js.map +1 -1
- package/build/dist/Types/Analytics/RevenueEvent.js +26 -0
- package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -0
- package/build/dist/Types/Dashboard/DashboardComponentType.js +27 -0
- package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceChartComponent.js +2 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceChartComponent.js.map +1 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceGaugeComponent.js +2 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceGaugeComponent.js.map +1 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceTableComponent.js +2 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceTableComponent.js.map +1 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceValueComponent.js +16 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceValueComponent.js.map +1 -0
- package/build/dist/Types/DataSource/DataSourceLimits.js +47 -0
- package/build/dist/Types/DataSource/DataSourceLimits.js.map +1 -0
- package/build/dist/Types/DataSource/DataSourceQueryConfig.js +10 -0
- package/build/dist/Types/DataSource/DataSourceQueryConfig.js.map +1 -0
- package/build/dist/Types/DataSource/DataSourceTableResult.js +14 -0
- package/build/dist/Types/DataSource/DataSourceTableResult.js.map +1 -0
- package/build/dist/Types/DataSource/DataSourceType.js +183 -0
- package/build/dist/Types/DataSource/DataSourceType.js.map +1 -0
- package/build/dist/Types/Date.js +38 -3
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/Password.js +56 -0
- package/build/dist/Types/Password.js.map +1 -0
- package/build/dist/Types/Permission.js +42 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/UserAuthenticationStatus.js +2 -0
- package/build/dist/Types/UserAuthenticationStatus.js.map +1 -0
- package/build/dist/UI/Components/ErrorBoundary.js +388 -72
- package/build/dist/UI/Components/ErrorBoundary.js.map +1 -1
- package/build/dist/UI/Components/Icon/Icon.js +10 -4
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/UI/Components/Modal/ConfirmModal.js +1 -1
- package/build/dist/UI/Components/Modal/ConfirmModal.js.map +1 -1
- package/build/dist/UI/Components/Modal/Modal.js +178 -19
- package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
- package/build/dist/UI/Components/Modal/ModalFooter.js +3 -1
- package/build/dist/UI/Components/Modal/ModalFooter.js.map +1 -1
- package/build/dist/UI/Components/MoreMenu/MoreMenu.js +9 -6
- package/build/dist/UI/Components/MoreMenu/MoreMenu.js.map +1 -1
- package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js +7 -1
- package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js.map +1 -1
- package/build/dist/UI/Components/StatusPage/ResourceGrid.js +119 -0
- package/build/dist/UI/Components/StatusPage/ResourceGrid.js.map +1 -0
- package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js +354 -0
- package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js.map +1 -0
- package/build/dist/UI/Components/StatusPage/ResourceList.js +203 -0
- package/build/dist/UI/Components/StatusPage/ResourceList.js.map +1 -0
- package/build/dist/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.js +1 -6
- package/build/dist/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +3 -8
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
- package/build/dist/UI/Utils/TestDataSource.js +39 -0
- package/build/dist/UI/Utils/TestDataSource.js.map +1 -0
- package/build/dist/Utils/Analytics.js +4 -0
- package/build/dist/Utils/Analytics.js.map +1 -1
- package/build/dist/Utils/Dashboard/Components/DashboardDataSourceChartComponent.js +79 -0
- package/build/dist/Utils/Dashboard/Components/DashboardDataSourceChartComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/DashboardDataSourceGaugeComponent.js +111 -0
- package/build/dist/Utils/Dashboard/Components/DashboardDataSourceGaugeComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/DashboardDataSourceShared.js +15 -0
- package/build/dist/Utils/Dashboard/Components/DashboardDataSourceShared.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/DashboardDataSourceTableComponent.js +62 -0
- package/build/dist/Utils/Dashboard/Components/DashboardDataSourceTableComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/DashboardDataSourceValueComponent.js +112 -0
- package/build/dist/Utils/Dashboard/Components/DashboardDataSourceValueComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/Index.js +16 -0
- package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
- package/build/dist/Utils/DataSource/DataSourceQueryText.js +64 -0
- package/build/dist/Utils/DataSource/DataSourceQueryText.js.map +1 -0
- package/build/dist/Utils/DataSource/DataSourceValueReducer.js +124 -0
- package/build/dist/Utils/DataSource/DataSourceValueReducer.js.map +1 -0
- package/build/dist/Utils/Metrics/MetricResourceAttributeUtil.js +281 -0
- package/build/dist/Utils/Metrics/MetricResourceAttributeUtil.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/ScanTargetUtil.js +8 -6
- package/build/dist/Utils/NetworkDiscovery/ScanTargetUtil.js.map +1 -1
- package/build/dist/Utils/StatusPage/GroupHierarchyView.js +560 -0
- package/build/dist/Utils/StatusPage/GroupHierarchyView.js.map +1 -0
- package/build/dist/Utils/StatusPage/ResourceExplorer.js +519 -0
- package/build/dist/Utils/StatusPage/ResourceExplorer.js.map +1 -0
- package/jest.config.json +1 -0
- package/package.json +5 -1
- package/tsconfig.json +12 -10
|
@@ -0,0 +1,2109 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
jest,
|
|
8
|
+
test,
|
|
9
|
+
} from "@jest/globals";
|
|
10
|
+
import {
|
|
11
|
+
RenderResult,
|
|
12
|
+
act,
|
|
13
|
+
cleanup,
|
|
14
|
+
fireEvent,
|
|
15
|
+
render,
|
|
16
|
+
screen,
|
|
17
|
+
waitFor,
|
|
18
|
+
} from "@testing-library/react";
|
|
19
|
+
import * as React from "react";
|
|
20
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* The four External Data Source widgets each own their own fetch loop, and
|
|
24
|
+
* every one of those loops has to answer the same three questions before it
|
|
25
|
+
* is allowed to touch the network: is this widget actually configured, which
|
|
26
|
+
* concrete time window is it asking about, and is this a public dashboard?
|
|
27
|
+
*
|
|
28
|
+
* Getting the first wrong spams a customer's Postgres/Prometheus with empty
|
|
29
|
+
* queries the moment a widget is dropped on a board. Getting the last wrong
|
|
30
|
+
* is a security boundary failure — the anonymous public dashboard API
|
|
31
|
+
* deliberately exposes no data-source surface, so a widget that fetches
|
|
32
|
+
* anyway leaks project-owned credentials' reach to unauthenticated viewers.
|
|
33
|
+
*
|
|
34
|
+
* Variable interpolation lives on the client (the server receives final query
|
|
35
|
+
* text), so "{{env}}" surviving into the request is a real bug class, not a
|
|
36
|
+
* cosmetic one — which is why it is asserted against the request arguments
|
|
37
|
+
* rather than against anything rendered.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fetchTimeSeriesMock: MockFunction = getJestMockFunction();
|
|
41
|
+
const fetchTableMock: MockFunction = getJestMockFunction();
|
|
42
|
+
const metricChartsRenderMock: MockFunction = getJestMockFunction();
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* The arrow wrappers are load bearing: jest.mock is hoisted above the
|
|
46
|
+
* compiled requires, so the mock variables above are still unassigned when
|
|
47
|
+
* the factory runs. Dereferencing them lazily, at call time, is what makes
|
|
48
|
+
* this work.
|
|
49
|
+
*/
|
|
50
|
+
jest.mock(
|
|
51
|
+
"../../../../App/FeatureSet/Dashboard/src/Utils/DataSourceQuery",
|
|
52
|
+
() => {
|
|
53
|
+
return {
|
|
54
|
+
__esModule: true,
|
|
55
|
+
default: {
|
|
56
|
+
fetchTimeSeries: (...args: Array<any>) => {
|
|
57
|
+
return fetchTimeSeriesMock(...args);
|
|
58
|
+
},
|
|
59
|
+
fetchTable: (...args: Array<any>) => {
|
|
60
|
+
return fetchTableMock(...args);
|
|
61
|
+
},
|
|
62
|
+
getAttributeKeys: (result: { data: Array<Record<string, any>> }) => {
|
|
63
|
+
const keys: Array<string> = [];
|
|
64
|
+
for (const point of result.data || []) {
|
|
65
|
+
const attributes: Record<string, unknown> | undefined = point[
|
|
66
|
+
"attributes"
|
|
67
|
+
] as Record<string, unknown> | undefined;
|
|
68
|
+
for (const key of Object.keys(attributes || {})) {
|
|
69
|
+
if (!keys.includes(key)) {
|
|
70
|
+
keys.push(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return keys;
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
/*
|
|
82
|
+
* The chart widget's whole job here is what it ASKS for and what it hands
|
|
83
|
+
* down; rendering a real recharts surface in jsdom would test neither.
|
|
84
|
+
*/
|
|
85
|
+
jest.mock(
|
|
86
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts",
|
|
87
|
+
() => {
|
|
88
|
+
return {
|
|
89
|
+
__esModule: true,
|
|
90
|
+
default: (props: MetricChartsStubProps): React.ReactElement => {
|
|
91
|
+
metricChartsRenderMock(props);
|
|
92
|
+
return React.createElement(
|
|
93
|
+
"div",
|
|
94
|
+
{ "data-testid": "metric-charts" },
|
|
95
|
+
`results:${props.metricResults.length} configs:${props.metricViewData.queryConfigs.length}`,
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
import DashboardDataSourceChartComponentElement from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardDataSourceChartComponent";
|
|
103
|
+
import DashboardDataSourceGaugeComponentElement from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardDataSourceGaugeComponent";
|
|
104
|
+
import DashboardDataSourceTableComponentElement from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardDataSourceTableComponent";
|
|
105
|
+
import DashboardDataSourceValueComponentElement from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardDataSourceValueComponent";
|
|
106
|
+
import { DashboardBaseComponentProps } from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent";
|
|
107
|
+
import {
|
|
108
|
+
PublicDashboardContext,
|
|
109
|
+
setPublicDashboardContext,
|
|
110
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Utils/PublicDashboardContext";
|
|
111
|
+
import AggregatedModel from "../../../Types/BaseDatabase/AggregatedModel";
|
|
112
|
+
import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
|
|
113
|
+
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
114
|
+
import DashboardChartType from "../../../Types/Dashboard/Chart/ChartType";
|
|
115
|
+
import DashboardComponentType from "../../../Types/Dashboard/DashboardComponentType";
|
|
116
|
+
import DashboardDataSourceChartComponent from "../../../Types/Dashboard/DashboardComponents/DashboardDataSourceChartComponent";
|
|
117
|
+
import DashboardDataSourceGaugeComponent from "../../../Types/Dashboard/DashboardComponents/DashboardDataSourceGaugeComponent";
|
|
118
|
+
import DashboardDataSourceTableComponent from "../../../Types/Dashboard/DashboardComponents/DashboardDataSourceTableComponent";
|
|
119
|
+
import DashboardDataSourceValueComponent, {
|
|
120
|
+
DataSourceValueReduce,
|
|
121
|
+
} from "../../../Types/Dashboard/DashboardComponents/DashboardDataSourceValueComponent";
|
|
122
|
+
import { DashboardValueTrendDirection } from "../../../Types/Dashboard/DashboardComponents/DashboardValueComponent";
|
|
123
|
+
import DashboardVariable, {
|
|
124
|
+
DashboardVariableType,
|
|
125
|
+
} from "../../../Types/Dashboard/DashboardVariable";
|
|
126
|
+
import DashboardViewConfig from "../../../Types/Dashboard/DashboardViewConfig";
|
|
127
|
+
import DataSourceQueryConfig from "../../../Types/DataSource/DataSourceQueryConfig";
|
|
128
|
+
import DataSourceTableResult, {
|
|
129
|
+
DataSourceTableColumnType,
|
|
130
|
+
} from "../../../Types/DataSource/DataSourceTableResult";
|
|
131
|
+
import { ObjectType } from "../../../Types/JSON";
|
|
132
|
+
import MetricQueryConfigData from "../../../Types/Metrics/MetricQueryConfigData";
|
|
133
|
+
import MetricViewData from "../../../Types/Metrics/MetricViewData";
|
|
134
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
135
|
+
import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
|
|
136
|
+
import TimeRange from "../../../Types/Time/TimeRange";
|
|
137
|
+
|
|
138
|
+
interface MetricChartsStubProps {
|
|
139
|
+
metricResults: Array<AggregatedResult>;
|
|
140
|
+
metricViewData: MetricViewData;
|
|
141
|
+
hideCard?: boolean | undefined;
|
|
142
|
+
topNOverrideScope?: string | undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One recorded call into the data source query client. */
|
|
146
|
+
interface DataSourceFetchArgs {
|
|
147
|
+
queryConfig: DataSourceQueryConfig;
|
|
148
|
+
startDate: Date;
|
|
149
|
+
endDate: Date;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const COMPONENT_ID: ObjectID = new ObjectID(
|
|
153
|
+
"11111111-1111-4111-8111-111111111111",
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const START_DATE: Date = new Date("2026-08-10T09:00:00.000Z");
|
|
157
|
+
const END_DATE: Date = new Date("2026-08-10T10:00:00.000Z");
|
|
158
|
+
|
|
159
|
+
/*
|
|
160
|
+
* A CUSTOM range pins the window to fixed instants — a relative range would
|
|
161
|
+
* resolve against the wall clock and make the startDate/endDate assertions
|
|
162
|
+
* untestable.
|
|
163
|
+
*/
|
|
164
|
+
const DASHBOARD_RANGE: RangeStartAndEndDateTime = {
|
|
165
|
+
range: TimeRange.CUSTOM,
|
|
166
|
+
startAndEndDate: new InBetween<Date>(START_DATE, END_DATE),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const DASHBOARD_VIEW_CONFIG: DashboardViewConfig = {
|
|
170
|
+
_type: ObjectType.DashboardViewConfig,
|
|
171
|
+
components: [],
|
|
172
|
+
heightInDashboardUnits: 60,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
type BuildBasePropsFunction = (
|
|
176
|
+
overrides?: Partial<DashboardBaseComponentProps>,
|
|
177
|
+
) => DashboardBaseComponentProps;
|
|
178
|
+
|
|
179
|
+
const buildBaseProps: BuildBasePropsFunction = (
|
|
180
|
+
overrides: Partial<DashboardBaseComponentProps> = {},
|
|
181
|
+
): DashboardBaseComponentProps => {
|
|
182
|
+
return {
|
|
183
|
+
componentId: COMPONENT_ID,
|
|
184
|
+
isEditMode: false,
|
|
185
|
+
isSelected: false,
|
|
186
|
+
key: "data-source-widget",
|
|
187
|
+
onComponentUpdate: (): void => {
|
|
188
|
+
// The widgets under test never write back through this.
|
|
189
|
+
},
|
|
190
|
+
totalCurrentDashboardWidthInPx: 1200,
|
|
191
|
+
dashboardCanvasTopInPx: 0,
|
|
192
|
+
dashboardCanvasLeftInPx: 0,
|
|
193
|
+
dashboardCanvasWidthInPx: 1200,
|
|
194
|
+
dashboardCanvasHeightInPx: 800,
|
|
195
|
+
dashboardComponentHeightInPx: 320,
|
|
196
|
+
dashboardComponentWidthInPx: 480,
|
|
197
|
+
dashboardViewConfig: DASHBOARD_VIEW_CONFIG,
|
|
198
|
+
dashboardStartAndEndDate: DASHBOARD_RANGE,
|
|
199
|
+
metricTypes: [],
|
|
200
|
+
refreshTick: 0,
|
|
201
|
+
variables: undefined,
|
|
202
|
+
...overrides,
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
type BuildPointFunction = (
|
|
207
|
+
value: number | null,
|
|
208
|
+
timestamp: string,
|
|
209
|
+
attributes?: Record<string, string>,
|
|
210
|
+
) => AggregatedModel;
|
|
211
|
+
|
|
212
|
+
const buildPoint: BuildPointFunction = (
|
|
213
|
+
value: number | null,
|
|
214
|
+
timestamp: string,
|
|
215
|
+
attributes?: Record<string, string>,
|
|
216
|
+
): AggregatedModel => {
|
|
217
|
+
return {
|
|
218
|
+
timestamp: new Date(timestamp),
|
|
219
|
+
value: value as number,
|
|
220
|
+
...(attributes ? { attributes: attributes } : {}),
|
|
221
|
+
} as AggregatedModel;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
type BuildSeriesFunction = (
|
|
225
|
+
points: Array<AggregatedModel>,
|
|
226
|
+
errorMessage?: string,
|
|
227
|
+
) => AggregatedResult;
|
|
228
|
+
|
|
229
|
+
const buildSeries: BuildSeriesFunction = (
|
|
230
|
+
points: Array<AggregatedModel>,
|
|
231
|
+
errorMessage?: string,
|
|
232
|
+
): AggregatedResult => {
|
|
233
|
+
return {
|
|
234
|
+
data: points,
|
|
235
|
+
truncated: false,
|
|
236
|
+
...(errorMessage ? { errorMessage: errorMessage } : {}),
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
type GetFetchCallsFunction = (mock: MockFunction) => Array<DataSourceFetchArgs>;
|
|
241
|
+
|
|
242
|
+
const getFetchCalls: GetFetchCallsFunction = (
|
|
243
|
+
mock: MockFunction,
|
|
244
|
+
): Array<DataSourceFetchArgs> => {
|
|
245
|
+
return mock.mock.calls.map((call: Array<unknown>): DataSourceFetchArgs => {
|
|
246
|
+
return call[0] as DataSourceFetchArgs;
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
type RenderValueFunction = (
|
|
251
|
+
args: DashboardDataSourceValueComponent["arguments"],
|
|
252
|
+
overrides?: Partial<DashboardBaseComponentProps>,
|
|
253
|
+
) => RenderResult;
|
|
254
|
+
|
|
255
|
+
const renderValueWidget: RenderValueFunction = (
|
|
256
|
+
args: DashboardDataSourceValueComponent["arguments"],
|
|
257
|
+
overrides: Partial<DashboardBaseComponentProps> = {},
|
|
258
|
+
): RenderResult => {
|
|
259
|
+
const component: DashboardDataSourceValueComponent = {
|
|
260
|
+
_type: ObjectType.DashboardComponent,
|
|
261
|
+
componentId: COMPONENT_ID,
|
|
262
|
+
componentType: DashboardComponentType.DataSourceValue,
|
|
263
|
+
topInDashboardUnits: 0,
|
|
264
|
+
leftInDashboardUnits: 0,
|
|
265
|
+
widthInDashboardUnits: 6,
|
|
266
|
+
heightInDashboardUnits: 4,
|
|
267
|
+
minWidthInDashboardUnits: 3,
|
|
268
|
+
minHeightInDashboardUnits: 2,
|
|
269
|
+
arguments: args,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return render(
|
|
273
|
+
<DashboardDataSourceValueComponentElement
|
|
274
|
+
{...buildBaseProps(overrides)}
|
|
275
|
+
component={component}
|
|
276
|
+
/>,
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
type RenderGaugeFunction = (
|
|
281
|
+
args: DashboardDataSourceGaugeComponent["arguments"],
|
|
282
|
+
overrides?: Partial<DashboardBaseComponentProps>,
|
|
283
|
+
) => RenderResult;
|
|
284
|
+
|
|
285
|
+
const renderGaugeWidget: RenderGaugeFunction = (
|
|
286
|
+
args: DashboardDataSourceGaugeComponent["arguments"],
|
|
287
|
+
overrides: Partial<DashboardBaseComponentProps> = {},
|
|
288
|
+
): RenderResult => {
|
|
289
|
+
const component: DashboardDataSourceGaugeComponent = {
|
|
290
|
+
_type: ObjectType.DashboardComponent,
|
|
291
|
+
componentId: COMPONENT_ID,
|
|
292
|
+
componentType: DashboardComponentType.DataSourceGauge,
|
|
293
|
+
topInDashboardUnits: 0,
|
|
294
|
+
leftInDashboardUnits: 0,
|
|
295
|
+
widthInDashboardUnits: 6,
|
|
296
|
+
heightInDashboardUnits: 4,
|
|
297
|
+
minWidthInDashboardUnits: 3,
|
|
298
|
+
minHeightInDashboardUnits: 2,
|
|
299
|
+
arguments: args,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
return render(
|
|
303
|
+
<DashboardDataSourceGaugeComponentElement
|
|
304
|
+
{...buildBaseProps(overrides)}
|
|
305
|
+
component={component}
|
|
306
|
+
/>,
|
|
307
|
+
);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
type RenderTableFunction = (
|
|
311
|
+
args: DashboardDataSourceTableComponent["arguments"],
|
|
312
|
+
overrides?: Partial<DashboardBaseComponentProps>,
|
|
313
|
+
) => RenderResult;
|
|
314
|
+
|
|
315
|
+
const renderTableWidget: RenderTableFunction = (
|
|
316
|
+
args: DashboardDataSourceTableComponent["arguments"],
|
|
317
|
+
overrides: Partial<DashboardBaseComponentProps> = {},
|
|
318
|
+
): RenderResult => {
|
|
319
|
+
const component: DashboardDataSourceTableComponent = {
|
|
320
|
+
_type: ObjectType.DashboardComponent,
|
|
321
|
+
componentId: COMPONENT_ID,
|
|
322
|
+
componentType: DashboardComponentType.DataSourceTable,
|
|
323
|
+
topInDashboardUnits: 0,
|
|
324
|
+
leftInDashboardUnits: 0,
|
|
325
|
+
widthInDashboardUnits: 8,
|
|
326
|
+
heightInDashboardUnits: 6,
|
|
327
|
+
minWidthInDashboardUnits: 4,
|
|
328
|
+
minHeightInDashboardUnits: 3,
|
|
329
|
+
arguments: args,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
return render(
|
|
333
|
+
<DashboardDataSourceTableComponentElement
|
|
334
|
+
{...buildBaseProps(overrides)}
|
|
335
|
+
component={component}
|
|
336
|
+
/>,
|
|
337
|
+
);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
type RenderChartFunction = (
|
|
341
|
+
args: DashboardDataSourceChartComponent["arguments"],
|
|
342
|
+
overrides?: Partial<DashboardBaseComponentProps>,
|
|
343
|
+
) => RenderResult;
|
|
344
|
+
|
|
345
|
+
const renderChartWidget: RenderChartFunction = (
|
|
346
|
+
args: DashboardDataSourceChartComponent["arguments"],
|
|
347
|
+
overrides: Partial<DashboardBaseComponentProps> = {},
|
|
348
|
+
): RenderResult => {
|
|
349
|
+
const component: DashboardDataSourceChartComponent = {
|
|
350
|
+
_type: ObjectType.DashboardComponent,
|
|
351
|
+
componentId: COMPONENT_ID,
|
|
352
|
+
componentType: DashboardComponentType.DataSourceChart,
|
|
353
|
+
topInDashboardUnits: 0,
|
|
354
|
+
leftInDashboardUnits: 0,
|
|
355
|
+
widthInDashboardUnits: 8,
|
|
356
|
+
heightInDashboardUnits: 6,
|
|
357
|
+
minWidthInDashboardUnits: 4,
|
|
358
|
+
minHeightInDashboardUnits: 3,
|
|
359
|
+
arguments: args,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
return render(
|
|
363
|
+
<DashboardDataSourceChartComponentElement
|
|
364
|
+
{...buildBaseProps(overrides)}
|
|
365
|
+
component={component}
|
|
366
|
+
/>,
|
|
367
|
+
);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/*
|
|
371
|
+
* The render helpers above mount and forget. The staleness and refresh-tick
|
|
372
|
+
* tests need to hand the SAME element type back to `rerender` with different
|
|
373
|
+
* base props, so they build the element instead of rendering it.
|
|
374
|
+
*/
|
|
375
|
+
type BuildValueElementFunction = (
|
|
376
|
+
args: DashboardDataSourceValueComponent["arguments"],
|
|
377
|
+
overrides?: Partial<DashboardBaseComponentProps>,
|
|
378
|
+
) => React.ReactElement;
|
|
379
|
+
|
|
380
|
+
const buildValueElement: BuildValueElementFunction = (
|
|
381
|
+
args: DashboardDataSourceValueComponent["arguments"],
|
|
382
|
+
overrides: Partial<DashboardBaseComponentProps> = {},
|
|
383
|
+
): React.ReactElement => {
|
|
384
|
+
const component: DashboardDataSourceValueComponent = {
|
|
385
|
+
_type: ObjectType.DashboardComponent,
|
|
386
|
+
componentId: COMPONENT_ID,
|
|
387
|
+
componentType: DashboardComponentType.DataSourceValue,
|
|
388
|
+
topInDashboardUnits: 0,
|
|
389
|
+
leftInDashboardUnits: 0,
|
|
390
|
+
widthInDashboardUnits: 6,
|
|
391
|
+
heightInDashboardUnits: 4,
|
|
392
|
+
minWidthInDashboardUnits: 3,
|
|
393
|
+
minHeightInDashboardUnits: 2,
|
|
394
|
+
arguments: args,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<DashboardDataSourceValueComponentElement
|
|
399
|
+
{...buildBaseProps(overrides)}
|
|
400
|
+
component={component}
|
|
401
|
+
/>
|
|
402
|
+
);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
type BuildTableElementFunction = (
|
|
406
|
+
args: DashboardDataSourceTableComponent["arguments"],
|
|
407
|
+
overrides?: Partial<DashboardBaseComponentProps>,
|
|
408
|
+
) => React.ReactElement;
|
|
409
|
+
|
|
410
|
+
const buildTableElement: BuildTableElementFunction = (
|
|
411
|
+
args: DashboardDataSourceTableComponent["arguments"],
|
|
412
|
+
overrides: Partial<DashboardBaseComponentProps> = {},
|
|
413
|
+
): React.ReactElement => {
|
|
414
|
+
const component: DashboardDataSourceTableComponent = {
|
|
415
|
+
_type: ObjectType.DashboardComponent,
|
|
416
|
+
componentId: COMPONENT_ID,
|
|
417
|
+
componentType: DashboardComponentType.DataSourceTable,
|
|
418
|
+
topInDashboardUnits: 0,
|
|
419
|
+
leftInDashboardUnits: 0,
|
|
420
|
+
widthInDashboardUnits: 8,
|
|
421
|
+
heightInDashboardUnits: 6,
|
|
422
|
+
minWidthInDashboardUnits: 4,
|
|
423
|
+
minHeightInDashboardUnits: 3,
|
|
424
|
+
arguments: args,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
<DashboardDataSourceTableComponentElement
|
|
429
|
+
{...buildBaseProps(overrides)}
|
|
430
|
+
component={component}
|
|
431
|
+
/>
|
|
432
|
+
);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
type BuildChartElementFunction = (
|
|
436
|
+
args: DashboardDataSourceChartComponent["arguments"],
|
|
437
|
+
overrides?: Partial<DashboardBaseComponentProps>,
|
|
438
|
+
) => React.ReactElement;
|
|
439
|
+
|
|
440
|
+
const buildChartElement: BuildChartElementFunction = (
|
|
441
|
+
args: DashboardDataSourceChartComponent["arguments"],
|
|
442
|
+
overrides: Partial<DashboardBaseComponentProps> = {},
|
|
443
|
+
): React.ReactElement => {
|
|
444
|
+
const component: DashboardDataSourceChartComponent = {
|
|
445
|
+
_type: ObjectType.DashboardComponent,
|
|
446
|
+
componentId: COMPONENT_ID,
|
|
447
|
+
componentType: DashboardComponentType.DataSourceChart,
|
|
448
|
+
topInDashboardUnits: 0,
|
|
449
|
+
leftInDashboardUnits: 0,
|
|
450
|
+
widthInDashboardUnits: 8,
|
|
451
|
+
heightInDashboardUnits: 6,
|
|
452
|
+
minWidthInDashboardUnits: 4,
|
|
453
|
+
minHeightInDashboardUnits: 3,
|
|
454
|
+
arguments: args,
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
return (
|
|
458
|
+
<DashboardDataSourceChartComponentElement
|
|
459
|
+
{...buildBaseProps(overrides)}
|
|
460
|
+
component={component}
|
|
461
|
+
/>
|
|
462
|
+
);
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* A promise whose settlement this test file controls, so two fetches can be
|
|
467
|
+
* left in flight at once and completed in whatever order the test needs.
|
|
468
|
+
*/
|
|
469
|
+
interface DeferredPromise<T> {
|
|
470
|
+
promise: Promise<T>;
|
|
471
|
+
resolve: (value: T) => void;
|
|
472
|
+
reject: (error: Error) => void;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
type CreateDeferredFunction = <T>() => DeferredPromise<T>;
|
|
476
|
+
|
|
477
|
+
const createDeferred: CreateDeferredFunction = <T,>(): DeferredPromise<T> => {
|
|
478
|
+
let resolveFunction: (value: T) => void = (): void => {
|
|
479
|
+
// Replaced synchronously by the executor below.
|
|
480
|
+
};
|
|
481
|
+
let rejectFunction: (error: Error) => void = (): void => {
|
|
482
|
+
// Replaced synchronously by the executor below.
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const promise: Promise<T> = new Promise<T>(
|
|
486
|
+
(resolve: (value: T) => void, reject: (error: Error) => void): void => {
|
|
487
|
+
resolveFunction = resolve;
|
|
488
|
+
rejectFunction = reject;
|
|
489
|
+
},
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
promise: promise,
|
|
494
|
+
resolve: resolveFunction,
|
|
495
|
+
reject: rejectFunction,
|
|
496
|
+
};
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
/** Settle a deferred fetch and let React commit whatever it caused. */
|
|
500
|
+
type SettleFunction = (settle: () => void) => Promise<void>;
|
|
501
|
+
|
|
502
|
+
const settleAndFlush: SettleFunction = async (
|
|
503
|
+
settle: () => void,
|
|
504
|
+
): Promise<void> => {
|
|
505
|
+
await act(async (): Promise<void> => {
|
|
506
|
+
settle();
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
/** The `value` of the first point of the first result MetricCharts was given. */
|
|
511
|
+
type GetChartRenderedValuesFunction = () => Array<number | undefined>;
|
|
512
|
+
|
|
513
|
+
const getChartRenderedValues: GetChartRenderedValuesFunction = (): Array<
|
|
514
|
+
number | undefined
|
|
515
|
+
> => {
|
|
516
|
+
return metricChartsRenderMock.mock.calls.map(
|
|
517
|
+
(call: Array<unknown>): number | undefined => {
|
|
518
|
+
const chartProps: MetricChartsStubProps =
|
|
519
|
+
call[0] as MetricChartsStubProps;
|
|
520
|
+
return chartProps.metricResults[0]?.data[0]?.value;
|
|
521
|
+
},
|
|
522
|
+
);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
/** The "N rows (truncated)" counter in the table widget's header. */
|
|
526
|
+
type GetRowCountLabelFunction = (container: HTMLElement) => string;
|
|
527
|
+
|
|
528
|
+
const getRowCountLabel: GetRowCountLabelFunction = (
|
|
529
|
+
container: HTMLElement,
|
|
530
|
+
): string => {
|
|
531
|
+
const label: Element | null = container.querySelector("span.tabular-nums");
|
|
532
|
+
|
|
533
|
+
if (!label) {
|
|
534
|
+
throw new Error("Table widget did not render a row count label.");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return (label.textContent || "").trim();
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
/** Rendered table body, cell text by row, in DOM order. */
|
|
541
|
+
type GetRenderedRowsFunction = (container: HTMLElement) => Array<Array<string>>;
|
|
542
|
+
|
|
543
|
+
const getRenderedRows: GetRenderedRowsFunction = (
|
|
544
|
+
container: HTMLElement,
|
|
545
|
+
): Array<Array<string>> => {
|
|
546
|
+
return Array.from(container.querySelectorAll("tbody tr")).map(
|
|
547
|
+
(row: Element): Array<string> => {
|
|
548
|
+
return Array.from(row.querySelectorAll("td")).map(
|
|
549
|
+
(cell: Element): string => {
|
|
550
|
+
return (cell.textContent || "").trim();
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
},
|
|
554
|
+
);
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const TABLE_RESULT: DataSourceTableResult = {
|
|
558
|
+
columns: [
|
|
559
|
+
{ key: "service", title: "Service", type: DataSourceTableColumnType.Text },
|
|
560
|
+
{ key: "errors", title: "Errors", type: DataSourceTableColumnType.Number },
|
|
561
|
+
],
|
|
562
|
+
rows: [
|
|
563
|
+
{ service: "checkout", errors: 12 },
|
|
564
|
+
{ service: "search", errors: 3 },
|
|
565
|
+
{ service: "billing", errors: 41 },
|
|
566
|
+
{ service: "auth", errors: 7 },
|
|
567
|
+
],
|
|
568
|
+
truncated: false,
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const PUBLIC_DASHBOARD_CONTEXT: PublicDashboardContext = {
|
|
572
|
+
dashboardId: new ObjectID("22222222-2222-4222-8222-222222222222"),
|
|
573
|
+
apiUrl: {
|
|
574
|
+
toString: (): string => {
|
|
575
|
+
return "http://localhost/public-dashboard-api";
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
postJSON: () => {
|
|
579
|
+
throw new Error(
|
|
580
|
+
"A data source widget reached the public dashboard API. It must not.",
|
|
581
|
+
);
|
|
582
|
+
},
|
|
583
|
+
} as unknown as PublicDashboardContext;
|
|
584
|
+
|
|
585
|
+
beforeEach((): void => {
|
|
586
|
+
jest.clearAllMocks();
|
|
587
|
+
fetchTimeSeriesMock.mockResolvedValue(buildSeries([]));
|
|
588
|
+
fetchTableMock.mockResolvedValue({
|
|
589
|
+
columns: [],
|
|
590
|
+
rows: [],
|
|
591
|
+
truncated: false,
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
afterEach((): void => {
|
|
596
|
+
cleanup();
|
|
597
|
+
setPublicDashboardContext(null);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe("Data Source widget fetching", () => {
|
|
601
|
+
describe("unconfigured widgets never touch the query client", () => {
|
|
602
|
+
test("value widget with a blank data source renders its setup prompt", () => {
|
|
603
|
+
renderValueWidget({
|
|
604
|
+
query: { dataSourceId: "", query: "up" },
|
|
605
|
+
title: "Requests",
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
609
|
+
expect(
|
|
610
|
+
screen.getByText("Click to configure a query"),
|
|
611
|
+
).toBeInTheDocument();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("value widget with whitespace-only query text is not configured", () => {
|
|
615
|
+
renderValueWidget({
|
|
616
|
+
query: { dataSourceId: "ds-prom", query: " \n\t " },
|
|
617
|
+
title: "Requests",
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
621
|
+
expect(screen.getByText("Requests")).toBeInTheDocument();
|
|
622
|
+
expect(
|
|
623
|
+
screen.getByText("Click to configure a query"),
|
|
624
|
+
).toBeInTheDocument();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("value widget with no query object at all renders its setup prompt", () => {
|
|
628
|
+
renderValueWidget({ title: "Requests" });
|
|
629
|
+
|
|
630
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
631
|
+
expect(
|
|
632
|
+
screen.getByText("Click to configure a query"),
|
|
633
|
+
).toBeInTheDocument();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("gauge widget with whitespace-only query text is not configured", () => {
|
|
637
|
+
renderGaugeWidget({
|
|
638
|
+
query: { dataSourceId: "ds-prom", query: " " },
|
|
639
|
+
gaugeTitle: "CPU",
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
643
|
+
expect(screen.getByText("CPU")).toBeInTheDocument();
|
|
644
|
+
expect(
|
|
645
|
+
screen.getByText("Click to configure a query"),
|
|
646
|
+
).toBeInTheDocument();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("gauge widget falls back to its own setup title when untitled", () => {
|
|
650
|
+
renderGaugeWidget({ query: { dataSourceId: "", query: "" } });
|
|
651
|
+
|
|
652
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
653
|
+
expect(screen.getByText("Data Source Gauge")).toBeInTheDocument();
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("table widget with whitespace-only query text is not configured", () => {
|
|
657
|
+
renderTableWidget({
|
|
658
|
+
query: { dataSourceId: "ds-pg", query: "\t \n" },
|
|
659
|
+
tableTitle: "Slow queries",
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
expect(fetchTableMock).not.toHaveBeenCalled();
|
|
663
|
+
expect(screen.getByText("Slow queries")).toBeInTheDocument();
|
|
664
|
+
expect(
|
|
665
|
+
screen.getByText("Click to configure a query"),
|
|
666
|
+
).toBeInTheDocument();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("table widget with a blank data source renders its setup prompt", () => {
|
|
670
|
+
renderTableWidget({ query: { dataSourceId: " ", query: "select 1" } });
|
|
671
|
+
|
|
672
|
+
expect(fetchTableMock).not.toHaveBeenCalled();
|
|
673
|
+
expect(screen.getByText("Data Source Table")).toBeInTheDocument();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
/*
|
|
677
|
+
* A prompt, not an error tile. Every chart is seeded with one blank
|
|
678
|
+
* query card, so this is the state the widget is in the instant it is
|
|
679
|
+
* added — reporting it as a failure blames the user for the default.
|
|
680
|
+
*/
|
|
681
|
+
test("chart widget with only half-filled query cards asks to be configured", () => {
|
|
682
|
+
renderChartWidget({
|
|
683
|
+
queries: [
|
|
684
|
+
{ id: "q1", dataSourceId: "ds-prom", query: " " },
|
|
685
|
+
{ id: "q2", dataSourceId: "", query: "up" },
|
|
686
|
+
],
|
|
687
|
+
chartTitle: "Throughput",
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
691
|
+
expect(
|
|
692
|
+
screen.getByText("Click to configure a query"),
|
|
693
|
+
).toBeInTheDocument();
|
|
694
|
+
expect(screen.getByText("Throughput")).toBeInTheDocument();
|
|
695
|
+
expect(
|
|
696
|
+
screen.queryByText("Please configure a data source query."),
|
|
697
|
+
).not.toBeInTheDocument();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("chart widget with no queries array at all asks to be configured", () => {
|
|
701
|
+
renderChartWidget({ chartTitle: "Throughput" });
|
|
702
|
+
|
|
703
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
704
|
+
expect(
|
|
705
|
+
screen.getByText("Click to configure a query"),
|
|
706
|
+
).toBeInTheDocument();
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// Untitled, so the prompt has to name the widget itself.
|
|
710
|
+
test("an untitled unconfigured chart names itself in the prompt", () => {
|
|
711
|
+
renderChartWidget({ queries: [] });
|
|
712
|
+
|
|
713
|
+
expect(screen.getByText("Data Source Chart")).toBeInTheDocument();
|
|
714
|
+
expect(
|
|
715
|
+
screen.getByText("Click to configure a query"),
|
|
716
|
+
).toBeInTheDocument();
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
describe("configured widgets query once, for the dashboard's window", () => {
|
|
721
|
+
test("value widget sends the data source id and the resolved time range", async () => {
|
|
722
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
723
|
+
buildSeries([buildPoint(42, "2026-08-10T09:30:00.000Z")]),
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
const rendered: RenderResult = renderValueWidget({
|
|
727
|
+
query: { dataSourceId: "ds-prom", query: "up" },
|
|
728
|
+
title: "Up",
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
await waitFor((): void => {
|
|
732
|
+
expect(rendered.container.textContent).toContain("42");
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
|
|
736
|
+
const call: DataSourceFetchArgs = getFetchCalls(fetchTimeSeriesMock)[0]!;
|
|
737
|
+
expect(call.queryConfig.dataSourceId).toBe("ds-prom");
|
|
738
|
+
expect(call.queryConfig.query).toBe("up");
|
|
739
|
+
expect(call.startDate).toEqual(START_DATE);
|
|
740
|
+
expect(call.endDate).toEqual(END_DATE);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("table widget sends the same window through the table endpoint", async () => {
|
|
744
|
+
fetchTableMock.mockResolvedValue(TABLE_RESULT);
|
|
745
|
+
|
|
746
|
+
const rendered: RenderResult = renderTableWidget({
|
|
747
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
await waitFor((): void => {
|
|
751
|
+
expect(getRenderedRows(rendered.container)).toHaveLength(4);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
expect(fetchTableMock).toHaveBeenCalledTimes(1);
|
|
755
|
+
const call: DataSourceFetchArgs = getFetchCalls(fetchTableMock)[0]!;
|
|
756
|
+
expect(call.queryConfig.dataSourceId).toBe("ds-pg");
|
|
757
|
+
expect(call.startDate).toEqual(START_DATE);
|
|
758
|
+
expect(call.endDate).toEqual(END_DATE);
|
|
759
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("gauge widget reduces the returned series into one number", async () => {
|
|
763
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
764
|
+
buildSeries([
|
|
765
|
+
buildPoint(10, "2026-08-10T09:10:00.000Z"),
|
|
766
|
+
buildPoint(80, "2026-08-10T09:20:00.000Z"),
|
|
767
|
+
]),
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const rendered: RenderResult = renderGaugeWidget({
|
|
771
|
+
query: { dataSourceId: "ds-prom", query: "cpu" },
|
|
772
|
+
gaugeTitle: "CPU",
|
|
773
|
+
reduce: DataSourceValueReduce.Max,
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
await waitFor((): void => {
|
|
777
|
+
expect(rendered.container.textContent).toContain("80");
|
|
778
|
+
});
|
|
779
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("value widget reduces out of TIME order, not array order", async () => {
|
|
783
|
+
/*
|
|
784
|
+
* A hand-written SQL query with no ORDER BY can return anything; Last
|
|
785
|
+
* has to mean "latest timestamp", not "last row the driver emitted".
|
|
786
|
+
*/
|
|
787
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
788
|
+
buildSeries([
|
|
789
|
+
buildPoint(7, "2026-08-10T09:50:00.000Z"),
|
|
790
|
+
buildPoint(3, "2026-08-10T09:05:00.000Z"),
|
|
791
|
+
]),
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
const rendered: RenderResult = renderValueWidget({
|
|
795
|
+
query: { dataSourceId: "ds-pg", query: "select value from t" },
|
|
796
|
+
title: "Latest",
|
|
797
|
+
reduce: DataSourceValueReduce.Last,
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
await waitFor((): void => {
|
|
801
|
+
expect(rendered.container.textContent).toContain("7");
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("a configured query that returns nothing says so instead of showing zero", async () => {
|
|
806
|
+
fetchTimeSeriesMock.mockResolvedValue(buildSeries([]));
|
|
807
|
+
|
|
808
|
+
renderValueWidget({
|
|
809
|
+
query: { dataSourceId: "ds-prom", query: "up" },
|
|
810
|
+
title: "Up",
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
await waitFor((): void => {
|
|
814
|
+
expect(
|
|
815
|
+
screen.getByText("No data for the selected time range"),
|
|
816
|
+
).toBeInTheDocument();
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("a connector's own error message beats the generic no-data text", async () => {
|
|
821
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
822
|
+
buildSeries([], "PromQL parse error at char 4"),
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
renderValueWidget({
|
|
826
|
+
query: { dataSourceId: "ds-prom", query: "up{" },
|
|
827
|
+
title: "Up",
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
await waitFor((): void => {
|
|
831
|
+
expect(
|
|
832
|
+
screen.getByText("PromQL parse error at char 4"),
|
|
833
|
+
).toBeInTheDocument();
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("non-finite points are discarded rather than poisoning the reduction", async () => {
|
|
838
|
+
/*
|
|
839
|
+
* A SQL NULL or a missing column arrives as null on a field typed
|
|
840
|
+
* `number` — averaged unchecked it renders NaN.
|
|
841
|
+
*/
|
|
842
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
843
|
+
buildSeries([
|
|
844
|
+
buildPoint(null, "2026-08-10T09:10:00.000Z"),
|
|
845
|
+
buildPoint(10, "2026-08-10T09:20:00.000Z"),
|
|
846
|
+
buildPoint(30, "2026-08-10T09:30:00.000Z"),
|
|
847
|
+
]),
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
const rendered: RenderResult = renderValueWidget({
|
|
851
|
+
query: { dataSourceId: "ds-pg", query: "select value from t" },
|
|
852
|
+
title: "Average",
|
|
853
|
+
reduce: DataSourceValueReduce.Avg,
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
await waitFor((): void => {
|
|
857
|
+
expect(rendered.container.textContent).toContain("20");
|
|
858
|
+
});
|
|
859
|
+
expect(rendered.container.textContent).not.toContain("NaN");
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
describe("dashboard variables are interpolated before the request leaves", () => {
|
|
864
|
+
const ENV_VARIABLE: DashboardVariable = {
|
|
865
|
+
id: "var-env",
|
|
866
|
+
name: "env",
|
|
867
|
+
type: DashboardVariableType.TextInput,
|
|
868
|
+
selectedValue: "production",
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
test("value widget replaces {{env}} in the query it sends", async () => {
|
|
872
|
+
renderValueWidget(
|
|
873
|
+
{
|
|
874
|
+
query: {
|
|
875
|
+
dataSourceId: "ds-prom",
|
|
876
|
+
query: 'sum(rate(http_requests_total{env="{{env}}"}[5m]))',
|
|
877
|
+
},
|
|
878
|
+
title: "Requests",
|
|
879
|
+
},
|
|
880
|
+
{ variables: [ENV_VARIABLE] },
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
await waitFor((): void => {
|
|
884
|
+
expect(
|
|
885
|
+
screen.getByText("No data for the selected time range"),
|
|
886
|
+
).toBeInTheDocument();
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
|
|
890
|
+
expect(getFetchCalls(fetchTimeSeriesMock)[0]!.queryConfig.query).toBe(
|
|
891
|
+
'sum(rate(http_requests_total{env="production"}[5m]))',
|
|
892
|
+
);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
test("table widget interpolates too, and keeps the data source id", async () => {
|
|
896
|
+
fetchTableMock.mockResolvedValue(TABLE_RESULT);
|
|
897
|
+
|
|
898
|
+
const rendered: RenderResult = renderTableWidget(
|
|
899
|
+
{
|
|
900
|
+
query: {
|
|
901
|
+
dataSourceId: "ds-pg",
|
|
902
|
+
query: "select * from deploys where env = '{{ env }}'",
|
|
903
|
+
},
|
|
904
|
+
},
|
|
905
|
+
{ variables: [ENV_VARIABLE] },
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
await waitFor((): void => {
|
|
909
|
+
expect(getRenderedRows(rendered.container)).toHaveLength(4);
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
expect(fetchTableMock).toHaveBeenCalledTimes(1);
|
|
913
|
+
const call: DataSourceFetchArgs = getFetchCalls(fetchTableMock)[0]!;
|
|
914
|
+
expect(call.queryConfig.query).toBe(
|
|
915
|
+
"select * from deploys where env = 'production'",
|
|
916
|
+
);
|
|
917
|
+
expect(call.queryConfig.dataSourceId).toBe("ds-pg");
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("a multi-select variable joins its values with regex alternation", async () => {
|
|
921
|
+
const clusterVariable: DashboardVariable = {
|
|
922
|
+
id: "var-cluster",
|
|
923
|
+
name: "cluster",
|
|
924
|
+
type: DashboardVariableType.CustomList,
|
|
925
|
+
isMultiSelect: true,
|
|
926
|
+
selectedValues: ["eu-1", "us-2"],
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
renderGaugeWidget(
|
|
930
|
+
{
|
|
931
|
+
query: {
|
|
932
|
+
dataSourceId: "ds-prom",
|
|
933
|
+
query: 'up{cluster=~"{{cluster}}"}',
|
|
934
|
+
},
|
|
935
|
+
},
|
|
936
|
+
{ variables: [clusterVariable] },
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
await waitFor((): void => {
|
|
940
|
+
expect(
|
|
941
|
+
screen.getByText("No data for the selected time range"),
|
|
942
|
+
).toBeInTheDocument();
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
|
|
946
|
+
expect(getFetchCalls(fetchTimeSeriesMock)[0]!.queryConfig.query).toBe(
|
|
947
|
+
'up{cluster=~"eu-1|us-2"}',
|
|
948
|
+
);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("an unresolved variable leaves its token intact for the connector to complain about", async () => {
|
|
952
|
+
const emptyVariable: DashboardVariable = {
|
|
953
|
+
id: "var-env",
|
|
954
|
+
name: "env",
|
|
955
|
+
type: DashboardVariableType.TextInput,
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
renderValueWidget(
|
|
959
|
+
{
|
|
960
|
+
query: { dataSourceId: "ds-prom", query: 'up{env="{{env}}"}' },
|
|
961
|
+
title: "Up",
|
|
962
|
+
},
|
|
963
|
+
{ variables: [emptyVariable] },
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
await waitFor((): void => {
|
|
967
|
+
expect(
|
|
968
|
+
screen.getByText("No data for the selected time range"),
|
|
969
|
+
).toBeInTheDocument();
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
|
|
973
|
+
expect(getFetchCalls(fetchTimeSeriesMock)[0]!.queryConfig.query).toBe(
|
|
974
|
+
'up{env="{{env}}"}',
|
|
975
|
+
);
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
describe("public dashboards are a hard boundary, not a cosmetic one", () => {
|
|
980
|
+
beforeEach((): void => {
|
|
981
|
+
setPublicDashboardContext(PUBLIC_DASHBOARD_CONTEXT);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("value widget renders the placeholder and issues no request", () => {
|
|
985
|
+
renderValueWidget({
|
|
986
|
+
query: { dataSourceId: "ds-prom", query: "up" },
|
|
987
|
+
title: "Up",
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
expect(
|
|
991
|
+
screen.getByText(/not available on public dashboards/i),
|
|
992
|
+
).toBeInTheDocument();
|
|
993
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
test("gauge widget renders the placeholder and issues no request", () => {
|
|
997
|
+
renderGaugeWidget({
|
|
998
|
+
query: { dataSourceId: "ds-prom", query: "cpu" },
|
|
999
|
+
gaugeTitle: "CPU",
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
expect(
|
|
1003
|
+
screen.getByText(/not available on public dashboards/i),
|
|
1004
|
+
).toBeInTheDocument();
|
|
1005
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
test("table widget renders the placeholder and issues no request", () => {
|
|
1009
|
+
renderTableWidget({
|
|
1010
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
1011
|
+
tableTitle: "Slow queries",
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
expect(
|
|
1015
|
+
screen.getByText(/not available on public dashboards/i),
|
|
1016
|
+
).toBeInTheDocument();
|
|
1017
|
+
expect(fetchTableMock).not.toHaveBeenCalled();
|
|
1018
|
+
expect(screen.queryByText("Slow queries")).not.toBeInTheDocument();
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
test("chart widget renders the placeholder and issues no request", () => {
|
|
1022
|
+
renderChartWidget({
|
|
1023
|
+
queries: [{ id: "q1", dataSourceId: "ds-prom", query: "up" }],
|
|
1024
|
+
chartTitle: "Throughput",
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
expect(
|
|
1028
|
+
screen.getByText(/not available on public dashboards/i),
|
|
1029
|
+
).toBeInTheDocument();
|
|
1030
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
1031
|
+
expect(screen.queryByTestId("metric-charts")).not.toBeInTheDocument();
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
describe("a rejected fetch surfaces a message instead of throwing", () => {
|
|
1036
|
+
test("value widget renders the friendly error", async () => {
|
|
1037
|
+
fetchTimeSeriesMock.mockRejectedValue(
|
|
1038
|
+
new Error("Prometheus refused the connection"),
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
renderValueWidget({
|
|
1042
|
+
query: { dataSourceId: "ds-prom", query: "up" },
|
|
1043
|
+
title: "Up",
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
await waitFor((): void => {
|
|
1047
|
+
expect(
|
|
1048
|
+
screen.getByText("Prometheus refused the connection"),
|
|
1049
|
+
).toBeInTheDocument();
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
test("gauge widget renders the friendly error", async () => {
|
|
1054
|
+
fetchTimeSeriesMock.mockRejectedValue(new Error("Query timed out"));
|
|
1055
|
+
|
|
1056
|
+
renderGaugeWidget({
|
|
1057
|
+
query: { dataSourceId: "ds-prom", query: "cpu" },
|
|
1058
|
+
gaugeTitle: "CPU",
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
await waitFor((): void => {
|
|
1062
|
+
expect(screen.getByText("Query timed out")).toBeInTheDocument();
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
test("table widget renders the friendly error instead of an empty grid", async () => {
|
|
1067
|
+
fetchTableMock.mockRejectedValue(
|
|
1068
|
+
new Error('relation "deploys" does not exist'),
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
const rendered: RenderResult = renderTableWidget({
|
|
1072
|
+
query: { dataSourceId: "ds-pg", query: "select * from deploys" },
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
await waitFor((): void => {
|
|
1076
|
+
expect(
|
|
1077
|
+
screen.getByText('relation "deploys" does not exist'),
|
|
1078
|
+
).toBeInTheDocument();
|
|
1079
|
+
});
|
|
1080
|
+
expect(rendered.container.querySelector("table")).toBeNull();
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
test("chart widget renders the friendly error and no chart", async () => {
|
|
1084
|
+
fetchTimeSeriesMock.mockRejectedValue(new Error("Data source not found"));
|
|
1085
|
+
|
|
1086
|
+
renderChartWidget({
|
|
1087
|
+
queries: [{ id: "q1", dataSourceId: "ds-prom", query: "up" }],
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
await waitFor((): void => {
|
|
1091
|
+
expect(screen.getByText("Data source not found")).toBeInTheDocument();
|
|
1092
|
+
});
|
|
1093
|
+
expect(screen.queryByTestId("metric-charts")).not.toBeInTheDocument();
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
describe("table widget row cap", () => {
|
|
1098
|
+
beforeEach((): void => {
|
|
1099
|
+
fetchTableMock.mockResolvedValue(TABLE_RESULT);
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
test("renders every row and no truncation flag when maxRows is unset", async () => {
|
|
1103
|
+
const rendered: RenderResult = renderTableWidget({
|
|
1104
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
await waitFor((): void => {
|
|
1108
|
+
expect(getRenderedRows(rendered.container)).toHaveLength(4);
|
|
1109
|
+
});
|
|
1110
|
+
expect(getRowCountLabel(rendered.container)).toBe("4 rows");
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
test("trims to maxRows and flags the result as truncated", async () => {
|
|
1114
|
+
const rendered: RenderResult = renderTableWidget({
|
|
1115
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
1116
|
+
maxRows: 2,
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
await waitFor((): void => {
|
|
1120
|
+
expect(getRenderedRows(rendered.container)).toHaveLength(2);
|
|
1121
|
+
});
|
|
1122
|
+
expect(getRowCountLabel(rendered.container)).toBe("2 rows (truncated)");
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
test("trims AFTER sorting, so the cap means top-N by the sorted column", async () => {
|
|
1126
|
+
const rendered: RenderResult = renderTableWidget({
|
|
1127
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
1128
|
+
maxRows: 2,
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
await waitFor((): void => {
|
|
1132
|
+
expect(getRenderedRows(rendered.container)).toHaveLength(2);
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// Unsorted: the first two rows the query returned.
|
|
1136
|
+
expect(
|
|
1137
|
+
getRenderedRows(rendered.container).map(
|
|
1138
|
+
(row: Array<string>): string => {
|
|
1139
|
+
return row[0]!;
|
|
1140
|
+
},
|
|
1141
|
+
),
|
|
1142
|
+
).toEqual(["checkout", "search"]);
|
|
1143
|
+
|
|
1144
|
+
fireEvent.click(screen.getByRole("columnheader", { name: /Errors/ }));
|
|
1145
|
+
|
|
1146
|
+
// Ascending by error count: the two smallest, not the first two rows.
|
|
1147
|
+
expect(
|
|
1148
|
+
getRenderedRows(rendered.container).map(
|
|
1149
|
+
(row: Array<string>): string => {
|
|
1150
|
+
return row[0]!;
|
|
1151
|
+
},
|
|
1152
|
+
),
|
|
1153
|
+
).toEqual(["search", "auth"]);
|
|
1154
|
+
expect(getRowCountLabel(rendered.container)).toBe("2 rows (truncated)");
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test("a maxRows of zero is treated as no cap at all", async () => {
|
|
1158
|
+
const rendered: RenderResult = renderTableWidget({
|
|
1159
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
1160
|
+
maxRows: 0,
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
await waitFor((): void => {
|
|
1164
|
+
expect(getRenderedRows(rendered.container)).toHaveLength(4);
|
|
1165
|
+
});
|
|
1166
|
+
expect(getRowCountLabel(rendered.container)).toBe("4 rows");
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
test("a maxRows larger than the result set does not claim truncation", async () => {
|
|
1170
|
+
const rendered: RenderResult = renderTableWidget({
|
|
1171
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
1172
|
+
maxRows: 50,
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
await waitFor((): void => {
|
|
1176
|
+
expect(getRenderedRows(rendered.container)).toHaveLength(4);
|
|
1177
|
+
});
|
|
1178
|
+
expect(getRowCountLabel(rendered.container)).toBe("4 rows");
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
test("the connector's own truncation is reported even without a widget cap", async () => {
|
|
1182
|
+
fetchTableMock.mockResolvedValue({
|
|
1183
|
+
...TABLE_RESULT,
|
|
1184
|
+
truncated: true,
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
const rendered: RenderResult = renderTableWidget({
|
|
1188
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
await waitFor((): void => {
|
|
1192
|
+
expect(getRenderedRows(rendered.container)).toHaveLength(4);
|
|
1193
|
+
});
|
|
1194
|
+
expect(getRowCountLabel(rendered.container)).toBe("4 rows (truncated)");
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
test("a query returning no columns says so rather than drawing an empty table", async () => {
|
|
1198
|
+
fetchTableMock.mockResolvedValue({
|
|
1199
|
+
columns: [],
|
|
1200
|
+
rows: [],
|
|
1201
|
+
truncated: false,
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
const rendered: RenderResult = renderTableWidget({
|
|
1205
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
await waitFor((): void => {
|
|
1209
|
+
expect(screen.getByText("No rows returned.")).toBeInTheDocument();
|
|
1210
|
+
});
|
|
1211
|
+
expect(rendered.container.querySelector("table")).toBeNull();
|
|
1212
|
+
expect(getRowCountLabel(rendered.container)).toBe("0 rows");
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
describe("chart widget fans out one request per configured query", () => {
|
|
1217
|
+
test("fires a request per query and ignores half-filled cards", async () => {
|
|
1218
|
+
fetchTimeSeriesMock.mockImplementation(
|
|
1219
|
+
(args: DataSourceFetchArgs): Promise<AggregatedResult> => {
|
|
1220
|
+
return Promise.resolve(
|
|
1221
|
+
buildSeries([
|
|
1222
|
+
buildPoint(1, "2026-08-10T09:15:00.000Z", {
|
|
1223
|
+
query: args.queryConfig.query,
|
|
1224
|
+
}),
|
|
1225
|
+
]),
|
|
1226
|
+
);
|
|
1227
|
+
},
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
renderChartWidget({
|
|
1231
|
+
queries: [
|
|
1232
|
+
{ id: "q1", dataSourceId: "ds-prom", query: "up" },
|
|
1233
|
+
// Source picked, query still empty — the user is mid-edit.
|
|
1234
|
+
{ id: "q2", dataSourceId: "ds-prom", query: " " },
|
|
1235
|
+
// Query typed, source never picked.
|
|
1236
|
+
{ id: "q3", dataSourceId: "", query: "select 1" },
|
|
1237
|
+
{ id: "q4", dataSourceId: "ds-pg", query: "select count(*) from t" },
|
|
1238
|
+
],
|
|
1239
|
+
chartType: DashboardChartType.Line,
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
await waitFor((): void => {
|
|
1243
|
+
expect(screen.getByTestId("metric-charts")).toBeInTheDocument();
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
|
|
1247
|
+
const calls: Array<DataSourceFetchArgs> =
|
|
1248
|
+
getFetchCalls(fetchTimeSeriesMock);
|
|
1249
|
+
expect(
|
|
1250
|
+
calls.map((call: DataSourceFetchArgs): string => {
|
|
1251
|
+
return call.queryConfig.query;
|
|
1252
|
+
}),
|
|
1253
|
+
).toEqual(["up", "select count(*) from t"]);
|
|
1254
|
+
expect(
|
|
1255
|
+
calls.map((call: DataSourceFetchArgs): string => {
|
|
1256
|
+
return call.queryConfig.dataSourceId;
|
|
1257
|
+
}),
|
|
1258
|
+
).toEqual(["ds-prom", "ds-pg"]);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
test("hands the chart layer one result and one synthetic config per ready query", async () => {
|
|
1262
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
1263
|
+
buildSeries([
|
|
1264
|
+
buildPoint(5, "2026-08-10T09:15:00.000Z", { instance: "a:9090" }),
|
|
1265
|
+
]),
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
renderChartWidget({
|
|
1269
|
+
queries: [
|
|
1270
|
+
{ id: "q1", dataSourceId: "ds-prom", query: "up" },
|
|
1271
|
+
{ id: "q2", dataSourceId: "ds-prom", query: "" },
|
|
1272
|
+
{ id: "q3", dataSourceId: "ds-prom", query: "rate(x[5m])" },
|
|
1273
|
+
],
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
await waitFor((): void => {
|
|
1277
|
+
expect(screen.getByTestId("metric-charts")).toHaveTextContent(
|
|
1278
|
+
"results:2 configs:2",
|
|
1279
|
+
);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
const lastProps: MetricChartsStubProps = metricChartsRenderMock.mock
|
|
1283
|
+
.calls[
|
|
1284
|
+
metricChartsRenderMock.mock.calls.length - 1
|
|
1285
|
+
]![0] as MetricChartsStubProps;
|
|
1286
|
+
|
|
1287
|
+
expect(
|
|
1288
|
+
lastProps.metricViewData.queryConfigs.map(
|
|
1289
|
+
(config: MetricQueryConfigData): string | undefined => {
|
|
1290
|
+
return config.id;
|
|
1291
|
+
},
|
|
1292
|
+
),
|
|
1293
|
+
).toEqual(["q1", "q3"]);
|
|
1294
|
+
expect(
|
|
1295
|
+
lastProps.metricViewData.queryConfigs[0]!.metricQueryData
|
|
1296
|
+
.groupByAttributeKeys,
|
|
1297
|
+
).toEqual(["instance"]);
|
|
1298
|
+
expect(lastProps.topNOverrideScope).toBe(COMPONENT_ID.toString());
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
test("one failing query fails the widget rather than drawing a partial chart", async () => {
|
|
1302
|
+
fetchTimeSeriesMock
|
|
1303
|
+
.mockResolvedValueOnce(
|
|
1304
|
+
buildSeries([buildPoint(1, "2026-08-10T09:15:00.000Z")]),
|
|
1305
|
+
)
|
|
1306
|
+
.mockRejectedValueOnce(new Error("Query exceeded the statement limit"));
|
|
1307
|
+
|
|
1308
|
+
renderChartWidget({
|
|
1309
|
+
queries: [
|
|
1310
|
+
{ id: "q1", dataSourceId: "ds-prom", query: "up" },
|
|
1311
|
+
{ id: "q2", dataSourceId: "ds-pg", query: "select 1" },
|
|
1312
|
+
],
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
await waitFor((): void => {
|
|
1316
|
+
expect(
|
|
1317
|
+
screen.getByText("Query exceeded the statement limit"),
|
|
1318
|
+
).toBeInTheDocument();
|
|
1319
|
+
});
|
|
1320
|
+
expect(screen.queryByTestId("metric-charts")).not.toBeInTheDocument();
|
|
1321
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
/*
|
|
1326
|
+
* The staleness guard (`fetchSequenceRef`). A refresh tick or a config
|
|
1327
|
+
* edit starts a second request while the first is still in flight, and
|
|
1328
|
+
* nothing orders their completions — a data source that is slow once is
|
|
1329
|
+
* slow unpredictably. Every one of these tests leaves two fetches open at
|
|
1330
|
+
* the same time and completes them in the WRONG order on purpose.
|
|
1331
|
+
*/
|
|
1332
|
+
describe("only the newest fetch is allowed to write state", () => {
|
|
1333
|
+
const VALUE_ARGS: DashboardDataSourceValueComponent["arguments"] = {
|
|
1334
|
+
query: { dataSourceId: "ds-prom", query: "up" },
|
|
1335
|
+
title: "Up",
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1338
|
+
const TABLE_ARGS: DashboardDataSourceTableComponent["arguments"] = {
|
|
1339
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1342
|
+
const CHART_ARGS: DashboardDataSourceChartComponent["arguments"] = {
|
|
1343
|
+
queries: [{ id: "q1", dataSourceId: "ds-prom", query: "up" }],
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
const STALE_TABLE_RESULT: DataSourceTableResult = {
|
|
1347
|
+
columns: [
|
|
1348
|
+
{
|
|
1349
|
+
key: "service",
|
|
1350
|
+
title: "Service",
|
|
1351
|
+
type: DataSourceTableColumnType.Text,
|
|
1352
|
+
},
|
|
1353
|
+
],
|
|
1354
|
+
rows: [{ service: "stale-service" }],
|
|
1355
|
+
truncated: false,
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
const FRESH_TABLE_RESULT: DataSourceTableResult = {
|
|
1359
|
+
columns: [
|
|
1360
|
+
{
|
|
1361
|
+
key: "service",
|
|
1362
|
+
title: "Service",
|
|
1363
|
+
type: DataSourceTableColumnType.Text,
|
|
1364
|
+
},
|
|
1365
|
+
],
|
|
1366
|
+
rows: [{ service: "fresh-service" }],
|
|
1367
|
+
truncated: false,
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
test("value widget keeps the newer number when the older fetch answers last", async () => {
|
|
1371
|
+
const slowFirst: DeferredPromise<AggregatedResult> =
|
|
1372
|
+
createDeferred<AggregatedResult>();
|
|
1373
|
+
const newerSecond: DeferredPromise<AggregatedResult> =
|
|
1374
|
+
createDeferred<AggregatedResult>();
|
|
1375
|
+
|
|
1376
|
+
fetchTimeSeriesMock
|
|
1377
|
+
.mockReturnValueOnce(slowFirst.promise)
|
|
1378
|
+
.mockReturnValueOnce(newerSecond.promise);
|
|
1379
|
+
|
|
1380
|
+
const rendered: RenderResult = render(
|
|
1381
|
+
buildValueElement(VALUE_ARGS, { refreshTick: 0 }),
|
|
1382
|
+
);
|
|
1383
|
+
|
|
1384
|
+
// The auto-refresh tick, while the first request is still open.
|
|
1385
|
+
rendered.rerender(buildValueElement(VALUE_ARGS, { refreshTick: 1 }));
|
|
1386
|
+
|
|
1387
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
|
|
1388
|
+
|
|
1389
|
+
await settleAndFlush((): void => {
|
|
1390
|
+
newerSecond.resolve(
|
|
1391
|
+
buildSeries([buildPoint(77, "2026-08-10T09:40:00.000Z")]),
|
|
1392
|
+
);
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
expect(rendered.container.textContent).toContain("77");
|
|
1396
|
+
|
|
1397
|
+
await settleAndFlush((): void => {
|
|
1398
|
+
slowFirst.resolve(
|
|
1399
|
+
buildSeries([buildPoint(11, "2026-08-10T09:10:00.000Z")]),
|
|
1400
|
+
);
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
expect(rendered.container.textContent).toContain("77");
|
|
1404
|
+
expect(rendered.container.textContent).not.toContain("11");
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
/*
|
|
1408
|
+
* The parenthetical in the comment: the stale response does not merely
|
|
1409
|
+
* lose the value race, it must not run `setError("")` either — that
|
|
1410
|
+
* would wipe a failure the user is entitled to see and leave the tile
|
|
1411
|
+
* silently blank.
|
|
1412
|
+
*/
|
|
1413
|
+
test("value widget's newer error survives a stale success landing after it", async () => {
|
|
1414
|
+
const slowFirst: DeferredPromise<AggregatedResult> =
|
|
1415
|
+
createDeferred<AggregatedResult>();
|
|
1416
|
+
const failingSecond: DeferredPromise<AggregatedResult> =
|
|
1417
|
+
createDeferred<AggregatedResult>();
|
|
1418
|
+
|
|
1419
|
+
fetchTimeSeriesMock
|
|
1420
|
+
.mockReturnValueOnce(slowFirst.promise)
|
|
1421
|
+
.mockReturnValueOnce(failingSecond.promise);
|
|
1422
|
+
|
|
1423
|
+
const rendered: RenderResult = render(
|
|
1424
|
+
buildValueElement(VALUE_ARGS, { refreshTick: 0 }),
|
|
1425
|
+
);
|
|
1426
|
+
rendered.rerender(buildValueElement(VALUE_ARGS, { refreshTick: 1 }));
|
|
1427
|
+
|
|
1428
|
+
await settleAndFlush((): void => {
|
|
1429
|
+
failingSecond.reject(new Error("Prometheus refused the connection"));
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
expect(
|
|
1433
|
+
screen.getByText("Prometheus refused the connection"),
|
|
1434
|
+
).toBeInTheDocument();
|
|
1435
|
+
|
|
1436
|
+
await settleAndFlush((): void => {
|
|
1437
|
+
slowFirst.resolve(
|
|
1438
|
+
buildSeries([buildPoint(11, "2026-08-10T09:10:00.000Z")]),
|
|
1439
|
+
);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
expect(
|
|
1443
|
+
screen.getByText("Prometheus refused the connection"),
|
|
1444
|
+
).toBeInTheDocument();
|
|
1445
|
+
expect(rendered.container.textContent).not.toContain("11");
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
/*
|
|
1449
|
+
* The mirror image, and the one that proves the stale response really is
|
|
1450
|
+
* delivered rather than dropped: the component is awaiting this promise,
|
|
1451
|
+
* so its rejection reaches the widget's own catch (an unawaited one would
|
|
1452
|
+
* surface as an unhandled rejection instead). The catch has to bail the
|
|
1453
|
+
* same way the success path does.
|
|
1454
|
+
*/
|
|
1455
|
+
test("value widget's newer data survives a stale failure landing after it", async () => {
|
|
1456
|
+
const slowFirst: DeferredPromise<AggregatedResult> =
|
|
1457
|
+
createDeferred<AggregatedResult>();
|
|
1458
|
+
const newerSecond: DeferredPromise<AggregatedResult> =
|
|
1459
|
+
createDeferred<AggregatedResult>();
|
|
1460
|
+
|
|
1461
|
+
fetchTimeSeriesMock
|
|
1462
|
+
.mockReturnValueOnce(slowFirst.promise)
|
|
1463
|
+
.mockReturnValueOnce(newerSecond.promise);
|
|
1464
|
+
|
|
1465
|
+
const rendered: RenderResult = render(
|
|
1466
|
+
buildValueElement(VALUE_ARGS, { refreshTick: 0 }),
|
|
1467
|
+
);
|
|
1468
|
+
rendered.rerender(buildValueElement(VALUE_ARGS, { refreshTick: 1 }));
|
|
1469
|
+
|
|
1470
|
+
await settleAndFlush((): void => {
|
|
1471
|
+
newerSecond.resolve(
|
|
1472
|
+
buildSeries([buildPoint(77, "2026-08-10T09:40:00.000Z")]),
|
|
1473
|
+
);
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
expect(rendered.container.textContent).toContain("77");
|
|
1477
|
+
|
|
1478
|
+
await settleAndFlush((): void => {
|
|
1479
|
+
slowFirst.reject(new Error("Connection reset by peer"));
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
expect(rendered.container.textContent).toContain("77");
|
|
1483
|
+
expect(
|
|
1484
|
+
screen.queryByText("Connection reset by peer"),
|
|
1485
|
+
).not.toBeInTheDocument();
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
test("table widget keeps the newer rows when the older fetch answers last", async () => {
|
|
1489
|
+
const slowFirst: DeferredPromise<DataSourceTableResult> =
|
|
1490
|
+
createDeferred<DataSourceTableResult>();
|
|
1491
|
+
const newerSecond: DeferredPromise<DataSourceTableResult> =
|
|
1492
|
+
createDeferred<DataSourceTableResult>();
|
|
1493
|
+
|
|
1494
|
+
fetchTableMock
|
|
1495
|
+
.mockReturnValueOnce(slowFirst.promise)
|
|
1496
|
+
.mockReturnValueOnce(newerSecond.promise);
|
|
1497
|
+
|
|
1498
|
+
const rendered: RenderResult = render(
|
|
1499
|
+
buildTableElement(TABLE_ARGS, { refreshTick: 0 }),
|
|
1500
|
+
);
|
|
1501
|
+
rendered.rerender(buildTableElement(TABLE_ARGS, { refreshTick: 1 }));
|
|
1502
|
+
|
|
1503
|
+
expect(fetchTableMock).toHaveBeenCalledTimes(2);
|
|
1504
|
+
|
|
1505
|
+
await settleAndFlush((): void => {
|
|
1506
|
+
newerSecond.resolve(FRESH_TABLE_RESULT);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
expect(getRenderedRows(rendered.container)).toEqual([["fresh-service"]]);
|
|
1510
|
+
|
|
1511
|
+
await settleAndFlush((): void => {
|
|
1512
|
+
slowFirst.resolve(STALE_TABLE_RESULT);
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
expect(getRenderedRows(rendered.container)).toEqual([["fresh-service"]]);
|
|
1516
|
+
expect(rendered.container.textContent).not.toContain("stale-service");
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
test("table widget's newer error survives a stale success landing after it", async () => {
|
|
1520
|
+
const slowFirst: DeferredPromise<DataSourceTableResult> =
|
|
1521
|
+
createDeferred<DataSourceTableResult>();
|
|
1522
|
+
const failingSecond: DeferredPromise<DataSourceTableResult> =
|
|
1523
|
+
createDeferred<DataSourceTableResult>();
|
|
1524
|
+
|
|
1525
|
+
fetchTableMock
|
|
1526
|
+
.mockReturnValueOnce(slowFirst.promise)
|
|
1527
|
+
.mockReturnValueOnce(failingSecond.promise);
|
|
1528
|
+
|
|
1529
|
+
const rendered: RenderResult = render(
|
|
1530
|
+
buildTableElement(TABLE_ARGS, { refreshTick: 0 }),
|
|
1531
|
+
);
|
|
1532
|
+
rendered.rerender(buildTableElement(TABLE_ARGS, { refreshTick: 1 }));
|
|
1533
|
+
|
|
1534
|
+
await settleAndFlush((): void => {
|
|
1535
|
+
failingSecond.reject(new Error('relation "deploys" does not exist'));
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
expect(
|
|
1539
|
+
screen.getByText('relation "deploys" does not exist'),
|
|
1540
|
+
).toBeInTheDocument();
|
|
1541
|
+
|
|
1542
|
+
await settleAndFlush((): void => {
|
|
1543
|
+
slowFirst.resolve(STALE_TABLE_RESULT);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
expect(
|
|
1547
|
+
screen.getByText('relation "deploys" does not exist'),
|
|
1548
|
+
).toBeInTheDocument();
|
|
1549
|
+
expect(rendered.container.textContent).not.toContain("stale-service");
|
|
1550
|
+
expect(rendered.container.querySelector("table")).toBeNull();
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
test("table widget's newer rows survive a stale failure landing after them", async () => {
|
|
1554
|
+
const slowFirst: DeferredPromise<DataSourceTableResult> =
|
|
1555
|
+
createDeferred<DataSourceTableResult>();
|
|
1556
|
+
const newerSecond: DeferredPromise<DataSourceTableResult> =
|
|
1557
|
+
createDeferred<DataSourceTableResult>();
|
|
1558
|
+
|
|
1559
|
+
fetchTableMock
|
|
1560
|
+
.mockReturnValueOnce(slowFirst.promise)
|
|
1561
|
+
.mockReturnValueOnce(newerSecond.promise);
|
|
1562
|
+
|
|
1563
|
+
const rendered: RenderResult = render(
|
|
1564
|
+
buildTableElement(TABLE_ARGS, { refreshTick: 0 }),
|
|
1565
|
+
);
|
|
1566
|
+
rendered.rerender(buildTableElement(TABLE_ARGS, { refreshTick: 1 }));
|
|
1567
|
+
|
|
1568
|
+
await settleAndFlush((): void => {
|
|
1569
|
+
newerSecond.resolve(FRESH_TABLE_RESULT);
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
expect(getRenderedRows(rendered.container)).toEqual([["fresh-service"]]);
|
|
1573
|
+
|
|
1574
|
+
await settleAndFlush((): void => {
|
|
1575
|
+
slowFirst.reject(new Error("Connection reset by peer"));
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
expect(getRenderedRows(rendered.container)).toEqual([["fresh-service"]]);
|
|
1579
|
+
expect(
|
|
1580
|
+
screen.queryByText("Connection reset by peer"),
|
|
1581
|
+
).not.toBeInTheDocument();
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
/*
|
|
1585
|
+
* Same race on the chart, where the comment adds "and Promise.all
|
|
1586
|
+
* completions are not ordered" — the whole batch of a stale tick is one
|
|
1587
|
+
* settlement, so a single late batch would replace every series at once.
|
|
1588
|
+
*/
|
|
1589
|
+
test("chart widget never hands the chart layer a stale batch", async () => {
|
|
1590
|
+
const slowFirst: DeferredPromise<AggregatedResult> =
|
|
1591
|
+
createDeferred<AggregatedResult>();
|
|
1592
|
+
const newerSecond: DeferredPromise<AggregatedResult> =
|
|
1593
|
+
createDeferred<AggregatedResult>();
|
|
1594
|
+
|
|
1595
|
+
fetchTimeSeriesMock
|
|
1596
|
+
.mockReturnValueOnce(slowFirst.promise)
|
|
1597
|
+
.mockReturnValueOnce(newerSecond.promise);
|
|
1598
|
+
|
|
1599
|
+
const chart: RenderResult = render(
|
|
1600
|
+
buildChartElement(CHART_ARGS, { refreshTick: 0 }),
|
|
1601
|
+
);
|
|
1602
|
+
chart.rerender(buildChartElement(CHART_ARGS, { refreshTick: 1 }));
|
|
1603
|
+
|
|
1604
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
|
|
1605
|
+
|
|
1606
|
+
await settleAndFlush((): void => {
|
|
1607
|
+
newerSecond.resolve(
|
|
1608
|
+
buildSeries([buildPoint(77, "2026-08-10T09:40:00.000Z")]),
|
|
1609
|
+
);
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
expect(screen.getByTestId("metric-charts")).toBeInTheDocument();
|
|
1613
|
+
|
|
1614
|
+
await settleAndFlush((): void => {
|
|
1615
|
+
slowFirst.resolve(
|
|
1616
|
+
buildSeries([buildPoint(11, "2026-08-10T09:10:00.000Z")]),
|
|
1617
|
+
);
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
expect(getChartRenderedValues()).not.toContain(11);
|
|
1621
|
+
expect(
|
|
1622
|
+
getChartRenderedValues()[getChartRenderedValues().length - 1],
|
|
1623
|
+
).toBe(77);
|
|
1624
|
+
});
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
/*
|
|
1628
|
+
* `refreshTick` is a dependency of the startAndEndDate memo purely so a
|
|
1629
|
+
* RELATIVE range re-resolves against the wall clock on every auto-refresh.
|
|
1630
|
+
* Every other test in this file pins a CUSTOM range, which resolves to the
|
|
1631
|
+
* same two instants forever and therefore cannot tell a working dep from a
|
|
1632
|
+
* missing one.
|
|
1633
|
+
*/
|
|
1634
|
+
describe("an auto-refresh re-resolves a relative range against the clock", () => {
|
|
1635
|
+
const RELATIVE_RANGE: RangeStartAndEndDateTime = {
|
|
1636
|
+
range: TimeRange.PAST_ONE_HOUR,
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
const ONE_HOUR_IN_MS: number = 60 * 60 * 1000;
|
|
1640
|
+
const FIVE_MINUTES_IN_MS: number = 5 * 60 * 1000;
|
|
1641
|
+
|
|
1642
|
+
beforeEach((): void => {
|
|
1643
|
+
jest.useFakeTimers();
|
|
1644
|
+
jest.setSystemTime(new Date("2026-08-10T12:00:00.000Z"));
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
afterEach((): void => {
|
|
1648
|
+
jest.useRealTimers();
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
test("value widget asks for a window that moved with the clock", async () => {
|
|
1652
|
+
const rendered: RenderResult = render(
|
|
1653
|
+
buildValueElement(
|
|
1654
|
+
{ query: { dataSourceId: "ds-prom", query: "up" }, title: "Up" },
|
|
1655
|
+
{ dashboardStartAndEndDate: RELATIVE_RANGE, refreshTick: 0 },
|
|
1656
|
+
),
|
|
1657
|
+
);
|
|
1658
|
+
|
|
1659
|
+
await act(async (): Promise<void> => {});
|
|
1660
|
+
|
|
1661
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
|
|
1662
|
+
|
|
1663
|
+
act((): void => {
|
|
1664
|
+
jest.advanceTimersByTime(FIVE_MINUTES_IN_MS);
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
rendered.rerender(
|
|
1668
|
+
buildValueElement(
|
|
1669
|
+
{ query: { dataSourceId: "ds-prom", query: "up" }, title: "Up" },
|
|
1670
|
+
{ dashboardStartAndEndDate: RELATIVE_RANGE, refreshTick: 1 },
|
|
1671
|
+
),
|
|
1672
|
+
);
|
|
1673
|
+
|
|
1674
|
+
await act(async (): Promise<void> => {});
|
|
1675
|
+
|
|
1676
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
|
|
1677
|
+
|
|
1678
|
+
const calls: Array<DataSourceFetchArgs> =
|
|
1679
|
+
getFetchCalls(fetchTimeSeriesMock);
|
|
1680
|
+
|
|
1681
|
+
// "Past 1 hour" really is an hour wide, both times.
|
|
1682
|
+
expect(calls[0]!.endDate.getTime() - calls[0]!.startDate.getTime()).toBe(
|
|
1683
|
+
ONE_HOUR_IN_MS,
|
|
1684
|
+
);
|
|
1685
|
+
expect(calls[1]!.endDate.getTime() - calls[1]!.startDate.getTime()).toBe(
|
|
1686
|
+
ONE_HOUR_IN_MS,
|
|
1687
|
+
);
|
|
1688
|
+
|
|
1689
|
+
// ...and the second one is the window five minutes later, not the first.
|
|
1690
|
+
expect(calls[1]!.endDate.getTime()).toBeGreaterThan(
|
|
1691
|
+
calls[0]!.endDate.getTime(),
|
|
1692
|
+
);
|
|
1693
|
+
expect(calls[1]!.endDate.getTime() - calls[0]!.endDate.getTime()).toBe(
|
|
1694
|
+
FIVE_MINUTES_IN_MS,
|
|
1695
|
+
);
|
|
1696
|
+
expect(
|
|
1697
|
+
calls[1]!.startDate.getTime() - calls[0]!.startDate.getTime(),
|
|
1698
|
+
).toBe(FIVE_MINUTES_IN_MS);
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
test("chart widget asks for a window that moved with the clock", async () => {
|
|
1702
|
+
const chartArgs: DashboardDataSourceChartComponent["arguments"] = {
|
|
1703
|
+
queries: [{ id: "q1", dataSourceId: "ds-prom", query: "up" }],
|
|
1704
|
+
};
|
|
1705
|
+
|
|
1706
|
+
const rendered: RenderResult = render(
|
|
1707
|
+
buildChartElement(chartArgs, {
|
|
1708
|
+
dashboardStartAndEndDate: RELATIVE_RANGE,
|
|
1709
|
+
refreshTick: 0,
|
|
1710
|
+
}),
|
|
1711
|
+
);
|
|
1712
|
+
|
|
1713
|
+
await act(async (): Promise<void> => {});
|
|
1714
|
+
|
|
1715
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
|
|
1716
|
+
|
|
1717
|
+
act((): void => {
|
|
1718
|
+
jest.advanceTimersByTime(FIVE_MINUTES_IN_MS);
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
rendered.rerender(
|
|
1722
|
+
buildChartElement(chartArgs, {
|
|
1723
|
+
dashboardStartAndEndDate: RELATIVE_RANGE,
|
|
1724
|
+
refreshTick: 1,
|
|
1725
|
+
}),
|
|
1726
|
+
);
|
|
1727
|
+
|
|
1728
|
+
await act(async (): Promise<void> => {});
|
|
1729
|
+
|
|
1730
|
+
expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
|
|
1731
|
+
|
|
1732
|
+
const calls: Array<DataSourceFetchArgs> =
|
|
1733
|
+
getFetchCalls(fetchTimeSeriesMock);
|
|
1734
|
+
expect(calls[1]!.endDate.getTime() - calls[0]!.endDate.getTime()).toBe(
|
|
1735
|
+
FIVE_MINUTES_IN_MS,
|
|
1736
|
+
);
|
|
1737
|
+
expect(calls[0]!.endDate.getTime() - calls[0]!.startDate.getTime()).toBe(
|
|
1738
|
+
ONE_HOUR_IN_MS,
|
|
1739
|
+
);
|
|
1740
|
+
});
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
/*
|
|
1744
|
+
* A window that resolves to no dates at all. The chart calls that an error
|
|
1745
|
+
* the user has to fix; the other three stop loading and say nothing about
|
|
1746
|
+
* it. The asymmetry is deliberate but easy to "tidy up" by accident, so
|
|
1747
|
+
* both halves are pinned here.
|
|
1748
|
+
*/
|
|
1749
|
+
describe("a window with no dates: the chart complains, the rest go quiet", () => {
|
|
1750
|
+
const NO_DATE_RANGE: RangeStartAndEndDateTime = {
|
|
1751
|
+
range: TimeRange.CUSTOM,
|
|
1752
|
+
startAndEndDate: new InBetween<Date>(
|
|
1753
|
+
undefined as unknown as Date,
|
|
1754
|
+
undefined as unknown as Date,
|
|
1755
|
+
),
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
const MISSING_DATE_ERROR: string =
|
|
1759
|
+
"Please select a valid start and end date.";
|
|
1760
|
+
|
|
1761
|
+
test("chart widget reports the missing window and queries nothing", async () => {
|
|
1762
|
+
renderChartWidget(
|
|
1763
|
+
{
|
|
1764
|
+
queries: [{ id: "q1", dataSourceId: "ds-prom", query: "up" }],
|
|
1765
|
+
chartTitle: "Throughput",
|
|
1766
|
+
},
|
|
1767
|
+
{ dashboardStartAndEndDate: NO_DATE_RANGE },
|
|
1768
|
+
);
|
|
1769
|
+
|
|
1770
|
+
await waitFor((): void => {
|
|
1771
|
+
expect(screen.getByText(MISSING_DATE_ERROR)).toBeInTheDocument();
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
1775
|
+
expect(screen.queryByTestId("metric-charts")).not.toBeInTheDocument();
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
test("value widget stops loading with no error of any kind", async () => {
|
|
1779
|
+
renderValueWidget(
|
|
1780
|
+
{ query: { dataSourceId: "ds-prom", query: "up" }, title: "Up" },
|
|
1781
|
+
{ dashboardStartAndEndDate: NO_DATE_RANGE },
|
|
1782
|
+
);
|
|
1783
|
+
|
|
1784
|
+
await waitFor((): void => {
|
|
1785
|
+
expect(
|
|
1786
|
+
screen.getByText("No data for the selected time range"),
|
|
1787
|
+
).toBeInTheDocument();
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
1791
|
+
expect(screen.queryByText(MISSING_DATE_ERROR)).not.toBeInTheDocument();
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
test("gauge widget stops loading with no error of any kind", async () => {
|
|
1795
|
+
renderGaugeWidget(
|
|
1796
|
+
{ query: { dataSourceId: "ds-prom", query: "cpu" }, gaugeTitle: "CPU" },
|
|
1797
|
+
{ dashboardStartAndEndDate: NO_DATE_RANGE },
|
|
1798
|
+
);
|
|
1799
|
+
|
|
1800
|
+
await waitFor((): void => {
|
|
1801
|
+
expect(
|
|
1802
|
+
screen.getByText("No data for the selected time range"),
|
|
1803
|
+
).toBeInTheDocument();
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
|
|
1807
|
+
expect(screen.queryByText(MISSING_DATE_ERROR)).not.toBeInTheDocument();
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
test("table widget stops loading with no error of any kind", async () => {
|
|
1811
|
+
const rendered: RenderResult = renderTableWidget(
|
|
1812
|
+
{
|
|
1813
|
+
query: { dataSourceId: "ds-pg", query: "select 1" },
|
|
1814
|
+
tableTitle: "Slow queries",
|
|
1815
|
+
},
|
|
1816
|
+
{ dashboardStartAndEndDate: NO_DATE_RANGE },
|
|
1817
|
+
);
|
|
1818
|
+
|
|
1819
|
+
await waitFor((): void => {
|
|
1820
|
+
expect(screen.getByText("No rows returned.")).toBeInTheDocument();
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
expect(fetchTableMock).not.toHaveBeenCalled();
|
|
1824
|
+
expect(screen.queryByText(MISSING_DATE_ERROR)).not.toBeInTheDocument();
|
|
1825
|
+
// The header still renders, so this is the quiet state, not the error tile.
|
|
1826
|
+
expect(screen.getByText("Slow queries")).toBeInTheDocument();
|
|
1827
|
+
expect(getRowCountLabel(rendered.container)).toBe("0 rows");
|
|
1828
|
+
});
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
/*
|
|
1832
|
+
* A widget title is NOT a metric name.
|
|
1833
|
+
*
|
|
1834
|
+
* The Value and Gauge widgets share their renderers with the OneUptime
|
|
1835
|
+
* metric widgets, and those renderers take a `metricName` so ValueFormatter
|
|
1836
|
+
* can apply two name-based heuristics:
|
|
1837
|
+
*
|
|
1838
|
+
* - isFractionMetric(name): with unit "1", a `.utilization` / `.ratio` /
|
|
1839
|
+
* `.fraction` / `.percent` name means the number is a [0, 1] ratio, so
|
|
1840
|
+
* the view multiplies it by 100 before displaying it AND before
|
|
1841
|
+
* comparing it against the widget's thresholds.
|
|
1842
|
+
* - isHigherWorseMetric(name): decides whether a rising trend is drawn
|
|
1843
|
+
* green or red when the widget's own `trendDirection` is unset.
|
|
1844
|
+
*
|
|
1845
|
+
* Both are safe for OneUptime metrics, whose names are exporter-authored
|
|
1846
|
+
* and follow those conventions. Neither is safe for external data: a Data
|
|
1847
|
+
* Source widget's title is free text a human typed, and naming a Postgres
|
|
1848
|
+
* query "cache.hit.utilization" is a label, not a promise that the number
|
|
1849
|
+
* is a ratio. So both widgets pass a hardcoded `metricName=""`
|
|
1850
|
+
* (DashboardDataSourceValueComponent.tsx ~201,
|
|
1851
|
+
* DashboardDataSourceGaugeComponent.tsx ~172) and the heuristics stay
|
|
1852
|
+
* silent.
|
|
1853
|
+
*
|
|
1854
|
+
* That constant is the kind of thing a later reader "fixes" by wiring the
|
|
1855
|
+
* title through — which silently multiplies a customer's 0.9 by 100 and
|
|
1856
|
+
* turns a Healthy gauge Critical. Every title below is therefore chosen to
|
|
1857
|
+
* be exactly a name that WOULD trip a heuristic, and each test is paired
|
|
1858
|
+
* with a control that asks for the same behaviour EXPLICITLY (via `unit` or
|
|
1859
|
+
* `trendDirection`). The controls are what make a failure legible: the
|
|
1860
|
+
* invariant is "the title is never read as a metric name", not "this widget
|
|
1861
|
+
* can never render a percent or a red arrow".
|
|
1862
|
+
*/
|
|
1863
|
+
describe("a widget title is never read as a metric name", () => {
|
|
1864
|
+
/*
|
|
1865
|
+
* The big number and its unit suffix. Both views draw them in the same
|
|
1866
|
+
* `font-bold tabular-nums` box, and everything else on the tile (title,
|
|
1867
|
+
* status label, gauge min/max footer, threshold tick tooltips) lives
|
|
1868
|
+
* outside it — so this reads the digits, not the chrome around them.
|
|
1869
|
+
*/
|
|
1870
|
+
type GetBigNumberFunction = (container: HTMLElement) => string;
|
|
1871
|
+
|
|
1872
|
+
const getBigNumberText: GetBigNumberFunction = (
|
|
1873
|
+
container: HTMLElement,
|
|
1874
|
+
): string => {
|
|
1875
|
+
const element: Element | null = container.querySelector(
|
|
1876
|
+
"div.font-bold.tabular-nums",
|
|
1877
|
+
);
|
|
1878
|
+
|
|
1879
|
+
if (!element) {
|
|
1880
|
+
throw new Error("Widget did not render a value.");
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
return (element.textContent || "").trim();
|
|
1884
|
+
};
|
|
1885
|
+
|
|
1886
|
+
/** The trend chip: which way it points, and which way it was coloured. */
|
|
1887
|
+
interface TrendIndicator {
|
|
1888
|
+
tone: "good" | "bad";
|
|
1889
|
+
text: string;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
type GetTrendIndicatorFunction = (container: HTMLElement) => TrendIndicator;
|
|
1893
|
+
|
|
1894
|
+
const getTrendIndicator: GetTrendIndicatorFunction = (
|
|
1895
|
+
container: HTMLElement,
|
|
1896
|
+
): TrendIndicator => {
|
|
1897
|
+
const good: Element | null = container.querySelector(
|
|
1898
|
+
"span.text-emerald-500",
|
|
1899
|
+
);
|
|
1900
|
+
const bad: Element | null = container.querySelector("span.text-red-500");
|
|
1901
|
+
|
|
1902
|
+
if (good && !bad) {
|
|
1903
|
+
return { tone: "good", text: (good.textContent || "").trim() };
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
if (bad && !good) {
|
|
1907
|
+
return { tone: "bad", text: (bad.textContent || "").trim() };
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
throw new Error(
|
|
1911
|
+
"Value widget did not render exactly one trend indicator.",
|
|
1912
|
+
);
|
|
1913
|
+
};
|
|
1914
|
+
|
|
1915
|
+
/*
|
|
1916
|
+
* Ends in ".utilization", so ValueFormatter.isFractionMetric matches it —
|
|
1917
|
+
* a perfectly ordinary thing to call a cache hit-rate query.
|
|
1918
|
+
*/
|
|
1919
|
+
const FRACTION_LOOKING_TITLE: string = "cache.hit.utilization";
|
|
1920
|
+
|
|
1921
|
+
/*
|
|
1922
|
+
* Contains "error", so ValueFormatter.isHigherWorseMetric matches it,
|
|
1923
|
+
* while carrying none of the "higher is better" tokens that regex checks
|
|
1924
|
+
* first.
|
|
1925
|
+
*/
|
|
1926
|
+
const HIGHER_WORSE_LOOKING_TITLE: string = "api.error.rate";
|
|
1927
|
+
|
|
1928
|
+
/*
|
|
1929
|
+
* Four points is the minimum the trend calculation looks at, and the
|
|
1930
|
+
* second half averages double the first — an unambiguous rise, so the
|
|
1931
|
+
* only thing left in question is its colour.
|
|
1932
|
+
*/
|
|
1933
|
+
const RISING_SERIES: Array<AggregatedModel> = [
|
|
1934
|
+
buildPoint(10, "2026-08-10T09:10:00.000Z"),
|
|
1935
|
+
buildPoint(10, "2026-08-10T09:20:00.000Z"),
|
|
1936
|
+
buildPoint(20, "2026-08-10T09:30:00.000Z"),
|
|
1937
|
+
buildPoint(20, "2026-08-10T09:40:00.000Z"),
|
|
1938
|
+
];
|
|
1939
|
+
|
|
1940
|
+
const FRACTION_GAUGE_ARGS: DashboardDataSourceGaugeComponent["arguments"] =
|
|
1941
|
+
{
|
|
1942
|
+
query: { dataSourceId: "ds-prom", query: "cache_hit_rate" },
|
|
1943
|
+
gaugeTitle: FRACTION_LOOKING_TITLE,
|
|
1944
|
+
unit: "1",
|
|
1945
|
+
minValue: 0,
|
|
1946
|
+
maxValue: 100,
|
|
1947
|
+
warningThreshold: 50,
|
|
1948
|
+
criticalThreshold: 80,
|
|
1949
|
+
};
|
|
1950
|
+
|
|
1951
|
+
test("value widget shows 0.9 as 0.9, not as the percent its title implies", async () => {
|
|
1952
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
1953
|
+
buildSeries([buildPoint(0.9, "2026-08-10T09:30:00.000Z")]),
|
|
1954
|
+
);
|
|
1955
|
+
|
|
1956
|
+
const rendered: RenderResult = renderValueWidget({
|
|
1957
|
+
query: { dataSourceId: "ds-prom", query: "cache_hit_rate" },
|
|
1958
|
+
title: FRACTION_LOOKING_TITLE,
|
|
1959
|
+
unit: "1",
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
await waitFor((): void => {
|
|
1963
|
+
expect(getBigNumberText(rendered.container)).toBe("0.9");
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
// 90.00% is exactly what isFractionMetric would have produced here.
|
|
1967
|
+
expect(rendered.container.textContent).not.toContain("90.00");
|
|
1968
|
+
expect(getBigNumberText(rendered.container)).not.toContain("%");
|
|
1969
|
+
});
|
|
1970
|
+
|
|
1971
|
+
test("the same value widget does render a percent when the UNIT says so", async () => {
|
|
1972
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
1973
|
+
buildSeries([buildPoint(0.9, "2026-08-10T09:30:00.000Z")]),
|
|
1974
|
+
);
|
|
1975
|
+
|
|
1976
|
+
const rendered: RenderResult = renderValueWidget({
|
|
1977
|
+
query: { dataSourceId: "ds-prom", query: "cache_hit_rate" },
|
|
1978
|
+
title: FRACTION_LOOKING_TITLE,
|
|
1979
|
+
unit: "%",
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
/*
|
|
1983
|
+
* The percent path is alive and the title is unchanged — so the test
|
|
1984
|
+
* above is pinning where the "%" decision comes from, not the absence
|
|
1985
|
+
* of percent rendering. Still 0.9, not 90: only the fraction heuristic
|
|
1986
|
+
* rescales, and it did not fire here either.
|
|
1987
|
+
*/
|
|
1988
|
+
await waitFor((): void => {
|
|
1989
|
+
expect(getBigNumberText(rendered.container)).toBe("0.90%");
|
|
1990
|
+
});
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
test("gauge widget compares 0.9 against its thresholds unscaled", async () => {
|
|
1994
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
1995
|
+
buildSeries([buildPoint(0.9, "2026-08-10T09:30:00.000Z")]),
|
|
1996
|
+
);
|
|
1997
|
+
|
|
1998
|
+
const rendered: RenderResult = renderGaugeWidget(FRACTION_GAUGE_ARGS);
|
|
1999
|
+
|
|
2000
|
+
/*
|
|
2001
|
+
* Thresholds are authored on the gauge's own 0-100 scale. Rescaled,
|
|
2002
|
+
* 0.9 would become 90 and cross the critical line at 80; unscaled it
|
|
2003
|
+
* sits below both, which is what the user configured.
|
|
2004
|
+
*/
|
|
2005
|
+
await waitFor((): void => {
|
|
2006
|
+
expect(screen.getByText("Healthy")).toBeInTheDocument();
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
expect(getBigNumberText(rendered.container)).toBe("0.9");
|
|
2010
|
+
expect(screen.queryByText("Critical")).not.toBeInTheDocument();
|
|
2011
|
+
expect(screen.queryByText("Warning")).not.toBeInTheDocument();
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
test("the same gauge does go Critical once the value really is 90", async () => {
|
|
2015
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
2016
|
+
buildSeries([buildPoint(90, "2026-08-10T09:30:00.000Z")]),
|
|
2017
|
+
);
|
|
2018
|
+
|
|
2019
|
+
const rendered: RenderResult = renderGaugeWidget(FRACTION_GAUGE_ARGS);
|
|
2020
|
+
|
|
2021
|
+
/*
|
|
2022
|
+
* Same widget, same thresholds, same title — only the number moved.
|
|
2023
|
+
* The critical branch is reachable, so the Healthy above is a
|
|
2024
|
+
* statement about the scale of the value, not about dead thresholds.
|
|
2025
|
+
*/
|
|
2026
|
+
await waitFor((): void => {
|
|
2027
|
+
expect(screen.getByText("Critical")).toBeInTheDocument();
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
expect(getBigNumberText(rendered.container)).toBe("90");
|
|
2031
|
+
expect(screen.queryByText("Healthy")).not.toBeInTheDocument();
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
test("the same gauge does render a percent when the UNIT says so", async () => {
|
|
2035
|
+
fetchTimeSeriesMock.mockResolvedValue(
|
|
2036
|
+
buildSeries([buildPoint(0.9, "2026-08-10T09:30:00.000Z")]),
|
|
2037
|
+
);
|
|
2038
|
+
|
|
2039
|
+
const rendered: RenderResult = renderGaugeWidget({
|
|
2040
|
+
...FRACTION_GAUGE_ARGS,
|
|
2041
|
+
unit: "%",
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
await waitFor((): void => {
|
|
2045
|
+
expect(getBigNumberText(rendered.container)).toBe("0.90%");
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
// A percent SUFFIX, still no rescale — and still below the thresholds.
|
|
2049
|
+
expect(screen.getByText("Healthy")).toBeInTheDocument();
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
test("value widget colours a rise green even under an error-shaped title", async () => {
|
|
2053
|
+
fetchTimeSeriesMock.mockResolvedValue(buildSeries(RISING_SERIES));
|
|
2054
|
+
|
|
2055
|
+
const rendered: RenderResult = renderValueWidget({
|
|
2056
|
+
query: { dataSourceId: "ds-prom", query: "sum(rate(x[5m]))" },
|
|
2057
|
+
title: HIGHER_WORSE_LOOKING_TITLE,
|
|
2058
|
+
// trendDirection deliberately unset — this is the heuristic's branch.
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
await waitFor((): void => {
|
|
2062
|
+
expect(getBigNumberText(rendered.container)).toBe("20");
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
const trend: TrendIndicator = getTrendIndicator(rendered.container);
|
|
2066
|
+
expect(trend.text).toContain("↑");
|
|
2067
|
+
// The empty-metricName default: nothing known, so up is good.
|
|
2068
|
+
expect(trend.tone).toBe("good");
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
test("an explicit Auto is the same heuristic branch, and still reads up as good", async () => {
|
|
2072
|
+
fetchTimeSeriesMock.mockResolvedValue(buildSeries(RISING_SERIES));
|
|
2073
|
+
|
|
2074
|
+
const rendered: RenderResult = renderValueWidget({
|
|
2075
|
+
query: { dataSourceId: "ds-prom", query: "sum(rate(x[5m]))" },
|
|
2076
|
+
title: HIGHER_WORSE_LOOKING_TITLE,
|
|
2077
|
+
trendDirection: DashboardValueTrendDirection.Auto,
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
await waitFor((): void => {
|
|
2081
|
+
expect(getBigNumberText(rendered.container)).toBe("20");
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
expect(getTrendIndicator(rendered.container).tone).toBe("good");
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
test("the same rise IS painted red when the widget explicitly says so", async () => {
|
|
2088
|
+
fetchTimeSeriesMock.mockResolvedValue(buildSeries(RISING_SERIES));
|
|
2089
|
+
|
|
2090
|
+
const rendered: RenderResult = renderValueWidget({
|
|
2091
|
+
query: { dataSourceId: "ds-prom", query: "sum(rate(x[5m]))" },
|
|
2092
|
+
title: HIGHER_WORSE_LOOKING_TITLE,
|
|
2093
|
+
trendDirection: DashboardValueTrendDirection.HigherIsWorse,
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
await waitFor((): void => {
|
|
2097
|
+
expect(getBigNumberText(rendered.container)).toBe("20");
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
/*
|
|
2101
|
+
* The inverted colouring is reachable through configuration, so the
|
|
2102
|
+
* green above is a statement about where the decision came from.
|
|
2103
|
+
*/
|
|
2104
|
+
const trend: TrendIndicator = getTrendIndicator(rendered.container);
|
|
2105
|
+
expect(trend.text).toContain("↑");
|
|
2106
|
+
expect(trend.tone).toBe("bad");
|
|
2107
|
+
});
|
|
2108
|
+
});
|
|
2109
|
+
});
|