├── .config ├── .cprc.json ├── .eslintrc ├── .prettierrc.js ├── Dockerfile ├── README.md ├── docker-compose-base.yaml ├── entrypoint.sh ├── jest-setup.js ├── jest.config.js ├── jest │ ├── mocks │ │ └── react-inlinesvg.tsx │ └── utils.js ├── supervisord │ └── supervisord.conf ├── tsconfig.json ├── types │ ├── bundler-rules.d.ts │ ├── custom.d.ts │ └── webpack-plugins.d.ts └── webpack │ ├── BuildModeWebpackPlugin.ts │ ├── constants.ts │ ├── utils.ts │ └── webpack.config.ts ├── .cprc.json ├── .cursor └── rules │ └── promql-conventions.mdc ├── .env.example ├── .eslintrc ├── .github ├── CODEOWNERS ├── pull_request_template.md ├── workflows │ ├── bundle-stats-compare.yml │ ├── bundle-stats-create.yml │ ├── ci.yml │ ├── cp-update.yml │ ├── is-compatible.yml │ ├── playwright-published.yml │ ├── publish-technical-documentation-next.yml │ └── publish.yml └── zizmor.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── CHANGELOG.md ├── DEV_GUIDE.md ├── LICENSE ├── README.md ├── docker-compose.yaml ├── docs ├── Makefile ├── contributing.md ├── docs.mk ├── end-to-end-testing.md ├── make-docs ├── sources │ ├── _index.md │ ├── about-metrics │ │ └── index.md │ ├── drill-down-metrics │ │ └── index.md │ └── get-started │ │ └── index.md └── variables.mk ├── e2e ├── .eslintrc ├── config │ ├── constants.ts │ ├── playwright.config.ci.ts │ ├── playwright.config.common.ts │ └── playwright.config.local.ts ├── docker │ ├── Dockerfile.playwright │ └── Dockerfile.prometheus-static-data ├── fixtures │ ├── components │ │ ├── AppControls.ts │ │ ├── QuickSearchInput.ts │ │ └── Sidebar.ts │ ├── css │ │ └── hide-app-controls.css │ ├── index.ts │ └── views │ │ ├── DrilldownView.ts │ │ ├── MetricSceneView.ts │ │ └── MetricsReducerView.ts ├── provisioning │ └── prometheus │ │ ├── data.zip │ │ └── prometheus.yml └── tests │ ├── browser-history.spec.ts │ ├── metric-scene-view.spec.ts │ ├── metric-scene-view.spec.ts-snapshots │ ├── metric-scene-breakdown-all-panels-list-chromium-linux.png │ └── metric-scene-main-viz-chromium-linux.png │ ├── metrics-reducer-view.spec.ts │ ├── metrics-reducer-view.spec.ts-snapshots │ ├── metrics-reducer-default-sort-chromium-linux.png │ ├── metrics-reducer-group-by-label-chromium-linux.png │ ├── sidebar-prefixes-and-suffixes-selected-metric-counts-chromium-linux.png │ └── sidebar-prefixes-selected-metric-counts-chromium-linux.png │ ├── native-histogram.spec.ts │ └── native-histogram.spec.ts-snapshots │ └── native-histogram-panel-chromium-linux.png ├── jest-setup.js ├── jest.config.js ├── package-lock.json ├── package.json ├── provisioning ├── README.md ├── alerting │ └── alert-rules-wingman.json ├── dashboards │ ├── dashboardJson │ │ └── wingman │ │ │ ├── test-dashboard-00-1741152269128.json │ │ │ ├── test-dashboard-01-1741152318112.json │ │ │ └── test-dashboard-02-1741152341951.json │ └── wingman.yaml ├── datasources │ └── default.yaml └── plugins │ └── app.yaml ├── scripts ├── update-commit-sha.sh └── upgrade-playwright.sh ├── src ├── App │ ├── App.tsx │ ├── ErrorView.tsx │ ├── InlineBanner.tsx │ ├── Onboarding.tsx │ ├── Routes.tsx │ ├── testIds.ts │ ├── useCatchExceptions.ts │ ├── useReportAppInitialized.ts │ └── useTrail.tsx ├── Breakdown │ ├── AddToFiltersGraphAction.tsx │ ├── BreakdownSearchScene.tsx │ ├── ByFrameRepeater.tsx │ ├── LabelBreakdownScene.tsx │ ├── LayoutSwitcher.tsx │ ├── SearchInput.tsx │ ├── SortByScene.tsx │ ├── panelConfigs.ts │ ├── types.test.ts │ ├── types.ts │ ├── utils.ts │ └── yAxisSyncBehavior.ts ├── BreakdownLabelSelector.tsx ├── DataTrail.test.tsx ├── DataTrail.tsx ├── DataTrailBookmarks.test.tsx ├── DataTrailBookmarks.tsx ├── DataTrailCard.test.tsx ├── DataTrailCard.tsx ├── DataTrailSettings.tsx ├── DataTrailsRecentMetrics.test.tsx ├── DataTrailsRecentMetrics.tsx ├── Integrations │ ├── SceneDrawer.tsx │ ├── getQueryMetrics.ts │ ├── logs │ │ ├── base.ts │ │ ├── labelsCrossReference.test.ts │ │ ├── labelsCrossReference.ts │ │ ├── lokiRecordingRules.test.ts │ │ └── lokiRecordingRules.ts │ └── utils.ts ├── Menu │ └── PanelMenu.tsx ├── MetricActionBar.tsx ├── MetricGraphScene.tsx ├── MetricScene.tsx ├── MetricSelect │ ├── AddToExplorationsButton.test.tsx │ ├── AddToExplorationsButton.tsx │ ├── MetricSelectScene.tsx │ ├── PreviewPanel.tsx │ ├── SelectMetricAction.tsx │ ├── WithUsageDataPreviewPanel.tsx │ ├── api.ts │ ├── hideEmptyPreviews.ts │ ├── previewPanel.test.ts │ ├── relatedMetrics.ts │ └── util.ts ├── MetricsDrilldownDataSourceVariable.ts ├── PluginInfo │ ├── PluginInfo.tsx │ └── PluginLogo.tsx ├── README.md ├── RelatedLogs │ ├── NoRelatedLogsFound.tsx │ ├── RelatedLogsOrchestrator.ts │ └── RelatedLogsScene.tsx ├── ShareTrailButton.tsx ├── StatusWrapper.tsx ├── TrailStore │ ├── TrailStore.test.ts │ ├── TrailStore.ts │ ├── useBookmarkState.ts │ └── utils.tsx ├── WingmanDataTrail │ ├── GroupBy │ │ ├── MetricsGroupByList.tsx │ │ ├── MetricsGroupByRow.tsx │ │ └── MetricsWithLabelValue │ │ │ ├── MetricsWithLabelValueDataSource.ts │ │ │ └── MetricsWithLabelValueVariable.ts │ ├── Labels │ │ ├── LabelValuesVariable.tsx │ │ ├── LabelsDataSource.ts │ │ └── LabelsVariable.tsx │ ├── ListControls │ │ ├── LayoutSwitcher.tsx │ │ ├── ListControls.tsx │ │ ├── MetricsSorter │ │ │ ├── MetricUsageFetcher.ts │ │ │ ├── MetricsSorter.test.tsx │ │ │ ├── MetricsSorter.tsx │ │ │ ├── events │ │ │ │ └── EventSortByChanged.ts │ │ │ └── fetchers │ │ │ │ ├── fetchAlertingMetrics.ts │ │ │ │ └── fetchDashboardMetrics.ts │ │ └── QuickSearch │ │ │ ├── EventQuickSearchChanged.ts │ │ │ └── QuickSearch.tsx │ ├── MetricVizPanel │ │ ├── MetricVizPanel.tsx │ │ ├── __tests__ │ │ │ └── parseMatchers.test.ts │ │ ├── actions │ │ │ ├── ApplyAction.tsx │ │ │ ├── ConfigureAction.tsx │ │ │ ├── EventApplyFunction.ts │ │ │ ├── EventConfigureFunction.ts │ │ │ └── SelectAction.tsx │ │ ├── getPrometheusMetricType.ts │ │ ├── panels │ │ │ ├── buildHeatmapPanel.ts │ │ │ ├── buildStatusHistoryPanel.ts │ │ │ └── buildTimeseriesPanel.ts │ │ └── parseMatcher.ts │ ├── MetricsList │ │ └── SimpleMetricsList.tsx │ ├── MetricsReducer.tsx │ ├── MetricsVariables │ │ ├── EventMetricsVariableActivated.ts │ │ ├── EventMetricsVariableDeactivated.ts │ │ ├── EventMetricsVariableLoaded.ts │ │ ├── FilteredMetricsVariable.ts │ │ ├── MetricsVariable.ts │ │ ├── MetricsVariableFilterEngine.ts │ │ ├── MetricsVariableSortEngine.ts │ │ ├── computeMetricPrefixGroups.ts │ │ ├── computeMetricSuffixGroups.ts │ │ ├── computeRulesGroups.ts │ │ ├── helpers │ │ │ └── areArraysEqual.ts │ │ ├── metricLabels.ts │ │ └── withLifecycleEvents.ts │ ├── SceneByVariableRepeater │ │ └── SceneByVariableRepeater.tsx │ ├── SceneDrawer.tsx │ ├── ShowMoreButton.tsx │ ├── SideBar │ │ ├── SideBar.tsx │ │ ├── SideBarButton.tsx │ │ ├── custom-icons │ │ │ ├── GroupsIcon.tsx │ │ │ └── RulesIcon.tsx │ │ └── sections │ │ │ ├── BookmarksList.tsx │ │ │ ├── EventSectionValueChanged.ts │ │ │ ├── LabelsBrowser │ │ │ ├── LabelsBrowser.tsx │ │ │ └── LabelsList.tsx │ │ │ ├── MetricsFilterSection │ │ │ ├── CheckBoxList.tsx │ │ │ ├── CheckboxWithCount.tsx │ │ │ ├── EventFiltersChanged.ts │ │ │ └── MetricsFilterSection.tsx │ │ │ ├── SectionTitle.tsx │ │ │ ├── Settings.tsx │ │ │ └── types.ts │ └── helpers │ │ ├── displayStatus.ts │ │ ├── isPrometheusRule.ts │ │ ├── localCompare.ts │ │ └── registerRuntimeDataSources.ts ├── assets │ └── rockets.tsx ├── autoQuery │ ├── buildPrometheusQuery.test.ts │ ├── buildPrometheusQuery.ts │ ├── components │ │ ├── AutoVizPanel.tsx │ │ └── AutoVizPanelQuerySelector.tsx │ ├── getAutoQueriesForMetric.test.ts │ ├── getAutoQueriesForMetric.ts │ ├── graphBuilders.ts │ ├── queryGenerators │ │ ├── baseQuery.test.ts │ │ ├── baseQuery.ts │ │ ├── common.test.ts │ │ ├── common.ts │ │ ├── default.test.ts │ │ ├── default.ts │ │ ├── histogram.test.ts │ │ ├── histogram.ts │ │ ├── summary.test.ts │ │ └── summary.ts │ ├── types.ts │ ├── units.test.ts │ └── units.ts ├── constants.ts ├── constants │ └── ui.ts ├── extensions │ ├── links.test.ts │ └── links.ts ├── groop │ ├── lookup.test.ts │ ├── lookup.ts │ ├── parser.test.ts │ ├── parser.ts │ └── testdata │ │ └── metrics.txt ├── helpers │ ├── MetricDataSourceHelper.test.ts │ └── MetricDatasourceHelper.ts ├── img │ ├── breakdown.png │ ├── logo.svg │ └── metrics-drilldown.png ├── interactions.ts ├── mocks │ ├── datasource.ts │ ├── interactionsMock.ts │ ├── loggerMock.ts │ ├── plugin.ts │ └── svgMock.js ├── module.tsx ├── pages │ ├── Trail.tsx │ └── TrailWingman.tsx ├── plugin.json ├── services │ ├── levels.test.ts │ ├── levels.ts │ ├── search.ts │ ├── sorting.test.ts │ ├── sorting.ts │ ├── store.ts │ └── variables.ts ├── shared.ts ├── stubs │ ├── grafana-plugin-ui.ts │ ├── moment-timezone.ts │ └── monaco-editor.ts ├── tracking │ ├── __tests__ │ │ └── getEnvironment.spec.ts │ ├── faro │ │ ├── __tests__ │ │ │ └── faro.spec.ts │ │ ├── faro-environments.ts │ │ ├── faro.ts │ │ └── getFaroEnvironment.ts │ ├── getEnvironment.ts │ └── logger │ │ └── logger.ts ├── trailFactory.ts ├── types.ts ├── utils.test.ts ├── utils.ts ├── utils │ ├── utils.datasource.test.ts │ ├── utils.datasource.ts │ ├── utils.events.ts │ ├── utils.layout.ts │ ├── utils.plugin.ts │ ├── utils.promql.ts │ ├── utils.queries.ts │ ├── utils.scopes.ts │ ├── utils.testing.ts │ ├── utils.timerange.ts │ └── utils.variables.ts └── version.ts ├── tsconfig.json ├── webpack-analyze.config.ts └── webpack.config.ts /.config/.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5.19.8" 3 | } 4 | -------------------------------------------------------------------------------- /.config/.eslintrc: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-eslint-config 6 | */ 7 | { 8 | "extends": ["@grafana/eslint-config"], 9 | "root": true, 10 | "rules": { 11 | "react/prop-types": "off" 12 | }, 13 | "overrides": [ 14 | { 15 | "plugins": ["deprecation"], 16 | "files": ["src/**/*.{ts,tsx}"], 17 | "rules": { 18 | "deprecation/deprecation": "warn" 19 | }, 20 | "parserOptions": { 21 | "project": "./tsconfig.json" 22 | } 23 | }, 24 | { 25 | "files": ["./tests/**/*"], 26 | "rules": { 27 | "react-hooks/rules-of-hooks": "off" 28 | } 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.config/.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | module.exports = { 8 | endOfLine: 'auto', 9 | printWidth: 120, 10 | trailingComma: 'es5', 11 | semi: true, 12 | jsxSingleQuote: false, 13 | singleQuote: true, 14 | useTabs: false, 15 | tabWidth: 2, 16 | }; 17 | -------------------------------------------------------------------------------- /.config/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG grafana_version=latest 2 | ARG grafana_image=grafana-enterprise 3 | 4 | FROM grafana/${grafana_image}:${grafana_version} 5 | 6 | ARG anonymous_auth_enabled=true 7 | ARG development=false 8 | ARG TARGETARCH 9 | 10 | 11 | ENV DEV "${development}" 12 | 13 | # Make it as simple as possible to access the grafana instance for development purposes 14 | # Do NOT enable these settings in a public facing / production grafana instance 15 | ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin" 16 | ENV GF_AUTH_ANONYMOUS_ENABLED "${anonymous_auth_enabled}" 17 | ENV GF_AUTH_BASIC_ENABLED "false" 18 | # Set development mode so plugins can be loaded without the need to sign 19 | ENV GF_DEFAULT_APP_MODE "development" 20 | 21 | 22 | LABEL maintainer="Grafana Labs " 23 | 24 | ENV GF_PATHS_HOME="/usr/share/grafana" 25 | WORKDIR $GF_PATHS_HOME 26 | 27 | USER root 28 | 29 | # Installing supervisor and inotify-tools 30 | RUN if [ "${development}" = "true" ]; then \ 31 | if grep -i -q alpine /etc/issue; then \ 32 | apk add supervisor inotify-tools git; \ 33 | elif grep -i -q ubuntu /etc/issue; then \ 34 | DEBIAN_FRONTEND=noninteractive && \ 35 | apt-get update && \ 36 | apt-get install -y supervisor inotify-tools git && \ 37 | rm -rf /var/lib/apt/lists/*; \ 38 | else \ 39 | echo 'ERROR: Unsupported base image' && /bin/false; \ 40 | fi \ 41 | fi 42 | 43 | COPY supervisord/supervisord.conf /etc/supervisor.d/supervisord.ini 44 | COPY supervisord/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 45 | 46 | 47 | 48 | # Inject livereload script into grafana index.html 49 | RUN sed -i 's|||g' /usr/share/grafana/public/views/index.html 50 | 51 | 52 | COPY entrypoint.sh /entrypoint.sh 53 | RUN chmod +x /entrypoint.sh 54 | ENTRYPOINT ["/entrypoint.sh"] 55 | -------------------------------------------------------------------------------- /.config/docker-compose-base.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | user: root 4 | container_name: 'grafana-metricsdrilldown-app' 5 | 6 | build: 7 | context: . 8 | args: 9 | grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} 10 | grafana_version: ${GRAFANA_VERSION:-11.5.3} 11 | development: ${DEVELOPMENT:-false} 12 | anonymous_auth_enabled: ${ANONYMOUS_AUTH_ENABLED:-true} 13 | ports: 14 | - 3000:3000/tcp 15 | volumes: 16 | - ../dist:/var/lib/grafana/plugins/grafana-metricsdrilldown-app 17 | - ../provisioning:/etc/grafana/provisioning 18 | - ..:/root/grafana-metricsdrilldown-app 19 | 20 | environment: 21 | NODE_ENV: development 22 | GF_LOG_FILTERS: plugin.grafana-metricsdrilldown-app:debug 23 | GF_LOG_LEVEL: debug 24 | GF_DATAPROXY_LOGGING: 1 25 | GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-metricsdrilldown-app 26 | -------------------------------------------------------------------------------- /.config/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "${DEV}" = "false" ]; then 4 | echo "Starting test mode" 5 | exec /run.sh 6 | fi 7 | 8 | echo "Starting development mode" 9 | 10 | if grep -i -q alpine /etc/issue; then 11 | exec /usr/bin/supervisord -c /etc/supervisord.conf 12 | elif grep -i -q ubuntu /etc/issue; then 13 | exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf 14 | else 15 | echo 'ERROR: Unsupported base image' 16 | exit 1 17 | fi 18 | 19 | -------------------------------------------------------------------------------- /.config/jest-setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-jest-config 6 | */ 7 | 8 | import '@testing-library/jest-dom'; 9 | import { TextEncoder, TextDecoder } from 'util'; 10 | 11 | Object.assign(global, { TextDecoder, TextEncoder }); 12 | 13 | // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 14 | Object.defineProperty(global, 'matchMedia', { 15 | writable: true, 16 | value: (query) => ({ 17 | matches: false, 18 | media: query, 19 | onchange: null, 20 | addListener: jest.fn(), // deprecated 21 | removeListener: jest.fn(), // deprecated 22 | addEventListener: jest.fn(), 23 | removeEventListener: jest.fn(), 24 | dispatchEvent: jest.fn(), 25 | }), 26 | }); 27 | 28 | HTMLCanvasElement.prototype.getContext = () => {}; 29 | -------------------------------------------------------------------------------- /.config/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-jest-config 6 | */ 7 | 8 | const path = require('path'); 9 | const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils'); 10 | 11 | module.exports = { 12 | moduleNameMapper: { 13 | '\\.(css|scss|sass)$': 'identity-obj-proxy', 14 | 'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'), 15 | }, 16 | modulePaths: ['/src'], 17 | setupFilesAfterEnv: ['/jest-setup.js'], 18 | testEnvironment: 'jest-environment-jsdom', 19 | testMatch: [ 20 | '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', 21 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 22 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 23 | ], 24 | transform: { 25 | '^.+\\.(t|j)sx?$': [ 26 | '@swc/jest', 27 | { 28 | sourceMaps: 'inline', 29 | jsc: { 30 | parser: { 31 | syntax: 'typescript', 32 | tsx: true, 33 | decorators: false, 34 | dynamicImport: true, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | // Jest will throw `Cannot use import statement outside module` if it tries to load an 41 | // ES module without it being transformed first. ./config/README.md#esm-errors-with-jest 42 | transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)], 43 | }; 44 | -------------------------------------------------------------------------------- /.config/jest/mocks/react-inlinesvg.tsx: -------------------------------------------------------------------------------- 1 | // Due to the grafana/ui Icon component making fetch requests to 2 | // `/public/img/icon/.svg` we need to mock react-inlinesvg to prevent 3 | // the failed fetch requests from displaying errors in console. 4 | 5 | import React from 'react'; 6 | 7 | type Callback = (...args: any[]) => void; 8 | 9 | export interface StorageItem { 10 | content: string; 11 | queue: Callback[]; 12 | status: string; 13 | } 14 | 15 | export const cacheStore: { [key: string]: StorageItem } = Object.create(null); 16 | 17 | const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/; 18 | 19 | const InlineSVG = ({ src }: { src: string }) => { 20 | // testId will be the file name without extension (e.g. `public/img/icons/angle-double-down.svg` -> `angle-double-down`) 21 | const testId = src.replace(SVG_FILE_NAME_REGEX, '$2'); 22 | return ; 23 | }; 24 | 25 | export default InlineSVG; 26 | -------------------------------------------------------------------------------- /.config/jest/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | /* 8 | * This utility function is useful in combination with jest `transformIgnorePatterns` config 9 | * to transform specific packages (e.g.ES modules) in a projects node_modules folder. 10 | */ 11 | const nodeModulesToTransform = (moduleNames) => `node_modules\/(?!.*(${moduleNames.join('|')})\/.*)`; 12 | 13 | // Array of known nested grafana package dependencies that only bundle an ESM version 14 | const grafanaESModules = [ 15 | '.pnpm', // Support using pnpm symlinked packages 16 | '@grafana/schema', 17 | 'd3', 18 | 'd3-color', 19 | 'd3-force', 20 | 'd3-interpolate', 21 | 'd3-scale-chromatic', 22 | 'ol', 23 | 'react-colorful', 24 | 'rxjs', 25 | 'uuid', 26 | ]; 27 | 28 | module.exports = { 29 | nodeModulesToTransform, 30 | grafanaESModules, 31 | }; 32 | -------------------------------------------------------------------------------- /.config/supervisord/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | 5 | [program:grafana] 6 | user=root 7 | directory=/var/lib/grafana 8 | command=/run.sh 9 | stdout_logfile=/dev/fd/1 10 | stdout_logfile_maxbytes=0 11 | redirect_stderr=true 12 | killasgroup=true 13 | stopasgroup=true 14 | autostart=true 15 | 16 | -------------------------------------------------------------------------------- /.config/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-typescript-config 6 | */ 7 | { 8 | "compilerOptions": { 9 | "alwaysStrict": true, 10 | "declaration": false, 11 | "rootDir": "../src", 12 | "baseUrl": "../src", 13 | "typeRoots": ["../node_modules/@types"], 14 | "resolveJsonModule": true 15 | }, 16 | "ts-node": { 17 | "compilerOptions": { 18 | "module": "commonjs", 19 | "target": "es5", 20 | "esModuleInterop": true 21 | }, 22 | "transpileOnly": true 23 | }, 24 | "include": ["../src", "./types"], 25 | "extends": "@grafana/tsconfig" 26 | } 27 | -------------------------------------------------------------------------------- /.config/types/bundler-rules.d.ts: -------------------------------------------------------------------------------- 1 | // Image declarations 2 | declare module '*.gif' { 3 | const src: string; 4 | export default src; 5 | } 6 | 7 | declare module '*.jpg' { 8 | const src: string; 9 | export default src; 10 | } 11 | 12 | declare module '*.jpeg' { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module '*.png' { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module '*.webp' { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module '*.svg' { 28 | const src: string; 29 | export default src; 30 | } 31 | 32 | // Font declarations 33 | declare module '*.woff'; 34 | declare module '*.woff2'; 35 | declare module '*.eot'; 36 | declare module '*.ttf'; 37 | declare module '*.otf'; 38 | -------------------------------------------------------------------------------- /.config/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | // Image declarations 2 | declare module '*.gif' { 3 | const src: string; 4 | export default src; 5 | } 6 | 7 | declare module '*.jpg' { 8 | const src: string; 9 | export default src; 10 | } 11 | 12 | declare module '*.jpeg' { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module '*.png' { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module '*.webp' { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module '*.svg' { 28 | const content: string; 29 | export default content; 30 | } 31 | 32 | // Font declarations 33 | declare module '*.woff'; 34 | declare module '*.woff2'; 35 | declare module '*.eot'; 36 | declare module '*.ttf'; 37 | declare module '*.otf'; 38 | -------------------------------------------------------------------------------- /.config/webpack/BuildModeWebpackPlugin.ts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | 3 | const PLUGIN_NAME = 'BuildModeWebpack'; 4 | 5 | export class BuildModeWebpackPlugin { 6 | apply(compiler: webpack.Compiler) { 7 | compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { 8 | compilation.hooks.processAssets.tap( 9 | { 10 | name: PLUGIN_NAME, 11 | stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, 12 | }, 13 | async () => { 14 | const assets = compilation.getAssets(); 15 | for (const asset of assets) { 16 | if (asset.name.endsWith('plugin.json')) { 17 | const pluginJsonString = asset.source.source().toString(); 18 | const pluginJsonWithBuildMode = JSON.stringify( 19 | { 20 | ...JSON.parse(pluginJsonString), 21 | buildMode: compilation.options.mode, 22 | }, 23 | null, 24 | 4 25 | ); 26 | compilation.updateAsset(asset.name, new webpack.sources.RawSource(pluginJsonWithBuildMode)); 27 | } 28 | } 29 | } 30 | ); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.config/webpack/constants.ts: -------------------------------------------------------------------------------- 1 | export const SOURCE_DIR = 'src'; 2 | export const DIST_DIR = 'dist'; 3 | -------------------------------------------------------------------------------- /.config/webpack/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import process from 'process'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { glob } from 'glob'; 6 | import { SOURCE_DIR } from './constants'; 7 | 8 | export function isWSL() { 9 | if (process.platform !== 'linux') { 10 | return false; 11 | } 12 | 13 | if (os.release().toLowerCase().includes('microsoft')) { 14 | return true; 15 | } 16 | 17 | try { 18 | return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); 19 | } catch { 20 | return false; 21 | } 22 | } 23 | 24 | export function getPackageJson() { 25 | return require(path.resolve(process.cwd(), 'package.json')); 26 | } 27 | 28 | export function getPluginJson() { 29 | return require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`)); 30 | } 31 | 32 | export function getCPConfigVersion() { 33 | const cprcJson = path.resolve(__dirname, '../', '.cprc.json'); 34 | return fs.existsSync(cprcJson) ? require(cprcJson).version : { version: 'unknown' }; 35 | } 36 | 37 | export function hasReadme() { 38 | return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md')); 39 | } 40 | 41 | // Support bundling nested plugins by finding all plugin.json files in src directory 42 | // then checking for a sibling module.[jt]sx? file. 43 | export async function getEntries() { 44 | const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true }); 45 | 46 | const plugins = await Promise.all( 47 | pluginsJson.map((pluginJson) => { 48 | const folder = path.dirname(pluginJson); 49 | return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true }); 50 | }) 51 | ); 52 | 53 | return plugins.reduce>((result, modules) => { 54 | return modules.reduce((innerResult, module) => { 55 | const pluginPath = path.dirname(module); 56 | const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, ''); 57 | const entryName = pluginName === '' ? 'module' : `${pluginName}/module`; 58 | 59 | innerResult[entryName] = module; 60 | return innerResult; 61 | }, result); 62 | }, {}); 63 | } 64 | -------------------------------------------------------------------------------- /.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": { 3 | "bundleGrafanaUI": false, 4 | "useReactRouterV6": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Prevent port collision when also running `grafana/grafana` locally on port 3000 2 | GRAFANA_PORT=3001 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["import", "sonarjs", "jest"], 3 | "extends": [ 4 | "./.config/.eslintrc", 5 | "plugin:import/recommended", 6 | "plugin:import/typescript", 7 | "plugin:sonarjs/recommended-legacy", 8 | "plugin:jest/recommended" 9 | ], 10 | "settings": { 11 | "import/parsers": { 12 | "@typescript-eslint/parser": [".ts", ".tsx"] 13 | }, 14 | "import/resolver": { 15 | "typescript": { 16 | "project": "./tsconfig.json" 17 | } 18 | } 19 | }, 20 | "rules": { 21 | "import/order": [ 22 | "error", 23 | { 24 | "groups": ["builtin", "external", "internal", ["parent", "sibling"], "index", "object", "type"], 25 | "newlines-between": "always", 26 | "alphabetize": { 27 | "order": "asc", 28 | "caseInsensitive": true 29 | }, 30 | "named": { 31 | "enabled": true, 32 | "types": "types-last" 33 | } 34 | } 35 | ], 36 | "@typescript-eslint/consistent-type-imports": [ 37 | "error", 38 | { 39 | "fixStyle": "inline-type-imports" 40 | } 41 | ], 42 | "no-unused-vars": ["error"], 43 | "sonarjs/todo-tag": ["warn"], 44 | "sonarjs/fixme-tag": ["warn"], 45 | "sonarjs/prefer-regexp-exec": ["off"] 46 | }, 47 | "overrides": [ 48 | { 49 | "files": ["jest.config.js"], 50 | "rules": { 51 | "import/order": "off" 52 | } 53 | } 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @grafana/observability-metrics 2 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### ✨ Description 2 | 3 | **Related issue(s):** 4 | 5 | 6 | 7 | 8 | ### 📖 Summary of the changes 9 | 10 | 11 | 12 | ### 🧪 How to test? 13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/bundle-stats-compare.yml: -------------------------------------------------------------------------------- 1 | name: Bundle Stats 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | actions: write 12 | 13 | jobs: 14 | compare: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Get Node.js version from package.json 24 | id: node-version 25 | run: | 26 | NODE_VERSION=$(node -p "require('./package.json').engines.node.replace('>=', '')") 27 | echo "version=$NODE_VERSION" >> $GITHUB_OUTPUT 28 | 29 | - name: Compare bundle stats 30 | uses: grafana/plugin-actions/bundle-size@main 31 | with: 32 | node-version: ${{ steps.node-version.outputs.version }} 33 | -------------------------------------------------------------------------------- /.github/workflows/bundle-stats-create.yml: -------------------------------------------------------------------------------- 1 | name: create bundle stats 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 0 * * *' # Run once per day at midnight UTC 7 | push: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: 'read' 13 | actions: 'write' 14 | 15 | jobs: 16 | build-stats: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | with: 22 | persist-credentials: false 23 | 24 | - name: Setup Node.js environment 25 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 26 | with: 27 | node-version: '22' 28 | cache: 'npm' 29 | 30 | - name: Install dependencies 31 | run: npm ci 32 | 33 | - name: Build frontend 34 | run: npm run build -- --profile --json stats.json 35 | 36 | - name: Upload stats.json artifact 37 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 38 | with: 39 | name: main-branch-stats 40 | path: stats.json 41 | retention-days: 90 42 | overwrite: true 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | skip-grafana-dev-image: 7 | description: Skip the grafana dev image e2e test 8 | required: false 9 | type: boolean 10 | default: false 11 | pull_request: 12 | branches: 13 | - main 14 | 15 | permissions: 16 | contents: 'read' 17 | id-token: 'write' 18 | 19 | jobs: 20 | CI: 21 | uses: grafana/plugin-ci-workflows/.github/workflows/ci.yml@main 22 | with: 23 | run-playwright: false 24 | run-playwright-docker: true 25 | run-playwright-with-skip-grafana-dev-image: ${{ inputs.skip-grafana-dev-image || false }} 26 | upload-playwright-artifacts: true 27 | playwright-report-path: e2e/test-reports/ 28 | playwright-docker-compose-file: docker-compose.yaml 29 | playwright-grafana-url: http://localhost:3001 30 | -------------------------------------------------------------------------------- /.github/workflows/cp-update.yml: -------------------------------------------------------------------------------- 1 | name: Create Plugin Update 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 0 1 * *' # run once a month on the 1st day 7 | 8 | # To use the default github token with the following elevated permissions make sure to check: 9 | # **Allow GitHub Actions to create and approve pull requests** in https://github.com/ORG_NAME/REPO_NAME/settings/actions. 10 | # Alternatively create a fine-grained personal access token for your repository with 11 | # `contents: read and write` and `pull requests: read and write` and pass it to the action. 12 | 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | 17 | jobs: 18 | release: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: grafana/plugin-actions/create-plugin-update@main 22 | # Uncomment to use a fine-grained personal access token instead of default github token 23 | # (For more info on how to generate the token see https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) 24 | # with: 25 | # Make sure to save the token in your repository secrets 26 | # token: $ 27 | -------------------------------------------------------------------------------- /.github/workflows/is-compatible.yml: -------------------------------------------------------------------------------- 1 | name: Latest Grafana API compatibility check 2 | on: [pull_request] 3 | permissions: 4 | contents: 'read' 5 | actions: 'write' 6 | 7 | jobs: 8 | compatibilitycheck: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 12 | with: 13 | persist-credentials: false 14 | - name: Setup Node.js environment 15 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 16 | with: 17 | node-version: '22' 18 | cache: 'npm' 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Build plugin 22 | run: npm run build 23 | - name: Compatibility check 24 | run: npx --yes @grafana/levitate@latest is-compatible --path $(find ./src -type f \( -name "module.ts" -o -name "module.tsx" \)) --target @grafana/data,@grafana/ui,@grafana/runtime 25 | -------------------------------------------------------------------------------- /.github/workflows/publish-technical-documentation-next.yml: -------------------------------------------------------------------------------- 1 | name: publish-technical-documentation-next 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'docs/sources/**' 9 | workflow_dispatch: 10 | jobs: 11 | sync: 12 | if: github.repository == 'grafana/metrics-drilldown' 13 | permissions: 14 | contents: read 15 | id-token: write 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | persist-credentials: false 21 | - uses: grafana/writers-toolkit/publish-technical-documentation@publish-technical-documentation/v1 22 | with: 23 | website_directory: content/docs/explore-metrics/next 24 | -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | # This is also used as the default configuration for the Zizmor reusable 2 | # workflow. 3 | 4 | rules: 5 | unpinned-uses: 6 | config: 7 | policies: 8 | actions/*: any # trust GitHub 9 | grafana/*: any # trust Grafana 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | .env* 3 | !.env.example 4 | 5 | # Local provisioned datasources 6 | provisioning/datasources/ 7 | !provisioning/datasources/default.yaml 8 | 9 | # Logs 10 | logs 11 | !src/**/logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | .pnpm-debug.log* 17 | 18 | node_modules/ 19 | 20 | # yarn 21 | .yarn/cache 22 | .yarn/unplugged 23 | .yarn/build-state.yml 24 | .yarn/install-state.gz 25 | .pnp.* 26 | 27 | # Runtime data 28 | pids 29 | *.pid 30 | *.seed 31 | *.pid.lock 32 | 33 | # Directory for instrumented libs generated by jscoverage/JSCover 34 | lib-cov 35 | 36 | # Coverage directory used by tools like istanbul 37 | coverage 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | dist/ 41 | artifacts/ 42 | work/ 43 | ci/ 44 | 45 | 46 | 47 | # Editor 48 | .cursor 49 | .vscode 50 | .idea 51 | 52 | .eslintcache 53 | 54 | # MacOS 55 | .DS_Store 56 | 57 | # e2e test directories 58 | e2e/test-results/ 59 | e2e/test-reports/ 60 | playwright 61 | 62 | e2e/tests/**/*darwin.png 63 | e2e/provisioning/prometheus/data* 64 | !e2e/provisioning/prometheus/data.zip 65 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | playwright/ 3 | playwright-report/ 4 | test-results/ 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require('./.config/.prettierrc.js'), 4 | }; 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.0.2-0 4 | 5 | See 6 | 7 | ## 1.0.1 8 | 9 | See 10 | 11 | ## 1.0.0 12 | 13 | See 14 | 15 | ## 1.0.0-9 16 | 17 | See 18 | 19 | ## 1.0.0-8 20 | 21 | See 22 | 23 | ## 1.0.0-7 24 | 25 | See 26 | 27 | ## 1.0.0-6 28 | 29 | See 30 | 31 | ## 1.0.0-5 32 | 33 | See 34 | 35 | ## 1.0.0-4 36 | 37 | See 38 | 39 | ## 1.0.0-3 40 | 41 | See 42 | 43 | ## 1.0.0-2 44 | 45 | See 46 | 47 | ## 1.0.0-1 48 | 49 | See 50 | 51 | ## 1.0.0-0 52 | 53 | See 54 | 55 | ## 0.1.0 (Unreleased) 56 | 57 | Initial release. 58 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | name: 'metrics-drilldown' 2 | services: 3 | grafana-gmd: 4 | container_name: 'grafana-gmd' 5 | extends: 6 | file: .config/docker-compose-base.yaml 7 | service: grafana 8 | build: 9 | args: 10 | grafana_version: ${GRAFANA_VERSION:-11.6.0} 11 | ports: !override 12 | - '${GRAFANA_PORT:-3001}:3000' 13 | environment: 14 | GF_SERVER_ROOT_URL: 'http://localhost:${GRAFANA_PORT:-3001}' 15 | GF_FEATURE_TOGGLES_ENABLE: exploreMetricsUseExternalAppPlugin 16 | # prevents a Grafana startup error in case a newer plugin version (set in package.json) is used compared to the latest one published in the catalog 17 | GF_PLUGINS_PREINSTALL_DISABLED: true 18 | volumes: 19 | - ./provisioning/dashboards/dashboardJson:/etc/dashboards 20 | extra_hosts: 21 | # This allows us to connect to other services running on the host machine. 22 | - 'host.docker.internal:host-gateway' 23 | 24 | prometheus-gmd: 25 | container_name: 'prometheus-gmd' 26 | build: 27 | dockerfile: ./e2e/docker/Dockerfile.prometheus-static-data 28 | context: . 29 | ports: 30 | - '9099:9090' 31 | extra_hosts: 32 | - 'host.docker.internal:host-gateway' 33 | command: > 34 | --enable-feature=remote-write-receiver 35 | --enable-feature=exemplar-storage 36 | --enable-feature=native-histograms 37 | --config.file=/etc/prometheus/prometheus.yml 38 | --storage.tsdb.path=/prometheus/data 39 | --storage.tsdb.retention.time=42y 40 | 41 | # e2e testing 42 | playwright: 43 | network_mode: host 44 | container_name: 'playwright-e2e' 45 | build: 46 | dockerfile: ./e2e/docker/Dockerfile.playwright 47 | context: . 48 | volumes: 49 | - ./e2e:/app/e2e 50 | - ./src:/app/src 51 | command: ${PLAYWRIGHT_ARGS:-} 52 | profiles: [playwright] 53 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | .ONESHELL: 2 | .DELETE_ON_ERROR: 3 | export SHELL := bash 4 | export SHELLOPTS := pipefail:errexit 5 | MAKEFLAGS += --warn-undefined-variables 6 | MAKEFLAGS += --no-builtin-rule 7 | 8 | include docs.mk 9 | -------------------------------------------------------------------------------- /docs/sources/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | labels: 3 | products: 4 | - cloud 5 | - enterprise 6 | - oss 7 | title: Metrics Drilldown 8 | aliases: 9 | - ../explore-metrics/ # /docs/grafana/latest/explore/explore-metrics/ 10 | canonical: https://grafana.com/docs/grafana/latest/explore/simplified-exploration/metrics/ 11 | description: Grafana Metrics Drilldown lets you browse Prometheus-compatible metrics using an intuitive, queryless experience. 12 | hero: 13 | title: Queryless metrics exploration with Grafana Metrics Drilldown 14 | level: 1 15 | width: 100 16 | height: 100 17 | description: Use Grafana Metrics Drilldown to analyze metric data without writing a PromQL query. 18 | cards: 19 | title_class: pt-0 lh-1 20 | items: 21 | - title: Metrics and telemetry 22 | href: ./about-metrics/ 23 | description: Learn about metrics and the role they serve in analyzing telemetry data. 24 | height: 24 25 | - title: Get started 26 | href: ./get-started/ 27 | description: Get started analyzing your metrics with Grafana Metrics Drilldown 28 | height: 24 29 | - title: Drill down your metrics 30 | href: ./drill-down-metrics/ 31 | description: Drill down into your metrics without writing a PromQL query. 32 | height: 24 33 | weight: 200 34 | --- 35 | 36 | # Grafana Metrics Drilldown 37 | 38 | Grafana Metrics Drilldown is a queryless experience for browsing Prometheus-compatible metrics. Quickly find related metrics with just a few simple clicks, without needing to write a PromQL query. 39 | 40 | {{< docs/learning-journeys title="Explore data using Metrics Drilldown" url="https://grafana.com/docs/learning-journeys/drilldown-metrics/" >}} 41 | 42 | {{< docs/shared source="grafana" lookup="plugins/rename-note.md" version="" >}} 43 | 44 | With Metrics Drilldown, you can: 45 | 46 | - Segment metrics based on their labels, so you can immediately spot anomalies and identify issues. 47 | - Automatically display the optimal visualization for each metric type (gauge vs. counter, for example) without manual setup. 48 | - Uncover related metrics relevant to the one you're viewing. 49 | - Seamlessly pivot to related telemetry, including log data. 50 | 51 | {{< docs/play title="Metrics Drilldown" url="https://play.grafana.org/a/grafana-metricsdrilldown-app/trail?from=now-1h&to=now&var-ds=grafanacloud-demoinfra-prom&var-filters=&refresh=&metricPrefix=all" >}} 52 | 53 | ## Explore 54 | 55 | {{< card-grid key="cards" type="simple" >}} -------------------------------------------------------------------------------- /docs/sources/get-started/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | labels: 3 | products: 4 | - cloud 5 | - enterprise 6 | - oss 7 | title: Get started with Grafana Metrics Drilldown 8 | menuTitle: Get started 9 | weight: 20 10 | refs: 11 | drilldown: 12 | - pattern: /docs/grafana/ 13 | destination: https://grafana.com/docs/grafana//explore/simplified-exploration/metrics/drill-down-metrics/ 14 | - pattern: /docs/grafana-cloud/ 15 | destination: https://grafana.com/docs/grafana-cloud/visualizations/simplified-exploration/metrics/drill-down-metrics/ 16 | --- 17 | 18 | # Get started with Grafana Metrics Drilldown 19 | 20 | Use Grafana Metrics Drilldown to explore your metrics without writing a PromQL query. You can access the Grafana Metrics Drilldown app in Grafana Cloud or in self-mananged Grafana. 21 | 22 | ## Before you begin 23 | 24 | The Grafana Metrics Drilldown app is installed in both Grafana Cloud and self-managed Grafana by default. 25 | 26 | To use Grafana Metrics Drilldown with Grafana Cloud, you need: 27 | 28 | - A Grafana Cloud account 29 | - A Grafana stack in Grafana Cloud with a configured Prometheus-compatible metrics data source 30 | 31 | To use Grafana Metrics Drilldown with Grafana open source or Grafana Enterprise, you need: 32 | 33 | - Your own Grafana instance running Grafana version 11.6 or later 34 | - A configured Prometheus-compatible metrics data source 35 | 36 | ## Access Grafana Metrics Drilldown 37 | 38 | Access the Grafana Metrics Drilldown app either through the main page in Grafana or through a dashboard. 39 | 40 | ### Access the app through the Grafana main page 41 | 42 | Follow these steps to access the app through the Grafana main page. 43 | 44 | 1. From the Grafana left-side menu, select **Drilldown**. 45 | 46 | The **Drilldown** page opens. 47 | 1. From the list of Drilldown apps, select **Metrics**. 48 | 49 | The Grafana Metrics Drilldown app opens. 50 | 51 | ### Access the app through a dashboard 52 | 53 | Follow these steps to access the app through an existing metrics dashboard in Grafana. 54 | 55 | 1. Navigate to your dashboard in Grafana. 56 | 1. Select a time series panel. 57 | 1. Select the panel menu, and then select **Metrics drilldown** > **Open in Grafana Metrics Drilldown**. 58 | 59 | The selected metric opens in the Metrics Drilldown app. 60 | 61 | ## Next steps 62 | 63 | Now you're ready to drill down into your metric data. For more information, refer to [Drill down your metrics](ref:drilldown). 64 | -------------------------------------------------------------------------------- /docs/variables.mk: -------------------------------------------------------------------------------- 1 | # List of projects to provide to the make-docs script. 2 | # Format is PROJECT[:[VERSION][:[REPOSITORY][:[DIRECTORY]]]] 3 | # The following PROJECTS value mounts: 4 | REPO_DIR := $(notdir $(basename $(shell git rev-parse --show-toplevel))) 5 | PROJECTS := grafana-cloud/visualizations/simplified-exploration/metrics:UNVERSIONED:$(REPO_DIR): 6 | -------------------------------------------------------------------------------- /e2e/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": false, 3 | "rules": { 4 | "jest/no-standalone-expect": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /e2e/config/constants.ts: -------------------------------------------------------------------------------- 1 | export const CHROMIUM_VIEWPORT = { width: 1920, height: 1080 }; 2 | 3 | // taken from Grafana 4 | // see https://github.com/grafana/grafana/blob/852d032e1ae1f7c989d8b2ec7d8e05bf2a54928e/public/app/core/components/AppChrome/AppChromeService.tsx#L32-L33 5 | export const DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY = 'grafana.navigation.open'; 6 | export const DOCKED_MENU_DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked'; 7 | 8 | export const DEFAULT_STATIC_URL_SEARCH_PARAMS = new URLSearchParams({ 9 | 'var-ds': 'gdev-prometheus', 10 | from: '2025-05-26T11:00:00.000Z', 11 | to: '2025-05-26T12:05:00.000Z', 12 | timezone: 'utc', 13 | }); 14 | 15 | export const DEFAULT_URL_SEARCH_PARAMS = new URLSearchParams({ 16 | from: 'now-15m', 17 | to: 'now', 18 | 'var-ds': 'gdev-prometheus', 19 | }); 20 | -------------------------------------------------------------------------------- /e2e/config/playwright.config.ci.ts: -------------------------------------------------------------------------------- 1 | import { config } from './playwright.config.common'; 2 | 3 | export default config({ 4 | // we use the "list" reporter instead of the "dot" one, because it doesn't show in GitHub actions logs 5 | reporter: [['list'], ['html', { outputFolder: '../test-reports', open: 'never' }], ['github']], 6 | retries: 1, 7 | forbidOnly: true, 8 | workers: 2, 9 | }); 10 | -------------------------------------------------------------------------------- /e2e/config/playwright.config.local.ts: -------------------------------------------------------------------------------- 1 | import { config } from './playwright.config.common'; 2 | 3 | export default config({ 4 | reporter: [ 5 | ['list', { printSteps: true }], 6 | ['html', { outputFolder: '../test-reports', open: 'never' }], 7 | ], 8 | workers: 4, 9 | forbidOnly: false, 10 | }); 11 | -------------------------------------------------------------------------------- /e2e/docker/Dockerfile.playwright: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/playwright:v1.53.0 2 | 3 | WORKDIR /app 4 | 5 | # required by the e2e test code 6 | RUN npm install "@playwright/test@^1.53.0" "dotenv@^16.3.1" "@grafana/plugin-e2e" 7 | 8 | ENV TZ=Europe/Madrid 9 | 10 | ENTRYPOINT ["npx", "playwright", "test", "-c", "e2e/config/playwright.config.ci.ts"] 11 | -------------------------------------------------------------------------------- /e2e/docker/Dockerfile.prometheus-static-data: -------------------------------------------------------------------------------- 1 | FROM docker.io/prom/prometheus:v3.2.1 2 | 3 | USER nobody 4 | 5 | # data from ~ 2025-05-26 11:00 to 2025-05-26 12:05 6 | COPY e2e/provisioning/prometheus/data.zip /prometheus/data.zip 7 | RUN unzip /prometheus/data.zip 8 | RUN rm /prometheus/data.zip 9 | 10 | COPY e2e/provisioning/prometheus/prometheus.yml /etc/prometheus/prometheus.yml 11 | -------------------------------------------------------------------------------- /e2e/fixtures/components/QuickSearchInput.ts: -------------------------------------------------------------------------------- 1 | import { type Locator, type Page } from '@playwright/test'; 2 | 3 | export class QuickSearchInput { 4 | private readonly locator: Locator; 5 | 6 | constructor(private readonly page: Page) { 7 | this.locator = page.getByRole('textbox', { name: /search metrics/i }); 8 | } 9 | 10 | get() { 11 | return this.locator; 12 | } 13 | 14 | async enterText(searchText: string) { 15 | await this.get().fill(searchText); 16 | await this.page.waitForTimeout(250); // see SceneQuickFilter.DEBOUNCE_DELAY 17 | } 18 | 19 | clear() { 20 | return this.get().clear(); 21 | } 22 | 23 | async assert(expectedValue: string, expectedResultsCount: string) { 24 | await expect(this.get()).toHaveValue(expectedValue); 25 | await expect(this.page.getByTestId('quick-filter-results-count')).toHaveText(expectedResultsCount); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /e2e/fixtures/css/hide-app-controls.css: -------------------------------------------------------------------------------- 1 | .main-view > header { 2 | display: none; 3 | } 4 | 5 | [data-testid='app-controls'] { 6 | display: none; 7 | } 8 | 9 | [aria-label='Native Histogram Support'][role='status'] { 10 | display: none; 11 | } 12 | -------------------------------------------------------------------------------- /e2e/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | import { test as base, type AppConfigPage, type AppPage } from '@grafana/plugin-e2e'; 2 | 3 | import pluginJson from '../../src/plugin.json'; 4 | import { 5 | DEFAULT_STATIC_URL_SEARCH_PARAMS, 6 | DOCKED_MENU_DOCKED_LOCAL_STORAGE_KEY, 7 | DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 8 | } from '../config/constants'; 9 | import { MetricSceneView } from './views/MetricSceneView'; 10 | import { MetricsReducerView } from './views/MetricsReducerView'; 11 | 12 | type AppTestFixture = { 13 | appConfigPage: AppConfigPage; 14 | gotoPage: (path?: string) => Promise; 15 | metricsReducerView: MetricsReducerView; 16 | metricSceneView: MetricSceneView; 17 | }; 18 | 19 | export const test = base.extend({ 20 | appConfigPage: async ({ gotoAppConfigPage }, use) => { 21 | const configPage = await gotoAppConfigPage({ 22 | pluginId: pluginJson.id, 23 | }); 24 | await use(configPage); 25 | }, 26 | gotoPage: async ({ gotoAppPage, page }, use) => { 27 | await use(async (path) => { 28 | const urlParams = DEFAULT_STATIC_URL_SEARCH_PARAMS; 29 | const url = `${path}?${urlParams.toString()}`; 30 | 31 | await page.addInitScript( 32 | (keys) => { 33 | keys.forEach((key) => { 34 | window.localStorage.setItem(key, 'false'); 35 | }); 36 | }, 37 | [DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, DOCKED_MENU_DOCKED_LOCAL_STORAGE_KEY] 38 | ); 39 | 40 | const appPage = await gotoAppPage({ 41 | path: url, 42 | pluginId: pluginJson.id, 43 | }); 44 | 45 | return appPage; 46 | }); 47 | }, 48 | metricsReducerView: async ({ page }, use) => { 49 | const metricsReducerView = new MetricsReducerView(page, DEFAULT_STATIC_URL_SEARCH_PARAMS); 50 | await use(metricsReducerView); 51 | }, 52 | metricSceneView: async ({ page }, use) => { 53 | const metricSceneView = new MetricSceneView(page, DEFAULT_STATIC_URL_SEARCH_PARAMS); 54 | await use(metricSceneView); 55 | }, 56 | }); 57 | 58 | export { expect } from '@grafana/plugin-e2e'; 59 | -------------------------------------------------------------------------------- /e2e/provisioning/prometheus/data.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/e2e/provisioning/prometheus/data.zip -------------------------------------------------------------------------------- /e2e/provisioning/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | evaluation_interval: 15s 4 | 5 | scrape_configs: 6 | - job_name: 'grafana-gmd' 7 | static_configs: 8 | - targets: ['grafana-gmd:3000'] 9 | 10 | - job_name: 'prometheus' 11 | static_configs: 12 | - targets: ['prometheus-gmd:9090'] 13 | -------------------------------------------------------------------------------- /e2e/tests/browser-history.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '../fixtures'; 2 | 3 | test.describe('Browser History', () => { 4 | test('The back button works', async ({ metricsReducerView }) => { 5 | await metricsReducerView.goto(); 6 | await metricsReducerView.assertCoreUI(); 7 | 8 | await metricsReducerView.selectMetricPanel('go_cgo_go_to_c_calls_calls_total'); 9 | await metricsReducerView.goBack(); 10 | 11 | await metricsReducerView.assertCoreUI(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /e2e/tests/metric-scene-view.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '../fixtures'; 2 | 3 | const METRIC_NAME = 'go_gc_duration_seconds'; 4 | const URL_SEARCH_PARAMS_WITH_METRIC_NAME = new URLSearchParams([['metric', METRIC_NAME]]); 5 | 6 | test.describe('Metrics Scene view', () => { 7 | test.beforeEach(async ({ metricSceneView }) => { 8 | await metricSceneView.goto(URL_SEARCH_PARAMS_WITH_METRIC_NAME); 9 | }); 10 | 11 | test('Core UI elements', async ({ metricSceneView }) => { 12 | await metricSceneView.assertCoreUI(METRIC_NAME); 13 | 14 | await expect(metricSceneView.getMainViz()).toHaveScreenshot('metric-scene-main-viz.png'); 15 | }); 16 | 17 | test.describe('Breakdown tab', () => { 18 | test('All labels', async ({ metricSceneView }) => { 19 | await metricSceneView.assertLabelDropdown('All'); 20 | await metricSceneView.assertPanelsList(); 21 | 22 | await expect(metricSceneView.getPanelsList()).toHaveScreenshot('metric-scene-breakdown-all-panels-list.png'); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /e2e/tests/metric-scene-view.spec.ts-snapshots/metric-scene-breakdown-all-panels-list-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/e2e/tests/metric-scene-view.spec.ts-snapshots/metric-scene-breakdown-all-panels-list-chromium-linux.png -------------------------------------------------------------------------------- /e2e/tests/metric-scene-view.spec.ts-snapshots/metric-scene-main-viz-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/e2e/tests/metric-scene-view.spec.ts-snapshots/metric-scene-main-viz-chromium-linux.png -------------------------------------------------------------------------------- /e2e/tests/metrics-reducer-view.spec.ts-snapshots/metrics-reducer-default-sort-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/e2e/tests/metrics-reducer-view.spec.ts-snapshots/metrics-reducer-default-sort-chromium-linux.png -------------------------------------------------------------------------------- /e2e/tests/metrics-reducer-view.spec.ts-snapshots/metrics-reducer-group-by-label-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/e2e/tests/metrics-reducer-view.spec.ts-snapshots/metrics-reducer-group-by-label-chromium-linux.png -------------------------------------------------------------------------------- /e2e/tests/metrics-reducer-view.spec.ts-snapshots/sidebar-prefixes-and-suffixes-selected-metric-counts-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/e2e/tests/metrics-reducer-view.spec.ts-snapshots/sidebar-prefixes-and-suffixes-selected-metric-counts-chromium-linux.png -------------------------------------------------------------------------------- /e2e/tests/metrics-reducer-view.spec.ts-snapshots/sidebar-prefixes-selected-metric-counts-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/e2e/tests/metrics-reducer-view.spec.ts-snapshots/sidebar-prefixes-selected-metric-counts-chromium-linux.png -------------------------------------------------------------------------------- /e2e/tests/native-histogram.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '../fixtures'; 2 | 3 | test.describe('Native histograms', () => { 4 | test.beforeEach(async ({ metricsReducerView }) => { 5 | await metricsReducerView.goto(); 6 | }); 7 | 8 | test('Native histogram panels display an info badge', async ({ metricsReducerView }) => { 9 | const METRIC_NAME = `grafana_database_all_migrations_duration_seconds`; 10 | 11 | await metricsReducerView.quickSearch.enterText(METRIC_NAME); 12 | await metricsReducerView.assertPanel(METRIC_NAME); 13 | 14 | // we take a screenshot because the badge is displayed via a CSS pseudo element and text assertions do not work 15 | // we take a screenshot of the whole panel and not only the header because the selector generated by Grafana Dev 12 is different from Grafana enterprise 11 16 | await expect(metricsReducerView.getPanelByTitle(METRIC_NAME)).toHaveScreenshot('native-histogram-panel.png'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /e2e/tests/native-histogram.spec.ts-snapshots/native-histogram-panel-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/e2e/tests/native-histogram.spec.ts-snapshots/native-histogram-panel-chromium-linux.png -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup provided by Grafana scaffolding 2 | import './.config/jest-setup'; 3 | 4 | const mockIntersectionObserver = jest.fn().mockImplementation((callback) => ({ 5 | observe: jest.fn().mockImplementation((elem) => { 6 | callback([{ target: elem, isIntersecting: true }]); 7 | }), 8 | unobserve: jest.fn(), 9 | disconnect: jest.fn(), 10 | })); 11 | global.IntersectionObserver = mockIntersectionObserver; 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // force timezone to UTC to allow tests to work regardless of local timezone 2 | // generally used by snapshots, but can affect specific tests 3 | process.env.TZ = 'UTC'; 4 | 5 | const baseConfig = require('./.config/jest.config'); // Jest configuration provided by Grafana scaffolding 6 | const { nodeModulesToTransform, grafanaESModules } = require('./.config/jest/utils'); 7 | const esModules = [...grafanaESModules, '@bsull/augurs', 'monaco-promql', 'tsqtsq', 'p-limit', 'yocto-queue']; 8 | 9 | module.exports = { 10 | ...baseConfig, 11 | transformIgnorePatterns: [nodeModulesToTransform(esModules)], 12 | moduleNameMapper: { 13 | ...baseConfig.moduleNameMapper, 14 | '\\.svg$': '/src/mocks/svgMock.js', 15 | '^.+/logger/logger$': '/src/mocks/loggerMock.ts', 16 | '^.+/interactions$': '/src/mocks/interactionsMock.ts', 17 | }, 18 | transform: { 19 | '^.+\\.(t|j)sx?$': [ 20 | '@swc/jest', 21 | { 22 | sourceMaps: 'inline', 23 | jsc: { 24 | parser: { 25 | syntax: 'typescript', 26 | tsx: true, 27 | decorators: false, 28 | dynamicImport: true, 29 | }, 30 | target: 'es2022', 31 | // Add these options for proper property descriptors 32 | keepClassNames: true, 33 | preserveAllComments: true, 34 | transform: { 35 | legacyDecorator: true, 36 | decoratorMetadata: true, 37 | }, 38 | }, 39 | module: { 40 | type: 'commonjs', 41 | strict: true, 42 | strictMode: true, 43 | noInterop: false, 44 | }, 45 | }, 46 | ], 47 | }, 48 | resetMocks: true, 49 | clearMocks: true, 50 | resetModules: true, 51 | collectCoverageFrom: ['./src/**'], 52 | coverageReporters: ['json-summary', 'text', 'text-summary'], 53 | }; 54 | -------------------------------------------------------------------------------- /provisioning/README.md: -------------------------------------------------------------------------------- 1 | For more information see our documentation: 2 | 3 | - [Provision Grafana](https://grafana.com/docs/grafana/latest/administration/provisioning/) 4 | - [Provision a plugin](https://grafana.com/developers/plugin-tools/publish-a-plugin/provide-test-environment) 5 | -------------------------------------------------------------------------------- /provisioning/dashboards/wingman.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: dashboards 5 | type: file 6 | updateIntervalSeconds: 30 7 | options: 8 | path: /etc/dashboards 9 | foldersFromFilesStructure: true 10 | -------------------------------------------------------------------------------- /provisioning/datasources/default.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: gdev-testdata 5 | type: testdata 6 | - name: gdev-loki 7 | type: loki 8 | uid: gdev-loki 9 | access: proxy 10 | url: http://host.docker.internal:3100 11 | - name: gdev-tempo 12 | type: tempo 13 | uid: gdev-tempo 14 | access: proxy 15 | url: http://host.docker.internal:3200 16 | editable: false 17 | jsonData: 18 | tracesToLogsV2: 19 | datasourceUid: gdev-loki 20 | spanStartTimeShift: '5m' 21 | spanEndTimeShift: '-5m' 22 | customQuery: true 23 | query: '{filename="/var/log/grafana/grafana.log"} |="$${__span.traceId}"' 24 | - name: gdev-prometheus 25 | uid: gdev-prometheus 26 | type: prometheus 27 | isDefault: true 28 | access: proxy 29 | url: http://host.docker.internal:9099 30 | basicAuth: true #username: admin, password: admin 31 | basicAuthUser: admin 32 | jsonData: 33 | manageAlerts: true 34 | alertmanagerUid: gdev-alertmanager 35 | prometheusType: Prometheus #Cortex | Mimir | Prometheus | Thanos 36 | prometheusVersion: 3.2.1 37 | exemplarTraceIdDestinations: 38 | - name: traceID 39 | datasourceUid: gdev-tempo 40 | secureJsonData: 41 | basicAuthPassword: admin #https://grafana.com/docs/grafana/latest/administration/provisioning/#using-environment-variables 42 | -------------------------------------------------------------------------------- /provisioning/plugins/app.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | apps: 4 | - type: 'grafana-metricsdrilldown-app' 5 | org_id: 1 6 | org_name: 'grafana' 7 | disabled: false 8 | jsonData: 9 | apiUrl: http://default-url.com 10 | isApiKeySet: true 11 | secureJsonData: 12 | apiKey: secret-key 13 | -------------------------------------------------------------------------------- /scripts/update-commit-sha.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | commit_sha=$(git rev-parse HEAD) 4 | 5 | echo "export const GIT_COMMIT = '$commit_sha';" > src/version.ts 6 | -------------------------------------------------------------------------------- /scripts/upgrade-playwright.sh: -------------------------------------------------------------------------------- 1 | OLD_VERSION=$1 2 | NEW_VERSION=$2 3 | 4 | if [ -z "$OLD_VERSION" ] || [ -z "$NEW_VERSION" ]; then 5 | echo "Error: Please provide both the old and new Playwright versions!" 6 | echo "\nUsage: upgrade-playwright [old version number] [new version number]" 7 | exit 1 8 | fi 9 | 10 | npm up @playwright/test --save 11 | 12 | find ./e2e/docker/Dockerfile.playwright -type f -exec sed -i "" "s/$OLD_VERSION/$NEW_VERSION/g" {} \; 13 | 14 | npm run e2e:prepare 15 | 16 | -------------------------------------------------------------------------------- /src/App/App.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { type AppRootProps, type GrafanaTheme2 } from '@grafana/data'; 3 | import { config } from '@grafana/runtime'; 4 | import { useStyles2 } from '@grafana/ui'; 5 | import React from 'react'; 6 | 7 | import { initFaro } from 'tracking/faro/faro'; 8 | 9 | import { ErrorView } from './ErrorView'; 10 | import { Onboarding } from './Onboarding'; 11 | import { AppRoutes } from './Routes'; 12 | import { useCatchExceptions } from './useCatchExceptions'; 13 | import { useReportAppInitialized } from './useReportAppInitialized'; 14 | import { MetricsContext, useTrail } from './useTrail'; 15 | import { isPrometheusDataSource } from '../utils/utils.datasource'; 16 | import { PluginPropsContext } from '../utils/utils.plugin'; 17 | 18 | initFaro(); 19 | 20 | const prometheusDatasources = Object.values(config.datasources).filter(isPrometheusDataSource); 21 | 22 | export default function App(props: Readonly) { 23 | const styles = useStyles2(getStyles); 24 | const [error] = useCatchExceptions(); 25 | const { trail, goToUrlForTrail } = useTrail(); 26 | 27 | useReportAppInitialized(); 28 | 29 | if (error) { 30 | return ( 31 |
32 | 33 |
34 | ); 35 | } 36 | 37 | if (!prometheusDatasources.length) { 38 | return ; 39 | } 40 | 41 | return ( 42 |
43 | 44 | 45 | 46 | 47 | 48 |
49 | ); 50 | } 51 | 52 | function getStyles(theme: GrafanaTheme2) { 53 | return { 54 | appContainer: css({ 55 | display: 'flex', 56 | flexDirection: 'column', 57 | height: '100%', 58 | backgroundColor: theme.colors.background.primary, 59 | }), 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /src/App/ErrorView.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { type GrafanaTheme2 } from '@grafana/data'; 3 | import { TextLink, useStyles2 } from '@grafana/ui'; 4 | import React, { useCallback } from 'react'; 5 | import { useLocation, useNavigate } from 'react-router-dom'; 6 | 7 | import { InlineBanner } from './InlineBanner'; 8 | 9 | type ErrorViewProps = { error: Error }; 10 | 11 | export function ErrorView({ error }: Readonly) { 12 | const styles = useStyles2(getStyles); 13 | 14 | const navigate = useNavigate(); 15 | const { pathname, search } = useLocation(); 16 | 17 | const onClickReload = useCallback(() => { 18 | const searchParams = new URLSearchParams(search); 19 | const newSearchParams = new URLSearchParams(); 20 | 21 | // these are safe keys to keep 22 | ['from', 'to', 'timezone'] 23 | .filter((key) => searchParams.has(key)) 24 | .forEach((key) => newSearchParams.set(key, searchParams.get(key)!)); 25 | 26 | navigate({ pathname, search: newSearchParams.toString() }); 27 | window.location.reload(); 28 | }, [navigate, pathname, search]); 29 | 30 | return ( 31 |
32 | 37 | Please{' '} 38 | 39 | try reloading the page 40 | {' '} 41 | or, if the problem persists, contact your organization admin. Sorry for the inconvenience. 42 | 43 | } 44 | error={error} 45 | errorContext={{ handheldBy: 'React error boundary' }} 46 | /> 47 |
48 | ); 49 | } 50 | 51 | function getStyles(theme: GrafanaTheme2) { 52 | return { 53 | container: css({ 54 | margin: theme.spacing(2), 55 | }), 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/App/InlineBanner.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, type AlertVariant } from '@grafana/ui'; 2 | import React from 'react'; 3 | 4 | import { logger, type ErrorContext } from '../tracking/logger/logger'; 5 | 6 | type InlineBannerProps = { 7 | severity: AlertVariant; 8 | title: string; 9 | message?: string | React.ReactNode; 10 | error?: Error; 11 | errorContext?: ErrorContext; 12 | children?: React.ReactNode; 13 | }; 14 | 15 | export function InlineBanner({ severity, title, message, error, errorContext, children }: Readonly) { 16 | let errorObject; 17 | 18 | if (error) { 19 | errorObject = typeof error === 'string' ? new Error(error) : error; 20 | 21 | logger.error(errorObject, { 22 | ...(errorObject.cause || {}), 23 | ...errorContext, 24 | bannerTitle: title, 25 | }); 26 | } 27 | 28 | return ( 29 | 30 | {errorObject && ( 31 | <> 32 | {errorObject.message || errorObject.toString()} 33 |
34 | 35 | )} 36 | {message} 37 | {children} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/App/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy, useContext } from 'react'; 2 | import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; 3 | 4 | import { ROUTES } from '../constants'; 5 | import { MetricsContext } from './useTrail'; 6 | 7 | const Wingman = lazy(() => import('../pages/TrailWingman')); 8 | 9 | // For /trail links, redirect to /drilldown with the same search params 10 | const TrailRedirect = () => { 11 | const location = useLocation(); 12 | return ; 13 | }; 14 | 15 | export const AppRoutes = () => { 16 | const { trail } = useContext(MetricsContext); 17 | 18 | return ( 19 | 20 | } /> 21 | } /> 22 | {/* catch-all route */} 23 | } /> 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/App/testIds.ts: -------------------------------------------------------------------------------- 1 | export const testIds = { 2 | appConfig: { 3 | container: 'data-testid ac-container', 4 | apiKey: 'data-testid ac-api-key', 5 | apiUrl: 'data-testid ac-api-url', 6 | submit: 'data-testid ac-submit-form', 7 | }, 8 | pageTrail: { 9 | container: 'data-testid pg-trail-container', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/App/useCatchExceptions.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | function ensureErrorObject(error: any, defaultMessage: string): Error { 4 | if (error instanceof Error) { 5 | return error; 6 | } 7 | if (typeof error === 'string') { 8 | return new Error(error); 9 | } 10 | if (typeof error.message === 'string') { 11 | return new Error(error.message); 12 | } 13 | return new Error(defaultMessage); 14 | } 15 | 16 | export function useCatchExceptions(): [Error | undefined, React.Dispatch>] { 17 | const [error, setError] = useState(); 18 | 19 | // even though we wrap the app in an ErrorBoundary, some errors are not caught, 20 | // so we have to set global handlers to catch these (e.g. error thrown from some click handlers) 21 | useEffect(() => { 22 | const onError = (errorEvent: ErrorEvent) => { 23 | setError(ensureErrorObject(errorEvent.error, 'Uncaught exception!')); 24 | }; 25 | 26 | const onUnHandledRejection = (event: PromiseRejectionEvent) => { 27 | // TODO: remove me when we remove MetricSelectScene 28 | // indeed, it seems there's always a cancelled request when landing on the view :man_shrug: 29 | // Ideally, the code in DataTrail should handle the cancellation but we do it here because it's easier 30 | if (event.reason.type === 'cancelled') { 31 | setError(undefined); 32 | return; 33 | } 34 | 35 | setError(ensureErrorObject(event.reason, 'Unhandled rejection!')); 36 | }; 37 | 38 | window.addEventListener('error', onError); 39 | window.addEventListener('unhandledrejection', onUnHandledRejection); 40 | return () => { 41 | window.removeEventListener('unhandledrejection', onUnHandledRejection); 42 | window.removeEventListener('error', onError); 43 | }; 44 | }, []); 45 | 46 | return [error, setError]; 47 | } 48 | -------------------------------------------------------------------------------- /src/App/useReportAppInitialized.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { reportExploreMetrics, type ViewName } from 'interactions'; 4 | 5 | export function useReportAppInitialized() { 6 | const initRef = useRef(false); 7 | 8 | useEffect(() => { 9 | if (!initRef.current) { 10 | initRef.current = true; 11 | 12 | const view: ViewName = new URL(window.location.href).searchParams.get('metric') 13 | ? 'metric-details' 14 | : 'metrics-reducer'; 15 | 16 | reportExploreMetrics('app_initialized', { view }); 17 | } 18 | }, []); 19 | } 20 | -------------------------------------------------------------------------------- /src/App/useTrail.tsx: -------------------------------------------------------------------------------- 1 | import { locationService } from '@grafana/runtime'; 2 | import { createContext, useEffect, useState } from 'react'; 3 | 4 | import { type DataTrail } from 'DataTrail'; 5 | import { getUrlForTrail, newMetricsTrail } from 'utils'; 6 | 7 | import { navigationEvents } from '../WingmanDataTrail/SideBar/sections/BookmarksList'; 8 | 9 | interface MetricsAppContext { 10 | trail: DataTrail; 11 | goToUrlForTrail: (trail: DataTrail) => void; 12 | } 13 | 14 | export const MetricsContext = createContext({ 15 | trail: newMetricsTrail(), 16 | goToUrlForTrail: () => {}, 17 | }); 18 | 19 | export function useTrail() { 20 | const [trail, setTrail] = useState(newMetricsTrail()); 21 | 22 | const goToUrlForTrail = (trail: DataTrail) => { 23 | locationService.push(getUrlForTrail(trail)); 24 | setTrail(trail); 25 | }; 26 | 27 | // Subscribe to navigation events from BookmarksList 28 | useEffect(() => { 29 | const handleNavigation = (trail: DataTrail) => { 30 | goToUrlForTrail(trail); 31 | }; 32 | 33 | // Subscribe to navigation events 34 | const unsubscribe = navigationEvents.subscribe(handleNavigation); 35 | 36 | // Clean up subscription 37 | return () => unsubscribe(); 38 | }, []); 39 | 40 | return { trail, goToUrlForTrail }; 41 | } 42 | -------------------------------------------------------------------------------- /src/Breakdown/AddToFiltersGraphAction.tsx: -------------------------------------------------------------------------------- 1 | import { type DataFrame } from '@grafana/data'; 2 | import { sceneGraph, SceneObjectBase, type SceneComponentProps, type SceneObjectState } from '@grafana/scenes'; 3 | import { Button } from '@grafana/ui'; 4 | import React from 'react'; 5 | 6 | import { reportExploreMetrics } from '../interactions'; 7 | import { getTrailFor } from '../utils'; 8 | import { isAdHocFiltersVariable } from '../utils/utils.variables'; 9 | 10 | export interface AddToFiltersGraphActionState extends SceneObjectState { 11 | frame: DataFrame; 12 | } 13 | 14 | export class AddToFiltersGraphAction extends SceneObjectBase { 15 | public onClick = () => { 16 | const variable = sceneGraph.lookupVariable('filters', this); 17 | if (!isAdHocFiltersVariable(variable)) { 18 | return; 19 | } 20 | 21 | const labels = this.state.frame.fields[1]?.labels ?? {}; 22 | if (Object.keys(labels).length !== 1) { 23 | return; 24 | } 25 | 26 | const labelName = Object.keys(labels)[0]; 27 | reportExploreMetrics('label_filter_changed', { label: labelName, action: 'added', cause: 'breakdown' }); 28 | const trail = getTrailFor(this); 29 | const filter = { 30 | key: labelName, 31 | operator: '=', 32 | value: labels[labelName], 33 | }; 34 | 35 | trail.addFilterWithoutReportingInteraction(filter); 36 | }; 37 | 38 | public static readonly Component = ({ model }: SceneComponentProps) => { 39 | const state = model.useState(); 40 | const labels = state.frame.fields[1]?.labels || {}; 41 | 42 | const canAddToFilters = Object.keys(labels).length !== 0; 43 | 44 | if (!canAddToFilters) { 45 | return null; 46 | } 47 | 48 | return ( 49 | 52 | ); 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/Breakdown/BreakdownSearchScene.tsx: -------------------------------------------------------------------------------- 1 | import { BusEventBase } from '@grafana/data'; 2 | import { SceneObjectBase, type SceneComponentProps, type SceneObjectState } from '@grafana/scenes'; 3 | import React, { type ChangeEvent } from 'react'; 4 | 5 | import { ByFrameRepeater } from './ByFrameRepeater'; 6 | import { LabelBreakdownScene } from './LabelBreakdownScene'; 7 | import { SearchInput } from './SearchInput'; 8 | 9 | export class BreakdownSearchReset extends BusEventBase { 10 | public static readonly type = 'breakdown-search-reset'; 11 | } 12 | 13 | export interface BreakdownSearchSceneState extends SceneObjectState { 14 | filter?: string; 15 | } 16 | 17 | const recentFilters: Record = {}; 18 | 19 | export class BreakdownSearchScene extends SceneObjectBase { 20 | private cacheKey: string; 21 | 22 | constructor(cacheKey: string) { 23 | super({ 24 | filter: recentFilters[cacheKey] ?? '', 25 | }); 26 | this.cacheKey = cacheKey; 27 | } 28 | 29 | public static readonly Component = ({ model }: SceneComponentProps) => { 30 | const { filter } = model.useState(); 31 | return ( 32 | 38 | ); 39 | }; 40 | 41 | public onValueFilterChange = (event: ChangeEvent) => { 42 | this.setState({ filter: event.target.value }); 43 | this.filterValues(event.target.value); 44 | }; 45 | 46 | public clearValueFilter = () => { 47 | this.setState({ filter: '' }); 48 | this.filterValues(''); 49 | }; 50 | 51 | public reset = () => { 52 | this.setState({ filter: '' }); 53 | recentFilters[this.cacheKey] = ''; 54 | }; 55 | 56 | private filterValues(filter: string) { 57 | if (this.parent instanceof LabelBreakdownScene) { 58 | recentFilters[this.cacheKey] = filter; 59 | const body = this.parent.state.body; 60 | body?.forEachChild((child) => { 61 | if (child instanceof ByFrameRepeater && child.state.body.isActive) { 62 | child.filterByString(filter); 63 | } 64 | }); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Breakdown/SearchInput.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { Icon, Input } from '@grafana/ui'; 3 | import React, { type HTMLProps } from 'react'; 4 | 5 | interface Props extends Omit, 'width'> { 6 | onClear(): void; 7 | } 8 | 9 | export const SearchInput = ({ value, onChange, placeholder, onClear, ...rest }: Props) => { 10 | return ( 11 | : undefined 16 | } 17 | prefix={} 18 | placeholder={placeholder} 19 | {...rest} 20 | /> 21 | ); 22 | }; 23 | 24 | const styles = { 25 | clearIcon: css({ 26 | cursor: 'pointer', 27 | }), 28 | }; 29 | -------------------------------------------------------------------------------- /src/Breakdown/panelConfigs.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/src/Breakdown/panelConfigs.ts -------------------------------------------------------------------------------- /src/Breakdown/types.test.ts: -------------------------------------------------------------------------------- 1 | import { isBreakdownLayoutType } from './types'; 2 | 3 | describe('types', () => { 4 | it('isBreakdownLayoutType should return true for rows', () => { 5 | const expected = true; 6 | const result = isBreakdownLayoutType('rows'); 7 | expect(result).toBe(expected); 8 | }); 9 | 10 | it('isBreakdownLayoutType should return false for undefined', () => { 11 | const expected = false; 12 | const result1 = isBreakdownLayoutType(undefined); 13 | const result2 = isBreakdownLayoutType('undefined'); 14 | const result3 = isBreakdownLayoutType(null); 15 | const result4 = isBreakdownLayoutType('null'); 16 | expect(result1).toBe(expected); 17 | expect(result2).toBe(expected); 18 | expect(result3).toBe(expected); 19 | expect(result4).toBe(expected); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/Breakdown/types.ts: -------------------------------------------------------------------------------- 1 | const BREAKDOWN_LAYOUT_TYPES = ['single', 'grid', 'rows'] as const; 2 | 3 | export type BreakdownLayoutType = (typeof BREAKDOWN_LAYOUT_TYPES)[number]; 4 | 5 | export function isBreakdownLayoutType( 6 | breakdownLayoutType: string | null | undefined 7 | ): breakdownLayoutType is BreakdownLayoutType { 8 | return BREAKDOWN_LAYOUT_TYPES.includes(breakdownLayoutType as BreakdownLayoutType); 9 | } 10 | 11 | export type BreakdownLayoutChangeCallback = (newBreakdownLayout: BreakdownLayoutType) => void; 12 | -------------------------------------------------------------------------------- /src/Breakdown/utils.ts: -------------------------------------------------------------------------------- 1 | import { type SelectableValue } from '@grafana/data'; 2 | import { sceneGraph, type QueryVariable, type SceneObject } from '@grafana/scenes'; 3 | 4 | import { VAR_FILTERS } from '../shared'; 5 | import { isAdHocFiltersVariable } from '../utils/utils.variables'; 6 | 7 | export function getLabelOptions(scenObject: SceneObject, variable: QueryVariable) { 8 | const labelFilters = sceneGraph.lookupVariable(VAR_FILTERS, scenObject); 9 | const labelOptions: Array> = []; 10 | 11 | if (!isAdHocFiltersVariable(labelFilters)) { 12 | return []; 13 | } 14 | 15 | const filters = labelFilters.state.filters; 16 | 17 | for (const option of variable.getOptionsForSelect()) { 18 | const filterExists = filters.find((f) => f.key === option.value); 19 | 20 | if (option.label === 'le') { 21 | // Do not show the "le" label 22 | continue; 23 | } 24 | if (filterExists) { 25 | continue; 26 | } 27 | labelOptions.push({ label: option.label, value: String(option.value) }); 28 | } 29 | 30 | return labelOptions; 31 | } 32 | 33 | interface Type extends Function { 34 | new (...args: any[]): T; 35 | } 36 | export function findSceneObjectByType(scene: SceneObject, sceneType: Type) { 37 | const targetScene = sceneGraph.findObject(scene, (obj) => obj instanceof sceneType); 38 | 39 | if (targetScene instanceof sceneType) { 40 | return targetScene; 41 | } 42 | 43 | return null; 44 | } 45 | 46 | export function findSceneObjectsByType(scene: SceneObject, sceneType: Type) { 47 | function isSceneType(scene: SceneObject): scene is T { 48 | return scene instanceof sceneType; 49 | } 50 | 51 | const targetScenes = sceneGraph.findAllObjects(scene, isSceneType); 52 | return targetScenes.filter(isSceneType); 53 | } 54 | -------------------------------------------------------------------------------- /src/BreakdownLabelSelector.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { type GrafanaTheme2, type SelectableValue } from '@grafana/data'; 3 | import { Combobox, useStyles2 } from '@grafana/ui'; 4 | import React from 'react'; 5 | 6 | type Props = { 7 | options: Array>; 8 | value?: string; 9 | onChange: (label: string | undefined) => void; 10 | }; 11 | 12 | export function BreakdownLabelSelector({ options, value, onChange }: Readonly) { 13 | const styles = useStyles2(getStyles); 14 | 15 | return ( 16 |
17 | ({ label: opt.label || '', value: opt.value || '' }))} 19 | value={value || ''} 20 | onChange={(selected) => onChange(selected?.value)} 21 | width={16} 22 | /> 23 |
24 | ); 25 | } 26 | 27 | function getStyles(theme: GrafanaTheme2) { 28 | return { 29 | select: css({ 30 | maxWidth: theme.spacing(16), 31 | }), 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/Integrations/SceneDrawer.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { type GrafanaTheme2 } from '@grafana/data'; 3 | import { getAppEvents } from '@grafana/runtime'; 4 | import { SceneObjectBase, type SceneComponentProps, type SceneObject, type SceneObjectState } from '@grafana/scenes'; 5 | import { Drawer, useStyles2 } from '@grafana/ui'; 6 | import React from 'react'; 7 | 8 | import { ShowModalReactEvent } from '../utils/utils.events'; 9 | 10 | export type SceneDrawerProps = { 11 | scene: SceneObject; 12 | title: string; 13 | onDismiss: () => void; 14 | }; 15 | 16 | export function SceneDrawer(props: Readonly) { 17 | const { scene, title, onDismiss } = props; 18 | const styles = useStyles2(getStyles); 19 | 20 | return ( 21 | 22 |
23 | 24 |
25 |
26 | ); 27 | } 28 | 29 | interface SceneDrawerAsSceneState extends SceneObjectState, SceneDrawerProps {} 30 | 31 | export class SceneDrawerAsScene extends SceneObjectBase { 32 | constructor(state: SceneDrawerProps) { 33 | super(state); 34 | } 35 | 36 | static Component({ model }: SceneComponentProps) { 37 | const state = model.useState(); 38 | 39 | return ; 40 | } 41 | } 42 | 43 | export function launchSceneDrawerInGlobalModal(props: Omit) { 44 | const payload = { 45 | component: SceneDrawer, 46 | props, 47 | }; 48 | 49 | getAppEvents().publish(new ShowModalReactEvent(payload)); 50 | } 51 | 52 | function getStyles(theme: GrafanaTheme2) { 53 | return { 54 | drawerInnerWrapper: css({ 55 | display: 'flex', 56 | padding: theme.spacing(2), 57 | background: theme.isDark ? theme.colors.background.canvas : theme.colors.background.primary, 58 | position: 'absolute', 59 | left: 0, 60 | right: 0, 61 | top: 0, 62 | }), 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/Integrations/getQueryMetrics.ts: -------------------------------------------------------------------------------- 1 | import { buildVisualQueryFromString, type QueryBuilderLabelFilter } from '@grafana/prometheus'; 2 | 3 | import { isEquals } from './utils'; 4 | 5 | /** An identified metric and its label for a query */ 6 | export type QueryMetric = { 7 | metric: string; 8 | labelFilters: QueryBuilderLabelFilter[]; 9 | query: string; 10 | }; 11 | 12 | export function getQueryMetrics(queries: string[]) { 13 | const queryMetrics: QueryMetric[] = []; 14 | 15 | queries.forEach((query) => { 16 | const struct = buildVisualQueryFromString(query); 17 | if (struct.errors.length > 0) { 18 | return; 19 | } 20 | 21 | const { metric, labels } = struct.query; 22 | 23 | queryMetrics.push({ metric, labelFilters: labels.filter(isEquals), query }); 24 | struct.query.binaryQueries?.forEach(({ query: { metric, labels } }) => { 25 | queryMetrics.push({ metric, labelFilters: labels.filter(isEquals), query }); 26 | }); 27 | }); 28 | 29 | return queryMetrics; 30 | } 31 | -------------------------------------------------------------------------------- /src/Integrations/logs/base.ts: -------------------------------------------------------------------------------- 1 | import { type DataSourceSettings } from '@grafana/data'; 2 | 3 | export type FoundLokiDataSource = Pick; 4 | 5 | /** 6 | * Defines the interface for connecting metrics and their related logs. 7 | * Implementations should provide methods for retrieving Loki data sources associated 8 | * with a metric, and creating a Loki query expression for a given metric and data source. 9 | * 10 | * By using this interface, the `RelatedLogsScene` can orchestrate 11 | * the retrieval of logs without needing to know the specifics of how we're 12 | * associating logs with a given metric. 13 | */ 14 | export interface MetricsLogsConnector { 15 | /** 16 | * The name of the connector 17 | */ 18 | name: string; 19 | 20 | /** 21 | * Whether the conditions are met for related logs to be shown by the connector 22 | */ 23 | checkConditionsMetForRelatedLogs: () => boolean; 24 | 25 | /** 26 | * Retrieves the Loki data sources associated with the specified metric. 27 | */ 28 | getDataSources(selectedMetric: string): Promise; 29 | 30 | /** 31 | * Constructs a Loki query expression for the specified metric and data source. 32 | */ 33 | getLokiQueryExpr(selectedMetric: string, datasourceUid: string): string; 34 | } 35 | 36 | export function createMetricsLogsConnector(connector: T): T { 37 | return connector; 38 | } 39 | -------------------------------------------------------------------------------- /src/Integrations/utils.ts: -------------------------------------------------------------------------------- 1 | import { type QueryBuilderLabelFilter } from '@grafana/prometheus'; 2 | 3 | import { type QueryMetric } from './getQueryMetrics'; // We only support label filters with the '=' operator 4 | 5 | // We only support label filters with the '=' operator 6 | export function isEquals(labelFilter: QueryBuilderLabelFilter) { 7 | return labelFilter.op === '='; 8 | } 9 | 10 | export function getQueryMetricLabel({ metric, labelFilters }: QueryMetric) { 11 | // Don't show the filter unless there is more than one entry 12 | if (labelFilters.length === 0) { 13 | return metric; 14 | } 15 | 16 | const filterExpression = labelFilters.map(({ label, op, value }) => `${label}${op}"${value}"`); 17 | return `${metric}{${filterExpression}}`; 18 | } 19 | 20 | export function createAdHocFilters(labels: QueryBuilderLabelFilter[]) { 21 | return labels?.map((label) => ({ key: label.label, value: label.value, operator: label.op })); 22 | } 23 | -------------------------------------------------------------------------------- /src/MetricSelect/AddToExplorationsButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { PluginExtensionTypes } from '@grafana/data'; 2 | import { setPluginLinksHook } from '@grafana/runtime'; 3 | import { render, screen } from '@testing-library/react'; 4 | import React from 'react'; 5 | 6 | import { 7 | AddToExplorationButton, 8 | addToExplorationsButtonLabel, 9 | investigationsPluginId, 10 | } from './AddToExplorationsButton'; 11 | import { mockPluginLinkExtension } from '../mocks/plugin'; 12 | 13 | jest.mock('@grafana/runtime', () => ({ 14 | ...jest.requireActual('@grafana/runtime'), 15 | setPluginExtensionGetter: jest.fn(), 16 | getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), 17 | useChromeHeaderHeight: jest.fn().mockReturnValue(80), 18 | getBackendSrv: () => { 19 | return { 20 | get: jest.fn(), 21 | }; 22 | }, 23 | getDataSourceSrv: () => { 24 | return { 25 | get: jest.fn().mockResolvedValue({}), 26 | getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }), 27 | }; 28 | }, 29 | getAppEvents: () => ({ 30 | publish: jest.fn(), 31 | }), 32 | })); 33 | 34 | describe('AddToExplorationButton', () => { 35 | it("shouldn't render when a plugin extension link isn't provided by the Explorations app", async () => { 36 | setPluginLinksHook(() => ({ 37 | links: [], 38 | isLoading: false, 39 | })); 40 | const scene = new AddToExplorationButton({}); 41 | render(); 42 | expect(() => screen.getByLabelText(addToExplorationsButtonLabel)).toThrow(); 43 | }); 44 | 45 | it('should render when the Explorations app provides a plugin extension link', async () => { 46 | setPluginLinksHook(() => ({ 47 | links: [ 48 | mockPluginLinkExtension({ 49 | description: addToExplorationsButtonLabel, // this overrides the aria-label 50 | onClick: () => {}, 51 | path: '/a/grafana-investigations-app', 52 | pluginId: investigationsPluginId, 53 | title: 'Explorations', 54 | type: PluginExtensionTypes.link, 55 | }), 56 | ], 57 | isLoading: false, 58 | })); 59 | const scene = new AddToExplorationButton({}); 60 | render(); 61 | const button = screen.getByLabelText(addToExplorationsButtonLabel); 62 | expect(button).toBeInTheDocument(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/MetricSelect/SelectMetricAction.tsx: -------------------------------------------------------------------------------- 1 | import { SceneObjectBase, type SceneComponentProps, type SceneObjectState } from '@grafana/scenes'; 2 | import { Button } from '@grafana/ui'; 3 | import React from 'react'; 4 | 5 | import { MetricSelectedEvent } from '../shared'; 6 | 7 | export interface SelectMetricActionState extends SceneObjectState { 8 | title: string; 9 | metric: string; 10 | } 11 | 12 | export class SelectMetricAction extends SceneObjectBase { 13 | public onClick = () => { 14 | this.publishEvent(new MetricSelectedEvent(this.state.metric), true); 15 | }; 16 | 17 | public static readonly Component = ({ model }: SceneComponentProps) => { 18 | const { title, metric } = model.useState(); 19 | return ( 20 | 23 | ); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/MetricSelect/hideEmptyPreviews.ts: -------------------------------------------------------------------------------- 1 | import { FieldType, LoadingState } from '@grafana/data'; 2 | import { sceneGraph, type SceneCSSGridItem } from '@grafana/scenes'; 3 | 4 | import { MetricSelectScene } from './MetricSelectScene'; 5 | 6 | export function hideEmptyPreviews(metric: string) { 7 | return (gridItem: SceneCSSGridItem) => { 8 | const data = sceneGraph.getData(gridItem); 9 | if (!data) { 10 | return; 11 | } 12 | 13 | data.subscribeToState((state) => { 14 | if (state.data?.state === LoadingState.Loading || state.data?.state === LoadingState.Error) { 15 | return; 16 | } 17 | const scene = sceneGraph.getAncestor(gridItem, MetricSelectScene); 18 | 19 | if (!state.data?.series.length) { 20 | scene.updateMetricPanel(metric, true, true); 21 | return; 22 | } 23 | 24 | let hasValue = false; 25 | for (const frame of state.data.series) { 26 | for (const field of frame.fields) { 27 | if (field.type !== FieldType.number) { 28 | continue; 29 | } 30 | 31 | hasValue = field.values.some((v) => v != null && !isNaN(v) && v !== 0); 32 | if (hasValue) { 33 | break; 34 | } 35 | } 36 | if (hasValue) { 37 | break; 38 | } 39 | } 40 | scene.updateMetricPanel(metric, true, !hasValue); 41 | }); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/MetricSelect/relatedMetrics.ts: -------------------------------------------------------------------------------- 1 | import leven from 'leven'; 2 | 3 | export function sortRelatedMetrics(metricList: string[], metric: string) { 4 | return metricList.sort((aValue, bValue) => { 5 | const a = getLevenDistances(aValue, metric); 6 | const b = getLevenDistances(bValue, metric); 7 | 8 | return a.halfLeven + a.wholeLeven - (b.halfLeven + b.wholeLeven); 9 | }); 10 | } 11 | 12 | type LevenDistances = { halfLeven: number; wholeLeven: number }; 13 | type TargetToLevenDistances = Map; 14 | 15 | const metricToTargetLevenDistances = new Map(); 16 | 17 | // Provides the Levenshtein distance between a metric to be sorted 18 | // and a targetMetric compared to which all other metrics are being sorted 19 | // There are two distances: once for the first half and once for the whole string. 20 | // This operation is not expected to be symmetric; order of parameters matters 21 | // since only `metric` is split. 22 | function getLevenDistances(metric: string, targetMetric: string) { 23 | let targetToDistances: TargetToLevenDistances | undefined = metricToTargetLevenDistances.get(metric); 24 | if (!targetToDistances) { 25 | targetToDistances = new Map(); 26 | metricToTargetLevenDistances.set(metric, targetToDistances); 27 | } 28 | 29 | let distances: LevenDistances | undefined = targetToDistances.get(targetMetric); 30 | if (!distances) { 31 | const metricSplit = metric.split('_'); 32 | const metricHalf = metricSplit.slice(0, metricSplit.length / 2).join('_'); 33 | 34 | const halfLeven = leven(metricHalf, targetMetric!) || 0; 35 | const wholeLeven = leven(metric, targetMetric!) || 0; 36 | 37 | distances = { halfLeven, wholeLeven }; 38 | targetToDistances.set(targetMetric, distances); 39 | } 40 | 41 | return distances; 42 | } 43 | -------------------------------------------------------------------------------- /src/MetricSelect/util.ts: -------------------------------------------------------------------------------- 1 | // Consider any sequence of characters not permitted for metric names as a sepratator 2 | const splitSeparator = /[^a-z0-9_:]+/; 3 | 4 | export function deriveSearchTermsFromInput(whiteSpaceSeparatedTerms?: string) { 5 | return ( 6 | whiteSpaceSeparatedTerms 7 | ?.toLowerCase() 8 | .split(splitSeparator) 9 | .filter((term) => term.length > 0) || [] 10 | ); 11 | } 12 | 13 | export function createJSRegExpFromSearchTerms(searchQuery?: string) { 14 | const searchParts = deriveSearchTermsFromInput(searchQuery).map((part) => `(?=(.*${part.toLowerCase()}.*))`); 15 | 16 | if (searchParts.length === 0) { 17 | return null; 18 | } 19 | 20 | const regex = searchParts.join(''); 21 | // (?=(.*expr1.*)(?=(.*expr2.*))... 22 | // The ?=(...) lookahead allows us to match these in any order. 23 | // we disable the ESLint rule for now, waiting to remove this file completely (see main comment in src/MetricSelect/MetricSelectScene.tsx) 24 | return new RegExp(regex, 'igy'); // eslint-disable-line sonarjs/stateful-regex 25 | } 26 | 27 | export function createPromRegExp(searchQuery?: string) { 28 | const searchParts = getUniqueTerms(deriveSearchTermsFromInput(searchQuery)) 29 | .filter((term) => term.length > 0) 30 | .map((term) => `(.*${term.toLowerCase()}.*)`); 31 | 32 | const count = searchParts.length; 33 | 34 | if (searchParts.length === 0) { 35 | // avoid match[] must contain at least one non-empty matcher 36 | return null; //'..*'; 37 | } 38 | 39 | const regex = `(?i:${searchParts.join('|')}){${count}}`; 40 | // (?i:(.*expr_1.*)|.*expr_2.*)|...|.*expr_n.*){n} 41 | // ?i: to ignore case 42 | // {n} to ensure that it matches n times, one match per term 43 | // - This isn't ideal, since it doesn't enforce that each unique term is matched, 44 | // but it's the best we can do with the Prometheus / Go stdlib implementation of regex. 45 | 46 | return regex; 47 | } 48 | 49 | function getUniqueTerms(terms: string[] = []) { 50 | const set = new Set(terms.map((term) => term.toLowerCase().trim())); 51 | return Array.from(set); 52 | } 53 | -------------------------------------------------------------------------------- /src/MetricsDrilldownDataSourceVariable.ts: -------------------------------------------------------------------------------- 1 | import { config } from '@grafana/runtime'; 2 | import { DataSourceVariable } from '@grafana/scenes'; 3 | 4 | import { VAR_DATASOURCE } from 'shared'; 5 | import { logger } from 'tracking/logger/logger'; 6 | import { isPrometheusDataSource } from 'utils/utils.datasource'; 7 | 8 | export class MetricsDrilldownDataSourceVariable extends DataSourceVariable { 9 | private static LOCAL_STORAGE_KEY = 'metricsDrilldownDataSource'; 10 | 11 | constructor({ initialDS }: { initialDS?: string }) { 12 | super({ 13 | key: VAR_DATASOURCE, 14 | name: VAR_DATASOURCE, 15 | pluginId: 'prometheus', 16 | label: 'Data source', 17 | description: 'Only prometheus data sources are supported', 18 | // if no initialDS is passed to the constructor, we bypass Scenes native behaviour by determining the data source ourselves (see getCurrentDataSource())... 19 | skipUrlSync: !initialDS, 20 | // ... by doing this, we make sure that we'll always have a data source when the "var-ds" URL search param is missing, incorrect, etc. 21 | value: initialDS || MetricsDrilldownDataSourceVariable.getCurrentDataSource(), 22 | }); 23 | 24 | this.addActivationHandler(this.onActivate.bind(this)); 25 | } 26 | 27 | private onActivate() { 28 | this.setState({ skipUrlSync: false }); // restore URL sync 29 | 30 | this.subscribeToState((newState, prevState) => { 31 | if (newState.value && newState.value !== prevState.value) { 32 | // store the new value for future visits 33 | localStorage.setItem(MetricsDrilldownDataSourceVariable.LOCAL_STORAGE_KEY, newState.value as string); 34 | } 35 | }); 36 | } 37 | 38 | private static getCurrentDataSource(): string { 39 | const prometheusDataSources = Object.values(config.datasources).filter((ds) => isPrometheusDataSource(ds)); 40 | 41 | const uidFromUrl = new URL(window.location.href).searchParams.get(`var-${VAR_DATASOURCE}`); 42 | const uidFromLocalStorage = localStorage.getItem(MetricsDrilldownDataSourceVariable.LOCAL_STORAGE_KEY); 43 | 44 | const currentDataSource = 45 | prometheusDataSources.find((ds) => ds.uid === uidFromUrl) || 46 | prometheusDataSources.find((ds) => ds.uid === uidFromLocalStorage) || 47 | prometheusDataSources.find((ds) => ds.isDefault) || 48 | prometheusDataSources[0]; 49 | 50 | if (!currentDataSource) { 51 | logger.warn('Cannot find any Prometheus data source!'); 52 | return 'no-data-source-configured'; 53 | } 54 | 55 | return currentDataSource.uid; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/PluginInfo/PluginLogo.tsx: -------------------------------------------------------------------------------- 1 | import { css, cx } from '@emotion/css'; 2 | import { useStyles2 } from '@grafana/ui'; 3 | import React, { memo } from 'react'; 4 | 5 | type PluginLogoProps = { 6 | size: 'small' | 'large'; 7 | }; 8 | 9 | export const PluginLogo = memo(function PluginLogoComponent({ size }: PluginLogoProps) { 10 | const styles = useStyles2(getStyles); 11 | return ; 12 | }); 13 | 14 | const getStyles = () => ({ 15 | logo: css` 16 | &.small { 17 | width: 24px; 18 | height: 24px; 19 | margin-right: 4px; 20 | position: relative; 21 | top: -2px; 22 | } 23 | 24 | &.large { 25 | width: 40px; 26 | height: 40px; 27 | } 28 | `, 29 | }); 30 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Grafana Metrics Drilldown 2 | 3 | The Grafana Metrics Drilldown app provides a queryless experience for browsing Prometheus-compatible metrics. Quickly find related metrics without writing PromQL queries. 4 | 5 | ## Requirements 6 | 7 | Requires Grafana 11.6.0 or newer. 8 | 9 | ## Getting Started 10 | 11 | See the [docs](https://grafana.com/docs/grafana-cloud/visualizations/simplified-exploration/metrics/) for more info using Grafana Metrics Drilldown. 12 | 13 | ## Documentation 14 | 15 | - [DOCS](https://grafana.com/docs/grafana-cloud/visualizations/simplified-exploration/metrics) 16 | - [CHANGELOG](https://github.com/grafana/metrics-drilldown/releases) 17 | - [GITHUB](https://github.com/grafana/metrics-drilldown) 18 | 19 | ## Contributing 20 | 21 | We love accepting contributions! If your change is minor, please feel free submit a [pull request](https://help.github.com/articles/about-pull-requests/). If your change is larger, or adds a feature, please file an issue beforehand so that we can discuss the change. You're welcome to file an implementation pull request immediately as well, although we generally lean towards discussing the change and then reviewing the implementation separately. 22 | 23 | ### Bugs 24 | 25 | If your issue is a bug, please open one [here](https://github.com/grafana/metrics-drilldown/issues/new). 26 | 27 | ### Changes 28 | 29 | We do not have a formal proposal process for changes or feature requests. If you have a change you would like to see in Grafana Metrics Drilldown, please [file an issue](https://github.com/grafana/metrics-drilldown/issues/new) with the necessary details. 30 | -------------------------------------------------------------------------------- /src/RelatedLogs/NoRelatedLogsFound.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { type GrafanaTheme2 } from '@grafana/data'; 3 | import { Alert, Stack, Text, TextLink, useStyles2 } from '@grafana/ui'; 4 | import React from 'react'; 5 | 6 | export function NoRelatedLogs() { 7 | const styles = useStyles2(getStyles); 8 | 9 | return ( 10 | 11 | 12 | We couldn't find any logs related to the current metric with your selected filters. 13 | 14 | 15 | To find related logs, try the following: 16 |
    17 |
  • Adjust your label filters to include labels that exist in both the current metric and your logs
  • 18 |
  • 19 | Select a metric created by a{' '} 20 | 21 | Loki Recording Rule 22 | 23 |
  • 24 |
  • Broaden the time range to include more data
  • 25 |
26 |
27 | 28 | Note: Related logs is an experimental feature. 29 | 30 |
31 | ); 32 | } 33 | 34 | function getStyles(theme: GrafanaTheme2) { 35 | return { 36 | list: css({ 37 | paddingLeft: theme.spacing(2), 38 | marginTop: theme.spacing(1), 39 | }), 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/ShareTrailButton.tsx: -------------------------------------------------------------------------------- 1 | import { config } from '@grafana/runtime'; 2 | import { ToolbarButton } from '@grafana/ui'; 3 | import React, { useState } from 'react'; 4 | 5 | import { PLUGIN_BASE_URL } from './constants'; 6 | import { UI_TEXT } from './constants/ui'; 7 | import { type DataTrail } from './DataTrail'; 8 | import { reportExploreMetrics } from './interactions'; 9 | import { getUrlForTrail } from './utils'; 10 | 11 | interface ShareTrailButtonState { 12 | trail: DataTrail; 13 | } 14 | 15 | const COPY_LABEL = UI_TEXT.METRIC_SELECT_SCENE.COPY_URL_LABEL; 16 | 17 | export const ShareTrailButton = ({ trail }: ShareTrailButtonState) => { 18 | const [tooltip, setTooltip] = useState(COPY_LABEL); 19 | 20 | const onShare = () => { 21 | if (navigator.clipboard) { 22 | reportExploreMetrics('selected_metric_action_clicked', { action: 'share_url' }); 23 | const appUrl = config.appUrl.endsWith('/') ? config.appUrl.slice(0, -1) : config.appUrl; 24 | const url = `${appUrl}${PLUGIN_BASE_URL}/${getUrlForTrail(trail)}`; 25 | navigator.clipboard.writeText(url); 26 | setTooltip('Copied!'); 27 | setTimeout(() => { 28 | setTooltip(COPY_LABEL); 29 | }, 2000); 30 | } 31 | }; 32 | 33 | return ; 34 | }; 35 | -------------------------------------------------------------------------------- /src/StatusWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { type GrafanaTheme2 } from '@grafana/data'; 3 | import { LoadingPlaceholder, useStyles2 } from '@grafana/ui'; 4 | import React, { type ReactNode } from 'react'; 5 | 6 | type Props = { 7 | blockingMessage?: string; 8 | isLoading?: boolean; 9 | children?: ReactNode; 10 | }; 11 | 12 | export function StatusWrapper({ blockingMessage, isLoading, children }: Readonly) { 13 | const styles = useStyles2(getStyles); 14 | 15 | if (isLoading && !blockingMessage) { 16 | blockingMessage = 'Loading...'; 17 | } 18 | 19 | if (isLoading) { 20 | return ; 21 | } 22 | 23 | if (!blockingMessage) { 24 | return <>{children}; 25 | } 26 | 27 | return
{blockingMessage}
; 28 | } 29 | 30 | function getStyles(theme: GrafanaTheme2) { 31 | return { 32 | statusMessage: css({ 33 | fontStyle: 'italic', 34 | marginTop: theme.spacing(7), 35 | textAlign: 'center', 36 | width: '100%', 37 | }), 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/TrailStore/useBookmarkState.ts: -------------------------------------------------------------------------------- 1 | import { SceneObjectStateChangedEvent } from '@grafana/scenes'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | import { type DataTrail } from '../DataTrail'; 5 | import { reportExploreMetrics } from '../interactions'; 6 | import { getTrailStore } from './TrailStore'; 7 | 8 | export function useBookmarkState(trail: DataTrail) { 9 | // Note that trail object may stay the same, but the state used by `getBookmarkIndex` result may 10 | // differ for each re-render of this hook 11 | const getBookmarkIndex = () => getTrailStore().getBookmarkIndex(trail); 12 | 13 | const indexOnRender = getBookmarkIndex(); 14 | 15 | const [bookmarkIndex, setBookmarkIndex] = useState(indexOnRender); 16 | 17 | useEffect(() => { 18 | const sub = trail.subscribeToEvent(SceneObjectStateChangedEvent, () => { 19 | setBookmarkIndex(getTrailStore().getBookmarkIndex(trail)); 20 | }); 21 | return () => sub.unsubscribe(); 22 | }, [trail]); 23 | 24 | // Check if index changed and force a re-render 25 | if (indexOnRender !== bookmarkIndex) { 26 | setBookmarkIndex(indexOnRender); 27 | } 28 | 29 | const isBookmarked = bookmarkIndex != null; 30 | 31 | const toggleBookmark = () => { 32 | reportExploreMetrics('bookmark_changed', { action: isBookmarked ? 'toggled_off' : 'toggled_on' }); 33 | if (isBookmarked) { 34 | let indexToRemove = getBookmarkIndex(); 35 | while (indexToRemove != null) { 36 | // This loop will remove all indices that have an equivalent bookmark key 37 | getTrailStore().removeBookmark(indexToRemove); 38 | indexToRemove = getBookmarkIndex(); 39 | } 40 | } else { 41 | getTrailStore().addBookmark(trail); 42 | } 43 | setBookmarkIndex(getBookmarkIndex()); 44 | }; 45 | 46 | const result: [typeof isBookmarked, typeof toggleBookmark] = [isBookmarked, toggleBookmark]; 47 | return result; 48 | } 49 | -------------------------------------------------------------------------------- /src/TrailStore/utils.tsx: -------------------------------------------------------------------------------- 1 | import { AppEvents } from '@grafana/data'; 2 | import { getAppEvents } from '@grafana/runtime'; 3 | import { LinkButton, Stack } from '@grafana/ui'; 4 | import React from 'react'; 5 | 6 | import { ROUTES } from '../constants'; 7 | import { HOME_ROUTE } from '../shared'; 8 | import { currentPathIncludes } from '../utils'; 9 | 10 | export function createBookmarkSavedNotification() { 11 | const appEvents = getAppEvents(); 12 | const isSidebarView = currentPathIncludes(ROUTES.Drilldown); 13 | 14 | const infoText = !isSidebarView ? Drilldown > Metrics : the Metrics Reducer sidebar; 15 | 16 | appEvents.publish({ 17 | type: AppEvents.alertSuccess.name, 18 | payload: [ 19 | 'Bookmark created', 20 | 21 |
You can view bookmarks under {infoText}
22 | {!isSidebarView && ( 23 | 24 | View bookmarks 25 | 26 | )} 27 |
, 28 | ], 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/GroupBy/MetricsWithLabelValue/MetricsWithLabelValueVariable.ts: -------------------------------------------------------------------------------- 1 | import { VariableHide, VariableRefresh } from '@grafana/data'; 2 | import { QueryVariable, sceneGraph } from '@grafana/scenes'; 3 | 4 | import { VAR_FILTERS, VAR_FILTERS_EXPR } from 'shared'; 5 | import { withLifecycleEvents } from 'WingmanDataTrail/MetricsVariables/withLifecycleEvents'; 6 | 7 | import { MetricsWithLabelValueDataSource } from './MetricsWithLabelValueDataSource'; 8 | 9 | export const VAR_METRIC_WITH_LABEL_VALUE = 'metrics-with-label-value'; 10 | 11 | export class MetricsWithLabelValueVariable extends QueryVariable { 12 | constructor({ 13 | labelName, 14 | labelValue, 15 | removeRules, 16 | }: { 17 | labelName: string; 18 | labelValue: string; 19 | removeRules?: boolean; 20 | }) { 21 | super({ 22 | key: `${VAR_METRIC_WITH_LABEL_VALUE}-${labelName}-${labelValue}`, 23 | name: VAR_METRIC_WITH_LABEL_VALUE, 24 | datasource: { uid: MetricsWithLabelValueDataSource.uid }, 25 | query: MetricsWithLabelValueVariable.buildQuery(labelName, labelValue, removeRules), 26 | isMulti: false, 27 | allowCustomValue: false, 28 | refresh: VariableRefresh.onTimeRangeChanged, 29 | hide: VariableHide.hideVariable, 30 | skipUrlSync: true, 31 | // BOTH "value" and "includeAll" below ensure the repetition in SceneByVariableRepeater 32 | // // (if not set, it'll render only the 1st variable option) 33 | value: '$__all', 34 | includeAll: true, 35 | }); 36 | 37 | this.addActivationHandler(this.onActivate.bind(this, labelName, labelValue, removeRules)); 38 | 39 | // required for filtering and sorting 40 | return withLifecycleEvents(this); 41 | } 42 | 43 | protected onActivate(labelName: string, labelValue: string, removeRules?: boolean) { 44 | const adHocFiltersVariable = sceneGraph.lookupVariable(VAR_FILTERS, this); 45 | 46 | if (adHocFiltersVariable?.state.hide !== VariableHide.hideVariable) { 47 | this.setState({ 48 | query: MetricsWithLabelValueVariable.buildQuery(labelName, labelValue, removeRules), 49 | }); 50 | } 51 | } 52 | 53 | private static buildQuery(labelName: string, labelValue: string, removeRules?: boolean) { 54 | return removeRules 55 | ? `removeRules{${labelName}="${labelValue}",${VAR_FILTERS_EXPR}}` 56 | : `{${labelName}="${labelValue}",${VAR_FILTERS_EXPR}}`; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/Labels/LabelValuesVariable.tsx: -------------------------------------------------------------------------------- 1 | import { VariableHide, VariableRefresh } from '@grafana/data'; 2 | import { QueryVariable } from '@grafana/scenes'; 3 | import React from 'react'; 4 | 5 | import { LabelsDataSource } from './LabelsDataSource'; 6 | 7 | export const VAR_LABEL_VALUES = 'wingmanLabelValues'; 8 | 9 | export class LabelValuesVariable extends QueryVariable { 10 | constructor({ labelName }: { labelName: string }) { 11 | super({ 12 | name: VAR_LABEL_VALUES, 13 | datasource: { uid: LabelsDataSource.uid }, 14 | // just some syntax we make up so that the data source can decide what to fetch 15 | query: `valuesOf(${labelName})`, 16 | isMulti: false, 17 | allowCustomValue: false, 18 | refresh: VariableRefresh.onTimeRangeChanged, 19 | hide: VariableHide.hideVariable, 20 | // BOTH "value" and "includeAll" below ensure the repetition in SceneByVariableRepeater 21 | // // (if not set, it'll render only the 1st variable option) 22 | value: '$__all', 23 | includeAll: true, 24 | }); 25 | } 26 | 27 | public static readonly Component = () => { 28 | return <>; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/ListControls/LayoutSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | SceneObjectBase, 3 | SceneObjectUrlSyncConfig, 4 | type SceneComponentProps, 5 | type SceneObjectState, 6 | type SceneObjectUrlValues, 7 | } from '@grafana/scenes'; 8 | import { RadioButtonGroup } from '@grafana/ui'; 9 | import React from 'react'; 10 | 11 | export enum LayoutType { 12 | GRID = 'grid', 13 | ROWS = 'rows', 14 | } 15 | 16 | export interface LayoutSwitcherState extends SceneObjectState { 17 | layout: LayoutType; 18 | onChange?: (layout: LayoutType) => void; 19 | } 20 | 21 | export class LayoutSwitcher extends SceneObjectBase { 22 | protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['layout'] }); 23 | 24 | static readonly OPTIONS = [ 25 | { label: 'Grid', value: LayoutType.GRID }, 26 | { label: 'Rows', value: LayoutType.ROWS }, 27 | ]; 28 | 29 | static readonly DEFAULT_LAYOUT = LayoutType.GRID; 30 | 31 | constructor() { 32 | super({ 33 | key: 'layout-switcher', 34 | layout: LayoutSwitcher.DEFAULT_LAYOUT, 35 | }); 36 | } 37 | 38 | getUrlState() { 39 | return { 40 | layout: this.state.layout, 41 | }; 42 | } 43 | 44 | updateFromUrl(values: SceneObjectUrlValues) { 45 | const stateUpdate: Partial = {}; 46 | 47 | if (typeof values.layout === 'string' && values.layout !== this.state.layout) { 48 | stateUpdate.layout = Object.values(LayoutType).includes(values.layout as LayoutType) 49 | ? (values.layout as LayoutType) 50 | : LayoutSwitcher.DEFAULT_LAYOUT; 51 | } 52 | 53 | this.setState(stateUpdate); 54 | } 55 | 56 | onChange = (layout: LayoutType) => { 57 | this.setState({ layout }); 58 | }; 59 | 60 | static readonly Component = ({ model }: SceneComponentProps) => { 61 | const { layout } = model.useState(); 62 | 63 | return ( 64 | 71 | ); 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/ListControls/ListControls.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { type SelectableValue } from '@grafana/data'; 3 | import { 4 | EmbeddedScene, 5 | SceneFlexItem, 6 | SceneFlexLayout, 7 | type SceneComponentProps, 8 | type SceneObjectState, 9 | type SceneReactObject, 10 | type SceneVariableSet, 11 | } from '@grafana/scenes'; 12 | import { useStyles2 } from '@grafana/ui'; 13 | import React from 'react'; 14 | 15 | import { LayoutSwitcher } from './LayoutSwitcher'; 16 | import { MetricsSorter } from './MetricsSorter/MetricsSorter'; 17 | import { QuickSearch } from './QuickSearch/QuickSearch'; 18 | 19 | interface ListControlsState extends SceneObjectState { 20 | $variables?: SceneVariableSet; 21 | inputControls?: SceneReactObject; 22 | onChange?: (value: SelectableValue) => void; // Keeping for backward compatibility 23 | } 24 | 25 | // @ts-ignore to fix build error. Is there a Scenes friend way of doing this? 26 | export class ListControls extends EmbeddedScene { 27 | constructor(state: Partial) { 28 | super({ 29 | ...state, 30 | key: 'list-controls', 31 | body: new SceneFlexLayout({ 32 | direction: 'row', 33 | width: '100%', 34 | maxHeight: '32px', 35 | children: [ 36 | new SceneFlexItem({ 37 | body: new QuickSearch(), 38 | }), 39 | new SceneFlexItem({ 40 | width: 'auto', 41 | body: new MetricsSorter({}), 42 | }), 43 | new SceneFlexItem({ 44 | width: 'auto', 45 | body: new LayoutSwitcher(), 46 | }), 47 | ], 48 | }), 49 | }); 50 | } 51 | 52 | public static readonly Component = ({ model }: SceneComponentProps) => { 53 | const styles = useStyles2(getStyles); 54 | const { body } = model.useState(); 55 | 56 | return ( 57 |
58 | 59 |
60 | ); 61 | }; 62 | } 63 | 64 | function getStyles() { 65 | return { 66 | headerWrapper: css({ 67 | display: 'flex', 68 | alignItems: 'center', 69 | '& > div': { 70 | display: 'flex', 71 | alignItems: 'center', 72 | '& > div': { 73 | display: 'flex', 74 | alignItems: 'center', 75 | }, 76 | }, 77 | }), 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/ListControls/MetricsSorter/MetricUsageFetcher.ts: -------------------------------------------------------------------------------- 1 | import { fetchAlertingMetrics } from './fetchers/fetchAlertingMetrics'; 2 | import { fetchDashboardMetrics } from './fetchers/fetchDashboardMetrics'; 3 | import { type SortingOption } from './MetricsSorter'; 4 | 5 | interface MetricsUsageState { 6 | metrics: Record; 7 | metricsPromise: Promise> | undefined; 8 | fetcher: () => Promise>; 9 | } 10 | 11 | export type MetricUsageType = Exclude; 12 | 13 | export class MetricUsageFetcher { 14 | private _usageState: Record = { 15 | 'dashboard-usage': { 16 | metrics: {}, 17 | metricsPromise: undefined, 18 | fetcher: fetchDashboardMetrics, 19 | }, 20 | 'alerting-usage': { 21 | metrics: {}, 22 | metricsPromise: undefined, 23 | fetcher: fetchAlertingMetrics, 24 | }, 25 | }; 26 | 27 | public getUsageMetrics(usageType: MetricUsageType): Promise> { 28 | const hasExistingMetrics = 29 | this._usageState[usageType].metrics && Object.keys(this._usageState[usageType].metrics).length > 0; 30 | 31 | if (hasExistingMetrics) { 32 | return Promise.resolve(this._usageState[usageType].metrics); 33 | } 34 | 35 | if (!this._usageState[usageType].metricsPromise) { 36 | this._usageState[usageType].metricsPromise = this._usageState[usageType].fetcher().then((metrics) => { 37 | this._usageState[usageType].metrics = metrics; 38 | this._usageState[usageType].metricsPromise = undefined; 39 | return metrics; 40 | }); 41 | } 42 | 43 | return this._usageState[usageType].metricsPromise; 44 | } 45 | 46 | public getUsageForMetric(metricName: string, usageType: MetricUsageType): Promise { 47 | return this.getUsageMetrics(usageType).then((metrics) => metrics[metricName] ?? 0); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/ListControls/MetricsSorter/events/EventSortByChanged.ts: -------------------------------------------------------------------------------- 1 | import { BusEventWithPayload } from '@grafana/data'; 2 | 3 | import { type SortingOption } from '../MetricsSorter'; 4 | 5 | export interface EventSortByChangedPayload { 6 | sortBy: SortingOption; 7 | } 8 | 9 | export class EventSortByChanged extends BusEventWithPayload { 10 | public static readonly type = 'sort-by-changed'; 11 | } 12 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/ListControls/QuickSearch/EventQuickSearchChanged.ts: -------------------------------------------------------------------------------- 1 | import { BusEventWithPayload } from '@grafana/data'; 2 | 3 | export interface EventQuickSearchChangedPayload { 4 | searchText: string; 5 | } 6 | 7 | export class EventQuickSearchChanged extends BusEventWithPayload { 8 | public static readonly type = 'quick-search-changed'; 9 | } 10 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricVizPanel/__tests__/parseMatchers.test.ts: -------------------------------------------------------------------------------- 1 | import { parseMatcher } from '../parseMatcher'; 2 | 3 | describe('parseMatcher(matcher)', () => { 4 | test.each([ 5 | ['alertname=ErrorRatioBreach', { key: 'alertname', operator: '=', value: 'ErrorRatioBreach' }], 6 | ['alertname!=ErrorRatioBreach', { key: 'alertname', operator: '!=', value: 'ErrorRatioBreach' }], 7 | ['alertname=~ErrorRatioBreach', { key: 'alertname', operator: '=~', value: 'ErrorRatioBreach' }], 8 | ['alertname=~Error.+', { key: 'alertname', operator: '=~', value: 'Error.+' }], 9 | ['alertname=~.+Error', { key: 'alertname', operator: '=~', value: '.+Error' }], 10 | ['alertname!~ErrorRatioBreach', { key: 'alertname', operator: '!~', value: 'ErrorRatioBreach' }], 11 | ['alertname!~Error.+', { key: 'alertname', operator: '!~', value: 'Error.+' }], 12 | ['alertname!~.+Error', { key: 'alertname', operator: '!~', value: '.+Error' }], 13 | ['p99<42', { key: 'p99', operator: '<', value: '42' }], 14 | ['p99>42', { key: 'p99', operator: '>', value: '42' }], 15 | ])('%s', (matcher, expectedFilter) => { 16 | expect(parseMatcher(matcher)).toStrictEqual(expectedFilter); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricVizPanel/actions/ApplyAction.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { SceneObjectBase, type SceneComponentProps, type SceneObjectState } from '@grafana/scenes'; 3 | import { Button, useStyles2 } from '@grafana/ui'; 4 | import React from 'react'; 5 | 6 | import { type PrometheusFn } from './ConfigureAction'; 7 | import { EventApplyFunction } from './EventApplyFunction'; 8 | 9 | interface ApplyActionState extends SceneObjectState { 10 | metricName: string; 11 | prometheusFunction: PrometheusFn; 12 | disabled?: boolean; 13 | } 14 | 15 | export class ApplyAction extends SceneObjectBase { 16 | constructor({ metricName, prometheusFunction, disabled }: ApplyActionState) { 17 | super({ 18 | key: `apply-action-${metricName}`, 19 | metricName, 20 | prometheusFunction, 21 | disabled: Boolean(disabled), 22 | }); 23 | } 24 | 25 | public onClick = (event: React.MouseEvent) => { 26 | const { metricName, prometheusFunction } = this.state; 27 | 28 | event.preventDefault(); 29 | 30 | this.publishEvent( 31 | new EventApplyFunction({ 32 | metricName, 33 | prometheusFunction, 34 | }), 35 | true 36 | ); 37 | }; 38 | 39 | public static readonly Component = ({ model }: SceneComponentProps) => { 40 | const styles = useStyles2(getStyles); 41 | const { disabled } = model.useState(); 42 | 43 | return ( 44 | 54 | ); 55 | }; 56 | } 57 | 58 | const getStyles = () => ({ 59 | selectButton: css``, 60 | }); 61 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricVizPanel/actions/ConfigureAction.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { SceneObjectBase, type SceneComponentProps, type SceneObjectState } from '@grafana/scenes'; 3 | import { Button, useStyles2 } from '@grafana/ui'; 4 | import React from 'react'; 5 | 6 | import { EventConfigureFunction } from './EventConfigureFunction'; 7 | 8 | interface ConfigureActionState extends SceneObjectState { 9 | metricName: string; 10 | } 11 | 12 | export class ConfigureAction extends SceneObjectBase { 13 | static readonly PROMETHEUS_FN_OPTIONS = [ 14 | { label: 'Average', value: 'avg' }, 15 | { label: 'Sum', value: 'sum' }, 16 | { label: 'Minimum', value: 'min' }, 17 | { label: 'Maximum', value: 'max' }, 18 | { label: 'Rate', value: 'rate' }, 19 | ] as const; 20 | 21 | constructor({ metricName }: { metricName: ConfigureActionState['metricName'] }) { 22 | super({ 23 | key: `configure-action-${metricName}`, 24 | metricName, 25 | }); 26 | } 27 | 28 | public onClick = () => { 29 | this.publishEvent(new EventConfigureFunction({ metricName: this.state.metricName }), true); 30 | }; 31 | 32 | public static readonly Component = ({ model }: SceneComponentProps) => { 33 | const styles = useStyles2(getStyles); 34 | 35 | return ( 36 | 48 | ); 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricVizPanel/getPrometheusMetricType.ts: -------------------------------------------------------------------------------- 1 | export type PrometheusMetricType = 'counter' | 'gauge' | 'histogram'; 2 | 3 | export function getPrometheusMetricType(metricName: string) { 4 | if (metricName.endsWith('count')) { 5 | return 'counter'; 6 | } 7 | 8 | if (metricName.endsWith('bucket')) { 9 | return 'histogram'; 10 | } 11 | 12 | return 'gauge'; 13 | } 14 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricVizPanel/panels/buildHeatmapPanel.ts: -------------------------------------------------------------------------------- 1 | import { PanelBuilders, type SceneObject, type SceneQueryRunner } from '@grafana/scenes'; 2 | import { HeatmapColorMode } from '@grafana/schema/dist/esm/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen'; 3 | 4 | interface PanelProps { 5 | panelTitle: string; 6 | queryRunner: SceneQueryRunner; 7 | hideLegend: boolean; 8 | headerActions: SceneObject[]; 9 | } 10 | 11 | export function buildHeatmapPanel({ panelTitle, queryRunner, headerActions, hideLegend }: PanelProps) { 12 | return ( 13 | PanelBuilders.heatmap() 14 | .setTitle(panelTitle) 15 | .setData(queryRunner) 16 | .setOption('calculate', false) 17 | .setOption('color', { 18 | mode: HeatmapColorMode.Scheme, 19 | exponent: 0.5, 20 | scheme: 'Spectral', 21 | steps: 32, 22 | reverse: false, 23 | }) 24 | // we clone to prevent Scenes warnings "SceneObject already has a parent set that is different from the new parent. You cannot share the same SceneObject instance in multiple scenes or in multiple different places of the same scene graph. Use SceneObject.clone() to duplicate a SceneObject or store a state key reference and use sceneGraph.findObject to locate it." 25 | .setHeaderActions(headerActions.map((action) => action.clone())) 26 | .setOption('legend', { show: !hideLegend }) 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricVizPanel/panels/buildStatusHistoryPanel.ts: -------------------------------------------------------------------------------- 1 | import { PanelBuilders, type SceneObject, type SceneQueryRunner } from '@grafana/scenes'; 2 | import { MappingType, ThresholdsMode, VisibilityMode } from '@grafana/schema'; 3 | 4 | interface PanelProps { 5 | panelTitle: string; 6 | headerActions: SceneObject[]; 7 | queryRunner: SceneQueryRunner; 8 | } 9 | 10 | export function buildStatusHistoryPanel({ panelTitle, headerActions, queryRunner }: PanelProps) { 11 | queryRunner.setState({ maxDataPoints: 100 }); 12 | 13 | return ( 14 | PanelBuilders.statushistory() 15 | .setTitle(panelTitle) 16 | // we clone to prevent Scenes warnings "SceneObject already has a parent set that is different from the new parent. You cannot share the same SceneObject instance in multiple scenes or in multiple different places of the same scene graph. Use SceneObject.clone() to duplicate a SceneObject or store a state key reference and use sceneGraph.findObject to locate it." 17 | .setHeaderActions(headerActions.map((action) => action.clone())) 18 | .setData(queryRunner) 19 | .setColor({ mode: 'thresholds' }) // Set color mode to enable threshold coloring 20 | .setMappings([ 21 | { 22 | type: MappingType.ValueToText, 23 | options: { 24 | '0': { 25 | color: 'red', 26 | text: 'down', 27 | }, 28 | '1': { 29 | color: 'green', 30 | text: 'up', 31 | }, 32 | }, 33 | }, 34 | ]) 35 | .setThresholds({ 36 | mode: ThresholdsMode.Absolute, 37 | steps: [ 38 | { value: 0, color: 'red' }, 39 | { value: 1, color: 'green' }, 40 | ], 41 | }) 42 | // Hide the threshold annotations 43 | .setOption('legend', { showLegend: false }) 44 | .setOption('showValue', VisibilityMode.Never) 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricVizPanel/panels/buildTimeseriesPanel.ts: -------------------------------------------------------------------------------- 1 | import { PanelBuilders, type SceneObject, type SceneQueryRunner } from '@grafana/scenes'; 2 | 3 | interface PanelProps { 4 | panelTitle: string; 5 | color: string; 6 | queryRunner: SceneQueryRunner; 7 | hideLegend: boolean; 8 | headerActions: SceneObject[]; 9 | } 10 | 11 | export function buildTimeseriesPanel({ panelTitle, queryRunner, color, headerActions, hideLegend }: PanelProps) { 12 | return ( 13 | PanelBuilders.timeseries() 14 | .setTitle(panelTitle) 15 | .setData(queryRunner) 16 | .setColor({ mode: 'fixed', fixedColor: color }) 17 | .setCustomFieldConfig('fillOpacity', 9) 18 | // we clone to prevent Scenes warnings "SceneObject already has a parent set that is different from the new parent. You cannot share the same SceneObject instance in multiple scenes or in multiple different places of the same scene graph. Use SceneObject.clone() to duplicate a SceneObject or store a state key reference and use sceneGraph.findObject to locate it." 19 | .setHeaderActions(headerActions.map((action) => action.clone())) 20 | .setOption('legend', { showLegend: !hideLegend }) 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricVizPanel/parseMatcher.ts: -------------------------------------------------------------------------------- 1 | type Filter = { 2 | key: string; 3 | operator: '=' | '!=' | '=~' | '!~' | '<' | '>'; 4 | value: string; 5 | }; 6 | 7 | export function parseMatcher(matcher: string): Filter { 8 | // eslint-disable-next-line sonarjs/slow-regex 9 | const [, rawKey, rawOperator, rawValue] = matcher.match(/([a-z0-9]+)(>|<|!~|=~|!=|=)(.+)/i) || [, '', '', '']; 10 | return { 11 | key: rawKey.trim(), 12 | value: rawValue.replace(/['" ]/g, ''), 13 | operator: rawOperator.trim() as Filter['operator'], 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/EventMetricsVariableActivated.ts: -------------------------------------------------------------------------------- 1 | import { BusEventWithPayload } from '@grafana/data'; 2 | 3 | export interface EventMetricsVariableActivatedPayload { 4 | key: string; 5 | } 6 | 7 | export class EventMetricsVariableActivated extends BusEventWithPayload { 8 | public static readonly type = 'metrics-variable-activated'; 9 | } 10 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/EventMetricsVariableDeactivated.ts: -------------------------------------------------------------------------------- 1 | import { BusEventWithPayload } from '@grafana/data'; 2 | 3 | export interface EventMetricsVariableDeactivatedPayload { 4 | key: string; 5 | } 6 | 7 | export class EventMetricsVariableDeactivated extends BusEventWithPayload { 8 | public static readonly type = 'metrics-variable-deactivated'; 9 | } 10 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/EventMetricsVariableLoaded.ts: -------------------------------------------------------------------------------- 1 | import { BusEventWithPayload } from '@grafana/data'; 2 | import { type VariableValueOption } from '@grafana/scenes'; 3 | 4 | export interface EventMetricsVariableLoadedPayload { 5 | key: string; 6 | options: VariableValueOption[]; 7 | } 8 | 9 | export class EventMetricsVariableLoaded extends BusEventWithPayload { 10 | public static readonly type = 'metrics-variable-loaded'; 11 | } 12 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/FilteredMetricsVariable.ts: -------------------------------------------------------------------------------- 1 | import { VAR_FILTERS } from 'shared'; 2 | import { NULL_GROUP_BY_VALUE } from 'WingmanDataTrail/Labels/LabelsDataSource'; 3 | 4 | import { MetricsVariable } from './MetricsVariable'; 5 | import { withLifecycleEvents } from './withLifecycleEvents'; 6 | 7 | export const VAR_FILTERED_METRICS_VARIABLE = 'filtered-metrics-wingman'; 8 | 9 | export class FilteredMetricsVariable extends MetricsVariable { 10 | constructor() { 11 | super({ 12 | key: VAR_FILTERED_METRICS_VARIABLE, 13 | name: VAR_FILTERED_METRICS_VARIABLE, 14 | label: 'Filtered Metrics', 15 | }); 16 | 17 | // required for filtering and sorting 18 | return withLifecycleEvents(this); 19 | } 20 | 21 | public updateGroupByQuery(groupByValue: string) { 22 | const matcher = 23 | groupByValue && groupByValue !== NULL_GROUP_BY_VALUE ? `${groupByValue}!="",$${VAR_FILTERS}` : `$${VAR_FILTERS}`; 24 | 25 | const query = `label_values({${matcher}}, __name__)`; 26 | 27 | if (query !== this.state.query) { 28 | this.setState({ query }); 29 | this.refreshOptions(); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/MetricsVariable.ts: -------------------------------------------------------------------------------- 1 | import { VariableHide, VariableRefresh, VariableSort } from '@grafana/data'; 2 | import { QueryVariable, type SceneObjectState } from '@grafana/scenes'; 3 | 4 | import { trailDS, VAR_FILTERS } from 'shared'; 5 | 6 | export const VAR_METRICS_VARIABLE = 'metrics-wingman'; 7 | 8 | export type MetricOptions = Array<{ label: string; value: string }>; 9 | 10 | interface MetricsVariableState extends SceneObjectState { 11 | key: string; 12 | name?: string; 13 | label?: string; 14 | } 15 | 16 | export class MetricsVariable extends QueryVariable { 17 | constructor(state?: MetricsVariableState) { 18 | super({ 19 | key: VAR_METRICS_VARIABLE, 20 | name: VAR_METRICS_VARIABLE, 21 | label: 'Metrics', 22 | ...state, 23 | datasource: trailDS, 24 | query: `label_values({$${VAR_FILTERS}}, __name__)`, 25 | includeAll: true, 26 | value: '$__all', 27 | skipUrlSync: true, 28 | refresh: VariableRefresh.onTimeRangeChanged, 29 | sort: VariableSort.alphabeticalAsc, 30 | hide: VariableHide.hideVariable, 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/computeMetricPrefixGroups.ts: -------------------------------------------------------------------------------- 1 | import { localeCompare } from 'WingmanDataTrail/helpers/localCompare'; 2 | 3 | const NONE_PREFIX = ''; 4 | 5 | export function computeMetricPrefixGroups(options: Array<{ label: string; value: string }>) { 6 | const rawPrefixesMap = new Map(); 7 | 8 | for (const option of options) { 9 | const parts = option.value.split(/[^a-z0-9]/i); 10 | const key = parts.length <= 1 ? option.value : parts[0]; 11 | const values = rawPrefixesMap.get(key) ?? []; 12 | 13 | values.push(option.value); 14 | rawPrefixesMap.set(key || NONE_PREFIX, values); 15 | } 16 | 17 | const prefixesMap = new Map(); 18 | 19 | for (const [prefix, values] of rawPrefixesMap) { 20 | prefixesMap.set(prefix, values.length); 21 | } 22 | 23 | return Array.from(prefixesMap.entries()) 24 | .sort((a, b) => { 25 | if (a[1] !== b[1]) { 26 | return b[1] - a[1]; 27 | } 28 | 29 | return localeCompare(a[0], b[0]); 30 | }) 31 | .map(([value, count]) => ({ 32 | value, 33 | count, 34 | label: value, 35 | })); 36 | } 37 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/computeMetricSuffixGroups.ts: -------------------------------------------------------------------------------- 1 | import { localeCompare } from 'WingmanDataTrail/helpers/localCompare'; 2 | 3 | const NONE_SUFFIX = ''; 4 | 5 | export function computeMetricSuffixGroups(options: Array<{ label: string; value: string }>) { 6 | const rawSuffixesMap = new Map(); 7 | 8 | for (const option of options) { 9 | const parts = option.value.split(/[^a-z0-9]/i); 10 | const key = parts.length <= 1 ? option.value : parts[parts.length - 1]; 11 | const values = rawSuffixesMap.get(key) ?? []; 12 | 13 | values.push(option.value); 14 | rawSuffixesMap.set(key || NONE_SUFFIX, values); 15 | } 16 | 17 | const suffixesMap = new Map(); 18 | 19 | for (const [suffix, values] of rawSuffixesMap) { 20 | suffixesMap.set(suffix, values.length); 21 | } 22 | 23 | return Array.from(suffixesMap.entries()) 24 | .sort((a, b) => { 25 | if (a[1] !== b[1]) { 26 | return b[1] - a[1]; 27 | } 28 | 29 | return localeCompare(a[0], b[0]); 30 | }) 31 | .map(([value, count]) => ({ 32 | value, 33 | count, 34 | label: value, 35 | })); 36 | } 37 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/computeRulesGroups.ts: -------------------------------------------------------------------------------- 1 | import { ruleGroupLabels } from './metricLabels'; 2 | 3 | type MetricType = 'metrics' | 'rules'; 4 | 5 | export function computeRulesGroups(options: Array<{ label: string; value: string }>) { 6 | const rulesMap = new Map([ 7 | ['metrics', []], 8 | ['rules', []], 9 | ]); 10 | 11 | for (const option of options) { 12 | const { value } = option; 13 | const key: MetricType = /:/i.test(value) ? 'rules' : 'metrics'; 14 | 15 | const values = rulesMap.get(key) ?? []; 16 | values.push(value); 17 | rulesMap.set(key, values); 18 | } 19 | 20 | return [ 21 | { value: '^(?!.*:.*)', label: ruleGroupLabels.metrics, count: rulesMap.get('metrics')!.length }, 22 | { value: ':', label: ruleGroupLabels.rules, count: rulesMap.get('rules')!.length }, 23 | ]; 24 | } 25 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/helpers/areArraysEqual.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from 'lodash'; 2 | 3 | export const areArraysEqual = (array1: any[], array2: any[]) => 4 | array1.length === array2.length && isEqual(array1, array2); 5 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/metricLabels.ts: -------------------------------------------------------------------------------- 1 | // Constants for metric rule type labels 2 | export const LABEL_METRICS = 'Non-rules metrics'; 3 | export const LABEL_RULES = 'Recording rules'; 4 | 5 | export const ruleGroupLabels = { 6 | metrics: LABEL_METRICS, 7 | rules: LABEL_RULES, 8 | } as const; 9 | 10 | export type RuleGroupLabel = (typeof ruleGroupLabels)[keyof typeof ruleGroupLabels]; 11 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/MetricsVariables/withLifecycleEvents.ts: -------------------------------------------------------------------------------- 1 | import { type MultiValueVariable, type MultiValueVariableState } from '@grafana/scenes'; 2 | 3 | import { EventMetricsVariableActivated } from './EventMetricsVariableActivated'; 4 | import { EventMetricsVariableDeactivated } from './EventMetricsVariableDeactivated'; 5 | import { EventMetricsVariableLoaded } from './EventMetricsVariableLoaded'; 6 | 7 | /** 8 | * Adds the publication of lifecycle events to a metrics variable: 9 | * 10 | * - `EventMetricsVariableActivated` 11 | * - `EventMetricsVariableDeactivated` 12 | * - `EventMetricsVariableLoaded` 13 | * 14 | * This is particularly useful for filtering and sorting the variable options, while keeping the 15 | * different pieces of code decoupled. 16 | * 17 | * The filtering and sorting logic is centralized in the `MetricsReducer` class. 18 | */ 19 | export function withLifecycleEvents(variable: T): T { 20 | const key = variable.state.key as string; 21 | 22 | if (!key) { 23 | throw new TypeError( 24 | `Variable "${variable.state.name}" has no key. Please provide a key in order to publish its lifecycle events.` 25 | ); 26 | } 27 | 28 | variable.addActivationHandler(() => { 29 | variable.publishEvent(new EventMetricsVariableActivated({ key }), true); 30 | 31 | variable.subscribeToState((newState: MultiValueVariableState, prevState: MultiValueVariableState) => { 32 | if (!newState.loading && prevState.loading) { 33 | variable.publishEvent(new EventMetricsVariableLoaded({ key, options: newState.options }), true); 34 | } 35 | }); 36 | 37 | return () => { 38 | variable.publishEvent(new EventMetricsVariableDeactivated({ key }), true); 39 | }; 40 | }); 41 | 42 | return variable; 43 | } 44 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/SceneDrawer.tsx: -------------------------------------------------------------------------------- 1 | import { SceneObjectBase, type SceneComponentProps, type SceneObject, type SceneObjectState } from '@grafana/scenes'; 2 | import { Drawer } from '@grafana/ui'; 3 | import React from 'react'; 4 | 5 | interface SceneDrawerState extends SceneObjectState { 6 | key?: string; 7 | isOpen?: boolean; 8 | title?: string; 9 | subTitle?: string; 10 | body?: SceneObject; 11 | } 12 | 13 | export class SceneDrawer extends SceneObjectBase { 14 | constructor(state?: SceneDrawerState) { 15 | super({ 16 | key: 'drawer', 17 | isOpen: false, 18 | ...state, 19 | }); 20 | } 21 | 22 | open = ({ 23 | title, 24 | subTitle, 25 | body, 26 | }: { 27 | title?: SceneDrawerState['title']; 28 | subTitle?: SceneDrawerState['subTitle']; 29 | body?: SceneDrawerState['body']; 30 | }) => { 31 | this.setState({ ...this.state, isOpen: true, title, subTitle, body }); 32 | }; 33 | 34 | close = () => { 35 | this.setState({ isOpen: false }); 36 | }; 37 | 38 | static readonly Component = ({ model }: SceneComponentProps) => { 39 | const { isOpen, title, subTitle, body } = model.useState(); 40 | 41 | return ( 42 | <> 43 | {body && isOpen && ( 44 | 45 | 46 | 47 | )} 48 | 49 | ); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/ShowMoreButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@grafana/ui'; 2 | import React, { type MouseEventHandler } from 'react'; 3 | 4 | type ShowMoreButtonProps = { 5 | label: string; 6 | batchSizes: { 7 | increment: number; 8 | current: number; 9 | total: number; 10 | }; 11 | onClick: MouseEventHandler; 12 | tooltip?: string; 13 | }; 14 | 15 | export function ShowMoreButton({ label, batchSizes, onClick, tooltip }: Readonly) { 16 | return ( 17 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/SideBar/SideBarButton.tsx: -------------------------------------------------------------------------------- 1 | import { css, cx } from '@emotion/css'; 2 | import { availableIconsIndex, type GrafanaTheme2, type IconName } from '@grafana/data'; 3 | import { Button, useStyles2 } from '@grafana/ui'; 4 | import React from 'react'; 5 | 6 | import { GroupsIcon } from './custom-icons/GroupsIcon'; 7 | import { RulesIcon } from './custom-icons/RulesIcon'; 8 | 9 | const CustomIcons = new Map([ 10 | ['rules', RulesIcon], 11 | ['groups', GroupsIcon], 12 | ]); 13 | 14 | type SideBarButtonProps = { 15 | ariaLabel: string; 16 | disabled: boolean; 17 | visible: boolean; 18 | active: boolean; 19 | tooltip: string; 20 | onClick: () => void; 21 | iconOrText: string | IconName; 22 | }; 23 | 24 | export function SideBarButton({ 25 | ariaLabel, 26 | disabled, 27 | visible, 28 | active, 29 | tooltip, 30 | iconOrText, 31 | onClick, 32 | }: Readonly) { 33 | const styles = useStyles2(getStyles); 34 | 35 | let buttonIcon; 36 | let ButtonChild; 37 | 38 | if (iconOrText in availableIconsIndex) { 39 | buttonIcon = iconOrText as IconName; 40 | } else if (CustomIcons.has(iconOrText)) { 41 | // some icons are not available in the Saga Design System and have been added as SVG files to the code base 42 | ButtonChild = CustomIcons.get(iconOrText); 43 | } else { 44 | ButtonChild = function ButtonChildText() { 45 | return <>{iconOrText}; 46 | }; 47 | } 48 | 49 | return ( 50 | 64 | ); 65 | } 66 | 67 | function getStyles(theme: GrafanaTheme2) { 68 | return { 69 | button: css({ 70 | margin: 0, 71 | color: theme.colors.text.secondary, 72 | '&:hover': { 73 | color: theme.colors.text.maxContrast, 74 | background: 'transparent', 75 | }, 76 | '&.disabled:hover': { 77 | color: theme.colors.text.secondary, 78 | }, 79 | '&.visible': { 80 | color: theme.colors.text.maxContrast, 81 | }, 82 | '&.active': { 83 | color: theme.colors.text.maxContrast, 84 | }, 85 | }), 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/SideBar/custom-icons/GroupsIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function GroupsIcon() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/SideBar/custom-icons/RulesIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function RulesIcon() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/SideBar/sections/EventSectionValueChanged.ts: -------------------------------------------------------------------------------- 1 | import { BusEventWithPayload } from '@grafana/data'; 2 | 3 | export interface EventSectionValueChangedPayload { 4 | key: string; 5 | values: string[]; 6 | } 7 | 8 | export class EventSectionValueChanged extends BusEventWithPayload { 9 | public static readonly type = 'section-value-changed'; 10 | } 11 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/SideBar/sections/MetricsFilterSection/CheckboxWithCount.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { type GrafanaTheme2 } from '@grafana/data'; 3 | import { Checkbox, useStyles2 } from '@grafana/ui'; 4 | import React from 'react'; 5 | 6 | export const CheckboxWithCount = ({ 7 | label, 8 | count, 9 | checked, 10 | onChange, 11 | }: { 12 | label: string; 13 | count: number; 14 | checked: boolean; 15 | onChange: (e: React.ChangeEvent) => void; 16 | }) => { 17 | const styles = useStyles2(getStyles); 18 | 19 | return ( 20 |
21 | 22 | ({count}) 23 |
24 | ); 25 | }; 26 | 27 | function getStyles(theme: GrafanaTheme2) { 28 | return { 29 | checkboxWrapper: css({ 30 | display: 'flex', 31 | alignItems: 'center', 32 | width: '100%', 33 | '& label *': { 34 | fontSize: '14px !important', 35 | whiteSpace: 'nowrap', 36 | overflow: 'hidden', 37 | textOverflow: 'ellipsis', 38 | }, 39 | }), 40 | count: css({ 41 | color: theme.colors.text.secondary, 42 | marginLeft: theme.spacing(0.5), 43 | display: 'inline-block', 44 | }), 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/SideBar/sections/MetricsFilterSection/EventFiltersChanged.ts: -------------------------------------------------------------------------------- 1 | import { BusEventWithPayload } from '@grafana/data'; 2 | 3 | import { type MetricFilters } from 'WingmanDataTrail/MetricsVariables/MetricsVariableFilterEngine'; 4 | 5 | export interface EventFiltersChangedPayload { 6 | type: keyof MetricFilters; 7 | filters: string[]; 8 | } 9 | 10 | export class EventFiltersChanged extends BusEventWithPayload { 11 | public static readonly type = 'filters-changed'; 12 | } 13 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/SideBar/sections/SectionTitle.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { type GrafanaTheme2 } from '@grafana/data'; 3 | import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; 4 | import React from 'react'; 5 | 6 | type SectionTitleProps = { 7 | title: string; 8 | description: string; 9 | }; 10 | 11 | export function SectionTitle({ title, description }: Readonly) { 12 | const styles = useStyles2(getStyles); 13 | 14 | return ( 15 |
16 | {title} 17 | 18 | 19 | 20 |
21 | ); 22 | } 23 | 24 | function getStyles(theme: GrafanaTheme2) { 25 | return { 26 | title: css({ 27 | fontSize: '15px', 28 | fontWeight: theme.typography.fontWeightLight, 29 | borderBottom: `1px solid ${theme.colors.border.weak}`, 30 | paddingBottom: theme.spacing(0.5), 31 | }), 32 | infoIcon: css({ 33 | marginLeft: theme.spacing(1), 34 | cursor: 'pointer', 35 | color: theme.colors.text.secondary, 36 | position: 'relative', 37 | top: '-4px', 38 | }), 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/SideBar/sections/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { type GrafanaTheme2 } from '@grafana/data'; 3 | import { SceneObjectBase, type SceneComponentProps } from '@grafana/scenes'; 4 | import { useStyles2 } from '@grafana/ui'; 5 | import React from 'react'; 6 | 7 | import { SectionTitle } from './SectionTitle'; 8 | import { type SideBarSectionState } from './types'; 9 | 10 | export interface SettingsState extends SideBarSectionState {} 11 | 12 | export class Settings extends SceneObjectBase { 13 | constructor({ 14 | key, 15 | title, 16 | description, 17 | icon, 18 | disabled, 19 | }: { 20 | key: SettingsState['key']; 21 | title: SettingsState['title']; 22 | description: SettingsState['description']; 23 | icon: SettingsState['icon']; 24 | disabled?: SettingsState['disabled']; 25 | }) { 26 | super({ 27 | key, 28 | title, 29 | description, 30 | icon, 31 | disabled: disabled ?? false, 32 | active: false, 33 | }); 34 | 35 | this.addActivationHandler(this.onActivate.bind(this)); 36 | } 37 | 38 | private onActivate() {} 39 | 40 | public static readonly Component = ({ model }: SceneComponentProps) => { 41 | const styles = useStyles2(getStyles); 42 | const { title, description } = model.useState(); 43 | 44 | return ( 45 |
46 | 47 |
48 | ); 49 | }; 50 | } 51 | 52 | function getStyles(theme: GrafanaTheme2) { 53 | return { 54 | container: css({ 55 | display: 'flex', 56 | flexDirection: 'column', 57 | gap: theme.spacing(1), 58 | height: '100%', 59 | overflowY: 'hidden', 60 | }), 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/SideBar/sections/types.ts: -------------------------------------------------------------------------------- 1 | import { type IconName } from '@grafana/data'; 2 | import { type SceneComponent, type SceneObjectBase, type SceneObjectState } from '@grafana/scenes'; 3 | 4 | export interface SideBarSectionState extends SceneObjectState { 5 | key: string; 6 | title: string; 7 | description: string; 8 | icon: IconName | string; 9 | disabled: boolean; 10 | active: boolean; 11 | } 12 | 13 | export interface SideBarSection extends SceneObjectBase { 14 | Component: SceneComponent; 15 | } 16 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/helpers/displayStatus.ts: -------------------------------------------------------------------------------- 1 | import { AppEvents } from '@grafana/data'; 2 | import { getAppEvents } from '@grafana/runtime'; 3 | 4 | const logger = console; 5 | 6 | export function displayError(error: Error, msgs: string[]) { 7 | const context = msgs.reduce((acc, msg, i) => ({ ...acc, [`info${i + 1}`]: msg }), { handheldBy: 'displayError' }); 8 | 9 | logger.error(error, context); 10 | 11 | getAppEvents().publish({ 12 | type: AppEvents.alertError.name, 13 | payload: msgs, 14 | }); 15 | } 16 | 17 | export function displayWarning(msgs: string[]) { 18 | logger.warn(msgs); 19 | 20 | getAppEvents().publish({ 21 | type: AppEvents.alertWarning.name, 22 | payload: msgs, 23 | }); 24 | } 25 | 26 | export function displaySuccess(msgs: string[]) { 27 | getAppEvents().publish({ 28 | type: AppEvents.alertSuccess.name, 29 | payload: msgs, 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/helpers/isPrometheusRule.ts: -------------------------------------------------------------------------------- 1 | export const isPrometheusRule = (metricName: string) => 2 | metricName === 'ALERTS' || metricName === 'ALERTS_FOR_STATE' || metricName.includes(':'); 3 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/helpers/localCompare.ts: -------------------------------------------------------------------------------- 1 | export const localeCompare = new Intl.Collator('en', { sensitivity: 'base' }).compare; 2 | -------------------------------------------------------------------------------- /src/WingmanDataTrail/helpers/registerRuntimeDataSources.ts: -------------------------------------------------------------------------------- 1 | import { registerRuntimeDataSource, type RuntimeDataSource } from '@grafana/scenes'; 2 | 3 | import { displayError } from './displayStatus'; 4 | 5 | export function registerRuntimeDataSources(dataSources: RuntimeDataSource[]) { 6 | try { 7 | for (const dataSource of dataSources) { 8 | registerRuntimeDataSource({ dataSource }); 9 | } 10 | } catch (error) { 11 | const { message } = error as Error; 12 | 13 | if (!/A runtime data source with uid (.+) has already been registered/.test(message)) { 14 | displayError(error as Error, [ 15 | 'Fail to register all the runtime data sources!', 16 | 'The application cannot work as expected, please try reloading the page or if the problem persists, contact your organization admin.', 17 | ]); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/autoQuery/components/AutoVizPanelQuerySelector.tsx: -------------------------------------------------------------------------------- 1 | import { SceneObjectBase, type SceneComponentProps, type SceneObjectState } from '@grafana/scenes'; 2 | import { RadioButtonGroup } from '@grafana/ui'; 3 | import React from 'react'; 4 | 5 | import { getMetricSceneFor } from '../../utils'; 6 | import { type AutoQueryDef } from '../types'; 7 | 8 | interface QuerySelectorState extends SceneObjectState { 9 | queryDef: AutoQueryDef; 10 | onChangeQuery: (variant: string) => void; 11 | options?: Array<{ 12 | label: string; 13 | value: string; 14 | }>; 15 | } 16 | 17 | export class AutoVizPanelQuerySelector extends SceneObjectBase { 18 | constructor(state: QuerySelectorState) { 19 | super(state); 20 | this.addActivationHandler(this._onActivate.bind(this)); 21 | } 22 | 23 | private _onActivate() { 24 | const { autoQuery } = getMetricSceneFor(this).state; 25 | 26 | if (autoQuery.variants.length === 0) { 27 | return; 28 | } 29 | 30 | this.setState({ options: autoQuery.variants.map((q) => ({ label: q.variant, value: q.variant })) }); 31 | } 32 | 33 | public static readonly Component = ({ model }: SceneComponentProps) => { 34 | const { options, onChangeQuery, queryDef } = model.useState(); 35 | 36 | if (!options) { 37 | return null; 38 | } 39 | 40 | return ; 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/autoQuery/getAutoQueriesForMetric.ts: -------------------------------------------------------------------------------- 1 | import { isValidLegacyName } from '@grafana/prometheus'; 2 | 3 | import { createDefaultMetricQueryDefs } from './queryGenerators/default'; 4 | import { createHistogramMetricQueryDefs } from './queryGenerators/histogram'; 5 | import { createSummaryMetricQueryDefs } from './queryGenerators/summary'; 6 | import { type AutoQueryContext, type AutoQueryInfo } from './types'; 7 | import { getUnit } from './units'; 8 | 9 | export function getAutoQueriesForMetric(metric: string, nativeHistogram?: boolean): AutoQueryInfo { 10 | const isUtf8Metric = !isValidLegacyName(metric); 11 | const metricParts = metric.split('_'); 12 | const suffix = metricParts.at(-1); 13 | 14 | // If the suffix is null or is in the set of unsupported suffixes, throw an error because the metric should be delegated to a different generator (summary or histogram) 15 | if (suffix == null) { 16 | throw new Error(`This function does not support a metric suffix of "${suffix}"`); 17 | } 18 | 19 | const unitSuffix = metricParts.at(-2); 20 | const unit = getUnit(unitSuffix); 21 | const ctx: AutoQueryContext = { 22 | metricParts, 23 | isUtf8Metric, 24 | suffix, 25 | unitSuffix, 26 | unit, 27 | nativeHistogram, 28 | }; 29 | 30 | if (suffix === 'sum') { 31 | return createSummaryMetricQueryDefs(ctx); 32 | } 33 | 34 | if (suffix === 'bucket' || nativeHistogram) { 35 | return createHistogramMetricQueryDefs(ctx); 36 | } 37 | 38 | return createDefaultMetricQueryDefs(ctx); 39 | } 40 | -------------------------------------------------------------------------------- /src/autoQuery/graphBuilders.ts: -------------------------------------------------------------------------------- 1 | import { PanelBuilders } from '@grafana/scenes'; 2 | import { SortOrder, TooltipDisplayMode } from '@grafana/schema'; 3 | import { HeatmapColorMode } from '@grafana/schema/dist/esm/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen'; 4 | 5 | export type CommonVizParams = { 6 | title: string; 7 | unit: string; 8 | }; 9 | 10 | export function simpleGraphBuilder({ title, unit }: CommonVizParams) { 11 | return PanelBuilders.timeseries() // 12 | .setTitle(title) 13 | .setUnit(unit) 14 | .setOption('legend', { showLegend: false }) 15 | .setOption('tooltip', { mode: TooltipDisplayMode.Multi, sort: SortOrder.Descending }) 16 | .setCustomFieldConfig('fillOpacity', 9); 17 | } 18 | 19 | export function heatmapGraphBuilder({ title, unit }: CommonVizParams) { 20 | return PanelBuilders.heatmap() // 21 | .setTitle(title) 22 | .setUnit(unit) 23 | .setOption('calculate', false) 24 | .setOption('color', { 25 | mode: HeatmapColorMode.Scheme, 26 | exponent: 0.5, 27 | scheme: 'Spectral', 28 | steps: 32, 29 | reverse: false, 30 | }); 31 | } 32 | 33 | export function percentilesGraphBuilder({ title, unit }: CommonVizParams) { 34 | return PanelBuilders.timeseries() 35 | .setTitle(title) 36 | .setUnit(unit) 37 | .setCustomFieldConfig('fillOpacity', 9) 38 | .setOption('tooltip', { mode: TooltipDisplayMode.Multi, sort: SortOrder.Descending }) 39 | .setOption('legend', { showLegend: false }); 40 | } 41 | -------------------------------------------------------------------------------- /src/autoQuery/queryGenerators/baseQuery.test.ts: -------------------------------------------------------------------------------- 1 | import { generateBaseQuery } from './baseQuery'; 2 | 3 | describe('generateBaseQuery', () => { 4 | it('should return base query without rate and groupings', () => { 5 | const result = generateBaseQuery({}); 6 | expect(result).toBe('${metric}{${filters}}'); 7 | }); 8 | 9 | it('should return rate base query without groupings', () => { 10 | const result = generateBaseQuery({ isRateQuery: true }); 11 | expect(result).toBe('rate(${metric}{${filters}}[$__rate_interval])'); 12 | }); 13 | 14 | it('should return base query with groupings', () => { 15 | const result = generateBaseQuery({ groupings: ['job', 'instance'] }); 16 | expect(result).toBe('sum by(job, instance) (${metric}{${filters}})'); 17 | }); 18 | 19 | it('should return rate base query with groupings', () => { 20 | const result = generateBaseQuery({ isRateQuery: true, groupings: ['job', 'instance'] }); 21 | expect(result).toBe('sum by(job, instance) (rate(${metric}{${filters}}[$__rate_interval]))'); 22 | }); 23 | 24 | it('should return UTF-8 base query without rate and groupings', () => { 25 | const result = generateBaseQuery({ isUtf8Metric: true }); 26 | expect(result).toBe('{"${metric}", ${filters}}'); 27 | }); 28 | 29 | it('should return UTF-8 rate base query without groupings', () => { 30 | const result = generateBaseQuery({ isRateQuery: true, isUtf8Metric: true }); 31 | expect(result).toBe('rate({"${metric}", ${filters}}[$__rate_interval])'); 32 | }); 33 | 34 | it('should return UTF-8 base query with groupings', () => { 35 | const result = generateBaseQuery({ isUtf8Metric: true, groupings: ['job', 'instance'] }); 36 | expect(result).toBe('sum by(job, instance) ({"${metric}", ${filters}})'); 37 | }); 38 | 39 | it('should return UTF-8 rate base query with groupings', () => { 40 | const result = generateBaseQuery({ isRateQuery: true, isUtf8Metric: true, groupings: ['job', 'instance'] }); 41 | expect(result).toBe('sum by(job, instance) (rate({"${metric}", ${filters}}[$__rate_interval]))'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/autoQuery/queryGenerators/baseQuery.ts: -------------------------------------------------------------------------------- 1 | import { VAR_FILTERS_EXPR, VAR_METRIC_EXPR } from '../../shared'; 2 | 3 | // For usual non-utf8-metrics we use filters in the curly braces 4 | // metric_name{filter_label="filter_value"} 5 | const BASE_QUERY_TEMPLATE = `${VAR_METRIC_EXPR}{${VAR_FILTERS_EXPR}}`; 6 | const RATE_BASE_QUERY_TEMPLATE = `rate(${BASE_QUERY_TEMPLATE}[$__rate_interval])`; 7 | 8 | // For utf8 metrics we need to put the metric name inside curly braces with filters 9 | // {"utf8.metric", filter_label="filter_val"} 10 | const BASE_QUERY_UTF8_METRIC_TEMPLATE = `{"${VAR_METRIC_EXPR}", ${VAR_FILTERS_EXPR}}`; 11 | const RATE_BASE_QUERY_UTF8_METRIC_TEMPLATE = `rate(${BASE_QUERY_UTF8_METRIC_TEMPLATE}[$__rate_interval])`; 12 | 13 | export function generateBaseQuery({ 14 | isRateQuery = false, 15 | groupings = [], 16 | isUtf8Metric = false, 17 | }: { 18 | isRateQuery?: boolean; 19 | groupings?: string[]; 20 | isUtf8Metric?: boolean; 21 | }): string { 22 | // Determine base query template 23 | let baseQuery; 24 | 25 | if (isUtf8Metric) { 26 | baseQuery = isRateQuery ? RATE_BASE_QUERY_UTF8_METRIC_TEMPLATE : BASE_QUERY_UTF8_METRIC_TEMPLATE; 27 | } else { 28 | baseQuery = isRateQuery ? RATE_BASE_QUERY_TEMPLATE : BASE_QUERY_TEMPLATE; 29 | } 30 | 31 | // Apply groupings (e.g., `sum by(le, instance)`) 32 | if (groupings.length > 0) { 33 | return `sum by(${groupings.join(', ')}) (${baseQuery})`; 34 | } 35 | 36 | return `${baseQuery}`; 37 | } 38 | -------------------------------------------------------------------------------- /src/autoQuery/queryGenerators/common.ts: -------------------------------------------------------------------------------- 1 | import { VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../../shared'; 2 | import { simpleGraphBuilder } from '../graphBuilders'; 3 | import { type AutoQueryInfo } from '../types'; 4 | 5 | export type CommonQueryInfoParams = { 6 | description: string; 7 | mainQueryExpr: string; 8 | breakdownQueryExpr: string; 9 | unit: string; 10 | }; 11 | 12 | export function generateCommonAutoQueryInfo({ 13 | description, 14 | mainQueryExpr, 15 | breakdownQueryExpr, 16 | unit, 17 | }: CommonQueryInfoParams): AutoQueryInfo { 18 | const common = { 19 | title: VAR_METRIC_EXPR, 20 | unit, 21 | }; 22 | 23 | const mainQuery = { 24 | refId: 'A', 25 | expr: mainQueryExpr, 26 | legendFormat: description, 27 | fromExploreMetrics: true, 28 | }; 29 | 30 | const main = { 31 | ...common, 32 | title: description, 33 | queries: [mainQuery], 34 | variant: 'main', 35 | vizBuilder: () => simpleGraphBuilder({ ...main }), 36 | }; 37 | 38 | const preview = { 39 | ...common, 40 | queries: [{ ...mainQuery, legendFormat: description }], 41 | vizBuilder: () => simpleGraphBuilder(preview), 42 | variant: 'preview', 43 | }; 44 | 45 | const breakdown = { 46 | ...common, 47 | queries: [ 48 | { 49 | refId: 'A', 50 | expr: breakdownQueryExpr, 51 | legendFormat: `{{${VAR_GROUP_BY_EXP}}}`, 52 | fromExploreMetrics: true, 53 | }, 54 | ], 55 | vizBuilder: () => simpleGraphBuilder(breakdown), 56 | variant: 'breakdown', 57 | }; 58 | 59 | return { preview, main, breakdown, variants: [] }; 60 | } 61 | -------------------------------------------------------------------------------- /src/autoQuery/queryGenerators/default.test.ts: -------------------------------------------------------------------------------- 1 | import { type AutoQueryContext } from '../types'; 2 | import { createDefaultMetricQueryDefs } from './default'; 3 | 4 | describe('createDefaultMetricQueryDefs', () => { 5 | it('should generate correct AutoQueryInfo for rate query with UTF-8 metric', () => { 6 | const context: AutoQueryContext = { 7 | metricParts: ['http.requests', 'total'], 8 | suffix: 'total', 9 | isUtf8Metric: true, 10 | unit: 'cps', 11 | }; 12 | 13 | const result = createDefaultMetricQueryDefs(context); 14 | 15 | expect(result.main.title).toBe('${metric} (overall per-second rate)'); 16 | expect(result.main.queries[0].expr).toBe('sum(rate({"${metric}", ${filters}}[$__rate_interval]))'); 17 | expect(result.breakdown.queries[0].expr).toBe( 18 | 'sum(rate({"${metric}", ${filters}}[$__rate_interval]))by(${groupby})' 19 | ); 20 | expect(result.preview.unit).toBe('cps'); 21 | }); 22 | 23 | it('should generate correct AutoQueryInfo for non-rate query without UTF-8 metric', () => { 24 | const context: AutoQueryContext = { 25 | metricParts: ['cpu', 'usage', 'seconds'], 26 | suffix: 'avg', 27 | isUtf8Metric: false, 28 | unit: 's', 29 | }; 30 | 31 | const result = createDefaultMetricQueryDefs(context); 32 | 33 | expect(result.main.title).toBe('${metric} (average)'); 34 | expect(result.main.queries[0].expr).toBe('avg(${metric}{${filters}})'); 35 | expect(result.breakdown.queries[0].expr).toBe('avg(${metric}{${filters}})by(${groupby})'); 36 | expect(result.preview.unit).toBe('none'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/autoQuery/queryGenerators/default.ts: -------------------------------------------------------------------------------- 1 | import { VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../../shared'; 2 | import { type AutoQueryContext, type AutoQueryInfo } from '../types'; 3 | import { getPerSecondRateUnit, getUnit } from '../units'; 4 | import { generateBaseQuery } from './baseQuery'; 5 | import { generateCommonAutoQueryInfo } from './common'; 6 | 7 | const RATE_SUFFIXES = new Set(['count', 'total']); 8 | const SPECIFIC_AGGREGATIONS_FOR_SUFFIX: Record = { 9 | count: 'sum', 10 | total: 'sum', 11 | }; 12 | const aggLabels: Record = { 13 | avg: 'average', 14 | sum: 'overall', 15 | }; 16 | 17 | function getAggLabel(agg: string): string { 18 | return aggLabels[agg] || agg; 19 | } 20 | 21 | export function createDefaultMetricQueryDefs(context: AutoQueryContext): AutoQueryInfo { 22 | const { metricParts, suffix, isUtf8Metric } = context; 23 | const unitSuffix = suffix === 'total' ? metricParts.at(-2) : suffix; 24 | 25 | // Determine query type and unit 26 | const isRateQuery = RATE_SUFFIXES.has(suffix); 27 | const aggregation = SPECIFIC_AGGREGATIONS_FOR_SUFFIX[suffix] || 'avg'; 28 | const unit = isRateQuery ? getPerSecondRateUnit(unitSuffix) : getUnit(unitSuffix); 29 | 30 | // Generate base query and descriptions 31 | const baseQuery = generateBaseQuery({ isRateQuery, isUtf8Metric }); 32 | const aggregationDescription = `${getAggLabel(aggregation)}${isRateQuery ? ' per-second rate' : ''}`; 33 | const description = `${VAR_METRIC_EXPR} (${aggregationDescription})`; 34 | 35 | // Create query expressions 36 | const mainQueryExpr = `${aggregation}(${baseQuery})`; 37 | const breakdownQueryExpr = `${aggregation}(${baseQuery})by(${VAR_GROUP_BY_EXP})`; 38 | 39 | return generateCommonAutoQueryInfo({ 40 | description, 41 | mainQueryExpr, 42 | breakdownQueryExpr, 43 | unit, 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/autoQuery/queryGenerators/summary.test.ts: -------------------------------------------------------------------------------- 1 | import { type AutoQueryContext } from '../types'; 2 | import { createSummaryMetricQueryDefs } from './summary'; 3 | 4 | describe('createSummaryMetricQueryDefs', () => { 5 | it('should generate correct AutoQueryInfo with rate query and UTF-8 metric', () => { 6 | const context: AutoQueryContext = { 7 | metricParts: ['http.requests', 'sum'], 8 | isUtf8Metric: true, 9 | unit: 'ms', 10 | suffix: 'sum', 11 | }; 12 | 13 | const result = createSummaryMetricQueryDefs(context); 14 | 15 | expect(result.preview.title).toBe('${metric}'); 16 | expect(result.main.title).toBe('http.requests (average)'); 17 | expect(result.breakdown.title).toBe('${metric}'); 18 | expect(result.preview.queries[0].expr).toBe( 19 | 'sum(rate({"http.requests_sum", ${filters}}[$__rate_interval]))/sum(rate({"http.requests_count", ${filters}}[$__rate_interval]))' 20 | ); 21 | expect(result.breakdown.queries[0].expr).toBe( 22 | 'sum(rate({"http.requests_sum", ${filters}}[$__rate_interval]))by(${groupby})/sum(rate({"http.requests_count", ${filters}}[$__rate_interval]))by(${groupby})' 23 | ); 24 | expect(result.preview.unit).toBe('ms'); 25 | }); 26 | 27 | it('should generate correct AutoQueryInfo without UTF-8 metric', () => { 28 | const context: AutoQueryContext = { 29 | metricParts: ['cpu', 'usage', 'seconds', 'sum'], 30 | isUtf8Metric: false, 31 | unit: 's', 32 | suffix: 'sum', 33 | }; 34 | 35 | const result = createSummaryMetricQueryDefs(context); 36 | 37 | expect(result.preview.title).toBe('${metric}'); 38 | expect(result.main.title).toBe('cpu_usage_seconds (average)'); 39 | expect(result.breakdown.title).toBe('${metric}'); 40 | expect(result.preview.queries[0].expr).toBe( 41 | 'sum(rate(cpu_usage_seconds_sum{${filters}}[$__rate_interval]))/sum(rate(cpu_usage_seconds_count{${filters}}[$__rate_interval]))' 42 | ); 43 | expect(result.breakdown.queries[0].expr).toBe( 44 | 'sum(rate(cpu_usage_seconds_sum{${filters}}[$__rate_interval]))by(${groupby})/sum(rate(cpu_usage_seconds_count{${filters}}[$__rate_interval]))by(${groupby})' 45 | ); 46 | expect(result.preview.unit).toBe('s'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/autoQuery/queryGenerators/summary.ts: -------------------------------------------------------------------------------- 1 | import { VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../../shared'; 2 | import { type AutoQueryContext, type AutoQueryInfo } from '../types'; 3 | import { generateBaseQuery } from './baseQuery'; 4 | import { generateCommonAutoQueryInfo } from './common'; 5 | 6 | export function createSummaryMetricQueryDefs(context: AutoQueryContext): AutoQueryInfo { 7 | const { metricParts, isUtf8Metric, unit } = context; 8 | const subMetric = metricParts.slice(0, -1).join('_'); 9 | const description = `${subMetric} (average)`; 10 | const baseQuery = generateBaseQuery({ isRateQuery: true, isUtf8Metric }); 11 | const mainQueryExpr = createMeanExpr(`sum(${baseQuery})`, subMetric); 12 | const breakdownQueryExpr = createMeanExpr(`sum(${baseQuery})by(${VAR_GROUP_BY_EXP})`, subMetric); 13 | 14 | return generateCommonAutoQueryInfo({ 15 | description, 16 | mainQueryExpr, 17 | breakdownQueryExpr, 18 | unit, 19 | }); 20 | } 21 | 22 | function createMeanExpr(expr: string, subMetric: string): string { 23 | const numerator = expr.replace(VAR_METRIC_EXPR, `${subMetric}_sum`); 24 | const denominator = expr.replace(VAR_METRIC_EXPR, `${subMetric}_count`); 25 | return `${numerator}/${denominator}`; 26 | } 27 | -------------------------------------------------------------------------------- /src/autoQuery/types.ts: -------------------------------------------------------------------------------- 1 | import { type PromQuery } from '@grafana/prometheus'; 2 | import { type VizPanelBuilder } from '@grafana/scenes'; 3 | 4 | export interface AutoQueryDef { 5 | variant: string; 6 | title: string; 7 | unit: string; 8 | queries: PromQuery[]; 9 | vizBuilder: VizBuilder; 10 | } 11 | 12 | export interface AutoQueryInfo { 13 | preview: AutoQueryDef; 14 | main: AutoQueryDef; 15 | variants: AutoQueryDef[]; 16 | breakdown: AutoQueryDef; 17 | } 18 | 19 | export type VizBuilder = () => VizPanelBuilder<{}, {}>; 20 | 21 | export type AutoQueryContext = { 22 | metricParts: string[]; 23 | isUtf8Metric: boolean; 24 | unit: string; 25 | suffix: string; 26 | unitSuffix?: string; 27 | nativeHistogram?: boolean; 28 | }; 29 | -------------------------------------------------------------------------------- /src/autoQuery/units.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_UNIT = 'none'; 2 | export const DEFAULT_RATE_UNIT = 'cps'; // Count per second 3 | 4 | // Unit constants 5 | export const UNIT_BYTES = 'bytes'; 6 | export const UNIT_SECONDS = 'seconds'; 7 | export const UNIT_PERCENT = 'percent'; 8 | export const UNIT_COUNT = 'count'; 9 | 10 | // Rate unit constants 11 | export const RATE_BYTES_PER_SECOND = 'Bps'; 12 | export const RATE_BITS_PER_SECOND = 'bps'; 13 | 14 | const UNIT_MAP: Record = { 15 | [UNIT_BYTES]: UNIT_BYTES, 16 | [UNIT_SECONDS]: 's', 17 | [UNIT_PERCENT]: UNIT_PERCENT, 18 | [UNIT_COUNT]: DEFAULT_UNIT, 19 | }; 20 | 21 | const UNIT_LIST = Object.keys(UNIT_MAP); // used to check if a metric name contains any of the supported units 22 | 23 | const RATE_UNIT_MAP: Record = { 24 | [UNIT_BYTES]: RATE_BYTES_PER_SECOND, 25 | // seconds per second is unitless 26 | [UNIT_SECONDS]: DEFAULT_UNIT, 27 | [UNIT_COUNT]: DEFAULT_RATE_UNIT, 28 | [UNIT_PERCENT]: UNIT_PERCENT, 29 | }; 30 | 31 | // Get unit from metric name (e.g. "go_gc_duration_seconds" -> "seconds") 32 | export function getUnitFromMetric(metric: string) { 33 | if (!metric) { 34 | return null; 35 | } 36 | 37 | // Get last two parts of the metric name and check if they are valid units 38 | const metricParts = metric.toLowerCase().split('_').slice(-2); 39 | for (let i = metricParts.length - 1; i >= Math.max(0, metricParts.length - 2); i--) { 40 | const part = metricParts[i]; 41 | if (UNIT_LIST.includes(part)) { 42 | return part; 43 | } 44 | } 45 | 46 | return null; 47 | } 48 | 49 | // Get Grafana unit for a panel (e.g. "go_gc_duration_seconds" -> "s") 50 | export function getUnit(metricName: string | undefined) { 51 | if (!metricName) { 52 | return DEFAULT_UNIT; 53 | } 54 | 55 | const metricPart = getUnitFromMetric(metricName); 56 | return (metricPart && UNIT_MAP[metricPart.toLowerCase()]) || DEFAULT_UNIT; 57 | } 58 | 59 | export function getPerSecondRateUnit(metricName: string | undefined) { 60 | if (!metricName) { 61 | return DEFAULT_RATE_UNIT; 62 | } 63 | 64 | const metricPart = getUnitFromMetric(metricName); 65 | 66 | return (metricPart && RATE_UNIT_MAP[metricPart]) || DEFAULT_RATE_UNIT; 67 | } 68 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import pluginJson from './plugin.json'; 2 | 3 | export const PLUGIN_ID = pluginJson.id; 4 | export const PLUGIN_BASE_URL = `/a/${pluginJson.id}`; 5 | 6 | export const ROUTES = { 7 | Trail: 'trail', 8 | Drilldown: 'drilldown', 9 | }; 10 | 11 | export const DATASOURCE_REF = { 12 | uid: 'gdev-testdata', 13 | type: 'testdata', 14 | }; 15 | -------------------------------------------------------------------------------- /src/constants/ui.ts: -------------------------------------------------------------------------------- 1 | export const UI_TEXT = { 2 | SEARCH: { 3 | TITLE: 'Search metrics', 4 | }, 5 | METRIC_SELECT_SCENE: { 6 | OPEN_EXPLORE_LABEL: 'Open in explore', 7 | COPY_URL_LABEL: 'Copy url', 8 | BOOKMARK_LABEL: 'Bookmark', 9 | SELECT_NEW_METRIC_TOOLTIP: 'Remove existing metric and choose a new metric', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/groop/lookup.ts: -------------------------------------------------------------------------------- 1 | export { collectValues, lookup, lookupNode }; 2 | 3 | import { prefixDelimited } from './parser'; 4 | 5 | interface Node { 6 | groups: Map; 7 | values: string[]; 8 | descendants: number; 9 | } 10 | 11 | // lookup finds the node matching the prefix. 12 | // If no match is found, it returns null. 13 | function lookupNode(node: Node, prefix: string): Node | null { 14 | return _lookupNode(node, 0, prefix); 15 | } 16 | 17 | // _lookupNode is the recursive implementation of lookup. 18 | function _lookupNode(node: Node, level: number, prefix: string): Node | null { 19 | const thisPrefix = prefixDelimited(prefix, level); 20 | for (const [k, group] of node.groups.entries()) { 21 | if (k === prefix) { 22 | // perfect match 23 | return group; 24 | } 25 | if (k.startsWith(thisPrefix)) { 26 | return _lookupNode(group, level + 1, prefix); 27 | } 28 | } 29 | return null; 30 | } 31 | 32 | function lookup(node: Node, prefix: string): Node | null { 33 | const groups = new Map(); 34 | const values: string[] = []; 35 | let descendants = 0; 36 | for (const [nodePrefix, group] of node.groups.entries()) { 37 | if (nodePrefix.startsWith(prefix)) { 38 | groups.set(nodePrefix, group); 39 | descendants += group.descendants; 40 | continue; 41 | } 42 | if (prefix.startsWith(nodePrefix)) { 43 | const subGroup = lookup(group, prefix); 44 | if (subGroup) { 45 | groups.set(nodePrefix, subGroup); 46 | descendants += subGroup.descendants; 47 | } 48 | } 49 | } 50 | for (const v of node.values) { 51 | if (v.startsWith(prefix)) { 52 | values.push(v); 53 | descendants += 1; 54 | } 55 | } 56 | if (groups.size === 0 && values.length === 0) { 57 | // nothing partially matching - so just return null 58 | return null; 59 | } 60 | return { groups, values, descendants }; 61 | } 62 | 63 | // collectValues returns all values from the node and its descendants. 64 | function collectValues(node: Node): string[] { 65 | const values: string[] = []; // Specify the type of the 'values' array 66 | function collectFrom(currentNode: Node): void { 67 | values.push(...currentNode.values); 68 | for (const groupNode of currentNode.groups.values()) { 69 | collectFrom(groupNode); 70 | } 71 | } 72 | 73 | collectFrom(node); 74 | return values; 75 | } 76 | -------------------------------------------------------------------------------- /src/helpers/MetricDataSourceHelper.test.ts: -------------------------------------------------------------------------------- 1 | import { DataTrail } from '../DataTrail'; 2 | import { MetricDatasourceHelper } from './MetricDatasourceHelper'; 3 | 4 | jest.mock('@grafana/runtime', () => ({ 5 | ...jest.requireActual('@grafana/runtime'), 6 | config: { 7 | ...jest.requireActual('@grafana/runtime').config, 8 | publicDashboardAccessToken: '123', 9 | }, 10 | })); 11 | 12 | const NATIVE_HISTOGRAM = 'test_metric'; 13 | describe('MetricDatasourceHelper', () => { 14 | let metricDatasourceHelper: MetricDatasourceHelper; 15 | 16 | beforeEach(() => { 17 | const trail = new DataTrail({}); 18 | metricDatasourceHelper = new MetricDatasourceHelper(trail); 19 | metricDatasourceHelper['_classicHistograms'] = { 20 | test_metric_bucket: 1, 21 | }; 22 | }); 23 | 24 | describe('isNativeHistogram', () => { 25 | it('should return false if metric is not provided', async () => { 26 | const result = await metricDatasourceHelper.isNativeHistogram(''); 27 | expect(result).toBe(false); 28 | }); 29 | 30 | it('should return true if metric is a native histogram', async () => { 31 | const result = await metricDatasourceHelper.isNativeHistogram(NATIVE_HISTOGRAM); 32 | expect(result).toBe(true); 33 | }); 34 | 35 | it('should return false if metric is not a native histogram', async () => { 36 | const result = await metricDatasourceHelper.isNativeHistogram('non_histogram_metric'); 37 | expect(result).toBe(false); 38 | }); 39 | 40 | it('should return false if metric is a classic histogram', async () => { 41 | const result = await metricDatasourceHelper.isNativeHistogram('test_metric_bucket'); 42 | expect(result).toBe(false); 43 | }); 44 | 45 | it('should return true if metric is a native histogram and has metadata but does not have a classic histogram to compare to', async () => { 46 | metricDatasourceHelper._metricsMetadata = { 47 | solo_native_histogram: { 48 | type: 'histogram', 49 | help: 'test', 50 | }, 51 | }; 52 | const result = await metricDatasourceHelper.isNativeHistogram('solo_native_histogram'); 53 | expect(result).toBe(true); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/img/breakdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/src/img/breakdown.png -------------------------------------------------------------------------------- /src/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/img/metrics-drilldown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/metrics-drilldown/c13bdaae7bedc0a34176ca68cfa2e6d1f73f91f6/src/img/metrics-drilldown.png -------------------------------------------------------------------------------- /src/mocks/interactionsMock.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | export const reportExploreMetrics = jest.fn(); 4 | export const reportChangeInLabelFilters = jest.fn(); 5 | -------------------------------------------------------------------------------- /src/mocks/loggerMock.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | export const logger = { 4 | trace: jest.fn(), 5 | debug: jest.fn(), 6 | info: jest.fn(), 7 | log: jest.fn(), 8 | warn: jest.fn(), 9 | error: jest.fn(), 10 | }; 11 | -------------------------------------------------------------------------------- /src/mocks/svgMock.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ReactComponent: 'svg', 3 | default: 'svg', 4 | }; 5 | -------------------------------------------------------------------------------- /src/module.tsx: -------------------------------------------------------------------------------- 1 | import { AppPlugin, type AppRootProps } from '@grafana/data'; 2 | import { LoadingPlaceholder } from '@grafana/ui'; 3 | import React, { lazy, Suspense } from 'react'; 4 | 5 | import { linkConfigs } from 'extensions/links'; 6 | 7 | const LazyApp = lazy(async () => { 8 | const { wasmSupported } = await import('./services/sorting'); 9 | const { default: initOutlier } = await import('@bsull/augurs/outlier'); 10 | 11 | if (wasmSupported()) { 12 | await initOutlier(); 13 | console.info('WASM supported'); 14 | } 15 | 16 | return import('./App/App'); 17 | }); 18 | 19 | const App = (props: AppRootProps) => ( 20 | }> 21 | 22 | 23 | ); 24 | 25 | export const plugin = new AppPlugin<{}>().setRootPage(App); 26 | 27 | for (const linkConfig of linkConfigs) { 28 | plugin.addLink(linkConfig); 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/Trail.tsx: -------------------------------------------------------------------------------- 1 | import { UrlSyncContextProvider } from '@grafana/scenes'; 2 | import React, { useEffect, useState } from 'react'; 3 | 4 | import { type DataTrail } from 'DataTrail'; 5 | import { getTrailStore } from 'TrailStore/TrailStore'; 6 | 7 | type TrailProps = { 8 | trail: DataTrail; 9 | }; 10 | 11 | export default function Trail({ trail }: Readonly) { 12 | const [isInitialized, setIsInitialized] = useState(false); 13 | 14 | useEffect(() => { 15 | if (!isInitialized) { 16 | if (trail.state.metric) { 17 | getTrailStore().setRecentTrail(trail); 18 | } 19 | setIsInitialized(true); 20 | } 21 | }, [trail, isInitialized]); 22 | 23 | if (!isInitialized) { 24 | return null; 25 | } 26 | 27 | return ( 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/TrailWingman.tsx: -------------------------------------------------------------------------------- 1 | import { UrlSyncContextProvider } from '@grafana/scenes'; 2 | import React, { useEffect, useState } from 'react'; 3 | 4 | import { getTrailStore } from 'TrailStore/TrailStore'; 5 | 6 | import type { DataTrail } from 'DataTrail'; 7 | 8 | type TrailProps = { 9 | trail: DataTrail; 10 | }; 11 | 12 | export default function Trail({ trail }: Readonly) { 13 | const [isInitialized, setIsInitialized] = useState(false); 14 | 15 | useEffect(() => { 16 | if (!isInitialized) { 17 | if (trail.state.metric) { 18 | getTrailStore().setRecentTrail(trail); 19 | } 20 | setIsInitialized(true); 21 | } 22 | }, [trail, isInitialized]); 23 | 24 | if (!isInitialized) { 25 | return null; 26 | } 27 | 28 | return ( 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json", 3 | "type": "app", 4 | "name": "Grafana Metrics Drilldown", 5 | "id": "grafana-metricsdrilldown-app", 6 | "dependencies": { 7 | "grafanaDependency": ">=11.6.0", 8 | "plugins": [] 9 | }, 10 | "preload": true, 11 | "autoEnabled": true, 12 | "info": { 13 | "keywords": ["drilldown", "metrics", "app", "prometheus", "mimir"], 14 | "description": "Quickly find related metrics with a few clicks, without needing to write PromQL queries to retrieve metrics.", 15 | "author": { 16 | "name": "Grafana" 17 | }, 18 | "logos": { 19 | "small": "img/logo.svg", 20 | "large": "img/logo.svg" 21 | }, 22 | "screenshots": [ 23 | { 24 | "name": "metricselect", 25 | "path": "img/metrics-drilldown.png" 26 | }, 27 | { 28 | "name": "breakdown", 29 | "path": "img/breakdown.png" 30 | } 31 | ], 32 | "version": "%VERSION%", 33 | "updated": "%TODAY%", 34 | "links": [ 35 | { 36 | "name": "GitHub", 37 | "url": "https://github.com/grafana/metrics-drilldown" 38 | }, 39 | { 40 | "name": "Report a bug", 41 | "url": "https://github.com/grafana/metrics-drilldown/issues/new" 42 | } 43 | ] 44 | }, 45 | "includes": [ 46 | { 47 | "type": "page", 48 | "name": "Grafana Metrics Drilldown", 49 | "path": "/a/%PLUGIN_ID%/drilldown", 50 | "action": "datasources:explore", 51 | "addToNav": true, 52 | "defaultNav": true 53 | } 54 | ], 55 | "extensions": { 56 | "addedLinks": [ 57 | { 58 | "targets": ["grafana/dashboard/panel/menu", "grafana/explore/toolbar/action"], 59 | "title": "Open in Grafana Metrics Drilldown", 60 | "description": "Open current query in the Grafana Metrics Drilldown view" 61 | } 62 | ], 63 | "extensionPoints": [ 64 | { 65 | "id": "grafana-exploremetrics-app/investigation/v1" 66 | } 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/services/levels.test.ts: -------------------------------------------------------------------------------- 1 | import { FieldType, toDataFrame } from '@grafana/data'; 2 | 3 | import { getLabelValueFromDataFrame } from './levels'; 4 | 5 | describe('getLabelValueFromDataFrame', () => { 6 | it('returns correct label value from data frame', () => { 7 | expect( 8 | getLabelValueFromDataFrame( 9 | toDataFrame({ 10 | fields: [ 11 | { name: 'Time', type: FieldType.time, values: [0] }, 12 | { 13 | name: 'Value', 14 | type: FieldType.number, 15 | values: [1], 16 | labels: { 17 | detected_level: 'warn', 18 | }, 19 | }, 20 | ], 21 | }) 22 | ) 23 | ).toEqual('warn'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/services/levels.ts: -------------------------------------------------------------------------------- 1 | import { type DataFrame } from '@grafana/data'; 2 | 3 | export function getLabelValueFromDataFrame(frame: DataFrame) { 4 | const labels = frame.fields[1]?.labels; 5 | 6 | if (!labels) { 7 | return null; 8 | } 9 | 10 | const keys = Object.keys(labels); 11 | if (keys.length === 0) { 12 | return null; 13 | } 14 | 15 | return labels[keys[0]]; 16 | } 17 | -------------------------------------------------------------------------------- /src/services/search.ts: -------------------------------------------------------------------------------- 1 | import uFuzzy from '@leeoniya/ufuzzy'; 2 | import { debounce as debounceLodash } from 'lodash'; 3 | 4 | const uf = new uFuzzy({ 5 | intraMode: 1, 6 | intraIns: 1, 7 | intraSub: 1, 8 | intraTrn: 1, 9 | intraDel: 1, 10 | }); 11 | 12 | export function fuzzySearch(haystack: string[], query: string, callback: (data: string[][]) => void) { 13 | const [idxs, info, order] = uf.search(haystack, query, 0, 1e5); 14 | 15 | let haystackOrder: string[] = []; 16 | let matchesSet: Set = new Set(); 17 | if (idxs && order) { 18 | /** 19 | * get the fuzzy matches for highlighting 20 | * @param part 21 | * @param matched 22 | */ 23 | const mark = (part: string, matched: boolean) => { 24 | if (matched) { 25 | matchesSet.add(part); 26 | } 27 | }; 28 | 29 | // Iterate to create the order of needles(queries) and the matches 30 | for (let i = 0; i < order.length; i++) { 31 | let infoIdx = order[i]; 32 | 33 | /** Evaluate the match, get the matches for highlighting */ 34 | uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark); 35 | /** Get the order */ 36 | haystackOrder.push(haystack[info.idx[infoIdx]]); 37 | } 38 | 39 | callback([haystackOrder, [...matchesSet]]); 40 | } else if (!query) { 41 | callback([]); 42 | } 43 | } 44 | 45 | export const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300); 46 | -------------------------------------------------------------------------------- /src/services/sorting.test.ts: -------------------------------------------------------------------------------- 1 | import { FieldType, ReducerID, toDataFrame } from '@grafana/data'; 2 | 3 | import { sortSeries } from './sorting'; 4 | 5 | const frameA = toDataFrame({ 6 | fields: [ 7 | { name: 'Time', type: FieldType.time, values: [0] }, 8 | { 9 | name: 'Value', 10 | type: FieldType.number, 11 | values: [0, 1, 0], 12 | labels: { 13 | test: 'C', 14 | }, 15 | }, 16 | ], 17 | }); 18 | const frameB = toDataFrame({ 19 | fields: [ 20 | { name: 'Time', type: FieldType.time, values: [0] }, 21 | { 22 | name: 'Value', 23 | type: FieldType.number, 24 | values: [1, 1, 1], 25 | labels: { 26 | test: 'A', 27 | }, 28 | }, 29 | ], 30 | }); 31 | const frameC = toDataFrame({ 32 | fields: [ 33 | { name: 'Time', type: FieldType.time, values: [0] }, 34 | { 35 | name: 'Value', 36 | type: FieldType.number, 37 | values: [100, 9999, 100], 38 | labels: { 39 | test: 'B', 40 | }, 41 | }, 42 | ], 43 | }); 44 | const frameEmpty = toDataFrame({ fields: [] }); 45 | 46 | describe('sortSeries', () => { 47 | test('Sorts series by standard deviation, descending', () => { 48 | const series = [frameA, frameB, frameC]; 49 | const sortedSeries = [frameC, frameA, frameB]; 50 | 51 | const result = sortSeries(series, ReducerID.stdDev, 'desc'); 52 | expect(result).toEqual(sortedSeries); 53 | }); 54 | test('Sorts series by standard deviation, ascending', () => { 55 | const series = [frameA, frameB, frameC]; 56 | const sortedSeries = [frameB, frameA, frameC]; 57 | 58 | const result = sortSeries(series, ReducerID.stdDev, 'asc'); 59 | expect(result).toEqual(sortedSeries); 60 | }); 61 | test('Sorts series alphabetically, ascending', () => { 62 | const series = [frameA, frameB, frameC]; 63 | const sortedSeries = [frameB, frameC, frameA]; 64 | 65 | const result = sortSeries(series, 'alphabetical', 'asc'); 66 | expect(result).toEqual(sortedSeries); 67 | }); 68 | test('Sorts series alphabetically, descending', () => { 69 | const series = [frameA, frameB, frameC]; 70 | const sortedSeries = [frameB, frameC, frameA]; 71 | 72 | const result = sortSeries(series, 'alphabetical', 'desc'); 73 | expect(result).toEqual(sortedSeries); 74 | }); 75 | test('Does not throw on empty series', () => { 76 | const series = [frameEmpty]; 77 | 78 | expect(() => sortSeries(series, 'alphabetical', 'asc')).not.toThrow(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/services/store.ts: -------------------------------------------------------------------------------- 1 | import { type LabelBreakdownSortingOption } from 'Breakdown/SortByScene'; 2 | 3 | import { TRAIL_BREAKDOWN_SORT_KEY, TRAIL_BREAKDOWN_VIEW_KEY } from '../shared'; 4 | 5 | export function getVewByPreference() { 6 | return localStorage.getItem(TRAIL_BREAKDOWN_VIEW_KEY) ?? 'grid'; 7 | } 8 | 9 | export function setVewByPreference(value?: string) { 10 | return localStorage.setItem(TRAIL_BREAKDOWN_VIEW_KEY, value ?? 'grid'); 11 | } 12 | 13 | export function getSortByPreference( 14 | target: string, 15 | defaultSortBy: LabelBreakdownSortingOption 16 | ): { sortBy: LabelBreakdownSortingOption; direction?: string } { 17 | const preference = localStorage.getItem(`${TRAIL_BREAKDOWN_SORT_KEY}.${target}.by`) ?? ''; 18 | const parts = preference.split('.'); 19 | if (!parts[0] || !parts[1]) { 20 | return { sortBy: defaultSortBy }; 21 | } 22 | return { sortBy: parts[0] as LabelBreakdownSortingOption, direction: parts[1] }; 23 | } 24 | 25 | export function setSortByPreference(target: string, sortBy: LabelBreakdownSortingOption) { 26 | // Prevent storing empty values 27 | if (sortBy) { 28 | localStorage.setItem(`${TRAIL_BREAKDOWN_SORT_KEY}.${target}.by`, `${sortBy}`); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/services/variables.ts: -------------------------------------------------------------------------------- 1 | export const ALL_VARIABLE_VALUE = '$__all'; 2 | -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | import { BusEventBase, BusEventWithPayload } from '@grafana/data'; 2 | import { ConstantVariable } from '@grafana/scenes'; 3 | import { VariableHide } from '@grafana/schema'; 4 | 5 | export const TRAILS_ROUTE = '/explore/metrics/trail'; 6 | export const HOME_ROUTE = '/explore/metrics'; 7 | 8 | export const VAR_FILTERS = 'filters'; 9 | export const VAR_FILTERS_EXPR = '${filters}'; 10 | export const VAR_METRIC = 'metric'; 11 | export const VAR_METRIC_EXPR = '${metric}'; 12 | export const VAR_GROUP_BY = 'groupby'; 13 | export const VAR_GROUP_BY_EXP = '${groupby}'; 14 | export const VAR_DATASOURCE = 'ds'; 15 | export const VAR_DATASOURCE_EXPR = '${ds}'; 16 | export const VAR_LOGS_DATASOURCE = 'logsDs'; 17 | export const VAR_LOGS_DATASOURCE_EXPR = '${logsDs}'; 18 | export const VAR_OTHER_METRIC_FILTERS = 'other_metric_filters'; 19 | 20 | export const LOGS_METRIC = '$__logs__'; 21 | export const KEY_SQR_METRIC_VIZ_QUERY = 'sqr-metric-viz-query'; 22 | 23 | export const trailDS = { uid: VAR_DATASOURCE_EXPR }; 24 | 25 | // Local storage keys 26 | export const RECENT_TRAILS_KEY = 'grafana.trails.recent'; 27 | export const TRAIL_BOOKMARKS_KEY = 'grafana.trails.bookmarks'; 28 | export const TRAIL_BREAKDOWN_VIEW_KEY = 'grafana.trails.breakdown.view'; 29 | export const TRAIL_BREAKDOWN_SORT_KEY = 'grafana.trails.breakdown.sort'; 30 | 31 | export const MDP_METRIC_PREVIEW = 250; 32 | export const MDP_METRIC_OVERVIEW = 500; 33 | 34 | export type MakeOptional = Pick, K> & Omit; 35 | 36 | export function getVariablesWithMetricConstant(metric: string) { 37 | return [ 38 | new ConstantVariable({ 39 | name: VAR_METRIC, 40 | value: metric, 41 | hide: VariableHide.hideVariable, 42 | }), 43 | ]; 44 | } 45 | 46 | export class MetricSelectedEvent extends BusEventWithPayload { 47 | public static readonly type = 'metric-selected-event'; 48 | } 49 | 50 | export class RefreshMetricsEvent extends BusEventBase { 51 | public static readonly type = 'refresh-metrics-event'; 52 | } 53 | -------------------------------------------------------------------------------- /src/stubs/grafana-plugin-ui.ts: -------------------------------------------------------------------------------- 1 | export const AccessoryButton = {}; 2 | export const AdvancedHttpSettings = {}; 3 | export const Auth = {}; 4 | export const AuthMethod = {}; 5 | export const ConfigSection = {}; 6 | export const ConfigSubSection = {}; 7 | export const ConnectionSettings = {}; 8 | export const convertLegacyAuthProps = {}; 9 | export const DataSourceDescription = {}; 10 | export const EditorField = {}; 11 | export const EditorFieldGroup = {}; 12 | export const EditorHeader = {}; 13 | export const EditorList = {}; 14 | export const EditorRow = {}; 15 | export const EditorRows = {}; 16 | export const EditorSwitch = {}; 17 | export const FlexItem = {}; 18 | export const InputGroup = {}; 19 | export const Plugin = {}; 20 | export const PluginPage = {}; 21 | -------------------------------------------------------------------------------- /src/stubs/moment-timezone.ts: -------------------------------------------------------------------------------- 1 | export const tz = {}; 2 | -------------------------------------------------------------------------------- /src/stubs/monaco-editor.ts: -------------------------------------------------------------------------------- 1 | export const editor = { 2 | IEditorContribution: {}, 3 | }; 4 | 5 | export const languages = {}; 6 | -------------------------------------------------------------------------------- /src/tracking/__tests__/getEnvironment.spec.ts: -------------------------------------------------------------------------------- 1 | import { getEnvironment } from '../getEnvironment'; 2 | 3 | describe('getEnvironment()', () => { 4 | test.each([ 5 | // edge cases 6 | [undefined, null], 7 | ['unknownhost', null], 8 | // local 9 | ['localhost', 'local'], 10 | // dev 11 | ['grafana-dev.net', 'dev'], 12 | ['test.grafana-dev.net', 'dev'], 13 | // ops 14 | ['foobar.grafana-ops.net', 'ops'], 15 | ['grafana-ops.net', 'ops'], 16 | // prod 17 | ['foobar.grafana.net', 'prod'], 18 | ['grafana.net', 'prod'], 19 | ])('when the host is "%s" → %s', (host, expectedEnvironment) => { 20 | Object.defineProperty(window, 'location', { 21 | value: { host }, 22 | writable: true, 23 | }); 24 | 25 | expect(getEnvironment()).toBe(expectedEnvironment); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/tracking/faro/faro-environments.ts: -------------------------------------------------------------------------------- 1 | import { type Environment } from '../getEnvironment'; 2 | import { type FaroEnvironment } from './getFaroEnvironment'; 3 | 4 | export const FARO_ENVIRONMENTS = new Map([ 5 | // Uncomment this map entry to test from your local machine 6 | // [ 7 | // 'local', 8 | // { 9 | // environment: 'local', 10 | // appName: 'grafana-metricsdrilldown-app-local', 11 | // faroUrl: 'https://faro-collector-ops-eu-south-0.grafana-ops.net/collect/b854cd2319527968f415fd44ea01fe8a', 12 | // }, 13 | // ], 14 | // Always keep the options below 15 | [ 16 | 'dev', 17 | { 18 | environment: 'dev', 19 | appName: 'grafana-metricsdrilldown-app-dev', 20 | faroUrl: 'https://faro-collector-ops-eu-south-0.grafana-ops.net/collect/8c57b32175ba39d35dfaccee7cd793c7', 21 | }, 22 | ], 23 | [ 24 | 'ops', 25 | { 26 | environment: 'ops', 27 | appName: 'grafana-metricsdrilldown-app-ops', 28 | faroUrl: 'https://faro-collector-ops-eu-south-0.grafana-ops.net/collect/d65ab91eb9c5e8c51b474d9313ba28f4', 29 | }, 30 | ], 31 | [ 32 | 'prod', 33 | { 34 | environment: 'prod', 35 | appName: 'grafana-metricsdrilldown-app-prod', 36 | faroUrl: 'https://faro-collector-ops-eu-south-0.grafana-ops.net/collect/0f4f1bbc97c9e2db4fa85ef75a559885', 37 | }, 38 | ], 39 | ]); 40 | -------------------------------------------------------------------------------- /src/tracking/faro/faro.ts: -------------------------------------------------------------------------------- 1 | import { getWebInstrumentations, initializeFaro, type Faro } from '@grafana/faro-web-sdk'; 2 | import { config } from '@grafana/runtime'; 3 | 4 | import { getFaroEnvironment } from './getFaroEnvironment'; 5 | import { PLUGIN_BASE_URL, PLUGIN_ID } from '../../constants'; 6 | import { GIT_COMMIT } from '../../version'; 7 | 8 | let faro: Faro | null = null; 9 | 10 | export const getFaro = () => faro; 11 | export const setFaro = (instance: Faro | null) => (faro = instance); 12 | 13 | export function initFaro() { 14 | if (getFaro()) { 15 | return; 16 | } 17 | 18 | const faroEnvironment = getFaroEnvironment(); 19 | if (!faroEnvironment) { 20 | return; 21 | } 22 | 23 | const { environment, faroUrl, appName } = faroEnvironment; 24 | const { apps, bootData } = config; 25 | const appRelease = apps[PLUGIN_ID].version; 26 | const userEmail = bootData.user.email; 27 | 28 | setFaro( 29 | initializeFaro({ 30 | url: faroUrl, 31 | app: { 32 | name: appName, 33 | release: appRelease, 34 | version: GIT_COMMIT, 35 | environment, 36 | }, 37 | user: { 38 | email: userEmail, 39 | }, 40 | instrumentations: [ 41 | ...getWebInstrumentations({ 42 | captureConsole: false, 43 | }), 44 | ], 45 | isolate: true, 46 | beforeSend: (event) => { 47 | if ((event.meta.page?.url ?? '').includes(PLUGIN_BASE_URL)) { 48 | return event; 49 | } 50 | 51 | return null; 52 | }, 53 | }) 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/tracking/faro/getFaroEnvironment.ts: -------------------------------------------------------------------------------- 1 | import { getEnvironment, type Environment } from '../getEnvironment'; 2 | import { FARO_ENVIRONMENTS } from './faro-environments'; 3 | 4 | export type FaroEnvironment = { environment: Environment; appName: string; faroUrl: string }; 5 | 6 | export function getFaroEnvironment() { 7 | const environment = getEnvironment(); 8 | 9 | if (!environment || !FARO_ENVIRONMENTS.has(environment)) { 10 | return; 11 | } 12 | 13 | return FARO_ENVIRONMENTS.get(environment) as FaroEnvironment; 14 | } 15 | -------------------------------------------------------------------------------- /src/tracking/getEnvironment.ts: -------------------------------------------------------------------------------- 1 | export type Environment = 'local' | 'dev' | 'ops' | 'prod'; 2 | 3 | const MATCHERS: Array<{ regExp: RegExp; environment: Environment }> = [ 4 | { 5 | regExp: /localhost/, 6 | environment: 'local', 7 | }, 8 | { 9 | regExp: /grafana-dev\.net/, 10 | environment: 'dev', 11 | }, 12 | { 13 | regExp: /grafana-ops\.net/, 14 | environment: 'ops', 15 | }, 16 | { 17 | regExp: /grafana\.net/, 18 | environment: 'prod', 19 | }, 20 | ]; 21 | 22 | export function getEnvironment(): Environment | null { 23 | if (!window?.location?.host) { 24 | return null; 25 | } 26 | 27 | const found = MATCHERS.find(({ regExp }) => regExp.test(window.location.host)); 28 | 29 | return found ? found.environment : null; 30 | } 31 | -------------------------------------------------------------------------------- /src/tracking/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from '@grafana/faro-web-sdk'; 2 | 3 | import { getFaro } from '../faro/faro'; 4 | import { getEnvironment, type Environment } from '../getEnvironment'; 5 | 6 | export type ErrorContext = Record; 7 | 8 | /** 9 | * Logger class that handles logging to both console and Grafana Faro. 10 | * 11 | * This class provides a unified logging interface that: 12 | * - Logs to console in non-production environments 13 | * - Sends logs to Faro for remote monitoring and error tracking 14 | * - Supports different log levels (trace, debug, info, log, warn, error) 15 | * - Handles error contexts for better error tracking 16 | * 17 | * Used throughout the application for consistent logging and error reporting. 18 | */ 19 | 20 | export class Logger { 21 | #environment: Environment | null; 22 | 23 | constructor() { 24 | this.#environment = getEnvironment(); 25 | } 26 | 27 | #callConsole(methodName: 'trace' | 'debug' | 'info' | 'log' | 'warn' | 'error', args: any[]) { 28 | // silence console in production 29 | if (this.#environment !== 'prod') { 30 | console[methodName](...args); // eslint-disable-line no-console 31 | } 32 | } 33 | 34 | trace() { 35 | this.#callConsole('trace', []); 36 | 37 | getFaro()?.api.pushLog([], { 38 | level: LogLevel.TRACE, 39 | }); 40 | } 41 | 42 | debug(...args: any) { 43 | this.#callConsole('debug', args); 44 | 45 | getFaro()?.api.pushLog(args, { 46 | level: LogLevel.DEBUG, 47 | }); 48 | } 49 | 50 | info(...args: any) { 51 | this.#callConsole('info', args); 52 | 53 | getFaro()?.api.pushLog(args, { 54 | level: LogLevel.INFO, 55 | }); 56 | } 57 | 58 | log(...args: any) { 59 | this.#callConsole('log', args); 60 | 61 | getFaro()?.api.pushLog(args, { 62 | level: LogLevel.LOG, 63 | }); 64 | } 65 | 66 | warn(...args: any) { 67 | this.#callConsole('warn', args); 68 | 69 | getFaro()?.api.pushLog(args, { 70 | level: LogLevel.WARN, 71 | }); 72 | } 73 | 74 | error(error: Error, context?: ErrorContext) { 75 | this.#callConsole('error', [error]); 76 | 77 | if (context) { 78 | this.#callConsole('error', ['Error context', context]); 79 | } 80 | 81 | // does not report an error, but an exception ;) 82 | getFaro()?.api.pushError(error, { 83 | context, 84 | }); 85 | } 86 | } 87 | 88 | export const logger = new Logger(); 89 | -------------------------------------------------------------------------------- /src/trailFactory.ts: -------------------------------------------------------------------------------- 1 | import { SceneTimeRange } from '@grafana/scenes'; 2 | 3 | import { DataTrail } from './DataTrail'; 4 | 5 | /** 6 | * Creates a new metrics trail with the given initial data source and start button clicked state 7 | * @param initialDS The initial data source 8 | * @returns A new DataTrail instance 9 | */ 10 | export function newMetricsTrail(initialDS?: string): DataTrail { 11 | return new DataTrail({ 12 | initialDS, 13 | $timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), 14 | embedded: false, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { type AdHocVariableFilter } from '@grafana/data'; 2 | import { type SceneObject, type SceneObjectState, type SceneReactObject } from '@grafana/scenes'; 3 | 4 | import { type DataTrailSettings } from './DataTrailSettings'; 5 | 6 | export interface DataTrailState extends SceneObjectState { 7 | topScene?: SceneObject; 8 | embedded?: boolean; 9 | controls: SceneObject[]; 10 | settings: DataTrailSettings; 11 | pluginInfo: SceneReactObject; 12 | createdAt: number; 13 | 14 | // wingman 15 | dashboardMetrics?: Record; 16 | alertingMetrics?: Record; 17 | 18 | // just for the starting data source 19 | initialDS?: string; 20 | initialFilters?: AdHocVariableFilter[]; 21 | 22 | // Synced with url 23 | metric?: string; 24 | metricSearch?: string; 25 | 26 | histogramsLoaded: boolean; 27 | nativeHistograms: string[]; 28 | nativeHistogramMetric: string; 29 | 30 | trailActivated: boolean; // this indicates that the trail has been updated by metric or filter selected 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/utils.datasource.test.ts: -------------------------------------------------------------------------------- 1 | import { isPrometheusDataSource } from './utils.datasource'; 2 | 3 | describe('isPrometheusDataSource', () => { 4 | it('should return true for a core Prometheus datasource', () => { 5 | const ds = { type: 'prometheus', uid: 'prometheus' }; 6 | expect(isPrometheusDataSource(ds)).toBe(true); 7 | }); 8 | 9 | it('should return true for a Grafana developed Prometheus datasource', () => { 10 | const ds = { type: 'grafana-amazonprometheus-datasource', uid: 'grafana-amazonprometheus-datasource' }; 11 | expect(isPrometheusDataSource(ds)).toBe(true); 12 | }); 13 | 14 | it('should return false for non-Prometheus datasource', () => { 15 | const ds = { type: 'grafana-test-datasource', uid: 'grafana-test-datasource' }; 16 | expect(isPrometheusDataSource(ds)).toBe(false); 17 | }); 18 | 19 | it('should return false for object without type property', () => { 20 | const ds = { name: 'prometheus', uid: 'prometheus' }; 21 | expect(isPrometheusDataSource(ds)).toBe(false); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/utils/utils.events.ts: -------------------------------------------------------------------------------- 1 | import { BusEventWithPayload } from '@grafana/data'; 2 | 3 | interface ShowModalReactPayload { 4 | component: React.ComponentType; 5 | props?: any; 6 | } 7 | 8 | export class ShowModalReactEvent extends BusEventWithPayload { 9 | static readonly type = 'show-react-modal'; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/utils.layout.ts: -------------------------------------------------------------------------------- 1 | import { type SceneCSSGridLayout, type SceneFlexLayout, type SceneLayout } from '@grafana/scenes'; 2 | 3 | type MaybeLayout = SceneLayout | null | undefined; 4 | 5 | export function isSceneCSSGridLayout(input: MaybeLayout): input is SceneCSSGridLayout { 6 | return typeof input !== 'undefined' && input !== null && 'isDraggable' in input && 'templateColumns' in input.state; 7 | } 8 | 9 | export function isSceneFlexLayout(input: MaybeLayout): input is SceneFlexLayout { 10 | return typeof input !== 'undefined' && input !== null && 'toggleDirection' in input && 'children' in input.state; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/utils.plugin.ts: -------------------------------------------------------------------------------- 1 | import { type AppRootProps } from '@grafana/data'; 2 | import { createContext, useContext } from 'react'; 3 | 4 | // This is used to be able to retrieve the root plugin props anywhere inside the app. 5 | export const PluginPropsContext = createContext(null); 6 | 7 | export const usePluginProps = () => { 8 | const pluginProps = useContext(PluginPropsContext); 9 | 10 | return pluginProps; 11 | }; 12 | 13 | export const usePluginMeta = () => { 14 | const pluginProps = usePluginProps(); 15 | 16 | return pluginProps?.meta; 17 | }; 18 | -------------------------------------------------------------------------------- /src/utils/utils.promql.ts: -------------------------------------------------------------------------------- 1 | import { parser } from '@prometheus-io/lezer-promql'; 2 | 3 | /** 4 | * Extracts all metric names from a PromQL expression 5 | * @param {string} promqlExpression - The PromQL expression to parse 6 | * @returns {string[]} An array of unique metric names found in the expression 7 | */ 8 | export function extractMetricNames(promqlExpression: string): string[] { 9 | const tree = parser.parse(promqlExpression); 10 | const metricNames = new Set(); 11 | const cursor = tree.cursor(); 12 | 13 | do { 14 | // have we found a VectorSelector? 15 | if (!cursor.type.is('VectorSelector')) { 16 | continue; 17 | } 18 | 19 | // does it have a first child? 20 | if (!cursor.firstChild()) { 21 | continue; 22 | } 23 | 24 | do { 25 | // ...let's look for any Identifier node 26 | if (cursor.type.is('Identifier')) { 27 | const metricName = promqlExpression.slice(cursor.from, cursor.to); 28 | if (metricName) { 29 | metricNames.add(metricName); 30 | } 31 | } 32 | } while (cursor.nextSibling()); 33 | 34 | cursor.parent(); 35 | } while (cursor.next()); 36 | 37 | return Array.from(metricNames); 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/utils.queries.ts: -------------------------------------------------------------------------------- 1 | import { type SceneDataTransformer, type SceneObject, type SceneQueryRunner } from '@grafana/scenes'; 2 | 3 | export function getQueryRunnerFor(sceneObject: SceneObject | undefined): SceneQueryRunner | undefined { 4 | if (!sceneObject) { 5 | return undefined; 6 | } 7 | 8 | const dataProvider = sceneObject.state.$data ?? sceneObject.parent?.state.$data; 9 | 10 | if (isSceneQueryRunner(dataProvider)) { 11 | return dataProvider; 12 | } 13 | 14 | if (isSceneDataTransformer(dataProvider)) { 15 | return getQueryRunnerFor(dataProvider); 16 | } 17 | 18 | return undefined; 19 | } 20 | 21 | export function isSceneQueryRunner(input: SceneObject | null | undefined): input is SceneQueryRunner { 22 | return typeof input !== 'undefined' && input !== null && 'state' in input && 'runQueries' in input; 23 | } 24 | 25 | export function isSceneDataTransformer(input: SceneObject | null | undefined): input is SceneDataTransformer { 26 | return typeof input !== 'undefined' && input !== null && 'state' in input && 'transformations' in input.state; 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/utils.scopes.ts: -------------------------------------------------------------------------------- 1 | import { type Scope } from '@grafana/data'; 2 | import { SceneObjectBase, type SceneObjectState } from '@grafana/scenes'; 3 | 4 | export function getSelectedScopes(): Scope[] { 5 | return []; 6 | } 7 | 8 | export function getClosestScopesFacade(): ScopesFacade { 9 | return new ScopesFacade(); 10 | } 11 | 12 | interface SelectedScope { 13 | scope: Scope; 14 | path: string[]; 15 | } 16 | 17 | interface ScopesFacadeState extends SceneObjectState { 18 | // A callback that will be executed when new scopes are set 19 | handler?: (facade: ScopesFacade) => void; 20 | // The render count is a workaround to force the URL sync manager to update the URL with the latest scopes 21 | // Basically it starts at 0, and it is increased with every scopes value update 22 | renderCount?: number; 23 | } 24 | 25 | export class ScopesFacade extends SceneObjectBase { 26 | private selectedScopes: SelectedScope[] = []; 27 | private onScopesChangeCallbacks: Array<(scopes: SelectedScope[]) => void> = []; 28 | 29 | constructor() { 30 | super({}); 31 | } 32 | 33 | public getSelectedScopes(): SelectedScope[] { 34 | return this.selectedScopes; 35 | } 36 | 37 | public getSelectedScopesNames(): string[] { 38 | return this.selectedScopes.map(({ scope }) => scope.metadata.name); 39 | } 40 | 41 | public setSelectedScopes(scopes: SelectedScope[]) { 42 | this.selectedScopes = scopes; 43 | this.notifySubscribers(); 44 | } 45 | 46 | public onScopesChange(callback: (scopes: SelectedScope[]) => void) { 47 | this.onScopesChangeCallbacks.push(callback); 48 | return () => { 49 | this.onScopesChangeCallbacks = this.onScopesChangeCallbacks.filter((cb) => cb !== callback); 50 | }; 51 | } 52 | 53 | private notifySubscribers() { 54 | for (const callback of this.onScopesChangeCallbacks) { 55 | callback(this.selectedScopes); 56 | } 57 | } 58 | 59 | public get value() { 60 | return getSelectedScopes(); 61 | } 62 | } 63 | 64 | export function ScopesSelector(): null { 65 | return null; 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/utils.testing.ts: -------------------------------------------------------------------------------- 1 | import { type SceneDeactivationHandler, type SceneObject } from '@grafana/scenes'; 2 | 3 | /** 4 | * Useful from tests to simulate mounting a full scene. Children are activated before parents to simulate the real order 5 | * of React mount order and useEffect ordering. 6 | * 7 | * @remarks 8 | * This was copied from `grafana/grafana`'s `public/app/features/dashboard-scene/utils/test-utils.ts` 9 | */ 10 | export function activateFullSceneTree(scene: SceneObject): SceneDeactivationHandler { 11 | const deactivationHandlers: SceneDeactivationHandler[] = []; 12 | 13 | // Important that variables are activated before other children 14 | if (scene.state.$variables) { 15 | deactivationHandlers.push(activateFullSceneTree(scene.state.$variables)); 16 | } 17 | 18 | scene.forEachChild((child) => { 19 | // For query runners which by default use the container width for maxDataPoints calculation we are setting a width. 20 | // In real life this is done by the React component when VizPanel is rendered. 21 | if ('setContainerWidth' in child) { 22 | // @ts-expect-error 23 | child.setContainerWidth(500); 24 | } 25 | deactivationHandlers.push(activateFullSceneTree(child)); 26 | }); 27 | 28 | deactivationHandlers.push(scene.activate()); 29 | 30 | return () => { 31 | for (const handler of deactivationHandlers) { 32 | handler(); 33 | } 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/utils.timerange.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type SceneObject, 3 | type SceneObjectState, 4 | type SceneTimeRange, 5 | type SceneTimeRangeState, 6 | } from '@grafana/scenes'; 7 | 8 | export function isSceneTimeRange(input: SceneObject | null | undefined): input is SceneTimeRange { 9 | return typeof input !== 'undefined' && input !== null && 'getTimeZone' in input && isSceneTimeRangeState(input.state); 10 | } 11 | 12 | export function isSceneTimeRangeState(input: SceneObjectState | null | undefined): input is SceneTimeRangeState { 13 | return typeof input !== 'undefined' && input !== null && 'value' in input && 'from' in input && 'to' in input; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/utils.variables.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AdHocFiltersVariable, 3 | type ConstantVariable, 4 | type CustomVariable, 5 | type QueryVariable, 6 | type SceneVariable, 7 | } from '@grafana/scenes'; 8 | 9 | type MaybeVariable = SceneVariable | null | undefined; 10 | 11 | export function isConstantVariable(variable: MaybeVariable): variable is ConstantVariable { 12 | return variable !== null && variable?.state.type === 'constant'; 13 | } 14 | 15 | export function isAdHocFiltersVariable(variable: MaybeVariable): variable is AdHocFiltersVariable { 16 | return variable !== null && variable?.state.type === 'adhoc'; 17 | } 18 | 19 | export function isCustomVariable(variable: MaybeVariable): variable is CustomVariable { 20 | return variable !== null && variable?.state.type === 'custom'; 21 | } 22 | 23 | export function isQueryVariable(variable: MaybeVariable): variable is QueryVariable { 24 | return variable !== null && variable?.state.type === 'query'; 25 | } 26 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | export const GIT_COMMIT = 'dev'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /webpack-analyze.config.ts: -------------------------------------------------------------------------------- 1 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 2 | import { merge } from 'webpack-merge'; 3 | 4 | import projectConfig from './webpack.config'; 5 | 6 | import type { Configuration } from 'webpack'; 7 | 8 | const config = async (env): Promise => { 9 | const baseConfig = await projectConfig(env); 10 | 11 | return merge(baseConfig, { 12 | plugins: [new BundleAnalyzerPlugin({ analyzerMode: 'static' })], 13 | }); 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { NormalModuleReplacementPlugin, type Configuration } from 'webpack'; 4 | import { merge } from 'webpack-merge'; 5 | 6 | import grafanaConfig from './.config/webpack/webpack.config'; 7 | 8 | const config = async (env): Promise => { 9 | const baseConfig = await grafanaConfig(env); 10 | 11 | return merge(baseConfig, { 12 | externals: ['react-router'], 13 | experiments: { 14 | // Required to load WASM modules. 15 | asyncWebAssembly: true, 16 | }, 17 | output: { 18 | asyncChunks: true, 19 | }, 20 | plugins: [ 21 | new NormalModuleReplacementPlugin(/monaco-editor/, path.resolve(__dirname, 'src/stubs/monaco-editor.ts')), 22 | new NormalModuleReplacementPlugin( 23 | /@grafana\/plugin-ui/, 24 | path.resolve(__dirname, 'src/stubs/grafana-plugin-ui.ts') 25 | ), 26 | new NormalModuleReplacementPlugin(/moment-timezone/, path.resolve(__dirname, 'src/stubs/moment-timezone.ts')), 27 | ], 28 | }); 29 | }; 30 | 31 | export default config; 32 | --------------------------------------------------------------------------------