diff --git a/.betterer.results b/.betterer.results index d7345aa4cf6..2a993529fb0 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2846,22 +2846,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "10"], [0, 0, 0, "Unexpected any. Specify a different type.", "11"] ], - "public/app/features/trails/Breakdown/types.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/features/trails/Breakdown/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/trails/DataTrailBookmarks.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] - ], - "public/app/features/trails/DataTrailCard.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] - ], - "public/app/features/trails/MetricSelect/MetricSelectScene.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] - ], "public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/.github/commands.json b/.github/commands.json index e6c2036d7cf..f92e29a215b 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -573,7 +573,7 @@ }, { "type": "label", - "name": "area/exploremetrics", + "name": "area/metricsdrilldown", "action": "addToProject", "addToProject": { "url": "https://github.com/orgs/grafana/projects/516" diff --git a/.github/workflows/auto-triager/labels.txt b/.github/workflows/auto-triager/labels.txt index 1a33cf676dc..517a6ad867d 100644 --- a/.github/workflows/auto-triager/labels.txt +++ b/.github/workflows/auto-triager/labels.txt @@ -33,7 +33,6 @@ area/dashboard/variable area/dashboards/panel area/data/export area/explore -area/exploremetrics area/expressions area/field/overrides area/frontend/library-panels @@ -42,6 +41,7 @@ area/image-rendering area/internationalization area/legend area/library-panel +area/metricsdrilldown area/navigation area/panel/annotation-list area/panel/barchart diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 2377ade6f44..9c35f1b9483 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -57,7 +57,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes | | `ssoSettingsApi` | Enables the SSO settings API and the OAuth configuration UIs in Grafana | Yes | | `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes | -| `exploreMetrics` | Enables the new Grafana Metrics Drilldown core app | Yes | | `alertingSimplifiedRouting` | Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule | Yes | | `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes | | `lokiQueryHints` | Enables query hints for Loki | Yes | @@ -87,7 +86,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes | | `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes | | `lokiLabelNamesQueryApi` | Defaults to using the Loki `/labels` API instead of `/series` | Yes | -| `exploreMetricsUseExternalAppPlugin` | Use the externalized Grafana Metrics Drilldown (formerly known as Explore Metrics) app plugin | Yes | | `unifiedNavbars` | Enables unified navbars | | ## Public preview feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 71fd3773c2e..80f8acf7db7 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -461,11 +461,6 @@ export interface FeatureToggles { */ logsInfiniteScrolling?: boolean; /** - * Enables the new Grafana Metrics Drilldown core app - * @default true - */ - exploreMetrics?: boolean; - /** * Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule * @default true */ @@ -923,11 +918,6 @@ export interface FeatureToggles { */ elasticsearchImprovedParsing?: boolean; /** - * Use the externalized Grafana Metrics Drilldown (formerly known as Explore Metrics) app plugin - * @default true - */ - exploreMetricsUseExternalAppPlugin?: boolean; - /** * Shows defined connections for a data source in the plugins detail page */ datasourceConnectionsTab?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 12885943168..d129dfcb25e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -769,14 +769,6 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, }, - { - Name: "exploreMetrics", - Description: "Enables the new Grafana Metrics Drilldown core app", - Stage: FeatureStageGeneralAvailability, - Expression: "true", // enabled by default - FrontendOnly: true, - Owner: grafanaObservabilityMetricsSquad, - }, { Name: "alertingSimplifiedRouting", Description: "Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule", @@ -1564,15 +1556,6 @@ var ( Stage: FeatureStageExperimental, Owner: awsDatasourcesSquad, }, - { - Name: "exploreMetricsUseExternalAppPlugin", - Description: "Use the externalized Grafana Metrics Drilldown (formerly known as Explore Metrics) app plugin", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaObservabilityMetricsSquad, - Expression: "true", - FrontendOnly: false, - RequiresRestart: true, - }, { Name: "datasourceConnectionsTab", Description: "Shows defined connections for a data source in the plugins detail page", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 6263e9d0183..5621690738b 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -101,7 +101,6 @@ pdfTables,preview,@grafana/sharing-squad,false,false,false ssoSettingsApi,GA,@grafana/identity-access-team,false,false,false canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true logsInfiniteScrolling,GA,@grafana/observability-logs,false,false,true -exploreMetrics,GA,@grafana/observability-metrics,false,false,true alertingSimplifiedRouting,GA,@grafana/alerting-squad,false,false,false logRowsPopoverMenu,GA,@grafana/observability-logs,false,false,true pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false @@ -206,7 +205,6 @@ templateVariablesUsesCombobox,experimental,@grafana/grafana-frontend-platform,fa ABTestFeatureToggleB,experimental,@grafana/sharing-squad,false,false,false grafanaAdvisor,experimental,@grafana/plugins-platform-backend,false,false,false elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false -exploreMetricsUseExternalAppPlugin,GA,@grafana/observability-metrics,false,true,false datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true fetchRulesUsingPost,experimental,@grafana/alerting-squad,false,false,false newLogsPanel,experimental,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 1fedab7823d..24e7598742a 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -415,10 +415,6 @@ const ( // Enables infinite scrolling for the Logs panel in Explore and Dashboards FlagLogsInfiniteScrolling = "logsInfiniteScrolling" - // FlagExploreMetrics - // Enables the new Grafana Metrics Drilldown core app - FlagExploreMetrics = "exploreMetrics" - // FlagAlertingSimplifiedRouting // Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule FlagAlertingSimplifiedRouting = "alertingSimplifiedRouting" @@ -835,10 +831,6 @@ const ( // Enables less memory intensive Elasticsearch result parsing FlagElasticsearchImprovedParsing = "elasticsearchImprovedParsing" - // FlagExploreMetricsUseExternalAppPlugin - // Use the externalized Grafana Metrics Drilldown (formerly known as Explore Metrics) app plugin - FlagExploreMetricsUseExternalAppPlugin = "exploreMetricsUseExternalAppPlugin" - // FlagDatasourceConnectionsTab // Shows defined connections for a data source in the plugins detail page FlagDatasourceConnectionsTab = "datasourceConnectionsTab" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 0fa35053a07..730b65d2e3b 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1115,7 +1115,8 @@ "metadata": { "name": "exploreMetrics", "resourceVersion": "1743693517832", - "creationTimestamp": "2024-04-09T18:15:18Z" + "creationTimestamp": "2024-04-09T18:15:18Z", + "deletionTimestamp": "2025-04-11T01:41:57Z" }, "spec": { "description": "Enables the new Grafana Metrics Drilldown core app", @@ -1143,6 +1144,7 @@ "name": "exploreMetricsUseExternalAppPlugin", "resourceVersion": "1744146547481", "creationTimestamp": "2025-04-03T15:18:37Z", + "deletionTimestamp": "2025-04-11T01:41:57Z", "annotations": { "grafana.app/updatedTimestamp": "2025-04-08 21:09:07.481769 +0000 UTC" } diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index fbb99ef08bd..73b14acec52 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -294,6 +294,7 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-k8s-app": {SectionID: navtree.NavIDInfrastructure, SortWeight: 1, Text: "Kubernetes"}, "grafana-dbo11y-app": {SectionID: navtree.NavIDInfrastructure, SortWeight: 2, Text: "Databases"}, "grafana-app-observability-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightApplication, Text: "Application", Icon: "graph-bar"}, + "grafana-metricsdrilldown-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 1, Text: "Metrics"}, "grafana-lokiexplore-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 2, Text: "Logs"}, "grafana-exploretraces-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 3, Text: "Traces"}, "grafana-pyroscope-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 4, Text: "Profiles"}, @@ -317,10 +318,6 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-csp-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightCloudServiceProviders, Icon: "cloud"}, } - if s.features.IsEnabledGlobally(featuremgmt.FlagExploreMetricsUseExternalAppPlugin) { - s.navigationAppConfig["grafana-metricsdrilldown-app"] = NavigationAppConfig{SectionID: navtree.NavIDDrilldown, SortWeight: 1, Text: "Metrics"} - } - if s.features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisor) { s.navigationAppConfig["grafana-advisor-app"] = NavigationAppConfig{ SectionID: navtree.NavIDCfg, diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index fca5c63b215..6a8c4b0e1b7 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -139,7 +139,6 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere } if hasAccess(ac.EvalPermission(ac.ActionDatasourcesExplore)) { - drilldownChildNavLinks := s.buildDrilldownNavLinks(c) treeRoot.AddSection(&navtree.NavLink{ Text: "Drilldown", Id: navtree.NavIDDrilldown, @@ -148,7 +147,6 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere IsNew: true, SortWeight: navtree.WeightDrilldown, Url: s.cfg.AppSubURL + "/drilldown", - Children: drilldownChildNavLinks, }) } @@ -572,17 +570,3 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n } return nil } - -func (s *ServiceImpl) buildDrilldownNavLinks(c *contextmodel.ReqContext) []*navtree.NavLink { - drilldownChildNavs := []*navtree.NavLink{} - if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagExploreMetrics) && !s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagExploreMetricsUseExternalAppPlugin) { - drilldownChildNavs = append(drilldownChildNavs, &navtree.NavLink{ - Text: "Metrics", - SubTitle: "Queryless exploration of your metrics", - Id: "explore/metrics", - Url: s.cfg.AppSubURL + "/explore/metrics", - Icon: "code-branch", - }) - } - return drilldownChildNavs -} diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index 212a5bfc181..80423e93631 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -29,9 +29,10 @@ func extractPluginSettings(sections []*ini.Section) PluginSettings { var ( defaultPreinstallPlugins = map[string]InstallPlugin{ // Default preinstalled plugins - "grafana-lokiexplore-app": {"grafana-lokiexplore-app", "", ""}, - "grafana-pyroscope-app": {"grafana-pyroscope-app", "", ""}, - "grafana-exploretraces-app": {"grafana-exploretraces-app", "", ""}, + "grafana-lokiexplore-app": {"grafana-lokiexplore-app", "", ""}, + "grafana-pyroscope-app": {"grafana-pyroscope-app", "", ""}, + "grafana-exploretraces-app": {"grafana-exploretraces-app", "", ""}, + "grafana-metricsdrilldown-app": {"grafana-metricsdrilldown-app", "", ""}, } ) @@ -59,9 +60,6 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { if cfg.IsFeatureToggleEnabled("grafanaAdvisor") { // Use literal string to avoid circular dependency preinstallPlugins["grafana-advisor-app"] = InstallPlugin{"grafana-advisor-app", "", ""} } - if cfg.IsFeatureToggleEnabled("exploreMetricsUseExternalAppPlugin") { // Use literal string to avoid circular dependency - preinstallPlugins["grafana-metricsdrilldown-app"] = InstallPlugin{"grafana-metricsdrilldown-app", "", ""} - } // Add the plugins defined in the configuration for _, plugin := range rawInstallPlugins { parts := strings.Split(plugin, "@") diff --git a/public/app/features/trails/ActionTabs/RelatedMetricsScene.tsx b/public/app/features/trails/ActionTabs/RelatedMetricsScene.tsx deleted file mode 100644 index 384f5ad7528..00000000000 --- a/public/app/features/trails/ActionTabs/RelatedMetricsScene.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MetricSelectScene } from '../MetricSelect/MetricSelectScene'; - -export function buildRelatedMetricsScene() { - return new MetricSelectScene({}); -} diff --git a/public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx b/public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx deleted file mode 100644 index 091159b1b35..00000000000 --- a/public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { DataFrame } from '@grafana/data'; -import { - SceneObjectState, - SceneObjectBase, - SceneComponentProps, - sceneGraph, - AdHocFiltersVariable, -} from '@grafana/scenes'; -import { Button } from '@grafana/ui'; -import { Trans } from 'app/core/internationalization'; - -import { reportExploreMetrics } from '../interactions'; -import { VAR_OTEL_AND_METRIC_FILTERS, VAR_OTEL_GROUP_LEFT, VAR_OTEL_RESOURCES } from '../shared'; -import { getTrailFor } from '../utils'; - -export interface AddToFiltersGraphActionState extends SceneObjectState { - frame: DataFrame; -} - -export class AddToFiltersGraphAction extends SceneObjectBase { - public onClick = () => { - const variable = sceneGraph.lookupVariable('filters', this); - if (!(variable instanceof AdHocFiltersVariable)) { - return; - } - - const labels = this.state.frame.fields[1]?.labels ?? {}; - if (Object.keys(labels).length !== 1) { - return; - } - - const labelName = Object.keys(labels)[0]; - reportExploreMetrics('label_filter_changed', { label: labelName, action: 'added', cause: 'breakdown' }); - const trail = getTrailFor(this); - const resourceAttributes = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail); - const allAttributes = resourceAttributes?.getValue(); - const filter = { - key: labelName, - operator: '=', - value: labels[labelName], - }; - // add to either label filters or otel resource filters - if ( - allAttributes && - typeof allAttributes === 'string' && - // if the label chosen is a resource attribute, add it to the otel resource variable - allAttributes?.split(',').includes(labelName) - ) { - // This is different than the first non-promoted labels on data trail. In data trail we look at all labels - // for all metrics. In breakdown, we look at one metric. - // - // The metric may not have the label promoted so we have to compare not the non-promoted - // label collection we use in the parent datatrail, but instead have to look at `VAR_OTEL_GROUP_LEFT` - // which are a collection of labels from `target_info` that have not been promoted to the metric. - // - // These metric-specific non-promoted labels are retrieved in the function `getFilteredResourceAttributes`. - // These attributes on the metric that has been selected. - trail.setState({ addingLabelFromBreakdown: true }); - // add to OTel resource var filters - const otelResourcesVar = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, trail); - const otelAndMetricsResourcesVar = sceneGraph.lookupVariable(VAR_OTEL_AND_METRIC_FILTERS, trail); - if ( - !( - otelResourcesVar instanceof AdHocFiltersVariable && otelAndMetricsResourcesVar instanceof AdHocFiltersVariable - ) - ) { - return; - } - - otelResourcesVar.setState({ filters: [...otelResourcesVar.state.filters, filter] }); - otelAndMetricsResourcesVar.setState({ filters: [...otelAndMetricsResourcesVar.state.filters, filter] }); - trail.setState({ addingLabelFromBreakdown: false }); - } else { - // add to regular var filters - trail.addFilterWithoutReportingInteraction(filter); - } - }; - - public static Component = ({ model }: SceneComponentProps) => { - const state = model.useState(); - const labels = state.frame.fields[1]?.labels || {}; - - const canAddToFilters = Object.keys(labels).length !== 0; - - if (!canAddToFilters) { - return null; - } - - return ( - - ); - }; -} diff --git a/public/app/features/trails/Breakdown/BreakdownSearchScene.tsx b/public/app/features/trails/Breakdown/BreakdownSearchScene.tsx deleted file mode 100644 index f64aa66e458..00000000000 --- a/public/app/features/trails/Breakdown/BreakdownSearchScene.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { ChangeEvent } from 'react'; - -import { BusEventBase } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { t } from 'app/core/internationalization'; - -import { ByFrameRepeater } from './ByFrameRepeater'; -import { LabelBreakdownScene } from './LabelBreakdownScene'; -import { SearchInput } from './SearchInput'; - -export class BreakdownSearchReset extends BusEventBase { - public static type = 'breakdown-search-reset'; -} - -export interface BreakdownSearchSceneState extends SceneObjectState { - filter?: string; -} - -const recentFilters: Record = {}; - -export class BreakdownSearchScene extends SceneObjectBase { - private cacheKey: string; - - constructor(cacheKey: string) { - super({ - filter: recentFilters[cacheKey] ?? '', - }); - this.cacheKey = cacheKey; - } - - public static Component = ({ model }: SceneComponentProps) => { - const { filter } = model.useState(); - return ( - - ); - }; - - public onValueFilterChange = (event: ChangeEvent) => { - this.setState({ filter: event.target.value }); - this.filterValues(event.target.value); - }; - - public clearValueFilter = () => { - this.setState({ filter: '' }); - this.filterValues(''); - }; - - public reset = () => { - this.setState({ filter: '' }); - recentFilters[this.cacheKey] = ''; - }; - - private filterValues(filter: string) { - if (this.parent instanceof LabelBreakdownScene) { - recentFilters[this.cacheKey] = filter; - const body = this.parent.state.body; - body?.forEachChild((child) => { - if (child instanceof ByFrameRepeater && child.state.body.isActive) { - child.filterByString(filter); - } - }); - } - } -} diff --git a/public/app/features/trails/Breakdown/ByFrameRepeater.tsx b/public/app/features/trails/Breakdown/ByFrameRepeater.tsx deleted file mode 100644 index 6e0fd133760..00000000000 --- a/public/app/features/trails/Breakdown/ByFrameRepeater.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { css } from '@emotion/css'; - -import { DataFrame, LoadingState, PanelData } from '@grafana/data'; -import { - SceneByFrameRepeater, - SceneComponentProps, - SceneDataNode, - SceneFlexItem, - SceneFlexLayout, - sceneGraph, - SceneLayout, - SceneObjectBase, - SceneObjectState, - SceneReactObject, -} from '@grafana/scenes'; -import { Alert, Button } from '@grafana/ui'; -import { Trans } from 'app/core/internationalization'; - -import { getLabelValueFromDataFrame } from '../services/levels'; -import { fuzzySearch } from '../services/search'; -import { sortSeries } from '../services/sorting'; - -import { BreakdownSearchReset } from './BreakdownSearchScene'; -import { findSceneObjectsByType } from './utils'; - -interface ByFrameRepeaterState extends SceneObjectState { - body: SceneLayout; - - getLayoutChild(data: PanelData, frame: DataFrame, frameIndex: number): SceneFlexItem; -} - -type FrameFilterCallback = (frame: DataFrame) => boolean; -type FrameIterateCallback = (frames: DataFrame[], seriesIndex: number) => void; - -export class ByFrameRepeater extends SceneObjectBase { - private unfilteredChildren: SceneFlexItem[] = []; - private sortBy: string; - private sortedSeries: DataFrame[] = []; - private getFilter: () => string; - - public constructor({ - sortBy, - getFilter, - ...state - }: ByFrameRepeaterState & { sortBy: string; getFilter: () => string }) { - super(state); - - this.sortBy = sortBy; - this.getFilter = getFilter; - - this.addActivationHandler(() => { - const data = sceneGraph.getData(this); - - this._subs.add( - data.subscribeToState((newState, oldState) => { - if (newState.data === undefined) { - return; - } - - const newData = newState.data; - - if (newState.data?.state !== oldState.data?.state) { - findSceneObjectsByType(this, SceneDataNode).forEach((dataNode) => { - dataNode.setState({ data: { ...dataNode.state.data, state: newData.state } }); - }); - } - if (newData.state === LoadingState.Done) { - this.performRepeat(newData); - } - }) - ); - - if (data.state.data) { - this.performRepeat(data.state.data); - } - }); - } - - public sort = (sortBy: string) => { - const data = sceneGraph.getData(this); - this.sortBy = sortBy; - if (data.state.data) { - this.performRepeat(data.state.data); - } - }; - - private performRepeat(data: PanelData) { - const newChildren: SceneFlexItem[] = []; - const sortedSeries = sortSeries(data.series, this.sortBy); - - for (let seriesIndex = 0; seriesIndex < sortedSeries.length; seriesIndex++) { - const layoutChild = this.state.getLayoutChild(data, sortedSeries[seriesIndex], seriesIndex); - newChildren.push(layoutChild); - } - - this.sortedSeries = sortedSeries; - this.unfilteredChildren = newChildren; - - if (this.getFilter()) { - this.state.body.setState({ children: [] }); - this.filterByString(this.getFilter()); - } else { - this.state.body.setState({ children: newChildren }); - } - } - - filterByString = (filter: string) => { - let haystack: string[] = []; - - this.iterateFrames((frames, seriesIndex) => { - const labelValue = getLabelValue(frames[seriesIndex]); - haystack.push(labelValue); - }); - fuzzySearch(haystack, filter, (data) => { - if (data && data[0]) { - // We got search results - this.filterFrames((frame: DataFrame) => { - const label = getLabelValue(frame); - return data[0].includes(label); - }); - } else { - // reset search - this.filterFrames(() => true); - } - }); - }; - - public iterateFrames = (callback: FrameIterateCallback) => { - const data = sceneGraph.getData(this).state.data; - if (!data) { - return; - } - for (let seriesIndex = 0; seriesIndex < this.sortedSeries.length; seriesIndex++) { - callback(this.sortedSeries, seriesIndex); - } - }; - - public filterFrames = (filterFn: FrameFilterCallback) => { - const newChildren: SceneFlexItem[] = []; - this.iterateFrames((frames, seriesIndex) => { - if (filterFn(frames[seriesIndex])) { - newChildren.push(this.unfilteredChildren[seriesIndex]); - } - }); - - if (newChildren.length === 0) { - this.state.body.setState({ children: [buildNoResultsScene(this.getFilter(), this.clearFilter)] }); - } else { - this.state.body.setState({ children: newChildren }); - } - }; - - public clearFilter = () => { - this.publishEvent(new BreakdownSearchReset(), true); - }; - - public static Component = ({ model }: SceneComponentProps) => { - const { body } = model.useState(); - return ; - }; -} - -function buildNoResultsScene(filter: string, clearFilter: () => void) { - return new SceneFlexLayout({ - direction: 'row', - children: [ - new SceneFlexItem({ - body: new SceneReactObject({ - reactNode: ( -
- - - No values found matching; {{ filter }} - - - -
- ), - }), - }), - ], - }); -} - -const styles = { - alertContainer: css({ - flexGrow: 1, - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - }), - noResultsAlert: css({ - minWidth: '30vw', - flexGrow: 0, - }), - clearButton: css({ - marginLeft: '1.5rem', - }), -}; - -function getLabelValue(frame: DataFrame) { - return getLabelValueFromDataFrame(frame) ?? 'No labels'; -} diff --git a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx deleted file mode 100644 index 18154e396b5..00000000000 --- a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx +++ /dev/null @@ -1,692 +0,0 @@ -import init from '@bsull/augurs/outlier'; -import { css } from '@emotion/css'; -import { isNumber, max, min, throttle } from 'lodash'; -import { useEffect, useState } from 'react'; - -import { DataFrame, FieldType, GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data'; -import { isValidLegacyName, utf8Support } from '@grafana/prometheus'; -import { config } from '@grafana/runtime'; -import { - ConstantVariable, - PanelBuilders, - QueryVariable, - SceneComponentProps, - SceneCSSGridItem, - SceneCSSGridLayout, - SceneDataNode, - SceneFlexItem, - SceneFlexItemLike, - SceneFlexLayout, - sceneGraph, - SceneObject, - SceneObjectBase, - SceneObjectState, - SceneQueryRunner, - SceneReactObject, - VariableDependencyConfig, - VizPanel, -} from '@grafana/scenes'; -import { DataQuery, SortOrder, TooltipDisplayMode } from '@grafana/schema'; -import { Alert, Button, Field, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; -import { Trans, t } from 'app/core/internationalization'; - -import { BreakdownLabelSelector } from '../BreakdownLabelSelector'; -import { DataTrail } from '../DataTrail'; -import { PanelMenu } from '../Menu/PanelMenu'; -import { MetricScene } from '../MetricScene'; -import { StatusWrapper } from '../StatusWrapper'; -import { getAutoQueriesForMetric } from '../autoQuery/getAutoQueriesForMetric'; -import { AutoQueryDef } from '../autoQuery/types'; -import { reportExploreMetrics } from '../interactions'; -import { updateOtelJoinWithGroupLeft } from '../otel/util'; -import { getSortByPreference } from '../services/store'; -import { ALL_VARIABLE_VALUE } from '../services/variables'; -import { - MDP_METRIC_PREVIEW, - RefreshMetricsEvent, - trailDS, - VAR_FILTERS, - VAR_GROUP_BY, - VAR_GROUP_BY_EXP, - VAR_MISSING_OTEL_TARGETS, - VAR_OTEL_GROUP_LEFT, -} from '../shared'; -import { getColorByIndex, getTrailFor } from '../utils'; - -import { AddToFiltersGraphAction } from './AddToFiltersGraphAction'; -import { BreakdownSearchReset, BreakdownSearchScene } from './BreakdownSearchScene'; -import { ByFrameRepeater } from './ByFrameRepeater'; -import { LayoutSwitcher } from './LayoutSwitcher'; -import { SortByScene, SortCriteriaChanged } from './SortByScene'; -import { BreakdownLayoutChangeCallback, BreakdownLayoutType } from './types'; -import { getLabelOptions } from './utils'; -import { BreakdownAxisChangeEvent, yAxisSyncBehavior } from './yAxisSyncBehavior'; - -const MAX_PANELS_IN_ALL_LABELS_BREAKDOWN = 60; - -export interface LabelBreakdownSceneState extends SceneObjectState { - body?: LayoutSwitcher; - search: BreakdownSearchScene; - sortBy: SortByScene; - labels: Array>; - value?: string; - loading?: boolean; - error?: string; - blockingMessage?: string; -} - -export class LabelBreakdownScene extends SceneObjectBase { - protected _variableDependency = new VariableDependencyConfig(this, { - variableNames: [VAR_FILTERS], - onReferencedVariableValueChanged: this.onReferencedVariableValueChanged.bind(this), - }); - - constructor(state: Partial) { - super({ - ...state, - labels: state.labels ?? [], - sortBy: new SortByScene({ target: 'labels' }), - search: new BreakdownSearchScene('labels'), - }); - - this.addActivationHandler(this._onActivate.bind(this)); - } - - private _query?: AutoQueryDef; - - private _onActivate() { - // eslint-disable-next-line no-console - init().then(() => console.debug('Grafana ML initialized')); - - const variable = this.getVariable(); - - if (config.featureToggles.enableScopesInMetricsExplore) { - this._subs.add( - this.subscribeToEvent(RefreshMetricsEvent, () => { - this.updateBody(this.getVariable()); - }) - ); - } - - variable.subscribeToState((newState, oldState) => { - if ( - newState.options !== oldState.options || - newState.value !== oldState.value || - newState.loading !== oldState.loading - ) { - this.updateBody(variable); - } - }); - - this._subs.add( - this.subscribeToEvent(BreakdownSearchReset, () => { - this.state.search.clearValueFilter(); - }) - ); - this._subs.add(this.subscribeToEvent(SortCriteriaChanged, this.handleSortByChange)); - - const metricScene = sceneGraph.getAncestor(this, MetricScene); - const metric = metricScene.state.metric; - this._query = getAutoQueriesForMetric(metric).breakdown; - - // The following state changes (and conditions) will each result in a call to `clearBreakdownPanelAxisValues`. - // By clearing the axis, subsequent calls to `reportBreakdownPanelData` will adjust to an updated axis range. - // These state changes coincide with the panels having their data updated, making a call to `reportBreakdownPanelData`. - // If the axis was not cleared by `clearBreakdownPanelAxisValues` any calls to `reportBreakdownPanelData` which result - // in the same axis will result in no updates to the panels. - - const trail = getTrailFor(this); - trail.state.$timeRange?.subscribeToState(() => { - // The change in time range will cause a refresh of panel values. - this.clearBreakdownPanelAxisValues(); - }); - - // OTEL - this._subs.add( - trail.subscribeToState(({ useOtelExperience }, oldState) => { - // if otel changes - if (useOtelExperience !== oldState.useOtelExperience) { - this.updateBody(variable); - } - }) - ); - - // OTEL - const resourceAttributes = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail); - if (resourceAttributes instanceof ConstantVariable) { - resourceAttributes?.subscribeToState((newState, oldState) => { - // wait for the resource attributes to be loaded - if (newState.value !== oldState.value) { - this.updateBody(variable); - } - }); - } - - this.updateBody(variable); - } - - private breakdownPanelMaxValue: number | undefined; - private breakdownPanelMinValue: number | undefined; - - public reportBreakdownPanelData(data: PanelData | undefined) { - if (!data) { - return; - } - - let newMin = this.breakdownPanelMinValue; - let newMax = this.breakdownPanelMaxValue; - - data.series.forEach((dataFrame) => { - dataFrame.fields.forEach((breakdownData) => { - if (breakdownData.type !== FieldType.number) { - return; - } - const values = breakdownData.values.filter(isNumber); - - const maxValue = max(values); - const minValue = min(values); - - newMax = max([newMax, maxValue].filter(isNumber)); - newMin = min([newMin, minValue].filter(isNumber)); - }); - }); - - if (newMax === undefined || newMin === undefined || !Number.isFinite(newMax + newMin)) { - return; - } - - if (this.breakdownPanelMaxValue === newMax && this.breakdownPanelMinValue === newMin) { - return; - } - - this.breakdownPanelMaxValue = newMax; - this.breakdownPanelMinValue = newMin; - - this._triggerAxisChangedEvent(); - } - - private _triggerAxisChangedEvent = throttle(() => { - const { breakdownPanelMinValue, breakdownPanelMaxValue } = this; - if (breakdownPanelMinValue !== undefined && breakdownPanelMaxValue !== undefined) { - this.publishEvent(new BreakdownAxisChangeEvent({ min: breakdownPanelMinValue, max: breakdownPanelMaxValue })); - } - }, 1000); - - private clearBreakdownPanelAxisValues() { - this.breakdownPanelMaxValue = undefined; - this.breakdownPanelMinValue = undefined; - } - - private getVariable(): QueryVariable { - const variable = sceneGraph.lookupVariable(VAR_GROUP_BY, this)!; - if (!(variable instanceof QueryVariable)) { - throw new Error('Group by variable not found'); - } - - return variable; - } - - private handleSortByChange = (event: SortCriteriaChanged) => { - if (event.target !== 'labels') { - return; - } - if (this.state.body instanceof LayoutSwitcher) { - this.state.body.state.breakdownLayouts.forEach((layout) => { - if (layout instanceof ByFrameRepeater) { - layout.sort(event.sortBy); - } - }); - } - reportExploreMetrics('sorting_changed', { sortBy: event.sortBy }); - }; - - private onReferencedVariableValueChanged() { - const variable = this.getVariable(); - variable.changeValueTo(ALL_VARIABLE_VALUE); - this.updateBody(variable); - } - - private updateBody(variable: QueryVariable) { - const options = getLabelOptions(this, variable); - - const trail = getTrailFor(this); - - let allLabelOptions = options; - if (trail.state.useOtelExperience) { - allLabelOptions = this.updateLabelOptions(trail, allLabelOptions); - } - - const stateUpdate: Partial = { - loading: variable.state.loading, - value: String(variable.state.value), - labels: allLabelOptions, - error: variable.state.error, - blockingMessage: undefined, - }; - - if (!variable.state.loading && variable.state.options.length) { - stateUpdate.body = variable.hasAllValue() - ? buildAllLayout(allLabelOptions, this._query!, this.onBreakdownLayoutChange, trail.state.useOtelExperience) - : buildNormalLayout(this._query!, this.onBreakdownLayoutChange, this.state.search); - } else if (!variable.state.loading) { - stateUpdate.body = undefined; - stateUpdate.blockingMessage = 'Unable to retrieve label options for currently selected metric.'; - } - - this.clearBreakdownPanelAxisValues(); - // Setting the new panels will gradually end up calling reportBreakdownPanelData to update the new min & max - this.setState(stateUpdate); - } - - public onBreakdownLayoutChange = (_: BreakdownLayoutType) => { - this.clearBreakdownPanelAxisValues(); - }; - - public onChange = (value?: string) => { - if (!value) { - return; - } - - reportExploreMetrics('label_selected', { label: value, cause: 'selector' }); - const variable = this.getVariable(); - - variable.changeValueTo(value); - }; - - private async updateOtelGroupLeft() { - const trail = getTrailFor(this); - - if (trail.state.useOtelExperience) { - await updateOtelJoinWithGroupLeft(trail, trail.state.metric ?? ''); - } - } - - /** - * supplement normal label options with resource attributes - * @param trail - * @param allLabelOptions - * @returns - */ - private updateLabelOptions(trail: DataTrail, allLabelOptions: SelectableValue[]): Array> { - // when the group left variable is changed we should get all the resource attributes + labels - const resourceAttributes = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail)?.getValue(); - if (typeof resourceAttributes !== 'string') { - return []; - } - - const attributeArray: SelectableValue[] = resourceAttributes.split(',').map((el) => { - let label = el; - if (!isValidLegacyName(el)) { - // remove '' from label - label = el.slice(1, -1); - } - return { label, value: el }; - }); - // shift ALL value to the front - const all: SelectableValue = [{ label: 'All', value: ALL_VARIABLE_VALUE }]; - const firstGroup = all.concat(attributeArray); - - // remove duplicates of ALL option - allLabelOptions = allLabelOptions.filter((option) => option.value !== ALL_VARIABLE_VALUE); - allLabelOptions = firstGroup.concat(allLabelOptions); - - return allLabelOptions; - } - - public static Component = ({ model }: SceneComponentProps) => { - const { labels, body, search, sortBy, loading, value, blockingMessage } = model.useState(); - const styles = useStyles2(getStyles); - - const trail = getTrailFor(model); - const { useOtelExperience } = trail.useState(); - - let allLabelOptions = labels; - if (trail.state.useOtelExperience) { - // All value moves to the middle because it is part of the label options variable - const all: SelectableValue = [{ label: 'All', value: ALL_VARIABLE_VALUE }]; - allLabelOptions.filter((option) => option.value !== ALL_VARIABLE_VALUE).unshift(all); - } - - const [dismissOtelWarning, updateDismissOtelWarning] = useState(false); - const missingOtelTargets = sceneGraph.lookupVariable(VAR_MISSING_OTEL_TARGETS, trail)?.getValue(); - if (missingOtelTargets && !dismissOtelWarning) { - reportExploreMetrics('missing_otel_labels_by_truncating_job_and_instance', { - metric: trail.state.metric, - }); - } - - useEffect(() => { - if (useOtelExperience) { - // this will update the group left variable - model.updateOtelGroupLeft(); - } - }, [model, useOtelExperience]); - - return ( -
- -
- {!loading && labels.length && ( - - - - )} - - {value !== ALL_VARIABLE_VALUE && ( - <> - - - - - - )} - {body instanceof LayoutSwitcher && ( - - - - )} -
- {missingOtelTargets && !dismissOtelWarning && ( - updateDismissOtelWarning(true)} - className={styles.truncatedOTelResources} - > - - This metric has too many job and instance label values to call the Prometheus label_values endpoint with - the match[] parameter. These label values are used to join the metric with target_info, which contains - the resource attributes. Please include more resource attributes filters. - - - )} - -
{body && }
-
-
- ); - }; -} - -function getStyles(theme: GrafanaTheme2) { - return { - container: css({ - flexGrow: 1, - display: 'flex', - minHeight: '100%', - flexDirection: 'column', - paddingTop: theme.spacing(1), - }), - content: css({ - flexGrow: 1, - display: 'flex', - paddingTop: theme.spacing(0), - }), - searchField: css({ - flexGrow: 1, - }), - controls: css({ - flexGrow: 0, - display: 'flex', - alignItems: 'flex-end', - gap: theme.spacing(2), - justifyContent: 'space-between', - }), - truncatedOTelResources: css({ - minWidth: '30vw', - flexGrow: 0, - }), - }; -} - -export function buildAllLayout( - options: Array>, - queryDef: AutoQueryDef, - onBreakdownLayoutChange: BreakdownLayoutChangeCallback, - useOtelExperience?: boolean -) { - const children: SceneFlexItemLike[] = []; - - for (const option of options) { - if (option.value === ALL_VARIABLE_VALUE) { - continue; - } - - if (children.length === MAX_PANELS_IN_ALL_LABELS_BREAKDOWN) { - break; - } - - const expr = queryDef.queries[0].expr.replaceAll(VAR_GROUP_BY_EXP, utf8Support(String(option.value))); - const unit = queryDef.unit; - - const vizPanel = PanelBuilders.timeseries() - .setOption('tooltip', { mode: TooltipDisplayMode.Multi, sort: SortOrder.Descending }) - .setOption('legend', { showLegend: false }) - .setTitle(option.label!) - .setData( - new SceneQueryRunner({ - maxDataPoints: MDP_METRIC_PREVIEW, - datasource: trailDS, - queries: [ - { - refId: `A-${option.label}`, - expr, - legendFormat: `{{${option.label}}}`, - fromExploreMetrics: true, - }, - ], - }) - ) - .setHeaderActions([new SelectLabelAction({ labelName: String(option.value) })]) - .setShowMenuAlways(true) - .setMenu(new PanelMenu({ labelName: String(option.value) })) - .setUnit(unit) - .setBehaviors([fixLegendForUnspecifiedLabelValueBehavior]) - .build(); - - children.push( - new SceneCSSGridItem({ - $behaviors: [yAxisSyncBehavior], - body: vizPanel, - }) - ); - } - return new LayoutSwitcher({ - breakdownLayoutOptions: [ - { value: 'grid', label: 'Grid' }, - { value: 'rows', label: 'Rows' }, - ], - onBreakdownLayoutChange, - breakdownLayouts: [ - new SceneCSSGridLayout({ - templateColumns: GRID_TEMPLATE_COLUMNS, - autoRows: '200px', - children: children, - isLazy: true, - }), - new SceneCSSGridLayout({ - templateColumns: '1fr', - autoRows: '200px', - // Clone children since a scene object can only have one parent at a time - children: children.map((c) => c.clone()), - isLazy: true, - }), - ], - }); -} - -const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))'; - -function buildNormalLayout( - queryDef: AutoQueryDef, - onBreakdownLayoutChange: BreakdownLayoutChangeCallback, - searchScene: BreakdownSearchScene -) { - const unit = queryDef.unit; - - function getLayoutChild(data: PanelData, frame: DataFrame, frameIndex: number): SceneFlexItem { - const vizPanel: VizPanel = queryDef - .vizBuilder() - .setTitle(getLabelValue(frame)) - .setData(new SceneDataNode({ data: { ...data, series: [frame] } })) - .setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) - .setHeaderActions([new AddToFiltersGraphAction({ frame })]) - .setShowMenuAlways(true) - .setMenu(new PanelMenu({ labelName: getLabelValue(frame) })) - .setUnit(unit) - .build(); - - // Find a frame that has at more than one point. - const isHidden = frame.length <= 1; - - const item: SceneCSSGridItem = new SceneCSSGridItem({ - $behaviors: [yAxisSyncBehavior], - body: vizPanel, - isHidden, - }); - - return item; - } - - const { sortBy } = getSortByPreference('labels', 'outliers'); - const getFilter = () => searchScene.state.filter ?? ''; - - return new LayoutSwitcher({ - $data: new SceneQueryRunner({ - datasource: trailDS, - maxDataPoints: MDP_METRIC_PREVIEW, - queries: queryDef.queries, - }), - breakdownLayoutOptions: [ - { value: 'single', label: 'Single' }, - { value: 'grid', label: 'Grid' }, - { value: 'rows', label: 'Rows' }, - ], - onBreakdownLayoutChange, - breakdownLayouts: [ - new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - minHeight: 300, - body: PanelBuilders.timeseries() - .setOption('tooltip', { mode: TooltipDisplayMode.Multi, sort: SortOrder.Descending }) - .setOption('legend', { showLegend: false }) - .setTitle('$metric') - .build(), - }), - ], - }), - new ByFrameRepeater({ - body: new SceneCSSGridLayout({ - templateColumns: GRID_TEMPLATE_COLUMNS, - autoRows: '200px', - children: [ - new SceneFlexItem({ - body: new SceneReactObject({ - reactNode: , - }), - }), - ], - }), - getLayoutChild, - sortBy, - getFilter, - }), - new ByFrameRepeater({ - body: new SceneCSSGridLayout({ - templateColumns: '1fr', - autoRows: '200px', - children: [], - }), - getLayoutChild, - sortBy, - getFilter, - }), - ], - }); -} - -function getLabelValue(frame: DataFrame) { - const labels = frame.fields[1]?.labels || {}; - - const keys = Object.keys(labels); - if (keys.length === 0) { - return ''; - } - - return labels[keys[0]]; -} - -export function buildLabelBreakdownActionScene() { - return new LabelBreakdownScene({}); -} - -interface SelectLabelActionState extends SceneObjectState { - labelName: string; -} - -export class SelectLabelAction extends SceneObjectBase { - public onClick = () => { - const label = this.state.labelName; - - // check that it is resource or label and update the rudderstack event - const trail = getTrailFor(this); - const resourceAttributes = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail)?.getValue(); - let otel_resource_attribute = false; - if (typeof resourceAttributes === 'string') { - otel_resource_attribute = resourceAttributes?.split(',').includes(label); - } - - reportExploreMetrics('label_selected', { label, cause: 'breakdown_panel', otel_resource_attribute }); - getBreakdownSceneFor(this).onChange(label); - }; - - public static Component = ({ model }: SceneComponentProps) => { - return ( - - ); - }; -} - -function getBreakdownSceneFor(model: SceneObject): LabelBreakdownScene { - if (model instanceof LabelBreakdownScene) { - return model; - } - - if (model.parent) { - return getBreakdownSceneFor(model.parent); - } - - throw new Error('Unable to find breakdown scene'); -} - -function fixLegendForUnspecifiedLabelValueBehavior(vizPanel: VizPanel) { - vizPanel.state.$data?.subscribeToState((newState, prevState) => { - const target = newState.data?.request?.targets[0]; - if (hasLegendFormat(target)) { - const { legendFormat } = target; - // Assume {{label}} - const label = legendFormat.slice(2, -2); - - newState.data?.series.forEach((series) => { - if (!series.fields[1].labels?.[label]) { - const labels = series.fields[1].labels; - if (labels) { - labels[label] = ``; - } - } - }); - } - }); -} - -function hasLegendFormat(target: DataQuery | undefined): target is DataQuery & { legendFormat: string } { - return target !== undefined && 'legendFormat' in target && typeof target.legendFormat === 'string'; -} diff --git a/public/app/features/trails/Breakdown/LayoutSwitcher.tsx b/public/app/features/trails/Breakdown/LayoutSwitcher.tsx deleted file mode 100644 index 8d573c1c11f..00000000000 --- a/public/app/features/trails/Breakdown/LayoutSwitcher.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { SelectableValue } from '@grafana/data'; -import { - SceneComponentProps, - SceneObject, - SceneObjectBase, - SceneObjectState, - SceneObjectUrlSyncConfig, - SceneObjectUrlValues, - SceneObjectWithUrlSync, -} from '@grafana/scenes'; -import { RadioButtonGroup } from '@grafana/ui'; - -import { reportExploreMetrics } from '../interactions'; -import { getVewByPreference, setVewByPreference } from '../services/store'; -import { MakeOptional } from '../shared'; - -import { BreakdownLayoutChangeCallback, BreakdownLayoutType, isBreakdownLayoutType } from './types'; - -export interface LayoutSwitcherState extends SceneObjectState { - activeBreakdownLayout: BreakdownLayoutType; - breakdownLayouts: SceneObject[]; - breakdownLayoutOptions: Array>; - onBreakdownLayoutChange: BreakdownLayoutChangeCallback; -} - -export class LayoutSwitcher extends SceneObjectBase implements SceneObjectWithUrlSync { - protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['breakdownLayout'] }); - - public constructor(state: MakeOptional) { - const storedBreakdownLayout = getVewByPreference(); - super({ - activeBreakdownLayout: isBreakdownLayoutType(storedBreakdownLayout) ? storedBreakdownLayout : 'grid', - ...state, - }); - } - - getUrlState() { - return { breakdownLayout: this.state.activeBreakdownLayout }; - } - - updateFromUrl(values: SceneObjectUrlValues) { - const newBreakdownLayout = values.breakdownLayout; - if (typeof newBreakdownLayout === 'string' && isBreakdownLayoutType(newBreakdownLayout)) { - if (this.state.activeBreakdownLayout !== newBreakdownLayout) { - this.setState({ activeBreakdownLayout: newBreakdownLayout }); - } - } - } - - public Selector({ model }: { model: LayoutSwitcher }) { - const { activeBreakdownLayout, breakdownLayoutOptions } = model.useState(); - - return ( - - ); - } - - public onLayoutChange = (active: BreakdownLayoutType) => { - if (this.state.activeBreakdownLayout === active) { - return; - } - - reportExploreMetrics('breakdown_layout_changed', { layout: active }); - setVewByPreference(active); - this.setState({ activeBreakdownLayout: active }); - this.state.onBreakdownLayoutChange(active); - }; - - public static Component = ({ model }: SceneComponentProps) => { - const { breakdownLayouts, breakdownLayoutOptions, activeBreakdownLayout } = model.useState(); - - const index = breakdownLayoutOptions.findIndex((o) => o.value === activeBreakdownLayout); - if (index === -1) { - return null; - } - - const layout = breakdownLayouts[index]; - - return ; - }; -} diff --git a/public/app/features/trails/Breakdown/SearchInput.tsx b/public/app/features/trails/Breakdown/SearchInput.tsx deleted file mode 100644 index c76bc3e58d0..00000000000 --- a/public/app/features/trails/Breakdown/SearchInput.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { css } from '@emotion/css'; -import { HTMLProps } from 'react'; - -import { Icon, Input } from '@grafana/ui'; -import { t } from 'app/core/internationalization'; - -interface Props extends Omit, 'width'> { - onClear(): void; -} - -export const SearchInput = ({ value, onChange, placeholder, onClear, ...rest }: Props) => { - return ( - - ) : undefined - } - prefix={} - placeholder={placeholder} - {...rest} - /> - ); -}; - -const styles = { - clearIcon: css({ - cursor: 'pointer', - }), -}; diff --git a/public/app/features/trails/Breakdown/SortByScene.tsx b/public/app/features/trails/Breakdown/SortByScene.tsx deleted file mode 100644 index 617dd0f1aa9..00000000000 --- a/public/app/features/trails/Breakdown/SortByScene.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { css } from '@emotion/css'; - -import { BusEventBase, GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { IconButton, Select, Field, useStyles2 } from '@grafana/ui'; -import { t } from 'app/core/internationalization'; - -import { Trans } from '../../../core/internationalization'; -import { getSortByPreference, setSortByPreference } from '../services/store'; - -export interface SortBySceneState extends SceneObjectState { - target: 'fields' | 'labels'; - sortBy: string; -} - -export class SortCriteriaChanged extends BusEventBase { - constructor( - public target: 'fields' | 'labels', - public sortBy: string - ) { - super(); - } - - public static type = 'sort-criteria-changed'; -} - -export class SortByScene extends SceneObjectBase { - public sortingOptions = [ - { - label: '', - options: [ - { - value: 'outliers', - label: 'Outlying series', - description: 'Prioritizes values that show distinct behavior from others within the same label', - }, - { - value: 'alphabetical', - label: 'Name [A-Z]', - description: 'Alphabetical order', - }, - { - value: 'alphabetical-reversed', - label: 'Name [Z-A]', - description: 'Reversed alphabetical order', - }, - ], - }, - ]; - - constructor(state: Pick) { - const { sortBy } = getSortByPreference(state.target, 'outliers'); - super({ - target: state.target, - sortBy, - }); - } - - public onCriteriaChange = (criteria: SelectableValue) => { - if (!criteria.value) { - return; - } - this.setState({ sortBy: criteria.value }); - setSortByPreference(this.state.target, criteria.value); - this.publishEvent(new SortCriteriaChanged(this.state.target, criteria.value), true); - }; - - public static Component = ({ model }: SceneComponentProps) => { - const styles = useStyles2(getStyles); - const { sortBy } = model.useState(); - const group = model.sortingOptions.find((group) => group.options.find((option) => option.value === sortBy)); - const value = group?.options.find((option) => option.value === sortBy); - return ( - - Sort by - - - } - > - onChange(selected.value)} className={styles.select} />; -} - -function getStyles(theme: GrafanaTheme2) { - return { - select: css({ - maxWidth: theme.spacing(16), - }), - }; -} diff --git a/public/app/features/trails/DataTrail.test.tsx b/public/app/features/trails/DataTrail.test.tsx deleted file mode 100644 index ec3ebdb88bc..00000000000 --- a/public/app/features/trails/DataTrail.test.tsx +++ /dev/null @@ -1,609 +0,0 @@ -import { VariableHide } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; -import { AdHocFiltersVariable, ConstantVariable, sceneGraph } from '@grafana/scenes'; -import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; -import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; - -import { mockDataSource } from '../alerting/unified/mocks'; -import { activateFullSceneTree } from '../dashboard-scene/utils/test-utils'; - -import { DataTrail } from './DataTrail'; -import { MetricScene } from './MetricScene'; -import { MetricSelectScene } from './MetricSelect/MetricSelectScene'; -import { - MetricSelectedEvent, - VAR_FILTERS, - VAR_OTEL_AND_METRIC_FILTERS, - VAR_OTEL_GROUP_LEFT, - VAR_OTEL_JOIN_QUERY, - VAR_OTEL_RESOURCES, -} from './shared'; - -jest.mock('./otel/api', () => ({ - totalOtelResources: jest.fn(() => ({ job: 'oteldemo', instance: 'instance' })), - isOtelStandardization: jest.fn(() => true), -})); - -describe('DataTrail', () => { - beforeAll(() => { - jest.spyOn(DataTrail.prototype, 'checkDataSourceForOTelResources').mockImplementation(() => Promise.resolve()); - - setupDataSources( - mockDataSource({ - name: 'Prometheus', - type: DataSourceType.Prometheus, - }) - ); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - describe('Given starting non-embedded trail with url sync and no url state', () => { - let trail: DataTrail; - const preTrailUrl = '/'; - - function getStepFilterVar(step: number) { - const variable = trail.state.history.state.steps[step].trailState.$variables?.getByName(VAR_FILTERS); - if (variable instanceof AdHocFiltersVariable) { - return variable; - } - throw new Error(`getStepFilterVar failed for step ${step}`); - } - - beforeEach(() => { - trail = new DataTrail({}); - locationService.push(preTrailUrl); - activateFullSceneTree(trail); - }); - - it('Should default to metric select scene', () => { - expect(trail.state.topScene).toBeInstanceOf(MetricSelectScene); - }); - - it('Should set history current step to 1', () => { - expect(trail.state.history.state.currentStep).toBe(1); - }); - - it('Should set history step 0 parentIndex to -1', () => { - expect(trail.state.history.state.steps[0].parentIndex).toBe(-1); - }); - - describe('And metric is selected', () => { - beforeEach(() => { - trail.publishEvent(new MetricSelectedEvent('metric_bucket')); - }); - - it('should switch scene to MetricScene', () => { - expect(trail.state.metric).toBe('metric_bucket'); - expect(trail.state.topScene).toBeInstanceOf(MetricScene); - }); - - it('should sync state with url', () => { - expect(trail.getUrlState().metric).toBe('metric_bucket'); - }); - - it('should add history step', () => { - expect(trail.state.history.state.steps[1].type).toBe('metric_page'); - }); - - it('Should set history currentStep to 2', () => { - expect(trail.state.history.state.currentStep).toBe(2); - }); - - it('Should set history step 1 parentIndex to 0', () => { - expect(trail.state.history.state.steps[1].parentIndex).toBe(0); - }); - - it('Should have time range `from` be default "now-6h"', () => { - expect(trail.state.$timeRange?.state.from).toBe('now-6h'); - }); - - describe('And browser back button is pressed', () => { - locationService.getHistory().goBack(); - - it('Should return to original URL', () => { - const { pathname } = locationService.getLocation(); - expect(pathname).toEqual(preTrailUrl); - }); - }); - - describe('And when changing the time range `from` to "now-1h"', () => { - beforeEach(() => { - trail.state.$timeRange?.setState({ from: 'now-1h' }); - }); - - it('should add history step', () => { - expect(trail.state.history.state.steps[3].type).toBe('time'); - }); - - it('Should set history currentStep to 3', () => { - expect(trail.state.history.state.currentStep).toBe(3); - }); - - it('Should set history step 2 parentIndex to 1', () => { - expect(trail.state.history.state.steps[2].parentIndex).toBe(1); - }); - - it('Should have time range `from` be updated "now-1h"', () => { - expect(trail.state.$timeRange?.state.from).toBe('now-1h'); - }); - - it('Previous history step should have previous default `from` of "now-6h"', () => { - expect(trail.state.history.state.steps[1].trailState.$timeRange?.state.from).toBe('now-6h'); - }); - - it('Current history step should have new `from` of "now-1h"', () => { - expect(trail.state.history.state.steps[3].trailState.$timeRange?.state.from).toBe('now-1h'); - }); - - describe('And when traversing back to step 1', () => { - beforeEach(() => { - trail.state.history.goBackToStep(1); - }); - - it('Should set history currentStep to 1', () => { - expect(trail.state.history.state.currentStep).toBe(1); - }); - - it('should sync state with url', () => { - expect(locationService.getSearchObject().from).toBe('now-6h'); - }); - - it('Should have time range `from` be set back to "now-6h"', () => { - expect(trail.state.$timeRange?.state.from).toBe('now-6h'); - }); - - describe('And then when changing the time range `from` to "now-15m"', () => { - beforeEach(() => { - trail.state.$timeRange?.setState({ from: 'now-15m' }); - }); - - it('should add history step', () => { - expect(trail.state.history.state.steps[3].type).toBe('time'); - }); - - it('Should set history currentStep to 4', () => { - expect(trail.state.history.state.currentStep).toBe(4); - }); - - it('Should set history step 4 parentIndex to 1', () => { - expect(trail.state.history.state.steps[4].parentIndex).toBe(1); - }); - - it('Should have time range `from` be updated "now-15m"', () => { - expect(trail.state.$timeRange?.state.from).toBe('now-15m'); - }); - - it('History step 1 (parent) should have previous default `from` of "now-6h"', () => { - expect(trail.state.history.state.steps[1].trailState.$timeRange?.state.from).toBe('now-6h'); - }); - - it('History step 2 should still have `from` of "now-1h"', () => { - expect(trail.state.history.state.steps[3].trailState.$timeRange?.state.from).toBe('now-1h'); - }); - - describe('And then when returning again to step 1', () => { - beforeEach(() => { - trail.state.history.goBackToStep(1); - }); - - it('Should set history currentStep to 1', () => { - expect(trail.state.history.state.currentStep).toBe(1); - }); - - it('should sync state with url', () => { - expect(locationService.getSearchObject().from).toBe('now-6h'); - }); - - it('History step 1 (parent) should have previous default `from` of "now-6h"', () => { - expect(trail.state.history.state.steps[1].trailState.$timeRange?.state.from).toBe('now-6h'); - }); - - it('History step 3 should still have `from` of "now-1h"', () => { - expect(trail.state.history.state.steps[3].trailState.$timeRange?.state.from).toBe('now-1h'); - }); - - it('History step 4 should still have `from` of "now-15m"', () => { - expect(trail.state.history.state.steps[4].trailState.$timeRange?.state.from).toBe('now-15m'); - }); - - it('Should have time range `from` be set back to "now-6h"', () => { - expect(trail.state.$timeRange?.state.from).toBe('now-6h'); - }); - }); - }); - }); - }); - - it('Should have default empty filter', () => { - expect(getFilterVar(trail).state.filters.length).toBe(0); - }); - - describe('And when changing the filter to zone=a', () => { - beforeEach(() => { - getFilterVar(trail).setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] }); - }); - - it('should add history step', () => { - expect(trail.state.history.state.steps[3].type).toBe('filters'); - }); - - it('Should set history currentStep to 3', () => { - expect(trail.state.history.state.currentStep).toBe(3); - }); - - it('Should set history step 2 parentIndex to 1', () => { - expect(trail.state.history.state.steps[2].parentIndex).toBe(1); - }); - - it('Should have filter be updated to "zone=a"', () => { - expect(getFilterVar(trail).state.filters[0].key).toBe('zone'); - expect(getFilterVar(trail).state.filters[0].value).toBe('a'); - }); - - it('Previous history step should have empty filter', () => { - expect(getStepFilterVar(1).state.filters.length).toBe(0); - }); - - it('Current history step should have new filter zone=a', () => { - expect(getStepFilterVar(3).state.filters[0].key).toBe('zone'); - expect(getStepFilterVar(3).state.filters[0].value).toBe('a'); - }); - - describe('And when traversing back to step 1', () => { - beforeEach(() => { - trail.state.history.goBackToStep(1); - }); - - it('Should set history currentStep to 1', () => { - expect(trail.state.history.state.currentStep).toBe(1); - }); - - it('should sync state with url', () => { - expect(locationService.getSearchObject()['var-filters']).toBe(''); - }); - - it('Should have filters set back to empty', () => { - expect(getFilterVar(trail).state.filters.length).toBe(0); - }); - - describe('And when changing the filter to zone=b', () => { - beforeEach(() => { - getFilterVar(trail).setState({ filters: [{ key: 'zone', operator: '=', value: 'b' }] }); - }); - - it('should add history step', () => { - expect(trail.state.history.state.steps[3].type).toBe('filters'); - }); - - it('Should set history currentStep to 4', () => { - expect(trail.state.history.state.currentStep).toBe(4); - }); - - it('Should set history step 4 parentIndex to 1', () => { - expect(trail.state.history.state.steps[4].parentIndex).toBe(1); - }); - - it('Should have filter be updated to "zone=b"', () => { - expect(getFilterVar(trail).state.filters[0].key).toBe('zone'); - expect(getFilterVar(trail).state.filters[0].value).toBe('b'); - }); - - it('Parent history step 1 should still have empty filter', () => { - expect(getStepFilterVar(1).state.filters.length).toBe(0); - }); - - it('History step 3 should still have old filter zone=a', () => { - expect(getStepFilterVar(3).state.filters[0].key).toBe('zone'); - expect(getStepFilterVar(3).state.filters[0].value).toBe('a'); - }); - - it('Current history step 4 should have new filter zone=b', () => { - expect(getStepFilterVar(4).state.filters[0].key).toBe('zone'); - expect(getStepFilterVar(4).state.filters[0].value).toBe('b'); - }); - - describe('And then when returning again to step 1', () => { - beforeEach(() => { - trail.state.history.goBackToStep(1); - }); - - it('Should set history currentStep to 1', () => { - expect(trail.state.history.state.currentStep).toBe(1); - }); - - it('should sync state with url', () => { - expect(locationService.getSearchObject()['var-filters']).toBe(''); - }); - - it('Should have filters set back to empty', () => { - expect(getFilterVar(trail).state.filters.length).toBe(0); - }); - - it('History step 1 should still have empty filter', () => { - expect(getStepFilterVar(1).state.filters.length).toBe(0); - }); - - it('History step 3 should still have old filter zone=a', () => { - expect(getStepFilterVar(3).state.filters[0].key).toBe('zone'); - expect(getStepFilterVar(3).state.filters[0].value).toBe('a'); - }); - - it('History step 4 should have new filter zone=b', () => { - expect(getStepFilterVar(4).state.filters[0].key).toBe('zone'); - expect(getStepFilterVar(4).state.filters[0].value).toBe('b'); - }); - }); - }); - }); - }); - }); - - describe('When going back to history step 2', () => { - beforeEach(() => { - trail.publishEvent(new MetricSelectedEvent('first_metric')); - trail.publishEvent(new MetricSelectedEvent('second_metric')); - trail.state.history.goBackToStep(2); - }); - - it('Should restore state and url', () => { - expect(trail.state.metric).toBe('first_metric'); - expect(locationService.getSearchObject().metric).toBe('first_metric'); - }); - - it('Should set history currentStep to 2', () => { - expect(trail.state.history.state.currentStep).toBe(2); - }); - - it('Should not create another history step', () => { - expect(trail.state.history.state.steps.length).toBe(4); - }); - - describe('But then selecting a new metric', () => { - beforeEach(() => { - trail.publishEvent(new MetricSelectedEvent('third_metric')); - }); - - it('Should create another history step', () => { - expect(trail.state.history.state.steps.length).toBe(5); - }); - - it('Should set history current step to 4', () => { - expect(trail.state.history.state.currentStep).toBe(4); - }); - - it('Should set history step 4 parent index to 2', () => { - expect(trail.state.history.state.steps[4].parentIndex).toBe(2); - }); - - describe('And browser back button is pressed', () => { - locationService.getHistory().goBack(); - - it('Should return to original URL', () => { - const { pathname } = locationService.getLocation(); - expect(pathname).toEqual(preTrailUrl); - }); - }); - }); - }); - describe('When going back to history step 0', () => { - beforeEach(() => { - trail.publishEvent(new MetricSelectedEvent('first_metric')); - trail.publishEvent(new MetricSelectedEvent('second_metric')); - trail.state.history.goBackToStep(0); - }); - - it('Should remove metric from state and url', () => { - expect(trail.state.metric).toBe(undefined); - - expect(locationService.getSearchObject().metric).toBe(undefined); - expect(locationService.getSearch().has('metric')).toBe(false); - }); - }); - - it('Filter should be empty', () => { - expect(getStepFilterVar(0).state.filters.length).toBe(0); - }); - - describe('And filter is added zone=a', () => { - beforeEach(() => { - getFilterVar(trail).setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] }); - }); - - it('Filter of trail should be zone=a', () => { - expect(getFilterVar(trail).state.filters[0].key).toBe('zone'); - expect(getFilterVar(trail).state.filters[0].value).toBe('a'); - }); - - it('Filter of step 2 should be zone=a', () => { - expect(getStepFilterVar(2).state.filters[0].key).toBe('zone'); - expect(getStepFilterVar(2).state.filters[0].value).toBe('a'); - }); - - it('Filter of step 0 should empty', () => { - expect(getStepFilterVar(0).state.filters.length).toBe(0); - }); - - describe('When returning to step 0', () => { - beforeEach(() => { - trail.state.history.goBackToStep(0); - }); - - it('Filter of trail should be empty', () => { - expect(getFilterVar(trail).state.filters.length).toBe(0); - }); - }); - }); - - it('Time range `from` should be now-6h', () => { - expect(trail.state.$timeRange?.state.from).toBe('now-6h'); - }); - - describe('And time range is changed to now-15m to now', () => { - beforeEach(() => { - trail.state.$timeRange?.setState({ from: 'now-15m' }); - }); - - it('Time range `from` should be now-15m', () => { - expect(trail.state.$timeRange?.state.from).toBe('now-15m'); - }); - - it('Time range `from` of step 2 should be now-15m', () => { - expect(trail.state.history.state.steps[2].trailState.$timeRange?.state.from).toBe('now-15m'); - }); - - it('Time range `from` of step 1 should be now-6h', () => { - expect(trail.state.history.state.steps[1].trailState.$timeRange?.state.from).toBe('now-6h'); - }); - - describe('When returning to step 0', () => { - beforeEach(() => { - trail.state.history.goBackToStep(0); - }); - - it('Time range `from` should be now-6h', () => { - expect(trail.state.$timeRange?.state.from).toBe('now-6h'); - }); - }); - }); - }); - - describe('OTel resources attributes', () => { - let trail: DataTrail; - - // selecting a non promoted resource from VAR_OTEL_AND_METRICS will automatically update the otel resources var - const nonPromotedOtelResources = ['deployment_environment']; - const preTrailUrl = - '/trail?from=now-1h&to=now&var-ds=edwxqcebl0cg0c&var-deployment_environment=oteldemo01&var-otel_resources=k8s_cluster_name%7C%3D%7Cappo11ydev01&var-filters=&refresh=&metricPrefix=all&metricSearch=http&actionView=breakdown&var-groupby=$__all&metric=http_client_duration_milliseconds_bucket'; - - function getOtelAndMetricsVar(trail: DataTrail) { - const variable = sceneGraph.lookupVariable(VAR_OTEL_AND_METRIC_FILTERS, trail); - if (variable instanceof AdHocFiltersVariable) { - return variable; - } - throw new Error('getOtelAndMetricsVar failed'); - } - - function getOtelJoinQueryVar(trail: DataTrail) { - const variable = sceneGraph.lookupVariable(VAR_OTEL_JOIN_QUERY, trail); - if (variable instanceof ConstantVariable) { - return variable; - } - throw new Error('getOtelJoinQueryVar failed'); - } - - function getOtelResourcesVar(trail: DataTrail) { - const variable = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, trail); - if (variable instanceof AdHocFiltersVariable) { - return variable; - } - throw new Error('getOtelResourcesVar failed'); - } - - function getOtelGroupLeftVar(trail: DataTrail) { - const variable = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail); - if (variable instanceof ConstantVariable) { - return variable; - } - throw new Error('getOtelGroupLeftVar failed'); - } - - beforeEach(() => { - trail = new DataTrail({ - nonPromotedOtelResources, - // before checking, things should be hidden - initialOtelCheckComplete: false, - }); - locationService.push(preTrailUrl); - activateFullSceneTree(trail); - getOtelGroupLeftVar(trail).setState({ value: 'attribute1,attribute2' }); - }); - // default otel experience to off - it('clicking start button should start with OTel off and showing var filters', () => { - trail.setState({ startButtonClicked: true }); - const otelResourcesHide = getOtelResourcesVar(trail).state.hide; - const varFiltersHide = getFilterVar(trail).state.hide; - expect(otelResourcesHide).toBe(VariableHide.hideVariable); - expect(varFiltersHide).toBe(VariableHide.hideLabel); - }); - - it('should start with hidden otel join query variable', () => { - const joinQueryVarHide = getOtelJoinQueryVar(trail).state.hide; - expect(joinQueryVarHide).toBe(VariableHide.hideVariable); - }); - - it('should have a group left variable for resource attributes', () => { - expect(getOtelGroupLeftVar(trail).state.value).toBe('attribute1,attribute2'); - }); - - describe('resetting the OTel experience', () => { - it('should display with hideLabel var filters and hide VAR_OTEL_AND_METRIC_FILTERS when resetting otel experience', () => { - trail.resetOtelExperience(); - expect(getFilterVar(trail).state.hide).toBe(VariableHide.hideLabel); - expect(getOtelAndMetricsVar(trail).state.hide).toBe(VariableHide.hideVariable); - }); - - // it should preserve var filters when it resets - }); - - describe('when otel is on the subscription to Otel and metrics var should update other variables', () => { - beforeEach(() => { - trail.setState({ initialOtelCheckComplete: true, useOtelExperience: true }); - }); - - it('should automatically update the otel resources var when a non promoted resource has been selected from VAR_OTEL_AND_METRICS', () => { - getOtelAndMetricsVar(trail).setState({ - filters: [{ key: 'deployment_environment', operator: '=', value: 'production' }], - }); - - const otelResourcesVar = getOtelResourcesVar(trail); - const otelResourcesFilter = otelResourcesVar.state.filters[0]; - expect(otelResourcesFilter.key).toBe('deployment_environment'); - expect(otelResourcesFilter.value).toBe('production'); - }); - - it('should add history step of type "resource" when adding a non promoted otel resource', () => { - getOtelAndMetricsVar(trail).setState({ - filters: [{ key: 'deployment_environment', operator: '=', value: 'production' }], - }); - expect(trail.state.history.state.steps[2].type).toBe('resource'); - }); - - it('should automatically update the var filters when a promoted resource has been selected from VAR_OTEL_AND_METRICS', () => { - getOtelAndMetricsVar(trail).setState({ filters: [{ key: 'promoted', operator: '=', value: 'resource' }] }); - const varFilters = getFilterVar(trail).state.filters[0]; - expect(varFilters.key).toBe('promoted'); - expect(varFilters.value).toBe('resource'); - }); - - it('should add history step of type "filters" when adding a non promoted otel resource', () => { - getOtelAndMetricsVar(trail).setState({ filters: [{ key: 'promoted', operator: '=', value: 'resource' }] }); - expect(trail.state.history.state.steps[2].type).toBe('filters'); - }); - }); - }); - - describe('Label filters', () => { - let trail: DataTrail; - - beforeEach(() => { - trail = new DataTrail({}); - }); - - it('should not escape regex metacharacters in label values', () => { - const filterVar = getFilterVar(trail); - filterVar.setState({ filters: [{ key: 'app', operator: '=~', value: '.*end' }] }); // matches app=frontend, app=backend, etc. - expect(filterVar.getValue()).toBe('app=~".*end"'); - }); - }); -}); - -function getFilterVar(trail: DataTrail) { - const variable = sceneGraph.lookupVariable(VAR_FILTERS, trail); - if (variable instanceof AdHocFiltersVariable) { - return variable; - } - throw new Error('getFilterVar failed'); -} diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx deleted file mode 100644 index 3a6502f894f..00000000000 --- a/public/app/features/trails/DataTrail.tsx +++ /dev/null @@ -1,750 +0,0 @@ -import { css } from '@emotion/css'; -import { useEffect, useRef } from 'react'; - -import { AdHocVariableFilter, GrafanaTheme2, RawTimeRange, urlUtil, VariableHide } from '@grafana/data'; -import { PromQuery } from '@grafana/prometheus'; -import { locationService, useChromeHeaderHeight } from '@grafana/runtime'; -import { - AdHocFiltersVariable, - ConstantVariable, - CustomVariable, - DataSourceVariable, - SceneComponentProps, - SceneControlsSpacer, - sceneGraph, - SceneObject, - SceneObjectBase, - SceneObjectState, - SceneObjectUrlSyncConfig, - SceneObjectUrlValues, - SceneObjectWithUrlSync, - SceneQueryRunner, - SceneRefreshPicker, - SceneTimePicker, - SceneTimeRange, - sceneUtils, - SceneVariable, - SceneVariableSet, - UrlSyncContextProvider, - UrlSyncManager, - VariableDependencyConfig, - VariableValueSelectors, -} from '@grafana/scenes'; -import { useStyles2 } from '@grafana/ui'; - -import { DataTrailSettings } from './DataTrailSettings'; -import { DataTrailHistory } from './DataTrailsHistory'; -import { MetricScene } from './MetricScene'; -import { MetricSelectScene } from './MetricSelect/MetricSelectScene'; -import { MetricsHeader } from './MetricsHeader'; -import { getTrailStore } from './TrailStore/TrailStore'; -import { NativeHistogramBanner } from './banners/NativeHistogramBanner'; -import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper'; -import { reportChangeInLabelFilters, reportExploreMetrics } from './interactions'; -import { migrateOtelDeploymentEnvironment } from './migrations/otelDeploymentEnvironment'; -import { getDeploymentEnvironments, getNonPromotedOtelResources, totalOtelResources } from './otel/api'; -import { OtelTargetType } from './otel/types'; -import { manageOtelAndMetricFilters, updateOtelData, updateOtelJoinWithGroupLeft } from './otel/util'; -import { - getVariablesWithOtelJoinQueryConstant, - MetricSelectedEvent, - trailDS, - VAR_DATASOURCE, - VAR_DATASOURCE_EXPR, - VAR_FILTERS, - VAR_MISSING_OTEL_TARGETS, - VAR_OTEL_AND_METRIC_FILTERS, - VAR_OTEL_DEPLOYMENT_ENV, - VAR_OTEL_GROUP_LEFT, - VAR_OTEL_JOIN_QUERY, - VAR_OTEL_RESOURCES, -} from './shared'; -import { getTrailFor, limitAdhocProviders } from './utils'; - -export interface DataTrailState extends SceneObjectState { - topScene?: SceneObject; - embedded?: boolean; - controls: SceneObject[]; - history: DataTrailHistory; - settings: DataTrailSettings; - createdAt: number; - - // just for the starting data source - initialDS?: string; - initialFilters?: AdHocVariableFilter[]; - - // this is for otel, if the data source has it, it will be updated here - hasOtelResources?: boolean; - useOtelExperience?: boolean; - otelTargets?: OtelTargetType; // all the targets with job and instance regex, job=~"|"", instance=~"|" - otelJoinQuery?: string; - isStandardOtel?: boolean; - nonPromotedOtelResources?: string[]; - initialOtelCheckComplete?: boolean; // updated after the first otel check - startButtonClicked?: boolean; // from original landing page - afterFirstOtelCheck?: boolean; // when starting there is always a DS var change from variable dependency - resettingOtel?: boolean; // when switching OTel off from the switch - isUpdatingOtel?: boolean; - addingLabelFromBreakdown?: boolean; // do not use the otel and metrics var subscription when adding label from the breakdown - - // moved into settings - showPreviews?: boolean; - - // Synced with url - metric?: string; - metricSearch?: string; - - histogramsLoaded: boolean; - nativeHistograms: string[]; - nativeHistogramMetric: string; -} - -export class DataTrail extends SceneObjectBase implements SceneObjectWithUrlSync { - protected _urlSync = new SceneObjectUrlSyncConfig(this, { - keys: ['metric', 'metricSearch', 'showPreviews', 'nativeHistogramMetric'], - }); - - public constructor(state: Partial) { - super({ - $timeRange: state.$timeRange ?? new SceneTimeRange({}), - // the initial variables should include a metric for metric scene and the otelJoinQuery. - // NOTE: The other OTEL filters should be included too before this work is merged - $variables: - state.$variables ?? getVariableSet(state.initialDS, state.metric, state.initialFilters, state.otelJoinQuery), - controls: state.controls ?? [ - new VariableValueSelectors({ layout: 'vertical' }), - new SceneControlsSpacer(), - new SceneTimePicker({}), - new SceneRefreshPicker({}), - ], - history: state.history ?? new DataTrailHistory({}), - settings: state.settings ?? new DataTrailSettings({}), - createdAt: state.createdAt ?? new Date().getTime(), - // default to false but update this to true on updateOtelData() - // or true if the user either turned on the experience - useOtelExperience: state.useOtelExperience ?? false, - // preserve the otel join query - otelJoinQuery: state.otelJoinQuery ?? '', - showPreviews: state.showPreviews ?? true, - nativeHistograms: state.nativeHistograms ?? [], - histogramsLoaded: state.histogramsLoaded ?? false, - nativeHistogramMetric: state.nativeHistogramMetric ?? '', - ...state, - }); - - this.addActivationHandler(this._onActivate.bind(this)); - } - - public _onActivate() { - const urlParams = urlUtil.getUrlSearchParams(); - migrateOtelDeploymentEnvironment(this, urlParams); - - if (!this.state.topScene) { - this.setState({ topScene: getTopSceneFor(this.state.metric) }); - } - - // Some scene elements publish this - this.subscribeToEvent(MetricSelectedEvent, this._handleMetricSelectedEvent.bind(this)); - - const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, this); - if (filtersVariable instanceof AdHocFiltersVariable) { - this._subs.add( - filtersVariable?.subscribeToState((newState, prevState) => { - if (!this._addingFilterWithoutReportingInteraction) { - reportChangeInLabelFilters(newState.filters, prevState.filters); - } - }) - ); - } - - // This is for OTel consolidation filters - // whenever the otel and metric filter is updated, - // we need to add that filter to the correct otel resource var or var filter - // so the filter can be interpolated in the query correctly - const otelAndMetricsFiltersVariable = sceneGraph.lookupVariable(VAR_OTEL_AND_METRIC_FILTERS, this); - const otelFiltersVariable = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, this); - if ( - otelAndMetricsFiltersVariable instanceof AdHocFiltersVariable && - otelFiltersVariable instanceof AdHocFiltersVariable && - filtersVariable instanceof AdHocFiltersVariable - ) { - this._subs.add( - otelAndMetricsFiltersVariable?.subscribeToState((newState, prevState) => { - // identify the added, updated or removed variables and update the correct filter, - // either the otel resource or the var filter - // do not update on switching on otel experience or the initial check - // do not update when selecting a label from metric scene breakdown - if ( - this.state.useOtelExperience && - this.state.initialOtelCheckComplete && - !this.state.addingLabelFromBreakdown - ) { - const nonPromotedOtelResources = this.state.nonPromotedOtelResources ?? []; - manageOtelAndMetricFilters( - newState.filters, - prevState.filters, - nonPromotedOtelResources, - otelFiltersVariable, - filtersVariable - ); - } - }) - ); - } - - // Save the current trail as a recent (if the browser closes or reloads) if user selects a metric OR applies filters to metric select view - const saveRecentTrail = () => { - const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, this); - const hasFilters = filtersVariable instanceof AdHocFiltersVariable && filtersVariable.state.filters.length > 0; - if (this.state.metric || hasFilters) { - getTrailStore().setRecentTrail(this); - } - }; - window.addEventListener('unload', saveRecentTrail); - - return () => { - if (!this.state.embedded) { - saveRecentTrail(); - } - window.removeEventListener('unload', saveRecentTrail); - }; - } - - protected _variableDependency = new VariableDependencyConfig(this, { - variableNames: [VAR_DATASOURCE, VAR_OTEL_RESOURCES, VAR_OTEL_JOIN_QUERY, VAR_OTEL_AND_METRIC_FILTERS], - onReferencedVariableValueChanged: async (variable: SceneVariable) => { - const { name } = variable.state; - - if (name === VAR_DATASOURCE) { - this.datasourceHelper.reset(); - - // reset native histograms - this.resetNativeHistograms(); - - if (this.state.afterFirstOtelCheck) { - // we need a new check for OTel - this.setState({ initialOtelCheckComplete: false }); - // clear out the OTel filters, do not clear out var filters - this.resetOtelExperience(); - } - // fresh check for otel experience - this.checkDataSourceForOTelResources(); - } - - // update otel variables when changed - if (this.state.useOtelExperience && name === VAR_OTEL_RESOURCES && this.state.initialOtelCheckComplete) { - // for state and variables - const timeRange: RawTimeRange | undefined = this.state.$timeRange?.state; - const datasourceUid = sceneGraph.interpolate(this, VAR_DATASOURCE_EXPR); - if (timeRange) { - updateOtelData(this, datasourceUid, timeRange); - } - } - }, - }); - - /** - * Assuming that the change in filter was already reported with a cause other than `'adhoc_filter'`, - * this will modify the adhoc filter variable and prevent the automatic reporting which would - * normally occur through the call to `reportChangeInLabelFilters`. - */ - public addFilterWithoutReportingInteraction(filter: AdHocVariableFilter) { - const variable = sceneGraph.lookupVariable('filters', this); - const otelAndMetricsFiltersVariable = sceneGraph.lookupVariable(VAR_OTEL_AND_METRIC_FILTERS, this); - if ( - !(variable instanceof AdHocFiltersVariable) || - !(otelAndMetricsFiltersVariable instanceof AdHocFiltersVariable) - ) { - return; - } - - this._addingFilterWithoutReportingInteraction = true; - if (this.state.useOtelExperience) { - otelAndMetricsFiltersVariable.setState({ filters: [...otelAndMetricsFiltersVariable.state.filters, filter] }); - } else { - variable.setState({ filters: [...variable.state.filters, filter] }); - } - this._addingFilterWithoutReportingInteraction = false; - } - - private _addingFilterWithoutReportingInteraction = false; - private datasourceHelper = new MetricDatasourceHelper(this); - - public getMetricMetadata(metric?: string) { - return this.datasourceHelper.getMetricMetadata(metric); - } - - public isNativeHistogram(metric: string) { - return this.datasourceHelper.isNativeHistogram(metric); - } - - // use this to initialize histograms in all scenes - public async initializeHistograms() { - if (!this.state.histogramsLoaded) { - await this.datasourceHelper.initializeHistograms(); - - this.setState({ - nativeHistograms: this.listNativeHistograms(), - histogramsLoaded: true, - }); - } - } - - public listNativeHistograms() { - return this.datasourceHelper.listNativeHistograms() ?? []; - } - - private resetNativeHistograms() { - this.setState({ - histogramsLoaded: false, - nativeHistograms: [], - }); - } - - public getCurrentMetricMetadata() { - return this.getMetricMetadata(this.state.metric); - } - - public restoreFromHistoryStep(state: DataTrailState) { - if (!state.topScene && !state.metric) { - // If the top scene for an is missing, correct it. - state.topScene = new MetricSelectScene({}); - } - - this.setState( - sceneUtils.cloneSceneObjectState(state, { - history: this.state.history, - metric: !state.metric ? undefined : state.metric, - metricSearch: !state.metricSearch ? undefined : state.metricSearch, - // store type because this requires an expensive api call to determine - // when loading the metric scene - nativeHistogramMetric: !state.nativeHistogramMetric ? undefined : state.nativeHistogramMetric, - }) - ); - - const urlState = new UrlSyncManager().getUrlState(this); - const fullUrl = urlUtil.renderUrl(locationService.getLocation().pathname, urlState); - locationService.replace(fullUrl); - } - - private async _handleMetricSelectedEvent(evt: MetricSelectedEvent) { - const metric = evt.payload ?? ''; - - if (this.state.useOtelExperience) { - await updateOtelJoinWithGroupLeft(this, metric); - } - - // from the metric preview panel we have the info loaded to determine that a metric is a native histogram - let nativeHistogramMetric = false; - if (this.isNativeHistogram(metric)) { - nativeHistogramMetric = true; - } - - this.setState(this.getSceneUpdatesForNewMetricValue(metric, nativeHistogramMetric)); - - // Add metric to adhoc filters baseFilter - const filterVar = sceneGraph.lookupVariable(VAR_FILTERS, this); - if (filterVar instanceof AdHocFiltersVariable) { - filterVar.setState({ - baseFilters: getBaseFiltersForMetric(evt.payload), - }); - } - } - - private getSceneUpdatesForNewMetricValue(metric: string | undefined, nativeHistogramMetric?: boolean) { - const stateUpdate: Partial = {}; - stateUpdate.metric = metric; - // refactoring opportunity? Or do we pass metric knowledge all the way down? - // must pass this native histogram prometheus knowledge deep into - // the topscene set on the trail > MetricScene > getAutoQueriesForMetric() > createHistogramMetricQueryDefs(); - stateUpdate.nativeHistogramMetric = nativeHistogramMetric ? '1' : ''; - stateUpdate.topScene = getTopSceneFor(metric, nativeHistogramMetric); - return stateUpdate; - } - - getUrlState(): SceneObjectUrlValues { - const { metric, metricSearch, showPreviews, nativeHistogramMetric } = this.state; - return { - metric, - metricSearch, - ...{ showPreviews: showPreviews === false ? 'false' : null }, - // store the native histogram knowledge in url for the metric scene - nativeHistogramMetric, - }; - } - - updateFromUrl(values: SceneObjectUrlValues) { - const stateUpdate: Partial = {}; - - if (typeof values.metric === 'string') { - if (this.state.metric !== values.metric) { - // if we have a metric and we have stored in the url that it is a native histogram - // we can pass that info into the metric scene to generate the appropriate queries - let nativeHistogramMetric = false; - if (values.nativeHistogramMetric === '1') { - nativeHistogramMetric = true; - } - - Object.assign(stateUpdate, this.getSceneUpdatesForNewMetricValue(values.metric, nativeHistogramMetric)); - } - } else if (values.metric == null) { - stateUpdate.metric = undefined; - stateUpdate.topScene = new MetricSelectScene({}); - } - - if (typeof values.metricSearch === 'string') { - stateUpdate.metricSearch = values.metricSearch; - } else if (values.metric == null) { - stateUpdate.metricSearch = undefined; - } - - if (typeof values.showPreviews === 'string') { - stateUpdate.showPreviews = values.showPreviews !== 'false'; - } - - this.setState(stateUpdate); - } - - /** - * Check that the data source has otel resources - * Check that the data source is standard for OTEL - * Show a warning if not - * Update the following variables: - * otelResources (filters), otelJoinQuery (used in the query) - * Enable the otel experience - * - * @returns - */ - public async checkDataSourceForOTelResources() { - // call up in to the parent trail - const trail = getTrailFor(this); - - // get the time range - const timeRange: RawTimeRange | undefined = trail.state.$timeRange?.state; - - if (timeRange) { - const datasourceUid = sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR); - const otelTargets = await totalOtelResources(datasourceUid, timeRange); - const deploymentEnvironments = await getDeploymentEnvironments( - datasourceUid, - timeRange, - sceneGraph.getScopesBridge(trail)?.getValue() ?? [] - ); - const hasOtelResources = otelTargets.jobs.length > 0 && otelTargets.instances.length > 0; - // loading from the url with otel resources selected will result in turning on OTel experience - const otelResourcesVariable = sceneGraph.lookupVariable(VAR_OTEL_AND_METRIC_FILTERS, this); - let previouslyUsedOtelResources = false; - if (otelResourcesVariable instanceof AdHocFiltersVariable) { - previouslyUsedOtelResources = otelResourcesVariable.state.filters.length > 0; - } - - // Future refactor: non promoted resources could be the full check - // - remove hasOtelResources - // - remove deployment environments as a check - const nonPromotedOtelResources = await getNonPromotedOtelResources(datasourceUid, timeRange); - - // This is the function that will turn on OTel for the entire app. - // The conditions to use this function are - // 1. must be an otel data source - // 2. Do not turn it on if the start button was clicked - // 3. Url or bookmark has previous otel filters - // 4. We are restting OTel with the toggle switch - if ( - hasOtelResources && - nonPromotedOtelResources && // it is an otel data source - !this.state.startButtonClicked && // we are not starting from the start button - (previouslyUsedOtelResources || this.state.resettingOtel) // there are otel filters or we are restting - ) { - // HERE WE START THE OTEL EXPERIENCE ENGINE - // 1. Set deployment variable values - // 2. update all other variables and state - updateOtelData( - this, - datasourceUid, - timeRange, - deploymentEnvironments, - hasOtelResources, - nonPromotedOtelResources - ); - } else { - this.resetOtelExperience(hasOtelResources, nonPromotedOtelResources); - } - } - } - - resetOtelExperience(hasOtelResources?: boolean, nonPromotedResources?: string[]) { - const otelResourcesVariable = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, this); - const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, this); - const otelAndMetricsFiltersVariable = sceneGraph.lookupVariable(VAR_OTEL_AND_METRIC_FILTERS, this); - const otelJoinQueryVariable = sceneGraph.lookupVariable(VAR_OTEL_JOIN_QUERY, this); - - if ( - !( - otelResourcesVariable instanceof AdHocFiltersVariable && - filtersVariable instanceof AdHocFiltersVariable && - otelAndMetricsFiltersVariable instanceof AdHocFiltersVariable && - otelJoinQueryVariable instanceof ConstantVariable - ) - ) { - return; - } - - // show the var filters normally - filtersVariable.setState({ - addFilterButtonText: 'Add label', - label: 'Select label', - hide: VariableHide.hideLabel, - }); - // Resetting the otel experience filters means clearing both the otel resources var and the otelMetricsVar - // hide the super otel and metric filter and reset it - otelAndMetricsFiltersVariable.setState({ - filters: [], - hide: VariableHide.hideVariable, - }); - - // if there are no resources reset the otel variables and otel state - // or if not standard - otelResourcesVariable.setState({ - filters: [], - defaultKeys: [], - hide: VariableHide.hideVariable, - }); - - otelJoinQueryVariable.setState({ value: '' }); - - // potential full reset when a data source fails the check or is the initial check with turning off - if (hasOtelResources && nonPromotedResources) { - this.setState({ - hasOtelResources, - isStandardOtel: nonPromotedResources.length > 0, - useOtelExperience: false, - otelTargets: { jobs: [], instances: [] }, - otelJoinQuery: '', - afterFirstOtelCheck: true, - initialOtelCheckComplete: true, - isUpdatingOtel: false, - }); - } else { - // partial reset when a user turns off the otel experience - this.setState({ - otelTargets: { jobs: [], instances: [] }, - otelJoinQuery: '', - useOtelExperience: false, - afterFirstOtelCheck: true, - initialOtelCheckComplete: true, - isUpdatingOtel: false, - }); - } - } - - public getQueries(): PromQuery[] { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const sqrs = sceneGraph.findAllObjects(this, (b) => b instanceof SceneQueryRunner) as SceneQueryRunner[]; - - return sqrs.reduce((acc, sqr) => { - acc.push( - ...sqr.state.queries.map((q) => ({ - ...q, - expr: sceneGraph.interpolate(sqr, q.expr), - })) - ); - - return acc; - }, []); - } - - static Component = ({ model }: SceneComponentProps) => { - const { - controls, - topScene, - history, - settings, - useOtelExperience, - hasOtelResources, - embedded, - histogramsLoaded, - nativeHistograms, - } = model.useState(); - - const chromeHeaderHeight = useChromeHeaderHeight(); - const styles = useStyles2(getStyles, embedded ? 0 : (chromeHeaderHeight ?? 0)); - const showHeaderForFirstTimeUsers = getTrailStore().recent.length < 2; - // need to initialize this here and not on activate because it requires the data source helper to be fully initialized first - model.initializeHistograms(); - - useEffect(() => { - if (model.state.addingLabelFromBreakdown) { - return; - } - - if (!useOtelExperience && model.state.afterFirstOtelCheck) { - // if the experience has been turned off, reset the otel variables - model.resetOtelExperience(); - } else { - // if experience is enabled, check standardization and update the otel variables - model.checkDataSourceForOTelResources(); - } - }, [model, hasOtelResources, useOtelExperience]); - - useEffect(() => { - const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, model); - const otelAndMetricsFiltersVariable = sceneGraph.lookupVariable(VAR_OTEL_AND_METRIC_FILTERS, model); - const limitedFilterVariable = useOtelExperience ? otelAndMetricsFiltersVariable : filtersVariable; - const datasourceHelper = model.datasourceHelper; - limitAdhocProviders(model, limitedFilterVariable, datasourceHelper); - }, [model, useOtelExperience]); - - const reportOtelExperience = useRef(false); - // only report otel experience once - if (useOtelExperience && !reportOtelExperience.current) { - reportExploreMetrics('otel_experience_used', {}); - reportOtelExperience.current = true; - } - - return ( -
- {NativeHistogramBanner({ histogramsLoaded, nativeHistograms, trail: model })} - {showHeaderForFirstTimeUsers && } - - {controls && ( -
- {controls.map((control) => ( - - ))} - -
- )} - {topScene && ( - -
{topScene && }
-
- )} -
- ); - }; -} - -export function getTopSceneFor(metric?: string, nativeHistogram?: boolean) { - if (metric) { - return new MetricScene({ metric: metric, nativeHistogram: nativeHistogram ?? false }); - } else { - return new MetricSelectScene({}); - } -} - -function getVariableSet( - initialDS?: string, - metric?: string, - initialFilters?: AdHocVariableFilter[], - otelJoinQuery?: string -) { - return new SceneVariableSet({ - variables: [ - new DataSourceVariable({ - name: VAR_DATASOURCE, - label: 'Data source', - description: 'Only prometheus data sources are supported', - value: initialDS, - pluginId: 'prometheus', - }), - new AdHocFiltersVariable({ - name: VAR_OTEL_RESOURCES, - label: 'Select resource attributes', - addFilterButtonText: 'Select resource attributes', - datasource: trailDS, - hide: VariableHide.hideVariable, - layout: 'combobox', - defaultKeys: [], - applyMode: 'manual', - allowCustomValue: true, - }), - new AdHocFiltersVariable({ - name: VAR_FILTERS, - addFilterButtonText: 'Add label', - datasource: trailDS, - // default to use var filters and have otel off - hide: VariableHide.hideLabel, - layout: 'combobox', - filters: initialFilters ?? [], - baseFilters: getBaseFiltersForMetric(metric), - applyMode: 'manual', - allowCustomValue: true, - expressionBuilder: (filters: AdHocVariableFilter[]) => { - return [...getBaseFiltersForMetric(metric), ...filters] - .map((filter) => `${filter.key}${filter.operator}"${filter.value}"`) - .join(','); - }, - }), - ...getVariablesWithOtelJoinQueryConstant(otelJoinQuery ?? ''), - new ConstantVariable({ - name: VAR_OTEL_GROUP_LEFT, - value: undefined, - hide: VariableHide.hideVariable, - }), - new ConstantVariable({ - name: VAR_MISSING_OTEL_TARGETS, - hide: VariableHide.hideVariable, - value: false, - }), - new AdHocFiltersVariable({ - name: VAR_OTEL_AND_METRIC_FILTERS, - addFilterButtonText: 'Filter', - datasource: trailDS, - hide: VariableHide.hideVariable, - layout: 'combobox', - filters: initialFilters ?? [], - baseFilters: getBaseFiltersForMetric(metric), - applyMode: 'manual', - allowCustomValue: true, - // skipUrlSync: true - }), - // Legacy variable needed for bookmarking which is necessary because - // url sync method does not handle multiple dep env values - // Remove this when the rudderstack event "deployment_environment_migrated" tapers off - new CustomVariable({ - name: VAR_OTEL_DEPLOYMENT_ENV, - label: 'Deployment environment', - hide: VariableHide.hideVariable, - value: undefined, - placeholder: 'Select', - isMulti: true, - }), - ], - }); -} - -function getStyles(theme: GrafanaTheme2, chromeHeaderHeight: number) { - return { - container: css({ - flexGrow: 1, - display: 'flex', - gap: theme.spacing(1), - flexDirection: 'column', - background: theme.isLight ? theme.colors.background.primary : theme.colors.background.canvas, - padding: theme.spacing(2, 3, 2, 3), - }), - body: css({ - flexGrow: 1, - display: 'flex', - flexDirection: 'column', - }), - controls: css({ - display: 'flex', - gap: theme.spacing(1), - padding: theme.spacing(1, 0), - alignItems: 'flex-end', - flexWrap: 'wrap', - position: 'sticky', - background: theme.isDark ? theme.colors.background.canvas : theme.colors.background.primary, - zIndex: theme.zIndex.navbarFixed, - top: chromeHeaderHeight, - }), - }; -} - -function getBaseFiltersForMetric(metric?: string): AdHocVariableFilter[] { - if (metric) { - return [{ key: '__name__', operator: '=', value: metric }]; - } - return []; -} diff --git a/public/app/features/trails/DataTrailBookmarks.test.tsx b/public/app/features/trails/DataTrailBookmarks.test.tsx deleted file mode 100644 index e1648217e3f..00000000000 --- a/public/app/features/trails/DataTrailBookmarks.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; - -import { DataTrail } from './DataTrail'; -import { DataTrailsBookmarks } from './DataTrailBookmarks'; -import { getTrailStore, DataTrailBookmark } from './TrailStore/TrailStore'; - -jest.mock('./TrailStore/TrailStore', () => ({ - getTrailStore: jest.fn(), - getBookmarkKey: jest.fn(() => 'bookmark-key'), -})); - -const onSelect = jest.fn(); -const onDelete = jest.fn(); - -describe('DataTrailsBookmarks', () => { - const trail = new DataTrail({}); - const bookmark: DataTrailBookmark = { urlValues: { key: '1', metric: '' }, createdAt: Date.now() }; - - beforeEach(() => { - onSelect.mockClear(); - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [], - recent: [], - getTrailForBookmark: jest.fn(), - })); - }); - - it('does not render if there are no bookmarks', () => { - render(); - expect(screen.queryByText('Or view bookmarks')).not.toBeInTheDocument(); - }); - - it('renders the bookmarks header and toggle button', () => { - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [bookmark], - recent: [], - })); - render(); - expect(screen.getByText('Or view bookmarks')).toBeInTheDocument(); - expect(screen.getByLabelText('bookmarkCarrot')).toBeInTheDocument(); - }); - - it('toggles the bookmark list when the toggle button is clicked', () => { - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [bookmark], - recent: [], - getTrailForBookmark: jest.fn().mockReturnValue(trail), - })); - render(); - const button = screen.getByLabelText('bookmarkCarrot'); - fireEvent.click(button); - expect(screen.getByText('All metrics')).toBeInTheDocument(); - fireEvent.click(button); - expect(screen.queryByText('All metrics')).not.toBeInTheDocument(); - }); - - it('calls onDelete when the delete button is clicked', () => { - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [bookmark], - recent: [], - getTrailForBookmark: jest.fn().mockReturnValue(trail), - })); - render(); - fireEvent.click(screen.getByLabelText('bookmarkCarrot')); - fireEvent.click(screen.getByLabelText('Remove bookmark')); - expect(onDelete).toHaveBeenCalled(); - }); -}); diff --git a/public/app/features/trails/DataTrailBookmarks.tsx b/public/app/features/trails/DataTrailBookmarks.tsx deleted file mode 100644 index edf449c9b1b..00000000000 --- a/public/app/features/trails/DataTrailBookmarks.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { css } from '@emotion/css'; -import { useState, useEffect } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { IconButton, useStyles2 } from '@grafana/ui'; -import { Trans } from 'app/core/internationalization'; - -import { DataTrailCard } from './DataTrailCard'; -import { getTrailStore, getBookmarkKey } from './TrailStore/TrailStore'; - -type Props = { - onSelect: (index: number) => void; - onDelete: (index: number) => void; -}; - -export function DataTrailsBookmarks({ onSelect, onDelete }: Props) { - const [toggleBookmark, setToggleBookmark] = useState(() => { - const savedState = localStorage.getItem('toggleBookmark'); - return savedState ? JSON.parse(savedState) : false; - }); - const styles = useStyles2(getStyles); - - useEffect(() => { - localStorage.setItem('toggleBookmark', JSON.stringify(toggleBookmark)); - }, [toggleBookmark]); - - if (getTrailStore().bookmarks.length === 0) { - return null; - } - - return ( - <> -
-
-
- Or view bookmarks -
- setToggleBookmark(!toggleBookmark)} - /> -
- {toggleBookmark && ( -
- {getTrailStore().bookmarks.map((bookmark, index) => { - return ( - onSelect(index)} - onDelete={() => onDelete(index)} - /> - ); - })} -
- )} - - ); -} - -function getStyles(theme: GrafanaTheme2) { - return { - trailList: css({ - display: 'grid', - gridTemplateColumns: 'repeat(3, 1fr)', - gap: `${theme.spacing(4)}`, - alignItems: 'stretch', - justifyItems: 'center', - }), - gap20: css({ - marginTop: theme.spacing(3), - }), - bottomGap24: css({ - marginBottom: theme.spacing(3), - }), - bookmarkHeader: css({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - }), - header: css({ - color: theme.colors.text.primary, - textAlign: 'center', - fontSize: '18px', - lineHeight: '22px', - letterSpacing: '0.045px', - }), - horizontalLine: css({ - width: '400px', - height: '1px', - background: theme.colors.border.weak, - margin: '0 auto', // Center line horizontally - marginTop: '32px', - }), - }; -} diff --git a/public/app/features/trails/DataTrailCard.test.tsx b/public/app/features/trails/DataTrailCard.test.tsx deleted file mode 100644 index 29ee36ac808..00000000000 --- a/public/app/features/trails/DataTrailCard.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; - -import { DataTrail } from './DataTrail'; -import { DataTrailCard } from './DataTrailCard'; -import { DataTrailBookmark } from './TrailStore/TrailStore'; - -jest.mock('./utils', () => ({ - ...jest.requireActual('./utils'), -})); - -describe('DataTrailCard', () => { - // trail is a recent metric exploration - const trail = new DataTrail({ key: '1', metric: 'Test Recent Exploration' }); - // bookmark is a data trail stored in a url - const bookmark: DataTrailBookmark = { urlValues: { key: '1', metric: 'Test Bookmark' }, createdAt: Date.now() }; - const onSelect = jest.fn(); - const onDelete = jest.fn(); - beforeEach(() => { - onSelect.mockClear(); - onDelete.mockClear(); - }); - - it('renders the card with recent metric exploration', () => { - render(); - expect(screen.getByText('Test Recent Exploration')).toBeInTheDocument(); - }); - - it('renders the card with bookmark', () => { - render(); - expect(screen.getByText('Test Bookmark')).toBeInTheDocument(); - }); - - it('calls onSelect when the card is clicked', () => { - render(); - fireEvent.click(screen.getByText('Test Bookmark')); - expect(onSelect).toHaveBeenCalled(); - }); - - it('calls onDelete when the delete button is clicked', () => { - render(); - fireEvent.click(screen.getByTestId('deleteButton')); - expect(onDelete).toHaveBeenCalled(); - }); - - it('truncates singular long label in recent explorations', () => { - const longLabel = - 'aajalsdkfaldkjfalskdjfalsdkjfalsdkjflaskjdflaskjdflaskjdflaskjdflasjkdflaskjdflaskjdflaskjflaskdjfldaskjflasjflaskdjflaskjflasjflaskfjalsdfjlskdjflaskjdflajkfjfalkdfjaverylongalskdjlalsjflajkfklsajdfalskjdflkasjdflkadjf'; - const bookmarkWithLongLabel: DataTrailBookmark = { - urlValues: { key: '1', metric: 'metric', 'var-filters': `zone|=|${longLabel}` }, - createdAt: Date.now(), - }; - render(); - expect(screen.getByText('...', { exact: false })).toBeInTheDocument(); - }); - - it('truncates long list of labels after 2 lines in recent explorations', () => { - const bookmarkWithLongLabel: DataTrailBookmark = { - urlValues: { - key: '1', - metric: 'metric', - // labels are in a comma separated list - 'var-filters': `zone|=|averylonglabeltotakeupspace,zone1=averylonglabeltotakeupspace,zone2=averylonglabeltotakeupspace,zone3=averylonglabeltotakeupspace,zone4=averylonglabeltotakeupspace`, - }, - createdAt: Date.now(), - }; - render(); - // to test the non-existence of a truncated label we need queryByText - const truncatedLabel = screen.queryByText('zone3'); - expect(truncatedLabel).not.toBeInTheDocument(); - }); -}); diff --git a/public/app/features/trails/DataTrailCard.tsx b/public/app/features/trails/DataTrailCard.tsx deleted file mode 100644 index d752eb5088a..00000000000 --- a/public/app/features/trails/DataTrailCard.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { css } from '@emotion/css'; -import { useMemo } from 'react'; - -import { dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; -import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes'; -import { Card, IconButton, useStyles2 } from '@grafana/ui'; -import { Trans, t } from 'app/core/internationalization'; - -import { DataTrail } from './DataTrail'; -import { getTrailStore, DataTrailBookmark } from './TrailStore/TrailStore'; -import { VAR_FILTERS } from './shared'; -import { getMetricName } from './utils'; - -export type Props = { - trail?: DataTrail; - bookmark?: DataTrailBookmark; - onSelect: () => void; - onDelete?: () => void; -}; - -// Helper function to truncate the value for a single key:value pair -const truncateValue = (key: string, value: string, maxLength: number) => { - const combinedLength = key.length + 2 + value.length; // 2 for ": " - if (combinedLength > maxLength) { - return value.substring(0, maxLength - key.length - 5) + '...'; // 5 for ": " and "..." - } - return value; -}; - -export function DataTrailCard(props: Props) { - const { onSelect, onDelete, bookmark } = props; - const styles = useStyles2(getStyles); - - const values = useMemo(() => { - let trail = props.trail || (bookmark && getTrailStore().getTrailForBookmark(bookmark)); - - if (!trail) { - return null; - } - - const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail)!; - if (!(filtersVariable instanceof AdHocFiltersVariable)) { - return null; - } - - const createdAt = bookmark?.createdAt || trail.state.createdAt; - - return { - filters: filtersVariable.state.filters, - metric: trail.state.metric, - createdAt, - }; - }, [props.trail, bookmark]); - - if (!values) { - return null; - } - - const { filters, metric, createdAt } = values; - - return ( -
- - -
{truncateValue('', getMetricName(metric), 39)}
-
- - {filters.map((f) => ( - -
{f.key}:
-
{truncateValue(f.key, f.value, 44)}
-
- ))} -
-
- {onDelete && ( - - - - )} -
-
-
-
- Date created: -
-
{createdAt && dateTimeFormat(createdAt, { format: 'YYYY-MM-DD' })}
-
-
- ); -} - -export function getStyles(theme: GrafanaTheme2) { - return { - metricValue: css({ - display: 'inline', - color: theme.colors.text.primary, - fontWeight: 500, - wordBreak: 'break-all', - }), - card: css({ - position: 'relative', - width: '318px', - padding: `12px ${theme.spacing(2)} ${theme.spacing(1)} ${theme.spacing(2)}`, - height: '110px', - alignItems: 'start', - marginBottom: 0, - borderTop: `1px solid ${theme.colors.border.weak}`, - borderRight: `1px solid ${theme.colors.border.weak}`, - borderLeft: `1px solid ${theme.colors.border.weak}`, - borderBottom: 'none', // Remove the bottom border - // eslint-disable-next-line @grafana/no-border-radius-literal - borderRadius: '2px 2px 0 0', // Top-left and top-right corners are 2px, bottom-left and bottom-right are 0; cannot use theme.shape.radius.default because need bottom corners to be 0 - }), - secondary: css({ - color: theme.colors.text.secondary, - fontSize: '12px', - }), - date: css({ - border: `1px solid ${theme.colors.border.weak}`, - // eslint-disable-next-line @grafana/no-border-radius-literal - borderRadius: '0 0 2px 2px', - padding: `${theme.spacing(1)} ${theme.spacing(2)}`, - backgroundColor: theme.colors.background.primary, - }), - meta: css({ - flexWrap: 'wrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - maxHeight: '36px', // 2 lines * 18px line-height - margin: 0, - gridArea: 'Meta', - color: theme.colors.text.secondary, - whiteSpace: 'nowrap', - }), - primaryFont: css({ - display: 'inline', - color: theme.colors.text.primary, - fontSize: '12px', - fontWeight: '500', - letterSpacing: '0.018px', - }), - secondaryFont: css({ - display: 'inline', - color: theme.colors.text.secondary, - fontSize: '12px', - fontWeight: '400', - lineHeight: '18px' /* 150% */, - letterSpacing: '0.018px', - }), - deleteButton: css({ - position: 'absolute', - bottom: theme.spacing(1), - right: theme.spacing(1), - }), - }; -} diff --git a/public/app/features/trails/DataTrailSettings.tsx b/public/app/features/trails/DataTrailSettings.tsx deleted file mode 100644 index 88fd63adc96..00000000000 --- a/public/app/features/trails/DataTrailSettings.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { css } from '@emotion/css'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { Dropdown, Switch, ToolbarButton, useStyles2 } from '@grafana/ui'; -import { Trans } from 'app/core/internationalization'; - -import { MetricScene } from './MetricScene'; -import { MetricSelectScene } from './MetricSelect/MetricSelectScene'; -import { reportExploreMetrics } from './interactions'; -import { getTrailFor } from './utils'; - -export interface DataTrailSettingsState extends SceneObjectState { - stickyMainGraph?: boolean; - isOpen?: boolean; -} - -export class DataTrailSettings extends SceneObjectBase { - constructor(state: Partial) { - super({ - stickyMainGraph: state.stickyMainGraph ?? true, - isOpen: state.isOpen ?? false, - }); - } - - public onToggleStickyMainGraph = () => { - const stickyMainGraph = !this.state.stickyMainGraph; - reportExploreMetrics('settings_changed', { stickyMainGraph }); - this.setState({ stickyMainGraph }); - }; - - public onToggleOpen = (isOpen: boolean) => { - this.setState({ isOpen }); - }; - - public onTogglePreviews = () => { - const trail = getTrailFor(this); - trail.setState({ showPreviews: !trail.state.showPreviews }); - }; - - static Component = ({ model }: SceneComponentProps) => { - const { stickyMainGraph, isOpen } = model.useState(); - const styles = useStyles2(getStyles); - - const trail = getTrailFor(model); - - const { showPreviews, topScene } = trail.useState(); - - const renderPopover = () => { - return ( - /* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ -
evt.stopPropagation()}> -
- Settings -
- {topScene instanceof MetricScene && ( -
-
- - Always keep selected metric graph in-view - -
- -
- )} - {topScene instanceof MetricSelectScene && ( -
-
- Show previews of metric graphs -
- -
- )} -
- ); - }; - - return ( - - - - ); - }; -} - -function getStyles(theme: GrafanaTheme2) { - return { - popover: css({ - display: 'flex', - padding: theme.spacing(2), - flexDirection: 'column', - background: theme.colors.background.primary, - boxShadow: theme.shadows.z3, - borderRadius: theme.shape.borderRadius(), - border: `1px solid ${theme.colors.border.weak}`, - zIndex: 1, - marginRight: theme.spacing(2), - }), - heading: css({ - fontWeight: theme.typography.fontWeightMedium, - paddingBottom: theme.spacing(2), - }), - options: css({ - display: 'grid', - gridTemplateColumns: '1fr 50px', - rowGap: theme.spacing(1), - columnGap: theme.spacing(2), - }), - }; -} diff --git a/public/app/features/trails/DataTrailsApp.tsx b/public/app/features/trails/DataTrailsApp.tsx deleted file mode 100644 index 156a77e3e47..00000000000 --- a/public/app/features/trails/DataTrailsApp.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Routes, Route } from 'react-router-dom-v5-compat'; - -import { PageLayoutType } from '@grafana/data'; -import { config, locationService } from '@grafana/runtime'; -import { - SceneComponentProps, - SceneObjectBase, - SceneObjectState, - SceneScopesBridge, - UrlSyncContextProvider, -} from '@grafana/scenes'; -import { Page } from 'app/core/components/Page/Page'; - -import { DataTrail } from './DataTrail'; -import { DataTrailsHome } from './DataTrailsHome'; -import { getTrailStore } from './TrailStore/TrailStore'; -import { HOME_ROUTE, RefreshMetricsEvent, TRAILS_ROUTE } from './shared'; -import { getMetricName, getUrlForTrail, newMetricsTrail } from './utils'; - -export interface DataTrailsAppState extends SceneObjectState { - trail: DataTrail; - home: DataTrailsHome; - scopesBridge?: SceneScopesBridge | undefined; -} - -export class DataTrailsApp extends SceneObjectBase { - protected _renderBeforeActivation = true; - - public constructor(state: DataTrailsAppState) { - super(state); - } - - goToUrlForTrail(trail: DataTrail) { - locationService.push(getUrlForTrail(trail)); - this.setState({ trail }); - } - - static Component = ({ model }: SceneComponentProps) => { - const { trail, home, scopesBridge } = model.useState(); - - return ( - <> - {scopesBridge && } - - {/* The routes are relative to the HOME_ROUTE */} - null} - subTitle="" - > - - - } - /> - } /> - - - ); - }; -} - -function DataTrailView({ trail }: { trail: DataTrail }) { - const [isInitialized, setIsInitialized] = useState(false); - const { metric } = trail.useState(); - - useEffect(() => { - if (!isInitialized) { - if (trail.state.metric !== undefined) { - getTrailStore().setRecentTrail(trail); - } - setIsInitialized(true); - } - }, [trail, isInitialized]); - - if (!isInitialized) { - return null; - } - - return ( - - - - - - ); -} - -let dataTrailsApp: DataTrailsApp; - -export function getDataTrailsApp() { - if (!dataTrailsApp) { - const scopesBridge = - config.featureToggles.scopeFilters && config.featureToggles.enableScopesInMetricsExplore - ? new SceneScopesBridge({}) - : undefined; - - dataTrailsApp = new DataTrailsApp({ - trail: newMetricsTrail(), - home: new DataTrailsHome({}), - scopesBridge, - $behaviors: [ - () => { - scopesBridge?.setEnabled(true); - - const sub = scopesBridge?.subscribeToValue(() => { - dataTrailsApp.state.trail.publishEvent(new RefreshMetricsEvent()); - dataTrailsApp.state.trail.checkDataSourceForOTelResources(); - }); - - return () => { - scopesBridge?.setEnabled(false); - sub?.unsubscribe(); - }; - }, - ], - }); - } - - return dataTrailsApp; -} diff --git a/public/app/features/trails/DataTrailsHistory.test.tsx b/public/app/features/trails/DataTrailsHistory.test.tsx deleted file mode 100644 index 24e27dadc23..00000000000 --- a/public/app/features/trails/DataTrailsHistory.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { SceneObjectUrlValues } from '@grafana/scenes'; - -import { parseFilterTooltip, parseTimeTooltip } from './DataTrailsHistory'; - -type ParseTimeTestCase = { - name: string; - input: SceneObjectUrlValues; - expected: string; -}; - -type ParseFilterTestCase = { - name: string; - input: { urlValues: SceneObjectUrlValues; filtersApplied: string[] }; - expected: string; - expectedFiltersApplied: string[]; -}; - -describe('DataTrailsHistory', () => { - // Due to daylight saving changes the expected time differs depends on when we run the tests. - // Until we find a better way to test, those will be skipped. - describe.skip('parseTimeTooltip', () => { - // global timezone is set to Pacific/Easter, see jest-config.js file - test.each([ - { - name: 'from history', - input: { from: '2024-07-22T18:30:00.000Z', to: '2024-07-22T19:30:00.000Z' }, - expected: '2024-07-22 13:30:00 - 2024-07-22 14:30:00', - }, - { - name: 'time change event with timezone', - input: { from: '2024-07-22T18:30:00.000Z', to: '2024-07-22T19:30:00.000Z', timeZone: 'Europe/Berlin' }, - expected: '2024-07-22 20:30:00 - 2024-07-22 21:30:00', - }, - ])('$name', ({ input, expected }) => { - const result = parseTimeTooltip(input); - expect(result).toEqual(expected); - }); - }); - - describe('parseFilterTooltip', () => { - test.each([ - { - name: 'from history initial load', - input: { - urlValues: { 'var-filters': ['job|=|grafana'] }, - filtersApplied: [], - }, - expected: 'job = grafana', - expectedFiltersApplied: ['job|=|grafana'], - }, - { - name: 'from history initial load', - input: { - urlValues: { 'var-filters': ['job|=|grafana', 'instance|=|host.docker.internal:3000'] }, - filtersApplied: ['job|=|grafana'], - }, - expected: 'instance = host.docker.internal:3000', - expectedFiltersApplied: ['job|=|grafana', 'instance|=|host.docker.internal:3000'], - }, - ])('$name', ({ input, expected, expectedFiltersApplied }) => { - const filtersApplied = input.filtersApplied; - const result = parseFilterTooltip(input.urlValues, filtersApplied); - expect(result).toBe(expected); - expect(filtersApplied).toEqual(expectedFiltersApplied); - }); - }); -}); diff --git a/public/app/features/trails/DataTrailsHistory.tsx b/public/app/features/trails/DataTrailsHistory.tsx deleted file mode 100644 index 694d73918d1..00000000000 --- a/public/app/features/trails/DataTrailsHistory.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import { css, cx } from '@emotion/css'; -import { useMemo } from 'react'; - -import { getTimeZoneInfo, GrafanaTheme2, InternalTimeZones, TIME_FORMAT, rangeUtil } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { - SceneComponentProps, - SceneObjectBase, - SceneObjectState, - SceneObjectStateChangedEvent, - SceneObjectUrlValue, - SceneObjectUrlValues, - SceneTimeRange, - sceneUtils, - SceneVariableValueChangedEvent, -} from '@grafana/scenes'; -import { Stack, Tooltip, useStyles2 } from '@grafana/ui'; -import { appEvents } from 'app/core/app_events'; -import { Trans } from 'app/core/internationalization'; -import { RecordHistoryEntryEvent } from 'app/types/events'; - -import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail'; -import { SerializedTrailHistory } from './TrailStore/TrailStore'; -import { reportExploreMetrics } from './interactions'; -import { VAR_FILTERS, VAR_OTEL_DEPLOYMENT_ENV, VAR_OTEL_RESOURCES } from './shared'; -import { getTrailFor, isSceneTimeRangeState } from './utils'; - -export interface DataTrailsHistoryState extends SceneObjectState { - currentStep: number; - steps: DataTrailHistoryStep[]; - filtersApplied: string[]; - otelResources: string[]; - otelDepEnvs: string[]; -} - -export function isDataTrailsHistoryState(state: SceneObjectState): state is DataTrailsHistoryState { - return 'currentStep' in state && 'steps' in state; -} - -export function isDataTrailHistoryFilter(filter?: SceneObjectUrlValue): filter is string[] { - return !!filter; -} - -const isString = (value: unknown): value is string => typeof value === 'string'; - -export interface DataTrailHistoryStep { - description: string; - detail: string; - type: TrailStepType; - trailState: DataTrailState; - parentIndex: number; -} - -export type TrailStepType = 'filters' | 'time' | 'metric' | 'start' | 'metric_page' | 'dep_env' | 'resource'; - -const filterSubst = ` $2 `; -const filterPipeRegex = /(\|)(=|=~|!=|>|<|!~)(\|)/g; -const stepDescriptionMap: Record = { - start: 'Start of history', - metric: 'Metric selected:', - metric_page: 'Metric select page', - filters: 'Filter applied:', - time: 'Time range changed:', - dep_env: 'Deployment environment selected:', - resource: 'Resource attribute selected:', -}; - -export class DataTrailHistory extends SceneObjectBase { - public constructor(state: Partial) { - super({ - steps: state.steps ?? [], - currentStep: state.currentStep ?? 0, - filtersApplied: [], - otelResources: [], - otelDepEnvs: [], - }); - - this.addActivationHandler(this._onActivate.bind(this)); - } - - private stepTransitionInProgress = false; - - public _onActivate() { - const trail = getTrailFor(this); - - if (this.state.steps.length === 0) { - // We always want to ensure in initial 'start' step - this.addTrailStep(trail, 'start'); - - if (trail.state.metric) { - // But if our current trail has a metric, we want to remove it and the topScene, - // so that the "start" step always displays a metric select screen. - - // So we remove the metric and update the topscene for the "start" step - const { metric, ...startState } = trail.state; - startState.topScene = getTopSceneFor(undefined); - this.state.steps[0].trailState = startState; - - // But must add a secondary step to represent the selection of the metric - // for this restored trail state - this.addTrailStep(trail, 'metric', trail.state.metric); - } else { - this.addTrailStep(trail, 'metric_page'); - } - } - - trail.subscribeToState((newState, oldState) => { - if (newState.metric !== oldState.metric) { - if (this.state.steps.length === 1) { - // For the first step we want to update the starting state so that it contains data - this.state.steps[0].trailState = sceneUtils.cloneSceneObjectState(oldState, { history: this }); - } - - if (!newState.metric) { - this.addTrailStep(trail, 'metric_page'); - } else { - this.addTrailStep(trail, 'metric', newState.metric); - } - } - }); - - trail.subscribeToEvent(SceneVariableValueChangedEvent, (evt) => { - if (evt.payload.state.name === VAR_FILTERS) { - const filtersApplied = this.state.filtersApplied; - const urlState = sceneUtils.getUrlState(trail); - this.addTrailStep(trail, 'filters', parseFilterTooltip(urlState, filtersApplied)); - this.setState({ filtersApplied }); - } - - // TEST THE MIGRATION OF REMOVING THE VAR_OTEL_DEPLOYMENT_ENV - if (evt.payload.state.name === VAR_OTEL_DEPLOYMENT_ENV) { - const otelDepEnvs = this.state.otelDepEnvs; - const urlState = sceneUtils.getUrlState(trail); - this.addTrailStep(trail, 'dep_env', parseDepEnvTooltip(urlState, otelDepEnvs)); - this.setState({ otelDepEnvs }); - } - - if (evt.payload.state.name === VAR_OTEL_RESOURCES) { - const otelResources = this.state.otelResources; - const urlState = sceneUtils.getUrlState(trail); - this.addTrailStep(trail, 'resource', parseOtelResourcesTooltip(urlState, otelResources)); - this.setState({ otelResources }); - } - }); - - trail.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => { - if (evt.payload.changedObject instanceof SceneTimeRange) { - const { prevState, newState } = evt.payload; - - if (isSceneTimeRangeState(prevState) && isSceneTimeRangeState(newState)) { - if (prevState.from === newState.from && prevState.to === newState.to) { - return; - } - - const tooltip = parseTimeTooltip({ - from: newState.from, - to: newState.to, - timeZone: newState.timeZone, - }); - - this.addTrailStep(trail, 'time', tooltip); - - if (config.featureToggles.unifiedHistory) { - appEvents.publish( - new RecordHistoryEntryEvent({ - name: 'Time range changed', - description: tooltip, - url: window.location.href, - time: Date.now(), - }) - ); - } - } - } - }); - } - - public addTrailStep(trail: DataTrail, type: TrailStepType, detail = '') { - if (this.stepTransitionInProgress) { - // Do not add trail steps when step transition is in progress - return; - } - - const stepIndex = this.state.steps.length; - const parentIndex = type === 'start' ? -1 : this.state.currentStep; - - this.setState({ - currentStep: stepIndex, - steps: [ - ...this.state.steps, - { - type, - detail, - description: stepDescriptionMap[type], - trailState: sceneUtils.cloneSceneObjectState(trail.state, { history: this }), - parentIndex, - }, - ], - }); - } - - public addTrailStepFromStorage(trail: DataTrail, step: SerializedTrailHistory) { - if (this.stepTransitionInProgress) { - // Do not add trail steps when step transition is in progress - return; - } - - const type = step.type; - const stepIndex = this.state.steps.length; - const parentIndex = type === 'start' ? -1 : this.state.currentStep; - const filtersApplied = this.state.filtersApplied; - const otelResources = this.state.otelResources; - const otelDepEnvs = this.state.otelDepEnvs; - let detail = ''; - - switch (step.type) { - case 'metric': - detail = step.urlValues.metric?.toString() ?? ''; - break; - case 'filters': - detail = parseFilterTooltip(step.urlValues, filtersApplied); - break; - case 'time': - detail = parseTimeTooltip(step.urlValues); - break; - case 'dep_env': - detail = parseDepEnvTooltip(step.urlValues, otelDepEnvs); - case 'resource': - detail = parseOtelResourcesTooltip(step.urlValues, otelResources); - } - - this.setState({ - filtersApplied, - otelDepEnvs, - otelResources, - currentStep: stepIndex, - steps: [ - ...this.state.steps, - { - type, - detail, - description: stepDescriptionMap[type], - trailState: sceneUtils.cloneSceneObjectState(trail.state, { history: this }), - parentIndex, - }, - ], - }); - } - - public goBackToStep(stepIndex: number) { - if (stepIndex === this.state.currentStep) { - return; - } - - const step = this.state.steps[stepIndex]; - const type = step.type === 'metric' && step.trailState.metric === undefined ? 'metric-clear' : step.type; - - reportExploreMetrics('history_step_clicked', { type, step: stepIndex, numberOfSteps: this.state.steps.length }); - - this.stepTransitionInProgress = true; - this.setState({ currentStep: stepIndex }); - - getTrailFor(this).restoreFromHistoryStep(step.trailState); - - // The URL will update - this.stepTransitionInProgress = false; - } - - renderStepTooltip(step: DataTrailHistoryStep) { - return ( - -
{step.description}
- {step.detail !== '' &&
{step.detail}
} -
- ); - } - - public static Component = ({ model }: SceneComponentProps) => { - const { steps, currentStep } = model.useState(); - const styles = useStyles2(getStyles); - - const { ancestry, alternatePredecessorStyle } = useMemo(() => { - const ancestry = new Set(); - - let cursor = currentStep; - while (cursor >= 0) { - const step = steps[cursor]; - if (!step) { - break; - } - ancestry.add(cursor); - cursor = step.parentIndex; - } - - const alternatePredecessorStyle = new Map(); - - ancestry.forEach((index) => { - const parent = steps[index].parentIndex; - if (parent + 1 !== index) { - alternatePredecessorStyle.set(index, createAlternatePredecessorStyle(index, parent)); - } - }); - - return { ancestry, alternatePredecessorStyle }; - }, [currentStep, steps]); - - return ( -
-
- History -
- {steps.map((step, index) => { - let stepType = step.type; - - if (stepType === 'metric' && step.trailState.metric === undefined) { - // If we're resetting the metric, we want it to look like a start node - stepType = 'start'; - } - - return ( - model.renderStepTooltip(step)} key={index}> - - - ); - })} -
- ); - }; -} - -export function parseTimeTooltip(urlValues: SceneObjectUrlValues): string { - if (!isSceneTimeRangeState(urlValues)) { - return ''; - } - - const range = rangeUtil.convertRawToRange({ - from: urlValues.from, - to: urlValues.to, - }); - - const zone = isString(urlValues.timeZone) ? urlValues.timeZone : InternalTimeZones.localBrowserTime; - const tzInfo = getTimeZoneInfo(zone, Date.now()); - - const from = range.from.subtract(tzInfo?.offsetInMins ?? 0, 'minute').format(TIME_FORMAT); - const to = range.to.subtract(tzInfo?.offsetInMins ?? 0, 'minute').format(TIME_FORMAT); - - return `${from} - ${to}`; -} - -export function parseFilterTooltip(urlValues: SceneObjectUrlValues, filtersApplied: string[]): string { - let detail = ''; - const varFilters = urlValues['var-filters']; - if (isDataTrailHistoryFilter(varFilters)) { - detail = - varFilters.filter((f) => { - if (f !== '' && !filtersApplied.includes(f)) { - filtersApplied.push(f); - return true; - } - return false; - })[0] ?? ''; - } - // filters saved as key|operator|value - // we need to remove pipes (|) - return detail.replace(filterPipeRegex, filterSubst); -} - -export function parseOtelResourcesTooltip(urlValues: SceneObjectUrlValues, otelResources: string[]): string { - let detail = ''; - const varOtelResources = urlValues['var-otel_resources']; - if (isDataTrailHistoryFilter(varOtelResources)) { - detail = - varOtelResources.filter((f) => { - if (f !== '' && !otelResources.includes(f)) { - otelResources.push(f); - return true; - } - return false; - })[0] ?? ''; - } - // filters saved as key|operator|value - // we need to remove pipes (|) - return detail.replace(filterPipeRegex, filterSubst); -} - -export function parseDepEnvTooltip(urlValues: SceneObjectUrlValues, otelDepEnvs: string[]): string { - let detail = ''; - const varDepEnv = urlValues['var-deployment_environment']; - - if (typeof varDepEnv === 'string') { - return varDepEnv; - } - - if (isDataTrailHistoryFilter(varDepEnv)) { - detail = - varDepEnv?.filter((f) => { - if (f !== '' && !otelDepEnvs.includes(f)) { - otelDepEnvs.push(f); - return true; - } - return false; - })[0] ?? ''; - } - - return detail; -} - -function getStyles(theme: GrafanaTheme2) { - const visTheme = theme.visualization; - - return { - container: css({ - display: 'flex', - gap: 10, - alignItems: 'center', - }), - heading: css({}), - step: css({ - flexGrow: 0, - cursor: 'pointer', - border: 'none', - boxShadow: 'none', - padding: 0, - margin: 0, - width: 8, - height: 8, - opacity: 0.7, - borderRadius: theme.shape.radius.circle, - background: theme.colors.primary.main, - position: 'relative', - '&:hover': { - opacity: 1, - }, - '&:hover:before': { - // We only want the node to hover, not its connection to its parent - opacity: 0.7, - }, - '&:before': { - content: '""', - position: 'absolute', - width: 10, - height: 2, - left: -10, - top: 3, - background: theme.colors.primary.border, - pointerEvents: 'none', - }, - }), - stepSelected: css({ - '&:after': { - content: '""', - borderStyle: `solid`, - borderWidth: 2, - borderRadius: '50%', - position: 'absolute', - width: 16, - height: 16, - left: -4, - top: -4, - boxShadow: `0px 0px 0px 2px inset ${theme.colors.background.canvas}`, - }, - }), - stepOmitsDirectLeftLink: css({ - '&:before': { - background: 'none', - }, - }), - stepIsNotAncestorOfCurrent: css({ - opacity: 0.2, - '&:hover:before': { - opacity: 0.2, - }, - }), - stepTypes: { - start: generateStepTypeStyle(visTheme.getColorByName('green')), - filters: generateStepTypeStyle(visTheme.getColorByName('purple')), - metric: generateStepTypeStyle(visTheme.getColorByName('orange')), - metric_page: generateStepTypeStyle(visTheme.getColorByName('orange')), - time: generateStepTypeStyle(theme.colors.primary.main), - resource: generateStepTypeStyle(visTheme.getColorByName('purple')), - dep_env: generateStepTypeStyle(visTheme.getColorByName('purple')), - }, - }; -} - -function generateStepTypeStyle(color: string) { - return css({ - background: color, - '&:before': { - background: color, - borderColor: color, - }, - '&:after': { - borderColor: color, - }, - }); -} - -function createAlternatePredecessorStyle(index: number, parent: number) { - const difference = index - parent; - - const NODE_DISTANCE = 18; - const distanceToParent = difference * NODE_DISTANCE; - - return css({ - '&:before': { - content: '""', - width: distanceToParent + 2, - height: 10, - borderStyle: 'solid', - borderWidth: 2, - borderBottom: 'none', - borderTopLeftRadius: 8, - borderTopRightRadius: 8, - top: -10, - left: 3 - distanceToParent, - background: 'none', - }, - }); -} diff --git a/public/app/features/trails/DataTrailsHome.test.tsx b/public/app/features/trails/DataTrailsHome.test.tsx deleted file mode 100644 index b6e96d2b874..00000000000 --- a/public/app/features/trails/DataTrailsHome.test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import { AdHocFiltersVariable, sceneGraph, SceneObjectRef, SceneVariableSet } from '@grafana/scenes'; - -import { DataTrail } from './DataTrail'; -import { DataTrailsHome } from './DataTrailsHome'; -import { getTrailStore } from './TrailStore/TrailStore'; -import { VAR_FILTERS } from './shared'; - -jest.mock('./TrailStore/TrailStore', () => ({ - getTrailStore: jest.fn(), -})); - -describe('DataTrailsHome', () => { - let scene: DataTrailsHome; - beforeEach(() => { - const filtersVariable = new AdHocFiltersVariable({ name: VAR_FILTERS }); - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [], - recent: [], - })); - scene = new DataTrailsHome({ - $variables: new SceneVariableSet({ - variables: [filtersVariable], - }), - }); - }); - - it('renders the start button', () => { - render(); - expect(screen.getByText("Let's start!")).toBeInTheDocument(); - }); - - it('renders the learn more button and checks its href', () => { - render(); - const learnMoreButton = screen.getByText('Learn more'); - expect(learnMoreButton).toBeInTheDocument(); - expect(learnMoreButton.closest('a')).toHaveAttribute( - 'href', - 'https://grafana.com/docs/grafana/latest/explore/explore-metrics/' - ); - }); - - it('does not show recent metrics and bookmarks headers for first time user', () => { - render(); - expect(screen.queryByText('Or view a recent exploration')).not.toBeInTheDocument(); - expect(screen.queryByText('Or view bookmarks')).not.toBeInTheDocument(); - expect(screen.queryByRole('separator')).not.toBeInTheDocument(); - }); - - it('truncates singular long label in recent explorations', () => { - const trail = new DataTrail({}); - function getFilterVar() { - const variable = sceneGraph.lookupVariable(VAR_FILTERS, trail); - if (variable instanceof AdHocFiltersVariable) { - return variable; - } - throw new Error('getFilterVar failed'); - } - const filtersVariable = getFilterVar(); - const longLabel = 'averylongalskdjlalsjflajkfklsajdfalskjdflkasjdflkadjf'; - filtersVariable.setState({ - filters: [{ key: 'zone', operator: '=', value: longLabel }], - }); - const trailWithResolveMethod = new SceneObjectRef(trail); - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [], - recent: [trailWithResolveMethod], - })); - render(); - expect(screen.getByText('...', { exact: false })).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/trails/DataTrailsHome.tsx b/public/app/features/trails/DataTrailsHome.tsx deleted file mode 100644 index 6a35274cd85..00000000000 --- a/public/app/features/trails/DataTrailsHome.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { css } from '@emotion/css'; -import { useState } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { SceneComponentProps, sceneGraph, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { Box, Button, Icon, Stack, Text, TextLink, useStyles2, useTheme2 } from '@grafana/ui'; -import { Trans } from 'app/core/internationalization'; - -import { DataTrail } from './DataTrail'; -import { DataTrailsBookmarks } from './DataTrailBookmarks'; -import { DataTrailsApp } from './DataTrailsApp'; -import { DataTrailsRecentMetrics } from './DataTrailsRecentMetrics'; -import { getTrailStore } from './TrailStore/TrailStore'; -import { LightModeRocket, DarkModeRocket } from './assets/rockets'; -import { reportExploreMetrics } from './interactions'; -import { getDatasourceForNewTrail, newMetricsTrail } from './utils'; - -export interface DataTrailsHomeState extends SceneObjectState {} - -export class DataTrailsHome extends SceneObjectBase { - public constructor(state: DataTrailsHomeState) { - super(state); - } - - public onNewMetricsTrail = () => { - const app = getAppFor(this); - const trail = newMetricsTrail(getDatasourceForNewTrail(), true); - reportExploreMetrics('exploration_started', { cause: 'new_clicked' }); - app.goToUrlForTrail(trail); - }; - - public onSelectRecentTrail = (trail: DataTrail) => { - const app = getAppFor(this); - reportExploreMetrics('exploration_started', { cause: 'recent_clicked' }); - getTrailStore().setRecentTrail(trail); - app.goToUrlForTrail(trail); - }; - - public onSelectBookmark = (bookmarkIndex: number) => { - const app = getAppFor(this); - reportExploreMetrics('exploration_started', { cause: 'bookmark_clicked' }); - const trail = getTrailStore().getTrailForBookmarkIndex(bookmarkIndex); - getTrailStore().setRecentTrail(trail); - app.goToUrlForTrail(trail); - }; - - static Component = ({ model }: SceneComponentProps) => { - const [_, setLastDelete] = useState(Date.now()); - const styles = useStyles2(getStyles); - const theme = useTheme2(); - - const onDelete = (index: number) => { - getTrailStore().removeBookmark(index); - reportExploreMetrics('bookmark_changed', { action: 'deleted' }); - setLastDelete(Date.now()); // trigger re-render - }; - - return ( -
-
- -
{theme.isDark ? : }
- - Start your metrics exploration! - - - - - Explore your Prometheus-compatible metrics without writing a query. - - - Learn more - - - -
- -
-
-
- - -
- ); - }; -} - -function getAppFor(model: SceneObject) { - return sceneGraph.getAncestor(model, DataTrailsApp); -} - -function getStyles(theme: GrafanaTheme2) { - return { - container: css({ - display: 'flex', - alignItems: 'center', - marginTop: '84px', - flexDirection: 'column', - height: '100%', - boxSizing: 'border-box', // Ensure padding doesn't cause overflow - }), - homepageBox: css({ - backgroundColor: theme.colors.background.secondary, - width: '904px', - padding: '80px 32px', - boxSizing: 'border-box', // Ensure padding doesn't cause overflow - flexShrink: 0, - }), - startButton: css({ - fontWeight: theme.typography.fontWeightLight, - }), - gap24: css({ - marginTop: theme.spacing(2), // Adds a 24px gap since there is already a 8px gap from the button - }), - }; -} diff --git a/public/app/features/trails/DataTrailsPage.tsx b/public/app/features/trails/DataTrailsPage.tsx deleted file mode 100644 index c45cf46ba88..00000000000 --- a/public/app/features/trails/DataTrailsPage.tsx +++ /dev/null @@ -1,9 +0,0 @@ -// Libraries -import { getDataTrailsApp } from './DataTrailsApp'; - -export function DataTrailsPage() { - const app = getDataTrailsApp(); - return ; -} - -export default DataTrailsPage; diff --git a/public/app/features/trails/DataTrailsRecentMetrics.test.tsx b/public/app/features/trails/DataTrailsRecentMetrics.test.tsx deleted file mode 100644 index 4c3d4560f60..00000000000 --- a/public/app/features/trails/DataTrailsRecentMetrics.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; - -import { SceneObjectRef } from '@grafana/scenes'; - -import { DataTrail } from './DataTrail'; -import { DataTrailsRecentMetrics } from './DataTrailsRecentMetrics'; -import { getTrailStore } from './TrailStore/TrailStore'; - -jest.mock('./TrailStore/TrailStore', () => ({ - getTrailStore: jest.fn(), -})); - -const onSelect = jest.fn(); - -describe('DataTrailsRecentMetrics', () => { - beforeEach(() => { - onSelect.mockClear(); - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [], - recent: [], - })); - }); - - it('renders the recent metrics header if there is at least one recent metric', () => { - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [], - recent: [ - { - resolve: () => ({ state: { key: '1' } }), - }, - ], - })); - render(); - expect(screen.getByText('Or view a recent exploration')).toBeInTheDocument(); - }); - - it('does not show the "Show more" button if there are 3 or fewer recent metrics', () => { - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [], - recent: [ - { - resolve: () => ({ state: { key: '1' } }), - }, - { - resolve: () => ({ state: { key: '2' } }), - }, - { - resolve: () => ({ state: { key: '3' } }), - }, - ], - })); - render(); - expect(screen.queryByText('Show more')).not.toBeInTheDocument(); - }); - - it('shows the "Show more" button if there are more than 3 recent metrics', () => { - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [], - recent: [ - { - resolve: () => ({ state: { key: '1' } }), - }, - { - resolve: () => ({ state: { key: '2' } }), - }, - { - resolve: () => ({ state: { key: '3' } }), - }, - { - resolve: () => ({ state: { key: '4' } }), - }, - ], - })); - render(); - expect(screen.getByText('Show more')).toBeInTheDocument(); - }); - - it('toggles between "Show more" and "Show less" when the button is clicked', () => { - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [], - recent: [ - { - resolve: () => ({ state: { key: '1' } }), - }, - { - resolve: () => ({ state: { key: '2' } }), - }, - { - resolve: () => ({ state: { key: '3' } }), - }, - { - resolve: () => ({ state: { key: '4' } }), - }, - ], - })); - render(); - const button = screen.getByText('Show more'); - fireEvent.click(button); - expect(screen.getByText('Show less')).toBeInTheDocument(); - fireEvent.click(button); - expect(screen.getByText('Show more')).toBeInTheDocument(); - }); - - it('selecting a recent exploration card takes you to the metric', () => { - const trail = new DataTrail({ key: '1', metric: 'select me' }); - const trailWithResolveMethod = new SceneObjectRef(trail); - (getTrailStore as jest.Mock).mockImplementation(() => ({ - bookmarks: [], - recent: [trailWithResolveMethod], - })); - render(); - fireEvent.click(screen.getByText('select me')); - expect(onSelect).toHaveBeenCalledWith(trail); - }); -}); diff --git a/public/app/features/trails/DataTrailsRecentMetrics.tsx b/public/app/features/trails/DataTrailsRecentMetrics.tsx deleted file mode 100644 index 921edcbd92e..00000000000 --- a/public/app/features/trails/DataTrailsRecentMetrics.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { css } from '@emotion/css'; -import { useState } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Button, useStyles2, useTheme2 } from '@grafana/ui'; -import { Trans } from 'app/core/internationalization'; - -import { DataTrail } from './DataTrail'; -import { DataTrailCard } from './DataTrailCard'; -import { getTrailStore } from './TrailStore/TrailStore'; - -type Props = { onSelect: (trail: DataTrail) => void }; - -export function DataTrailsRecentMetrics({ onSelect }: Props) { - const styles = useStyles2(getStyles); - const recentMetrics = getTrailStore().recent; - const theme = useTheme2(); - - const [showAll, setShowAll] = useState(false); - const handleToggleShow = () => { - setShowAll(!showAll); - }; - - if (recentMetrics.length === 0) { - return null; - } - - return ( - <> -
-
- Or view a recent exploration -
-
-
- {getTrailStore() - .recent.slice(0, showAll ? recentMetrics.length : 3) - .map((trail, index) => { - const resolvedTrail = trail.resolve(); - return ( - onSelect(resolvedTrail)} - /> - ); - })} -
- {recentMetrics.length > 3 && ( - - )} - - ); -} - -function getStyles(theme: GrafanaTheme2) { - return { - recentExplorationHeader: css({ - marginTop: theme.spacing(6), - marginBottom: theme.spacing(3), - }), - header: css({ - color: theme.colors.text.primary, - textAlign: 'center', - fontSize: '18px', - fontWeight: '400', - letterSpacing: '0.045px', - }), - trailList: css({ - display: 'grid', - gridTemplateColumns: 'repeat(3, 1fr)', - gap: `${theme.spacing(4)}`, - alignItems: 'stretch', - justifyItems: 'center', - }), - bottomGap24: css({ - marginBottom: theme.spacing(3), - }), - }; -} diff --git a/public/app/features/trails/Integrations/DataTrailEmbedded.tsx b/public/app/features/trails/Integrations/DataTrailEmbedded.tsx deleted file mode 100644 index fa6ee7c7a5e..00000000000 --- a/public/app/features/trails/Integrations/DataTrailEmbedded.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { AdHocVariableFilter } from '@grafana/data'; -import { - SceneComponentProps, - SceneObjectBase, - SceneObjectState, - SceneTimeRange, - SceneTimeRangeState, -} from '@grafana/scenes'; - -import { DataTrail } from '../DataTrail'; - -export interface DataTrailEmbeddedState extends SceneObjectState { - timeRangeState: SceneTimeRangeState; - metric?: string; - filters?: AdHocVariableFilter[]; - dataSourceUid?: string; -} - -export class DataTrailEmbedded extends SceneObjectBase { - static Component = DataTrailEmbeddedRenderer; - - public trail: DataTrail; - - constructor(state: DataTrailEmbeddedState) { - super(state); - this.trail = buildDataTrailFromState(state); - } -} - -function DataTrailEmbeddedRenderer({ model }: SceneComponentProps) { - return ; -} - -function buildDataTrailFromState({ metric, filters, dataSourceUid, timeRangeState }: DataTrailEmbeddedState) { - return new DataTrail({ - $timeRange: new SceneTimeRange(timeRangeState), - metric, - initialDS: dataSourceUid, - initialFilters: filters, - embedded: true, - }); -} diff --git a/public/app/features/trails/Integrations/SceneDrawer.tsx b/public/app/features/trails/Integrations/SceneDrawer.tsx deleted file mode 100644 index caaad210868..00000000000 --- a/public/app/features/trails/Integrations/SceneDrawer.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { css } from '@emotion/css'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase, SceneObject, SceneObjectState } from '@grafana/scenes'; -import { Drawer, useStyles2 } from '@grafana/ui'; -import appEvents from 'app/core/app_events'; -import { ShowModalReactEvent } from 'app/types/events'; - -export type SceneDrawerProps = { - scene: SceneObject; - title: string; - onDismiss: () => void; -}; - -export function SceneDrawer(props: SceneDrawerProps) { - const { scene, title, onDismiss } = props; - const styles = useStyles2(getStyles); - - return ( - -
- -
-
- ); -} - -interface SceneDrawerAsSceneState extends SceneObjectState, SceneDrawerProps {} - -export class SceneDrawerAsScene extends SceneObjectBase { - constructor(state: SceneDrawerProps) { - super(state); - } - - static Component({ model }: SceneComponentProps) { - const state = model.useState(); - - return ; - } -} - -export function launchSceneDrawerInGlobalModal(props: Omit) { - const payload = { - component: SceneDrawer, - props, - }; - - appEvents.publish(new ShowModalReactEvent(payload)); -} - -function getStyles(theme: GrafanaTheme2) { - return { - drawerInnerWrapper: css({ - display: 'flex', - padding: theme.spacing(2), - background: theme.isDark ? theme.colors.background.canvas : theme.colors.background.primary, - position: 'absolute', - left: 0, - right: 0, - top: 0, - }), - }; -} diff --git a/public/app/features/trails/Integrations/dashboardIntegration.ts b/public/app/features/trails/Integrations/dashboardIntegration.ts deleted file mode 100644 index 1eaa0e3dff3..00000000000 --- a/public/app/features/trails/Integrations/dashboardIntegration.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { DataSourceApi, PanelMenuItem } from '@grafana/data'; -import { PromQuery } from '@grafana/prometheus'; -import { getDataSourceSrv } from '@grafana/runtime'; -import { SceneTimeRangeState, VizPanel } from '@grafana/scenes'; -import { DataQuery, DataSourceRef } from '@grafana/schema'; -import { getQueryRunnerFor } from 'app/features/dashboard-scene/utils/utils'; - -import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; -import { MetricScene } from '../MetricScene'; -import { reportExploreMetrics } from '../interactions'; - -import { DataTrailEmbedded, DataTrailEmbeddedState } from './DataTrailEmbedded'; -import { SceneDrawerAsScene } from './SceneDrawer'; -import { getQueryMetrics, QueryMetric } from './getQueryMetrics'; -import { createAdHocFilters, getQueryMetricLabel, getTimeRangeStateFromDashboard } from './utils'; - -export async function addDataTrailPanelAction(dashboard: DashboardScene, panel: VizPanel, items: PanelMenuItem[]) { - if (panel.state.pluginId !== 'timeseries') { - return; - } - - const queryRunner = getQueryRunnerFor(panel); - if (queryRunner == null) { - return; - } - - const { queries, datasource, data } = queryRunner.state; - - if (datasource == null) { - return; - } - - if ( - datasource.type !== 'prometheus' && - datasource.type !== 'grafana-amazonprometheus-datasource' && - datasource.type !== 'grafana-azureprometheus-datasource' - ) { - return; - } - - let dataSourceApi: DataSourceApi | undefined; - - try { - dataSourceApi = await getDataSourceSrv().get(datasource); - } catch (e) { - return; - } - - if (dataSourceApi.interpolateVariablesInQueries == null) { - return; - } - - const interpolated = dataSourceApi - .interpolateVariablesInQueries(queries, { __sceneObject: { value: panel } }, data?.request?.filters) - .filter(isPromQuery); - - const queryMetrics = getQueryMetrics(interpolated.map((q) => q.expr)); - - const subMenu: PanelMenuItem[] = queryMetrics.map((item) => { - return { - text: getQueryMetricLabel(item), - onClick: createClickHandler(item, dashboard, dataSourceApi), - }; - }); - - if (subMenu.length > 0) { - items.push({ - text: 'Metrics drilldown', - iconClassName: 'code-branch', - subMenu: getUnique(subMenu), - }); - } -} - -function getUnique(items: T[]) { - const uniqueMenuTexts = new Set(); - - function isUnique({ text }: { text: string }) { - const before = uniqueMenuTexts.size; - uniqueMenuTexts.add(text); - const after = uniqueMenuTexts.size; - return after > before; - } - - return items.filter(isUnique); -} - -function getEmbeddedTrailsState( - { metric, labelFilters, query }: QueryMetric, - timeRangeState: SceneTimeRangeState, - dataSourceUid: string | undefined -) { - const state: DataTrailEmbeddedState = { - metric, - filters: createAdHocFilters(labelFilters), - dataSourceUid, - timeRangeState, - }; - - return state; -} - -function createCommonEmbeddedTrailStateProps(item: QueryMetric, dashboard: DashboardScene, ds: DataSourceRef) { - const timeRangeState = getTimeRangeStateFromDashboard(dashboard); - const trailState = getEmbeddedTrailsState(item, timeRangeState, ds.uid); - const embeddedTrail: DataTrailEmbedded = new DataTrailEmbedded(trailState); - - embeddedTrail.trail.addActivationHandler(() => { - if (embeddedTrail.trail.state.topScene instanceof MetricScene) { - embeddedTrail.trail.state.topScene.setActionView('breakdown'); - } - }); - - const commonProps = { - scene: embeddedTrail, - title: 'Metrics drilldown', - }; - - return commonProps; -} - -function createClickHandler(item: QueryMetric, dashboard: DashboardScene, ds: DataSourceRef) { - return () => { - const commonProps = createCommonEmbeddedTrailStateProps(item, dashboard, ds); - const drawerScene = new SceneDrawerAsScene({ - ...commonProps, - onDismiss: () => dashboard.closeModal(), - }); - reportExploreMetrics('exploration_started', { cause: 'dashboard_panel' }); - dashboard.showModal(drawerScene); - }; -} - -export function isPromQuery(model: DataQuery): model is PromQuery { - return 'expr' in model; -} diff --git a/public/app/features/trails/Integrations/getQueryMetrics.ts b/public/app/features/trails/Integrations/getQueryMetrics.ts deleted file mode 100644 index 784808ca16f..00000000000 --- a/public/app/features/trails/Integrations/getQueryMetrics.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { buildVisualQueryFromString, QueryBuilderLabelFilter } from '@grafana/prometheus'; - -import { isEquals } from './utils'; - -/** An identified metric and its label for a query */ -export type QueryMetric = { - metric: string; - labelFilters: QueryBuilderLabelFilter[]; - query: string; -}; - -export function getQueryMetrics(queries: string[]) { - const queryMetrics: QueryMetric[] = []; - - queries.forEach((query) => { - const struct = buildVisualQueryFromString(query); - if (struct.errors.length > 0) { - return; - } - - const { metric, labels } = struct.query; - - queryMetrics.push({ metric, labelFilters: labels.filter(isEquals), query }); - struct.query.binaryQueries?.forEach(({ query: { metric, labels } }) => { - queryMetrics.push({ metric, labelFilters: labels.filter(isEquals), query }); - }); - }); - - return queryMetrics; -} diff --git a/public/app/features/trails/Integrations/logs/base.ts b/public/app/features/trails/Integrations/logs/base.ts deleted file mode 100644 index 7fd2a84a00f..00000000000 --- a/public/app/features/trails/Integrations/logs/base.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { DataSourceSettings } from '@grafana/data'; - -export type FoundLokiDataSource = Pick; - -/** - * Defines the interface for connecting metrics and their related logs. - * Implementations should provide methods for retrieving Loki data sources associated - * with a metric, and creating a Loki query expression for a given metric and data source. - * - * By using this interface, the `RelatedLogsScene` can orchestrate - * the retrieval of logs without needing to know the specifics of how we're - * associating logs with a given metric. - */ -export interface MetricsLogsConnector { - /** - * The name of the connector - */ - name: string; - - /** - * Retrieves the Loki data sources associated with the specified metric. - */ - getDataSources(selectedMetric: string): Promise; - - /** - * Constructs a Loki query expression for the specified metric and data source. - */ - getLokiQueryExpr(selectedMetric: string, datasourceUid: string): string; -} - -export function createMetricsLogsConnector(connector: T): T { - return connector; -} diff --git a/public/app/features/trails/Integrations/logs/labelsCrossReference.test.ts b/public/app/features/trails/Integrations/logs/labelsCrossReference.test.ts deleted file mode 100644 index 299d9c81d35..00000000000 --- a/public/app/features/trails/Integrations/logs/labelsCrossReference.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { type AdHocVariableFilter } from '@grafana/data'; -import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes'; - -import { DataTrail } from '../../DataTrail'; -import { RelatedLogsScene } from '../../RelatedLogs/RelatedLogsScene'; -import { VAR_FILTERS } from '../../shared'; -import * as utils from '../../utils'; - -import { createLabelsCrossReferenceConnector } from './labelsCrossReference'; - -// Create multiple mock Loki datasources with different behaviors -const mockLokiDS1 = { - uid: 'loki1', - name: 'Loki Production', - getTagKeys: jest.fn(), - getTagValues: jest.fn(), -}; - -const mockLokiDS2 = { - uid: 'loki2', - name: 'Loki Staging', - getTagKeys: jest.fn(), - getTagValues: jest.fn(), -}; - -const mockLokiDS3 = { - uid: 'loki3', - name: 'Loki Development', - getTagKeys: jest.fn(), - getTagValues: jest.fn(), -}; - -function setVariables(variables: AdHocVariableFilter[] | null) { - sceneGraphSpy.mockReturnValue(variables ? createAdHocVariableStub(variables) : null); -} - -const createAdHocVariableStub = (filters: AdHocVariableFilter[]) => { - return { - __typename: 'AdHocFiltersVariable', - state: { - name: VAR_FILTERS, - type: 'adhoc', - filters, - }, - } as unknown as AdHocFiltersVariable; -}; - -const filtersStub: AdHocVariableFilter[] = [ - { key: 'environment', operator: '=', value: 'production' }, - { key: 'app', operator: '=', value: 'frontend' }, -]; - -const mockDatasources = [mockLokiDS1, mockLokiDS2, mockLokiDS3]; -const getListSpy = jest.fn().mockReturnValue(mockDatasources); -const getSpy = jest.fn().mockImplementation(async (uid: string) => { - const ds = mockDatasources.find((ds) => ds.uid === uid); - if (!ds) { - throw new Error(`Datasource with uid ${uid} not found`); - } - return ds; -}); - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getDataSourceSrv: () => ({ - getList: getListSpy, - get: getSpy, - }), - getTemplateSrv: () => ({ - getAdhocFilters: jest.fn(), - }), - getBackendSrv: () => ({ - get: jest.fn().mockResolvedValue({ status: 'OK' }), // Mock successful health checks - }), -})); - -const getTrailForSpy = jest.spyOn(utils, 'getTrailFor'); -const sceneGraphSpy = jest.spyOn(sceneGraph, 'lookupVariable'); - -const mockScene = { - state: {}, - useState: jest.fn(), -} as unknown as RelatedLogsScene; - -describe('LabelsCrossReferenceConnector', () => { - beforeEach(() => { - getListSpy.mockClear(); - sceneGraphSpy.mockClear(); - getTrailForSpy.mockReturnValue(new DataTrail({})); - [mockLokiDS1, mockLokiDS2, mockLokiDS3].forEach((mockLokiDs) => { - mockLokiDs.getTagKeys.mockClear(); - mockLokiDs.getTagValues.mockClear(); - }); - }); - - describe('getDataSources', () => { - it('should find multiple Loki data sources with matching labels', async () => { - // DS1: Has all required labels and values - mockLokiDS1.getTagKeys.mockResolvedValue([{ text: 'environment' }, { text: 'app' }]); - mockLokiDS1.getTagValues.mockResolvedValue([{ text: 'production' }, { text: 'frontend' }]); - - // DS2: Has labels but missing values - mockLokiDS2.getTagKeys.mockResolvedValue([{ text: 'environment' }, { text: 'app' }]); - mockLokiDS2.getTagValues.mockResolvedValue([ - { text: 'staging' }, // Different value - { text: 'frontend' }, - ]); - - // DS3: Has all required labels and values - mockLokiDS3.getTagKeys.mockResolvedValue([{ text: 'environment' }, { text: 'app' }]); - mockLokiDS3.getTagValues.mockResolvedValue([{ text: 'production' }, { text: 'frontend' }]); - - setVariables(filtersStub); - - const connector = createLabelsCrossReferenceConnector(mockScene); - const result = await connector.getDataSources(); - - expect(result).toHaveLength(2); - expect(result).toEqual([ - { uid: 'loki1', name: 'Loki Production' }, - { uid: 'loki3', name: 'Loki Development' }, - ]); - - // Verify that getTagKeys was called for all datasources - expect(mockLokiDS1.getTagKeys).toHaveBeenCalled(); - expect(mockLokiDS2.getTagKeys).toHaveBeenCalled(); - expect(mockLokiDS3.getTagKeys).toHaveBeenCalled(); - - // Verify filters were passed correctly - const expectedFilters = [ - { key: 'environment', operator: '=', value: 'production' }, - { key: 'app', operator: '=', value: 'frontend' }, - ]; - - expect(mockLokiDS1.getTagKeys).toHaveBeenCalledWith( - expect.objectContaining({ - filters: expect.arrayContaining(expectedFilters), - }) - ); - }); - - it('should handle mixed availability of label keys across datasources', async () => { - // DS1: Has all required labels - mockLokiDS1.getTagKeys.mockResolvedValue([{ text: 'environment' }, { text: 'app' }]); - mockLokiDS1.getTagValues.mockResolvedValue([{ text: 'production' }, { text: 'frontend' }]); - - // DS2: Missing some required labels - mockLokiDS2.getTagKeys.mockResolvedValue([ - { text: 'environment' }, // missing 'app' - ]); - - // DS3: Has different set of labels - mockLokiDS3.getTagKeys.mockResolvedValue([{ text: 'region' }, { text: 'cluster' }]); - - setVariables(filtersStub); - - const connector = createLabelsCrossReferenceConnector(mockScene); - const result = await connector.getDataSources(); - - expect(result).toHaveLength(1); - expect(result).toEqual([{ uid: 'loki1', name: 'Loki Production' }]); - - // DS2 and DS3 should not have getTagValues called since they don't have all required labels - expect(mockLokiDS1.getTagValues).toHaveBeenCalled(); - expect(mockLokiDS2.getTagValues).not.toHaveBeenCalled(); - expect(mockLokiDS3.getTagValues).not.toHaveBeenCalled(); - }); - - it('should handle known label name discrepancies across multiple datasources', async () => { - const filtersWithKnownLabels: AdHocVariableFilter[] = [ - { key: 'job', operator: '=', value: 'grafana' }, - { key: 'instance', operator: '=', value: 'instance1' }, - ]; - - // DS1: Has matching labels with known discrepancies - mockLokiDS1.getTagKeys.mockResolvedValue([{ text: 'service_name' }, { text: 'service_instance_id' }]); - mockLokiDS1.getTagValues.mockResolvedValue([{ text: 'grafana' }, { text: 'instance1' }]); - - // DS2: Also has transformed label names - mockLokiDS2.getTagKeys.mockResolvedValue([{ text: 'service_name' }, { text: 'service_instance_id' }]); - mockLokiDS2.getTagValues.mockResolvedValue([{ text: 'grafana' }, { text: 'instance1' }]); - - // DS3: Missing required labels - mockLokiDS3.getTagKeys.mockResolvedValue([ - { text: 'service_name' }, // missing service_instance_id - ]); - - setVariables(filtersWithKnownLabels); - - const connector = createLabelsCrossReferenceConnector(mockScene); - const result = await connector.getDataSources(); - - expect(result).toHaveLength(2); - expect(result).toEqual([ - { uid: 'loki1', name: 'Loki Production' }, - { uid: 'loki2', name: 'Loki Staging' }, - ]); - - // Verify that label name mapping was applied correctly - expect(mockLokiDS1.getTagKeys).toHaveBeenCalledWith( - expect.objectContaining({ - filters: expect.arrayContaining([ - expect.objectContaining({ key: 'service_name' }), - expect.objectContaining({ key: 'service_instance_id' }), - ]), - }) - ); - }); - }); - - // Rest of the tests remain the same... - describe('getLokiQueryExpr', () => { - it('should generate correct Loki query expression from filters', () => { - setVariables(filtersStub); - const connector = createLabelsCrossReferenceConnector(mockScene); - const result = connector.getLokiQueryExpr(); - - expect(result).toBe('{environment="production",app="frontend"}'); - }); - - it('should handle conversion of known label names', () => { - const filtersWithKnownLabels: AdHocVariableFilter[] = [ - { key: 'job', operator: '=', value: 'grafana' }, - { key: 'instance', operator: '=', value: 'instance1' }, - ]; - setVariables(filtersWithKnownLabels); - - const connector = createLabelsCrossReferenceConnector(mockScene); - const result = connector.getLokiQueryExpr(); - - expect(result).toBe('{service_name="grafana",service_instance_id="instance1"}'); - }); - - it('should return empty string when no filters are present', () => { - setVariables([]); - const connector = createLabelsCrossReferenceConnector(mockScene); - const result = connector.getLokiQueryExpr(); - - expect(result).toBe(''); - }); - - it('should handle missing filters variable', () => { - setVariables(null); - const connector = createLabelsCrossReferenceConnector(mockScene); - const result = connector.getLokiQueryExpr(); - - expect(result).toBe(''); - }); - - it('should handle different filter operators', () => { - const filtersWithOperators: AdHocVariableFilter[] = [ - { key: 'environment', operator: '!=', value: 'dev' }, - { key: 'level', operator: '=~', value: 'error|warn' }, - ]; - setVariables(filtersWithOperators); - - const connector = createLabelsCrossReferenceConnector(mockScene); - const result = connector.getLokiQueryExpr(); - - expect(result).toBe('{environment!="dev",level=~"error|warn"}'); - }); - }); -}); diff --git a/public/app/features/trails/Integrations/logs/labelsCrossReference.ts b/public/app/features/trails/Integrations/logs/labelsCrossReference.ts deleted file mode 100644 index 4631d218c1c..00000000000 --- a/public/app/features/trails/Integrations/logs/labelsCrossReference.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { TimeRange, type AdHocVariableFilter } from '@grafana/data'; -import { getDataSourceSrv } from '@grafana/runtime'; -import { sceneGraph } from '@grafana/scenes'; - -import { findHealthyLokiDataSources, RelatedLogsScene } from '../../RelatedLogs/RelatedLogsScene'; -import { VAR_FILTERS } from '../../shared'; -import { getTrailFor, isAdHocVariable } from '../../utils'; - -import { createMetricsLogsConnector, type FoundLokiDataSource } from './base'; - -const knownLabelNameDiscrepancies = { - job: 'service_name', // `service.name` is `job` in Mimir and `service_name` in Loki - instance: 'service_instance_id', // `service.instance.id` is `instance` in Mimir and `service_instance_id` in Loki -} as const; - -function isLabelNameThatShouldBeReplaced(x: string): x is keyof typeof knownLabelNameDiscrepancies { - return x in knownLabelNameDiscrepancies; -} - -function replaceKnownLabelNames(labelName: string): string { - if (isLabelNameThatShouldBeReplaced(labelName)) { - return knownLabelNameDiscrepancies[labelName]; - } - - return labelName; -} - -/** - * Checks if a Loki data source has labels matching the current filters - */ -async function hasMatchingLabels(datasourceUid: string, filters: AdHocVariableFilter[], timeRange?: TimeRange) { - const ds = await getDataSourceSrv().get(datasourceUid); - - // Get all available label keys for this data source - const labelKeys = await ds.getTagKeys?.({ - timeRange, - filters: filters.map(({ key, operator, value }) => ({ - key: replaceKnownLabelNames(key), - operator, - value, - })), - }); - - if (!Array.isArray(labelKeys)) { - return false; - } - - const availableLabels = new Set(labelKeys.map((key) => key.text)); - - // Early return if none of our filter labels exist in this data source - const mappedFilterLabels = filters.map((f) => replaceKnownLabelNames(f.key)); - const hasRequiredLabels = mappedFilterLabels.every((label) => availableLabels.has(label)); - if (!hasRequiredLabels) { - return false; - } - - // Check if each filter's value exists for its label - const results = await Promise.all( - filters.map(async (filter) => { - const lokiLabelName = replaceKnownLabelNames(filter.key); - const values = await ds.getTagValues?.({ - key: lokiLabelName, - timeRange, - filters, - }); - - if (!Array.isArray(values)) { - return false; - } - - return values.some((v) => v.text === filter.value); - }) - ); - - // If any of the filters have no matching values, return false - return results.every(Boolean); -} - -export const createLabelsCrossReferenceConnector = (scene: RelatedLogsScene) => { - return createMetricsLogsConnector({ - name: 'labelsCrossReference', - async getDataSources(): Promise { - const trail = getTrailFor(scene); - const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail); - - if (!isAdHocVariable(filtersVariable) || !filtersVariable.state.filters.length) { - return []; - } - - const filters = filtersVariable.state.filters.map(({ key, operator, value }) => ({ key, operator, value })); - - // Get current time range if available - const timeRange = scene.state.$timeRange?.state.value; - - const lokiDataSources = await findHealthyLokiDataSources(); - const results = await Promise.all( - lokiDataSources.map(async ({ uid, name }) => { - const hasLabels = await hasMatchingLabels(uid, filters, timeRange); - return hasLabels ? { uid, name } : null; - }) - ); - - return results.filter((ds): ds is FoundLokiDataSource => ds !== null); - }, - getLokiQueryExpr(): string { - const trail = getTrailFor(scene); - const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail); - - if (!isAdHocVariable(filtersVariable) || !filtersVariable.state.filters.length) { - return ''; - } - - const labelValuePairs = filtersVariable.state.filters.map( - (filter) => `${replaceKnownLabelNames(filter.key)}${filter.operator}"${filter.value}"` - ); - - return `{${labelValuePairs.join(',')}}`; // e.g. `{environment="dev",region="us-west-1"}` - }, - }); -}; diff --git a/public/app/features/trails/Integrations/logs/lokiRecordingRules.test.ts b/public/app/features/trails/Integrations/logs/lokiRecordingRules.test.ts deleted file mode 100644 index 8d7e5418b3a..00000000000 --- a/public/app/features/trails/Integrations/logs/lokiRecordingRules.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { of } from 'rxjs'; - -import type { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; -import { getMockPlugin } from '@grafana/data/test'; -import * as runtime from '@grafana/runtime'; - -import { MetricsLogsConnector } from './base'; -import { lokiRecordingRulesConnector, type RecordingRuleGroup } from './lokiRecordingRules'; - -const mockLokiDS1: DataSourceInstanceSettings = { - access: 'proxy', - id: 1, - uid: 'loki1', - name: 'Loki Main', - type: 'loki', - url: '', - jsonData: {}, - meta: { - ...getMockPlugin(), - id: 'loki', - }, - readOnly: false, - isDefault: false, - database: '', - withCredentials: false, -}; - -const mockLokiDS2: DataSourceInstanceSettings = { - ...mockLokiDS1, - id: 2, - uid: 'loki2', - name: 'Loki Secondary', -}; - -const mockRuleGroups1: RecordingRuleGroup[] = [ - { - name: 'group1', - rules: [ - { - name: 'metric_a_total', - query: 'sum(rate({app="app-A"} |= "error" [5m]))', - type: 'recording', - }, - { - name: 'metric_b_total', - query: 'sum(rate({app="app-B"} |= "warn" [5m]))', - type: 'recording', - }, - ], - }, -]; - -const mockRuleGroups2: RecordingRuleGroup[] = [ - { - name: 'group2', - rules: [ - { - name: 'metric_a_total', // Intentionally same name as in DS1 - query: 'sum(rate({app="app-C"} |= "error" [5m]))', - type: 'recording', - }, - ], - }, -]; - -// Create spy functions -const getListSpy = jest.fn().mockReturnValue([mockLokiDS1, mockLokiDS2]); -const defaultFetchImpl = (req: Request) => { - if (req.url.includes('loki1')) { - return of({ - data: { data: { groups: mockRuleGroups1 } }, - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - redirected: false, - type: 'basic', - url: req.url, - config: { url: req.url }, - } as runtime.FetchResponse); - } - return of({ - data: { data: { groups: mockRuleGroups2 } }, - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - redirected: false, - type: 'basic', - url: req.url, - config: { url: req.url }, - } as runtime.FetchResponse); -}; -const fetchSpy = jest.fn().mockImplementation(defaultFetchImpl); - -// Mock the entire @grafana/runtime module -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getDataSourceSrv: () => ({ - getList: getListSpy, - get: jest.fn(), - getInstanceSettings: jest.fn(), - reload: jest.fn(), - }), - getBackendSrv: () => ({ - fetch: fetchSpy, - delete: jest.fn(), - get: jest.fn().mockResolvedValue({ status: 'OK' }), // Mock successful health checks - patch: jest.fn(), - post: jest.fn(), - put: jest.fn(), - request: jest.fn(), - datasourceRequest: jest.fn(), - }), -})); - -describe('LokiRecordingRulesConnector', () => { - let consoleSpy: jest.SpyInstance; - - beforeEach(() => { - getListSpy.mockClear(); - fetchSpy.mockClear(); - fetchSpy.mockImplementation(defaultFetchImpl); - consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - describe('getDataSources', () => { - it('should find all data sources containing the metric', async () => { - const connector = lokiRecordingRulesConnector; - const result = await connector.getDataSources('metric_a_total'); - - expect(result).toHaveLength(2); - expect(result).toContainEqual({ name: 'Loki Main', uid: 'loki1' }); - expect(result).toContainEqual({ name: 'Loki Secondary', uid: 'loki2' }); - - // Verify underlying calls - expect(getListSpy).toHaveBeenCalledWith({ logs: true, type: 'loki', filter: expect.any(Function) }); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it('should handle non-existent metrics', async () => { - const connector = lokiRecordingRulesConnector; - const result = await connector.getDataSources('non_existent_metric'); - - expect(result).toHaveLength(0); - }); - - it('should handle datasource fetch errors gracefully', async () => { - // Make the second datasource fail - fetchSpy.mockImplementation((req) => { - if (req.url.includes('loki1')) { - return of({ - data: { data: { groups: mockRuleGroups1 } }, - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - redirected: false, - type: 'basic', - url: req.url, - config: { url: req.url }, - } as runtime.FetchResponse); - } - throw new Error('Failed to fetch'); - }); - - const connector = lokiRecordingRulesConnector; - const result = await connector.getDataSources('metric_a_total'); - - // Should still get results from the working datasource - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ name: 'Loki Main', uid: 'loki1' }); - expect(consoleSpy).toHaveBeenCalled(); - }); - }); - - describe('getLokiQueryExpr', () => { - let connector: MetricsLogsConnector; - - beforeEach(async () => { - connector = lokiRecordingRulesConnector; - // Populate the rules first - await connector.getDataSources('metric_a_total'); - }); - - it('should return correct Loki query for existing metric', () => { - const result = connector.getLokiQueryExpr('metric_a_total', 'loki1'); - expect(result).toBe('{app="app-A"} |= "error"'); - }); - - it('should return empty string for non-existent metric', () => { - const result = connector.getLokiQueryExpr('non_existent_metric', 'loki1'); - expect(result).toBe(''); - }); - - it('should handle multiple occurrences of the same metric name', () => { - const query1 = connector.getLokiQueryExpr('metric_a_total', 'loki1'); - const query2 = connector.getLokiQueryExpr('metric_a_total', 'loki2'); - - expect(query1).toBe('{app="app-A"} |= "error"'); - expect(query2).toBe('{app="app-C"} |= "error"'); - }); - - it('should handle rules with hasMultipleOccurrences flag', () => { - const query = connector.getLokiQueryExpr('metric_a_total', 'loki1'); - expect(query).toBe('{app="app-A"} |= "error"'); - }); - }); -}); diff --git a/public/app/features/trails/Integrations/logs/lokiRecordingRules.ts b/public/app/features/trails/Integrations/logs/lokiRecordingRules.ts deleted file mode 100644 index 15c10f64042..00000000000 --- a/public/app/features/trails/Integrations/logs/lokiRecordingRules.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { lastValueFrom } from 'rxjs'; - -import type { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; -import { getBackendSrv, type BackendSrvRequest, type FetchResponse } from '@grafana/runtime'; -import { getLogQueryFromMetricsQuery } from 'app/plugins/datasource/loki/queryUtils'; - -import { findHealthyLokiDataSources } from '../../RelatedLogs/RelatedLogsScene'; - -import { createMetricsLogsConnector, type FoundLokiDataSource } from './base'; - -export interface RecordingRuleGroup { - name: string; - rules: RecordingRule[]; -} - -export interface RecordingRule { - name: string; - query: string; - type: 'recording' | 'alerting' | string; - labels?: Record; -} - -export interface ExtractedRecordingRule extends RecordingRule { - datasource: FoundLokiDataSource; - hasMultipleOccurrences?: boolean; -} - -export interface ExtractedRecordingRules { - [dataSourceUID: string]: ExtractedRecordingRule[]; -} - -/** - * Fetch Loki recording rule groups from the specified datasource. - * - * @param datasourceSettings - The settings of the datasource instance. - * @returns A promise that resolves to an array of recording rule groups. - */ -async function fetchRecordingRuleGroups(datasourceSettings: DataSourceInstanceSettings) { - const recordingRuleUrl = `api/prometheus/${datasourceSettings.uid}/api/v1/rules`; - const recordingRules: BackendSrvRequest = { url: recordingRuleUrl, showErrorAlert: false, showSuccessAlert: false }; - const res = await lastValueFrom< - FetchResponse<{ - data: { groups: RecordingRuleGroup[] }; - }> - >(getBackendSrv().fetch(recordingRules)); - - if (!res.ok) { - console.warn(`Failed to fetch recording rules from Loki data source: ${datasourceSettings.name}`); - return []; - } - - return res.data.data.groups; -} - -/** - * Extract recording rules from the provided rule groups and associate them with the given data source. - * - * @param ruleGroups - An array of recording rule groups to extract rules from. - * @param ds - The data source instance settings to associate with the extracted rules. - * @returns An array of extracted recording rules, each associated with the provided data source. - */ -export function extractRecordingRulesFromRuleGroups( - ruleGroups: RecordingRuleGroup[], - ds: DataSourceInstanceSettings -): ExtractedRecordingRule[] { - if (ruleGroups.length === 0) { - return []; - } - - // We only want to return the first matching rule when there are multiple rules with same name - const extractedRules = new Map(); - ruleGroups.forEach((rg) => { - rg.rules - .filter((r) => r.type === 'recording') - .forEach(({ type, name, query }) => { - const isExist = extractedRules.has(name); - if (isExist) { - // We already have the rule. - const existingRule = extractedRules.get(name); - if (existingRule) { - existingRule.hasMultipleOccurrences = true; - extractedRules.set(name, existingRule); - } - } else { - extractedRules.set(name, { - type, - name, - query, - datasource: { - name: ds.name, - uid: ds.uid, - }, - hasMultipleOccurrences: false, - }); - } - }); - }); - - return Array.from(extractedRules.values()); -} - -/** - * Retrieve an array of Loki data sources that contain recording rules with the specified metric name. - * - * @param metricName - The name of the metric to search for within the recording rules. - * @param extractedRecordingRules - An object containing extracted recording rules, where each key is a string and the value is an array of recording rules. - * @returns An array of `FoundLokiDataSource` objects that contain recording rules with the specified metric name. - */ -export function getDataSourcesWithRecordingRulesContainingMetric( - metricName: string, - extractedRecordingRules: ExtractedRecordingRules -): FoundLokiDataSource[] { - const foundLokiDataSources: FoundLokiDataSource[] = []; - Object.values(extractedRecordingRules).forEach((recRules) => { - recRules - .filter((rr) => rr.name === metricName) - .forEach((rr) => { - foundLokiDataSources.push(rr.datasource); - }); - }); - - return foundLokiDataSources; -} - -/** - * Generate a Loki query string for a related metric based on the provided metric name, data source ID, - * and extracted recording rules. - * - * @param metricName - The name of the metric for which to generate the Loki query. - * @param dataSourceUid - The UID of the data source containing the recording rules. - * @param extractedRecordingRules - An object containing recording rules, indexed by data source UID. - * @returns The generated Loki query string, or an empty string if the data source UID or metric name is not found. - */ -export function getLokiQueryForRelatedMetric( - metricName: string, - dataSourceUid: string, - extractedRecordingRules: ExtractedRecordingRules -): string { - if (!dataSourceUid || !extractedRecordingRules[dataSourceUid]) { - return ''; - } - const targetRule = extractedRecordingRules[dataSourceUid].find((rule) => rule.name === metricName); - if (!targetRule) { - return ''; - } - const lokiQuery = getLogQueryFromMetricsQuery(targetRule.query); - - return lokiQuery; -} - -/** - * Fetch and extract Loki recording rules from all Loki data sources. - * - * @returns {Promise} A promise that resolves to an object containing - * the extracted recording rules, keyed by data source UID. - * - * @throws Will log an error to the console if fetching or extracting rules fails for any data source. - */ -export async function fetchAndExtractLokiRecordingRules() { - const lokiDataSources = await findHealthyLokiDataSources(); - const extractedRecordingRules: ExtractedRecordingRules = {}; - await Promise.all( - lokiDataSources.map(async (dataSource) => { - try { - const ruleGroups: RecordingRuleGroup[] = await fetchRecordingRuleGroups(dataSource); - const extractedRules = extractRecordingRulesFromRuleGroups(ruleGroups, dataSource); - extractedRecordingRules[dataSource.uid] = extractedRules; - } catch (err) { - console.warn(err); - } - }) - ); - - return extractedRecordingRules; -} - -const createLokiRecordingRulesConnector = () => { - let lokiRecordingRules: ExtractedRecordingRules = {}; - - return createMetricsLogsConnector({ - name: 'lokiRecordingRules', - async getDataSources(selectedMetric: string): Promise { - lokiRecordingRules = await fetchAndExtractLokiRecordingRules(); - const lokiDataSources = getDataSourcesWithRecordingRulesContainingMetric(selectedMetric, lokiRecordingRules); - - return lokiDataSources; - }, - - getLokiQueryExpr(selectedMetric: string, datasourceUid: string): string { - return getLokiQueryForRelatedMetric(selectedMetric, datasourceUid, lokiRecordingRules); - }, - }); -}; - -export const lokiRecordingRulesConnector = createLokiRecordingRulesConnector(); diff --git a/public/app/features/trails/Integrations/utils.ts b/public/app/features/trails/Integrations/utils.ts deleted file mode 100644 index 8e8af363e1a..00000000000 --- a/public/app/features/trails/Integrations/utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { QueryBuilderLabelFilter } from '@grafana/prometheus'; -import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; - -import { QueryMetric } from './getQueryMetrics'; // We only support label filters with the '=' operator - -// We only support label filters with the '=' operator -export function isEquals(labelFilter: QueryBuilderLabelFilter) { - return labelFilter.op === '='; -} - -export function getTimeRangeStateFromDashboard(dashboard: DashboardScene) { - return dashboard.state.$timeRange!.state; -} - -export function getQueryMetricLabel({ metric, labelFilters }: QueryMetric) { - // Don't show the filter unless there is more than one entry - if (labelFilters.length === 0) { - return metric; - } - - const filter = `{${labelFilters.map(({ label, op, value }) => `${label}${op}"${value}"`)}}`; - return `${metric}${filter}`; -} - -export function createAdHocFilters(labels: QueryBuilderLabelFilter[]) { - return labels?.map((label) => ({ key: label.label, value: label.value, operator: label.op })); -} diff --git a/public/app/features/trails/Menu/PanelMenu.tsx b/public/app/features/trails/Menu/PanelMenu.tsx deleted file mode 100644 index 5cfe5ed7a8b..00000000000 --- a/public/app/features/trails/Menu/PanelMenu.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { DataFrame, PanelMenuItem } from '@grafana/data'; -import { isPluginExtensionLink } from '@grafana/runtime'; -import { - SceneComponentProps, - sceneGraph, - SceneObject, - SceneObjectBase, - SceneObjectState, - VizPanel, - VizPanelMenu, -} from '@grafana/scenes'; -import { getExploreUrl } from 'app/core/utils/explore'; -import { getQueryRunnerFor } from 'app/features/dashboard-scene/utils/utils'; -import { createPluginExtensionsGetter } from 'app/features/plugins/extensions/getPluginExtensions'; -import { pluginExtensionRegistries } from 'app/features/plugins/extensions/registry/setup'; -import { type GetPluginExtensions } from 'app/features/plugins/extensions/types'; - -import { AddToExplorationButton, extensionPointId } from '../MetricSelect/AddToExplorationsButton'; -import { getDataSource, getTrailFor } from '../utils'; - -const ADD_TO_INVESTIGATION_MENU_TEXT = 'Add to investigation'; -const ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT = 'investigations_divider'; // Text won't be visible -const ADD_TO_INVESTIGATION_MENU_GROUP_TEXT = 'Investigations'; - -interface PanelMenuState extends SceneObjectState { - body?: VizPanelMenu; - frame?: DataFrame; - labelName?: string; - fieldName?: string; - addExplorationsLink?: boolean; - explorationsButton?: AddToExplorationButton; -} - -let getPluginExtensions: GetPluginExtensions; - -function setupGetPluginExtensions() { - if (getPluginExtensions) { - return getPluginExtensions; - } - - getPluginExtensions = createPluginExtensionsGetter(pluginExtensionRegistries); - - return getPluginExtensions; -} - -/** - * @todo the VizPanelMenu interface is overly restrictive, doesn't allow any member functions on this class, so everything is currently inlined - */ -export class PanelMenu extends SceneObjectBase implements VizPanelMenu, SceneObject { - constructor(state: Partial) { - super({ ...state, addExplorationsLink: state.addExplorationsLink ?? true }); - this.addActivationHandler(() => { - let exploreUrl: Promise | undefined; - try { - const viz = sceneGraph.getAncestor(this, VizPanel); - const queryRunner = getQueryRunnerFor(viz); - const queries = queryRunner?.state.queries ?? []; - queries.forEach((query) => { - // removing legendFormat to get verbose legend in Explore - delete query.legendFormat; - }); - const trail = getTrailFor(this); - const dsValue = getDataSource(trail); - const timeRange = sceneGraph.getTimeRange(this); - exploreUrl = getExploreUrl({ - queries, - dsRef: { uid: dsValue }, - timeRange: timeRange.state.value, - scopedVars: { __sceneObject: { value: viz } }, - }); - } catch (e) {} - - // Navigation options (all panels) - const items: PanelMenuItem[] = [ - { - text: 'Navigation', - type: 'group', - }, - { - text: 'Explore', - iconClassName: 'compass', - onClick: () => exploreUrl?.then((url) => url && window.open(url, '_blank')), - shortcut: 'p x', - }, - ]; - - this.setState({ - body: new VizPanelMenu({ - items, - }), - }); - - const addToExplorationsButton = new AddToExplorationButton({ - labelName: this.state.labelName, - fieldName: this.state.fieldName, - frame: this.state.frame, - }); - this._subs.add( - addToExplorationsButton?.subscribeToState(() => { - subscribeToAddToExploration(this); - }) - ); - this.setState({ - explorationsButton: addToExplorationsButton, - }); - - if (this.state.addExplorationsLink) { - this.state.explorationsButton?.activate(); - } - }); - - setupGetPluginExtensions(); - } - - addItem(item: PanelMenuItem): void { - if (this.state.body) { - this.state.body.addItem(item); - } - } - - setItems(items: PanelMenuItem[]): void { - if (this.state.body) { - this.state.body.setItems(items); - } - } - - public static Component = ({ model }: SceneComponentProps) => { - const { body } = model.useState(); - - if (body) { - return ; - } - - return <>; - }; -} - -const getInvestigationLink = (addToExplorations: AddToExplorationButton) => { - const links = getPluginExtensions({ - extensionPointId, - context: addToExplorations.state.context, - }).extensions.filter((ext) => isPluginExtensionLink(ext)); - - return links[0]; -}; - -const onAddToInvestigationClick = (event: React.MouseEvent, addToExplorations: AddToExplorationButton) => { - const link = getInvestigationLink(addToExplorations); - if (link && link.onClick) { - link.onClick(event); - } -}; - -function subscribeToAddToExploration(menu: PanelMenu) { - const addToExplorationButton = menu.state.explorationsButton; - if (addToExplorationButton) { - const link = getInvestigationLink(addToExplorationButton); - - const existingMenuItems = menu.state.body?.state.items ?? []; - - const existingAddToExplorationLink = existingMenuItems.find((item) => item.text === ADD_TO_INVESTIGATION_MENU_TEXT); - - if (link) { - if (!existingAddToExplorationLink) { - menu.state.body?.addItem({ - text: ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT, - type: 'divider', - }); - menu.state.body?.addItem({ - text: ADD_TO_INVESTIGATION_MENU_GROUP_TEXT, - type: 'group', - }); - menu.state.body?.addItem({ - text: ADD_TO_INVESTIGATION_MENU_TEXT, - iconClassName: 'plus-square', - onClick: (e) => onAddToInvestigationClick(e, addToExplorationButton), - }); - } else { - if (existingAddToExplorationLink) { - menu.state.body?.setItems( - existingMenuItems.filter( - (item) => - [ - ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT, - ADD_TO_INVESTIGATION_MENU_GROUP_TEXT, - ADD_TO_INVESTIGATION_MENU_TEXT, - ].includes(item.text) === false - ) - ); - } - } - } - } -} diff --git a/public/app/features/trails/MetricGraphScene.tsx b/public/app/features/trails/MetricGraphScene.tsx deleted file mode 100644 index afdcff41341..00000000000 --- a/public/app/features/trails/MetricGraphScene.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { css } from '@emotion/css'; - -import { DashboardCursorSync, GrafanaTheme2 } from '@grafana/data'; -import { useChromeHeaderHeight } from '@grafana/runtime'; -import { - behaviors, - SceneComponentProps, - SceneFlexItem, - SceneFlexLayout, - SceneObject, - SceneObjectBase, - SceneObjectState, -} from '@grafana/scenes'; -import { useStyles2 } from '@grafana/ui'; - -import { MetricActionBar } from './MetricScene'; -import { AutoVizPanel } from './autoQuery/components/AutoVizPanel'; -import { getTrailFor, getTrailSettings } from './utils'; - -export const MAIN_PANEL_MIN_HEIGHT = 280; -export const MAIN_PANEL_MAX_HEIGHT = '40%'; - -export interface MetricGraphSceneState extends SceneObjectState { - topView: SceneFlexLayout; - selectedTab?: SceneObject; -} - -export class MetricGraphScene extends SceneObjectBase { - public constructor(state: Partial) { - super({ - topView: state.topView ?? buildGraphTopView(), - ...state, - }); - } - - public static Component = ({ model }: SceneComponentProps) => { - const { topView, selectedTab } = model.useState(); - const { stickyMainGraph } = getTrailSettings(model).useState(); - const chromeHeaderHeight = useChromeHeaderHeight(); - const trail = getTrailFor(model); - const styles = useStyles2(getStyles, trail.state.embedded ? 0 : (chromeHeaderHeight ?? 0)); - - return ( -
-
- -
- {selectedTab && } -
- ); - }; -} - -function getStyles(theme: GrafanaTheme2, chromeHeaderHeight: number) { - return { - container: css({ - display: 'flex', - flexDirection: 'column', - position: 'relative', - }), - sticky: css({ - display: 'flex', - flexDirection: 'row', - background: theme.isLight ? theme.colors.background.primary : theme.colors.background.canvas, - position: 'sticky', - paddingTop: theme.spacing(1), - marginTop: `-${theme.spacing(1)}`, - top: `${chromeHeaderHeight + 70}px`, - zIndex: 10, - }), - nonSticky: css({ - display: 'flex', - flexDirection: 'row', - }), - }; -} - -function buildGraphTopView() { - return new SceneFlexLayout({ - direction: 'column', - $behaviors: [new behaviors.CursorSync({ key: 'metricCrosshairSync', sync: DashboardCursorSync.Crosshair })], - children: [ - new SceneFlexItem({ - minHeight: MAIN_PANEL_MIN_HEIGHT, - maxHeight: MAIN_PANEL_MAX_HEIGHT, - body: new AutoVizPanel({}), - }), - new SceneFlexItem({ - ySizing: 'content', - body: new MetricActionBar({}), - }), - ], - }); -} diff --git a/public/app/features/trails/MetricScene.tsx b/public/app/features/trails/MetricScene.tsx deleted file mode 100644 index efd1bf85852..00000000000 --- a/public/app/features/trails/MetricScene.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { css } from '@emotion/css'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { - QueryVariable, - SceneComponentProps, - sceneGraph, - SceneObjectBase, - SceneObjectState, - SceneObjectUrlSyncConfig, - SceneObjectUrlValues, - SceneVariableSet, -} from '@grafana/scenes'; -import { Box, Icon, LinkButton, Stack, Tab, TabsBar, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui'; -import { t, Trans } from 'app/core/internationalization'; - -import { getExploreUrl } from '../../core/utils/explore'; - -import { buildRelatedMetricsScene } from './ActionTabs/RelatedMetricsScene'; -import { buildLabelBreakdownActionScene } from './Breakdown/LabelBreakdownScene'; -import { MAIN_PANEL_MAX_HEIGHT, MAIN_PANEL_MIN_HEIGHT, MetricGraphScene } from './MetricGraphScene'; -import { buildRelatedLogsScene } from './RelatedLogs/RelatedLogsScene'; -import { ShareTrailButton } from './ShareTrailButton'; -import { useBookmarkState } from './TrailStore/useBookmarkState'; -import { getAutoQueriesForMetric } from './autoQuery/getAutoQueriesForMetric'; -import { AutoQueryDef, AutoQueryInfo } from './autoQuery/types'; -import { reportExploreMetrics } from './interactions'; -import { - ActionViewDefinition, - ActionViewType, - getVariablesWithMetricConstant, - MakeOptional, - MetricSelectedEvent, - RefreshMetricsEvent, - trailDS, - VAR_GROUP_BY, - VAR_METRIC_EXPR, -} from './shared'; -import { getDataSource, getTrailFor, getUrlForTrail } from './utils'; - -const { exploreMetricsRelatedLogs } = config.featureToggles; - -export interface MetricSceneState extends SceneObjectState { - body: MetricGraphScene; - metric: string; - nativeHistogram?: boolean; - actionView?: string; - - autoQuery: AutoQueryInfo; - queryDef?: AutoQueryDef; -} - -export class MetricScene extends SceneObjectBase { - protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['actionView'] }); - - public constructor(state: MakeOptional) { - const autoQuery = state.autoQuery ?? getAutoQueriesForMetric(state.metric, state.nativeHistogram); - super({ - $variables: state.$variables ?? getVariableSet(state.metric), - body: state.body ?? new MetricGraphScene({}), - autoQuery, - queryDef: state.queryDef ?? autoQuery.main, - ...state, - }); - - this.addActivationHandler(this._onActivate.bind(this)); - } - - private _onActivate() { - if (this.state.actionView === undefined) { - this.setActionView('breakdown'); - } - - if (config.featureToggles.enableScopesInMetricsExplore) { - // Push the scopes change event to the tabs - // The event is not propagated because the tabs are not part of the scene graph - this._subs.add( - this.subscribeToEvent(RefreshMetricsEvent, (event) => { - this.state.body.state.selectedTab?.publishEvent(event); - }) - ); - } - } - - getUrlState() { - return { actionView: this.state.actionView }; - } - - updateFromUrl(values: SceneObjectUrlValues) { - if (typeof values.actionView === 'string') { - if (this.state.actionView !== values.actionView) { - const actionViewDef = actionViewsDefinitions.find((v) => v.value === values.actionView); - if (actionViewDef) { - this.setActionView(actionViewDef.value); - } - } - } else if (values.actionView === null) { - this.setActionView(undefined); - } - } - - public setActionView(actionView?: ActionViewType) { - const { body } = this.state; - const actionViewDef = actionViewsDefinitions.find((v) => v.value === actionView); - - if (actionViewDef && actionViewDef.value !== this.state.actionView) { - // reduce max height for main panel to reduce height flicker - body.state.topView.state.children[0].setState({ maxHeight: MAIN_PANEL_MIN_HEIGHT }); - body.setState({ selectedTab: actionViewDef.getScene() }); - this.setState({ actionView: actionViewDef.value }); - } else { - // restore max height - body.state.topView.state.children[0].setState({ maxHeight: MAIN_PANEL_MAX_HEIGHT }); - body.setState({ selectedTab: undefined }); - this.setState({ actionView: undefined }); - } - } - - static Component = ({ model }: SceneComponentProps) => { - const { body } = model.useState(); - return ; - }; -} - -const actionViewsDefinitions: ActionViewDefinition[] = [ - { displayName: 'Breakdown', value: 'breakdown', getScene: buildLabelBreakdownActionScene }, - { - displayName: 'Related metrics', - value: 'related', - getScene: buildRelatedMetricsScene, - description: 'Relevant metrics based on current label filters', - }, -]; - -if (exploreMetricsRelatedLogs) { - actionViewsDefinitions.push({ - displayName: 'Related logs', - value: 'related_logs', - getScene: buildRelatedLogsScene, - description: 'Relevant logs based on current label filters and time range', - }); -} - -export interface MetricActionBarState extends SceneObjectState {} - -export class MetricActionBar extends SceneObjectBase { - public getLinkToExplore = async () => { - const metricScene = sceneGraph.getAncestor(this, MetricScene); - const trail = getTrailFor(this); - const dsValue = getDataSource(trail); - - const queries = metricScene.state.queryDef?.queries || []; - const timeRange = sceneGraph.getTimeRange(this); - - return getExploreUrl({ - queries, - dsRef: { uid: dsValue }, - timeRange: timeRange.state.value, - scopedVars: { __sceneObject: { value: metricScene } }, - }); - }; - - public openExploreLink = async () => { - reportExploreMetrics('selected_metric_action_clicked', { action: 'open_in_explore' }); - this.getLinkToExplore().then((link) => { - // We use window.open instead of a Link or because we want to compute the explore link when clicking, - // if we precompute it we have to keep track of a lot of dependencies - window.open(link, '_blank'); - }); - }; - - public static Component = ({ model }: SceneComponentProps) => { - const metricScene = sceneGraph.getAncestor(model, MetricScene); - const styles = useStyles2(getStyles); - const trail = getTrailFor(model); - const [isBookmarked, toggleBookmark] = useBookmarkState(trail); - const { actionView } = metricScene.useState(); - - return ( - -
- - { - reportExploreMetrics('selected_metric_action_clicked', { action: 'unselect' }); - trail.publishEvent(new MetricSelectedEvent(undefined)); - }} - > - Select new metric - - - - - ) : ( - - ) - } - tooltip={t('trails.metric-action-bar.tooltip-bookmark', 'Bookmark')} - onClick={toggleBookmark} - /> - {trail.state.embedded && ( - reportExploreMetrics('selected_metric_action_clicked', { action: 'open_from_embedded' })} - > - Open - - )} - -
- - - {actionViewsDefinitions.map((tab, index) => { - const tabRender = ( - { - reportExploreMetrics('metric_action_view_changed', { view: tab.value }); - metricScene.setActionView(tab.value); - }} - /> - ); - - if (tab.description) { - return ( - - {tabRender} - - ); - } - return tabRender; - })} - -
- ); - }; -} - -function getStyles(theme: GrafanaTheme2) { - return { - actions: css({ - [theme.breakpoints.up(theme.breakpoints.values.md)]: { - position: 'absolute', - right: 0, - top: 16, - zIndex: 2, - }, - }), - }; -} - -function getVariableSet(metric: string) { - return new SceneVariableSet({ - variables: [ - ...getVariablesWithMetricConstant(metric), - new QueryVariable({ - name: VAR_GROUP_BY, - label: 'Group by', - datasource: trailDS, - includeAll: true, - defaultToAll: true, - query: { query: `label_names(${VAR_METRIC_EXPR})`, refId: 'A' }, - value: '', - text: '', - }), - ], - }); -} diff --git a/public/app/features/trails/MetricSelect/AddToExplorationsButton.test.tsx b/public/app/features/trails/MetricSelect/AddToExplorationsButton.test.tsx deleted file mode 100644 index 071b97981ec..00000000000 --- a/public/app/features/trails/MetricSelect/AddToExplorationsButton.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import { PluginExtensionTypes } from '@grafana/data'; -import { setPluginLinksHook } from '@grafana/runtime'; - -import { mockPluginLinkExtension } from '../../alerting/unified/mocks'; - -import { AddToExplorationButton, addToExplorationsButtonLabel, explorationsPluginId } from './AddToExplorationsButton'; - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - setPluginExtensionGetter: jest.fn(), - getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), - useChromeHeaderHeight: jest.fn().mockReturnValue(80), - getBackendSrv: () => { - return { - get: jest.fn(), - }; - }, - getDataSourceSrv: () => { - return { - get: jest.fn().mockResolvedValue({}), - getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }), - }; - }, - getAppEvents: () => ({ - publish: jest.fn(), - }), -})); - -describe('AddToExplorationButton', () => { - afterAll(() => { - jest.restoreAllMocks(); - }); - - it("shouldn't render when a plugin extension link isn't provided by the Explorations app ", async () => { - setPluginLinksHook(() => ({ - links: [], - isLoading: false, - })); - const scene = new AddToExplorationButton({}); - render(); - expect(() => screen.getByLabelText(addToExplorationsButtonLabel)).toThrow(); - }); - - it('should render when the Explorations app provides a plugin extension link', async () => { - setPluginLinksHook(() => ({ - links: [ - mockPluginLinkExtension({ - description: addToExplorationsButtonLabel, // this overrides the aria-label - onClick: () => {}, - path: '/a/grafana-explorations-app', - pluginId: explorationsPluginId, - title: 'Explorations', - type: PluginExtensionTypes.link, - }), - ], - isLoading: false, - })); - const scene = new AddToExplorationButton({}); - render(); - const button = screen.getByLabelText(addToExplorationsButtonLabel); - expect(button).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/trails/MetricSelect/AddToExplorationsButton.tsx b/public/app/features/trails/MetricSelect/AddToExplorationsButton.tsx deleted file mode 100644 index bbdd7110530..00000000000 --- a/public/app/features/trails/MetricSelect/AddToExplorationsButton.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { DataFrame, TimeRange } from '@grafana/data'; -import { usePluginLinks } from '@grafana/runtime'; -import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState, SceneQueryRunner } from '@grafana/scenes'; -import { DataQuery, DataSourceRef } from '@grafana/schema'; -import { IconButton } from '@grafana/ui'; - -import MimirLogo from '../../../plugins/datasource/prometheus/img/mimir_logo.svg'; -import { VAR_DATASOURCE_EXPR } from '../shared'; - -export const explorationsPluginId = 'grafana-explorations-app'; -export const extensionPointId = 'grafana-explore-metrics/exploration/v1'; -export const addToExplorationsButtonLabel = 'add panel to exploration'; - -export interface AddToExplorationButtonState extends SceneObjectState { - frame?: DataFrame; - dsUid?: string; - labelName?: string; - fieldName?: string; - context?: ExtensionContext; - - queries: DataQuery[]; -} - -interface ExtensionContext { - timeRange: TimeRange; - queries: DataQuery[]; - datasource: DataSourceRef; - origin: string; - url: string; - type: string; - title: string; - id: string; - logoPath: string; - note?: string; - drillDownLabel?: string; -} - -export class AddToExplorationButton extends SceneObjectBase { - constructor(state: Omit) { - super({ ...state, queries: [] }); - - this.addActivationHandler(this._onActivate.bind(this)); - } - - private _onActivate = () => { - this._subs.add( - this.subscribeToState(() => { - this.getQueries(); - this.getContext(); - }) - ); - - const datasourceUid = sceneGraph.interpolate(this, VAR_DATASOURCE_EXPR); - this.setState({ dsUid: datasourceUid }); - }; - - private readonly getQueries = () => { - const data = sceneGraph.getData(this); - const queryRunner = sceneGraph.findObject(data, isQueryRunner); - - if (isQueryRunner(queryRunner)) { - const filter = this.state.frame ? getFilter(this.state.frame) : null; - const queries = queryRunner.state.queries.map((q) => ({ - ...q, - expr: sceneGraph.interpolate(queryRunner, q.expr), - legendFormat: filter?.name ? `{{ ${filter.name} }}` : sceneGraph.interpolate(queryRunner, q.legendFormat), - })); - - if (JSON.stringify(queries) !== JSON.stringify(this.state.queries)) { - this.setState({ queries }); - } - } - }; - - private readonly getContext = () => { - const { queries, dsUid, labelName, fieldName } = this.state; - const timeRange = sceneGraph.getTimeRange(this); - - if (!timeRange || !queries || !dsUid) { - return; - } - const ctx = { - origin: 'Metrics Drilldown', - type: 'timeseries', - queries, - timeRange: { ...timeRange.state.value }, - datasource: { uid: dsUid }, - url: window.location.href, - id: `${JSON.stringify(queries)}${labelName}${fieldName}`, - title: `${labelName}${fieldName ? ` > ${fieldName}` : ''}`, - logoPath: MimirLogo, - drillDownLabel: fieldName, - }; - if (JSON.stringify(ctx) !== JSON.stringify(this.state.context)) { - this.setState({ context: ctx }); - } - }; - - public static Component = ({ model }: SceneComponentProps) => { - const { context } = model.useState(); - const { links } = usePluginLinks({ extensionPointId, context, limitPerPlugin: 1 }); - const link = links.find((link) => link.pluginId === explorationsPluginId); - - if (!link) { - return null; - } - - return ( - { - if (link.onClick) { - link.onClick(e); - } - }} - /> - ); - }; -} - -const getFilter = (frame: DataFrame) => { - const filterNameAndValueObj = frame.fields[1]?.labels ?? {}; - const keys = Object.keys(filterNameAndValueObj); - if (keys.length !== 1) { - return; - } - const name = keys[0]; - return { name, value: filterNameAndValueObj[name] }; -}; - -function isQueryRunner(o: unknown): o is SceneQueryRunner { - return o instanceof SceneQueryRunner; -} diff --git a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx deleted file mode 100644 index 95e725e5121..00000000000 --- a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx +++ /dev/null @@ -1,723 +0,0 @@ -import { css } from '@emotion/css'; -import { debounce, isEqual } from 'lodash'; -import { SyntheticEvent, useReducer } from 'react'; - -import { AdHocVariableFilter, GrafanaTheme2, RawTimeRange, SelectableValue } from '@grafana/data'; -import { config, isFetchError } from '@grafana/runtime'; -import { - AdHocFiltersVariable, - PanelBuilders, - SceneComponentProps, - SceneCSSGridItem, - SceneCSSGridLayout, - SceneFlexItem, - SceneFlexLayout, - sceneGraph, - SceneObject, - SceneObjectBase, - SceneObjectRef, - SceneObjectState, - SceneObjectStateChangedEvent, - SceneObjectUrlSyncConfig, - SceneObjectUrlValues, - SceneObjectWithUrlSync, - SceneTimeRange, - SceneVariableSet, - VariableDependencyConfig, -} from '@grafana/scenes'; -import { Alert, Badge, Field, Icon, IconButton, InlineSwitch, Input, Select, Tooltip, useStyles2 } from '@grafana/ui'; -import { Trans, t } from 'app/core/internationalization'; - -import { MetricScene } from '../MetricScene'; -import { StatusWrapper } from '../StatusWrapper'; -import { Node, Parser } from '../groop/parser'; -import { getMetricDescription } from '../helpers/MetricDatasourceHelper'; -import { reportExploreMetrics } from '../interactions'; -import { setOtelExperienceToggleState } from '../services/store'; -import { - getVariablesWithMetricConstant, - MetricSelectedEvent, - RefreshMetricsEvent, - VAR_DATASOURCE, - VAR_DATASOURCE_EXPR, - VAR_FILTERS, -} from '../shared'; -import { getFilters, getTrailFor, isSceneTimeRangeState } from '../utils'; - -import { SelectMetricAction } from './SelectMetricAction'; -import { getMetricNames } from './api'; -import { getPreviewPanelFor } from './previewPanel'; -import { sortRelatedMetrics } from './relatedMetrics'; -import { createJSRegExpFromSearchTerms, createPromRegExp, deriveSearchTermsFromInput } from './util'; - -interface MetricPanel { - name: string; - index: number; - itemRef?: SceneObjectRef; - isEmpty?: boolean; - isPanel?: boolean; - loaded?: boolean; -} - -export interface MetricSelectSceneState extends SceneObjectState { - body: SceneFlexLayout | SceneCSSGridLayout; - rootGroup?: Node; - metricPrefix?: string; - metricNames?: string[]; - metricNamesLoading?: boolean; - metricNamesError?: string; - metricNamesWarning?: string; - missingOtelTargets?: boolean; -} - -const ROW_PREVIEW_HEIGHT = '175px'; -const ROW_CARD_HEIGHT = '64px'; -const METRIC_PREFIX_ALL = 'all'; - -const MAX_METRIC_NAMES = 20000; - -const viewByTooltip = - 'View by the metric prefix. A metric prefix is a single word at the beginning of the metric name, relevant to the domain the metric belongs to.'; - -export class MetricSelectScene extends SceneObjectBase implements SceneObjectWithUrlSync { - private previewCache: Record = {}; - private ignoreNextUpdate = false; - private _debounceRefreshMetricNames = debounce(() => this._refreshMetricNames(), 1000); - - constructor(state: Partial) { - super({ - $variables: state.$variables, - metricPrefix: state.metricPrefix ?? METRIC_PREFIX_ALL, - body: - state.body ?? - new SceneCSSGridLayout({ - children: [], - templateColumns: 'repeat(auto-fill, minmax(450px, 1fr))', - autoRows: ROW_PREVIEW_HEIGHT, - isLazy: true, - }), - ...state, - }); - - this.addActivationHandler(this._onActivate.bind(this)); - } - - protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['metricPrefix'] }); - protected _variableDependency = new VariableDependencyConfig(this, { - variableNames: [VAR_DATASOURCE, VAR_FILTERS], - onReferencedVariableValueChanged: () => { - // In all cases, we want to reload the metric names - this._debounceRefreshMetricNames(); - }, - }); - - getUrlState() { - return { metricPrefix: this.state.metricPrefix }; - } - - updateFromUrl(values: SceneObjectUrlValues) { - if (typeof values.metricPrefix === 'string') { - if (this.state.metricPrefix !== values.metricPrefix) { - this.setState({ metricPrefix: values.metricPrefix }); - } - } - } - - private _onActivate() { - if (this.state.body.state.children.length === 0) { - this.buildLayout(); - } else { - // Temp hack when going back to select metric scene and variable updates - this.ignoreNextUpdate = true; - } - - const trail = getTrailFor(this); - - this._subs.add( - trail.subscribeToEvent(MetricSelectedEvent, (event) => { - const { steps, currentStep } = trail.state.history.state; - const prevStep = steps[currentStep].parentIndex; - const previousMetric = steps[prevStep].trailState.metric; - const isRelatedMetricSelector = previousMetric !== undefined; - - if (event.payload !== undefined) { - const metricSearch = getMetricSearch(trail); - const searchTermCount = deriveSearchTermsFromInput(metricSearch).length; - - reportExploreMetrics('metric_selected', { - from: isRelatedMetricSelector ? 'related_metrics' : 'metric_list', - searchTermCount, - }); - } - }) - ); - - this._subs.add( - trail.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => { - if (evt.payload.changedObject instanceof SceneTimeRange) { - const { prevState, newState } = evt.payload; - - if (isSceneTimeRangeState(prevState) && isSceneTimeRangeState(newState)) { - if (prevState.from === newState.from && prevState.to === newState.to) { - return; - } - } - } - }) - ); - - this._subs.add( - trail.subscribeToState(({ metricSearch }, oldState) => { - const oldSearchTerms = deriveSearchTermsFromInput(oldState.metricSearch); - const newSearchTerms = deriveSearchTermsFromInput(metricSearch); - if (!isEqual(oldSearchTerms, newSearchTerms)) { - this._debounceRefreshMetricNames(); - } - }) - ); - - this.subscribeToState((newState, prevState) => { - if (newState.metricNames !== prevState.metricNames) { - this.onMetricNamesChanged(); - } - }); - - this._subs.add( - trail.subscribeToState(({ otelTargets }, oldState) => { - // if the otel targets have changed, get the new list of metrics - if ( - otelTargets?.instances !== oldState.otelTargets?.instances && - otelTargets?.jobs !== oldState.otelTargets?.jobs - ) { - this._debounceRefreshMetricNames(); - } - }) - ); - - this._subs.add( - trail.subscribeToState(() => { - // users will most likely not switch this off but for now, - // update metric names when changing useOtelExperience - this._debounceRefreshMetricNames(); - }) - ); - - this._subs.add( - trail.subscribeToState(() => { - // move showPreviews into the settings - // build layout when toggled - this.buildLayout(); - }) - ); - - if (config.featureToggles.enableScopesInMetricsExplore) { - this._subs.add( - trail.subscribeToEvent(RefreshMetricsEvent, () => { - this._debounceRefreshMetricNames(); - }) - ); - } - - this._debounceRefreshMetricNames(); - } - - private async _refreshMetricNames() { - const trail = getTrailFor(this); - const timeRange: RawTimeRange | undefined = trail.state.$timeRange?.state; - - if (!timeRange) { - return; - } - - const filters: AdHocVariableFilter[] = []; - - const filtersVar = sceneGraph.lookupVariable(VAR_FILTERS, this); - const adhocFilters = filtersVar instanceof AdHocFiltersVariable ? (filtersVar?.state.filters ?? []) : []; - if (adhocFilters.length > 0) { - filters.push(...adhocFilters); - } - - const metricSearchRegex = createPromRegExp(trail.state.metricSearch); - if (metricSearchRegex) { - filters.push({ - key: '__name__', - operator: '=~', - value: metricSearchRegex, - }); - } - - const datasourceUid = sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR); - this.setState({ metricNamesLoading: true, metricNamesError: undefined, metricNamesWarning: undefined }); - - try { - const jobsList = trail.state.useOtelExperience ? (trail.state.otelTargets?.jobs ?? []) : []; - const instancesList = trail.state.useOtelExperience ? (trail.state.otelTargets?.instances ?? []) : []; - - const response = await getMetricNames( - datasourceUid, - timeRange, - sceneGraph.getScopesBridge(this)?.getValue() ?? [], - filters, - jobsList, - instancesList, - MAX_METRIC_NAMES - ); - const searchRegex = createJSRegExpFromSearchTerms(getMetricSearch(this)); - let metricNames = searchRegex - ? response.data.filter((metric) => !searchRegex || searchRegex.test(metric)) - : response.data; - - // use this to generate groups for metric prefix - const filteredMetricNames = metricNames; - - // filter the remaining metrics with the metric prefix - const metricPrefix = this.state.metricPrefix; - if (metricPrefix && metricPrefix !== 'all') { - const prefixRegex = new RegExp(`(^${metricPrefix}.*)`, 'igy'); - metricNames = metricNames.filter((metric) => !prefixRegex || prefixRegex.test(metric)); - } - - let metricNamesWarning = response.limitReached - ? `This feature will only return up to ${MAX_METRIC_NAMES} metric names for performance reasons. ` + - `This limit is being exceeded for the current data source. ` + - `Add search terms or label filters to narrow down the number of metric names returned.` - : undefined; - - // if there are no otel targets for otel resources, there will be no labels - if (trail.state.useOtelExperience && (jobsList.length === 0 || instancesList.length === 0)) { - metricNames = []; - metricNamesWarning = undefined; - } - - let bodyLayout = this.state.body; - - // generate groups based on the search metrics input - let rootGroupNode = await this.generateGroups(filteredMetricNames); - - this.setState({ - metricNames, - rootGroup: rootGroupNode, - body: bodyLayout, - metricNamesLoading: false, - metricNamesWarning, - metricNamesError: response.error, - missingOtelTargets: response.missingOtelTargets, - }); - } catch (err: unknown) { - let error = 'Unknown error'; - if (isFetchError(err)) { - if (err.cancelled) { - error = 'Request cancelled'; - } else if (err.statusText) { - error = err.statusText; - } - } - - this.setState({ metricNames: undefined, metricNamesLoading: false, metricNamesError: error }); - } - } - - private async generateGroups(metricNames: string[] = []) { - const groopParser = new Parser(); - groopParser.config = { - ...groopParser.config, - maxDepth: 2, - minGroupSize: 2, - miscGroupKey: 'misc', - }; - const { root: rootGroupNode } = groopParser.parse(metricNames); - return rootGroupNode; - } - - private onMetricNamesChanged() { - const metricNames = this.state.metricNames || []; - - const nameSet = new Set(metricNames); - - Object.values(this.previewCache).forEach((panel) => { - if (!nameSet.has(panel.name)) { - panel.isEmpty = true; - } - }); - - const trail = getTrailFor(this); - const sortedMetricNames = - trail.state.metric !== undefined ? sortRelatedMetrics(metricNames, trail.state.metric) : metricNames; - const metricsMap: Record = {}; - const metricsLimit = 120; - - // Clear absent metrics from cache - Object.keys(this.previewCache).forEach((metric) => { - if (!nameSet.has(metric)) { - delete this.previewCache[metric]; - } - }); - - for (let index = 0; index < sortedMetricNames.length; index++) { - const metricName = sortedMetricNames[index]; - - if (Object.keys(metricsMap).length > metricsLimit) { - break; - } - - const oldPanel = this.previewCache[metricName]; - - metricsMap[metricName] = oldPanel || { name: metricName, index, loaded: false }; - } - - try { - // If there is a current metric, do not present it - const currentMetric = sceneGraph.getAncestor(this, MetricScene).state.metric; - delete metricsMap[currentMetric]; - } catch (err) { - // There is no current metric - } - - this.previewCache = metricsMap; - this.buildLayout(); - } - - private sortedPreviewMetrics() { - return Object.values(this.previewCache).sort((a, b) => { - if (a.isEmpty && b.isEmpty) { - return a.index - b.index; - } - if (a.isEmpty) { - return 1; - } - if (b.isEmpty) { - return -1; - } - return a.index - b.index; - }); - } - - private async buildLayout() { - const trail = getTrailFor(this); - const showPreviews = trail.state.showPreviews; - // Temp hack when going back to select metric scene and variable updates - if (this.ignoreNextUpdate) { - this.ignoreNextUpdate = false; - return; - } - - const children: SceneFlexItem[] = []; - - const metricsList = this.sortedPreviewMetrics(); - - // Get the current filters to determine the count of them - // Which is required for `getPreviewPanelFor` - const filters = getFilters(this); - const currentFilterCount = filters?.length || 0; - - for (let index = 0; index < metricsList.length; index++) { - const metric = metricsList[index]; - const metadata = await trail.getMetricMetadata(metric.name); - const description = getMetricDescription(metadata); - - if (showPreviews) { - if (metric.itemRef && metric.isPanel) { - children.push(metric.itemRef.resolve()); - continue; - } - // refactor this into the query generator in future - const isNative = trail.isNativeHistogram(metric.name); - const panel = getPreviewPanelFor(metric.name, index, currentFilterCount, description, isNative, true); - - metric.itemRef = panel.getRef(); - metric.isPanel = true; - children.push(panel); - } else { - const panel = new SceneCSSGridItem({ - $variables: new SceneVariableSet({ - variables: getVariablesWithMetricConstant(metric.name), - }), - body: getCardPanelFor(metric.name, description), - }); - metric.itemRef = panel.getRef(); - metric.isPanel = false; - children.push(panel); - } - } - - const rowTemplate = showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT; - - this.state.body.setState({ children, autoRows: rowTemplate }); - } - - public updateMetricPanel = (metric: string, isLoaded?: boolean, isEmpty?: boolean) => { - const metricPanel = this.previewCache[metric]; - if (metricPanel) { - metricPanel.isEmpty = isEmpty; - metricPanel.loaded = isLoaded; - this.previewCache[metric] = metricPanel; - if (this.state.metricPrefix === 'All') { - this.buildLayout(); - } - } - }; - - public onSearchQueryChange = (evt: SyntheticEvent) => { - const metricSearch = evt.currentTarget.value; - const trail = getTrailFor(this); - // Update the variable - trail.setState({ metricSearch }); - }; - - public onPrefixFilterChange = (val: SelectableValue) => { - this.setState({ metricPrefix: val.value }); - this._refreshMetricNames(); - }; - - public reportPrefixFilterInteraction = (isMenuOpen: boolean) => { - const trail = getTrailFor(this); - const { steps, currentStep } = trail.state.history.state; - const previousMetric = steps[currentStep]?.trailState.metric; - const isRelatedMetricSelector = previousMetric !== undefined; - - reportExploreMetrics('prefix_filter_clicked', { - from: isRelatedMetricSelector ? 'related_metrics' : 'metric_list', - action: isMenuOpen ? 'open' : 'close', - }); - }; - - public onToggleOtelExperience = () => { - const trail = getTrailFor(this); - const useOtelExperience = trail.state.useOtelExperience; - // set the startButtonClicked to null as we have gone past the owrkflow this is needed for - let startButtonClicked = false; - let resettingOtel = true; - if (useOtelExperience) { - reportExploreMetrics('otel_experience_toggled', { value: 'off' }); - // if turning off OTel - resettingOtel = false; - trail.resetOtelExperience(); - } else { - reportExploreMetrics('otel_experience_toggled', { value: 'on' }); - } - setOtelExperienceToggleState(!useOtelExperience); - trail.setState({ useOtelExperience: !useOtelExperience, resettingOtel, startButtonClicked }); - }; - - public static Component = ({ model }: SceneComponentProps) => { - const { - body, - metricNames, - metricNamesError, - metricNamesLoading, - metricNamesWarning, - rootGroup, - metricPrefix, - missingOtelTargets, - } = model.useState(); - const { children } = body.useState(); - const trail = getTrailFor(model); - const styles = useStyles2(getStyles); - - const [warningDismissed, dismissWarning] = useReducer(() => true, false); - - const { metricSearch, useOtelExperience, hasOtelResources, isStandardOtel, metric } = trail.useState(); - - const tooStrict = children.length === 0 && metricSearch; - const noMetrics = !metricNamesLoading && metricNames && metricNames.length === 0; - - const isLoading = metricNamesLoading && children.length === 0; - - const unableToRetrieveMetricNames = t( - 'trails.metric-select-scene.unable-to-retrieve-metric-names', - 'Unable to retrieve metric names' - ); - const blockingMessage = isLoading - ? undefined - : missingOtelTargets - ? 'There are no metrics found. Please adjust your filters based on your OTel resource attributes.' - : (noMetrics && 'There are no results found. Try a different time range or a different data source.') || - (tooStrict && 'There are no results found. Try adjusting your search or filters.') || - undefined; - - const metricNamesWarningIcon = metricNamesWarning ? ( - -

{unableToRetrieveMetricNames}

-

{metricNamesWarning}

- - } - > - -
- ) : undefined; - - return ( -
-
- - } - value={metricSearch} - onChange={model.onSearchQueryChange} - suffix={metricNamesWarningIcon} - /> - - - View by - -
- } - className={styles.displayOption} - > -