From 88391b173ea5fd91fc55bc82918c75fbd6aec785 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Fri, 11 Apr 2025 06:56:05 -0600 Subject: [PATCH 01/73] K8s: Enable kubernetesClientDashboardsFolders by default (#103843) --- .../configure-grafana/feature-toggles/index.md | 2 +- .../grafana-data/src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 3 ++- pkg/services/featuremgmt/toggles_gen.csv | 2 +- pkg/services/featuremgmt/toggles_gen.json | 12 ++++++++---- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 51df92d1ac0..b8262500040 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -47,6 +47,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes | | `formatString` | Enable format string transformer | Yes | | `kubernetesPlaylists` | Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s | Yes | +| `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | Yes | | `recoveryThreshold` | Enables feature recovery threshold (aka hysteresis) for threshold server-side expression | Yes | | `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | Yes | | `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes | @@ -156,7 +157,6 @@ Experimental features might be changed or removed without prior notice. | `disableClassicHTTPHistogram` | Disables classic HTTP Histogram (use with enableNativeHTTPHistogram) | | `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint | | `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | -| `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | | `datasourceQueryTypes` | Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) | | `queryService` | Register /apis/query.grafana.app/ -- will eventually replace /api/ds/query | | `queryServiceRewrite` | Rewrite requests targeting /ds/query to the query service | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 154c070f860..89c02a1a693 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -336,6 +336,7 @@ export interface FeatureToggles { kubernetesDashboards?: boolean; /** * Route the folder and dashboard service requests to k8s + * @default true */ kubernetesClientDashboardsFolders?: boolean; /** diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 568a33f60ac..9e211337f3f 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -564,8 +564,9 @@ var ( { Name: "kubernetesClientDashboardsFolders", Description: "Route the folder and dashboard service requests to k8s", - Stage: FeatureStageExperimental, + Stage: FeatureStageGeneralAvailability, Owner: grafanaAppPlatformSquad, + Expression: "true", // enabled by default }, { Name: "datasourceQueryTypes", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 432f331d2f2..7edec7f5e13 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -72,7 +72,7 @@ formatString,GA,@grafana/dataviz-squad,false,false,true kubernetesPlaylists,GA,@grafana/grafana-app-platform-squad,false,true,false kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true -kubernetesClientDashboardsFolders,experimental,@grafana/grafana-app-platform-squad,false,false,false +kubernetesClientDashboardsFolders,GA,@grafana/grafana-app-platform-squad,false,false,false datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false queryService,experimental,@grafana/grafana-app-platform-squad,false,true,false queryServiceRewrite,experimental,@grafana/grafana-app-platform-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 542851adf3a..a722b4ab073 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1644,13 +1644,17 @@ { "metadata": { "name": "kubernetesClientDashboardsFolders", - "resourceVersion": "1743693517832", - "creationTimestamp": "2025-02-18T21:15:35Z" + "resourceVersion": "1744337414536", + "creationTimestamp": "2025-02-18T21:15:35Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-04-11 02:10:14.536012 +0000 UTC" + } }, "spec": { "description": "Route the folder and dashboard service requests to k8s", - "stage": "experimental", - "codeowner": "@grafana/grafana-app-platform-squad" + "stage": "GA", + "codeowner": "@grafana/grafana-app-platform-squad", + "expression": "true" } }, { From e7b32d62299178fd0548cc03d7fb0eb4bdeab6f8 Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Fri, 11 Apr 2025 15:18:46 +0200 Subject: [PATCH 02/73] Extension Sidebar Button: Prevent button to have stretched background (#103882) --- .../AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx index be49778db62..4d8f7031db7 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx @@ -87,6 +87,9 @@ export function ExtensionToolbarItem() { function getStyles(theme: GrafanaTheme2) { return { button: css({ + // this is needed because with certain breakpoints the button will get `width: auto` + // and the icon will stretch + aspectRatio: '1 / 1 !important', width: '28px', height: '28px', padding: 0, From ed9a7e8d9f74a25cb1988a3565e92a614269a5a0 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 11 Apr 2025 14:24:53 +0100 Subject: [PATCH 03/73] Alerting: Make nested folders work in Alert List Panel (#103550) --- .../features/alerting/unified/utils/rules.ts | 8 +++++- .../panel/alertlist/UnifiedAlertList.tsx | 4 +-- .../panel/alertlist/UnifiedalertList.test.tsx | 2 +- public/app/plugins/panel/alertlist/module.tsx | 26 +++++++++++-------- public/app/plugins/panel/alertlist/types.ts | 2 +- .../app/plugins/panel/alertlist/util.test.tsx | 2 +- public/app/types/unified-alerting.ts | 1 + 7 files changed, 28 insertions(+), 17 deletions(-) diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 88bc70a2cb8..13aa9982983 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -293,7 +293,13 @@ export const flattenCombinedRules = (rules: CombinedRuleNamespace[]) => { groups.forEach(({ name: groupName, rules }) => { rules.forEach((rule) => { if (rule.promRule && isAlertingRule(rule.promRule)) { - acc.push({ dataSourceName: getRulesSourceName(rulesSource), namespaceName, groupName, ...rule }); + acc.push({ + dataSourceName: getRulesSourceName(rulesSource), + namespaceName, + groupName, + ...rule, + namespace: { ...rule.namespace, uid: rule.promRule.folderUid }, + }); } }); }); diff --git a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx index db994772bd7..be6401e3ad1 100644 --- a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx @@ -309,9 +309,9 @@ function filterRules(props: PanelProps, rules: Combined ); }); - if (options.folder) { + if (options.folder && options.folder.uid) { filteredRules = filteredRules.filter((rule) => { - return rule.namespaceName === options.folder.title; + return rule.namespace.uid === options.folder.uid; }); } if (options.datasource) { diff --git a/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx b/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx index d2b1a7622ba..1a8369dbbef 100644 --- a/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx @@ -108,7 +108,7 @@ const defaultOptions: UnifiedAlertListOptions = { groupBy: [''], alertName: 'test', showInstances: false, - folder: { id: 1, title: 'test folder' }, + folder: { uid: 'abc', title: 'test folder' }, stateFilter: { firing: true, pending: false, noData: false, normal: true, error: false, recovering: false }, alertInstanceLabelFilter: '', datasource: 'grafana', diff --git a/public/app/plugins/panel/alertlist/module.tsx b/public/app/plugins/panel/alertlist/module.tsx index e11dd65ff5d..2d0e2ef64c5 100644 --- a/public/app/plugins/panel/alertlist/module.tsx +++ b/public/app/plugins/panel/alertlist/module.tsx @@ -1,8 +1,7 @@ import { DataSourceInstanceSettings, PanelPlugin } from '@grafana/data'; import { Button, Stack } from '@grafana/ui'; -import { OldFolderPicker } from 'app/core/components/Select/OldFolderPicker'; +import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; -import { PermissionLevelString } from 'app/types'; import { GRAFANA_DATASOURCE_NAME, @@ -118,7 +117,14 @@ const unifiedAlertList = new PanelPlugin(UnifiedAlertLi type={SUPPORTED_RULE_SOURCE_TYPES} noDefault current={props.value} - onChange={(ds: DataSourceInstanceSettings) => props.onChange(ds.name)} + onChange={(ds: DataSourceInstanceSettings) => { + // If we're changing the datasource, clear the folder selection + // as otherwise we might still be accidentally filtering out alerts + if (ds.uid !== 'grafana') { + props.context.options.folder = null; + } + return props.onChange(ds.name); + }} /> {showActions ? ( <> diff --git a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx index c629073d589..5c1592ca098 100644 --- a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx +++ b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx @@ -47,20 +47,11 @@ describe('Alerting Settings', () => { grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingInstanceRead]); }); - it('should be able to reset alertmanager config', async () => { + it('should not be able to reset alertmanager config', async () => { const onReset = jest.fn(); renderConfiguration('grafana', { onReset }); - await userEvent.click(await ui.resetButton.find()); - - await waitFor(() => { - expect(ui.resetConfirmButton.query()).toBeInTheDocument(); - }); - - await userEvent.click(ui.resetConfirmButton.get()); - - await waitFor(() => expect(onReset).toHaveBeenCalled()); - expect(onReset).toHaveBeenLastCalledWith('grafana'); + expect(ui.resetButton.query()).not.toBeInTheDocument(); }); it('should be able to cancel', async () => { @@ -100,4 +91,20 @@ describe('vanilla Alertmanager', () => { expect(ui.saveButton.get()).toBeInTheDocument(); expect(ui.resetButton.get()).toBeInTheDocument(); }); + + it('should be able to reset non-Grafana alertmanager config', async () => { + const onReset = jest.fn(); + renderConfiguration(PROVISIONED_MIMIR_ALERTMANAGER_UID, { onReset }); + + expect(ui.cancelButton.get()).toBeInTheDocument(); + expect(ui.saveButton.get()).toBeInTheDocument(); + expect(ui.resetButton.get()).toBeInTheDocument(); + + await userEvent.click(ui.resetButton.get()); + + await userEvent.click(ui.resetConfirmButton.get()); + + await waitFor(() => expect(onReset).toHaveBeenCalled()); + expect(onReset).toHaveBeenLastCalledWith(PROVISIONED_MIMIR_ALERTMANAGER_UID); + }); }); diff --git a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx index 05a02bcbec4..bd756fe9241 100644 --- a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx +++ b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx @@ -28,12 +28,12 @@ export default function AlertmanagerConfig({ alertmanagerName, onDismiss, onSave const { loading: isDeleting, error: deletingError } = useUnifiedAlertingSelector((state) => state.deleteAMConfig); const { loading: isSaving, error: savingError } = useUnifiedAlertingSelector((state) => state.saveAMConfig); const [showResetConfirmation, setShowResetConfirmation] = useState(false); + const isGrafanaManagedAlertmanager = alertmanagerName === GRAFANA_RULES_SOURCE_NAME; // ⚠️ provisioned data sources should not prevent the configuration from being edited const immutableDataSource = alertmanagerName ? isVanillaPrometheusAlertManagerDataSource(alertmanagerName) : false; - const readOnly = immutableDataSource; + const readOnly = immutableDataSource || isGrafanaManagedAlertmanager; - const isGrafanaManagedAlertmanager = alertmanagerName === GRAFANA_RULES_SOURCE_NAME; const styles = useStyles2(getStyles); const { @@ -128,12 +128,28 @@ export default function AlertmanagerConfig({ alertmanagerName, onDismiss, onSave ); } - const confirmationText = isGrafanaManagedAlertmanager - ? `Are you sure you want to reset configuration for the Grafana Alertmanager? Contact points and notification policies will be reset to their defaults.` - : `Are you sure you want to reset configuration for "${alertmanagerName}"? Contact points and notification policies will be reset to their defaults.`; + const confirmationText = t( + 'alerting.alertmanager-config.reset-confirmation', + 'Are you sure you want to reset configuration for "{{alertmanagerName}}"? Contact points and notification policies will be reset to their defaults.', + { alertmanagerName } + ); return (
+ {isGrafanaManagedAlertmanager && ( + + + The internal Grafana Alertmanager configuration cannot be manually changed. To change this configuration, + edit the individual resources through the UI. + + + )} {/* form error state */} {errors.configJSON && ( { setDataSourceName(dataSourceName); setOpen(true); @@ -33,14 +37,29 @@ export function useEditConfigurationDrawer() { } const isGrafanaAlertmanager = dataSourceName === GRAFANA_RULES_SOURCE_NAME; - const title = isGrafanaAlertmanager ? 'Internal Grafana Alertmanager' : dataSourceName; + const title = isGrafanaAlertmanager + ? t( + 'alerting.use-edit-configuration-drawer.drawer.internal-grafana-alertmanager-title', + 'Grafana built-in Alertmanager' + ) + : dataSourceName; + + const subtitle = readOnly + ? t( + 'alerting.use-edit-configuration-drawer.drawer.title-view-the-alertmanager-configuration', + 'View Alertmanager configuration' + ) + : t( + 'alerting.use-edit-configuration-drawer.drawer.title-edit-the-alertmanager-configuration', + 'Edit Alertmanager configuration' + ); // @todo check copy return ( ); - }, [open, dataSourceName, handleDismiss, activeTab, updateAlertmanagerSettings, resetAlertmanagerSettings]); + }, [open, dataSourceName, readOnly, handleDismiss, activeTab, updateAlertmanagerSettings, resetAlertmanagerSettings]); return [drawer, showConfiguration, handleDismiss] as const; } diff --git a/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx b/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx index add2e0f852d..b40619143f3 100644 --- a/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx +++ b/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx @@ -30,6 +30,7 @@ export default function InternalAlertmanager({ onEditConfiguration }: Props) { onEditConfiguration={handleEditConfiguration} onEnable={handleEnable} onDisable={handleDisable} + readOnly /> ); } diff --git a/public/app/features/alerting/unified/components/settings/VersionManager.tsx b/public/app/features/alerting/unified/components/settings/VersionManager.tsx index b5285d5f990..9a47437094e 100644 --- a/public/app/features/alerting/unified/components/settings/VersionManager.tsx +++ b/public/app/features/alerting/unified/components/settings/VersionManager.tsx @@ -94,11 +94,15 @@ const AlertmanagerConfigurationVersionManager = ({ } if (isLoading) { - return 'Loading...'; + return Loading...; } if (!historicalConfigs.length) { - return 'No previous configurations'; + return ( + + No previous configurations + + ); } // with this function we'll compute the diff with the previous version; that way the user can get some idea of how many lines where changed in each update that was applied diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e7633b9d8a3..f01845beb3f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -413,16 +413,21 @@ }, "alertmanager-card": { "disable": "Disable", + "edit-configuration": "Edit configuration", "enable": "Enable", "not-receiving-grafana-managed-alerts": "Not receiving Grafana managed alerts", "text-activation-in-progress": "Activation in progress", "text-failed-to-adopt-alertmanager": "Failed to adopt Alertmanager", "text-inconclusive": "Inconclusive", "text-receiving-grafanamanaged-alerts": "Receiving Grafana-managed alerts", - "title-alerting-settings": "Alerting settings" + "title-alerting-settings": "Alerting settings", + "view-configuration": "View configuration" }, "alertmanager-config": { + "gma-manual-configuration-description": "The internal Grafana Alertmanager configuration cannot be manually changed. To change this configuration, edit the individual resources through the UI.", + "gma-manual-configuration-is-not-supported": "Manual configuration changes not supported", "reset": "Reset", + "reset-confirmation": "Are you sure you want to reset configuration for \"{{alertmanagerName}}\"? Contact points and notification policies will be reset to their defaults.", "resetting-configuration-might-while": "Resetting configuration, this might take a while.", "title-failed-to-load-alertmanager-configuration": "Failed to load Alertmanager configuration", "title-oops-something-went-wrong": "Oops, something went wrong", @@ -435,6 +440,8 @@ "restore": "Restore", "text-latest": "Latest" }, + "loading": "Loading...", + "no-previous-configurations": "No previous configurations", "this-might-take-a-while": "This might take a while...", "title-failed-to-load-configuration-history": "Failed to load configuration history", "title-restore-version": "Restore version", @@ -2075,8 +2082,11 @@ }, "use-edit-configuration-drawer": { "drawer": { + "internal-grafana-alertmanager-title": "Grafana built-in Alertmanager", "label-json-model": "JSON Model", - "label-versions": "Versions" + "label-versions": "Versions", + "title-edit-the-alertmanager-configuration": "Edit Alertmanager configuration", + "title-view-the-alertmanager-configuration": "View Alertmanager configuration" } }, "use-edit-policy-modal": { From 920c7b1de55c12ebc7ba56cc9a4eeed9ccd11063 Mon Sep 17 00:00:00 2001 From: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Fri, 11 Apr 2025 18:30:50 +0200 Subject: [PATCH 31/73] Dashboards: SchemaV2 - Fix stateless queries for mixed ds (#103885) * Dashboards: SchemaV2 - Fix stateless queries for mixed ds * Add uni test for new utils function * Add extra condition to validate we have datasources configured in the grafanaBootData * Refactor code * remove unnecessary test --- .../layoutSerializers/utils.test.ts | 132 ++++++++++++++++++ .../serialization/layoutSerializers/utils.ts | 59 +++++--- 2 files changed, 173 insertions(+), 18 deletions(-) create mode 100644 public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts new file mode 100644 index 00000000000..050947de00d --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts @@ -0,0 +1,132 @@ +import { PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; + +import { getRuntimePanelDataSource } from './utils'; + +// Mock the config needed for the function +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + ...jest.requireActual('@grafana/runtime').config, + bootData: { + settings: { + defaultDatasource: 'default-ds-grafana', + datasources: { + 'default-ds-grafana': { + uid: 'default-ds-uid', + name: 'Default DS', + meta: { id: 'default-ds-grafana' }, + type: 'datasource', + }, + prometheus: { + uid: 'prometheus-uid', + name: 'Prometheus', + meta: { id: 'prometheus' }, + type: 'datasource', + }, + loki: { + uid: 'loki-uid', + name: 'Loki', + meta: { id: 'loki' }, + type: 'datasource', + }, + }, + }, + }, + }, +})); + +describe('getRuntimePanelDataSource', () => { + it('should return the datasource when it is specified in the query', () => { + const query: PanelQueryKind = { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + datasource: { + uid: 'test-ds-uid', + type: 'test-ds-type', + }, + query: { + kind: 'prometheus', + spec: {}, + }, + }, + }; + + const result = getRuntimePanelDataSource(query); + + expect(result).toEqual({ + uid: 'test-ds-uid', + type: 'test-ds-type', + }); + }); + + it('should infer datasource based on query kind when datasource is not specified', () => { + const query: PanelQueryKind = { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + datasource: undefined, + query: { + kind: 'prometheus', + spec: {}, + }, + }, + }; + + const result = getRuntimePanelDataSource(query); + + expect(result).toEqual({ + uid: 'prometheus-uid', + type: 'prometheus', + }); + }); + + it('should use default datasource when no datasource is specified and query kind does not match any available datasource', () => { + const query: PanelQueryKind = { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + datasource: undefined, + query: { + kind: 'unknown-type', + spec: {}, + }, + }, + }; + + const result = getRuntimePanelDataSource(query); + + expect(result).toEqual({ + uid: 'default-ds-uid', + type: 'default-ds-grafana', + }); + }); + + it('should handle the case when datasource uid is empty string', () => { + const query: PanelQueryKind = { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + datasource: { + uid: '', + type: 'test-ds-type', + }, + query: { + kind: 'prometheus', + spec: {}, + }, + }, + }; + + const result = getRuntimePanelDataSource(query); + + expect(result).toEqual({ + uid: 'prometheus-uid', + type: 'prometheus', + }); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index 48da1b2aa8f..c3b1133c1ec 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -186,11 +186,7 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { panel.spec.data.spec.queries.forEach((query) => { if (!datasource) { if (!query.spec.datasource?.uid) { - const defaultDatasource = config.bootData.settings.defaultDatasource; - const dsList = config.bootData.settings.datasources; - // this is look up by type - const bestGuess = Object.values(dsList).find((ds) => ds.meta.id === query.spec.query.kind); - datasource = bestGuess ? { uid: bestGuess.uid, type: bestGuess.meta.id } : dsList[defaultDatasource]; + datasource = getRuntimePanelDataSource(query); } else { datasource = query.spec.datasource; } @@ -203,26 +199,53 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { } export function getRuntimeVariableDataSource(variable: QueryVariableKind): DataSourceRef | undefined { - let datasource: DataSourceRef | undefined = undefined; + return getDataSourceForQuery(variable.spec.datasource, variable.spec.query.kind); +} - if (!datasource) { - if (!variable.spec.datasource?.uid) { - const defaultDatasource = config.bootData.settings.defaultDatasource; - const dsList = config.bootData.settings.datasources; - // this is look up by type - const bestGuess = Object.values(dsList).find((ds) => ds.meta.id === variable.spec.query.kind); - datasource = bestGuess ? { uid: bestGuess.uid, type: bestGuess.meta.id } : dsList[defaultDatasource]; - } else { - datasource = variable.spec.datasource; - } +export function getRuntimePanelDataSource(query: PanelQueryKind): DataSourceRef | undefined { + return getDataSourceForQuery(query.spec.datasource, query.spec.query.kind); +} + +/** + * @param querySpecDS - The datasource specified in the query + * @param queryKind - The kind of query being performed + * @returns The resolved DataSourceRef + */ +function getDataSourceForQuery( + querySpecDS: DataSourceRef | undefined | null, + queryKind: string +): DataSourceRef | undefined { + // If datasource is specified and has a uid, use it + if (querySpecDS?.uid) { + return querySpecDS; } - return datasource; + + // Otherwise try to infer datasource based on query kind (kind = ds type) + const defaultDatasource = config.bootData.settings.defaultDatasource; + const dsList = config.bootData.settings.datasources; + + // Look up by query type/kind + const bestGuess = dsList && Object.values(dsList).find((ds) => ds.meta.id === queryKind); + + if (bestGuess) { + return { uid: bestGuess.uid, type: bestGuess.meta.id }; + } else if (dsList && dsList[defaultDatasource]) { + // In the datasource list from bootData "id" is the type and the uid could be uid or the name + // in cases like grafana, dashboard or mixed datasource + return { + uid: dsList[defaultDatasource].uid || dsList[defaultDatasource].name, + type: dsList[defaultDatasource].meta.id, + }; + } + + // If we don't find a default datasource, return undefined + return undefined; } function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery { return { refId: query.spec.refId, - datasource: query.spec.datasource, + datasource: getRuntimePanelDataSource(query), hide: query.spec.hidden, ...query.spec.query.spec, }; From 95f04c79cd5176a722d528ee942107084343469e Mon Sep 17 00:00:00 2001 From: Marco de Abreu Date: Fri, 11 Apr 2025 18:52:46 +0200 Subject: [PATCH 32/73] Dashboards: Add Dashboard Schema validation (1) (#103662) --- Makefile | 7 + apps/dashboard/Makefile | 7 + apps/dashboard/go.mod | 4 + apps/dashboard/go.sum | 22 + .../dashboard/v0alpha1/dashboard_kind.cue | 759 ++++++++++++++ .../pkg/apis/dashboard/v0alpha1/validation.go | 90 ++ .../dashboard/v1alpha1/dashboard_kind.cue | 759 ++++++++++++++ .../pkg/apis/dashboard/v1alpha1/validation.go | 90 ++ .../dashboard/v2alpha1/dashboard_spec.cue | 965 ++++++++++++++++++ .../pkg/apis/dashboard/v2alpha1/validation.go | 84 ++ devenv/dev-dashboards/all-panels.json | 2 +- .../feature-toggles/index.md | 3 + .../src/types/featureToggles.gen.ts | 12 + pkg/registry/apis/dashboard/large_test.go | 2 +- pkg/registry/apis/dashboard/mutate.go | 56 +- pkg/registry/apis/dashboard/mutation_test.go | 12 +- .../apis/dashboard/schema_validation.go | 78 ++ pkg/services/apiserver/client/client.go | 12 +- pkg/services/apiserver/client/client_mock.go | 8 +- .../dashboards/service/dashboard_service.go | 16 +- .../service/dashboard_service_test.go | 20 +- pkg/services/featuremgmt/registry.go | 18 + pkg/services/featuremgmt/toggles_gen.csv | 3 + pkg/services/featuremgmt/toggles_gen.go | 12 + pkg/services/featuremgmt/toggles_gen.json | 62 ++ .../folder/folderimpl/unifiedstore.go | 8 +- .../testdata/devdash-all-panels-info.json | 2 +- pkg/tests/apis/dashboard/dashboards_test.go | 3 +- .../integration/api_validation_test.go | 3 +- .../dashboard/testdata/dashboard-test-v1.yaml | 1 + 30 files changed, 3086 insertions(+), 34 deletions(-) create mode 100644 apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue create mode 100644 apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go create mode 100644 apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue create mode 100644 apps/dashboard/pkg/apis/dashboard/v1alpha1/validation.go create mode 100644 apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue create mode 100644 apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go create mode 100644 pkg/registry/apis/dashboard/schema_validation.go diff --git a/Makefile b/Makefile index dd40b492aeb..db63469af41 100644 --- a/Makefile +++ b/Makefile @@ -148,6 +148,13 @@ gen-cue: ## Do all CUE/Thema code generation @echo "generate code from .cue files" go generate ./kinds/gen.go go generate ./public/app/plugins/gen.go + @echo "// This file is managed by Grafana - DO NOT EDIT MANUALLY" > apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue + @echo "// Source: kinds/dashboard/dashboard_kind.cue" >> apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue + @echo "// To sync changes, run: make gen-cue" >> apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue + @echo "" >> apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue + @cat kinds/dashboard/dashboard_kind.cue >> apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue + @cp apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue + .PHONY: gen-cuev2 gen-cuev2: ## Do all CUE code generation diff --git a/apps/dashboard/Makefile b/apps/dashboard/Makefile index 63f6212fd13..3a05e034c79 100644 --- a/apps/dashboard/Makefile +++ b/apps/dashboard/Makefile @@ -53,3 +53,10 @@ post-generate-cleanup: ## Clean up the generated code @sed -e '/\/\/ DeepCopyInto deep copies Spec into another Spec object/,+3d' ./pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go.tmp > ./pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go.tmp2 @rm ./pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go.tmp @mv ./pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go.tmp2 ./pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go + + # Copy dashboard/v2alpha1 spec so we can use it for schema validation + @echo "// This file is managed by grafana-app-sdk - DO NOT EDIT MANUALLY" > ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue + @echo "// Source: apps/dashboard/kinds/v2alpha1/dashboard_spec.cue" >> ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue + @echo "// To sync changes, run: make generate in apps/dashboard" >> ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue + @echo "" >> ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue + @cat ./kinds/v2alpha1/dashboard_spec.cue >> ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index 39a2480a4c9..24a75eb9446 100644 --- a/apps/dashboard/go.mod +++ b/apps/dashboard/go.mod @@ -3,6 +3,7 @@ module github.com/grafana/grafana/apps/dashboard go 1.24.2 require ( + cuelang.org/go v0.11.1 github.com/grafana/grafana-app-sdk v0.35.1 github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250312121619-f64be062c432 @@ -19,6 +20,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/chromedp/cdproto v0.0.0-20240810084448-b931b754e476 // indirect + github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/elazarl/goproxy v1.7.2 // indirect @@ -55,6 +57,7 @@ require ( github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect @@ -69,6 +72,7 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/oklog/run v1.1.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/apps/dashboard/go.sum b/apps/dashboard/go.sum index 04c94943cc1..b13e97d0eaa 100644 --- a/apps/dashboard/go.sum +++ b/apps/dashboard/go.sum @@ -1,3 +1,7 @@ +cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565 h1:R5wwEcbEZSBmeyg91MJZTxfd7WpBo2jPof3AYjRbxwY= +cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565/go.mod h1:5A4xfTzHTXfeVJBU6RAUf+QrlfTCW+017q/QiW+sMLg= +cuelang.org/go v0.11.1 h1:pV+49MX1mmvDm8Qh3Za3M786cty8VKPWzQ1Ho4gZRP0= +cuelang.org/go v0.11.1/go.mod h1:PBY6XvPUswPPJ2inpvUozP9mebDVTXaeehQikhZPBz0= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= @@ -20,6 +24,8 @@ github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitf github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= github.com/chromedp/cdproto v0.0.0-20240810084448-b931b754e476 h1:VnjHsRXCRti7Av7E+j4DCha3kf68echfDzQ+wD11SBU= github.com/chromedp/cdproto v0.0.0-20240810084448-b931b754e476/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= @@ -32,6 +38,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY= +github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= @@ -56,6 +64,8 @@ github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -133,6 +143,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -154,6 +166,8 @@ github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpsp github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -173,6 +187,12 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= @@ -190,6 +210,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d h1:HWfigq7lB31IeJL8iy7jkUmU/PG1Sr8jVGhS749dbUA= +github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue new file mode 100644 index 00000000000..b9fd38146b7 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue @@ -0,0 +1,759 @@ +// This file is managed by Grafana - DO NOT EDIT MANUALLY +// Source: kinds/dashboard/dashboard_kind.cue +// To sync changes, run: make gen-cue + +package kind + +import ( + "strings" + t "time" +) + +name: "Dashboard" +maturity: "experimental" +description: "A Grafana dashboard." + +crd: dummySchema: true + +lineage: schemas: [{ + version: [0, 0] + schema: { + spec: { + // Unique numeric identifier for the dashboard. + // `id` is internal to a specific Grafana instance. `uid` should be used to identify a dashboard across Grafana instances. + id?: int64 | null // TODO eliminate this null option + + // Unique dashboard identifier that can be generated by anyone. string (8-40) + uid?: string + + // Title of dashboard. + title?: string + + // Description of dashboard. + description?: string + + // This property should only be used in dashboards defined by plugins. It is a quick check + // to see if the version has changed since the last time. + revision?: int64 + + // ID of a dashboard imported from the https://grafana.com/grafana/dashboards/ portal + gnetId?: string + + // Tags associated with dashboard. + tags?: [...string] + + // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". + timezone?: string | *"browser" + + // Whether a dashboard is editable or not. + editable?: bool | *true + + // Configuration of dashboard cursor sync behavior. + // Accepted values are 0 (sync turned off), 1 (shared crosshair), 2 (shared crosshair and tooltip). + graphTooltip?: #DashboardCursorSync + + // Time range for dashboard. + // Accepted values are relative time strings like {from: 'now-6h', to: 'now'} or absolute time strings like {from: '2020-07-10T08:00:00.000Z', to: '2020-07-10T14:00:00.000Z'}. + time?: { + from: string | *"now-6h" + to: string | *"now" + } + + // Configuration of the time picker shown at the top of a dashboard. + timepicker?: #TimePickerConfig + + // The month that the fiscal year starts on. 0 = January, 11 = December + fiscalYearStartMonth?: uint8 & <12 | *0 + + // When set to true, the dashboard will redraw panels at an interval matching the pixel width. + // This will keep data "moving left" regardless of the query refresh rate. This setting helps + // avoid dashboards presenting stale live data + liveNow?: bool + + // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". + weekStart?: string + + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + refresh?: string + + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + schemaVersion: uint16 | *41 + + // Version of the dashboard, incremented each time the dashboard is updated. + version?: uint32 + + // List of dashboard panels + panels?: [...(#Panel | #RowPanel)] + + // Configured template variables + templating?: { + // List of configured template variables with their saved values along with some other metadata + list?: [...#VariableModel] + } + + // Contains the list of annotations that are associated with the dashboard. + // Annotations are used to overlay event markers and overlay event tags on graphs. + // Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the HTTP API. + // See https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/ + annotations?: #AnnotationContainer + + // Links with references to other dashboards or external websites. + links?: [...#DashboardLink] + + // Snapshot options. They are present only if the dashboard is a snapshot. + snapshot?: #Snapshot @grafanamaturity(NeedsExpertReview) + + // When set to true, the dashboard will load all panels in the dashboard when it's loaded. + preload?: bool + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + /////////////////////////////////////// + // Definitions (referenced above) are declared below + + // TODO: this should be a regular DataQuery that depends on the selected dashboard + // these match the properties of the "grafana" datasouce that is default in most dashboards + #AnnotationTarget: { + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + limit: int64 + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + matchAny: bool + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + tags: [...string] + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + type: string + ... // datasource will stick their raw DataQuery here + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + #AnnotationPanelFilter: { + // Should the specified panels be included or excluded + exclude?: bool | *false + + // Panel IDs that should be included or excluded + ids: [...uint8] + } @cuetsy(kind="interface") + + // Contains the list of annotations that are associated with the dashboard. + // Annotations are used to overlay event markers and overlay event tags on graphs. + // Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the HTTP API. + // See https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/ + #AnnotationContainer: { + // List of annotations + list?: [...#AnnotationQuery] + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // TODO docs + // FROM: AnnotationQuery in grafana-data/src/types/annotations.ts + #AnnotationQuery: { + // Name of annotation. + name: string + + // Datasource where the annotations data is + datasource: #DataSourceRef + + // When enabled the annotation query is issued with every dashboard refresh + enable: bool | *true + + // Annotation queries can be toggled on or off at the top of the dashboard. + // When hide is true, the toggle is not shown in the dashboard. + hide?: bool | *false + + // Color to use for the annotation event markers + iconColor: string + + // Filters to apply when fetching annotations + filter?: #AnnotationPanelFilter + + // TODO.. this should just be a normal query target + target?: #AnnotationTarget + + // TODO -- this should not exist here, it is based on the --grafana-- datasource + type?: string @grafanamaturity(NeedsExpertReview) + + // Set to 1 for the standard annotation query all dashboards have by default. + builtIn?: number | *0 + + // unless datasources have migrated to the target+mapping, + // they just spread their query into the base object :( + ... + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // A variable is a placeholder for a value. You can use variables in metric queries and in panel titles. + #VariableModel: { + // Type of variable + type: #VariableType + // Name of variable + name: string + // Optional display name + label?: string + // Visibility configuration for the variable + hide?: #VariableHide + // Whether the variable value should be managed by URL query params or not + skipUrlSync?: bool | *false + // Description of variable. It can be defined but `null`. + description?: string + // Query used to fetch values for a variable + query?: string | {...} + // Data source used to fetch values for a variable. It can be defined but `null`. + datasource?: #DataSourceRef + // Shows current selected variable text/value on the dashboard + current?: #VariableOption + // Whether multiple values can be selected or not from variable value list + multi?: bool | *false + // Allow custom values to be entered in the variable + allowCustomValue?: bool | *true + // Options that can be selected for a variable. + options?: [...#VariableOption] + // Options to config when to refresh a variable + refresh?: #VariableRefresh + // Options sort order + sort?: #VariableSort + // Whether all value option is available or not + includeAll?: bool | *false + // Custom all value + allValue?: string + // Optional field, if you want to extract part of a series name or metric node segment. + // Named capture groups can be used to separate the display text and value. + regex?: string + ... + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // Option to be selected in a variable. + #VariableOption: { + // Whether the option is selected or not + selected?: bool + // Text to be displayed for the option + text: string | [...string] + // Value of the option + value: string | [...string] + } @cuetsy(kind="interface") + + // Options to config when to refresh a variable + // `0`: Never refresh the variable + // `1`: Queries the data source every time the dashboard loads. + // `2`: Queries the data source when the dashboard time range changes. + #VariableRefresh: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="never|onDashboardLoad|onTimeRangeChanged") + + // Determine if the variable shows on dashboard + // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing). + #VariableHide: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable") @grafana(TSVeneer="type") + + // Sort variable options + // Accepted values are: + // `0`: No sorting + // `1`: Alphabetical ASC + // `2`: Alphabetical DESC + // `3`: Numerical ASC + // `4`: Numerical DESC + // `5`: Alphabetical Case Insensitive ASC + // `6`: Alphabetical Case Insensitive DESC + // `7`: Natural ASC + // `8`: Natural DESC + #VariableSort: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 @cuetsy(kind="enum",memberNames="disabled|alphabeticalAsc|alphabeticalDesc|numericalAsc|numericalDesc|alphabeticalCaseInsensitiveAsc|alphabeticalCaseInsensitiveDesc|naturalAsc|naturalDesc") + + // Ref to a DataSource instance + #DataSourceRef: { + // The plugin type-id + type?: string + + // Specific datasource instance + uid?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Links with references to other dashboards or external resources + #DashboardLink: { + // Title to display with the link + title: string + // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + type: #DashboardLinkType + // Icon name to be displayed with the link + icon: string + // Tooltip to display when the user hovers their mouse over it + tooltip: string + // Link URL. Only required/valid if the type is link + url?: string + // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards + tags: [...string] + // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards + asDropdown: bool | *false + // If true, the link will be opened in a new tab + targetBlank: bool | *false + // If true, includes current template variables values in the link as query params + includeVars: bool | *false + // If true, includes current time range in the link as query params + keepTime: bool | *false + } @cuetsy(kind="interface") + + // Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + #DashboardLinkType: "link" | "dashboards" @cuetsy(kind="type") + + // Dashboard variable type + // `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. + // `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). + // `constant`: Define a hidden constant. + // `datasource`: Quickly change the data source for an entire dashboard. + // `interval`: Interval variables represent time spans. + // `textbox`: Display a free text input field with an optional default value. + // `custom`: Define the variable options manually using a comma-separated list. + // `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables + #VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | + "system" | "snapshot" @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) + + // Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. + // Continuous color interpolates a color using the percentage of a value relative to min and max. + // Accepted values are: + // `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold + // `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations + // `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations + // `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode + // `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode + // `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode + // `continuous-YlRd`: Continuous Yellow-Red palette mode + // `continuous-BlPu`: Continuous Blue-Purple palette mode + // `continuous-YlBl`: Continuous Yellow-Blue palette mode + // `continuous-blues`: Continuous Blue palette mode + // `continuous-reds`: Continuous Red palette mode + // `continuous-greens`: Continuous Green palette mode + // `continuous-purples`: Continuous Purple palette mode + // `shades`: Shades of a single color. Specify a single color, useful in an override rule. + // `fixed`: Fixed color mode. Specify a single color, useful in an override rule. + #FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" @cuetsy(kind="enum",memberNames="Thresholds|PaletteClassic|PaletteClassicByName|ContinuousGrYlRd|ContinuousRdYlGr|ContinuousBlYlRd|ContinuousYlRd|ContinuousBlPu|ContinuousYlBl|ContinuousBlues|ContinuousReds|ContinuousGreens|ContinuousPurples|Fixed|Shades") @grafanamaturity(NeedsExpertReview) + + // Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value. + #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type") + + // Map a field to a color. + #FieldColor: { + // The main color scheme mode. + mode: #FieldColorModeId + // The fixed color value for fixed or shades color modes. + fixedColor?: string + // Some visualizations need to know how to assign a series color from by value color schemes. + seriesBy?: #FieldColorSeriesByMode + } @cuetsy(kind="interface") + + // Position and dimensions of a panel in the grid + #GridPos: { + // Panel height. The height is the number of rows from the top edge of the panel. + h: uint32 & >0 | *9 + // Panel width. The width is the number of columns from the left edge of the panel. + w: uint32 & >0 & <=24 | *12 + // Panel x. The x coordinate is the number of columns from the left edge of the grid + x: uint32 & >=0 & <24 | *0 + // Panel y. The y coordinate is the number of rows from the top edge of the grid + y: uint32 & >=0 | *0 + // Whether the panel is fixed within the grid. If true, the panel will not be affected by other panels' interactions + static?: bool + } @cuetsy(kind="interface") + + // User-defined value for a metric that triggers visual changes in a panel when this value is met or exceeded + // They are used to conditionally style and color visualizations based on query results , and can be applied to most visualizations. + #Threshold: { + // Value represents a specified metric for the threshold, which triggers a visual change in the dashboard when this value is met or exceeded. + // Nulls currently appear here when serializing -Infinity to JSON. + value: number | null @grafanamaturity(NeedsExpertReview) + // Color represents the color of the visual change that will occur in the dashboard when the threshold value is met or exceeded. + color: string @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Thresholds can either be `absolute` (specific number) or `percentage` (relative to min or max, it will be values between 0 and 1). + #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum",memberNames="Absolute|Percentage") + + // Thresholds configuration for the panel + #ThresholdsConfig: { + // Thresholds mode. + mode: #ThresholdsMode + + // Must be sorted by 'value', first value is always -Infinity + steps: [...#Threshold] @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Allow to transform the visual representation of specific data values in a visualization, irrespective of their original units + #ValueMapping: #ValueMap | #RangeMap | #RegexMap | #SpecialValueMap @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) + + // Supported value mapping types + // `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. + // `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. + // `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. + // `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A. + #MappingType: "value" | "range" | "regex" | "special" @cuetsy(kind="enum",memberNames="ValueToText|RangeToText|RegexToText|SpecialValue") @grafanamaturity(NeedsExpertReview) + + // Maps text values to a color or different display text and color. + // For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. + #ValueMap: { + type: #MappingType & "value" + // Map with : ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } } + options: [string]: #ValueMappingResult + } @cuetsy(kind="interface") + + // Maps numerical ranges to a display text and color. + // For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. + #RangeMap: { + type: #MappingType & "range" + // Range to match against and the result to apply when the value is within the range + options: { + // Min value of the range. It can be null which means -Infinity + from: float64 | null + // Max value of the range. It can be null which means +Infinity + to: float64 | null + // Config to apply when the value is within the range + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Maps regular expressions to replacement text and a color. + // For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. + #RegexMap: { + type: #MappingType & "regex" + // Regular expression to match against and the result to apply when the value matches the regex + options: { + // Regular expression to match against + pattern: string + // Config to apply when the value matches the regex + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. + // See SpecialValueMatch to see the list of special values. + // For example, you can configure a special value mapping so that null values appear as N/A. + #SpecialValueMap: { + type: #MappingType & "special" + options: { + // Special value to match against + match: #SpecialValueMatch + // Config to apply when the value matches the special value + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Special value types supported by the `SpecialValueMap` + #SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty" @cuetsy(kind="enum",memberNames="True|False|Null|NaN|NullAndNan|Empty") + + // Result used as replacement with text and color when the value matches + #ValueMappingResult: { + // Text to display when the value matches + text?: string + // Text to use when the value matches + color?: string + // Icon to display when the value matches. Only specific visualizations. + icon?: string + // Position in the mapping array. Only used internally. + index?: int32 + } @cuetsy(kind="interface") + + // Transformations allow to manipulate data returned by a query before the system applies a visualization. + // Using transformations you can: rename fields, join time series data, perform mathematical operations across queries, + // use the output of one transformation as the input to another transformation, etc. + #DataTransformerConfig: { + // Unique identifier of transformer + id: string + // Disabled transformations are skipped + disabled?: bool + // Optional frame matcher. When missing it will be applied to all results + filter?: #MatcherConfig + // Where to pull DataFrames from as input to transformation + topic?: "series" | "annotations" | "alertStates" // replaced with common.DataTopic + // Options to be passed to the transformer + // Valid options depend on the transformer id + options: _ + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Counterpart for TypeScript's TimeOption type. + #TimeOption: { + display: string + from: string + to: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Time picker configuration + // It defines the default config for the time picker and the refresh picker for the specific dashboard. + #TimePickerConfig: { + // Whether timepicker is visible or not. + hidden?: bool | *false + // Interval options available in the refresh picker dropdown. + refresh_intervals?: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + // Quick ranges for time picker. + quick_ranges?: [...#TimeOption] + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + nowDelay?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // 0 for no shared crosshair or tooltip (default). + // 1 for shared crosshair. + // 2 for shared crosshair AND shared tooltip. + #DashboardCursorSync: *0 | 1 | 2 @cuetsy(kind="enum",memberNames="Off|Crosshair|Tooltip") + + // Schema for panel targets is specified by datasource + // plugins. We use a placeholder definition, which the Go + // schema loader either left open/as-is with the Base + // variant of the Dashboard and Panel families, or filled + // with types derived from plugins in the Instance variant. + // When working directly from CUE, importers can extend this + // type directly to achieve the same effect. + #Target: {...} + + // A dashboard snapshot shares an interactive dashboard publicly. + // It is a read-only version of a dashboard, and is not editable. + // It is possible to create a snapshot of a snapshot. + // Grafana strips away all sensitive information from the dashboard. + // Sensitive information stripped: queries (metric, template,annotation) and panel links. + #Snapshot: { + // Time when the snapshot was created + created: string & t.Time + // Time when the snapshot expires, default is never to expire + expires: string @grafanamaturity(NeedsExpertReview) + // Is the snapshot saved in an external grafana instance + external: bool @grafanamaturity(NeedsExpertReview) + // external url, if snapshot was shared in external grafana instance + externalUrl: string @grafanamaturity(NeedsExpertReview) + // original url, url of the dashboard that was snapshotted + originalUrl: string @grafanamaturity(NeedsExpertReview) + // Unique identifier of the snapshot + id: uint32 @grafanamaturity(NeedsExpertReview) + // Optional, defined the unique key of the snapshot, required if external is true + key: string @grafanamaturity(NeedsExpertReview) + // Optional, name of the snapshot + name: string @grafanamaturity(NeedsExpertReview) + // org id of the snapshot + orgId: uint32 @grafanamaturity(NeedsExpertReview) + // last time when the snapshot was updated + updated: string & t.Time + // url of the snapshot, if snapshot was shared internally + url?: string @grafanamaturity(NeedsExpertReview) + // user id of the snapshot creator + userId: uint32 @grafanamaturity(NeedsExpertReview) + } @grafanamaturity(NeedsExpertReview) + + // Dashboard panels are the basic visualization building blocks. + #Panel: { + // The panel plugin type id. This is used to find the plugin to display the panel. + type: string & strings.MinRunes(1) + + // Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. + id?: uint32 + + // The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. + pluginVersion?: string + + // Depends on the panel plugin. See the plugin documentation for details. + targets?: [...#Target] + + // Panel title. + title?: string + + // Panel description. + description?: string + + // Whether to display the panel without a background. + transparent?: bool | *false + + // The datasource used in all targets. + datasource?: #DataSourceRef + + // Grid position. + gridPos?: #GridPos + + // Panel links. + links?: [...#DashboardLink] + + // Name of template variable to repeat for. + repeat?: string + + // Direction to repeat in if 'repeat' is set. + // `h` for horizontal, `v` for vertical. + repeatDirection?: *"h" | "v" + + // Option for repeated panels that controls max items per row + // Only relevant for horizontally repeated panels + maxPerRow?: number + + // The maximum number of data points that the panel queries are retrieving. + maxDataPoints?: number + + // List of transformations that are applied to the panel data before rendering. + // When there are multiple transformations, Grafana applies them in the order they are listed. + // Each transformation creates a result set that then passes on to the next transformation in the processing pipeline. + transformations?: [...#DataTransformerConfig] + + // The min time interval setting defines a lower limit for the $__interval and $__interval_ms variables. + // This value must be formatted as a number followed by a valid time + // identifier like: "40s", "3d", etc. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + interval?: string + + // Overrides the relative time range for individual panels, + // which causes them to be different than what is selected in + // the dashboard time picker in the top-right corner of the dashboard. You can use this to show metrics from different + // time periods or days on the same dashboard. + // The value is formatted as time operation like: `now-5m` (Last 5 minutes), `now/d` (the day so far), + // `now-5d/d`(Last 5 days), `now/w` (This week so far), `now-2y/y` (Last 2 years). + // Note: Panel time overrides have no effect when the dashboard’s time range is absolute. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + timeFrom?: string + + // Overrides the time range for individual panels by shifting its start and end relative to the time picker. + // For example, you can shift the time range for the panel to be two hours earlier than the dashboard time picker setting `2h`. + // Note: Panel time overrides have no effect when the dashboard’s time range is absolute. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + timeShift?: string + + // Controls if the timeFrom or timeShift overrides are shown in the panel header + hideTimeOverride?: bool + + // Dynamically load the panel + libraryPanel?: #LibraryPanelRef + + // Sets panel queries cache timeout. + cacheTimeout?: string + + // Overrides the data source configured time-to-live for a query cache item in milliseconds + queryCachingTTL?: number + + // It depends on the panel plugin. They are specified by the Options field in panel plugin schemas. + options?: {...} @grafanamaturity(NeedsExpertReview) + + // Field options allow you to change how the data is displayed in your visualizations. + fieldConfig?: #FieldConfigSource + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. + // Each column within this structure is called a field. A field can represent a single time series or table column. + // Field options allow you to change how the data is displayed in your visualizations. + #FieldConfigSource: { + // Defaults are the options applied to all fields. + defaults: #FieldConfig + // Overrides are the options applied to specific fields overriding the defaults. + overrides: [...{ + matcher: #MatcherConfig + properties: [...#DynamicConfigValue] + }] @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // A library panel is a reusable panel that you can use in any dashboard. + // When you make a change to a library panel, that change propagates to all instances of where the panel is used. + // Library panels streamline reuse of panels across multiple dashboards. + #LibraryPanelRef: { + // Library panel name + name: string + // Library panel uid + uid: string + } @cuetsy(kind="interface") + + // Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. + // It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. + #MatcherConfig: { + // The matcher id. This is used to find the matcher implementation from registry. + id: string | *"" @grafanamaturity(NeedsExpertReview) + // The matcher options. This is specific to the matcher implementation. + options?: _ @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + #DynamicConfigValue: { + id: string | *"" @grafanamaturity(NeedsExpertReview) + value?: _ @grafanamaturity(NeedsExpertReview) + } + + // The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. + // Each column within this structure is called a field. A field can represent a single time series or table column. + // Field options allow you to change how the data is displayed in your visualizations. + #FieldConfig: { + // The display value for this field. This supports template variables blank is auto + displayName?: string @grafanamaturity(NeedsExpertReview) + + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string @grafanamaturity(NeedsExpertReview) + + // Human readable field metadata + description?: string @grafanamaturity(NeedsExpertReview) + + // An explicit path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string @grafanamaturity(NeedsExpertReview) + + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: bool @grafanamaturity(NeedsExpertReview) + + // True if data source field supports ad-hoc filters + filterable?: bool @grafanamaturity(NeedsExpertReview) + + // Unit a field should use. The unit you select is applied to all fields except time. + // You can use the units ID availables in Grafana or a custom unit. + // Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts + // As custom unit, you can use the following formats: + // `suffix:` for custom unit that should go after value. + // `prefix:` for custom unit that should go before value. + // `time:` For custom date time formats type for example `time:YYYY-MM-DD`. + // `si:` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character. + // `count:` for a custom count unit. + // `currency:` for custom a currency unit. + unit?: string @grafanamaturity(NeedsExpertReview) + + // Specify the number of decimals Grafana includes in the rendered value. + // If you leave this field blank, Grafana automatically truncates the number of decimals based on the value. + // For example 1.1234 will display as 1.12 and 100.456 will display as 100. + // To display all decimals, set the unit to `String`. + decimals?: number @grafanamaturity(NeedsExpertReview) + + // The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + min?: number @grafanamaturity(NeedsExpertReview) + // The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + max?: number @grafanamaturity(NeedsExpertReview) + + // Convert input values into a display string + mappings?: [...#ValueMapping] @grafanamaturity(NeedsExpertReview) + + // Map numeric values to states + thresholds?: #ThresholdsConfig @grafanamaturity(NeedsExpertReview) + + // Panel color configuration + color?: #FieldColor + + // The behavior when clicking on a result + links?: [...] @grafanamaturity(NeedsExpertReview) + + // Alternative to empty string + noValue?: string @grafanamaturity(NeedsExpertReview) + + // custom is specified by the FieldConfig field + // in panel plugin schemas. + custom?: {...} @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // Row panel + #RowPanel: { + // The panel type + type: "row" + + // Whether this row should be collapsed or not. + collapsed: bool | *false + + // Row title + title?: string + + // Name of default datasource for the row + datasource?: #DataSourceRef + + // Row grid position + gridPos?: #GridPos + + // Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. + id: uint32 + + // List of panels in the row + panels: [...#Panel] + + // Name of template variable to repeat for. + repeat?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + } +}, +] diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go new file mode 100644 index 00000000000..6fdf1dd4515 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go @@ -0,0 +1,90 @@ +package v0alpha1 + +import ( + _ "embed" + json "encoding/json" + fmt "fmt" + "strings" + "sync" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" + "k8s.io/apimachinery/pkg/util/validation/field" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/errors" + cuejson "cuelang.org/go/encoding/json" +) + +func ValidateDashboardSpec(obj *Dashboard, forceValidation bool) (field.ErrorList, field.ErrorList) { + var schemaVersionError field.ErrorList + schemaVersion := schemaversion.GetSchemaVersion(obj.Spec.Object) + if schemaVersion != schemaversion.LATEST_VERSION { + schemaVersionError = field.ErrorList{field.Invalid(field.NewPath("spec", "schemaVersion"), field.OmitValueType{}, fmt.Sprintf("Schema version %d is not supported - please upgrade to %d", schemaVersion, schemaversion.LATEST_VERSION))} + if !forceValidation { + return nil, schemaVersionError + } + } + + data, err := json.Marshal(obj.Spec.Object) + if err != nil { + return field.ErrorList{ + field.Invalid(field.NewPath("spec"), field.OmitValueType{}, err.Error()), + }, schemaVersionError + } + + if err := cuejson.Validate(data, getCueSchema()); err != nil { + errs := field.ErrorList{} + + for _, e := range errors.Errors(err) { + if + // We don't want to return confusing "empty disjunction" errors, + // because the users don't necessarily understand what to do with them. + // For empty disjunctions, CUE will also return more specific errors, + // so we can safely ignore the generic ones. + strings.Contains(e.Error(), "disjunction") || + // We don't want to return errors about unknown fields either. + strings.Contains(e.Error(), "field not allowed") { + continue + } + + // We want to manually format the error message, + // because e.Error() contains the full CUE path. + format, args := e.Msg() + + errs = append(errs, field.Invalid( + field.NewPath(formatErrorPath(e.Path())), + field.OmitValueType{}, + fmt.Sprintf(format, args...), + )) + } + + return errs, schemaVersionError + } + + return nil, schemaVersionError +} + +func formatErrorPath(path []string) string { + // omitting the "lineage.schemas[0].schema.spec" prefix here. + return strings.Join(path[4:], ".") +} + +var ( + compiledSchema cue.Value + getSchemaOnce sync.Once +) + +//go:embed dashboard_kind.cue +var schemaSource string + +func getCueSchema() cue.Value { + getSchemaOnce.Do(func() { + cueCtx := cuecontext.New() + compiledSchema = cueCtx.CompileString(schemaSource).LookupPath( + cue.ParsePath("lineage.schemas[0].schema.spec"), + ) + }) + + return compiledSchema +} diff --git a/apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue new file mode 100644 index 00000000000..b9fd38146b7 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue @@ -0,0 +1,759 @@ +// This file is managed by Grafana - DO NOT EDIT MANUALLY +// Source: kinds/dashboard/dashboard_kind.cue +// To sync changes, run: make gen-cue + +package kind + +import ( + "strings" + t "time" +) + +name: "Dashboard" +maturity: "experimental" +description: "A Grafana dashboard." + +crd: dummySchema: true + +lineage: schemas: [{ + version: [0, 0] + schema: { + spec: { + // Unique numeric identifier for the dashboard. + // `id` is internal to a specific Grafana instance. `uid` should be used to identify a dashboard across Grafana instances. + id?: int64 | null // TODO eliminate this null option + + // Unique dashboard identifier that can be generated by anyone. string (8-40) + uid?: string + + // Title of dashboard. + title?: string + + // Description of dashboard. + description?: string + + // This property should only be used in dashboards defined by plugins. It is a quick check + // to see if the version has changed since the last time. + revision?: int64 + + // ID of a dashboard imported from the https://grafana.com/grafana/dashboards/ portal + gnetId?: string + + // Tags associated with dashboard. + tags?: [...string] + + // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". + timezone?: string | *"browser" + + // Whether a dashboard is editable or not. + editable?: bool | *true + + // Configuration of dashboard cursor sync behavior. + // Accepted values are 0 (sync turned off), 1 (shared crosshair), 2 (shared crosshair and tooltip). + graphTooltip?: #DashboardCursorSync + + // Time range for dashboard. + // Accepted values are relative time strings like {from: 'now-6h', to: 'now'} or absolute time strings like {from: '2020-07-10T08:00:00.000Z', to: '2020-07-10T14:00:00.000Z'}. + time?: { + from: string | *"now-6h" + to: string | *"now" + } + + // Configuration of the time picker shown at the top of a dashboard. + timepicker?: #TimePickerConfig + + // The month that the fiscal year starts on. 0 = January, 11 = December + fiscalYearStartMonth?: uint8 & <12 | *0 + + // When set to true, the dashboard will redraw panels at an interval matching the pixel width. + // This will keep data "moving left" regardless of the query refresh rate. This setting helps + // avoid dashboards presenting stale live data + liveNow?: bool + + // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". + weekStart?: string + + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + refresh?: string + + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + schemaVersion: uint16 | *41 + + // Version of the dashboard, incremented each time the dashboard is updated. + version?: uint32 + + // List of dashboard panels + panels?: [...(#Panel | #RowPanel)] + + // Configured template variables + templating?: { + // List of configured template variables with their saved values along with some other metadata + list?: [...#VariableModel] + } + + // Contains the list of annotations that are associated with the dashboard. + // Annotations are used to overlay event markers and overlay event tags on graphs. + // Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the HTTP API. + // See https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/ + annotations?: #AnnotationContainer + + // Links with references to other dashboards or external websites. + links?: [...#DashboardLink] + + // Snapshot options. They are present only if the dashboard is a snapshot. + snapshot?: #Snapshot @grafanamaturity(NeedsExpertReview) + + // When set to true, the dashboard will load all panels in the dashboard when it's loaded. + preload?: bool + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + /////////////////////////////////////// + // Definitions (referenced above) are declared below + + // TODO: this should be a regular DataQuery that depends on the selected dashboard + // these match the properties of the "grafana" datasouce that is default in most dashboards + #AnnotationTarget: { + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + limit: int64 + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + matchAny: bool + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + tags: [...string] + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + type: string + ... // datasource will stick their raw DataQuery here + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + #AnnotationPanelFilter: { + // Should the specified panels be included or excluded + exclude?: bool | *false + + // Panel IDs that should be included or excluded + ids: [...uint8] + } @cuetsy(kind="interface") + + // Contains the list of annotations that are associated with the dashboard. + // Annotations are used to overlay event markers and overlay event tags on graphs. + // Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the HTTP API. + // See https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/ + #AnnotationContainer: { + // List of annotations + list?: [...#AnnotationQuery] + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // TODO docs + // FROM: AnnotationQuery in grafana-data/src/types/annotations.ts + #AnnotationQuery: { + // Name of annotation. + name: string + + // Datasource where the annotations data is + datasource: #DataSourceRef + + // When enabled the annotation query is issued with every dashboard refresh + enable: bool | *true + + // Annotation queries can be toggled on or off at the top of the dashboard. + // When hide is true, the toggle is not shown in the dashboard. + hide?: bool | *false + + // Color to use for the annotation event markers + iconColor: string + + // Filters to apply when fetching annotations + filter?: #AnnotationPanelFilter + + // TODO.. this should just be a normal query target + target?: #AnnotationTarget + + // TODO -- this should not exist here, it is based on the --grafana-- datasource + type?: string @grafanamaturity(NeedsExpertReview) + + // Set to 1 for the standard annotation query all dashboards have by default. + builtIn?: number | *0 + + // unless datasources have migrated to the target+mapping, + // they just spread their query into the base object :( + ... + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // A variable is a placeholder for a value. You can use variables in metric queries and in panel titles. + #VariableModel: { + // Type of variable + type: #VariableType + // Name of variable + name: string + // Optional display name + label?: string + // Visibility configuration for the variable + hide?: #VariableHide + // Whether the variable value should be managed by URL query params or not + skipUrlSync?: bool | *false + // Description of variable. It can be defined but `null`. + description?: string + // Query used to fetch values for a variable + query?: string | {...} + // Data source used to fetch values for a variable. It can be defined but `null`. + datasource?: #DataSourceRef + // Shows current selected variable text/value on the dashboard + current?: #VariableOption + // Whether multiple values can be selected or not from variable value list + multi?: bool | *false + // Allow custom values to be entered in the variable + allowCustomValue?: bool | *true + // Options that can be selected for a variable. + options?: [...#VariableOption] + // Options to config when to refresh a variable + refresh?: #VariableRefresh + // Options sort order + sort?: #VariableSort + // Whether all value option is available or not + includeAll?: bool | *false + // Custom all value + allValue?: string + // Optional field, if you want to extract part of a series name or metric node segment. + // Named capture groups can be used to separate the display text and value. + regex?: string + ... + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // Option to be selected in a variable. + #VariableOption: { + // Whether the option is selected or not + selected?: bool + // Text to be displayed for the option + text: string | [...string] + // Value of the option + value: string | [...string] + } @cuetsy(kind="interface") + + // Options to config when to refresh a variable + // `0`: Never refresh the variable + // `1`: Queries the data source every time the dashboard loads. + // `2`: Queries the data source when the dashboard time range changes. + #VariableRefresh: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="never|onDashboardLoad|onTimeRangeChanged") + + // Determine if the variable shows on dashboard + // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing). + #VariableHide: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable") @grafana(TSVeneer="type") + + // Sort variable options + // Accepted values are: + // `0`: No sorting + // `1`: Alphabetical ASC + // `2`: Alphabetical DESC + // `3`: Numerical ASC + // `4`: Numerical DESC + // `5`: Alphabetical Case Insensitive ASC + // `6`: Alphabetical Case Insensitive DESC + // `7`: Natural ASC + // `8`: Natural DESC + #VariableSort: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 @cuetsy(kind="enum",memberNames="disabled|alphabeticalAsc|alphabeticalDesc|numericalAsc|numericalDesc|alphabeticalCaseInsensitiveAsc|alphabeticalCaseInsensitiveDesc|naturalAsc|naturalDesc") + + // Ref to a DataSource instance + #DataSourceRef: { + // The plugin type-id + type?: string + + // Specific datasource instance + uid?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Links with references to other dashboards or external resources + #DashboardLink: { + // Title to display with the link + title: string + // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + type: #DashboardLinkType + // Icon name to be displayed with the link + icon: string + // Tooltip to display when the user hovers their mouse over it + tooltip: string + // Link URL. Only required/valid if the type is link + url?: string + // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards + tags: [...string] + // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards + asDropdown: bool | *false + // If true, the link will be opened in a new tab + targetBlank: bool | *false + // If true, includes current template variables values in the link as query params + includeVars: bool | *false + // If true, includes current time range in the link as query params + keepTime: bool | *false + } @cuetsy(kind="interface") + + // Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + #DashboardLinkType: "link" | "dashboards" @cuetsy(kind="type") + + // Dashboard variable type + // `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. + // `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). + // `constant`: Define a hidden constant. + // `datasource`: Quickly change the data source for an entire dashboard. + // `interval`: Interval variables represent time spans. + // `textbox`: Display a free text input field with an optional default value. + // `custom`: Define the variable options manually using a comma-separated list. + // `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables + #VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | + "system" | "snapshot" @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) + + // Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. + // Continuous color interpolates a color using the percentage of a value relative to min and max. + // Accepted values are: + // `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold + // `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations + // `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations + // `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode + // `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode + // `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode + // `continuous-YlRd`: Continuous Yellow-Red palette mode + // `continuous-BlPu`: Continuous Blue-Purple palette mode + // `continuous-YlBl`: Continuous Yellow-Blue palette mode + // `continuous-blues`: Continuous Blue palette mode + // `continuous-reds`: Continuous Red palette mode + // `continuous-greens`: Continuous Green palette mode + // `continuous-purples`: Continuous Purple palette mode + // `shades`: Shades of a single color. Specify a single color, useful in an override rule. + // `fixed`: Fixed color mode. Specify a single color, useful in an override rule. + #FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" @cuetsy(kind="enum",memberNames="Thresholds|PaletteClassic|PaletteClassicByName|ContinuousGrYlRd|ContinuousRdYlGr|ContinuousBlYlRd|ContinuousYlRd|ContinuousBlPu|ContinuousYlBl|ContinuousBlues|ContinuousReds|ContinuousGreens|ContinuousPurples|Fixed|Shades") @grafanamaturity(NeedsExpertReview) + + // Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value. + #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type") + + // Map a field to a color. + #FieldColor: { + // The main color scheme mode. + mode: #FieldColorModeId + // The fixed color value for fixed or shades color modes. + fixedColor?: string + // Some visualizations need to know how to assign a series color from by value color schemes. + seriesBy?: #FieldColorSeriesByMode + } @cuetsy(kind="interface") + + // Position and dimensions of a panel in the grid + #GridPos: { + // Panel height. The height is the number of rows from the top edge of the panel. + h: uint32 & >0 | *9 + // Panel width. The width is the number of columns from the left edge of the panel. + w: uint32 & >0 & <=24 | *12 + // Panel x. The x coordinate is the number of columns from the left edge of the grid + x: uint32 & >=0 & <24 | *0 + // Panel y. The y coordinate is the number of rows from the top edge of the grid + y: uint32 & >=0 | *0 + // Whether the panel is fixed within the grid. If true, the panel will not be affected by other panels' interactions + static?: bool + } @cuetsy(kind="interface") + + // User-defined value for a metric that triggers visual changes in a panel when this value is met or exceeded + // They are used to conditionally style and color visualizations based on query results , and can be applied to most visualizations. + #Threshold: { + // Value represents a specified metric for the threshold, which triggers a visual change in the dashboard when this value is met or exceeded. + // Nulls currently appear here when serializing -Infinity to JSON. + value: number | null @grafanamaturity(NeedsExpertReview) + // Color represents the color of the visual change that will occur in the dashboard when the threshold value is met or exceeded. + color: string @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Thresholds can either be `absolute` (specific number) or `percentage` (relative to min or max, it will be values between 0 and 1). + #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum",memberNames="Absolute|Percentage") + + // Thresholds configuration for the panel + #ThresholdsConfig: { + // Thresholds mode. + mode: #ThresholdsMode + + // Must be sorted by 'value', first value is always -Infinity + steps: [...#Threshold] @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Allow to transform the visual representation of specific data values in a visualization, irrespective of their original units + #ValueMapping: #ValueMap | #RangeMap | #RegexMap | #SpecialValueMap @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) + + // Supported value mapping types + // `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. + // `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. + // `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. + // `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A. + #MappingType: "value" | "range" | "regex" | "special" @cuetsy(kind="enum",memberNames="ValueToText|RangeToText|RegexToText|SpecialValue") @grafanamaturity(NeedsExpertReview) + + // Maps text values to a color or different display text and color. + // For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. + #ValueMap: { + type: #MappingType & "value" + // Map with : ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } } + options: [string]: #ValueMappingResult + } @cuetsy(kind="interface") + + // Maps numerical ranges to a display text and color. + // For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. + #RangeMap: { + type: #MappingType & "range" + // Range to match against and the result to apply when the value is within the range + options: { + // Min value of the range. It can be null which means -Infinity + from: float64 | null + // Max value of the range. It can be null which means +Infinity + to: float64 | null + // Config to apply when the value is within the range + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Maps regular expressions to replacement text and a color. + // For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. + #RegexMap: { + type: #MappingType & "regex" + // Regular expression to match against and the result to apply when the value matches the regex + options: { + // Regular expression to match against + pattern: string + // Config to apply when the value matches the regex + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. + // See SpecialValueMatch to see the list of special values. + // For example, you can configure a special value mapping so that null values appear as N/A. + #SpecialValueMap: { + type: #MappingType & "special" + options: { + // Special value to match against + match: #SpecialValueMatch + // Config to apply when the value matches the special value + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Special value types supported by the `SpecialValueMap` + #SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty" @cuetsy(kind="enum",memberNames="True|False|Null|NaN|NullAndNan|Empty") + + // Result used as replacement with text and color when the value matches + #ValueMappingResult: { + // Text to display when the value matches + text?: string + // Text to use when the value matches + color?: string + // Icon to display when the value matches. Only specific visualizations. + icon?: string + // Position in the mapping array. Only used internally. + index?: int32 + } @cuetsy(kind="interface") + + // Transformations allow to manipulate data returned by a query before the system applies a visualization. + // Using transformations you can: rename fields, join time series data, perform mathematical operations across queries, + // use the output of one transformation as the input to another transformation, etc. + #DataTransformerConfig: { + // Unique identifier of transformer + id: string + // Disabled transformations are skipped + disabled?: bool + // Optional frame matcher. When missing it will be applied to all results + filter?: #MatcherConfig + // Where to pull DataFrames from as input to transformation + topic?: "series" | "annotations" | "alertStates" // replaced with common.DataTopic + // Options to be passed to the transformer + // Valid options depend on the transformer id + options: _ + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Counterpart for TypeScript's TimeOption type. + #TimeOption: { + display: string + from: string + to: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Time picker configuration + // It defines the default config for the time picker and the refresh picker for the specific dashboard. + #TimePickerConfig: { + // Whether timepicker is visible or not. + hidden?: bool | *false + // Interval options available in the refresh picker dropdown. + refresh_intervals?: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + // Quick ranges for time picker. + quick_ranges?: [...#TimeOption] + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + nowDelay?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // 0 for no shared crosshair or tooltip (default). + // 1 for shared crosshair. + // 2 for shared crosshair AND shared tooltip. + #DashboardCursorSync: *0 | 1 | 2 @cuetsy(kind="enum",memberNames="Off|Crosshair|Tooltip") + + // Schema for panel targets is specified by datasource + // plugins. We use a placeholder definition, which the Go + // schema loader either left open/as-is with the Base + // variant of the Dashboard and Panel families, or filled + // with types derived from plugins in the Instance variant. + // When working directly from CUE, importers can extend this + // type directly to achieve the same effect. + #Target: {...} + + // A dashboard snapshot shares an interactive dashboard publicly. + // It is a read-only version of a dashboard, and is not editable. + // It is possible to create a snapshot of a snapshot. + // Grafana strips away all sensitive information from the dashboard. + // Sensitive information stripped: queries (metric, template,annotation) and panel links. + #Snapshot: { + // Time when the snapshot was created + created: string & t.Time + // Time when the snapshot expires, default is never to expire + expires: string @grafanamaturity(NeedsExpertReview) + // Is the snapshot saved in an external grafana instance + external: bool @grafanamaturity(NeedsExpertReview) + // external url, if snapshot was shared in external grafana instance + externalUrl: string @grafanamaturity(NeedsExpertReview) + // original url, url of the dashboard that was snapshotted + originalUrl: string @grafanamaturity(NeedsExpertReview) + // Unique identifier of the snapshot + id: uint32 @grafanamaturity(NeedsExpertReview) + // Optional, defined the unique key of the snapshot, required if external is true + key: string @grafanamaturity(NeedsExpertReview) + // Optional, name of the snapshot + name: string @grafanamaturity(NeedsExpertReview) + // org id of the snapshot + orgId: uint32 @grafanamaturity(NeedsExpertReview) + // last time when the snapshot was updated + updated: string & t.Time + // url of the snapshot, if snapshot was shared internally + url?: string @grafanamaturity(NeedsExpertReview) + // user id of the snapshot creator + userId: uint32 @grafanamaturity(NeedsExpertReview) + } @grafanamaturity(NeedsExpertReview) + + // Dashboard panels are the basic visualization building blocks. + #Panel: { + // The panel plugin type id. This is used to find the plugin to display the panel. + type: string & strings.MinRunes(1) + + // Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. + id?: uint32 + + // The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. + pluginVersion?: string + + // Depends on the panel plugin. See the plugin documentation for details. + targets?: [...#Target] + + // Panel title. + title?: string + + // Panel description. + description?: string + + // Whether to display the panel without a background. + transparent?: bool | *false + + // The datasource used in all targets. + datasource?: #DataSourceRef + + // Grid position. + gridPos?: #GridPos + + // Panel links. + links?: [...#DashboardLink] + + // Name of template variable to repeat for. + repeat?: string + + // Direction to repeat in if 'repeat' is set. + // `h` for horizontal, `v` for vertical. + repeatDirection?: *"h" | "v" + + // Option for repeated panels that controls max items per row + // Only relevant for horizontally repeated panels + maxPerRow?: number + + // The maximum number of data points that the panel queries are retrieving. + maxDataPoints?: number + + // List of transformations that are applied to the panel data before rendering. + // When there are multiple transformations, Grafana applies them in the order they are listed. + // Each transformation creates a result set that then passes on to the next transformation in the processing pipeline. + transformations?: [...#DataTransformerConfig] + + // The min time interval setting defines a lower limit for the $__interval and $__interval_ms variables. + // This value must be formatted as a number followed by a valid time + // identifier like: "40s", "3d", etc. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + interval?: string + + // Overrides the relative time range for individual panels, + // which causes them to be different than what is selected in + // the dashboard time picker in the top-right corner of the dashboard. You can use this to show metrics from different + // time periods or days on the same dashboard. + // The value is formatted as time operation like: `now-5m` (Last 5 minutes), `now/d` (the day so far), + // `now-5d/d`(Last 5 days), `now/w` (This week so far), `now-2y/y` (Last 2 years). + // Note: Panel time overrides have no effect when the dashboard’s time range is absolute. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + timeFrom?: string + + // Overrides the time range for individual panels by shifting its start and end relative to the time picker. + // For example, you can shift the time range for the panel to be two hours earlier than the dashboard time picker setting `2h`. + // Note: Panel time overrides have no effect when the dashboard’s time range is absolute. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + timeShift?: string + + // Controls if the timeFrom or timeShift overrides are shown in the panel header + hideTimeOverride?: bool + + // Dynamically load the panel + libraryPanel?: #LibraryPanelRef + + // Sets panel queries cache timeout. + cacheTimeout?: string + + // Overrides the data source configured time-to-live for a query cache item in milliseconds + queryCachingTTL?: number + + // It depends on the panel plugin. They are specified by the Options field in panel plugin schemas. + options?: {...} @grafanamaturity(NeedsExpertReview) + + // Field options allow you to change how the data is displayed in your visualizations. + fieldConfig?: #FieldConfigSource + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. + // Each column within this structure is called a field. A field can represent a single time series or table column. + // Field options allow you to change how the data is displayed in your visualizations. + #FieldConfigSource: { + // Defaults are the options applied to all fields. + defaults: #FieldConfig + // Overrides are the options applied to specific fields overriding the defaults. + overrides: [...{ + matcher: #MatcherConfig + properties: [...#DynamicConfigValue] + }] @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // A library panel is a reusable panel that you can use in any dashboard. + // When you make a change to a library panel, that change propagates to all instances of where the panel is used. + // Library panels streamline reuse of panels across multiple dashboards. + #LibraryPanelRef: { + // Library panel name + name: string + // Library panel uid + uid: string + } @cuetsy(kind="interface") + + // Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. + // It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. + #MatcherConfig: { + // The matcher id. This is used to find the matcher implementation from registry. + id: string | *"" @grafanamaturity(NeedsExpertReview) + // The matcher options. This is specific to the matcher implementation. + options?: _ @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + #DynamicConfigValue: { + id: string | *"" @grafanamaturity(NeedsExpertReview) + value?: _ @grafanamaturity(NeedsExpertReview) + } + + // The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. + // Each column within this structure is called a field. A field can represent a single time series or table column. + // Field options allow you to change how the data is displayed in your visualizations. + #FieldConfig: { + // The display value for this field. This supports template variables blank is auto + displayName?: string @grafanamaturity(NeedsExpertReview) + + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string @grafanamaturity(NeedsExpertReview) + + // Human readable field metadata + description?: string @grafanamaturity(NeedsExpertReview) + + // An explicit path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string @grafanamaturity(NeedsExpertReview) + + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: bool @grafanamaturity(NeedsExpertReview) + + // True if data source field supports ad-hoc filters + filterable?: bool @grafanamaturity(NeedsExpertReview) + + // Unit a field should use. The unit you select is applied to all fields except time. + // You can use the units ID availables in Grafana or a custom unit. + // Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts + // As custom unit, you can use the following formats: + // `suffix:` for custom unit that should go after value. + // `prefix:` for custom unit that should go before value. + // `time:` For custom date time formats type for example `time:YYYY-MM-DD`. + // `si:` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character. + // `count:` for a custom count unit. + // `currency:` for custom a currency unit. + unit?: string @grafanamaturity(NeedsExpertReview) + + // Specify the number of decimals Grafana includes in the rendered value. + // If you leave this field blank, Grafana automatically truncates the number of decimals based on the value. + // For example 1.1234 will display as 1.12 and 100.456 will display as 100. + // To display all decimals, set the unit to `String`. + decimals?: number @grafanamaturity(NeedsExpertReview) + + // The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + min?: number @grafanamaturity(NeedsExpertReview) + // The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + max?: number @grafanamaturity(NeedsExpertReview) + + // Convert input values into a display string + mappings?: [...#ValueMapping] @grafanamaturity(NeedsExpertReview) + + // Map numeric values to states + thresholds?: #ThresholdsConfig @grafanamaturity(NeedsExpertReview) + + // Panel color configuration + color?: #FieldColor + + // The behavior when clicking on a result + links?: [...] @grafanamaturity(NeedsExpertReview) + + // Alternative to empty string + noValue?: string @grafanamaturity(NeedsExpertReview) + + // custom is specified by the FieldConfig field + // in panel plugin schemas. + custom?: {...} @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // Row panel + #RowPanel: { + // The panel type + type: "row" + + // Whether this row should be collapsed or not. + collapsed: bool | *false + + // Row title + title?: string + + // Name of default datasource for the row + datasource?: #DataSourceRef + + // Row grid position + gridPos?: #GridPos + + // Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. + id: uint32 + + // List of panels in the row + panels: [...#Panel] + + // Name of template variable to repeat for. + repeat?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + } +}, +] diff --git a/apps/dashboard/pkg/apis/dashboard/v1alpha1/validation.go b/apps/dashboard/pkg/apis/dashboard/v1alpha1/validation.go new file mode 100644 index 00000000000..37117b10108 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v1alpha1/validation.go @@ -0,0 +1,90 @@ +package v1alpha1 + +import ( + _ "embed" + json "encoding/json" + fmt "fmt" + "strings" + "sync" + + "k8s.io/apimachinery/pkg/util/validation/field" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/errors" + cuejson "cuelang.org/go/encoding/json" + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func ValidateDashboardSpec(obj *Dashboard, forceValidation bool) (field.ErrorList, field.ErrorList) { + var schemaVersionError field.ErrorList + schemaVersion := schemaversion.GetSchemaVersion(obj.Spec.Object) + if schemaVersion != schemaversion.LATEST_VERSION { + schemaVersionError = field.ErrorList{field.Invalid(field.NewPath("spec", "schemaVersion"), field.OmitValueType{}, fmt.Sprintf("Schema version %d is not supported - please upgrade to %d", schemaVersion, schemaversion.LATEST_VERSION))} + if !forceValidation { + return nil, schemaVersionError + } + } + + data, err := json.Marshal(obj.Spec.Object) + if err != nil { + return field.ErrorList{ + field.Invalid(field.NewPath("spec"), field.OmitValueType{}, err.Error()), + }, schemaVersionError + } + + if err := cuejson.Validate(data, getCueSchema()); err != nil { + errs := field.ErrorList{} + + for _, e := range errors.Errors(err) { + if + // We don't want to return confusing "empty disjunction" errors, + // because the users don't necessarily understand what to do with them. + // For empty disjunctions, CUE will also return more specific errors, + // so we can safely ignore the generic ones. + strings.Contains(e.Error(), "disjunction") || + // We don't want to return errors about unknown fields either. + strings.Contains(e.Error(), "field not allowed") { + continue + } + + // We want to manually format the error message, + // because e.Error() contains the full CUE path. + format, args := e.Msg() + + errs = append(errs, field.Invalid( + field.NewPath(formatErrorPath(e.Path())), + field.OmitValueType{}, + fmt.Sprintf(format, args...), + )) + } + + return errs, schemaVersionError + } + + return nil, schemaVersionError +} + +func formatErrorPath(path []string) string { + // omitting the "lineage.schemas[0].schema.spec" prefix here. + return strings.Join(path[4:], ".") +} + +var ( + compiledSchema cue.Value + getSchemaOnce sync.Once +) + +//go:embed dashboard_kind.cue +var schemaSource string + +func getCueSchema() cue.Value { + getSchemaOnce.Do(func() { + cueCtx := cuecontext.New() + compiledSchema = cueCtx.CompileString(schemaSource).LookupPath( + cue.ParsePath("lineage.schemas[0].schema.spec"), + ) + }) + + return compiledSchema +} diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue new file mode 100644 index 00000000000..4e6ace03e5f --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -0,0 +1,965 @@ +// This file is managed by grafana-app-sdk - DO NOT EDIT MANUALLY +// Source: apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +// To sync changes, run: make generate in apps/dashboard + +package v2alpha1 + +DashboardSpec: { + // Title of dashboard. + annotations: [...AnnotationQueryKind] + + // Configuration of dashboard cursor sync behavior. + // "Off" for no shared crosshair or tooltip (default). + // "Crosshair" for shared crosshair. + // "Tooltip" for shared crosshair AND shared tooltip. + cursorSync: DashboardCursorSync + + // Description of dashboard. + description?: string + + // Whether a dashboard is editable or not. + editable?: bool | *true + + elements: [ElementReference.name]: Element + + layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind + + // Links with references to other dashboards or external websites. + links: [...DashboardLink] + + // When set to true, the dashboard will redraw panels at an interval matching the pixel width. + // This will keep data "moving left" regardless of the query refresh rate. This setting helps + // avoid dashboards presenting stale live data. + liveNow?: bool + + // When set to true, the dashboard will load all panels in the dashboard when it's loaded. + preload: bool + + // Plugins only. The version of the dashboard installed together with the plugin. + // This is used to determine if the dashboard should be updated when the plugin is updated. + revision?: uint16 + + // Tags associated with dashboard. + tags: [...string] + + timeSettings: TimeSettingsSpec + + // Title of dashboard. + title: string + + // Configured template variables. + variables: [...VariableKind] +} + +// Supported dashboard elements +Element: PanelKind | LibraryPanelKind // |* more element types in the future + +LibraryPanelKind: { + kind: "LibraryPanel" + spec: LibraryPanelKindSpec +} + +LibraryPanelKindSpec: { + // Panel ID for the library panel in the dashboard + id: number + // Title for the library panel in the dashboard + title: string + + libraryPanel: LibraryPanelRef +} + +// A library panel is a reusable panel that you can use in any dashboard. +// When you make a change to a library panel, that change propagates to all instances of where the panel is used. +// Library panels streamline reuse of panels across multiple dashboards. +LibraryPanelRef: { + // Library panel name + name: string + // Library panel uid + uid: string +} + +AnnotationPanelFilter: { + // Should the specified panels be included or excluded + exclude?: bool | *false + + // Panel IDs that should be included or excluded + ids: [...uint8] +} + +// "Off" for no shared crosshair or tooltip (default). +// "Crosshair" for shared crosshair. +// "Tooltip" for shared crosshair AND shared tooltip. +DashboardCursorSync: "Off" | "Crosshair" | "Tooltip" + +// Links with references to other dashboards or external resources +DashboardLink: { + // Title to display with the link + title: string + // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + // FIXME: The type is generated as `type: DashboardLinkType | dashboardLinkType.Link;` but it should be `type: DashboardLinkType` + type: DashboardLinkType + // Icon name to be displayed with the link + icon: string + // Tooltip to display when the user hovers their mouse over it + tooltip: string + // Link URL. Only required/valid if the type is link + url?: string + // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards + tags: [...string] + // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards + asDropdown: bool | *false + // If true, the link will be opened in a new tab + targetBlank: bool | *false + // If true, includes current template variables values in the link as query params + includeVars: bool | *false + // If true, includes current time range in the link as query params + keepTime: bool | *false +} + +DataSourceRef: { + // The plugin type-id + type?: string + + // Specific datasource instance + uid?: string +} + +// A topic is attached to DataFrame metadata in query results. +// This specifies where the data should be used. +DataTopic: "series" | "annotations" | "alertStates" @cog(kind="enum",memberNames="Series|Annotations|AlertStates") + +// Transformations allow to manipulate data returned by a query before the system applies a visualization. +// Using transformations you can: rename fields, join time series data, perform mathematical operations across queries, +// use the output of one transformation as the input to another transformation, etc. +DataTransformerConfig: { + // Unique identifier of transformer + id: string + // Disabled transformations are skipped + disabled?: bool + // Optional frame matcher. When missing it will be applied to all results + filter?: MatcherConfig + // Where to pull DataFrames from as input to transformation + topic?: DataTopic + // Options to be passed to the transformer + // Valid options depend on the transformer id + options: _ +} + +DataLink: { + title: string + url: string + targetBlank?: bool +} + +// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. +// Each column within this structure is called a field. A field can represent a single time series or table column. +// Field options allow you to change how the data is displayed in your visualizations. +FieldConfigSource: { + // Defaults are the options applied to all fields. + defaults: FieldConfig + // Overrides are the options applied to specific fields overriding the defaults. + overrides: [...{ + matcher: MatcherConfig + properties: [...DynamicConfigValue] + }] +} + +// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. +// Each column within this structure is called a field. A field can represent a single time series or table column. +// Field options allow you to change how the data is displayed in your visualizations. +FieldConfig: { + // The display value for this field. This supports template variables blank is auto + displayName?: string + + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string + + // Human readable field metadata + description?: string + + // An explicit path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string + + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: bool + + // True if data source field supports ad-hoc filters + filterable?: bool + + // Unit a field should use. The unit you select is applied to all fields except time. + // You can use the units ID availables in Grafana or a custom unit. + // Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts + // As custom unit, you can use the following formats: + // `suffix:` for custom unit that should go after value. + // `prefix:` for custom unit that should go before value. + // `time:` For custom date time formats type for example `time:YYYY-MM-DD`. + // `si:` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character. + // `count:` for a custom count unit. + // `currency:` for custom a currency unit. + unit?: string + + // Specify the number of decimals Grafana includes in the rendered value. + // If you leave this field blank, Grafana automatically truncates the number of decimals based on the value. + // For example 1.1234 will display as 1.12 and 100.456 will display as 100. + // To display all decimals, set the unit to `String`. + decimals?: number + + // The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + min?: number + // The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + max?: number + + // Convert input values into a display string + mappings?: [...ValueMapping] + + // Map numeric values to states + thresholds?: ThresholdsConfig + + // Panel color configuration + color?: FieldColor + + // The behavior when clicking on a result + links?: [...] + + // Alternative to empty string + noValue?: string + + // custom is specified by the FieldConfig field + // in panel plugin schemas. + custom?: {...} +} + +DynamicConfigValue: { + id: string | *"" + value?: _ +} + +// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. +// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. +MatcherConfig: { + // The matcher id. This is used to find the matcher implementation from registry. + id: string | *"" + // The matcher options. This is specific to the matcher implementation. + options?: _ +} + +Threshold: { + value: number + color: string +} + +ThresholdsMode: "absolute" | "percentage" + +ThresholdsConfig: { + mode: ThresholdsMode + steps: [...Threshold] +} + +ValueMapping: ValueMap | RangeMap | RegexMap | SpecialValueMap + +// Supported value mapping types +// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. +// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. +// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. +// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A. +MappingType: "value" | "range" | "regex" | "special" + +// Maps text values to a color or different display text and color. +// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. +ValueMap: { + type: MappingType & "value" + // Map with : ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } } + options: [string]: ValueMappingResult +} + +// Maps numerical ranges to a display text and color. +// For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. +RangeMap: { + type: MappingType & "range" + // Range to match against and the result to apply when the value is within the range + options: { + // Min value of the range. It can be null which means -Infinity + from: float64 | null + // Max value of the range. It can be null which means +Infinity + to: float64 | null + // Config to apply when the value is within the range + result: ValueMappingResult + } +} + +// Maps regular expressions to replacement text and a color. +// For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. +RegexMap: { + type: MappingType & "regex" + // Regular expression to match against and the result to apply when the value matches the regex + options: { + // Regular expression to match against + pattern: string + // Config to apply when the value matches the regex + result: ValueMappingResult + } +} + +// Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. +// See SpecialValueMatch to see the list of special values. +// For example, you can configure a special value mapping so that null values appear as N/A. +SpecialValueMap: { + type: MappingType & "special" + options: { + // Special value to match against + match: SpecialValueMatch + // Config to apply when the value matches the special value + result: ValueMappingResult + } +} + +// Special value types supported by the `SpecialValueMap` +SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty" @cog(kind="enum",memberNames="True|False|Null|NaN|NullAndNaN|Empty") + +// Result used as replacement with text and color when the value matches +ValueMappingResult: { + // Text to display when the value matches + text?: string + // Text to use when the value matches + color?: string + // Icon to display when the value matches. Only specific visualizations. + icon?: string + // Position in the mapping array. Only used internally. + index?: int32 +} + +// Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. +// Continuous color interpolates a color using the percentage of a value relative to min and max. +// Accepted values are: +// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold +// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations +// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations +// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode +// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode +// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode +// `continuous-YlRd`: Continuous Yellow-Red palette mode +// `continuous-BlPu`: Continuous Blue-Purple palette mode +// `continuous-YlBl`: Continuous Yellow-Blue palette mode +// `continuous-blues`: Continuous Blue palette mode +// `continuous-reds`: Continuous Red palette mode +// `continuous-greens`: Continuous Green palette mode +// `continuous-purples`: Continuous Purple palette mode +// `shades`: Shades of a single color. Specify a single color, useful in an override rule. +// `fixed`: Fixed color mode. Specify a single color, useful in an override rule. +FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" + +// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value. +FieldColorSeriesByMode: "min" | "max" | "last" + +// Map a field to a color. +FieldColor: { + // The main color scheme mode. + mode: FieldColorModeId + // The fixed color value for fixed or shades color modes. + fixedColor?: string + // Some visualizations need to know how to assign a series color from by value color schemes. + seriesBy?: FieldColorSeriesByMode +} + +// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) +DashboardLinkType: "link" | "dashboards" + +// --- Common types --- +Kind: { + kind: string + spec: _ + metadata?: _ +} + +// --- Kinds --- +VizConfigSpec: { + pluginVersion: string + options: [string]: _ + fieldConfig: FieldConfigSource +} + +VizConfigKind: { + // The kind of a VizConfigKind is the plugin ID + kind: string + spec: VizConfigSpec +} + +AnnotationQuerySpec: { + datasource?: DataSourceRef + query?: DataQueryKind + enable: bool + hide: bool + iconColor: string + name: string + builtIn?: bool | *false + filter?: AnnotationPanelFilter + options?: [string]: _ //Catch-all field for datasource-specific properties +} + +AnnotationQueryKind: { + kind: "AnnotationQuery" + spec: AnnotationQuerySpec +} + +QueryOptionsSpec: { + timeFrom?: string + maxDataPoints?: int + timeShift?: string + queryCachingTTL?: int + interval?: string + cacheTimeout?: string + hideTimeOverride?: bool +} + +DataQueryKind: { + // The kind of a DataQueryKind is the datasource type + kind: string + spec: [string]: _ +} + +PanelQuerySpec: { + query: DataQueryKind + datasource?: DataSourceRef + + refId: string + hidden: bool +} + +PanelQueryKind: { + kind: "PanelQuery" + spec: PanelQuerySpec +} + +TransformationKind: { + // The kind of a TransformationKind is the transformation ID + kind: string + spec: DataTransformerConfig +} + +QueryGroupSpec: { + queries: [...PanelQueryKind] + transformations: [...TransformationKind] + queryOptions: QueryOptionsSpec +} + +QueryGroupKind: { + kind: "QueryGroup" + spec: QueryGroupSpec +} + +TimeRangeOption: { + display: string | *"Last 6 hours" + from: string | *"now-6h" + to: string | *"now" +} + +// Time configuration +// It defines the default time config for the time picker, the refresh picker for the specific dashboard. +TimeSettingsSpec: { + // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". + timezone?: string | *"browser" + // Start time range for dashboard. + // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". + from: string | *"now-6h" + // End time range for dashboard. + // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". + to: string | *"now" + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + autoRefresh: string // v1: refresh + // Interval options available in the refresh picker dropdown. + autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals + // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. + quickRanges?: [...TimeRangeOption] // v1: timepicker.quick_ranges , not exposed in the UI + // Whether timepicker is visible or not. + hideTimepicker: bool // v1: timepicker.hidden + // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". + weekStart?: "saturday" | "monday" | "sunday" + // The month that the fiscal year starts on. 0 = January, 11 = December + fiscalYearStartMonth: int + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + nowDelay?: string // v1: timepicker.nowDelay +} + +RepeatMode: "variable" // other repeat modes will be added in the future: label, frame + +RepeatOptions: { + mode: RepeatMode + value: string + direction?: "h" | "v" + maxPerRow?: int +} + +RowRepeatOptions: { + mode: RepeatMode + value: string +} + +AutoGridRepeatOptions: { + mode: RepeatMode + value: string +} + +GridLayoutItemSpec: { + x: int + y: int + width: int + height: int + element: ElementReference // reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference + repeat?: RepeatOptions +} + +GridLayoutItemKind: { + kind: "GridLayoutItem" + spec: GridLayoutItemSpec +} + +GridLayoutRowKind: { + kind: "GridLayoutRow" + spec: GridLayoutRowSpec +} + +GridLayoutRowSpec: { + y: int + collapsed: bool + title: string + elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard. + repeat?: RowRepeatOptions +} + +GridLayoutSpec: { + items: [...GridLayoutItemKind | GridLayoutRowKind] +} + +GridLayoutKind: { + kind: "GridLayout" + spec: GridLayoutSpec +} + +RowsLayoutKind: { + kind: "RowsLayout" + spec: RowsLayoutSpec +} + +RowsLayoutSpec: { + rows: [...RowsLayoutRowKind] +} + +RowsLayoutRowKind: { + kind: "RowsLayoutRow" + spec: RowsLayoutRowSpec +} + +RowsLayoutRowSpec: { + title?: string + collapse?: bool + hideHeader?: bool + fillScreen?: bool + conditionalRendering?: ConditionalRenderingGroupKind + repeat?: RowRepeatOptions + layout: GridLayoutKind | AutoGridLayoutKind | TabsLayoutKind | RowsLayoutKind +} + +AutoGridLayoutKind: { + kind: "AutoGridLayout" + spec: AutoGridLayoutSpec +} + +AutoGridLayoutSpec: { + maxColumnCount?: number | *3 + columnWidthMode: "narrow" | *"standard" | "wide" | "custom" + columnWidth?: number + rowHeightMode: "short" | *"standard" | "tall" | "custom" + rowHeight?: number + fillScreen?: bool | *false + items: [...AutoGridLayoutItemKind] +} + +AutoGridLayoutItemKind: { + kind: "AutoGridLayoutItem" + spec: AutoGridLayoutItemSpec +} + +AutoGridLayoutItemSpec: { + element: ElementReference + repeat?: AutoGridRepeatOptions + conditionalRendering?: ConditionalRenderingGroupKind +} + +TabsLayoutKind: { + kind: "TabsLayout" + spec: TabsLayoutSpec +} + +TabsLayoutSpec: { + tabs: [...TabsLayoutTabKind] +} + +TabsLayoutTabKind: { + kind: "TabsLayoutTab" + spec: TabsLayoutTabSpec +} + +TabsLayoutTabSpec: { + title?: string + layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind + conditionalRendering?: ConditionalRenderingGroupKind +} + +PanelSpec: { + id: number + title: string + description: string + links: [...DataLink] + data: QueryGroupKind + vizConfig: VizConfigKind + transparent?: bool +} + +PanelKind: { + kind: "Panel" + spec: PanelSpec +} + +ElementReference: { + kind: "ElementReference" + name: string +} + +// Start FIXME: variables - in CUE PR - this are things that should be added into the cue schema +// TODO: properties such as `hide`, `skipUrlSync`, `multi` are type boolean, and in the old schema they are conditional, +// should we make them conditional in the new schema as well? or should we make them required but default to false? + +// Variable types +VariableValue: VariableValueSingle | [...VariableValueSingle] + +VariableValueSingle: string | bool | number | CustomVariableValue + +// Custom formatter variable +CustomFormatterVariable: { + name: string + type: VariableType + multi: bool + includeAll: bool +} + +// Custom variable value +CustomVariableValue: { + // The format name or function used in the expression + formatter: *null | string | VariableCustomFormatterFn +} + +// Custom formatter function +VariableCustomFormatterFn: { + value: _ + legacyVariableModel: { + name: string + type: VariableType + multi: bool + includeAll: bool + } + legacyDefaultFormatter?: VariableCustomFormatterFn +} + +// Dashboard variable type +// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. +// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). +// `constant`: Define a hidden constant. +// `datasource`: Quickly change the data source for an entire dashboard. +// `interval`: Interval variables represent time spans. +// `textbox`: Display a free text input field with an optional default value. +// `custom`: Define the variable options manually using a comma-separated list. +// `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables +VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | + "system" | "snapshot" + +VariableKind: QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind + +// Sort variable options +// Accepted values are: +// `disabled`: No sorting +// `alphabeticalAsc`: Alphabetical ASC +// `alphabeticalDesc`: Alphabetical DESC +// `numericalAsc`: Numerical ASC +// `numericalDesc`: Numerical DESC +// `alphabeticalCaseInsensitiveAsc`: Alphabetical Case Insensitive ASC +// `alphabeticalCaseInsensitiveDesc`: Alphabetical Case Insensitive DESC +// `naturalAsc`: Natural ASC +// `naturalDesc`: Natural DESC +// VariableSort enum with default value +VariableSort: "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAsc" | "numericalDesc" | "alphabeticalCaseInsensitiveAsc" | "alphabeticalCaseInsensitiveDesc" | "naturalAsc" | "naturalDesc" + +// Options to config when to refresh a variable +// `never`: Never refresh the variable +// `onDashboardLoad`: Queries the data source every time the dashboard loads. +// `onTimeRangeChanged`: Queries the data source when the dashboard time range changes. +VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged" + +// Determine if the variable shows on dashboard +// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing). +VariableHide: *"dontHide" | "hideLabel" | "hideVariable" + +// FIXME: should we introduce this? --- Variable value option +VariableValueOption: { + label: string + value: VariableValueSingle + group?: string +} + +// Variable option specification +VariableOption: { + // Whether the option is selected or not + selected?: bool + // Text to be displayed for the option + text: string | [...string] + // Value of the option + value: string | [...string] +} + +// Query variable specification +QueryVariableSpec: { + name: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + label?: string + hide: VariableHide + refresh: VariableRefresh + skipUrlSync: bool | *false + description?: string + datasource?: DataSourceRef + query: DataQueryKind + regex: string | *"" + sort: VariableSort + definition?: string + options: [...VariableOption] | *[] + multi: bool | *false + includeAll: bool | *false + allValue?: string + placeholder?: string +} + +// Query variable kind +QueryVariableKind: { + kind: "QueryVariable" + spec: QueryVariableSpec +} + +// Text variable specification +TextVariableSpec: { + name: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + query: string | *"" + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Text variable kind +TextVariableKind: { + kind: "TextVariable" + spec: TextVariableSpec +} + +// Constant variable specification +ConstantVariableSpec: { + name: string | *"" + query: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Constant variable kind +ConstantVariableKind: { + kind: "ConstantVariable" + spec: ConstantVariableSpec +} + +// Datasource variable specification +DatasourceVariableSpec: { + name: string | *"" + pluginId: string | *"" + refresh: VariableRefresh + regex: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + options: [...VariableOption] | *[] + multi: bool | *false + includeAll: bool | *false + allValue?: string + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Datasource variable kind +DatasourceVariableKind: { + kind: "DatasourceVariable" + spec: DatasourceVariableSpec +} + +// Interval variable specification +IntervalVariableSpec: { + name: string | *"" + query: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + options: [...VariableOption] | *[] + auto: bool | *false + auto_min: string | *"" + auto_count: int | *0 + refresh: VariableRefresh + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Interval variable kind +IntervalVariableKind: { + kind: "IntervalVariable" + spec: IntervalVariableSpec +} + +// Custom variable specification +CustomVariableSpec: { + name: string | *"" + query: string | *"" + current: VariableOption + options: [...VariableOption] | *[] + multi: bool | *false + includeAll: bool | *false + allValue?: string + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Custom variable kind +CustomVariableKind: { + kind: "CustomVariable" + spec: CustomVariableSpec +} + +// GroupBy variable specification +GroupByVariableSpec: { + name: string | *"" + datasource?: DataSourceRef + current: VariableOption | *{ + text: "" + value: "" + } + options: [...VariableOption] | *[] + multi: bool | *false + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Group variable kind +GroupByVariableKind: { + kind: "GroupByVariable" + spec: GroupByVariableSpec +} + +// Adhoc variable specification +AdhocVariableSpec: { + name: string | *"" + datasource?: DataSourceRef + baseFilters: [...AdHocFilterWithLabels] | *[] + filters: [...AdHocFilterWithLabels] | *[] + defaultKeys: [...MetricFindValue] | *[] + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Define the MetricFindValue type +MetricFindValue: { + text: string + value?: string | number + group?: string + expandable?: bool +} + +// Define the AdHocFilterWithLabels type +AdHocFilterWithLabels: { + key: string + operator: string + value: string + values?: [...string] + keyLabel?: string + valueLabels?: [...string] + forceEdit?: bool + // @deprecated + condition?: string +} + +// Adhoc variable kind +AdhocVariableKind: { + kind: "AdhocVariable" + spec: AdhocVariableSpec +} + +ConditionalRenderingGroupKind: { + kind: "ConditionalRenderingGroup" + spec: ConditionalRenderingGroupSpec +} + +ConditionalRenderingGroupSpec: { + visibility: "show" | "hide" + condition: "and" | "or" + items: [...ConditionalRenderingVariableKind | ConditionalRenderingDataKind | ConditionalRenderingTimeRangeSizeKind] +} + +ConditionalRenderingVariableKind: { + kind: "ConditionalRenderingVariable" + spec: ConditionalRenderingVariableSpec +} + +ConditionalRenderingVariableSpec: { + variable: string + operator: "equals" | "notEquals" + value: string +} + +ConditionalRenderingDataKind: { + kind: "ConditionalRenderingData" + spec: ConditionalRenderingDataSpec +} + +ConditionalRenderingDataSpec: { + value: bool +} + +ConditionalRenderingTimeRangeSizeKind: { + kind: "ConditionalRenderingTimeRangeSize" + spec: ConditionalRenderingTimeRangeSizeSpec +} + +ConditionalRenderingTimeRangeSizeSpec: { + value: string +} diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go new file mode 100644 index 00000000000..7445000d7f8 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go @@ -0,0 +1,84 @@ +package v2alpha1 + +import ( + _ "embed" + json "encoding/json" + fmt "fmt" + "strings" + "sync" + + "k8s.io/apimachinery/pkg/util/validation/field" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/errors" + cuejson "cuelang.org/go/encoding/json" +) + +func ValidateDashboardSpec(obj *Dashboard) field.ErrorList { + data, err := json.Marshal(obj.Spec) + if err != nil { + return field.ErrorList{ + field.Invalid(field.NewPath("spec"), field.OmitValueType{}, err.Error()), + } + } + + if err := cuejson.Validate(data, getCueSchema()); err != nil { + errs := field.ErrorList{} + + for _, e := range errors.Errors(err) { + if + // We don't want to return confusing "empty disjunction" errors, + // because the users don't necessarily understand what to do with them. + // For empty disjunctions, CUE will also return more specific errors, + // so we can safely ignore the generic ones. + strings.Contains(e.Error(), "disjunction") || + // We don't want to return errors about unknown fields either. + strings.Contains(e.Error(), "field not allowed") { + continue + } + + if strings.Contains(e.Error(), "mismatched types null and list") { + // Go populates empty slices as nil, which the cue validator does not like + continue + } + + // We want to manually format the error message, + // because e.Error() contains the full CUE path. + format, args := e.Msg() + + errs = append(errs, field.Invalid( + field.NewPath(formatErrorPath(e.Path())), + field.OmitValueType{}, + fmt.Sprintf(format, args...), + )) + } + + return errs + } + + return nil +} + +func formatErrorPath(path []string) string { + return strings.Join(path, ".") +} + +var ( + compiledSchema cue.Value + getSchemaOnce sync.Once +) + +//go:embed dashboard_spec.cue +var schemaSource string + +func getCueSchema() cue.Value { + getSchemaOnce.Do(func() { + cueCtx := cuecontext.New() + compiledSchema = cueCtx.CompileString(schemaSource).LookupPath( + cue.ParsePath("DashboardSpec"), + ) + }) + + return compiledSchema +} diff --git a/devenv/dev-dashboards/all-panels.json b/devenv/dev-dashboards/all-panels.json index b9c95e8663e..be812e67ffa 100644 --- a/devenv/dev-dashboards/all-panels.json +++ b/devenv/dev-dashboards/all-panels.json @@ -883,7 +883,7 @@ } ], "refresh": "", - "schemaVersion": 33, + "schemaVersion": 36, "tags": [ "gdev", "panel-tests", diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index b8262500040..00b11fcc5c2 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -157,6 +157,9 @@ Experimental features might be changed or removed without prior notice. | `disableClassicHTTPHistogram` | Disables classic HTTP Histogram (use with enableNativeHTTPHistogram) | | `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint | | `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | +| `dashboardDisableSchemaValidationV1` | Disable schema validation for dashboards/v1 | +| `dashboardDisableSchemaValidationV2` | Disable schema validation for dashboards/v2 | +| `dashboardSchemaValidationLogging` | Log schema validation errors so they can be analyzed later | | `datasourceQueryTypes` | Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) | | `queryService` | Register /apis/query.grafana.app/ -- will eventually replace /api/ds/query | | `queryServiceRewrite` | Rewrite requests targeting /ds/query to the query service | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 89c02a1a693..71fd3773c2e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -340,6 +340,18 @@ export interface FeatureToggles { */ kubernetesClientDashboardsFolders?: boolean; /** + * Disable schema validation for dashboards/v1 + */ + dashboardDisableSchemaValidationV1?: boolean; + /** + * Disable schema validation for dashboards/v2 + */ + dashboardDisableSchemaValidationV2?: boolean; + /** + * Log schema validation errors so they can be analyzed later + */ + dashboardSchemaValidationLogging?: boolean; + /** * Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) */ datasourceQueryTypes?: boolean; diff --git a/pkg/registry/apis/dashboard/large_test.go b/pkg/registry/apis/dashboard/large_test.go index 58876d72f96..c9652bf2b59 100644 --- a/pkg/registry/apis/dashboard/large_test.go +++ b/pkg/registry/apis/dashboard/large_test.go @@ -50,7 +50,7 @@ func TestLargeDashboardSupport(t *testing.T) { small, err := json.MarshalIndent(&dash.Spec, "", " ") require.NoError(t, err) require.JSONEq(t, `{ - "schemaVersion": 33, + "schemaVersion": 36, "title": "Panel tests - All panels", "tags": ["gdev","panel-tests","all-panels"] }`, string(small)) diff --git a/pkg/registry/apis/dashboard/mutate.go b/pkg/registry/apis/dashboard/mutate.go index d91ea8fe679..2178aa19341 100644 --- a/pkg/registry/apis/dashboard/mutate.go +++ b/pkg/registry/apis/dashboard/mutate.go @@ -12,6 +12,9 @@ import ( "github.com/grafana/grafana/apps/dashboard/pkg/migration" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" "github.com/grafana/grafana/pkg/apimachinery/utils" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" ) func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) { @@ -28,6 +31,8 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute return err } + var migrationErr error + var resourceInfo utils.ResourceInfo switch v := obj.(type) { case *dashboardV0.Dashboard: delete(v.Spec.Object, "uid") @@ -36,6 +41,7 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute delete(v.Spec.Object, "id") internalID = int64(id) } + resourceInfo = dashboardV0.DashboardResourceInfo case *dashboardV1.Dashboard: delete(v.Spec.Object, "uid") delete(v.Spec.Object, "version") @@ -43,15 +49,16 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute delete(v.Spec.Object, "id") internalID = int64(id) } - // do not error here if the migrations fail - err = migration.Migrate(v.Spec.Object, schemaversion.LATEST_VERSION) - if err != nil { + resourceInfo = dashboardV1.DashboardResourceInfo + migrationErr = migration.Migrate(v.Spec.Object, schemaversion.LATEST_VERSION) + if migrationErr != nil { v.Status.Conversion = &dashboardV1.DashboardConversionStatus{ Failed: true, - Error: err.Error(), + Error: migrationErr.Error(), } } case *dashboardV2.Dashboard: + resourceInfo = dashboardV2.DashboardResourceInfo // Noop for V2 default: return fmt.Errorf("mutation error: expected to dashboard, got %T", obj) @@ -61,5 +68,46 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute meta.SetDeprecatedInternalID(internalID) // nolint:staticcheck } + fieldValidationMode := getFieldValidationMode(a) + + var validationErrorList field.ErrorList + var validationProcessingError error + if migrationErr == nil { + // Migration check passed, validate the spec now - this will respect the field validation mode! + validationErrorList, validationProcessingError = b.ValidateDashboardSpec(ctx, obj, fieldValidationMode) + } + + // Only fail if the field validation mode is strict + if fieldValidationMode == metav1.FieldValidationStrict { + if migrationErr != nil { + return apierrors.NewInvalid(resourceInfo.GroupVersionKind().GroupKind(), meta.GetName(), field.ErrorList{ + field.Invalid(field.NewPath("spec"), meta.GetName(), migrationErr.Error())}) + } + if validationProcessingError != nil { + return validationProcessingError + } + if len(validationErrorList) > 0 { + return apierrors.NewInvalid(resourceInfo.GroupVersionKind().GroupKind(), meta.GetName(), validationErrorList) + } + } + return nil } + +func getFieldValidationMode(a admission.Attributes) string { + var validation string + switch opts := a.GetOperationOptions().(type) { + case *metav1.CreateOptions: + validation = opts.FieldValidation + case *metav1.UpdateOptions: + validation = opts.FieldValidation + default: + validation = metav1.FieldValidationStrict + } + + if validation == "" { + validation = metav1.FieldValidationStrict + } + + return validation +} diff --git a/pkg/registry/apis/dashboard/mutation_test.go b/pkg/registry/apis/dashboard/mutation_test.go index 6e702edde48..44e1fd2986d 100644 --- a/pkg/registry/apis/dashboard/mutation_test.go +++ b/pkg/registry/apis/dashboard/mutation_test.go @@ -22,6 +22,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { operation admission.Operation expectedID int64 migrationExpected bool + expectedError bool }{ { name: "should skip non-create/update operations", @@ -62,7 +63,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { migrationExpected: true, }, { - name: "v1 should not error mutation hook if migration fails", + name: "v1 should error mutation hook if migration fails", inputObj: &dashv1.Dashboard{ Spec: common.Unstructured{ Object: map[string]interface{}{ @@ -71,8 +72,8 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { }, }, }, - operation: admission.Create, - expectedID: 456, + operation: admission.Create, + expectedError: true, }, } @@ -92,6 +93,11 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { false, nil, ), nil) + + if tt.expectedError { + require.Error(t, err) + return + } require.NoError(t, err) if tt.operation == admission.Create || tt.operation == admission.Update { diff --git a/pkg/registry/apis/dashboard/schema_validation.go b/pkg/registry/apis/dashboard/schema_validation.go new file mode 100644 index 00000000000..2fa75026ec6 --- /dev/null +++ b/pkg/registry/apis/dashboard/schema_validation.go @@ -0,0 +1,78 @@ +package dashboard + +import ( + "context" + _ "embed" + "errors" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/featuremgmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ValidateDashboardSpec validates the dashboard spec and throws a detailed error if there are validation errors. +func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj runtime.Object, fieldValidationMode string) (field.ErrorList, error) { + // This will be removed with the other PR + return nil, nil + + // Unreachable code is intentional until the code above is removed + //nolint:govet + accessor, err := utils.MetaAccessor(obj) + if err != nil { + return nil, fmt.Errorf("error getting meta accessor: %w", err) + } + + errorOnSchemaMismatches := false + mode := fieldValidationMode + if mode != metav1.FieldValidationIgnore { + switch obj.(type) { + case *v0alpha1.Dashboard: + errorOnSchemaMismatches = false // Never error for v0 + case *v1alpha1.Dashboard: + errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV1) + case *v2alpha1.Dashboard: + errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV2) + default: + return nil, fmt.Errorf("Invalid dashboard type: %T", obj) + } + } + if mode == metav1.FieldValidationWarn { + return nil, errors.New("FieldValidationWarn is not supported") + } + + alwaysLogSchemaValidationErrors := b.features.IsEnabled(ctx, featuremgmt.FlagDashboardSchemaValidationLogging) + + var errors field.ErrorList + var schemaVersionError field.ErrorList + if errorOnSchemaMismatches || alwaysLogSchemaValidationErrors { + switch v := obj.(type) { + case *v0alpha1.Dashboard: + errors, schemaVersionError = v0alpha1.ValidateDashboardSpec(v, alwaysLogSchemaValidationErrors) + case *v1alpha1.Dashboard: + errors, schemaVersionError = v1alpha1.ValidateDashboardSpec(v, alwaysLogSchemaValidationErrors) + case *v2alpha1.Dashboard: + errors = v2alpha1.ValidateDashboardSpec(v) + } + } + + if alwaysLogSchemaValidationErrors && len(errors) > 0 { + b.log.Info("Schema validation errors during dashboard validation", "group_version", obj.GetObjectKind().GroupVersionKind().GroupVersion().String(), "name", accessor.GetName(), "errors", errors.ToAggregate().Error(), "schema_version_mismatch", schemaVersionError != nil) + } + + if errorOnSchemaMismatches { + if schemaVersionError != nil { + return schemaVersionError, nil + } + if len(errors) > 0 { + return errors, nil + } + } + return nil, nil +} diff --git a/pkg/services/apiserver/client/client.go b/pkg/services/apiserver/client/client.go index 9c1e3ea66b4..d4d9ca9a255 100644 --- a/pkg/services/apiserver/client/client.go +++ b/pkg/services/apiserver/client/client.go @@ -24,8 +24,8 @@ import ( type K8sHandler interface { GetNamespace(orgID int64) string Get(ctx context.Context, name string, orgID int64, options v1.GetOptions, subresource ...string) (*unstructured.Unstructured, error) - Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) - Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) + Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.CreateOptions) (*unstructured.Unstructured, error) + Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.UpdateOptions) (*unstructured.Unstructured, error) Delete(ctx context.Context, name string, orgID int64, options v1.DeleteOptions) error DeleteCollection(ctx context.Context, orgID int64) error List(ctx context.Context, orgID int64, options v1.ListOptions) (*unstructured.UnstructuredList, error) @@ -71,22 +71,22 @@ func (h *k8sHandler) Get(ctx context.Context, name string, orgID int64, options return client.Get(ctx, name, options, subresource...) } -func (h *k8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) { +func (h *k8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.CreateOptions) (*unstructured.Unstructured, error) { client, err := h.getClient(ctx, orgID) if err != nil { return nil, err } - return client.Create(ctx, obj, v1.CreateOptions{}) + return client.Create(ctx, obj, opts) } -func (h *k8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) { +func (h *k8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.UpdateOptions) (*unstructured.Unstructured, error) { client, err := h.getClient(ctx, orgID) if err != nil { return nil, err } - return client.Update(ctx, obj, v1.UpdateOptions{}) + return client.Update(ctx, obj, opts) } func (h *k8sHandler) Delete(ctx context.Context, name string, orgID int64, options v1.DeleteOptions) error { diff --git a/pkg/services/apiserver/client/client_mock.go b/pkg/services/apiserver/client/client_mock.go index f17c89daf67..2cc34b89d95 100644 --- a/pkg/services/apiserver/client/client_mock.go +++ b/pkg/services/apiserver/client/client_mock.go @@ -32,16 +32,16 @@ func (m *MockK8sHandler) Get(ctx context.Context, name string, orgID int64, opti return args.Get(0).(*unstructured.Unstructured), args.Error(1) } -func (m *MockK8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) { - args := m.Called(ctx, obj, orgID) +func (m *MockK8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.CreateOptions) (*unstructured.Unstructured, error) { + args := m.Called(ctx, obj, orgID, opts) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*unstructured.Unstructured), args.Error(1) } -func (m *MockK8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) { - args := m.Called(ctx, obj, orgID) +func (m *MockK8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.UpdateOptions) (*unstructured.Unstructured, error) { + args := m.Called(ctx, obj, orgID, opts) if args.Get(0) == nil { return nil, args.Error(1) } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index eed74193348..77251a18e64 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -1833,10 +1833,14 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C meta.SetManagerProperties(m) meta.SetSourceProperties(s) - out, err := dr.k8sclient.Update(ctx, obj, cmd.OrgID) + out, err := dr.k8sclient.Update(ctx, obj, cmd.OrgID, v1.UpdateOptions{ + FieldValidation: v1.FieldValidationIgnore, + }) if err != nil && apierrors.IsNotFound(err) { // Create if it doesn't already exist. - out, err = dr.k8sclient.Create(ctx, obj, cmd.OrgID) + out, err = dr.k8sclient.Create(ctx, obj, cmd.OrgID, v1.CreateOptions{ + FieldValidation: v1.FieldValidationIgnore, + }) if err != nil { return nil, err } @@ -1854,10 +1858,14 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd } dashboard.SetPluginIDMeta(obj, cmd.PluginID) - out, err := dr.k8sclient.Update(ctx, obj, orgID) + out, err := dr.k8sclient.Update(ctx, obj, orgID, v1.UpdateOptions{ + FieldValidation: v1.FieldValidationIgnore, + }) if err != nil && apierrors.IsNotFound(err) { // Create if it doesn't already exist. - out, err = dr.k8sclient.Create(ctx, obj, orgID) + out, err = dr.k8sclient.Create(ctx, obj, orgID, v1.CreateOptions{ + FieldValidation: v1.FieldValidationIgnore, + }) if err != nil { return nil, err } diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 313edeba510..0ff3050f3ee 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -1133,7 +1133,9 @@ func TestUnprovisionDashboard(t *testing.T) { }, }} // should update it to be without annotations - k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything).Return(dashWithoutAnnotations, nil) + k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(dashWithoutAnnotations, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ @@ -1357,7 +1359,9 @@ func TestSaveProvisionedDashboard(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) - k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(&dashboardUnstructured, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") dashboard, err := service.SaveProvisionedDashboard(ctx, query, &dashboards.DashboardProvisioning{}) @@ -1419,7 +1423,9 @@ func TestSaveDashboard(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") - k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(&dashboardUnstructured, nil) dashboard, err := service.SaveDashboard(ctx, query, false) require.NoError(t, err) @@ -1430,7 +1436,9 @@ func TestSaveDashboard(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") - k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(&dashboardUnstructured, nil) dashboard, err := service.SaveDashboard(ctx, query, false) require.NoError(t, err) @@ -1440,7 +1448,9 @@ func TestSaveDashboard(t *testing.T) { t.Run("Should return an error if uid is invalid", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") - k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(&dashboardUnstructured, nil) query.Dashboard.UID = "invalid/uid" _, err := service.SaveDashboard(ctx, query, false) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 9e211337f3f..12885943168 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -568,6 +568,24 @@ var ( Owner: grafanaAppPlatformSquad, Expression: "true", // enabled by default }, + { + Name: "dashboardDisableSchemaValidationV1", + Description: "Disable schema validation for dashboards/v1", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + }, + { + Name: "dashboardDisableSchemaValidationV2", + Description: "Disable schema validation for dashboards/v2", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + }, + { + Name: "dashboardSchemaValidationLogging", + Description: "Log schema validation errors so they can be analyzed later", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + }, { Name: "datasourceQueryTypes", Description: "Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus)", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 7edec7f5e13..6263e9d0183 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -73,6 +73,9 @@ kubernetesPlaylists,GA,@grafana/grafana-app-platform-squad,false,true,false kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true kubernetesClientDashboardsFolders,GA,@grafana/grafana-app-platform-squad,false,false,false +dashboardDisableSchemaValidationV1,experimental,@grafana/grafana-app-platform-squad,false,false,false +dashboardDisableSchemaValidationV2,experimental,@grafana/grafana-app-platform-squad,false,false,false +dashboardSchemaValidationLogging,experimental,@grafana/grafana-app-platform-squad,false,false,false datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false queryService,experimental,@grafana/grafana-app-platform-squad,false,true,false queryServiceRewrite,experimental,@grafana/grafana-app-platform-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 4b23d0076e6..1fedab7823d 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -303,6 +303,18 @@ const ( // Route the folder and dashboard service requests to k8s FlagKubernetesClientDashboardsFolders = "kubernetesClientDashboardsFolders" + // FlagDashboardDisableSchemaValidationV1 + // Disable schema validation for dashboards/v1 + FlagDashboardDisableSchemaValidationV1 = "dashboardDisableSchemaValidationV1" + + // FlagDashboardDisableSchemaValidationV2 + // Disable schema validation for dashboards/v2 + FlagDashboardDisableSchemaValidationV2 = "dashboardDisableSchemaValidationV2" + + // FlagDashboardSchemaValidationLogging + // Log schema validation errors so they can be analyzed later + FlagDashboardSchemaValidationLogging = "dashboardSchemaValidationLogging" + // FlagDatasourceQueryTypes // Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) FlagDatasourceQueryTypes = "datasourceQueryTypes" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index a722b4ab073..0fa35053a07 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -731,6 +731,30 @@ "frontend": true } }, + { + "metadata": { + "name": "dashboardDisableSchemaValidationV1", + "resourceVersion": "1744303023863", + "creationTimestamp": "2025-04-10T16:37:03Z" + }, + "spec": { + "description": "Disable schema validation for dashboards/v1", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, + { + "metadata": { + "name": "dashboardDisableSchemaValidationV2", + "resourceVersion": "1744303023863", + "creationTimestamp": "2025-04-10T16:37:03Z" + }, + "spec": { + "description": "Disable schema validation for dashboards/v2", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, { "metadata": { "name": "dashboardNewLayouts", @@ -786,6 +810,18 @@ "expression": "true" } }, + { + "metadata": { + "name": "dashboardSchemaValidationLogging", + "resourceVersion": "1744303223631", + "creationTimestamp": "2025-04-10T16:40:23Z" + }, + "spec": { + "description": "Log schema validation errors so they can be analyzed later", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, { "metadata": { "name": "dashgpt", @@ -1278,6 +1314,32 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "flagDashboardDisableSchemaValidationV1", + "resourceVersion": "1744302136118", + "creationTimestamp": "2025-04-10T16:22:16Z", + "deletionTimestamp": "2025-04-10T16:37:03Z" + }, + "spec": { + "description": "Disable schema validation for dashboards/v1", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, + { + "metadata": { + "name": "flagDashboardDisableSchemaValidationV2", + "resourceVersion": "1744302136118", + "creationTimestamp": "2025-04-10T16:22:16Z", + "deletionTimestamp": "2025-04-10T16:37:03Z" + }, + "spec": { + "description": "Disable schema validation for dashboards/v2", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, { "metadata": { "name": "formatString", diff --git a/pkg/services/folder/folderimpl/unifiedstore.go b/pkg/services/folder/folderimpl/unifiedstore.go index 665ba45dd81..7280303f38e 100644 --- a/pkg/services/folder/folderimpl/unifiedstore.go +++ b/pkg/services/folder/folderimpl/unifiedstore.go @@ -50,8 +50,8 @@ func (ss *FolderUnifiedStoreImpl) Create(ctx context.Context, cmd folder.CreateF if err != nil { return nil, err } - - out, err := ss.k8sclient.Create(ctx, obj, cmd.OrgID) + out, err := ss.k8sclient.Create(ctx, obj, cmd.OrgID, v1.CreateOptions{ + FieldValidation: v1.FieldValidationIgnore}) if err != nil { return nil, err } @@ -106,7 +106,9 @@ func (ss *FolderUnifiedStoreImpl) Update(ctx context.Context, cmd folder.UpdateF meta.SetFolder(*cmd.NewParentUID) } - out, err := ss.k8sclient.Update(ctx, updated, cmd.OrgID) + out, err := ss.k8sclient.Update(ctx, updated, cmd.OrgID, v1.UpdateOptions{ + FieldValidation: v1.FieldValidationIgnore, + }) if err != nil { return nil, err } diff --git a/pkg/services/store/kind/dashboard/testdata/devdash-all-panels-info.json b/pkg/services/store/kind/dashboard/testdata/devdash-all-panels-info.json index 7d6e29aae5e..f97a4796ba6 100644 --- a/pkg/services/store/kind/dashboard/testdata/devdash-all-panels-info.json +++ b/pkg/services/store/kind/dashboard/testdata/devdash-all-panels-info.json @@ -228,7 +228,7 @@ ] } ], - "schemaVersion": 33, + "schemaVersion": 36, "linkCount": 2, "timeFrom": "now-6h", "timeTo": "now", diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index f6ffedfc601..a630d12bf34 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -42,7 +42,8 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper, gvr schema.Group obj := &unstructured.Unstructured{ Object: map[string]interface{}{ "spec": map[string]any{ - "title": "Test empty dashboard", + "title": "Test empty dashboard", + "schemaVersion": 41, }, }, } diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index 3551f7548f1..540498c91eb 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -793,7 +793,8 @@ func createDashboardObject(t *testing.T, title string, folderUID string, generat }, }, "spec": map[string]interface{}{ - "title": title, + "title": title, + "schemaVersion": 41, }, }, } diff --git a/pkg/tests/apis/dashboard/testdata/dashboard-test-v1.yaml b/pkg/tests/apis/dashboard/testdata/dashboard-test-v1.yaml index 1e17651de60..6ad80f602c6 100644 --- a/pkg/tests/apis/dashboard/testdata/dashboard-test-v1.yaml +++ b/pkg/tests/apis/dashboard/testdata/dashboard-test-v1.yaml @@ -6,3 +6,4 @@ spec: title: Test dashboard. Created at v1 uid: test-v1 # will be removed by mutation hook version: 1234567 # will be removed by mutation hook + schemaVersion: 41 From 25c84044f91304b8f3a140a7aaa2929c33d24df4 Mon Sep 17 00:00:00 2001 From: Steven Shaw Date: Fri, 11 Apr 2025 10:29:46 -0700 Subject: [PATCH 33/73] Live: Updated note on Live redis sentinel support (#103834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update note on Live redis sentinel support * Add ha_engine_password and update address parameters * style admonition * add code inside admonition * run prettier --------- Co-authored-by: Irene Rodríguez --- .../setup-grafana/configure-grafana/_index.md | 3 +- .../setup-grafana/set-up-grafana-live.md | 32 ++++++------------- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index d7f0c50cc8a..ad403d30d28 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2536,7 +2536,8 @@ Address string of selected the high availability (HA) Live engine. For Redis, it ```ini [live] ha_engine = redis -ha_engine_address = 127.0.0.1:6379 +ha_engine_address: redis-headless.grafana.svc.cluster.local:6379 +ha_engine_password: $__file{/your/redis/password/secret/mount} ```
diff --git a/docs/sources/setup-grafana/set-up-grafana-live.md b/docs/sources/setup-grafana/set-up-grafana-live.md index b2d1c605384..fc39dbff899 100644 --- a/docs/sources/setup-grafana/set-up-grafana-live.md +++ b/docs/sources/setup-grafana/set-up-grafana-live.md @@ -223,26 +223,14 @@ After running: - Streaming from Telegraf delivers messages to all subscribers. - A separate unidirectional stream between Grafana and backend data source opens on different Grafana servers. Publishing data to a channel delivers messages to instance subscribers, as a result, publications from different instances on different machines do not produce duplicate data on panels. -At the moment we only support single Redis node. +{{< admonition type="note" >}} +Live currently does not support Redis Sentinel. We recommend using a Redis Cluster for high-availability via a k8s helm chart such as the Bitnami Redis chart which has values to provision a Redis Cluster. Grafana Live can then be pointed to the `redis-headless` service. -> **Note:** It's possible to use Redis Sentinel and Haproxy to achieve a highly available Redis setup. Redis nodes should be managed by [Redis Sentinel](https://redis.io/topics/sentinel) to achieve automatic failover. Haproxy configuration example: -> -> ``` -> listen redis -> server redis-01 127.0.0.1:6380 check port 6380 check inter 2s weight 1 inter 2s downinter 5s rise 10 fall 2 on-marked-down shutdown-sessions on-marked-up shutdown-backup-sessions -> server redis-02 127.0.0.1:6381 check port 6381 check inter 2s weight 1 inter 2s downinter 5s rise 10 fall 2 backup -> bind *:6379 -> mode tcp -> option tcpka -> option tcplog -> option tcp-check -> tcp-check send PING\r\n -> tcp-check expect string +PONG -> tcp-check send info\ replication\r\n -> tcp-check expect string role:master -> tcp-check send QUIT\r\n -> tcp-check expect string +OK -> balance roundrobin -> ``` -> -> Next, point Grafana Live to Haproxy address:port. +``` + live: + ha_engine: redis + ha_engine_address: redis-headless.grafana.svc.cluster.local:6379 + ha_engine_password: $__file{/your/redis/password/secret/mount} +``` + +{{< /admonition >}} From a913c5426dca5740727abaa43528602cef6a3c09 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Fri, 11 Apr 2025 19:50:46 +0200 Subject: [PATCH 34/73] Alerting: Fix flaky TestIntegrationPrometheusRules test (#103886) --- pkg/tests/api/alerting/api_prometheus_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index a25cde45fb4..1114d1102b2 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -16,7 +16,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/expr" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -25,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/util" ) // Declare respModel at the function level @@ -194,6 +194,7 @@ func TestIntegrationPrometheusRules(t *testing.T) { }`), }, }, + IsPaused: util.Pointer(true), }, }, }, From 383f043be83c5f90df2716d5938f93fbaabf4a42 Mon Sep 17 00:00:00 2001 From: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> Date: Fri, 11 Apr 2025 11:58:34 -0600 Subject: [PATCH 35/73] TableNG: Use numeric sorting for string fields (not strictly lexicographic) (#103794) --- packages/grafana-ui/src/components/Table/TableNG/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index a80a758813a..a9c5908dda4 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -515,7 +515,9 @@ export interface MapFrameToGridOptions extends TableNGProps { } /* ----------------------------- Data grid comparator ---------------------------- */ -const compare = new Intl.Collator('en', { sensitivity: 'base' }).compare; +// The numeric: true option is used to sort numbers as strings correctly. It recognizes numeric sequences +// within strings and sorts numerically instead of lexicographically. +const compare = new Intl.Collator('en', { sensitivity: 'base', numeric: true }).compare; export function getComparator(sortColumnType: FieldType): Comparator { switch (sortColumnType) { case FieldType.time: From 6c45cc9e2dc48e3867effeafc895cdba0588316f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Fri, 11 Apr 2025 20:01:26 +0200 Subject: [PATCH 36/73] fix(util): don't use wall clock time for testing (#103924) --- pkg/util/debouncer/debouncer.go | 21 +++++++++++++++++---- pkg/util/debouncer/debouncer_test.go | 12 ++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/pkg/util/debouncer/debouncer.go b/pkg/util/debouncer/debouncer.go index a6c0e7b22e3..df2a58b8020 100644 --- a/pkg/util/debouncer/debouncer.go +++ b/pkg/util/debouncer/debouncer.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/benbjohnson/clock" "github.com/grafana/dskit/instrument" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -91,6 +92,9 @@ type DebouncerOpts[T comparable] struct { // for the same key keep arriving, we guarantee processing after MaxWait from the first event. MaxWait time.Duration Reg prometheus.Registerer + + // clock can be used for testing to not having to relay on wall clock time. + clock clock.Clock } type Group[T comparable] struct { @@ -108,6 +112,8 @@ type Group[T comparable] struct { minWait time.Duration maxWait time.Duration metrics *metrics + + clock clock.Clock } // NewGroup creates a new debouncer group for processing events with unique keys. @@ -165,6 +171,10 @@ func NewGroup[T comparable](opts DebouncerOpts[T]) (*Group[T], error) { opts.ErrorHandler = func(_ T, _ error) {} } + if opts.clock == nil { + opts.clock = clock.New() + } + return &Group[T]{ buffer: make(chan T, opts.BufferSize), debouncers: make(map[T]*debouncer[T]), @@ -173,6 +183,7 @@ func NewGroup[T comparable](opts DebouncerOpts[T]) (*Group[T], error) { minWait: opts.MinWait, maxWait: opts.MaxWait, metrics: newMetrics(opts.Reg, opts.Name), + clock: opts.clock, }, nil } @@ -217,7 +228,7 @@ func (g *Group[T]) processValue(key T) { g.debouncersMu.Lock() deb, ok := g.debouncers[key] if !ok { - deb = newDebouncer[T](g.minWait, g.maxWait, key, func(v T) { + deb = newDebouncer[T](g.minWait, g.maxWait, g.clock, key, func(v T) { g.processWithMetrics(g.ctx, v, g.processHandler) g.debouncersMu.Lock() @@ -256,16 +267,18 @@ type debouncer[T comparable] struct { minWait time.Duration maxWait time.Duration processFunc func(T) + clock clock.Clock } // newDebouncer creates a new key debouncer. -func newDebouncer[T comparable](minWait, maxWait time.Duration, key T, processFunc func(T)) *debouncer[T] { +func newDebouncer[T comparable](minWait, maxWait time.Duration, clock clock.Clock, key T, processFunc func(T)) *debouncer[T] { deb := &debouncer[T]{ key: key, resetChan: make(chan struct{}, 1), minWait: minWait, maxWait: maxWait, processFunc: processFunc, + clock: clock, } return deb } @@ -285,8 +298,8 @@ func (d *debouncer[T]) reset() { // run manages the debouncing process for a specific key. func (d *debouncer[T]) run(ctx context.Context) { // Create timers after getting the first updateChan. - minTimer := time.NewTimer(d.minWait) - maxTimer := time.NewTimer(d.maxWait) + minTimer := d.clock.Timer(d.minWait) + maxTimer := d.clock.Timer(d.maxWait) defer func() { minTimer.Stop() maxTimer.Stop() diff --git a/pkg/util/debouncer/debouncer_test.go b/pkg/util/debouncer/debouncer_test.go index 711c8761836..8fd58efddc9 100644 --- a/pkg/util/debouncer/debouncer_test.go +++ b/pkg/util/debouncer/debouncer_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/benbjohnson/clock" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" @@ -53,6 +54,7 @@ func TestDebouncer(t *testing.T) { t.Run("should process values after max wait", func(t *testing.T) { processed := make(map[string]int, 1) + clockMock := clock.NewMock() group, err := NewGroup(DebouncerOpts[string]{ BufferSize: 10, @@ -62,6 +64,7 @@ func TestDebouncer(t *testing.T) { }, MinWait: 50 * time.Millisecond, MaxWait: 500 * time.Millisecond, + clock: clockMock, }) require.NoError(t, err) @@ -70,20 +73,17 @@ func TestDebouncer(t *testing.T) { group.Start(ctx) - ticker := time.NewTicker(time.Millisecond * 40) - defer ticker.Stop() - - start := time.Now() + start := clockMock.Now() for counter := 0; counter < 25; counter++ { - <-ticker.C _ = group.Add("key1") + clockMock.Add(time.Millisecond * 40) if processed["key1"] == 1 { break } } - require.WithinDuration(t, start.Add(time.Millisecond*500), time.Now(), time.Millisecond*100) + require.WithinDuration(t, start.Add(time.Millisecond*500), clockMock.Now(), time.Millisecond*100) }) t.Run("should handle buffer full", func(t *testing.T) { From 878e239f16b02d24ac9e73a8afc855fa2c12194a Mon Sep 17 00:00:00 2001 From: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> Date: Fri, 11 Apr 2025 13:06:48 -0500 Subject: [PATCH 37/73] docs: recorded queries depreciation (#103838) * docs: recorded queries depreciation added a depreciation warning and a link to the alerting docs * Update index.md caps caps caps fix * purdier * Update docs/sources/administration/recorded-queries/index.md Co-authored-by: Brendan O'Handley --------- Co-authored-by: Brendan O'Handley --- docs/sources/administration/recorded-queries/index.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/sources/administration/recorded-queries/index.md b/docs/sources/administration/recorded-queries/index.md index fdeaa7da656..7460ee31249 100644 --- a/docs/sources/administration/recorded-queries/index.md +++ b/docs/sources/administration/recorded-queries/index.md @@ -15,7 +15,11 @@ title: Recorded queries weight: 300 --- -# Recorded queries +# DEPRECIATED Recorded queries + +{{% admonition type="warning" %}} +Recorded queries are deprecated. Please use the new [Grafana Managed Recording Rules](/docs/grafana/latest/alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules) instead. +{{% /admonition %}} Recorded queries allow you to see trends over time by taking a snapshot of a data point on a set interval. This can give you insight into historic trends. From 1a867b19080555aedafd8f20eacf746a452519f4 Mon Sep 17 00:00:00 2001 From: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> Date: Fri, 11 Apr 2025 12:07:57 -0600 Subject: [PATCH 38/73] TableNG: Fix cell type logic (#103919) * fix: cell type logic * chore: geo => json * chore: need to handle multiple types of json cells for preview * chore: revert changes * Add GeoCell to TableNG * Update cell inspect to handle FieldType.geo --------- Co-authored-by: drew08t --- .../Table/TableNG/Cells/GeoCell.tsx | 43 +++++++++ .../Table/TableNG/Cells/TableCellNG.tsx | 96 +++++++++---------- .../src/components/Table/TableNG/types.ts | 6 ++ 3 files changed, 97 insertions(+), 48 deletions(-) create mode 100644 packages/grafana-ui/src/components/Table/TableNG/Cells/GeoCell.tsx diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/GeoCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/GeoCell.tsx new file mode 100644 index 00000000000..40e5bc04778 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/GeoCell.tsx @@ -0,0 +1,43 @@ +import { css } from '@emotion/css'; +import WKT from 'ol/format/WKT'; +import { Geometry } from 'ol/geom'; + +import { useStyles2 } from '../../../../themes'; +import { GeoCellProps } from '../types'; + +export function GeoCell({ value, justifyContent, height }: GeoCellProps) { + const styles = useStyles2(getStyles); + + let disp = ''; + + if (value instanceof Geometry) { + disp = new WKT().writeGeometry(value, { + featureProjection: 'EPSG:3857', + dataProjection: 'EPSG:4326', + }); + } else if (value != null) { + disp = `${value}`; + } + + return ( +
+
+ {disp} +
+
+ ); +} + +const getStyles = () => ({ + cell: css({ + height: '100%', + display: 'flex', + alignItems: 'center', + padding: '0 8px', + }), + cellText: css({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }), +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx index 1a1c3bbca5a..f9a91fa226c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx @@ -1,12 +1,15 @@ import { css } from '@emotion/css'; +import { WKT } from 'ol/format'; +import { Geometry } from 'ol/geom'; import { ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { FieldType, GrafanaTheme2, isDataFrame, isTimeSeriesFrame } from '@grafana/data'; import { TableAutoCellOptions, TableCellDisplayMode } from '@grafana/schema'; import { useStyles2 } from '../../../../themes'; import { t } from '../../../../utils/i18n'; import { IconButton } from '../../../IconButton/IconButton'; +// import { GeoCell } from '../../Cells/GeoCell'; import { TableCellInspectorMode } from '../../TableCellInspector'; import { CellColors, @@ -21,6 +24,7 @@ import { ActionsCell } from './ActionsCell'; import AutoCell from './AutoCell'; import { BarGaugeCell } from './BarGaugeCell'; import { DataLinksCell } from './DataLinksCell'; +import { GeoCell } from './GeoCell'; import { ImageCell } from './ImageCell'; import { JSONCell } from './JSONCell'; import { SparklineCell } from './SparklineCell'; @@ -78,52 +82,31 @@ export function TableCellNG(props: TableCellNGProps) { } }, [divWidthRef.current]); // eslint-disable-line react-hooks/exhaustive-deps + // Common props for all cells + const commonProps = { + value, + field, + rowIdx, + justifyContent, + }; + // Get the correct cell type let cell: ReactNode = null; switch (cellType) { case TableCellDisplayMode.Sparkline: - cell = ( - - ); + cell = ; break; case TableCellDisplayMode.Gauge: case TableCellDisplayMode.BasicGauge: case TableCellDisplayMode.GradientGauge: case TableCellDisplayMode.LcdGauge: - cell = ( - - ); + cell = ; break; case TableCellDisplayMode.Image: - cell = ( - - ); + cell = ; break; case TableCellDisplayMode.JSONView: - cell = ; + cell = ; break; case TableCellDisplayMode.DataLinks: cell = ; @@ -137,15 +120,22 @@ export function TableCellNG(props: TableCellNGProps) { break; case TableCellDisplayMode.Auto: default: - cell = ( - - ); + // Handle auto cell type detection + if (field.type === FieldType.geo) { + cell = ; + } else if (field.type === FieldType.frame) { + const firstValue = field.values[0]; + if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) { + cell = ; + } else { + cell = ; + } + } else if (field.type === FieldType.other) { + cell = ; + } else { + cell = ; + } + break; } const handleMouseEnter = () => { @@ -200,12 +190,22 @@ export function TableCellNG(props: TableCellNGProps) { name="eye" tooltip={t('grafana-ui.table.cell-inspect-tooltip', 'Inspect value')} onClick={() => { + let inspectValue = value; + let mode = TableCellInspectorMode.text; + + if (field.type === FieldType.geo && value instanceof Geometry) { + inspectValue = new WKT().writeGeometry(value, { + featureProjection: 'EPSG:3857', + dataProjection: 'EPSG:4326', + }); + mode = TableCellInspectorMode.code; + } else if (cellType === TableCellDisplayMode.JSONView) { + mode = TableCellInspectorMode.code; + } + setContextMenuProps({ - value: String(value ?? ''), - mode: - cellType === TableCellDisplayMode.JSONView - ? TableCellInspectorMode.code - : TableCellInspectorMode.text, + value: String(inspectValue ?? ''), + mode, }); setIsInspecting(true); }} diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 994409a6a4d..c2a760b4f49 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -207,6 +207,12 @@ export interface DataLinksCellProps { rowIdx: number; } +export interface GeoCellProps { + value: TableCellValue; + justifyContent: Property.JustifyContent; + height: number; +} + export interface ActionCellProps { actions?: ActionModel[]; } From 17fbeb09f16792dd027988a2d14683acd866e45a Mon Sep 17 00:00:00 2001 From: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> Date: Fri, 11 Apr 2025 13:11:33 -0500 Subject: [PATCH 39/73] docs: alert migration tool draft (#103752) * docs: alert migration tool draft roughest of rough drafts, homies. * prettier + edit * Update migration-api.md * Update migration-api.md * edits per sonia and alex * Update migration-api.md * fresh edits edits from some alex wisdom * Update migration-api.md * Update migration-api.md * Update migration-api.md * Update docs/sources/alerting/alerting-rules/alerting-migration/_index.md Co-authored-by: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> * Update docs/sources/alerting/alerting-rules/alerting-migration/migration-api.md Co-authored-by: Alexander Akhmetov * edits feature flag info added to docs as well as another edit --------- Co-authored-by: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Co-authored-by: Alexander Akhmetov --- .../alerting-migration/_index.md | 78 ++++++++++++ .../alerting-migration/migration-api.md | 113 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 docs/sources/alerting/alerting-rules/alerting-migration/_index.md create mode 100644 docs/sources/alerting/alerting-rules/alerting-migration/migration-api.md diff --git a/docs/sources/alerting/alerting-rules/alerting-migration/_index.md b/docs/sources/alerting/alerting-rules/alerting-migration/_index.md new file mode 100644 index 00000000000..50654a58f1d --- /dev/null +++ b/docs/sources/alerting/alerting-rules/alerting-migration/_index.md @@ -0,0 +1,78 @@ +--- +description: Use the Grafana Alerting import tool to convert your datasource managed alert rules into Grafana managed alert rules +labels: + products: + - cloud + - enterprise + - oss +title: Import data source-managed alert rules +menuTitle: Import to Grafana-managed alert rules +weight: 600 +refs: +--- + +# Import data source-managed alert rules + +Grafana provides an internal tool in Alerting which allows you to import Prometheus and Loki alert rules into Grafana-managed alert rules. + +## Before you begin + +The `alertingMigrationUI` and `grafanaManagedRecordingRulesDatasources` [feature flags](/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles/) needs to be enabled to use this feature. + +To use the migration tool, you need the following [RBAC permissions](/docs/grafana/latest/administration/roles-and-permissions/access-control/): + +- Alerting: Rules Writer +- Alerting: Set provisioning status +- Datasources: Reader +- Folders: Creator + {{< admonition type="note" >}} + The Folders permission is optional and only necessary if you want to create new folders for your target namespace. If your account doesn't have permissions to view a namespace, the tool creates a new one. It is a best practice to prepare an import plan before you convert all your alert rules. + {{< /admonition >}} + +## How it works + +When you use the import tool, a folder of data source-managed rules is copied to another folder as Grafana-managed alert rules, preserving the behavior of the rules, and the original alert rules are kept in their original location. + +When data source-managed alert rules are converted to Grafana-managed alert rules, the following are applied to the Grafana-managed alert rules: + +- All rules are given `rule_query_offset` offset value of 1m. +- The `missing_series_evals_to_resolve` is set to 1 for the new rules. +- The newly created rules are given unique UIDs. + +{{< admonition type="note" >}} +Plugin rules that have the label `__grafana_origin` are not included on alert rule imports. +{{< /admonition >}} + +## Import alert rules + +To convert data source-managed alert rules to Grafana managed alerts: + +1. Go to **Alerting > Alert rules**. + +1. Navigate to the Data source-managed alert rules section and click **Import to Grafana-managed rules**. + + The import alert rules page opens. + +1. In the Data source dropdown, select the Loki or Prometheus data source of the alert rules. + +1. In Additional settings, select a target folder or designate a new folder to import the rules into. + + If you import the rules into an existing folder, don't chose a folder with existing alert rules, as they could get overwritten. + +1. (Optional) Select a Namespace and/or Group to determine which rules are imported. + +1. (Optional) Turn on **Pause imported alerting rules**. + + Pausing stops alert rule evaluation and doesn’t create any alert instances for the newly created Grafana-managed alert rules. + +1. (Optional) Turn on **Pause imported recording rules**. + + Pausing stops alert rule evaluation behavior for the newly created Grafana-managed alert rules. + +1. Select which target data source the new recording rule is written to. + +1. Click **Import**. + + A preview shows the rules that will be imported. If your target folder contains folders with the same name of the imported folders, a warning displays to inform you. You can explore the warning to see a list of folders that might be overwritten. + + Click **Yes, import** to import the rules. diff --git a/docs/sources/alerting/alerting-rules/alerting-migration/migration-api.md b/docs/sources/alerting/alerting-rules/alerting-migration/migration-api.md new file mode 100644 index 00000000000..4a623513094 --- /dev/null +++ b/docs/sources/alerting/alerting-rules/alerting-migration/migration-api.md @@ -0,0 +1,113 @@ +--- +description: Use the Grafana Alerting API import tool to convert your datasource managed alert rules into Grafana managed alert rules +labels: + products: + - cloud + - enterprise + - oss +title: Import data source-managed alert rules with Grafana Mimirtool +menuTitle: API alert rules import +weight: 601 +refs: +--- + +# Import data source-managed alert rules with Grafana Mimirtool + +You can convert data source-managed alert rules to Grafana-managed alert rules with the Grafana import tool in the Grafana user interface, or you can convert them with the Grafana Mimirtool command-line tool. This guide tells you how to use Mimirtool to import your data source-managed alert rules. + +## Before you begin + +The `grafanaManagedRecordingRulesDatasources` [feature flag](/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles/) needs to be enabled to use this feature. + +To import data source-managed alert rules with Grafana Mimirtool, you need to have the Grafana Mimirtool command-line tool installed. + +You need a service account the following [RBAC permissions](/docs/grafana/latest/administration/roles-and-permissions/access-control/): + +- Alerting: Rules Reader +- Alerting: Rules Writer +- Alerting: Set provisioning status +- Datasources: Reader +- Folders: Creator +- Folders: Reader +- Folders: Writer + +You also need to create a service account token with your service account. Refer to the [documentation for more information on service accounts and service account tokens](/docs/grafana/latest/administration/service-accounts/) + +## How it works + +When data source-managed alert rules are converted to Grafana-managed alert rules, the following are applied to the Grafana-managed alert rules: + +- All rules are given `rule_query_offset` offset value of 1m. + Grafana OSS and Enterprise can configure this value in their conf: + ``` + [unified_alerting.prometheus_conversion] + rule_query_offset = 1m + ``` + If this value is set explicitly in a rule group, that value takes precedence over the configuration setting. +- The `missing_series_evals_to_resolve` is set to 1 for the new rules. +- The newly created rules are given unique UIDs. + If you don't want the UID to be automatically generated, you can specify a specific UID with the `__grafana_alert_rule_uid__` label. + +## Import alert rules with Mimirtool or coretextool + +You can use either [Mimirtool](/docs/mimir/latest/manage/tools/mimirtool/) or [`cortextool`](https://github.com/grafana/cortex-tools) (version `0.11.3` or later) to import your alert rules. For more information about Mimirtool commands, see the [Mimirtool documentation](/docs/mimir/latest/manage/tools/mimirtool/#rules). + +To convert your alert rules, use the following command prompt substituting the your URL and your service account token as indicated, followed by your intended Mimirtool command. + +```bash +MIMIR_ADDRESS=https://.grafana-dev.net/api/convert/ MIMIR_AUTH_TOKEN= MIMIR_TENANT_ID=1 +``` + +For coretextool, you need to set `--backend=loki` to import Loki alert rules. For example: + +```bash +CORTEX_ADDRESS=/api/convert/ CORTEX_AUTH_TOKEN= CORTEX_TENANT_ID=1 cortextool rules --backend=loki list +``` + +Headers can be passed to the `mimirtool` or `coretextool` via `--extra-headers`. + +For more information about the Rule API points and examples of Mimirtool commands, see the [Mimir HTTP API documentation](/docs/mimir/latest/references/http-api/#ruler-rules:~:text=config/v1/rules-,Get%20rule%20groups%20by%20namespace,DELETE%20%3Cprometheus%2Dhttp%2Dprefix%3E/config/v1/rules/%7Bnamespace%7D,-Delete%20tenant%20configuration) for more information about the Rule API points and examples of Mimirtool commands. + +{{< admonition type="note" >}} +To use the `mimirtool rules sync` command, you need to set the `--concurrency` parameter to `1` (`--concurrency=1`). The parameter defaults to 8, which may cause the API to return errors. +{{< /admonition >}} + +### Compatible endpoints + +The following are compatible API endpoints: + +**GET** + +``` +GET /convert/prometheus/config/v1/rules - Get all rule groups across all namespaces +GET /convert/prometheus/config/v1/rules/ - Get rule groups in a specific namespace +GET /convert/prometheus/config/v1/rules// - Get a single rule group + +``` + +**POST** + +``` +POST /convert/prometheus/config/v1/rules - Create/update multiple rule groups across multiple namespaces +POST /convert/prometheus/config/v1/rules/ - Create/update a single rule group in a namespace +``` + +Post rules also require the following header: +When posting rules: +`X-Grafana-Alerting-Datasource-UID` - Supply the UID of the data source to use for queries. + +**Delete** + +``` +DELETE /convert/prometheus/config/v1/rules/{NamespaceTitle} - Delete all alert rules in a namespace +DELETE /convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group} - Delete a specific rule group +``` + +**Optional Headers** + +Additional configuration headers for more granular import control include the following: + +- `X-Grafana-Alerting-Recording-Rules-Paused` - Set to "true" to import recording rules in paused state. +- `X-Grafana-Alerting-Alert-Rules-Paused` - Set to "true" to import alert rules in paused state. +- `X-Grafana-Alerting-Target-Datasource-UID` - Enter the UID of the target data source. +- `X-Grafana-Alerting-Folder-UID` - Enter the UID of the target destination folder for imported rules. From 6933829ce2d914111f970d247c1f756650e41694 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Fri, 11 Apr 2025 12:18:48 -0600 Subject: [PATCH 40/73] Dashboard Schema V2: Import Dashboard (#102375) * WIP: Working import * wip * remove model * wip * working * remove console.logs * fix * remove * remove uid field as v2 spec doesn't have uid * clean up reducer * revert arrow func * support upload --- .betterer.results | 21 ++ .../dashboard/v2alpha0/dashboard.schema.cue | 1 - .../v2schema/ImportDashboardFormV2.tsx | 164 ++++++++++++++++ .../v2schema/ImportDashboardOverviewV2.tsx | 181 ++++++++++++++++++ .../manage-dashboards/DashboardImportPage.tsx | 37 +++- .../manage-dashboards/state/actions.ts | 57 ++++++ 6 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx create mode 100644 public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx diff --git a/.betterer.results b/.betterer.results index 917b0d0f1f2..518606f1515 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1643,6 +1643,27 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + ], + "public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Do not use any type assertions.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Do not use any type assertions.", "6"], + [0, 0, 0, "Do not use any type assertions.", "7"], + [0, 0, 0, "Do not use any type assertions.", "8"], + [0, 0, 0, "Do not use any type assertions.", "9"], + [0, 0, 0, "Unexpected any. Specify a different type.", "10"] + ], "public/app/features/dashboard-scene/v2schema/test-helpers.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index 4e15cffeb3d..73faa5df734 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -64,7 +64,6 @@ LibraryPanelSpec: { id: number // Title for the library panel in the dashboard title: string - libraryPanel: LibraryPanelRef } diff --git a/public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx b/public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx new file mode 100644 index 00000000000..4df6d8faf52 --- /dev/null +++ b/public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react'; +import { Controller, FieldErrors, UseFormReturn } from 'react-hook-form'; + +import { selectors } from '@grafana/e2e-selectors'; +import { ExpressionDatasourceRef } from '@grafana/runtime/internal'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { Button, Field, FormFieldErrors, FormsOnSubmit, Stack, Input } from '@grafana/ui'; +import { FolderPicker } from 'app/core/components/Select/FolderPicker'; +import { t, Trans } from 'app/core/internationalization'; +import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { DashboardInputs, DataSourceInput } from 'app/features/manage-dashboards/state/reducers'; +import { validateTitle } from 'app/features/manage-dashboards/utils/validation'; +interface Props + extends Pick< + UseFormReturn & { [key: `datasource-${string}`]: string }>, + 'register' | 'control' | 'getValues' | 'watch' + > { + inputs: DashboardInputs; + uidReset: boolean; + errors: FieldErrors & { [key: `datasource-${string}`]: string }>; + onCancel: () => void; + onUidReset: () => void; + onSubmit: FormsOnSubmit & { [key: `datasource-${string}`]: string }>; +} + +export const ImportDashboardFormV2 = ({ + register, + errors, + control, + inputs, + getValues, + uidReset, + onUidReset, + onCancel, + onSubmit, + watch, +}: Props) => { + const [isSubmitted, setSubmitted] = useState(false); + const [selectedDataSources, setSelectedDataSources] = useState>({}); + /* + This useEffect is needed for overwriting a dashboard. It + submits the form even if there's validation errors on title or uid. + */ + useEffect(() => { + if (isSubmitted && (errors.dashboard?.title || errors.k8s?.name)) { + const formValues = getValues(); + onSubmit({ + ...formValues, + dashboard: { + ...formValues.dashboard, + title: formValues.dashboard.title, + }, + }); + } + }, [errors, getValues, isSubmitted, onSubmit]); + + return ( + <> + Options + + await validateTitle(v, getValues().folderUid ?? ''), + })} + type="text" + data-testid={selectors.components.ImportDashboardForm.name} + /> + + + + render={({ field: { ref, value, onChange, ...field } }) => ( + { + onChange(uid, title); + }} + value={value} + /> + )} + name="folderUid" + control={control} + /> + + {inputs.dataSources && + inputs.dataSources.map((input: DataSourceInput) => { + if (input.pluginId === ExpressionDatasourceRef.type) { + return null; + } + + const dataSourceOption = `datasource-${input.pluginId}` as const; + + return ( + + + name={dataSourceOption} + render={({ field: { ref, ...field } }) => ( + { + field.onChange(ds); + // Update our selected datasources map + setSelectedDataSources((prev) => ({ + ...prev, + [input.pluginId]: { + uid: ds.uid, + type: ds.type, + }, + })); + }} + /> + )} + control={control} + rules={{ required: true }} + /> + + ); + })} + + + + + + + ); +}; + +function getButtonVariant( + errors: FormFieldErrors & { [key: `datasource-${string}`]: string }> +) { + return errors && (errors.dashboard?.title || errors.k8s?.name) ? 'destructive' : 'primary'; +} + +function getButtonText( + errors: FormFieldErrors & { [key: `datasource-${string}`]: string }> +) { + return errors && (errors.dashboard?.title || errors.k8s?.name) ? 'Import (Overwrite)' : 'Import'; +} diff --git a/public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx b/public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx new file mode 100644 index 00000000000..ef0262adec5 --- /dev/null +++ b/public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx @@ -0,0 +1,181 @@ +import { useState } from 'react'; + +import { locationUtil } from '@grafana/data'; +import { locationService, reportInteraction } from '@grafana/runtime'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { AnnotationQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Form } from 'app/core/components/Form/Form'; +import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; +import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; +import { clearLoadedDashboard } from 'app/features/manage-dashboards/state/actions'; +import { useDispatch, useSelector, StoreState } from 'app/types'; + +import { ImportDashboardFormV2 } from './ImportDashboardFormV2'; + +const IMPORT_FINISHED_EVENT_NAME = 'dashboard_import_imported'; + +export function ImportDashboardOverviewV2() { + const [uidReset, setUidReset] = useState(false); + const dispatch = useDispatch(); + + // Get state from Redux store + const searchObj = locationService.getSearchObject(); + const dashboard = useSelector((state: StoreState) => state.importDashboard.dashboard as DashboardV2Spec); + const inputs = useSelector((state: StoreState) => state.importDashboard.inputs); + const folder = searchObj.folderUid ? { uid: String(searchObj.folderUid) } : { uid: '' }; + + function onUidReset() { + setUidReset(true); + } + + function onCancel() { + dispatch(clearLoadedDashboard()); + } + + async function onSubmit(form: SaveDashboardCommand) { + reportInteraction(IMPORT_FINISHED_EVENT_NAME); + + const dashboardWithDataSources: DashboardV2Spec = { + ...dashboard, + title: form.dashboard.title, + annotations: dashboard.annotations?.map((annotation: AnnotationQueryKind) => { + if (annotation.spec.datasource?.type) { + const dsType = annotation.spec.datasource.type; + if (form[`datasource-${dsType}` as keyof typeof form]) { + const ds = form[`datasource-${dsType}` as keyof typeof form] as { uid: string; type: string }; + return { + ...annotation, + spec: { + ...annotation.spec, + datasource: { + uid: ds.uid, + type: ds.type, + }, + }, + }; + } + } + return annotation; + }), + variables: dashboard.variables?.map((variable) => { + if (variable.kind === 'QueryVariable') { + if (variable.spec.datasource?.type) { + const dsType = variable.spec.datasource.type; + if (form[`datasource-${dsType}` as keyof typeof form]) { + const ds = form[`datasource-${dsType}` as keyof typeof form] as { uid: string; type: string }; + return { + ...variable, + spec: { + ...variable.spec, + datasource: { + ...variable.spec.datasource, + uid: ds.uid, + type: ds.type, + }, + options: [], + current: { + text: '', + value: '', + }, + refresh: 'onDashboardLoad', + }, + }; + } + } + } else if (variable.kind === 'DatasourceVariable') { + return { + ...variable, + spec: { + ...variable.spec, + current: { + text: '', + value: '', + }, + }, + }; + } + return variable; + }), + elements: Object.fromEntries( + Object.entries(dashboard.elements).map(([key, element]) => { + if (element.kind === 'Panel') { + const panel = { ...element.spec }; + if (panel.data?.kind === 'QueryGroup') { + const newQueries = panel.data.spec.queries.map((query: any) => { + if (query.kind === 'PanelQuery') { + const queryType = query.spec.query?.kind; + // Match datasource by query kind + if (queryType && form[`datasource-${queryType}` as keyof typeof form]) { + const ds = form[`datasource-${queryType}` as keyof typeof form] as { uid: string; type: string }; + return { + ...query, + spec: { + ...query.spec, + datasource: { + uid: ds.uid, + type: ds.type, + }, + }, + }; + } + } + return query; + }); + panel.data = { + ...panel.data, + spec: { + ...panel.data.spec, + queries: newQueries, + }, + }; + } + return [ + key, + { + kind: element.kind, + spec: panel, + }, + ]; + } + return [key, element]; + }) + ), + }; + + const result = await getDashboardAPI('v2').saveDashboard({ + ...form, + dashboard: dashboardWithDataSources, + }); + + if (result.url) { + const dashboardUrl = locationUtil.stripBaseFromUrl(result.url); + locationService.push(dashboardUrl); + } + } + + return ( + <> + & { [key: `datasource-${string}`]: string }> + onSubmit={onSubmit} + defaultValues={{ dashboard, k8s: { annotations: { 'grafana.app/folder': folder.uid } } }} + validateOnMount + validateOn="onChange" + > + {({ register, errors, control, watch, getValues }) => ( + + )} + + + ); +} diff --git a/public/app/features/manage-dashboards/DashboardImportPage.tsx b/public/app/features/manage-dashboards/DashboardImportPage.tsx index bfebb0cec81..43297d1ec37 100644 --- a/public/app/features/manage-dashboards/DashboardImportPage.tsx +++ b/public/app/features/manage-dashboards/DashboardImportPage.tsx @@ -27,12 +27,14 @@ import { Form } from 'app/core/components/Form/Form'; import { Page } from 'app/core/components/Page/Page'; import { t, Trans } from 'app/core/internationalization'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { dispatch } from 'app/store/store'; import { StoreState } from 'app/types'; import { cleanUpAction } from '../../core/actions/cleanUp'; +import { ImportDashboardOverviewV2 } from '../dashboard-scene/v2schema/ImportDashboardOverviewV2'; import { ImportDashboardOverview } from './components/ImportDashboardOverview'; -import { fetchGcomDashboard, importDashboardJson } from './state/actions'; +import { fetchGcomDashboard, importDashboardJson, importDashboardV2Json } from './state/actions'; import { initialImportDashboardState } from './state/reducers'; import { validateDashboardJson, validateGcomDashboard } from './utils/validation'; @@ -53,6 +55,7 @@ const JSON_PLACEHOLDER = `{ const mapStateToProps = (state: StoreState) => ({ loadingState: state.importDashboard.state, + dashboard: state.importDashboard.dashboard, }); const mapDispatchToProps = { @@ -88,7 +91,13 @@ class UnthemedDashboardImport extends PureComponent { }); try { - this.props.importDashboardJson(JSON.parse(String(result))); + const json = JSON.parse(String(result)); + + if (json.elements) { + dispatch(importDashboardV2Json(json)); + return; + } + this.props.importDashboardJson(json); } catch (error) { if (error instanceof Error) { appEvents.emit(AppEvents.alertError, ['Import failed', 'JSON -> JS Serialization failed: ' + error.message]); @@ -102,7 +111,14 @@ class UnthemedDashboardImport extends PureComponent { import_source: 'json_pasted', }); - this.props.importDashboardJson(JSON.parse(formData.dashboardJson)); + const dashboard = JSON.parse(formData.dashboardJson); + + if (dashboard.elements) { + dispatch(importDashboardV2Json(dashboard)); + return; + } + + this.props.importDashboardJson(dashboard); }; getGcomDashboard = (formData: { gcomDashboard: string }) => { @@ -229,6 +245,19 @@ class UnthemedDashboardImport extends PureComponent { subTitle: 'Import dashboard from file or Grafana.com', }; + getDashboardOverview() { + const { loadingState, dashboard } = this.props; + + if (loadingState === LoadingState.Done) { + if (dashboard.elements) { + return ; + } + return ; + } + + return null; + } + render() { const { loadingState } = this.props; @@ -243,7 +272,7 @@ class UnthemedDashboardImport extends PureComponent { )} {[LoadingState.Error, LoadingState.NotStarted].includes(loadingState) && this.renderImportForm()} - {loadingState === LoadingState.Done && } + {this.getDashboardOverview()} ); diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index 48a8462a7f9..a5ddf74bbc6 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -1,8 +1,10 @@ import { DataSourceInstanceSettings } from '@grafana/data'; import { getBackendSrv, getDataSourceSrv, isFetchError } from '@grafana/runtime'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { PermissionLevelString, SearchQueryType, ThunkResult } from 'app/types'; import { @@ -18,6 +20,7 @@ import { DashboardJson } from '../types'; import { clearDashboard, + DataSourceInput, fetchDashboard, fetchFailed, ImportDashboardDTO, @@ -56,6 +59,13 @@ export function importDashboardJson(dashboard: any): ThunkResult { }; } +export function importDashboardV2Json(dashboard: DashboardV2Spec): ThunkResult { + return async (dispatch) => { + dispatch(setJsonDashboard(dashboard)); + dispatch(processV2Elements(dashboard)); + }; +} + const getNewLibraryPanelsByInput = (input: Input, state: ImportDashboardState): LibraryPanel[] | undefined => { return input?.usage?.libraryPanels?.filter((usageLibPanel) => state.inputs.libraryPanels.some( @@ -142,6 +152,53 @@ function processElements(dashboardJson?: { __elements?: Record { + return async function (dispatch) { + const elements = dashboard.elements; + // get elements from dashboard + // each element can only be a panel + const inputs: Record = {}; + for (const element of Object.values(elements)) { + if (element.kind !== 'Panel') { + throw new Error('Only panels are currenlty supported in v2 dashboards'); + } + + for (const query of element.spec.data.spec.queries) { + const datasourceRef = query.spec.datasource; + if (!datasourceRef) { + let dataSourceInput: DataSourceInput | undefined; + const dsType = query.spec.query.kind; + const datasource = await getDatasourceSrv().get({ type: dsType }); + if (!datasource) { + dataSourceInput = { + name: dsType, + label: dsType, + info: `No data sources of type ${dsType} found`, + value: '', + type: InputType.DataSource, + pluginId: dsType, + }; + + inputs[dsType] = dataSourceInput; + } else { + dataSourceInput = { + name: datasource.name, + label: datasource.name, + info: `Select a ${datasource.name} data source`, + value: datasource.uid, + type: InputType.DataSource, + pluginId: datasource.meta?.id, + }; + + inputs[datasource.meta?.id] = dataSourceInput; + } + } + } + } + dispatch(setInputs(Object.values(inputs))); + }; +} + export async function getLibraryPanelInputs(dashboardJson?: { __elements?: Record; }): Promise { From 5efb620f1bb71231c296519bfbcc3ccadcaddfc8 Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Fri, 11 Apr 2025 11:28:14 -0700 Subject: [PATCH 41/73] Canvas: Fix layout calcs for scale mode (#103408) --- public/app/features/canvas/runtime/element.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index f61e9d94742..f71ce0f1b9e 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -334,8 +334,8 @@ export class ElementState implements LayerElement { placement.height = height; break; case VerticalConstraint.Scale: - placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100; - placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100; + placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale; + placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale; break; } @@ -360,8 +360,8 @@ export class ElementState implements LayerElement { placement.width = width; break; case HorizontalConstraint.Scale: - placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100; - placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100; + placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale; + placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale; break; } From 652c374c4c992a6bdfc7e5917bd7307b3bd0f6f5 Mon Sep 17 00:00:00 2001 From: Michael Mandrus <41969079+mmandrus@users.noreply.github.com> Date: Fri, 11 Apr 2025 15:29:07 -0400 Subject: [PATCH 42/73] CloudMigrations: Make table sort case insensitive (#103898) * case insensitive sort * fix for all db types * add comment * add unit test * add a TODO to fix later --- .../cloudmigrationimpl/xorm_store.go | 9 ++- .../cloudmigrationimpl/xorm_store_test.go | 71 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go index a29c77f7a37..d2cf210d919 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/secrets" secretskv "github.com/grafana/grafana/pkg/services/secrets/kvstore" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/util" ) @@ -434,7 +435,13 @@ func (ss *sqlStore) getSnapshotResources(ctx context.Context, snapshotUid string if errorsOnly { sess.Where("status = ?", cloudmigration.ItemStatusError) } - return sess.OrderBy(fmt.Sprintf("%s %s", col, dir)).Find(&resources, &cloudmigration.CloudMigrationResource{ + // TODO: It would be better if the query builder supported a case-insensitive flag for the .OrderBy() method + orderByClause := fmt.Sprintf("lower(%s) %s", col, dir) + if ss.db.GetDBType() == migrator.Postgres || // Postgres does not support lower() in ORDER BY -- sorts by case-insensitive by default + params.SortColumn == cloudmigration.SortColumnID { // Don't apply a string sort to a numeric column + orderByClause = fmt.Sprintf("%s %s", col, dir) + } + return sess.OrderBy(orderByClause).Find(&resources, &cloudmigration.CloudMigrationResource{ SnapshotUID: snapshotUid, }) }) diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go index 530c489d19b..b64dd11dc85 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go @@ -359,6 +359,77 @@ func Test_SnapshotResources(t *testing.T) { }) } +func Test_SnapshotResourceCaseInsensitiveSorting(t *testing.T) { + t.Parallel() + + _, s := setUpTest(t) + ctx := context.Background() + + // Create test data with mixed case names + resources := []cloudmigration.CloudMigrationResource{ + {UID: "1", SnapshotUID: "abc123", Name: "B", Type: cloudmigration.DashboardDataType, Status: cloudmigration.ItemStatusOK}, + {UID: "2", SnapshotUID: "abc123", Name: "aa", Type: cloudmigration.AlertRuleType, Status: cloudmigration.ItemStatusOK}, + {UID: "3", SnapshotUID: "abc123", Name: "ba", Type: cloudmigration.DashboardDataType, Status: cloudmigration.ItemStatusOK}, + {UID: "4", SnapshotUID: "abc123", Name: "A", Type: cloudmigration.AlertRuleType, Status: cloudmigration.ItemStatusOK}, + } + + err := s.db.WithDbSession(ctx, func(sess *db.Session) error { + _, err := sess.Insert(resources) + return err + }) + require.NoError(t, err) + + // Test ascending sort + results, err := s.getSnapshotResources(ctx, "abc123", cloudmigration.SnapshotResultQueryParams{ + ResultPage: 1, + ResultLimit: 100, + SortColumn: cloudmigration.SortColumnName, + SortOrder: cloudmigration.SortOrderAsc, + }) + require.NoError(t, err) + assert.True(t, testNameComesBefore(t, results, "A", "aa")) + assert.True(t, testNameComesBefore(t, results, "B", "ba")) + assert.True(t, testNameComesBefore(t, results, "A", "B")) + assert.True(t, testNameComesBefore(t, results, "aa", "B")) + assert.True(t, testNameComesBefore(t, results, "aa", "ba")) + assert.True(t, testNameComesBefore(t, results, "A", "ba")) + assert.True(t, testNameComesBefore(t, results, "A", "B")) + + // Test descending sort + results, err = s.getSnapshotResources(ctx, "abc123", cloudmigration.SnapshotResultQueryParams{ + ResultPage: 1, + ResultLimit: 100, + SortColumn: cloudmigration.SortColumnName, + SortOrder: cloudmigration.SortOrderDesc, + }) + require.NoError(t, err) + assert.True(t, testNameComesBefore(t, results, "ba", "B")) + assert.True(t, testNameComesBefore(t, results, "aa", "A")) + assert.True(t, testNameComesBefore(t, results, "ba", "B")) + assert.True(t, testNameComesBefore(t, results, "aa", "A")) + assert.True(t, testNameComesBefore(t, results, "ba", "B")) + assert.True(t, testNameComesBefore(t, results, "aa", "A")) +} + +func testNameComesBefore(t *testing.T, input []cloudmigration.CloudMigrationResource, first string, second string) bool { + t.Helper() + + foundFirst, foundSecond := false, false + for _, r := range input { + if r.Name == second { + foundSecond = true + continue + } + if r.Name == first { + if foundSecond { + return false + } + foundFirst = true + } + } + return foundFirst && foundSecond +} + func TestGetSnapshotList(t *testing.T) { t.Parallel() From bba85c112845f1bb0b0f62906898900ee743ac7e Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Fri, 11 Apr 2025 13:31:41 -0600 Subject: [PATCH 43/73] K8s: Dashboards: Fix error handling (#103929) --- pkg/registry/apis/dashboard/register.go | 16 ++++++++-------- .../dashboard/integration/api_validation_test.go | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 82a5ed934f1..51d0ab4ef43 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -254,12 +254,12 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A // Basic validations if err := b.dashboardService.ValidateBasicDashboardProperties(title, accessor.GetName(), accessor.GetMessage()); err != nil { - return err + return apierrors.NewBadRequest(err.Error()) } // Validate refresh interval if err := b.dashboardService.ValidateDashboardRefreshInterval(b.cfg.MinRefreshInterval, refresh); err != nil { - return err + return apierrors.NewBadRequest(err.Error()) } id, err := identity.GetRequester(ctx) @@ -270,7 +270,7 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A // Validate folder existence if specified if !a.IsDryRun() && accessor.GetFolder() != "" { if err := b.validateFolderExists(ctx, accessor.GetFolder(), id.GetOrgID()); err != nil { - return err + return apierrors.NewNotFound(folders.FolderResourceInfo.GroupResource(), accessor.GetFolder()) } } @@ -288,7 +288,7 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A return err } if quotaReached { - return dashboards.ErrQuotaReached + return apierrors.NewForbidden(dashv1.DashboardResourceInfo.GroupResource(), a.GetName(), dashboards.ErrQuotaReached) } } @@ -324,19 +324,19 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A // Basic validations if err := b.dashboardService.ValidateBasicDashboardProperties(title, newAccessor.GetName(), newAccessor.GetMessage()); err != nil { - return err + return apierrors.NewBadRequest(err.Error()) } // Validate folder existence if specified and changed if !a.IsDryRun() && newAccessor.GetFolder() != "" && newAccessor.GetFolder() != oldAccessor.GetFolder() { if err := b.validateFolderExists(ctx, newAccessor.GetFolder(), nsInfo.OrgID); err != nil { - return err + return apierrors.NewNotFound(folders.FolderResourceInfo.GroupResource(), newAccessor.GetFolder()) } } // Validate refresh interval if err := b.dashboardService.ValidateDashboardRefreshInterval(b.cfg.MinRefreshInterval, refresh); err != nil { - return err + return apierrors.NewBadRequest(err.Error()) } allowOverwrite := false // TODO: Add support for overwrite flag @@ -345,7 +345,7 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A if allowOverwrite { newAccessor.SetGeneration(oldAccessor.GetGeneration()) } else { - return dashboards.ErrDashboardVersionMismatch + return apierrors.NewBadRequest(dashboards.ErrDashboardVersionMismatch.Error()) } } diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index 540498c91eb..fa5a3f2b0c6 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -212,7 +212,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("reject dashboard with non-existent folder UID", func(t *testing.T) { nonExistentFolderUID := "non-existent-folder-uid" _, err := createDashboard(t, adminClient, "Dashboard in Non-existent Folder", &nonExistentFolderUID, nil) - ctx.Helper.EnsureStatusError(err, http.StatusNotFound, "folder not found") + ctx.Helper.EnsureStatusError(err, http.StatusNotFound, "folders.folder.grafana.app \"non-existent-folder-uid\" not found") }) }) From 5011907dae6f4258422ecda6512b47d3523f4290 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Fri, 11 Apr 2025 13:35:39 -0600 Subject: [PATCH 44/73] Dashboard Schema V2: Export dashboard JSON (#101180) * wip * fix the async bug * Fix tests * support library panels * Update comment * clean up * refactor, use multi resource kind * uncomment * return only the spec * simplify templateizing logic * update dashboardkind * revert imprtable requirements to use the current interface * tests and fixes * add panel configs to required; more tests; * everything in elements should be processed * clean up * clean up * rename for clarity; clean up tests * export resource v spec * i18n * clean unused * fix library panels * revert comments * revert lefthook.rc * don't support lib panels * another lefhook revert * remove lib panel in test * Remove library panels on external export; show warning if dash contains library panels * fix tests * Support old export mode * clean up * rely on dash shape vs feature toggle, add spacing to alert, update message * lint * typo fix * make makeExportable part of dashboard serializer; clean up old exporter * clean up * more cleanup * gem file cleanup * remove unused function * remove unused selector * don't remove ds refs that use ds template variable * clean up --- .betterer.results | 21 + .../dashboard-scene/scene/DashboardScene.tsx | 5 +- .../scene/export/exporters.test.ts | 656 ++++++++++++++++++ .../dashboard-scene/scene/export/exporters.ts | 411 +++++++++++ .../dashboard-scene/scene/export/utils.ts | 94 +++ .../serialization/DashboardSceneSerializer.ts | 47 +- ...sformSceneToSaveModelSchemaV2.test.ts.snap | 12 +- .../transformSceneToSaveModelSchemaV2.test.ts | 9 + .../sharing/ExportButton/ExportAsJson.tsx | 71 +- .../sharing/ShareExportTab.tsx | 93 ++- .../components/ShareModal/ShareExport.tsx | 3 +- .../AddLibraryPanelModal.tsx | 1 - public/locales/en-US/grafana.json | 2 + 13 files changed, 1367 insertions(+), 58 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/export/exporters.test.ts create mode 100644 public/app/features/dashboard-scene/scene/export/exporters.ts create mode 100644 public/app/features/dashboard-scene/scene/export/utils.ts diff --git a/.betterer.results b/.betterer.results index 518606f1515..d7345aa4cf6 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1569,6 +1569,27 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/dashboard-scene/scene/export/exporters.test.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + ], + "public/app/features/dashboard-scene/scene/export/exporters.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"], + [0, 0, 0, "Unexpected any. Specify a different type.", "7"], + [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Unexpected any. Specify a different type.", "9"], + [0, 0, 0, "Unexpected any. Specify a different type.", "10"] + ], "public/app/features/dashboard-scene/serialization/angularMigration.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index f376ede4a5f..372579cd162 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -29,6 +29,7 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardModel, ScopeMeta } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; import { VariablesChanged } from 'app/features/variables/types'; import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types'; import { ShowConfirmModalEvent } from 'app/types/events'; @@ -181,7 +182,9 @@ export class DashboardScene extends SceneObjectBase impleme public serializer: DashboardSceneSerializerLike< Dashboard | DashboardV2Spec, - DashboardMeta | DashboardWithAccessInfo['metadata'] + DashboardMeta | DashboardWithAccessInfo['metadata'], + Dashboard | DashboardV2Spec, + DashboardJson | DashboardV2Spec >; private _layoutRestorer = new LayoutRestorer(); diff --git a/public/app/features/dashboard-scene/scene/export/exporters.test.ts b/public/app/features/dashboard-scene/scene/export/exporters.test.ts new file mode 100644 index 00000000000..4f93d54cf39 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/export/exporters.test.ts @@ -0,0 +1,656 @@ +import { find } from 'lodash'; + +import { DataSourceInstanceSettings, DataSourceRef, PanelPluginMeta, TypedVariableModel } from '@grafana/data'; +import { Dashboard, DashboardCursorSync, ThresholdsMode } from '@grafana/schema'; +import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; +import { + DatasourceVariableKind, + QueryVariableKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import config from 'app/core/config'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; + +import { LibraryElementKind } from '../../../library-panels/types'; +import { DashboardJson } from '../../../manage-dashboards/types'; +import { variableAdapters } from '../../../variables/adapters'; +import { createConstantVariableAdapter } from '../../../variables/constant/adapter'; +import { createDataSourceVariableAdapter } from '../../../variables/datasource/adapter'; +import { createQueryVariableAdapter } from '../../../variables/query/adapter'; + +import { makeExportableV1, makeExportableV2, LibraryElementExport } from './exporters'; + +jest.mock('app/core/store', () => { + return { + getBool: jest.fn(), + getObject: jest.fn((_a, b) => b), + get: jest.fn(), + }; +}); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => { + return { + get: (v: string | DataSourceRef) => { + const s = getStubInstanceSettings(v); + return Promise.resolve(s); + }, + getInstanceSettings: getStubInstanceSettings, + }; + }, + config: { + buildInfo: {}, + panels: {}, + apps: {}, + featureToggles: { + newVariables: false, + }, + }, +})); + +jest.mock('app/features/library-panels/state/api', () => ({ + getLibraryPanel: jest.fn().mockReturnValue( + Promise.resolve({ + name: 'Testing lib panel 1', + uid: 'abc-123', + model: { + type: 'graph', + datasource: { + type: 'testdb', + uid: '${DS_GFDB}', + }, + }, + }) + ), +})); + +variableAdapters.register(createQueryVariableAdapter()); +variableAdapters.register(createConstantVariableAdapter()); +variableAdapters.register(createDataSourceVariableAdapter()); + +describe('dashboard exporter v1', () => { + it('handles a default datasource in a template variable', async () => { + const dashboard: any = { + templating: { + list: [ + { + current: {}, + definition: 'test', + error: {}, + hide: 0, + includeAll: false, + multi: false, + name: 'query0', + options: [], + query: { + query: 'test', + refId: 'StandardVariableQuery', + }, + refresh: 1, + regex: '', + skipUrlSync: false, + sort: 0, + type: 'query', + }, + ], + }, + }; + const dashboardModel = new DashboardModel(dashboard, undefined, { + getVariablesFromState: () => dashboard.templating.list, + }); + + const exported: any = await makeExportableV1(dashboardModel); + expect(exported.templating.list[0].datasource.uid).toBe('${DS_GFDB}'); + }); + + it('do not expose datasource name and id in a in a template variable of type datasource', async () => { + const dashboard: Dashboard = { + title: 'My dashboard', + revision: 1, + editable: false, + graphTooltip: DashboardCursorSync.Off, + schemaVersion: 1, + timepicker: { hidden: true }, + timezone: '', + panels: [ + { + id: 1, + type: 'timeseries', + title: 'My panel title', + gridPos: { x: 0, y: 0, w: 1, h: 1 }, + }, + ], + templating: { + list: [ + { + current: { + selected: false, + text: 'my-prometheus-datasource', + value: 'my-prometheus-datasource-uid', + }, + hide: 0, + includeAll: false, + multi: false, + name: 'query1', + options: [], + query: 'prometheus', + refresh: 1, + regex: '', + skipUrlSync: false, + type: 'datasource', + }, + ], + }, + }; + const dashboardModel = new DashboardModel(dashboard, undefined, { + getVariablesFromState: () => dashboard.templating!.list! as TypedVariableModel[], + }); + const exported = (await makeExportableV1(dashboardModel)) as DashboardJson; + const value = exported?.templating?.list ? exported?.templating?.list[0].current : ''; + expect(value).toEqual({}); + }); + + it('replaces datasource ref in library panel', async () => { + const dashboard: Dashboard = { + editable: true, + graphTooltip: 1, + schemaVersion: 38, + panels: [ + { + id: 1, + title: 'Panel title', + type: 'timeseries', + options: { + cellHeight: 'sm', + footer: { + countRows: false, + fields: '', + reducer: ['sum'], + show: false, + }, + showHeader: true, + }, + transformations: [], + transparent: false, + fieldConfig: { + defaults: { + custom: { + align: 'auto', + cellOptions: { + type: 'auto', + }, + inspect: false, + }, + mappings: [], + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { + color: 'green', + value: 10, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 8, + w: 12, + x: 0, + y: 0, + }, + libraryPanel: { + name: 'Testing lib panel', + uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4', + }, + }, + ], + }; + + const dashboardModel = new DashboardModel(dashboard, {}); + + const exported = (await makeExportableV1(dashboardModel)) as DashboardJson; + if ('error' in exported) { + throw new Error('error should not be returned when making exportable json'); + } + expect(exported.__elements!['c46a6b49-de40-43b3-982c-1b5e1ec084a4'].model.datasource.uid).toBe('${DS_GFDB}'); + expect(exported.__inputs![0].name).toBe('DS_GFDB'); + }); + + it('If a panel queries has no datasource prop ignore it', async () => { + const dashboard = { + panels: [ + { + id: 1, + type: 'graph', + datasource: { + uid: 'other', + type: 'other', + }, + targets: [{ refId: 'A', a: 'A' }], + }, + ], + } as unknown as Dashboard; + const dashboardModel = new DashboardModel(dashboard, undefined, { + getVariablesFromState: () => [], + }); + const exported: any = await makeExportableV1(dashboardModel); + expect(exported.panels[0].datasource).toEqual({ uid: '${DS_OTHER}', type: 'other' }); + expect(exported.panels[0].targets[0].datasource).toEqual({ uid: '${DS_OTHER}', type: 'other' }); + }); + + describe('given dashboard with repeated panels', () => { + let dash: any, exported: any; + + beforeEach((done) => { + dash = { + templating: { + list: [ + { + name: 'apps', + type: 'query', + datasource: { uid: 'gfdb', type: 'testdb' }, + current: { value: 'Asd', text: 'Asd' }, + options: [{ value: 'Asd', text: 'Asd' }], + }, + { + name: 'prefix', + type: 'constant', + current: { value: 'collectd', text: 'collectd' }, + options: [], + query: 'collectd', + }, + { + name: 'ds', + type: 'datasource', + query: 'other2', + current: { value: 'other2', text: 'other2' }, + options: [], + }, + ], + }, + annotations: { + list: [ + { + name: 'logs', + datasource: 'gfdb', + }, + ], + }, + panels: [ + { id: 6, datasource: { uid: 'gfdb', type: 'testdb' }, type: 'graph' }, + { id: 7 }, + { + id: 8, + datasource: { uid: '-- Mixed --', type: 'mixed' }, + targets: [{ datasource: { uid: 'other', type: 'other' } }], + }, + { id: 9, datasource: { uid: '$ds', type: 'other2' } }, + { + id: 17, + libraryPanel: { + name: 'Library Panel 2', + uid: 'ah8NqyDPs', + }, + }, + { + id: 2, + repeat: 'apps', + datasource: { uid: 'gfdb', type: 'testdb' }, + type: 'graph', + }, + { id: 3, repeat: null, repeatPanelId: 2 }, + { + id: 4, + collapsed: true, + panels: [ + { id: 10, datasource: { uid: 'gfdb', type: 'testdb' }, type: 'table' }, + { id: 11 }, + { + id: 12, + datasource: { uid: '-- Mixed --', type: 'mixed' }, + targets: [{ datasource: { uid: 'other', type: 'other' } }], + }, + { id: 13, datasource: { uid: '$uid', type: 'other' } }, + { + id: 14, + repeat: 'apps', + datasource: { uid: 'gfdb', type: 'testdb' }, + type: 'heatmap', + }, + { id: 15, repeat: null, repeatPanelId: 14 }, + { + id: 16, + datasource: { uid: 'gfdb', type: 'testdb' }, + type: 'graph', + libraryPanel: { + name: 'Library Panel', + uid: 'jL6MrxCMz', + }, + }, + ], + }, + { + id: 5, + targets: [{ scenarioId: 'random_walk', refId: 'A' }], + }, + ], + }; + + config.buildInfo.version = '3.0.2'; + + config.panels['graph'] = { + id: 'graph', + name: 'Graph', + info: { version: '1.1.0' }, + } as PanelPluginMeta; + + config.panels['table'] = { + id: 'table', + name: 'Table', + info: { version: '1.1.1' }, + } as PanelPluginMeta; + + config.panels['heatmap'] = { + id: 'heatmap', + name: 'Heatmap', + info: { version: '1.1.2' }, + } as PanelPluginMeta; + + dash = new DashboardModel( + dash, + {}, + { + getVariablesFromState: () => dash.templating.list, + } + ); + + // init library panels + dash.getPanelById(17).initLibraryPanel({ + uid: 'ah8NqyDPs', + name: 'Library Panel 2', + model: { + datasource: { type: 'other2', uid: '$ds' }, + targets: [{ refId: 'A', datasource: { type: 'other2', uid: '$ds' } }], + type: 'graph', + }, + }); + + makeExportableV1(dash).then((clean) => { + exported = clean; + done(); + }); + }); + + it('should replace datasource refs', () => { + const panel = exported.panels[0]; + expect(panel.datasource.uid).toBe('${DS_GFDB}'); + }); + + it('should explicitly specify default datasources', () => { + const panel = exported.panels[7]; + expect(exported.__inputs.some((ds: Record) => ds.name === 'DS_GFDB')).toBeTruthy(); + expect(panel.datasource.uid).toBe('${DS_GFDB}'); + expect(panel.targets[0].datasource).toEqual({ type: 'testdb', uid: '${DS_GFDB}' }); + }); + + it('should not include default datasource in __inputs unnecessarily', async () => { + const testJson = { + panels: [{ id: 1, datasource: { uid: 'other', type: 'other' }, type: 'graph' }], + } as unknown as Dashboard; + const testDash = new DashboardModel(testJson); + const exportedJson: any = await makeExportableV1(testDash); + expect(exportedJson.__inputs.some((ds: Record) => ds.name === 'DS_GFDB')).toBeFalsy(); + }); + + it('should replace datasource refs in collapsed row', () => { + const panel = exported.panels[6].panels[0]; + expect(panel.datasource.uid).toBe('${DS_GFDB}'); + }); + + it('should replace datasource in variable query', () => { + expect(exported.templating.list[0].datasource.uid).toBe('${DS_GFDB}'); + expect(exported.templating.list[0].options.length).toBe(0); + expect(exported.templating.list[0].current.value).toBe(undefined); + expect(exported.templating.list[0].current.text).toBe(undefined); + }); + + it('should replace datasource in annotation query', () => { + expect(exported.annotations.list[1].datasource.uid).toBe('${DS_GFDB}'); + }); + + it('should add datasource as input', () => { + expect(exported.__inputs[0].name).toBe('DS_GFDB'); + expect(exported.__inputs[0].pluginId).toBe('testdb'); + expect(exported.__inputs[0].type).toBe('datasource'); + }); + + it('should add datasource to required', () => { + const require = find(exported.__requires, { name: 'TestDB' }); + expect(require.name).toBe('TestDB'); + expect(require.id).toBe('testdb'); + expect(require.type).toBe('datasource'); + expect(require.version).toBe('1.2.1'); + }); + + it('should not add built in datasources to required', () => { + const require = find(exported.__requires, { name: 'Mixed' }); + expect(require).toBe(undefined); + }); + + it('should add datasources used in mixed mode', () => { + const require = find(exported.__requires, { name: 'OtherDB' }); + expect(require).not.toBe(undefined); + }); + + it('should add graph panel to required', () => { + const require = find(exported.__requires, { name: 'Graph' }); + expect(require.name).toBe('Graph'); + expect(require.id).toBe('graph'); + expect(require.version).toBe('1.1.0'); + }); + + it('should add table panel to required', () => { + const require = find(exported.__requires, { name: 'Table' }); + expect(require.name).toBe('Table'); + expect(require.id).toBe('table'); + expect(require.version).toBe('1.1.1'); + }); + + it('should add heatmap panel to required', () => { + const require = find(exported.__requires, { name: 'Heatmap' }); + expect(require.name).toBe('Heatmap'); + expect(require.id).toBe('heatmap'); + expect(require.version).toBe('1.1.2'); + }); + + it('should add grafana version', () => { + const require = find(exported.__requires, { name: 'Grafana' }); + expect(require.type).toBe('grafana'); + expect(require.id).toBe('grafana'); + expect(require.version).toBe('3.0.2'); + }); + + it('should add constant template variables as inputs', () => { + const input = find(exported.__inputs, { name: 'VAR_PREFIX' }); + expect(input.type).toBe('constant'); + expect(input.label).toBe('prefix'); + expect(input.value).toBe('collectd'); + }); + + it('should templatize constant variables', () => { + const variable = find(exported.templating.list, { name: 'prefix' }); + expect(variable.query).toBe('${VAR_PREFIX}'); + expect(variable.current.text).toBe('${VAR_PREFIX}'); + expect(variable.current.value).toBe('${VAR_PREFIX}'); + expect(variable.options[0].text).toBe('${VAR_PREFIX}'); + expect(variable.options[0].value).toBe('${VAR_PREFIX}'); + }); + + it('should add datasources only use via datasource variable to requires', () => { + const require = find(exported.__requires, { name: 'OtherDB_2' }); + expect(require.id).toBe('other2'); + }); + + it('should add library panels as elements', () => { + const element: LibraryElementExport = exported.__elements['ah8NqyDPs']; + expect(element.name).toBe('Library Panel 2'); + expect(element.kind).toBe(LibraryElementKind.Panel); + expect(element.model).toEqual({ + datasource: { type: 'testdb', uid: '${DS_GFDB}' }, + type: 'graph', + }); + }); + + it('should add library panels in collapsed rows as elements', () => { + const element: LibraryElementExport = exported.__elements['jL6MrxCMz']; + expect(element.name).toBe('Library Panel'); + expect(element.kind).toBe(LibraryElementKind.Panel); + expect(element.model).toEqual({ + type: 'graph', + datasource: { + type: 'testdb', + uid: '${DS_GFDB}', + }, + }); + }); + }); +}); + +describe('dashboard exporter v2', () => { + const setup = async () => { + // Making a deep copy here because original JSON is mutated by the exporter + const schemaCopy = JSON.parse(JSON.stringify(handyTestingSchema)); + + // add a panel that uses a datasource variable + schemaCopy.elements['panel-using-datasource-var'] = { + kind: 'Panel', + spec: { + data: { + kind: 'QueryGroup', + spec: { + queries: [ + { + kind: 'PanelQuery', + spec: { + datasource: { + type: 'prometheus', + uid: '${datasourceVar}', + }, + hidden: false, + query: { + kind: 'prometheus', + spec: { + editorMode: 'builder', + expr: 'go_goroutines{job="prometheus"}', + includeNullMetadata: true, + legendFormat: '__auto', + range: true, + }, + }, + refId: 'A', + }, + }, + ], + }, + }, + }, + }; + + const dashboard = await makeExportableV2(schemaCopy); + if (typeof dashboard === 'object' && 'error' in dashboard) { + throw dashboard.error; + } + return { dashboard, originalSchema: handyTestingSchema }; + }; + + it('should replace datasource in a query variable', async () => { + const { dashboard } = await setup(); + const variable = dashboard.variables[0] as QueryVariableKind; + expect(variable.spec.datasource?.uid).toBeUndefined(); + }); + + it('do not expose datasource name and id in datasource variable', async () => { + const { dashboard } = await setup(); + const variable = dashboard.variables[2] as DatasourceVariableKind; + expect(variable.kind).toBe('DatasourceVariable'); + expect(variable.spec.current).toEqual({ text: '', value: '' }); + }); + + it('should replace datasource in annotation query', async () => { + const { dashboard } = await setup(); + const annotationQuery = dashboard.annotations[0]; + + expect(annotationQuery.spec.datasource?.uid).toBeUndefined(); + }); + + it('should remove library panels from layout', async () => { + const { dashboard, originalSchema } = await setup(); + const elementRef = 'panel-2'; + const libraryPanel = dashboard.elements[elementRef]; + const origLibraryPanel = originalSchema.elements[elementRef]; + expect(origLibraryPanel.kind).toBe('LibraryPanel'); + expect(libraryPanel).toBeUndefined(); + }); + + it('should not remove datasource ref from panel that uses a datasource variable', async () => { + const { dashboard } = await setup(); + const panel = dashboard.elements['panel-using-datasource-var']; + + if (panel.kind !== 'Panel') { + throw new Error('Panel should be a Panel'); + } + + expect(panel.spec.data.spec.queries[0].spec.datasource).toEqual({ + type: 'prometheus', + uid: '${datasourceVar}', + }); + }); +}); + +function getStubInstanceSettings(v: string | DataSourceRef): DataSourceInstanceSettings { + let key = (v as DataSourceRef)?.type ?? v; + return stubs[(key as string) ?? 'gfdb'] ?? stubs['gfdb']; +} + +// Stub responses +const stubs: { [key: string]: DataSourceInstanceSettings } = {}; +stubs['gfdb'] = { + name: 'gfdb', + meta: { id: 'testdb', info: { version: '1.2.1' }, name: 'TestDB' }, +} as DataSourceInstanceSettings; + +stubs['other'] = { + name: 'other', + meta: { id: 'other', info: { version: '1.2.1' }, name: 'OtherDB' }, +} as DataSourceInstanceSettings; + +stubs['other2'] = { + name: 'other2', + meta: { id: 'other2', info: { version: '1.2.1' }, name: 'OtherDB_2' }, +} as DataSourceInstanceSettings; + +stubs['mixed'] = { + name: 'mixed', + meta: { + id: 'mixed', + info: { version: '1.2.1' }, + name: 'Mixed', + builtIn: true, + }, +} as DataSourceInstanceSettings; + +stubs['grafana'] = { + name: '-- Grafana --', + meta: { + id: 'grafana', + info: { version: '1.2.1' }, + name: 'grafana', + builtIn: true, + }, +} as DataSourceInstanceSettings; diff --git a/public/app/features/dashboard-scene/scene/export/exporters.ts b/public/app/features/dashboard-scene/scene/export/exporters.ts new file mode 100644 index 00000000000..5c69f8a2f3b --- /dev/null +++ b/public/app/features/dashboard-scene/scene/export/exporters.ts @@ -0,0 +1,411 @@ +import { defaults, each, sortBy } from 'lodash'; + +import { DataSourceRef, PanelPluginMeta, VariableOption, VariableRefresh } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { + Spec as DashboardV2Spec, + PanelKind, + PanelQueryKind, + AnnotationQueryKind, + QueryVariableKind, + LibraryPanelRef, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import config from 'app/core/config'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { PanelModel, GridPos } from 'app/features/dashboard/state/PanelModel'; +import { getLibraryPanel } from 'app/features/library-panels/state/api'; +import { variableRegex } from 'app/features/variables/utils'; + +import { isPanelModelLibraryPanel } from '../../../library-panels/guard'; +import { LibraryElementKind } from '../../../library-panels/types'; +import { DashboardJson } from '../../../manage-dashboards/types'; +import { isConstant } from '../../../variables/guard'; + +import { removePanelRefFromLayout } from './utils'; + +export interface InputUsage { + libraryPanels?: LibraryPanelRef[]; +} + +export interface Input { + name: string; + type: string; + label: string; + value: any; + description: string; + usage?: InputUsage; +} + +interface Requires { + [key: string]: { + type: string; + id: string; + name: string; + version: string; + }; +} + +export interface ExternalDashboard { + __inputs?: Input[]; + __elements?: Record; + __requires?: Array; + panels: Array; +} + +interface PanelWithExportableLibraryPanel { + gridPos: GridPos; + id: number; + libraryPanel: LibraryPanelRef; +} + +function isExportableLibraryPanel( + p: PanelModel | PanelWithExportableLibraryPanel +): p is PanelWithExportableLibraryPanel { + return Boolean(p.libraryPanel?.name && p.libraryPanel?.uid); +} + +interface DataSources { + [key: string]: { + name: string; + label: string; + description: string; + type: string; + pluginId: string; + pluginName: string; + usage?: InputUsage; + }; +} + +export interface LibraryElementExport { + name: string; + uid: string; + model: any; + kind: LibraryElementKind; +} + +export async function makeExportableV1(dashboard: DashboardModel) { + // clean up repeated rows and panels, + // this is done on the live real dashboard instance, not on a clone + // so we need to undo this + // this is pretty hacky and needs to be changed + dashboard.cleanUpRepeats(); + + const saveModel = dashboard.getSaveModelCloneOld(); + saveModel.id = null; + + // undo repeat cleanup + dashboard.processRepeats(); + + const inputs: Input[] = []; + const requires: Requires = {}; + const datasources: DataSources = {}; + const variableLookup: { [key: string]: any } = {}; + const libraryPanels: Map = new Map(); + + for (const variable of saveModel.getVariables()) { + variableLookup[variable.name] = variable; + } + + const templateizeDatasourceUsage = (obj: any, fallback?: DataSourceRef) => { + if (obj.datasource === undefined) { + obj.datasource = fallback; + return; + } + + let datasource = obj.datasource; + let datasourceVariable: any = null; + + const datasourceUid: string | undefined = datasource?.uid; + const match = datasourceUid && variableRegex.exec(datasourceUid); + + // ignore data source properties that contain a variable + if (match) { + const varName = match[1] || match[2] || match[4]; + datasourceVariable = variableLookup[varName]; + if (datasourceVariable && datasourceVariable.current) { + datasource = datasourceVariable.current.value; + } + } + + return getDataSourceSrv() + .get(datasource) + .then((ds) => { + if (ds.meta?.builtIn) { + return; + } + + // add data source type to require list + requires['datasource' + ds.meta?.id] = { + type: 'datasource', + id: ds.meta.id, + name: ds.meta.name, + version: ds.meta.info.version || '1.0.0', + }; + + // if used via variable we can skip templatizing usage + if (datasourceVariable) { + return; + } + + const libraryPanel = obj.libraryPanel; + const libraryPanelSuffix = !!libraryPanel ? '-for-library-panel' : ''; + let refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase() + libraryPanelSuffix.toUpperCase(); + + datasources[refName] = { + name: refName, + label: ds.name, + description: '', + type: 'datasource', + pluginId: ds.meta?.id, + pluginName: ds.meta?.name, + usage: datasources[refName]?.usage, + }; + + if (!!libraryPanel) { + const libPanels = datasources[refName]?.usage?.libraryPanels || []; + libPanels.push({ name: libraryPanel.name, uid: libraryPanel.uid }); + + datasources[refName].usage = { + libraryPanels: libPanels, + }; + } + + obj.datasource = { type: ds.meta.id, uid: '${' + refName + '}' }; + }); + }; + + const processPanel = async (panel: PanelModel) => { + if (panel.type !== 'row') { + await templateizeDatasourceUsage(panel); + + if (panel.targets) { + for (const target of panel.targets) { + await templateizeDatasourceUsage(target, panel.datasource!); + } + } + + const panelDef: PanelPluginMeta = config.panels[panel.type]; + if (panelDef) { + requires['panel' + panelDef.id] = { + type: 'panel', + id: panelDef.id, + name: panelDef.name, + version: panelDef.info.version, + }; + } + } + }; + + const processLibraryPanels = async (panel: PanelModel) => { + if (isPanelModelLibraryPanel(panel)) { + const { name, uid } = panel.libraryPanel; + let model = panel.libraryPanel.model; + if (!model) { + const libPanel = await getLibraryPanel(uid, true); + model = libPanel.model; + } + + await templateizeDatasourceUsage(model); + + const { gridPos, id, ...rest } = model as any; + if (!libraryPanels.has(uid)) { + libraryPanels.set(uid, { name, uid, kind: LibraryElementKind.Panel, model: rest }); + } + } + }; + + try { + // check up panel data sources + for (const panel of saveModel.panels) { + await processPanel(panel); + + // handle collapsed rows + if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { + for (const rowPanel of panel.panels) { + await processPanel(rowPanel); + } + } + } + + // templatize template vars + for (const variable of saveModel.getVariables()) { + if (variable.type === 'query') { + await templateizeDatasourceUsage(variable); + variable.options = []; + variable.current = {} as unknown as VariableOption; + variable.refresh = + variable.refresh !== VariableRefresh.never ? variable.refresh : VariableRefresh.onDashboardLoad; + } else if (variable.type === 'datasource') { + variable.current = {}; + } + } + + // templatize annotations vars + for (const annotationDef of saveModel.annotations.list) { + await templateizeDatasourceUsage(annotationDef); + } + + // add grafana version + requires['grafana'] = { + type: 'grafana', + id: 'grafana', + name: 'Grafana', + version: config.buildInfo.version, + }; + + // we need to process all panels again after all the promises are resolved + // so all data sources, variables and targets have been templateized when we process library panels + for (const panel of saveModel.panels) { + await processLibraryPanels(panel); + if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { + for (const rowPanel of panel.panels) { + await processLibraryPanels(rowPanel); + } + } + } + + each(datasources, (value: any) => { + inputs.push(value); + }); + + // templatize constants + for (const variable of saveModel.getVariables()) { + if (isConstant(variable)) { + const refName = 'VAR_' + variable.name.replace(' ', '_').toUpperCase(); + inputs.push({ + name: refName, + type: 'constant', + label: variable.label || variable.name, + value: variable.query, + description: '', + }); + // update current and option + variable.query = '${' + refName + '}'; + variable.current = { + value: variable.query, + text: variable.query, + selected: false, + }; + variable.options = [variable.current]; + } + } + + const __elements = [...libraryPanels.entries()].reduce>( + (prev, [curKey, curLibPanel]) => { + prev[curKey] = curLibPanel; + return prev; + }, + {} + ); + + // make inputs and requires a top thing + const newObj: DashboardJson = defaults( + { + __inputs: inputs, + __elements, + __requires: sortBy(requires, ['id']), + }, + saveModel + ); + + // Remove extraneous props from library panels + for (let i = 0; i < newObj.panels.length; i++) { + const libPanel = newObj.panels[i]; + if (isExportableLibraryPanel(libPanel)) { + newObj.panels[i] = { + gridPos: libPanel.gridPos, + id: libPanel.id, + libraryPanel: { uid: libPanel.libraryPanel.uid, name: libPanel.libraryPanel.name }, + }; + } + } + + return newObj; + } catch (err) { + console.error('Export failed:', err); + return { + error: err, + }; + } +} + +export async function makeExportableV2(dashboard: DashboardV2Spec) { + const variableLookup: { [key: string]: any } = {}; + + // get all datasource variables + const datasourceVariables = dashboard.variables.filter((v) => v.kind === 'DatasourceVariable'); + + for (const variable of dashboard.variables) { + variableLookup[variable.spec.name] = variable.spec; + } + + const removeDataSourceRefs = ( + obj: AnnotationQueryKind['spec'] | QueryVariableKind['spec'] | PanelQueryKind['spec'] + ) => { + const datasourceUid = obj.datasource?.uid; + if (datasourceUid?.startsWith('${') && datasourceUid?.endsWith('}')) { + const varName = datasourceUid.slice(2, -1); + // if there's a match we don't want to remove the datasource ref + const match = datasourceVariables.find((v) => v.spec.name === varName); + if (match) { + return; + } + } + + obj.datasource = undefined; + }; + + const processPanel = (panel: PanelKind) => { + if (panel.spec.data.spec.queries) { + for (const query of panel.spec.data.spec.queries) { + removeDataSourceRefs(query.spec); + } + } + }; + + try { + const elements = dashboard.elements; + const layout = dashboard.layout; + + // process elements + for (const [key, element] of Object.entries(elements)) { + if (element.kind === 'Panel') { + processPanel(element); + } else if (element.kind === 'LibraryPanel') { + // just remove the library panel + delete elements[key]; + // remove reference from layout + removePanelRefFromLayout(layout, key); + } + } + + // process template variables + for (const variable of dashboard.variables) { + if (variable.kind === 'QueryVariable') { + removeDataSourceRefs(variable.spec); + variable.spec.options = []; + variable.spec.current = { + text: '', + value: '', + }; + } else if (variable.kind === 'DatasourceVariable') { + variable.spec.current = { + text: '', + value: '', + }; + } + } + + // process annotations vars + for (const annotation of dashboard.annotations) { + removeDataSourceRefs(annotation.spec); + } + + return dashboard; + } catch (err) { + console.error('Export failed:', err); + return { + error: err, + }; + } +} diff --git a/public/app/features/dashboard-scene/scene/export/utils.ts b/public/app/features/dashboard-scene/scene/export/utils.ts new file mode 100644 index 00000000000..3cc85834662 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/export/utils.ts @@ -0,0 +1,94 @@ +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; + +/** + * Removes a panel reference from a layout. + * + * @param layout - The layout to remove the panel reference from. + * @param elementName - The name of the element to remove the panel reference from. This is the key of the element in + * the elements object. + */ +export function removePanelRefFromLayout(layout: DashboardV2Spec['layout'], elementName: string) { + switch (layout.kind) { + case 'GridLayout': { + const items = layout.spec.items || []; + layout.spec.items = items.filter((item) => { + if (item.kind === 'GridLayoutItem') { + return item.spec.element.name !== elementName; + } else if (item.kind === 'GridLayoutRow') { + item.spec.elements = item.spec.elements.filter((el) => el.spec.element.name !== elementName); + // Keep the row if it still has elements left + return item.spec.elements.length > 0; + } + return true; + }); + break; + } + + case 'AutoGridLayout': { + const items = layout.spec.items || []; + layout.spec.items = items.filter((i) => i.kind === 'AutoGridLayoutItem' && i.spec.element.name !== elementName); + break; + } + + case 'RowsLayout': { + // Each row has a nested layout, which we must process recursively + const rows = layout.spec.rows || []; + layout.spec.rows = rows.filter((row) => { + removePanelRefFromLayout(row.spec.layout, elementName); + return !isLayoutEmpty(row.spec.layout); + }); + break; + } + + case 'TabsLayout': { + // Each tab also has a nested layout, so we process it recursively + const tabs = layout.spec.tabs || []; + layout.spec.tabs = tabs.filter((tab) => { + removePanelRefFromLayout(tab.spec.layout, elementName); + return !isLayoutEmpty(tab.spec.layout); + }); + break; + } + } +} + +function isLayoutEmpty(layout: DashboardV2Spec['layout']) { + if (!layout || !layout.spec) { + return true; + } + + switch (layout.kind) { + case 'GridLayout': { + const items = layout.spec.items || []; + return ( + items.length === 0 || + items.every((item) => { + if (item.kind === 'GridLayoutItem') { + return false; + } else if (item.kind === 'GridLayoutRow') { + return item.spec.elements.length === 0; + } + return false; + }) + ); + } + + case 'AutoGridLayout': { + const items = layout.spec.items || []; + return items.length === 0; + } + + case 'RowsLayout': { + const rows = layout.spec.rows || []; + return rows.length === 0; + } + + case 'TabsLayout': { + const tabs = layout.spec.tabs || []; + return tabs.length === 0; + } + + default: + return true; + } +} diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts index e6f29dc9d68..373b72358c8 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts @@ -5,16 +5,20 @@ import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types'; import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { getPanelPluginCounts, getV1SchemaVariables, getV2SchemaVariables, } from 'app/features/dashboard/utils/tracking'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; import { DashboardMeta, SaveDashboardResponseDTO } from 'app/types'; import { getRawDashboardChanges, getRawDashboardV2Changes } from '../saving/getDashboardChanges'; import { DashboardChangeInfo } from '../saving/shared'; import { DashboardScene } from '../scene/DashboardScene'; +import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters'; +import { getVariablesCompatibility } from '../utils/getVariablesCompatibility'; import { getVizPanelKeyForPanelId } from '../utils/utils'; import { transformSceneToSaveModel } from './transformSceneToSaveModel'; @@ -25,7 +29,7 @@ import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSc * M is the type of the metadata * I is the type of the initial save model. By default it's the same as T. */ -export interface DashboardSceneSerializerLike { +export interface DashboardSceneSerializerLike { /** * The save model which the dashboard scene was originally created from */ @@ -50,6 +54,7 @@ export interface DashboardSceneSerializerLike { getElementIdForPanel: (panelId: number) => string | undefined; getElementPanelMapping: () => Map; getDSReferencesMapping: () => DSReferencesMapping; + makeExportableExternally: (s: DashboardScene) => Promise; } interface DashboardTrackingInfo { @@ -67,7 +72,9 @@ export interface DSReferencesMapping { annotations: Set; } -export class V1DashboardSerializer implements DashboardSceneSerializerLike { +export class V1DashboardSerializer + implements DashboardSceneSerializerLike +{ initialSaveModel?: Dashboard; metadata?: DashboardMeta; protected elementPanelMap = new Map(); @@ -195,6 +202,16 @@ export class V1DashboardSerializer implements DashboardSceneSerializerLike { + return getVariablesCompatibility(window.__grafanaSceneContext); + }, + }); + return await makeExportableV1(oldModel); + } } export class V2DashboardSerializer @@ -390,18 +407,36 @@ export class V2DashboardSerializer getSnapshotUrl() { return this.metadata?.annotations?.[AnnoKeyDashboardSnapshotOriginalUrl]; } + + async makeExportableExternally(s: DashboardScene) { + return await makeExportableV2(this.getSaveModel(s)); + } } -export function getDashboardSceneSerializer(): DashboardSceneSerializerLike; -export function getDashboardSceneSerializer(version: 'v1'): DashboardSceneSerializerLike; +export function getDashboardSceneSerializer(): DashboardSceneSerializerLike< + Dashboard, + DashboardMeta, + Dashboard, + DashboardJson +>; +export function getDashboardSceneSerializer( + version: 'v1' +): DashboardSceneSerializerLike; export function getDashboardSceneSerializer( version: 'v2' -): DashboardSceneSerializerLike['metadata']>; +): DashboardSceneSerializerLike< + DashboardV2Spec, + DashboardWithAccessInfo['metadata'], + DashboardV2Spec, + DashboardV2Spec +>; export function getDashboardSceneSerializer( version?: 'v1' | 'v2' ): DashboardSceneSerializerLike< Dashboard | DashboardV2Spec, - DashboardMeta | DashboardWithAccessInfo['metadata'] + DashboardMeta | DashboardWithAccessInfo['metadata'], + Dashboard | DashboardV2Spec, + DashboardJson | DashboardV2Spec > { if (version === 'v2') { return new V2DashboardSerializer(); diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap index 1f802147429..db3d9c0644a 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -97,7 +97,17 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model }, "description": "Test Description 2", "id": 2, - "links": [], + "links": [ + { + "targetBlank": true, + "title": "Test Link 1", + "url": "http://test1.com", + }, + { + "title": "Test Link 2", + "url": "http://test2.com", + }, + ], "title": "Test Panel 2", "vizConfig": { "kind": "graph", diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index 727964416e3..1f7f9709d67 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -72,6 +72,15 @@ jest.mock('../utils/dashboardSceneGraph', () => { // Return the panel key if it exists, otherwise use panel-1 as default return panel?.state?.key || 'panel-1'; }), + getPanelLinks: jest.fn().mockImplementation(() => { + return new VizPanelLinks({ + rawLinks: [ + { title: 'Test Link 1', url: 'http://test1.com', targetBlank: true }, + { title: 'Test Link 2', url: 'http://test2.com' }, + ], + menu: new VizPanelLinksMenu({}), + }); + }), }, }; }); diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx index dabd2bf4a28..ad37c97ca36 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx @@ -5,7 +5,18 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { SceneComponentProps } from '@grafana/scenes'; -import { Button, ClipboardButton, CodeEditor, Label, Spinner, Stack, Switch, useStyles2 } from '@grafana/ui'; +import { + Alert, + Button, + ClipboardButton, + CodeEditor, + Label, + Spinner, + Stack, + Switch, + TextLink, + useStyles2, +} from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; import { t, Trans } from 'app/core/internationalization'; @@ -26,21 +37,25 @@ export class ExportAsJson extends ShareExportTab { function ExportAsJsonRenderer({ model }: SceneComponentProps) { const styles = useStyles2(getStyles); - const { isSharingExternally } = model.useState(); const dashboardJson = useAsync(async () => { const json = await model.getExportableDashboardJson(); - return JSON.stringify(json, null, 2); + return json; }, [isSharingExternally]); + const stringifiedDashboardJson = JSON.stringify(dashboardJson.value?.json, null, 2); + const hasLibraryPanels = dashboardJson.value?.hasLibraryPanels; + const isV2Dashboard = dashboardJson.value?.json && 'elements' in dashboardJson.value.json; + const showV2LibPanelAlert = isV2Dashboard && isSharingExternally && hasLibraryPanels; + const onClickDownload = async () => { await model.onSaveAsFile(); const message = t('export.json.download-successful_toast_message', 'Your JSON has been downloaded'); dispatch(notifyApp(createSuccessNotification(message))); }; - const switchLabel = t('export.json.export-externally-label', 'Export the dashboard to use in another instance'); + const switchExportLabel = t('export.json.export-externally-label', 'Export the dashboard to use in another instance'); return (
@@ -49,24 +64,48 @@ function ExportAsJsonRenderer({ model }: SceneComponentProps) { Copy or download a JSON file containing the JSON of your dashboard

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

{unableToRetrieveMetricNames}

-

{metricNamesWarning}

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