diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ca9de90ba02..6b40b814064 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -543,6 +543,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /packages/grafana-data/tsconfig.json @grafana/grafana-frontend-platform /packages/grafana-data/test/ @grafana/grafana-frontend-platform /packages/grafana-data/typings/ @grafana/grafana-frontend-platform +/packages/grafana-data/scripts/ @grafana/grafana-frontend-platform /packages/grafana-data/src/**/*logs* @grafana/observability-logs /packages/grafana-data/src/context/plugins/ @grafana/plugins-platform-frontend @@ -658,6 +659,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /packages/grafana-runtime/src/services/LocationService.tsx @grafana/grafana-search-navigate-organise /packages/grafana-runtime/src/services/LocationSrv.ts @grafana/grafana-search-navigate-organise /packages/grafana-runtime/src/services/live.ts @grafana/dashboards-squad +/packages/grafana-runtime/src/services/pluginMeta @grafana/plugins-platform-frontend /packages/grafana-runtime/src/utils/chromeHeaderHeight.ts @grafana/grafana-search-navigate-organise /packages/grafana-runtime/src/utils/DataSourceWithBackend* @grafana/grafana-datasources-core-services /packages/grafana-runtime/src/utils/licensing.ts @grafana/grafana-operator-experience-squad diff --git a/.golangci.yml b/.golangci.yml index 069e88632ff..d7037bf6fac 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -121,6 +121,8 @@ linters: - '**/pkg/tsdb/zipkin/**/*' - '**/pkg/tsdb/jaeger/*' - '**/pkg/tsdb/jaeger/**/*' + - '**/pkg/tsdb/elasticsearch/*' + - '**/pkg/tsdb/elasticsearch/**/*' deny: - pkg: github.com/grafana/grafana/pkg/api desc: Core plugins are not allowed to depend on Grafana core packages diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check.go b/apps/advisor/pkg/app/checks/datasourcecheck/check.go index cae29e181fd..c35c47e45f6 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check.go @@ -28,7 +28,7 @@ type check struct { PluginStore pluginstore.Store PluginContextProvider PluginContextProvider PluginClient plugins.Client - PluginRepo repo.Service + PluginRepo checks.PluginInfoGetter GrafanaVersion string pluginCanBeInstalledCache map[string]bool pluginExistsCacheMu sync.RWMutex @@ -39,7 +39,7 @@ func New( pluginStore pluginstore.Store, pluginContextProvider PluginContextProvider, pluginClient plugins.Client, - pluginRepo repo.Service, + pluginRepo checks.PluginInfoGetter, grafanaVersion string, ) checks.Check { return &check{ diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go b/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go index 1d784f0a544..9b70f5d0896 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go @@ -15,7 +15,7 @@ import ( type missingPluginStep struct { PluginStore pluginstore.Store - PluginRepo repo.Service + PluginRepo checks.PluginInfoGetter GrafanaVersion string } diff --git a/apps/advisor/pkg/app/checks/ifaces.go b/apps/advisor/pkg/app/checks/ifaces.go index 6573253b557..2b205933151 100644 --- a/apps/advisor/pkg/app/checks/ifaces.go +++ b/apps/advisor/pkg/app/checks/ifaces.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana-app-sdk/logging" advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" + "github.com/grafana/grafana/pkg/plugins/repo" ) // Check returns metadata about the check being executed and the list of Steps @@ -37,3 +38,10 @@ type Step interface { // Run executes the step for an item and returns a report Run(ctx context.Context, log logging.Logger, obj *advisorv0alpha1.CheckSpec, item any) ([]advisorv0alpha1.CheckReportFailure, error) } + +// PluginInfoGetter is a minimal interface for retrieving plugin information from a repository. +// It contains only the GetPluginsInfo method used by plugincheck and datasourcecheck. +type PluginInfoGetter interface { + // GetPluginsInfo will return a list of plugins from grafana.com/api/plugins. + GetPluginsInfo(ctx context.Context, options repo.GetPluginsInfoOptions, compatOpts repo.CompatOpts) ([]repo.PluginInfo, error) +} diff --git a/apps/advisor/pkg/app/checks/plugincheck/check.go b/apps/advisor/pkg/app/checks/plugincheck/check.go index 3d261f81b67..00bc293e86c 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check.go @@ -17,7 +17,7 @@ const ( func New( pluginStore pluginstore.Store, - pluginRepo repo.Service, + pluginRepo checks.PluginInfoGetter, updateChecker pluginchecker.PluginUpdateChecker, pluginErrorResolver plugins.ErrorResolver, grafanaVersion string, @@ -33,7 +33,7 @@ func New( type check struct { PluginStore pluginstore.Store - PluginRepo repo.Service + PluginRepo checks.PluginInfoGetter updateChecker pluginchecker.PluginUpdateChecker pluginErrorResolver plugins.ErrorResolver GrafanaVersion string diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json index 716b476f825..40b4fff030e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json @@ -586,6 +586,7 @@ }, "id": -1, "panels": [], + "repeat": "custom_var_tab", "title": "Repeated Tab by \"$custom_var_tab\"", "type": "row" }, @@ -610,8 +611,11 @@ "y": 22 }, "id": 6, + "maxPerRow": 3, "options": {}, "pluginVersion": "12.4.0-19736337744", + "repeat": "custom_var_panel", + "repeatDirection": "h", "targets": [ { "refId": "A" diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json index f915142fd14..ff1e2d42e20 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json @@ -586,6 +586,7 @@ }, "id": -1, "panels": [], + "repeat": "custom_var_tab", "title": "Repeated Tab by \"$custom_var_tab\"", "type": "row" }, @@ -610,8 +611,11 @@ "y": 22 }, "id": 6, + "maxPerRow": 3, "options": {}, "pluginVersion": "12.4.0-19736337744", + "repeat": "custom_var_panel", + "repeatDirection": "h", "targets": [ { "refId": "A" diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go index 857af7ca866..f9f953f965d 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go @@ -439,6 +439,11 @@ func processTabItem(elements map[string]dashv2alpha1.DashboardElement, tab *dash rowPanel["title"] = *tab.Spec.Title } + if tab.Spec.Repeat != nil && tab.Spec.Repeat.Value != "" { + // We only use value here as V1 doesn't support mode + rowPanel["repeat"] = tab.Spec.Repeat.Value + } + rowPanel["gridPos"] = map[string]interface{}{ "x": 0, "y": currentY, @@ -819,6 +824,21 @@ func convertAutoGridLayoutToPanelsWithOffset(elements map[string]dashv2alpha1.Da }, } + // Convert AutoGridRepeatOptions to RepeatOptions if present + // AutoGridRepeatOptions only has mode and value; infer direction and maxPerRow from AutoGrid settings: + // - direction: always "h" (AutoGrid flows horizontally, left-to-right then wraps) + // - maxPerRow: from AutoGrid's maxColumnCount + if item.Spec.Repeat != nil { + directionH := dashv2alpha1.DashboardRepeatOptionsDirectionH + maxPerRow := int64(maxColumnCount) + gridItem.Spec.Repeat = &dashv2alpha1.DashboardRepeatOptions{ + Mode: item.Spec.Repeat.Mode, + Value: item.Spec.Repeat.Value, + Direction: &directionH, + MaxPerRow: &maxPerRow, + } + } + panel, err := convertPanelFromElement(&element, &gridItem) if err != nil { return nil, fmt.Errorf("failed to convert panel %s: %w", item.Spec.Element.Name, err) diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json index 61f092d491e..7fdd474df87 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json @@ -2117,7 +2117,7 @@ } ], "title": "Numeric, no series", - "type": "gauge" + "type": "radialbar" }, { "datasource": { @@ -2183,7 +2183,7 @@ } ], "title": "Non-numeric", - "type": "gauge" + "type": "radialbar" } ], "preload": false, @@ -2201,4 +2201,4 @@ "title": "Panel tests - Gauge (new)", "uid": "panel-tests-gauge-new", "weekStart": "" -} \ No newline at end of file +} diff --git a/apps/plugins/Makefile b/apps/plugins/Makefile index 230bfd4149a..2db266ef19b 100644 --- a/apps/plugins/Makefile +++ b/apps/plugins/Makefile @@ -1,9 +1,16 @@ include ../sdk.mk -.PHONY: generate # Run Grafana App SDK code generation -generate: install-app-sdk update-app-sdk +.PHONY: internal-generate # Run Grafana App SDK code generation +internal-generate: install-app-sdk update-app-sdk @$(APP_SDK_BIN) generate \ --source=./kinds/ \ --gogenpath=./pkg/apis \ --grouping=group \ - --defencoding=none \ No newline at end of file + --defencoding=none + +.PHONY: generate +generate: internal-generate # copy files to packages/grafana-runtime/src/services/pluginMeta/types + rm -f ./packages/grafana-runtime/src/services/pluginMeta/types/*.ts + cp plugin/src/generated/meta/v0alpha1/meta_object_gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts + cp plugin/src/generated/meta/v0alpha1/types.spec.gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts + cp plugin/src/generated/meta/v0alpha1/types.status.gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts \ No newline at end of file diff --git a/apps/plugins/README.md b/apps/plugins/README.md index 7f91dd6ea12..f21fa6701b5 100644 --- a/apps/plugins/README.md +++ b/apps/plugins/README.md @@ -4,8 +4,7 @@ API documentation is available at http://localhost:3000/swagger?api=plugins.graf ## Codegen -- Go: `make generate` -- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md) +- Go and TypeScript: `make generate` ## Plugin sync diff --git a/apps/plugins/kinds/manifest.cue b/apps/plugins/kinds/manifest.cue index f624dc117bc..680a0f7565d 100644 --- a/apps/plugins/kinds/manifest.cue +++ b/apps/plugins/kinds/manifest.cue @@ -11,7 +11,7 @@ manifest: { v0alpha1Version: { served: true codegen: { - ts: {enabled: false} + ts: {enabled: true} go: {enabled: true} } kinds: [ diff --git a/apps/plugins/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts new file mode 100644 index 00000000000..044ec1f4cd8 --- /dev/null +++ b/apps/plugins/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts @@ -0,0 +1,49 @@ +/* + * This file was generated by grafana-app-sdk. DO NOT EDIT. + */ +import { Spec } from './types.spec.gen'; +import { Status } from './types.status.gen'; + +export interface Metadata { + name: string; + namespace: string; + generateName?: string; + selfLink?: string; + uid?: string; + resourceVersion?: string; + generation?: number; + creationTimestamp?: string; + deletionTimestamp?: string; + deletionGracePeriodSeconds?: number; + labels?: Record; + annotations?: Record; + ownerReferences?: OwnerReference[]; + finalizers?: string[]; + managedFields?: ManagedFieldsEntry[]; +} + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export interface ManagedFieldsEntry { + manager?: string; + operation?: string; + apiVersion?: string; + time?: string; + fieldsType?: string; + subresource?: string; +} + +export interface Meta { + kind: string; + apiVersion: string; + metadata: Metadata; + spec: Spec; + status: Status; +} diff --git a/apps/plugins/plugin/src/generated/meta/v0alpha1/types.metadata.gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.metadata.gen.ts new file mode 100644 index 00000000000..4377f3c1d08 --- /dev/null +++ b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.metadata.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +export interface Metadata { + updateTimestamp: string; + createdBy: string; + uid: string; + creationTimestamp: string; + deletionTimestamp?: string; + finalizers: string[]; + resourceVersion: string; + generation: number; + updatedBy: string; + labels: Record; +} + +export const defaultMetadata = (): Metadata => ({ + updateTimestamp: "", + createdBy: "", + uid: "", + creationTimestamp: "", + finalizers: [], + resourceVersion: "", + generation: 0, + updatedBy: "", + labels: {}, +}); + diff --git a/apps/plugins/plugin/src/generated/meta/v0alpha1/types.spec.gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.spec.gen.ts new file mode 100644 index 00000000000..51845e98454 --- /dev/null +++ b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.spec.gen.ts @@ -0,0 +1,278 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// JSON configuration schema for Grafana plugins +// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json +export interface JSONData { + // Unique name of the plugin + id: string; + // Plugin type + type: "app" | "datasource" | "panel" | "renderer"; + // Human-readable name of the plugin + name: string; + // Metadata for the plugin + info: Info; + // Dependency information + dependencies: Dependencies; + // Optional fields + alerting?: boolean; + annotations?: boolean; + autoEnabled?: boolean; + backend?: boolean; + buildMode?: string; + builtIn?: boolean; + category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other"; + enterpriseFeatures?: EnterpriseFeatures; + executable?: string; + hideFromList?: boolean; + // +listType=atomic + includes?: Include[]; + logs?: boolean; + metrics?: boolean; + multiValueFilterOperators?: boolean; + pascalName?: string; + preload?: boolean; + queryOptions?: QueryOptions; + // +listType=atomic + routes?: Route[]; + skipDataQuery?: boolean; + state?: "alpha" | "beta"; + streaming?: boolean; + suggestions?: boolean; + tracing?: boolean; + iam?: IAM; + // +listType=atomic + roles?: Role[]; + extensions?: Extensions; +} + +export const defaultJSONData = (): JSONData => ({ + id: "", + type: "app", + name: "", + info: defaultInfo(), + dependencies: defaultDependencies(), +}); + +export interface Info { + // Required fields + // +listType=set + keywords: string[]; + logos: { + small: string; + large: string; + }; + updated: string; + version: string; + // Optional fields + author?: { + name?: string; + email?: string; + url?: string; + }; + description?: string; + // +listType=atomic + links?: { + name?: string; + url?: string; + }[]; + // +listType=atomic + screenshots?: { + name?: string; + path?: string; + }[]; +} + +export const defaultInfo = (): Info => ({ + keywords: [], + logos: { + small: "", + large: "", +}, + updated: "", + version: "", +}); + +export interface Dependencies { + // Required field + grafanaDependency: string; + // Optional fields + grafanaVersion?: string; + // +listType=set + // +listMapKey=id + plugins?: { + id: string; + type: "app" | "datasource" | "panel"; + name: string; + }[]; + extensions?: { + // +listType=set + exposedComponents?: string[]; + }; +} + +export const defaultDependencies = (): Dependencies => ({ + grafanaDependency: "", +}); + +export interface EnterpriseFeatures { + // Allow additional properties + healthDiagnosticsErrors?: boolean; +} + +export const defaultEnterpriseFeatures = (): EnterpriseFeatures => ({ + healthDiagnosticsErrors: false, +}); + +export interface Include { + uid?: string; + type?: "dashboard" | "page" | "panel" | "datasource"; + name?: string; + component?: string; + role?: "Admin" | "Editor" | "Viewer" | "None"; + action?: string; + path?: string; + addToNav?: boolean; + defaultNav?: boolean; + icon?: string; +} + +export const defaultInclude = (): Include => ({ +}); + +export interface QueryOptions { + maxDataPoints?: boolean; + minInterval?: boolean; + cacheTimeout?: boolean; +} + +export const defaultQueryOptions = (): QueryOptions => ({ +}); + +export interface Route { + path?: string; + method?: string; + url?: string; + reqSignedIn?: boolean; + reqRole?: string; + reqAction?: string; + // +listType=atomic + headers?: string[]; + body?: Record; + tokenAuth?: { + url?: string; + // +listType=set + scopes?: string[]; + params?: Record; + }; + jwtTokenAuth?: { + url?: string; + // +listType=set + scopes?: string[]; + params?: Record; + }; + // +listType=atomic + urlParams?: { + name?: string; + content?: string; + }[]; +} + +export const defaultRoute = (): Route => ({ +}); + +export interface IAM { + // +listType=atomic + permissions?: { + action?: string; + scope?: string; + }[]; +} + +export const defaultIAM = (): IAM => ({ +}); + +export interface Role { + role?: { + name?: string; + description?: string; + // +listType=atomic + permissions?: { + action?: string; + scope?: string; + }[]; + }; + // +listType=set + grants?: string[]; +} + +export const defaultRole = (): Role => ({ +}); + +export interface Extensions { + // +listType=atomic + addedComponents?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=atomic + addedLinks?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=atomic + addedFunctions?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=set + // +listMapKey=id + exposedComponents?: { + id: string; + title?: string; + description?: string; + }[]; + // +listType=set + // +listMapKey=id + extensionPoints?: { + id: string; + title?: string; + description?: string; + }[]; +} + +export const defaultExtensions = (): Extensions => ({ +}); + +export interface Spec { + pluginJson: JSONData; + class: "core" | "external"; + module?: { + path: string; + hash?: string; + loadingStrategy?: "fetch" | "script"; + }; + baseURL?: string; + signature?: { + status: "internal" | "valid" | "invalid" | "modified" | "unsigned"; + type?: "grafana" | "commercial" | "community" | "private" | "private-glob"; + org?: string; + }; + angular?: { + detected: boolean; + }; + translations?: Record; + // +listType=atomic + children?: string[]; +} + +export const defaultSpec = (): Spec => ({ + pluginJson: defaultJSONData(), + class: "core", +}); + diff --git a/apps/plugins/plugin/src/generated/meta/v0alpha1/types.status.gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.status.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface OperatorState { + // lastEvaluation is the ResourceVersion last evaluated + lastEvaluation: string; + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + state: "success" | "in_progress" | "failed"; + // descriptiveState is an optional more descriptive state field which has no requirements on format + descriptiveState?: string; + // details contains any extra information that is operator-specific + details?: Record; +} + +export const defaultOperatorState = (): OperatorState => ({ + lastEvaluation: "", + state: "success", +}); + +export interface Status { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + operatorStates?: Record; + // additionalFields is reserved for future use + additionalFields?: Record; +} + +export const defaultStatus = (): Status => ({ +}); + diff --git a/apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts b/apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts new file mode 100644 index 00000000000..c4e625fc418 --- /dev/null +++ b/apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts @@ -0,0 +1,49 @@ +/* + * This file was generated by grafana-app-sdk. DO NOT EDIT. + */ +import { Spec } from './types.spec.gen'; +import { Status } from './types.status.gen'; + +export interface Metadata { + name: string; + namespace: string; + generateName?: string; + selfLink?: string; + uid?: string; + resourceVersion?: string; + generation?: number; + creationTimestamp?: string; + deletionTimestamp?: string; + deletionGracePeriodSeconds?: number; + labels?: Record; + annotations?: Record; + ownerReferences?: OwnerReference[]; + finalizers?: string[]; + managedFields?: ManagedFieldsEntry[]; +} + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export interface ManagedFieldsEntry { + manager?: string; + operation?: string; + apiVersion?: string; + time?: string; + fieldsType?: string; + subresource?: string; +} + +export interface Plugin { + kind: string; + apiVersion: string; + metadata: Metadata; + spec: Spec; + status: Status; +} diff --git a/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.metadata.gen.ts b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.metadata.gen.ts new file mode 100644 index 00000000000..4377f3c1d08 --- /dev/null +++ b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.metadata.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +export interface Metadata { + updateTimestamp: string; + createdBy: string; + uid: string; + creationTimestamp: string; + deletionTimestamp?: string; + finalizers: string[]; + resourceVersion: string; + generation: number; + updatedBy: string; + labels: Record; +} + +export const defaultMetadata = (): Metadata => ({ + updateTimestamp: "", + createdBy: "", + uid: "", + creationTimestamp: "", + finalizers: [], + resourceVersion: "", + generation: 0, + updatedBy: "", + labels: {}, +}); + diff --git a/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.spec.gen.ts b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.spec.gen.ts new file mode 100644 index 00000000000..6b7824b8941 --- /dev/null +++ b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.spec.gen.ts @@ -0,0 +1,13 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface Spec { + id: string; + version: string; + url?: string; +} + +export const defaultSpec = (): Spec => ({ + id: "", + version: "", +}); + diff --git a/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.status.gen.ts b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.status.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface OperatorState { + // lastEvaluation is the ResourceVersion last evaluated + lastEvaluation: string; + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + state: "success" | "in_progress" | "failed"; + // descriptiveState is an optional more descriptive state field which has no requirements on format + descriptiveState?: string; + // details contains any extra information that is operator-specific + details?: Record; +} + +export const defaultOperatorState = (): OperatorState => ({ + lastEvaluation: "", + state: "success", +}); + +export interface Status { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + operatorStates?: Record; + // additionalFields is reserved for future use + additionalFields?: Record; +} + +export const defaultStatus = (): Status => ({ +}); + diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json index ff69226fdf5..c44353b7df2 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json @@ -2067,7 +2067,7 @@ } ], "title": "Numeric, no series", - "type": "gauge" + "type": "radialbar" }, { "datasource": { @@ -2131,7 +2131,7 @@ } ], "title": "Non-numeric", - "type": "gauge" + "type": "radialbar" } ], "preload": false, diff --git a/docs/sources/as-code/observability-as-code/_index.md b/docs/sources/as-code/observability-as-code/_index.md index 338d9235255..92441bd5110 100644 --- a/docs/sources/as-code/observability-as-code/_index.md +++ b/docs/sources/as-code/observability-as-code/_index.md @@ -25,10 +25,6 @@ cards: height: 24 href: ./foundation-sdk/ description: The Grafana Foundation SDK is a set of tools, types, and libraries that let you define Grafana dashboards and resources using familiar programming languages like Go, TypeScript, Python, Java, and PHP. Use it in conjunction with `grafanactl` to push your programmatically generated resources. - - title: JSON schema v2 - height: 24 - href: ./schema-v2/ - description: Grafana dashboards are represented as JSON objects that store metadata, panels, variables, and settings. Observability as Code works with all versions of the JSON model, and it's fully compatible with version 2. - title: Git Sync (private preview) height: 24 href: ./provision-resources/intro-git-sync/ @@ -68,7 +64,7 @@ Historically, managing Grafana as code involved various community and Grafana La - This approach requires handling HTTP requests and responses but provides complete control over resource management. - `grafanactl`, Git Sync, and the Foundation SDK are all built on top of these APIs. -- To understand Dashboard Schemas accepted by the APIs, refer to the [JSON models documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/). +- To understand Dashboard Schemas accepted by the APIs, refer to the [JSON models documentation](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/view-dashboard-json-model/index.md). ## Explore diff --git a/docs/sources/as-code/observability-as-code/schema-v2/_index.md b/docs/sources/as-code/observability-as-code/schema-v2/_index.md deleted file mode 100644 index 65c73a49cbe..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/_index.md +++ /dev/null @@ -1,243 +0,0 @@ ---- -description: A reference for the JSON dashboard schemas used with Observability as Code, including the experimental V2 schema. -keywords: - - configuration - - as code - - dashboards - - git integration - - git sync - - github -labels: - products: - - cloud - - enterprise - - oss -title: JSON schema v2 -weight: 500 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/ -aliases: - - ../../observability-as-code/schema-v2/ # /docs/grafana/next/observability-as-code/schema-v2/ ---- - -# Dashboard JSON schema v2 - -{{< admonition type="caution" >}} - -Dashboard JSON schema v2 is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). - -**Do not enable this feature in production environments as it may result in the irreversible loss of data.** - -{{< /admonition >}} - -Grafana dashboards are represented as JSON objects that store metadata, panels, variables, and settings. - -Observability as Code works with all versions of the JSON model, and it's fully compatible with version 2. - -## Before you begin - -Schema v2 is automatically enabled with the Dynamic Dashboards feature toggle. -To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). -It also requires the new dashboards API feature toggle, `kubernetesDashboards`, to be enabled as well. - -For more information on how dashboards behave depending on your feature flag configuration, refer to [Notes and limitations](#notes-and-limitations). - -## Accessing the JSON Model - -To view the JSON representation of a dashboard: - -1. Toggle on the edit mode switch in the top-right corner of the dashboard. -1. Click the gear icon in the top navigation bar to go to **Settings**. -1. Select the **JSON Model** tab. -1. Copy or edit the JSON structure as needed. - -## JSON fields - -```json -{ - "annotations": [], - "cursorSync": "Off", - "editable": true, - "elements": {}, - "layout": { - "kind": GridLayout, // Can also be AutoGridLayout, RowsLayout, or TabsLayout - "spec": { - "items": [] - } - }, - "links": [], - "liveNow": false, - "preload": false, - "tags": [], // Tags associated with the dashboard. - "timeSettings": { - "autoRefresh": "", - "autoRefreshIntervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "fiscalYearStartMonth": 0, - "from": "now-6h", - "hideTimepicker": false, - "timezone": "browser", - "to": "now" - }, - "title": "", - "variables": [] -}, -``` - -The dashboard JSON sample shown uses the default `GridLayoutKind`. -The JSON in a new dashboard for the other three layout options, `AutoGridLayout`, `RowsLayout`, and `TabsLayout`, are as follows: - -**`AutoGridLayout`** - -```json - "layout": { - "kind": "AutoGridLayout", - "spec": { - "columnWidthMode": "standard", - "items": [], - "fillScreen": false, - "maxColumnCount": 3, - "rowHeightMode": "standard" - } - }, -``` - -**`RowsLayout`** - -```json - "layout": { - "kind": "RowsLayout", - "spec": { - "rows": [] - }, -``` - -**`TabsLayout`** - -```json - "layout": { - "kind": "TabsLayout", - "spec": { - "tabs": [] - }, -``` - -### `DashboardSpec` - -The following table explains the usage of the dashboard JSON fields. -The table includes default and other fields: - - - -| Name | Usage | -| ------------ | ------------------------------------------------------------------------- | -| annotations | Contains the list of annotations that are associated with the dashboard. | -| cursorSync | Dashboard cursor sync behavior.
  • `Off` - No shared crosshair or tooltip (default)
  • `Crosshair` - Shared crosshair
  • `Tooltip` - Shared crosshair and shared tooltip
| -| editable | bool. Whether or not a dashboard is editable. | -| elements | Contains the list of elements included in the dashboard. Supported dashboard elements are: PanelKind and LibraryPanelKind. | -| layout | The dashboard layout. Supported layouts are:
  • GridLayoutKind
  • AutoGridLayoutKind
  • RowsLayoutKind
  • TabsLayoutKind
| -| links | Links with references to other dashboards or external websites. | -| liveNow | bool. When set to `true`, the dashboard redraws panels at an interval matching the pixel width. This keeps data "moving left" regardless of the query refresh rate. This setting helps avoid dashboards presenting stale live data. | -| preload | bool. When set to `true`, the dashboard loads all panels when the dashboard is loaded. | -| tags | Contains the list of tags associated with dashboard. | -| timeSettings | All time settings for the dashboard. | -| title | Title of the dashboard. | -| variables | Contains the list of configured template variables. | - - - -### `annotations` - -The configuration for the list of annotations that are associated with the dashboard. -For the JSON and field usage notes, refer to the [annotations schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/annotations-schema/). - -### `elements` - -Dashboards can contain the following elements: - -- [PanelKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/panel-schema/) -- [LibraryPanelKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/librarypanel-schema/) - -### `layout` - -Dashboards can have four layout options: - -- [GridLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#gridlayoutkind) -- [AutoGridLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#autogridlayoutkind) -- [RowsLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#rowslayoutkind) -- [TabsLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#tabslayoutkind) - -For the JSON and field usage notes about each of these, refer to the [layout schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/). - -### `links` - -The configuration for links with references to other dashboards or external websites. - -For the JSON and field usage notes, refer to the [links schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/links-schema/). - -### `tags` - -Tags associated with the dashboard. Each tag can be up to 50 characters long. - -` [...string]` - -### `timesettings` - -The `TimeSettingsSpec` defines the default time configuration for the time picker and the refresh picker for the specific dashboard. -For the JSON and field usage notes about the `TimeSettingsSpec`, refer to the [timesettings schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/timesettings-schema/). - -### `variables` - -The `variables` schema defines which variables are used in the dashboard. - -There are eight variables types: - -- QueryVariableKind -- TextVariableKind -- ConstantVariableKind -- DatasourceVariableKind -- IntervalVariableKind -- CustomVariableKind -- GroupByVariableKind -- AdhocVariableKind - -For the JSON and field usage notes about the `variables` spec, refer to the [variables schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/variables-schema/). - -## Notes and limitations - -### Existing dashboards - -With schema v2 enabled, you can still open and view your pre-existing dashboards. -Upon saving, they’ll be updated to the new schema where you can take advantage of the new features and functionalities. - -### Dashboard behavior with disabled feature flags - -If you disable the Dynamic dashboards or `kubernetesDashboards` feature flags, you should be aware of how dashboards will behave. - -#### Disable Dynamic dashboards - -If the Dynamic dashboards feature toggle is disabled, depending on how the dashboard was built, it will behave differently: - -- Dashboards built on the new schema through the UI - View only -- Dashboards built on Schema v1 - View and edit -- Dashboards built on the new schema by way of Terraform or the CLI - View and edit -- Provisioned dashboards built on the new schema - View and edit, but the edit experience will be the old experience - -#### Disable Dynamic dashboards and `kubernetesDashboards` - -You’ll be unable to view or edit dashboards created or updated in the new schema. - -### Import and export - -From the UI, dashboards created on schema v2 can be exported and imported like other dashboards. -When you export them to use in another instance, references of data sources are not persisted but data source types are. -You’ll have the option to select the data source of your choice in the import UI. diff --git a/docs/sources/as-code/observability-as-code/schema-v2/annotations-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/annotations-schema.md deleted file mode 100644 index e99e7c2cce6..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/annotations-schema.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -description: A reference for the JSON annotations schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - annotations -labels: - products: - - cloud - - enterprise - - oss -menuTitle: annotations schema -title: annotations -weight: 100 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/annotations-schema/ -aliases: - - ../../../observability-as-code/schema-v2/annotations-schema/ # /docs/grafana/next/observability-as-code/schema-v2/annotations-schema/ ---- - -# `annotations` - -The configuration for the list of annotations that are associated with the dashboard. - -```json - "annotations": [ - { - "kind": "AnnotationQuery", - "spec": { - "builtIn": false, - "datasource": { - "type": "", - "uid": "" - }, - "enable": false, - "hide": false, - "iconColor": "", - "name": "" - } - } - ], -``` - -`AnnotationsQueryKind` consists of: - -- kind: "AnnotationQuery" -- spec: [AnnotationQuerySpec](#annotationqueryspec) - -## `AnnotationQuerySpec` - -| Name | Type/Definition | -| ---------- | ----------------------------------------------------------------- | -| datasource | [`DataSourceRef`](#datasourceref) | -| query | [`DataQueryKind`](#dataquerykind) | -| enable | bool | -| hide | bool | -| iconColor | string | -| name | string | -| builtIn | bool. Default is `false`. | -| filter | [`AnnotationPanelFilter`](#annotationpanelfilter) | -| options | `[string]`: A catch-all field for datasource-specific properties. | - -### `DataSourceRef` - -| Name | Usage | -| ----- | ---------------------------------- | -| type? | string. The plugin type-id. | -| uid? | The specific data source instance. | - -### `DataQueryKind` - -| Name | Type | -| ---- | ------ | -| kind | string | -| spec | string | - -### `AnnotationPanelFilter` - -| Name | Type/Definition | -| -------- | ------------------------------------------------------------------------------ | -| exclude? | bool. Should the specified panels be included or excluded. Default is `false`. | -| ids | `[...uint8]`. Panel IDs that should be included or excluded. | diff --git a/docs/sources/as-code/observability-as-code/schema-v2/layout-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/layout-schema.md deleted file mode 100644 index ca31417bbcf..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/layout-schema.md +++ /dev/null @@ -1,339 +0,0 @@ ---- -description: A reference for the JSON layout schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - layout -labels: - products: - - cloud - - enterprise - - oss -menuTitle: layout schema -title: layout -weight: 400 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/layout-schema/ -aliases: - - ../../../observability-as-code/schema-v2/layout-schema/ # /docs/grafana/next/observability-as-code/schema-v2/layout-schema/ ---- - -# `layout` - -There are four layout options offering two types of panel control: - -**Panel layout options** - -These options control the size and position of panels: - -- [GridLayoutKind](#gridlayoutkind) - Corresponds to the **Custom** option in the UI. You define panel size and panel positions using x- and y- settings. -- [AutoGridLayoutKind](#autogridlayoutkind) - Corresponds to the **Auto grid** option in the UI. Panel size and position are automatically set based on column and row parameters. - -**Panel grouping options** - -These options control the grouping of panels: - -- [RowsLayoutKind](#rowslayoutkind) - Groups panels into rows. -- [TabsLayoutKind](#tabslayoutkind) - Groups panels into tabs. - -## `GridLayoutKind` - -The grid layout allows you to manually size and position grid items by setting the height, width, x, and y of each item. -This layout corresponds to the **Custom** option in the UI. - -Following is the JSON for a default grid layout, a grid layout item, and a grid layout row: - -```json - "kind": "GridLayout", - "spec": { - "items": [ - { - "kind": "GridLayoutItem", - "spec": { - "element": {...}, - "height": 0, - "width": 0, - "x": 0, - "y": 0 - } - }, - { - "kind": "GridLayoutRow", - "spec": { - "collapsed": false, - "elements": [], - "title": "", - "y": 0 - } - }, - ] - } -``` - -`GridLayoutKind` consists of: - -- kind: "GridLayout" -- spec: GridLayoutSpec - - items: GridLayoutItemKind` or GridLayoutRowKind` - - GridLayoutItemKind - - kind: "GridLayoutItem" - - spec: [GridLayoutItemSpec](#gridlayoutitemspec) - - GridLayoutRowKind - - kind: "GridLayoutRow" - - spec: [GridLayoutRowSpec](#gridlayoutrowspec) - -### `GridLayoutItemSpec` - -The following table explains the usage of the grid layout item JSON fields: - -| Name | Usage | -| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| x | integer. Position of the item x-axis. | -| y | integer. Position of the item y-axis. | -| width | Width of the item in pixels. | -| height | Height of the item in pixels. | -| element | `ElementReference`. Reference to a [`PanelKind`](https://grafana.com/docs/grafana//observability-as-code/schema-v2/panel-schema/) from `dashboard.spec.elements` expressed as JSON Schema reference. | -| repeat? | [RepeatOptions](#repeatoptions). Configured repeat options, if any | - -#### `RepeatOptions` - -The following table explains the usage of the repeat option JSON fields: - -| Name | Usage | -| ---------- | ---------------------------------------------------- | -| mode | `RepeatMode` - "variable" | -| value | string | -| direction? | Options are `h` for horizontal and `v` for vertical. | -| maxPerRow? | integer | - -### `GridLayoutRowSpec` - -The following table explains the usage of the grid layout row JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| y | integer. Position of the row y-axis | -| collapsed | bool. Whether or not the row is collapsed | -| title | Row title | -| elements | [`[...GridLayoutItemKind]`](#gridlayoutitemspec). Grid items in the row will have their y value be relative to the row's 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 height of 1) in the dashboard. | -| repeat? | [RowRepeatOptions](#rowrepeatoptions) Configured row repeat options, if any

| - - - -#### `RowRepeatOptions` - -| Name | Usage | -| ----- | ------------------------- | -| mode | `RepeatMode` - "variable" | -| value | string | - -## `AutoGridLayoutKind` - -With an auto grid, Grafana sizes and positions your panels for the best fit based on the column and row constraints that you set. -This layout corresponds to the **Auto grid** option in the UI. - -Following is the JSON for a default auto grid layout and a grid layout item: - - - -```json - "kind": "AutoGridLayout", - "spec": { - "columnWidthMode": "standard", - "fillScreen": false, - "items": [ - { - "kind": "AutoGridLayoutItem", - "spec": { - "element": {...}, - } - } - ], - "maxColumnCount": 3, - "rowHeightMode": "standard" - } -``` - -`AutoGridLayoutKind` consists of: - -- kind: "AutoGridLayout" -- spec: [AutoGridLayoutSpec](#autogridlayoutspec) - -### `AutoGridLayoutSpec` - -The following table explains the usage of the auto grid layout JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| maxColumnCount? | number. Default is `3`. | -| columnWidthMode | Options are: `narrow`, `standard`, `wide`, and `custom`. Default is `standard`. | -| columnWidth? | number | -| rowHeightMode | Options are: `short`, `standard`, `tall`, and `custom`. Default is `standard`. | -| rowHeight? | number | -| fillScreen? | bool. Default is `false`. | -| items | `AutoGridLayoutItemKind`. Consists of:
  • kind: "AutoGridLayoutItem"
  • spec: [AutoGridLayoutItemSpec](#autogridlayoutitemspec)
| - - - -#### `AutoGridLayoutItemSpec` - -The following table explains the usage of the auto grid layout item JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| element | `ElementReference`. Reference to a [`PanelKind`](https://grafana.com/docs/grafana//observability-as-code/schema-v2/panel-schema/) from `dashboard.spec.elements` expressed as JSON Schema reference. | -| repeat? | [AutoGridRepeatOptions](#autogridrepeatoptions). Configured repeat options, if any. | -| conditionalRendering? | `ConditionalRenderingGroupKind`. Rules for hiding or showing panels, if any. Consists of:
  • kind: "ConditionalRenderingGroup"
  • spec: [ConditionalRenderingGroupSpec](#conditionalrenderinggroupspec)
| - - - -##### `AutoGridRepeatOptions` - -The following table explains the usage of the auto grid repeat option JSON fields: - -| Name | Usage | -| ----- | ------------------------- | -| mode | `RepeatMode` - "variable" | -| value | String | - -##### `ConditionalRenderingGroupSpec` - - - -| Name | Usage | -| ---- | ----- | -| visibility | Options are `show` and `hide` | -| condition | Options are `and` and `or` | -| items | Options are:
  • ConditionalRenderingVariableKind
    • kind: "ConditionalRenderingVariable"
    • spec: [ConditionalRenderingVariableSpec](#conditionalrenderingvariablespec)
  • ConditionalRenderingDataKind
    • kind: "ConditionalRenderingData"
    • spec: [ConditionalRenderingDataSpec](#conditionalrenderingdataspec)
  • ConditionalRenderingTimeRangeSizeKind
    • kind: "ConditionalRenderingTimeRangeSize"
    • spec: [ConditionalRenderingTimeRangeSizeSpec](#conditionalrenderingtimerangesizespec)
| - - - -###### `ConditionalRenderingVariableSpec` - -| Name | Usage | -| -------- | ------------------------------------ | -| variable | string | -| operator | Options are `equals` and `notEquals` | -| value | string | - -###### `ConditionalRenderingDataSpec` - -| Name | Type | -| ----- | ---- | -| value | bool | - -###### `ConditionalRenderingTimeRangeSizeSpec` - -| Name | Type | -| ----- | ------ | -| value | string | - -## `RowsLayoutKind` - -The `RowsLayoutKind` is one of two options that you can use to group panels. -You can nest any other kind of layout inside a layout row. -Rows can also be nested in auto grids or tabs. - -Following is the JSON for a default rows layout row: - -```json - "kind": "RowsLayout", - "spec": { - "rows": [ - { - "kind": "RowsLayoutRow", - "spec": { - "layout": { - "kind": "GridLayout", // Can also be AutoGridLayout or TabsLayout - "spec": {...} - }, - "title": "" - } - } - ] - } -``` - -`RowsLayoutKind` consists of: - -- kind: RowsLayout -- spec: RowsLayoutSpec - - rows: RowsLayoutRowKind - - kind: RowsLayoutRow - - spec: [RowsLayoutRowSpec](#rowslayoutrowspec) - -### `RowsLayoutRowSpec` - -The following table explains the usage of the rows layout row JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| title? | Title of the row. | -| collapse | bool. Whether or not the row is collapsed. | -| hideHeader? | bool. Whether the row header is hidden or shown. | -| fullScreen? | bool. Whether or not the row takes up the full screen. | -| conditionalRendering? | `ConditionalRenderingGroupKind`. Rules for hiding or showing rows, if any. Consists of:
  • kind: "ConditionalRenderingGroup"
  • spec: [ConditionalRenderingGroupSpec](#conditionalrenderinggroupspec)
| -| repeat? | [RowRepeatOptions](#rowrepeatoptions). Configured repeat options, if any. | -| layout | Supported layouts are:
  • [GridLayoutKind](#gridlayoutkind)
  • [RowsLayoutKind](#rowslayoutkind)
  • [AutoGridLayoutKind](#autogridlayoutkind)
  • [TabsLayoutKind](#tabslayoutkind)
| - - - -## `TabsLayoutKind` - -The `TabsLayoutKind` is one of two options that you can use to group panels. -You can nest any other kind of layout inside a tab. -Tabs can also be nested in auto grids or rows. - -Following is the JSON for a default tabs layout tab and a tab: - -```json - "kind": "TabsLayout", - "spec": { - "tabs": [ - { - "kind": "TabsLayoutTab", - "spec": { - "layout": { - "kind": "GridLayout", // Can also be AutoGridLayout or RowsLayout - "spec": {...} - }, - "title": "New tab" - } - } - ] - } -``` - -`TabsLayoutKind` consists of: - -- kind: TabsLayout - - spec: TabsLayoutSpec - - tabs: TabsLayoutTabKind - - kind: TabsLayoutTab - - spec: [TabsLayoutTabSpec](#tabslayouttabspec) - -### `TabsLayoutTabSpec` - -The following table explains the usage of the tabs layout tab JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| title? | The title of the tab. | -| layout | Supported layouts are:
  • [GridLayoutKind](#gridlayoutkind)
  • [RowsLayoutKind](#rowslayoutkind)
  • [AutoGridLayoutKind](#autogridlayoutkind)
  • [TabsLayoutKind](#tabslayoutkind)
| -| conditionalRendering? | `ConditionalRenderingGroupKind`. Rules for hiding or showing panels, if any. Consists of:
  • kind: "ConditionalRenderingGroup"
  • spec: [ConditionalRenderingGroupSpec](#conditionalrenderinggroupspec)
| - - diff --git a/docs/sources/as-code/observability-as-code/schema-v2/librarypanel-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/librarypanel-schema.md deleted file mode 100644 index 45715e15b15..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/librarypanel-schema.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -description: A reference for the JSON library panel schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - library panel -labels: - products: - - cloud - - enterprise - - oss -menuTitle: LibraryPanelKind schema -title: LibraryPanelKind -weight: 300 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/librarypanel-schema/ -aliases: - - ../../../observability-as-code/schema-v2/librarypanel-schema/ # /docs/grafana/next/observability-as-code/schema-v2/librarypanel-schema/ ---- - -# `LibraryPanelKind` - -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. - -Following is the default library panel element JSON: - -```json - "kind": "LibraryPanel", - "spec": { - "id": 0, - "libraryPanel": { - name: "", - uid: "", - } - "title": "" - } -``` - -The `LibraryPanelKind` consists of: - -- kind: "LibraryPanel" -- spec: [LibraryPanelKindSpec](#librarypanelkindspec) - - libraryPanel: [LibraryPanelRef](#librarypanelref) - -## `LibraryPanelKindSpec` - -The following table explains the usage of the library panel element JSON fields: - -| Name | Usage | -| ------------ | ------------------------------------------------ | -| id | Panel ID for the library panel in the dashboard. | -| libraryPanel | [`LibraryPanelRef`](#librarypanelref) | -| title | Title for the library panel in the dashboard. | - -### `LibraryPanelRef` - -The following table explains the usage of the library panel reference JSON fields: - -| Name | Usage | -| ---- | ------------------ | -| name | Library panel name | -| uid | Library panel uid | diff --git a/docs/sources/as-code/observability-as-code/schema-v2/links-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/links-schema.md deleted file mode 100644 index 0ddc50376de..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/links-schema.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -description: A reference for the JSON links schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - links -labels: - products: - - cloud - - enterprise - - oss -menuTitle: links schema -title: links -weight: 500 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/links-schema/ -aliases: - - ../../../observability-as-code/schema-v2/links-schema/ # /docs/grafana/next/observability-as-code/schema-v2/links-schema/ ---- - -# `links` - -The `links` schema is the configuration for links with references to other dashboards or external websites. -Following are the default JSON fields: - -```json - "links": [ - { - "asDropdown": false, - "icon": "", - "includeVars": false, - "keepTime": false, - "tags": [], - "targetBlank": false, - "title": "", - "tooltip": "", - "type": "link", - }, - ], -``` - -## `DashboardLink` - -The following table explains the usage of the dashboard link JSON fields. -The table includes default and other fields: - - - -| Name | Usage | -| ----------- | --------------------------------------- | -| title | string. Title to display with the link. | -| type | `DashboardLinkType`. Link type. Accepted values are:
  • dashboards - To refer to another dashboard
  • link - To refer to an external resource
| -| icon | string. Icon name to be displayed with the link. | -| tooltip | string. Tooltip to display when the user hovers their mouse over it. | -| url? | string. Link URL. Only required/valid if the type is link. | -| tags | string. List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards. | -| asDropdown | bool. 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. Default is `false`. | -| targetBlank | bool. If true, the link will be opened in a new tab. Default is `false`. | -| includeVars | bool. If true, includes current template variables values in the link as query params. Default is `false`. | -| keepTime | bool. If true, includes current time range in the link as query params. Default is `false`. | -| placement? | string. Use placement to display the link somewhere else on the dashboard other than above the visualizations. Use the `inControlsMenu` parameter to render the link in the dashboard controls dropdown menu. | - - diff --git a/docs/sources/as-code/observability-as-code/schema-v2/panel-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/panel-schema.md deleted file mode 100644 index 088ab8eebf4..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/panel-schema.md +++ /dev/null @@ -1,305 +0,0 @@ ---- -description: A reference for the JSON panel schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - panels -labels: - products: - - cloud - - enterprise - - oss -menuTitle: PanelKind schema -title: PanelKind -weight: 200 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/panel-schema/ -aliases: - - ../../../observability-as-code/schema-v2/panel-schema/ # /docs/grafana/next/observability-as-code/schema-v2/panel-schema/ ---- - -# `PanelKind` - -The panel element contains all the information about the panel including the visualization type, panel and visualization configuration, queries, and transformations. -There's a panel element for each panel contained in the dashboard. - -Following is the default panel element JSON: - -```json - "kind": "Panel", - "spec": { - "data": { - "kind": "QueryGroup", - "spec": {...}, - "description": "", - "id": 0, - "links": [], - "title": "", - "vizConfig": { - "kind": "", - "spec": {...}, - } - } -``` - -The `PanelKind` consists of: - -- kind: "Panel" -- spec: [PanelSpec](#panelspec) - -## `PanelSpec` - -The following table explains the usage of the panel element JSON fields: - - - -| Name | Usage | -| ------------ | --------------------------------------------------------------------- | -| data | `QueryGroupKind`, which includes queries and transformations. Consists of:
  • kind: "QueryGroup"
  • spec: [QueryGroupSpec](#querygroupspec)
| -| description | The panel description. | -| id | The panel ID. | -| links | Links with references to other dashboards or external websites. | -| title | The panel title. | -| vizConfig | `VizConfigKind`. Includes visualization type, field configuration options, and all other visualization options. Consists of:
  • kind: string. Plugin ID.
  • spec: [VizConfigSpec](#vizconfigspec)
| -| transparent? | bool. Controls whether or not the panel background is transparent. | - - - -### `QueryGroupSpec` - - - -| Name | Usage | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| queries | `PanelQueryKind`. Consists of:
  • kind: PanelQuery
  • spec: [PanelQuerySpec](#panelqueryspec)
| -| transformations | `TransformationKind`. Consists of:
  • kind: string. The transformation ID.
  • spec: [DataTransformerConfig](#datatransformerconfig)
| -| queryOptions | [`QueryOptionsSpec`](#queryoptionsspec) | - - - -#### `PanelQuerySpec` - -| Name | Usage | -| ----------- | --------------------------------- | -| query | [`DataQueryKind`](#dataquerykind) | -| datasource? | [`DataSourceRef`](#datasourceref) | - -##### `DataQueryKind` - -| Name | Type | -| ---- | ------ | -| kind | string | -| spec | string | - -##### `DataSourceRef` - -| Name | Usage | -| ----- | ---------------------------------- | -| type? | string. The plugin type-id. | -| uid? | The specific data source instance. | - -#### `DataTransformerConfig` - -Transformations allow you 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, or use the output of one transformation as the input to another transformation. - - - -| Name | Usage | -| --------- | ------------------------------------------- | -| id | string. Unique identifier of transformer. | -| disabled? | bool. Disabled transformations are skipped. | -| filter? | [`MatcherConfig`](#matcherconfig). Optional frame matcher. When missing it will be applied to all results. | -| topic? | `DataTopic`. Where to pull `DataFrames` from as input to transformation. Options are: `series`, `annotations`, and `alertStates`. | -| options | Options to be passed to the transformer. Valid options depend on the transformer id. | - - - -##### `MatcherConfig` - -Matcher is a predicate configuration. -Based on the configuration a set of field or values, it's filtered to apply an override or transformation. -It comes with in id (to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. - -| Name | Usage | -| -------- | -------------------------------------------------------------------------------------- | -| id | string. The matcher id. This is used to find the matcher implementation from registry. | -| options? | The matcher options. This is specific to the matcher implementation. | - -#### `QueryOptionsSpec` - -| Name | Type | -| ----------------- | ------- | -| timeFrom? | string | -| maxDataPoints? | integer | -| timeShift? | string | -| queryCachingTTL? | integer | -| interval? | string | -| cacheTimeout? | string | -| hideTimeOverride? | bool | - -### `VizConfigSpec` - -| Name | Type/Definition | -| ------------- | --------------------------------------- | -| pluginVersion | string | -| options | string | -| fieldConfig | [FieldConfigSource](#fieldconfigsource) | - -#### `FieldConfigSource` - -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. - - - -| Name | Type/Definition | -| ---------- | ------------------------------------- | -| defaults | [`FieldConfig`](#fieldconfig). Defaults are the options applied to all fields. | -| overrides | The options applied to specific fields overriding the defaults. | -| matcher | [`MatcherConfig`](#matcherconfig). Optional frame matcher. When missing it will be applied to all results. | -| properties | `DynamicConfigValue`. Consists of:
  • `id` - string
  • value?
| - - - -##### `FieldConfig` - - - -| Name | Type/Definition | -| ------------------ | --------------------------------------- | -| displayName? | string. The display value for this field. This supports template variables where empty is auto. | -| displayNameFromDS? | string. This can be used by data sources that return an explicit naming structure for values and labels. When this property is configured, this value is used rather than the default naming strategy. | -| description? | string. Human readable field metadata. | -| path? | string. An explicit path to the field in the data source. 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 data source scope, and may be used to update the results. | -| writeable? | bool. True if the data source can write a value to the path. Auth/authz are supported separately. | -| filterable? | bool. True if the data source field supports ad-hoc filters. | -| unit? | string. Unit a field should use. The unit you select is applied to all fields except time. You can use the unit's ID available 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 units, 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`. 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.
| -| decimals? | number. 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`. | -| min? | number. The minimum value used in percentage threshold calculations. Leave empty for auto calculation based on all series and fields. | -| max? | number. The maximum value used in percentage threshold calculations. Leave empty for auto calculation based on all series and fields. | -| mappings? | `[...ValueMapping]`. Convert input values into a display string. Options are: [`ValueMap`](#valuemap), [`RangeMap`](#rangemap), [`RegexMap`](#rangemap), [`SpecialValueMap`](#specialvaluemap). | -| thresholds? | `ThresholdsConfig`. Map numeric values to states. Consists of:
  • `mode` - `ThresholdsMode`. Options are: `absolute` and `percentage`.
  • `steps` - `[...Threshold]`
| -| color? | [`FieldColor`](#fieldcolor). Panel color configuration. | -| links? | `[...]`. The behavior when clicking a result. | -| noValue? | string. Alternative to an empty string. | -| custom? | `{...}`. Specified by the `FieldConfig` field in panel plugin schemas. | - - - -###### `ValueMap` - -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. - - - -| Name | Usage | -| ------- | -------- | -| type | `MappingType` & "value". `MappingType` options are: `value`, `range`, `regex`, and `special`. | -| options | string. [`ValueMappingResult`](#valuemappingresult). Map with ``: `ValueMappingResult`. For example: `{ "10": { text: "Perfection!", color: "green" } }`. | - - - -###### `RangeMap` - -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. - - - -| Name | Usage | -| ------- | ---------------------------------------------------------------------------------------------------- | -| type | `MappingType` & "range". `MappingType` options are: `value`, `range`, `regex`, and `special`. | -| options | Range to match against and the result to apply when the value is within the range. Spec:
  • `from` - `float64` or `null`. Min value of the range. It can be null which means `-Infinity`.
  • `to` - `float64` or `null`. Max value of the range. It can be null which means `+Infinity`.
  • `result` - [`ValueMappingResult`](#valuemappingresult) | - - - -###### `RegexMap` - -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. - - - -| Name | Usage | -| ------- | --------------------------------------------------------------------------------------------- | -| type | `MappingType` & "regex". `MappingType` options are: `value`, `range`, `regex`, and `special`. | -| options | Regular expression to match against and the result to apply when the value matches the regex. Spec:
    • `pattern` - string. Regular expression to match against.
    • `result` - [`ValueMappingResult`](#valuemappingresult) | - - - -###### `SpecialValueMap` - -Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. -See `SpecialValueMatch` in the following table to see the list of special values. -For example, you can configure a special value mapping so that null values appear as N/A. - - - -| Name | Usage | -| ------- | ----------------------------------------------------------------------------------------------- | -| type | `MappingType` & "special". `MappingType` options are: `value`, `range`, `regex`, and `special`. | -| options | Spec:
      • `match` - `SpecialValueMatch`. Special value to match against. Types are:
        • true
        • false
        • null
        • nan
        • empty
      • `result` - [`ValueMappingResult`](#valuemappingresult) | - - - -###### `ValueMappingResult` - -Result used as replacement with text and color when the value matches. - -| Name | Usage | -| ----- | ----------------------------------------------------------------------------- | -| text | string. Text to display when the value matches. | -| color | string. Color to use when the value matches. | -| icon | string. Icon to display when the value matches. Only specific visualizations. | -| index | int32. Position in the mapping array. Only used internally. | - -###### `FieldColor` - -Map a field to a color. - - - -| Name | Usage | -| ----------- | -------------------------------------------------------------------- | -| mode | [`FieldColorModeId`](#fieldcolormodeid). The main color scheme mode. | -| FixedColor? | string. The fixed color value for fixed or shades color modes. | -| seriesBy? | `FieldColorSeriesByMode`. Some visualizations need to know how to assign a series color from by value color schemes. 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. Options are: `min`, `max`, and `last`. | - - - -###### `FieldColorModeId` - -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: - - - -| Name | Description | -| --- | ---- | -| 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 | Continuous 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. | - - diff --git a/docs/sources/as-code/observability-as-code/schema-v2/timesettings-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/timesettings-schema.md deleted file mode 100644 index 8db14212740..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/timesettings-schema.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -description: A reference for the JSON timesettings schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - time settings -labels: - products: - - cloud - - enterprise - - oss -menuTitle: timesettings schema -title: timesettings -weight: 600 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/timesettings-schema/ -aliases: - - ../../../observability-as-code/schema-v2/timesettings-schema/ # /docs/grafana/next/observability-as-code/schema-v2/timesettings-schema/ ---- - -# `timeSettings` - -The `TimeSettingsSpec` defines the default time configuration for the time picker and the refresh picker for the specific dashboard. - -Following is the JSON for default time settings: - -```json - "timeSettings": { - "autoRefresh": "", - "autoRefreshIntervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "fiscalYearStartMonth": 0, - "from": "now-6h", - "hideTimepicker": false, - "timezone": "browser", - "to": "now" - }, -``` - -`timeSettings` consists of: - -- [TimeSettingsSpec](#timesettingsspec) - -## `TimeSettingsSpec` - -The following table explains the usage of the time settings JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| timezone? | string. Timezone of dashboard. Accepted values are IANA TZDB zone ID, `browser`, or `utc`. Default is `browser`. | -| from | string. 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`. Default is `now-6h`. | -| to | string. 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`. Default is `now`. | -| autoRefresh | string. Refresh rate of dashboard. Represented by interval string. For example: `5s`, `1m`, `1h`, `1d`. No default. In schema v1: `refresh`. | -| autoRefreshIntervals | string. Interval options available in the refresh picker drop-down menu. The default array is `["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]`. | -|quickRanges? | Selectable options available in the time picker drop-down menu. Has no effect on provisioned dashboard. Defined in the [`TimeRangeOption`](#timerangeoption) spec. In schema v1: `timepicker.quick_ranges`, not exposed in the UI. | -| hideTimepicker | bool. Whether or not the time picker is visible. Default is `false`. In schema v1: `timepicker.hidden`. | -| weekStart? | Day when the week starts. Expressed by the name of the day in lowercase. For example: `monday`. Options are `saturday`, `monday`, and `sunday`. | -| fiscalYearStartMonth | The month that the fiscal year starts on. `0` = January, `11` = December | -| nowDelay? | string. Override the "now" time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. In schema v1: `timepicker.nowDelay`. | - - - -### `TimeRangeOption` - -The following table explains the usage of the time range option JSON fields: - -| Name | Usage | -| ------- | ---------------------------------- | -| display | string. Default is `Last 6 hours`. | -| from | string. Default is `now-6h`. | -| to | string. Default is `now`. | diff --git a/docs/sources/as-code/observability-as-code/schema-v2/variables-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/variables-schema.md deleted file mode 100644 index 549478692f1..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/variables-schema.md +++ /dev/null @@ -1,501 +0,0 @@ ---- -description: A reference for the JSON variables schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - variables -labels: - products: - - cloud - - enterprise - - oss -menuTitle: variables schema -title: variables -weight: 700 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/variables-schema/ -aliases: - - ../../../observability-as-code/schema-v2/variables-schema/ # /docs/grafana/next/observability-as-code/schema-v2/variables-schema/ ---- - -# `variables` - -The available variable types described in the following sections: - -- [QueryVariableKind](#queryvariablekind) -- [TextVariableKind](#textvariablekind) -- [ConstantVariableKind](#constantvariablekind) -- [DatasourceVariableKind](#datasourcevariablekind) -- [IntervalVariableKind](#intervalvariablekind) -- [CustomVariableKind](#customvariablekind) -- [SwitchVariableKind](#switchvariablekind) -- [GroupByVariableKind](#groupbyvariablekind) -- [AdhocVariableKind](#adhocvariablekind) - -## `QueryVariableKind` - -Following is the JSON for a default query variable: - -```json - "variables": [ - { - "kind": "QueryVariable", - "spec": { - "current": { - "text": "", - "value": "" - }, - "hide": "dontHide", - "includeAll": false, - "multi": false, - "name": "", - "options": [], - "query": defaultDataQueryKind(), - "refresh": "never", - "regex": "", - "skipUrlSync": false, - "sort": "disabled" - } - } - ] -``` - -`QueryVariableKind` consists of: - -- kind: "QueryVariable" -- spec: [QueryVariableSpec](#queryvariablespec) - -### `QueryVariableSpec` - -The following table explains the usage of the query variable JSON fields: - - - -| Name | Usage | -| ------------ | ---------------------------------------------- | -| name | string. Name of the variable. | -| current | "Text" and a "value" or [`VariableOption`](#variableoption) | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| refresh | `VariableRefresh`. Options are `never`, `onDashboardLoad`, and `onTimeChanged`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | -| datasource? | [`DataSourceRef`](#datasourceref) | -| query | `DataQueryKind`. Consists of:
        • kind: string
        • spec: string
        | -| regex | string | -| sort | `VariableSort`. Options are:
        • disabled
        • alphabeticalAsc
        • alphabeticalDesc
        • numericalAsc
        • numericalDesc
        • alphabeticalCaseInsensitiveAsc
        • alphabeticalCaseInsensitiveDesc
        • naturalAsc
        • naturalDesc
        | -| definition? | string | -| options | [`VariableOption`](#variableoption) | -| multi | bool. Default is `false`. | -| includeAll | bool. Default is `false`. | -| allValue? | string | -| placeholder? | string | - - - -#### `VariableOption` - -| Name | Usage | -| -------- | -------------------------------------------- | -| selected | bool. Whether or not the option is selected. | -| text | string. Text to be displayed for the option. | -| value | string. Value of the option. | - -#### `DataSourceRef` - -| Name | Usage | -| ----- | ---------------------------------- | -| type? | string. The plugin type-id. | -| uid? | The specific data source instance. | - -## `TextVariableKind` - -Following is the JSON for a default text variable: - -```json - "variables": [ - { - "kind": "TextVariable", - "spec": { - "current": { - "text": "", - "value": "" - }, - "hide": "dontHide", - "name": "", - "query": "", - "skipUrlSync": false - } - } - ] -``` - -`TextVariableKind` consists of: - -- kind: TextVariableKind -- spec: [TextVariableSpec](#textvariablespec) - -### `TextVariableSpec` - -The following table explains the usage of the query variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| current | "Text" and a "value" or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| query | string | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - -## `ConstantVariableKind` - -Following is the JSON for a default constant variable: - -```json - "variables": [ - { - "kind": "ConstantVariable", - "spec": { - "current": { - "text": "", - "value": "" - }, - "hide": "hideVariable", - "name": "", - "query": "", - "skipUrlSync": true - } - } - ] -``` - -`ConstantVariableKind` consists of: - -- kind: "ConstantVariable" -- spec: [ConstantVariableSpec](#constantvariablespec) - -### `ConstantVariableSpec` - -The following table explains the usage of the constant variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| query | string | -| current | "Text" and a "value" or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - -## `DatasourceVariableKind` - -Following is the JSON for a default data source variable: - -```json - "variables": [ - { - "kind": "DatasourceVariable", - "spec": { - "current": { - "text": "", - "value": "" - }, - "hide": "dontHide", - "includeAll": false, - "multi": false, - "name": "", - "options": [], - "pluginId": "", - "refresh": "never", - "regex": "", - "skipUrlSync": false - } - } - ] -``` - -`DatasourceVariableKind` consists of: - -- kind: "DatasourceVariable" -- spec: [DatasourceVariableSpec](#datasourcevariablespec) - -### `DatasourceVariableSpec` - -The following table explains the usage of the data source variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| pluginId | string | -| refresh | `VariableRefresh`. Options are `never`, `onDashboardLoad`, and `onTimeChanged`. | -| regex | string | -| current | `Text` and a `value` or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| options | `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| multi | bool. Default is `false`. | -| includeAll | bool. Default is `false`. | -| allValue? | string | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - -## `IntervalVariableKind` - -Following is the JSON for a default interval variable: - -```json - "variables": [ - { - "kind": "IntervalVariable", - "spec": { - "auto": false, - "auto_count": 0, - "auto_min": "", - "current": { - "text": "", - "value": "" - }, - "hide": "dontHide", - "name": "", - "options": [], - "query": "", - "refresh": "never", - "skipUrlSync": false - } - } - ] -``` - -`IntervalVariableKind` consists of: - -- kind: "IntervalVariable" -- spec: [IntervalVariableSpec](#intervalvariablespec) - -### `IntervalVariableSpec` - -The following table explains the usage of the interval variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| query | string | -| current | `Text` and a `value` or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| options | `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| auto | bool. Default is `false`. | -| auto_count | integer. Default is `0`. | -| refresh | `VariableRefresh`. Options are `never`, `onDashboardLoad`, and `onTimeChanged`. | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false` | -| description? | string | - -## `CustomVariableKind` - -Following is the JSON for a default custom variable: - -```json - "variables": [ - { - "kind": "CustomVariable", - "spec": { - "current": defaultVariableOption(), - "hide": "dontHide", - "includeAll": false, - "multi": false, - "name": "", - "options": [], - "query": "", - "skipUrlSync": false - } - } - ] -``` - -`CustomVariableKind` consists of: - -- kind: "CustomVariable" -- spec: [CustomVariableSpec](#customvariablespec) - -### `CustomVariableSpec` - -The following table explains the usage of the custom variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| query | string | -| current | `Text` and a `value` or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| options | `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| multi | bool. Default is `false`. | -| includeAll | bool. Default is `false`. | -| allValue? | string | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - -## `SwitchVariableKind` - -Following is the JSON for a default switch variable: - -```json - "variables": [ - { - "kind": "SwitchVariable", - "spec": { - "current": "false", - "enabledValue": "true", - "disabledValue": "false", - "hide": "dontHide", - "name": "", - "skipUrlSync": false - } - } - ] -``` - -`SwitchVariableKind` consists of: - -- kind: "SwitchVariable" -- spec: [SwitchVariableSpec](#switchvariablespec) - -### `SwitchVariableSpec` - -The following table explains the usage of the switch variable JSON fields: - - - -| Name | Usage | -| -------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| current | string. Current value of the switch variable (either `enabledValue` or `disabledValue`). | -| enabledValue | string. Value when the switch is in the enabled state. | -| disabledValue | string. Value when the switch is in the disabled state. | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - - - -## `GroupByVariableKind` - -Following is the JSON for a default group by variable: - -```json - "variables": [ - { - "kind": "GroupByVariable", - "spec": { - "current": { - "text": [ - "" - ], - "value": [ - "" - ] - }, - "datasource": {}, - "hide": "dontHide", - "multi": false, - "name": "", - "options": [], - "skipUrlSync": false - } - } - ] -``` - -`GroupByVariableKind` consists of: - -- kind: "GroupByVariable" -- spec: [GroupByVariableSpec](#groupbyvariablespec) - -### `GroupByVariableSpec` - -The following table explains the usage of the group by variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable | -| datasource? | `DataSourceRef`. Refer to the [`DataSourceRef` definition](#datasourceref) under `QueryVariableKind`. | -| current | `Text` and a `value` or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| options | `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| multi | bool. Default is `false`. | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string. | - -## `AdhocVariableKind` - -Following is the JSON for a default ad hoc variable: - -```json - "variables": [ - { - "kind": "AdhocVariable", - "spec": { - "baseFilters": [], - "defaultKeys": [], - "filters": [], - "hide": "dontHide", - "name": "", - "skipUrlSync": false - } - } - ] -``` - -`AdhocVariableKind` consists of: - -- kind: "AdhocVariable" -- spec: [AdhocVariableSpec](#adhocvariablespec) - -### `AdhocVariableSpec` - -The following table explains the usage of the ad hoc variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| datasource? | `DataSourceRef`. Consists of:
        • type? - string. The plugin type-id.
        • uid? - string. The specific data source instance.
        | -| baseFilters | [AdHocFilterWithLabels](#adhocfilterswithlabels) | -| filters | [AdHocFilterWithLabels](#adhocfilterswithlabels) | -| defaultKeys | [MetricFindValue](#metricfindvalue) | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - -#### `AdHocFiltersWithLabels` - -The following table explains the usage of the ad hoc variable with labels JSON fields: - -| Name | Type | -| ------------ | ------------- | -| key | string | -| operator | string | -| value | string | -| values? | `[...string]` | -| keyLabel | string | -| valueLabels? | `[...string]` | -| forceEdit? | bool | - -#### `MetricFindValue` - -The following table explains the usage of the metric find value JSON fields: - -| Name | Type | -| ----------- | ---------------- | -| text | string | -| value? | string or number | -| group? | string | -| expandable? | bool | diff --git a/docs/sources/datasources/google-cloud-monitoring/_index.md b/docs/sources/datasources/google-cloud-monitoring/_index.md index 27bc7b390cd..d5412c33ef7 100644 --- a/docs/sources/datasources/google-cloud-monitoring/_index.md +++ b/docs/sources/datasources/google-cloud-monitoring/_index.md @@ -103,10 +103,11 @@ To configure basic settings for the data source, complete the following steps: 1. Set the data source's basic configuration options: - | Name | Description | - | ----------- | ------------------------------------------------------------------------ | - | **Name** | Sets the name you use to refer to the data source in panels and queries. | - | **Default** | Sets whether the data source is pre-selected for new panels. | + | Name | Description | + | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | **Name** | Sets the name you use to refer to the data source in panels and queries. | + | **Default** | Sets whether the data source is pre-selected for new panels. | + | **Universe Domain** | The universe domain to connect to. For more information, refer to [Documentation on universe domains](https://docs.cloud.google.com/python/docs/reference/monitoring/latest/google.cloud.monitoring_v3.services.service_monitoring_service.ServiceMonitoringServiceAsyncClient#google_cloud_monitoring_v3_services_service_monitoring_service_ServiceMonitoringServiceAsyncClient_universe_domain). Defaults to `googleapis.com`. | ### Provision the data source @@ -129,6 +130,7 @@ datasources: clientEmail: stackdriver@myproject.iam.gserviceaccount.com authenticationType: jwt defaultProject: my-project-name + universeDomain: googleapis.com secureJsonData: privateKey: | -----BEGIN PRIVATE KEY----- @@ -152,6 +154,7 @@ datasources: clientEmail: stackdriver@myproject.iam.gserviceaccount.com authenticationType: jwt defaultProject: my-project-name + universeDomain: googleapis.com privateKeyPath: /etc/secrets/gce.pem ``` @@ -166,6 +169,7 @@ datasources: access: proxy jsonData: authenticationType: gce + universeDomain: googleapis.com ``` ## Import pre-configured dashboards diff --git a/docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md b/docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md index f4b46adad3f..370b1fed7e4 100644 --- a/docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md @@ -171,146 +171,3 @@ Status Codes: - **200** - Ok - **401** - Unauthorized - **404** - Dashboard version not found - -## Restore dashboard by dashboard UID - -`POST /api/dashboards/uid/:uid/restore` - -Restores a dashboard to a given dashboard version using `uid`. - -**Example request for restoring a dashboard version**: - -```http -POST /api/dashboards/uid/QA7wKklGz/restore -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "version": 1 -} -``` - -JSON body schema: - -- **version** - The dashboard version to restore to - -**Example response**: - -```http -HTTP/1.1 200 OK -Content-Type: application/json; charset=UTF-8 -Content-Length: 67 - -{ - "id": 70, - "slug": "my-dashboard", - "status": "success", - "uid": "QA7wKklGz", - "url": "/d/QA7wKklGz/my-dashboard", - "version": 3 -} -``` - -JSON response body schema: - -- **slug** - the URL friendly slug of the dashboard's title -- **status** - whether the restoration was successful or not -- **version** - the new dashboard version, following the restoration - -Status codes: - -- **200** - OK -- **400** - Bad request (specified version has the same content as the current dashboard) -- **401** - Unauthorized -- **404** - Not found (dashboard not found or dashboard version not found) -- **500** - Internal server error (indicates issue retrieving dashboard tags from database) - -**Example error response** - -```http -HTTP/1.1 404 Not Found -Content-Type: application/json; charset=UTF-8 -Content-Length: 46 - -{ - "message": "Dashboard version not found" -} -``` - -JSON response body schema: - -- **message** - Message explaining the reason for the request failure. - -## Compare dashboard versions - -`POST /api/dashboards/calculate-diff` - -Compares two dashboard versions by calculating the JSON diff of them. - -**Example request**: - -```http -POST /api/dashboards/calculate-diff HTTP/1.1 -Accept: text/html -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "base": { - "dashboardId": 1, - "version": 1 - }, - "new": { - "dashboardId": 1, - "version": 2 - }, - "diffType": "json" -} -``` - -JSON body schema: - -- **base** - an object representing the base dashboard version -- **new** - an object representing the new dashboard version -- **diffType** - the type of diff to return. Can be "json" or "basic". - -**Example response (JSON diff)**: - -```http -HTTP/1.1 200 OK -Content-Type: text/html; charset=UTF-8 - -

        - -

        -``` - -The response is a textual representation of the diff, with the dashboard values being in JSON, similar to the diffs seen on sites like GitHub or GitLab. - -Status Codes: - -- **200** - Ok -- **400** - Bad request (invalid JSON sent) -- **401** - Unauthorized -- **404** - Not found - -**Example response (basic diff)**: - -```http -HTTP/1.1 200 OK -Content-Type: text/html; charset=UTF-8 - -
        - -
        -``` - -The response here is a summary of the changes, derived from the diff between the two JSON objects. - -Status Codes: - -- **200** - OK -- **400** - Bad request (invalid JSON sent) -- **401** - Unauthorized -- **404** - Not found diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index 5500601829d..0fa0e059472 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -87,6 +87,7 @@ With a Grafana Enterprise license, you also get access to premium data sources, - [CockroachDB](/grafana/plugins/grafana-cockroachdb-datasource) - [Databricks](/grafana/plugins/grafana-databricks-datasource) - [DataDog](/grafana/plugins/grafana-datadog-datasource) +- [IBM Db2](/grafana/plugins/grafana-ibmdb2-datasource) - [Drone](/grafana/plugins/grafana-drone-datasource) - [DynamoDB](/grafana/plugins/grafana-dynamodb-datasource/) - [Dynatrace](/grafana/plugins/grafana-dynatrace-datasource) diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index d9125991c12..d9c5f524bd3 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2030,6 +2030,44 @@ For example: `disabled_labels=grafana_folder`
        +### `[unified_alerting.state_history]` + +This section configures where Grafana Alerting writes alert state history. Refer to [Configure alert state history](/docs/grafana//alerting/set-up/configure-alert-state-history/) for end-to-end setup and examples. + +#### `enabled ` + +Enables recording alert state history. Default is `false`. + +#### `backend ` + +Select the backend used to store alert state history. Supported values: `loki`, `prometheus`, `multiple`. + +#### `loki_remote_url ` + +The URL of the Loki server used when `backend = loki` (or when `backend = multiple` and Loki is a primary/secondary). + +#### `prometheus_target_datasource_uid ` + +Target Prometheus data source UID used for writing alert state changes when `backend = prometheus` (or when `backend = multiple` and Prometheus is a secondary). + +#### `prometheus_metric_name ` + +Optional. Metric name for the alert state metric. Default is `GRAFANA_ALERTS`. + +#### `prometheus_write_timeout ` + +Optional. Timeout for writing alert state data to the target data source. Default is `10s`. + +#### `primary ` + +Used only when `backend = multiple`. Selects the primary backend (for example `loki`). + +#### `secondaries ` + +Used only when `backend = multiple`. Comma-separated list of secondary backends (for example `prometheus`). + +
        + ### `[unified_alerting.state_history.annotations]` This section controls retention of annotations automatically created while evaluating alert rules when alerting state history backend is configured to be annotations (see setting [unified_alerting.state_history].backend) 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 b7f55555e07..813efb29eba 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -83,6 +83,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `reportingRetries` | Enables rendering retries for the reporting feature | | `externalServiceAccounts` | Automatic service account and token setup for plugins | | `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | +| `dashboardNewLayouts` | Enables new dashboard layouts | | `pdfTables` | Enables generating table data as PDF in reporting | | `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | | `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. | diff --git a/docs/sources/shared/visualizations/panel-zoom.md b/docs/sources/shared/visualizations/panel-pan-zoom.md similarity index 65% rename from docs/sources/shared/visualizations/panel-zoom.md rename to docs/sources/shared/visualizations/panel-pan-zoom.md index 281f138fc63..72b089916ae 100644 --- a/docs/sources/shared/visualizations/panel-zoom.md +++ b/docs/sources/shared/visualizations/panel-pan-zoom.md @@ -4,7 +4,8 @@ comments: | This file is used in the following visualizations: candlestick, heatmap, state timeline, status history, time series. --- -You can zoom the panel time range in and out, which in turn, changes the dashboard time range. +You can pan the panel time range left and right, and zoom it and in and out. +This, in turn, changes the dashboard time range. **Zoom in** - Click and drag on the panel to zoom in on a particular time range. @@ -16,4 +17,9 @@ For example, if the original time range is from 9:00 to 9:59, the time range cha - Next range: 8:30 - 10:29 - Next range: 7:30 - 11:29 -For screen recordings showing these interactions, refer to the [Panel overview documentation](https://grafana.com/docs/grafana//visualizations/panels-visualizations/panel-overview/#zoom-panel-time-range). +**Pan** - Click and drag the x-axis area of the panel to pan the time range. + +The time range shifts by the distance you drag. +For example, if the original time range is from 9:00 to 9:59 and you drag 30 minutes to the right, the time range changes to 9:30 to 10:29. + +For screen recordings showing these interactions, refer to the [Panel overview documentation](https://grafana.com/docs/grafana//visualizations/panels-visualizations/panel-overview/#pan-and-zoom-panel-time-range). diff --git a/docs/sources/visualizations/dashboards/assess-dashboard-usage/index.md b/docs/sources/visualizations/dashboards/assess-dashboard-usage/index.md index 2030cd1b2d8..a220ba21b53 100644 --- a/docs/sources/visualizations/dashboards/assess-dashboard-usage/index.md +++ b/docs/sources/visualizations/dashboards/assess-dashboard-usage/index.md @@ -78,9 +78,9 @@ For every dashboard and data source, you can access usage information. ### Dashboard insights -To see dashboard usage information, click the dashboard insights icon in the header. +To see dashboard usage information, click the dashboard insights icon in the sidebar. -![Dashboard insights icon](/media/docs/grafana/dashboards/screenshot-dashboard-insights-icon-11.2.png) +{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-insights-v12.4.png" max-width="500px" alt="Dashboard insights icon" >}} Dashboard insights show the following information: diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md index 10196a40811..fd2deafa469 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md @@ -2,238 +2,423 @@ aliases: - ../../../dashboards/build-dashboards/add-organize-panels/ # /docs/grafana/next/dashboards/build-dashboards/add-organize-panels/ - ../../../dashboards/build-dashboards/create-dashboard/ # /docs/grafana/next/dashboards/build-dashboards/create-dashboard/ + - ../../../dashboards/build-dashboards/create-dynamic-dashboard/ # /docs/grafana/latest/dashboards/build-dashboards/create-dynamic-dashboard/ + - ./create-dynamic-dashboard/ # /docs/grafana/latest/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/ keywords: - panel - dashboard - create + - dynamic dashboard labels: products: - cloud - enterprise - oss -menuTitle: Create a dashboard -title: Create a dashboard +title: Create dashboards description: Create and edit a dashboard weight: 1 -refs: - built-in-special-data-sources: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/#special-data-sources - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/#special-data-sources - visualization-specific-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/visualizations/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/ - configure-standard-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/configure-standard-options/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/ - configure-value-mappings: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/configure-value-mappings/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-value-mappings/ - generative-ai-features: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - configure-thresholds: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/configure-thresholds/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-thresholds/ - data-sources: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/ - add-a-data-source: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/#add-a-data-source - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/#add-a-data-source - about-users-and-permissions: - - pattern: /docs/grafana/ - destination: /docs/grafana//administration/roles-and-permissions/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/roles-and-permissions/ - visualizations-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/visualizations/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/visualizations/ - configure-repeating-panels: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/configure-panel-options/#configure-repeating-panels - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-panel-options/#configure-repeating-panels - override-field-values: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/configure-overrides/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/ - saved-queries: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/query-transform-data/#saved-queries - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/#saved-queries - save-query: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/query-transform-data/#save-a-query - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/#save-a-query +image_maps: + - key: editpane-sidebar + src: /media/docs/grafana/dashboards/screenshot-edit-sidebar-v12.4.png + alt: An annotated image of the edit pane and sidebar + points: + - x_coord: 96 + y_coord: 17 + content: | + **Dashboard options** + + Click the icon to open the edit pane. Edit mode only. + - x_coord: 96 + y_coord: 25 + content: | + **Feedback** + + Submit feedback on the new editing experience. Edit mode only. + - x_coord: 96 + y_coord: 33 + content: | + **Export** + + Click to display [export](https://grafana.com/docs/grafana//visualizations/dashboards/share-dashboards-panels/#export-dashboards) options. + - x_coord: 96 + y_coord: 41 + content: | + **Content outline** + + Navigate a dashboard using the [Content outline](#navigate-using-the-content-outline). + - x_coord: 96 + y_coord: 49 + content: | + **Dashboard insights** + + View [dashboard analytics](https://grafana.com/docs/grafana//visualizations/dashboards/assess-dashboard-usage/) including information about users, activity, and query counts. --- -## Create a dashboard +# Create dashboards -Dashboards and panels allow you to show your data in visual form. Each panel needs at least one query to display a visualization. +{{< admonition type="note">}} +Dynamic dashboards is currently in public preview. Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. + +For information on the generally available dashboard creation experience, refer to the [documentation for the latest self-managed version of Grafana](https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/create-dashboard/). +{{< /admonition >}} + +Dashboards and panels allow you to show your data in visual form. +Each panel needs at least one query to display a visualization. **Before you begin:** -- Ensure that you have the proper permissions. For more information about permissions, refer to [About users and permissions](ref:about-users-and-permissions). -- Identify the dashboard to which you want to add the panel. +- Ensure that you have the proper permissions. For more information about permissions, refer to [About users and permissions](https://grafana.com/docs/grafana//administration/roles-and-permissions/). - Understand the query language of the target data source. -- Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source](ref:add-a-data-source) if you need instructions. + +## Create a dashboard To create a dashboard, follow these steps: -{{< shared id="create-dashboard" >}} - 1. Click **Dashboards** in the main menu. 1. Click **New** and select **New Dashboard**. -1. On the empty dashboard, click **+ Add visualization**. - - ![Empty dashboard state](/media/docs/grafana/dashboards/empty-dashboard-10.2.png) - -{{< /shared >}} - +1. Click **+ Add visualization**. 1. In the dialog box that opens, do one of the following: - Select one of your existing data sources. - - Select one of the Grafana [built-in special data sources](ref:built-in-special-data-sources). + - Select one of the Grafana [built-in special data sources](https://grafana.com/docs/grafana//datasources/#special-data-sources). - Click **Configure a new data source** to set up a new one (Admins only). {{< figure class="float-right" src="/media/docs/grafana/dashboards/screenshot-data-source-selector-10.0.png" max-width="800px" alt="Select data source modal" >}} The **Edit panel** view opens with your data source selected. - You can change the panel data source later using the drop-down in the **Queries** tab of the panel editor if needed. + You can change the panel data source later using the drop-down in the **Query** tab of the panel editor if needed. - For more information about data sources, refer to [Data sources](ref:data-sources) for specific guidelines. + For more information about data sources, refer to [Data sources](https://grafana.com/docs/grafana//datasources/) for specific guidelines. 1. To create a query, do one of the following: - Write or construct a query in the query language of your data source. - - Open the **Saved queries** drop-down menu and click **Replace query** to reuse a [saved query](ref:saved-queries). + - Open the **Saved queries** drop-down menu and click **Replace query** to reuse a [saved query](https://grafana.com/docs/grafana//visualizations/panels-visualizations/query-transform-data/#saved-queries). -1. (Optional) To [save the query](ref:save-query) for reuse, open the **Saved queries** drop-down menu and click the **Save query** option. -1. Click **Refresh** to query the data source. -1. (Optional) To add subsequent queries, click **+ Add query** or **+ Add from saved queries**, and refresh the data source as many times as needed. +1. (Optional) To [save the query](https://grafana.com/docs/grafana//visualizations/panels-visualizations/query-transform-data/#save-a-query) for reuse, open the **Saved queries** drop-down menu and click the **Save query** option. {{< admonition type="note" >}} - [Saved queries](ref:saved-queries) is currently in [public preview](https://grafana.com/docs/release-life-cycle/) in Grafana Enterprise and Grafana Cloud only. + [Saved queries](https://grafana.com/docs/grafana//visualizations/panels-visualizations/query-transform-data/#saved-queries) is currently in [public preview](https://grafana.com/docs/release-life-cycle/) in Grafana Enterprise and Grafana Cloud only. {{< /admonition >}} +1. Click **Refresh** to query the data source. 1. In the visualization list, select a visualization type. - ![Visualization selector](/media/docs/grafana/dashboards/screenshot-select-visualization-11-2.png) + {{< figure src="/media/docs/grafana/dashboards/screenshot-select-visualization-v12.png" max-width="350px" alt="Visualization selector" >}} Grafana displays a preview of your query results with the visualization applied. - For more information about individual visualizations, refer to [Visualizations options](ref:visualizations-options). + For more information about configuring individual visualizations, refer to [Visualizations options](https://grafana.com/docs/grafana//visualizations/panels-visualizations/visualizations/). -1. Under **Panel options**, enter a title and description for your panel or have Grafana create them using [generative AI features](ref:generative-ai-features). +1. Under **Panel options**, enter a title and description for the panel or have Grafana create them using [generative AI features](https://grafana.com/docs/grafana//visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards). 1. Refer to the following documentation for ways you can adjust panel settings. While not required, most visualizations need some adjustment before they properly display the information that you need. - - [Configure value mappings](ref:configure-value-mappings) - - [Visualization-specific options](ref:visualization-specific-options) - - [Override field values](ref:override-field-values) - - [Configure thresholds](ref:configure-thresholds) - - [Configure standard options](ref:configure-standard-options) + - [Configure value mappings](https://grafana.com/docs/grafana//visualizations/panels-visualizations/configure-value-mappings/) + - [Visualization-specific options](https://grafana.com/docs/grafana//visualizations/panels-visualizations/visualizations/) + - [Override field values](https://grafana.com/docs/grafana//visualizations/panels-visualizations/configure-overrides/) + - [Configure thresholds](https://grafana.com/docs/grafana//visualizations/panels-visualizations/configure-thresholds/) + - [Configure standard options](https://grafana.com/docs/grafana//visualizations/panels-visualizations/configure-standard-options/) -1. When you've finished editing your panel, click **Save dashboard**. - - Alternatively, click **Back to dashboard** if you want to see your changes applied to the dashboard first. Then click **Save dashboard** when you're ready. - -1. Enter a title and description for your dashboard or have Grafana create them using [generative AI features](ref:generative-ai-features). +1. When you've finished editing the panel, click **Save**. +1. Enter a title and description for the dashboard if you haven't already or have Grafana create them using [generative AI features](https://grafana.com/docs/grafana//visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards). 1. Select a folder, if applicable. +1. Click **Save** +1. Click **Back to dashboard**. +1. (Optional) Continue building the dashboard by clicking one or more of the following options: + - **+ Add panel**: Set panel options in the edit pane or click **Configure** to complete panel setup. + - **+ Add variable**: Follow the steps to [add a variable to the dashboard](#add-variables). + - **Group panels**: Choose from **Group into row** or **Group into tab**. For more information on groupings, refer to [Panel groupings](#panel-groupings). + - **Dashboard options** icon: Open the edit pane to access [panel layout options](#panel-layouts). + +1. When you've finished making changes, click **Save**. +1. (Optional) Enter a description of the changes you've made. 1. Click **Save**. -1. To add more panels to the dashboard, click **Back to dashboard**. - Then click **Add** in the dashboard header and select **Visualization** in the drop-down. +1. Click **Exit edit**. - ![Add drop-down](/media/docs/grafana/dashboards/screenshot-add-dropdown-11.2.png) +## Dashboard edit - When you add additional panels to the dashboard, you're taken straight to the **Edit panel** view. +Now that you've created a basic dashboard, you can augment it with more options. +You can make several updates without leaving the dashboard by using the edit pane, which is explained in the next section. -1. When you've saved all the changes you want to make to the dashboard, click **Exit edit**. +### The edit pane and sidebar - Now, when you want to make more changes to the saved dashboard, click **Edit** in the top-right corner. +The _edit pane_ allows you to make changes without leaving the dashboard, by displaying options associated with the part of the dashboard that's in focus. +The _sidebar_ is on the next to the edit pane, and it includes options that are useful to have available all the time. -### Begin dashboard creation from data source configuration +The following image shows the parts of the edit pane and the sidebar. +Hover your cursor over the numbers to display descriptions of the sidebar options (descriptions also follow the image): -You can start the process of creating a dashboard directly from a data source rather than from the **Dashboards** page. +{{< image-map key="editpane-sidebar" >}} -To begin building a dashboard directly from a data source, follow these steps: +{{< admonition type="note" >}} +The sidebar is displayed in both edit and view mode, but the **Dashboard options** and **Feedback** icons aren't available in view mode. +{{< /admonition >}} -1. Navigate to **Connections > Data sources**. -1. On the row of the data source for which you want to build a dashboard, click **Build a dashboard**. +You can dock, undock, and resize the edit pane. +When the edit pane is closed, you can resize the sidebar so the icon names are visible. - The empty dashboard page opens. +{{< video-embed src="/media/docs/grafana/dashboards/screenrecord-edit-side-v12.4.mp4" >}} +The available configuration options in the edit pane differ depending on the selected dashboard element: + +- Dashboards: High-level options are in the edit pane and further configuration options are in the **Settings** page. +- Groupings (rows and tabs): All configuration options are available in the edit pane. +- Panels: High-level options are in the edit pane and further configuration options are in the **Edit panel** view. + +### Navigate using the content outline + +The **Content outline** provides a tree-like structure that shows you all the parts of the dashboard and their relationships to each other, including panels, rows, tabs, and variables. +The outline also lets you quickly navigate the dashboard and is available in both view and edit modes (note that variables are only included in edit mode). + +{{< figure src="/media/docs/grafana/dashboards/screenshot-content-outline-v12.4.png" max-width="750px" alt="Dashboard with outline open" >}} + +To navigate the dashboard using the outline, follow these steps: + +1. Navigate to the dashboard you want to view or update. +1. In the right sidebar, click the **Content outline** icon to open it. +1. Expand the outline to find the part of the dashboard you want to view or update. +1. Click the tree item to navigate that part of the dashboard. + +### Edit a dashboard + +To edit a dashboard, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the part of the dashboard you want to update to open the edit pane, or click the **Dashboard options** icon to open it. + + If the dashboard is large, open the **Content outline** and use it to navigate to the part of the dashboard you want to update. + +1. Update the dashboard as needed. +1. When you've finished making changes, click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Back to dashboard**, if needed. +1. Click **Exit edit** + +## Panel layouts + +Panel layouts control the size and arrangement of panels in the dashboard. +There are two panel layout options: + +- **Custom**: You can position and size panels individually. This is the default selection for a new dashboard. **Show/hide rules** are not supported. +- **Auto grid**: Panels resize and fit automatically to create a uniform grid. You can't make manual changes to this layout. **Show/hide rules** are supported. + +You can use both layouts in row or tab groupings. + +### Auto grid layout + +In the auto grid layout, panels are automatically sized and positioned as you add them. +There are default parameters to constrain the layout, and you can update these to have more control over the display: + +- **Min column width**: Choose from **Standard**, **Narrow**, **Wide**, or **Custom**, for which you can enter the minimum width in pixels. +- **Max columns**: Set a number up to 10. +- **Row height**: Choose from **Standard**, **Short**, **Tall**, and **Custom**, for which you can enter the row height in pixels. +- **Fill screen**: Toggle the switch on to have the panel fill the entire height of the screen. If the panel is in a row, the **Fill screen** toggle for the row must also be enabled (refer to [grouping configuration options](#grouping-configuration-options)). + +### Update panel layout + +To update the panel layout, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the dashboard or the grouping that contains the panel layout you want to update. +1. Click the **Dashboard options** icon to open the edit pane, if needed. +1. Under **Layout**, select **Custom** or **Auto grid**. +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit** + +## Panel groupings + +To help create meaningful sections in your dashboard, you can group panels into rows or tabs. +Rows and tabs let you break up big dashboards or make one dashboard out of several smaller ones. + +You can think of the dashboard as a series of nested containers: the dashboard is the largest container and it contains panels, rows, or tabs. +Rows and tabs are the next largest containers, and they contain panels. + +You can also nest: + +- Rows in a row +- Rows in a tab +- Tabs in a row + +You can nest up to two levels deep, which means a dashboard can have a maximum of four configuration levels: + +- Dashboard +- Grouping 1 - Row or tab +- Grouping 2 - Row or tab +- Panels + +You can only have one type of grouping at each level. +Inside of those groupings however, you have to freedom to add different elements. +Also, custom and auto grid panel layouts are supported for rows and tabs, so each grouping can have a different panel layout. + + + +The following sections describe: + +- [Grouping configuration options](#grouping-configuration-options) +- [Grouping layouts](#grouping-layouts) +- [How to group panels](#group-panels) +- [How to ungroup panels](#ungroup-panels) + +### Grouping configuration options + +The following table describes the options you can set for a row or tab: + + + +| Option | Description | +| ----------------| --------------------------------------------------------------------------- | +| Title | Title of the row or tab. | +| Fill screen | Toggle the switch on to make the row fill the screen. Rows only. | +| Hide row header | Toggle the switch on to hide row headers in view mode. In edit mode, the row header is visible, but crossed out with the hidden icon next to it. Rows only. | +| Layout | Select the layout. If the grouping contains another grouping, choose from **Rows** or **Tabs**. If the grouping contains panels, choose from **Custom** or **Auto grid**. For more information, refer to [Panel layouts](#panel-layouts) or [Grouping layouts](#grouping-layouts). | +| Repeat options > [Repeat by variable](#configure-repeat-options) | Configure the dashboard to dynamically add panels, rows, or tabs based on the value of a variable. | +| Show / hide rules > [Panel/Row/Tab visibility](#configure-showhide-rules) | Control whether or not panels, rows, or tabs are displayed based on variable values, a time range, or query results (panels only). | + + + +### Grouping layouts + +When you have panels grouped into rows or tabs, the **Layout** options available depend on which dashboard element is selected and the nesting level of that element. + +You can nest up to two levels deep, which means a dashboard can have a maximum of four configuration levels, with the following layout options: + +- **Dashboard**: Layout options allow you to choose between rows or tabs. +- **Grouping 1 (outer)**: Layout options allow you to choose between rows or tabs. +- **Grouping 2 (inner)**: Layout options allow you to choose between custom and auto grid (refer to [Panel layouts](#panel-layouts)). +- **Panels**: No layout options + +You can switch between rows and tabs or update the panel layout by clicking the parent container and changing the layout selection. + +### Group panels + +To group panels, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Under a panel, click **Group panels**. + + While grouping is typically used for multiple panels, you can start a grouping with just one panel. + +1. Select **Group into row** or **Group into tab**. + + All the panels are moved into the grouping, and a dotted blue line surrounds the row or tab. + The edit pane opens, displaying the relevant options. + +1. Set the [grouping configuration options](#grouping-configuration-options) in the edit pane. +1. (Optional) Add one or both of the following: + - A [nested grouping](#add-nested-groupings) + - Other [groupings at the same level](#add-more-groupings-at-the-same-level). + +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. + +#### Add nested groupings + +To add a second-level (or nested) grouping, follow these steps: + +1. In the existing grouping, under the panels, click **Group panels**. + + {{< figure src="/media/docs/grafana/dashboards/screenshot-nest-group-v12.4.png" alt="Adding a nested grouping" max-width="500px" >}} + +1. Click **Group into row** or **Group into tab** (**Group into tab** is only available if the parent grouping is a row). + + The new grouping is added inside the first grouping, and the panels are moved into the nested grouping. + The edit pane opens displaying the relevant options. + +1. Set the configuration options for the nested grouping. +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. + +#### Add more groupings at the same level + +To add more first-level groupings, follow these steps: + +1. On the dashboard, outside the existing first-level grouping, click **New row** or **New tab** (only one option will be available). + + {{< figure src="/media/docs/grafana/dashboards/screenshot-add-group-v12.4.png" alt="Adding a nested grouping" max-width="500px" >}} + +1. Set the configuration options for the new grouping. +1. Click **+ Add panel** to begin adding panels. +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. + +### Ungroup panels + +You can ungroup some or all of the dashboard groupings without losing your panels. +Ungrouping behavior depends on whether you're working with first-level or nested groupings: + +| Grouping | Action and outcome | +| ---------- | -------------------------------------------------------------------------------------------------- | +| Rows | **Ungroup rows** ungroups all first-level rows in the dashboard and all of their nested groupings. | +| Tabs | **Ungroup tabs** ungroups all first-level tabs in the dashboard and all of their nested groupings. | +| Row > row | **Ungroup rows** ungroups the nested row. | +| Row > tabs | **Ungroup tabs** ungroups all the nested tabs in that row. Tabs in other rows are not affected. | +| Tab > rows | **Ungroup rows** ungroups all the nested rows in that tab. Rows in other tabs are not affected. | + +{{< figure src="/media/docs/grafana/dashboards/screenshot-ungrouping-v12.4.png" alt="Dashboard with ungrouping behavior annotated" max-width="750px" >}} + +{{< admonition type="caution" >}} +If you delete a grouping, rather than ungrouping it, its panels are deleted as well. +{{< /admonition >}} + +To remove groupings, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. (Optional) Click the **Content outline** icon to quickly navigate to the grouping you want to remove. 1. Do one of the following: - - Click **+Add visualization** to configure all the elements of the new dashboard. - - Select one of the suggested dashboards by clicking its **Use dashboard** button. This can be helpful when you're not sure how to most effectively visualize your data. - The suggested dashboards are specific to your data source type (for example, Prometheus, Loki, or Elasticsearch). If there are more than three dashboard suggestions, you can click **View all** to see the rest of them. - - ![Empty dashboard with add visualization and suggested dashboard options](/media/docs/grafana/dashboards/screenshot-suggested-dashboards-v12.3.png) - - {{< docs/public-preview product="Suggested dashboards" >}} - -1. Complete the rest of the dashboard configuration. For more detailed steps, refer to [Create a dashboard](#create-a-dashboard), beginning at step five. - -## Copy a dashboard - -To copy a dashboard, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Open the dashboard you want to copy. -1. Click **Edit** in top-right corner. -1. Click the **Save dashboard** drop-down and select **Save as copy**. -1. (Optional) Specify the name, folder, description, and whether or not to copy the original dashboard tags for the copied dashboard. - - By default, the copied dashboard has the same name as the original dashboard with the word "Copy" appended and is in the same folder. + - Click **Ungroup rows** or **Ungroup tabs** at the bottom of the dashboard to ungroup all rows or tabs, including any nested groupings. + - Click in a grouping and click **Ungroup rows** or **Ungroup tabs** to ungroup only the tabs or rows nested in that grouping. +1. If you've ungrouped panels that were previously in different panel layouts, you'll be prompted to select a common layout type for all the panels; click **Convert to Auto grid** or **Convert to Custom**. 1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. -## Configure repeating rows +## Configure repeat options -You can configure Grafana to dynamically add panels or rows to a dashboard based on the value of a variable. Variables dynamically change your queries across all rows in a dashboard. For more information about repeating panels, refer to [Configure repeating panels](ref:configure-repeating-panels). +You can configure Grafana to dynamically add panels, rows, or tabs to a dashboard based on the value of a variable. +Variables dynamically change your queries across all panels, rows, or tabs in a dashboard. -To see an example of repeating rows, refer to [Dashboard with repeating rows](https://play.grafana.org/d/000000153/repeat-rows). The example shows that you can also repeat rows if you have variables set with `Multi-value` or `Include all values` selected. +This only applies to queries that include a multi-value variable. -**Before you begin:** +To configure repeats, follow these steps: -- Ensure that the query includes a multi-value variable. +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the panel, row, or tab you want to update to open the edit pane, or click the **Dashboard options** icon to open it. -**To configure repeating rows:** + If the dashboard is large, open the **Content outline** and use it to navigate to the part of the dashboard you want to update. -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to work on. -1. At the top of the dashboard, click **Add** and select **Row** in the drop-down. +1. Expand the **Repeat options** section. +1. Select the **Repeat by variable**. +1. For panels in a custom layout, set the following options: + 1. Under **Repeat direction**, choose one of the following: + - **Horizontal** - Arrange panels side-by-side. Grafana adjusts the width of a repeated panel. You can’t mix other panels on a row with a repeated panel. + - **Vertical** - Arrange panels in a column. The width of repeated panels is the same as the original, repeated panel. + 1. If you selected **Horizontal**, select a value in the **Max per row** drop-down list to control the maximum number of panels that can be in a row. - If the dashboard is empty, you can click the **+ Add row** button in the middle of the dashboard. +1. (Optional) To provide context to dashboard users, add the variable name to the panel, row, or tab title. +1. When you've finished setting the repeat option, click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. -1. Hover over the row title and click the cog icon. -1. In the **Row Options** dialog box, add a title and select the variable for which you want to add repeating rows. -1. Click **Update**. +### Repeating rows and tabs and the Dashboard special data source -To provide context to dashboard users, add the variable to the row title. - -### Repeating rows and the Dashboard special data source - -If a row includes panels using the special [Dashboard data source](ref:built-in-special-data-sources)—the data source that uses a result set from another panel in the same dashboard—then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows. +If a row includes panels using the special [Dashboard data source](https://grafana.com/docs/grafana//datasources/#special-data-sources)—the data source that uses a result set from another panel in the same dashboard—then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows. +The same behavior applies to tabs. For example, in a dashboard: @@ -242,28 +427,196 @@ For example, in a dashboard: - Repeating row, `Row 2`, includes `Panel 2A` and `Panel 2B` - `Panel 2B` references `Panel 1A`, not `Panel 2A` -## Move a panel +## Show/hide rules -You can place a panel on a dashboard in any location. +You can configure panels, rows, and tabs to be shown or hidden based on rules. +For example, you can set a panel to be hidden if there's no data returned by a query or a tab to only be shown if a specific variable value is present. -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to work on. -1. Click **Edit** in the top-right corner. -1. Click the panel title and drag the panel to the new location. -1. Click **Save dashboard**. +There are three types of show/hide rules to choose from: + +- [Query result](#query-result-rule) +- [Template variable](#template-variable-rule) +- [Time range less than](#time-range-less-than-rule) + +For steps on how to create show/hide rules, refer to [Configure show/hide rules](#configure-showhide-rules). + +{{< admonition type="note" >}} +You can only configure show/hide rules for panels in the **Auto grid** layout. Set the panel layout at the dashboard, row, or tab-level. +{{< /admonition >}} + +### Query result rule + +Show or hide a panel based on whether or not the query returns any results. +The rule provides **Has data** and **No data** options, so you can choose to show or hide the panel based on the presence or absence of data. + +For example, if you have a dashboard with several panels and only want panels that return data to appear, set the rule as follows: + +- Panel visibility > Show +- Query result > Has data + +Alternatively, you might also want to troubleshoot a dashboard with several panels to see which ones contain broken queries that aren't returning any results. +In this case, you'd set the rule as follows: + +- Panel visibility > Show +- Query result > No data + +### Template variable rule + +Show or hide a panel, row, or tab dynamically based on the variable value. +You can select any variable that's configured for the dashboard and choose from the following operators for maximum flexibility: + +- Equals +- Not equals +- Matches (regular expression values) +- Not matches (regular expression values) + +You can [add more variables](#add-variables) if you need to without leaving the dashboard. + +### Time range less than rule + +Show or hide a panel, row, or tab if the dashboard time range is shorter than the selected time range. +This ensures that as you change the time range of the dashboard, you only see data relevant to that time period. + +For example, a dashboard is tracking adoption of a feature over time has the following setup: + +- Dashboard time range is **Last 7 days** +- One panel tracks weekly stats +- One panel tracks daily stats + +For the panel tracking weekly stats, a rule is set up to hide it if the dashboard time range is less than 7 days. +For the panel tracking daily stats, a rule is set up to hide it if the dashboard time range is less 24 hours. +This configuration ensures that these time-based panels are only displayed when enough time has passed to make them relevant. + +For this rule type, you can select time ranges from **5 minutes** to **5 years**. + +### Configure show/hide rules + +To configure show/hide rules, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the panel, row, or tab you want to update to open the edit pane, or click the **Dashboard options** icon to open it. + + If the dashboard is large, open the **Content outline** and use it to navigate to the part of the dashboard you want to update. + +1. Expand the **Show / hide rules** section. +1. Select **Show** or **Hide** to set whether the panel, row, or tab is shown or hidden based on the rules outcome. +1. Click **+ Add rule**. +1. Select a rule type: + - **Query result**: Show or hide a panel based on query results. Choose from **Has data** and **No data**. + - **Template variable**: Show or hide the panel, row, or tab dynamically based on the variable value. Select a variable and operator and enter a value. + - **Time range less than**: Show or hide the panel, row, or tab if the dashboard time range is shorter than the selected time range. Select a time range from **5 minutes** to **5 years**. + +1. If you've configured more than rule, under **Match rules**, select one of the following: + - **Match all**: The panel, row, or tab is shown or hidden only if _all_ the rules are matched. + - **Match any**: The panel, row, or tab is shown or hidden if _any_ of the rules are matched. + + This option is only displayed if you add multiple rules. + +1. When you've finished setting rules, click **Save**. 1. (Optional) Enter a description of the changes you've made. 1. Click **Save**. -1. Click **Exit edit**. +1. Click **Exit edit** + +Hidden panels, rows, or tabs aren't visible when the dashboard is in view mode. +In edit mode, hidden dashboard elements are displayed with an icon or overlay indicating this. + +## Move a panel + +To move a panel, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Navigate to the panel you want to move. + + If the dashboard is large, open the **Content outline** and use it to navigate to the panel. + +1. Click the panel title and drag the panel to another row or tab, or to a new position on the dashboard. + + If the dashboard has groupings, you can only move the panel to another grouping. + +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit** ## Resize a panel -You can size a dashboard panel to suits your needs. +When your dashboard or grouping has a **Custom** layout, you can manually resize a panel. -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to work on. -1. Click **Edit** in the top-right corner. -1. To adjust the size of the panel, click and drag the lower-right corner of the panel. -1. Click **Save dashboard**. +To resize a panel, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Navigate to the panel you want to resize. + + If the dashboard is large, open the **Content outline** and use it to navigate to the panel. + +1. Click and drag the lower-right corner of the panel to change the size of the panel. +1. Click **Save**. 1. (Optional) Enter a description of the changes you've made. 1. Click **Save**. -1. Click **Exit edit**. +1. Click **Exit edit** + +## Add variables + +To add variables without leaving the dashboard, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click **+ Add variable** at the top of the dashboard. +1. Choose a variable type from the list. +1. Set the options for the variable. +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit** + +### Add variables using the content outline + +You can also add variables without leaving the dashboard using the content outline. + +To access the variables creation flow this way, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the **Content outline** icon. +1. Click **Variables** in the outline. +1. Click **+ Add variable**. +1. Complete the rest of the steps to [add a variable without leaving the dashboard](#add-variables). + +## Copy or duplicate dashboard elements + +You can copy and paste or duplicate panels, rows, and tabs. + +To copy or duplicate dashboard elements, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the panel, row, or tab you want to update to open the edit pane, or click the **Dashboard options** icon to open it. + + If the dashboard is large, open the **Content outline** and use it to navigate to the part of the dashboard you want to update. + +1. In the top-corner of the edit pane, click the **Copy or Duplicate** icon and do one of the following: + - Click **Copy**. + - Click **Duplicate**. The duplicated element is added next to the original one. Proceed to step 6. + +1. If you selected **Copy**, navigate to the part of the dashboard where you want to add the copied element, and click **Paste panel**, **Paste row**, or **Paste tab**. +1. Update the copied or duplicated element if needed. +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit** + +## Copy a dashboard + +To make a copy of a dashboard, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the **Save** drop-down list and select **Save as copy**. +1. (Optional) Specify the name, folder, description, and whether or not to copy the original dashboard tags for the copied dashboard. + + By default, the copied dashboard has the same name as the original dashboard with the word "Copy" appended and is in the same folder. + +1. Click **Save**. diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md deleted file mode 100644 index 0167ff147e5..00000000000 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md +++ /dev/null @@ -1,416 +0,0 @@ ---- -labels: - products: - - cloud - - oss - stage: - - experimental -_build: - list: false -noindex: true -title: Create a dynamic dashboard -description: Create and edit a dynamic dashboard -weight: 900 -refs: - built-in-special-data-sources: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/#special-data-sources - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/#special-data-sources - visualization-specific-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/visualizations/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/ - configure-standard-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-standard-options/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/ - configure-value-mappings: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-value-mappings/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-value-mappings/ - generative-ai-features: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - configure-thresholds: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-thresholds/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-thresholds/ - data-sources: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/ - add-a-data-source: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/#add-a-data-source - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/#add-a-data-source - about-users-and-permissions: - - pattern: /docs/grafana/ - destination: /docs/grafana//administration/roles-and-permissions/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/roles-and-permissions/ - visualizations-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/visualizations/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/visualizations/ - configure-repeating-panels: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-panel-options/#configure-repeating-panels - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-panel-options/#configure-repeating-panels - override-field-values: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-overrides/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/ -aliases: - - ../../../dashboards/build-dashboards/create-dynamic-dashboard/ # /docs/grafana/next/dashboards/build-dashboards/create-dynamic-dashboard/ ---- - -# Create and edit dynamic dashboards - -{{< admonition type="caution" >}} - -Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). - -**Do not enable this feature in production environments as it may result in the irreversible loss of data.** - -{{< /admonition >}} - -Dashboards and panels allow you to show your data in visual form. Each panel needs at least one query to display a visualization. - -## Before you begin - -- Ensure that you have the proper permissions. For more information about permissions, refer to [About users and permissions](ref:about-users-and-permissions). -- Identify the dashboard to which you want to add the panel. -- Understand the query language of the target data source. -- Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source](ref:add-a-data-source) if you need instructions. - -## Create a dashboard - -To create a dashboard, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Click **New** and select **New Dashboard**. -1. In the edit pane, enter the dashboard title and description. - - {{< figure src="/media/docs/grafana/dashboards/screenshot-new-dashboard-v12.png" max-width="750px" alt="New dashboard" >}} - -1. Under **Panel layout**, choose one of the following options: - - **Custom** - Position and size panels manually. The default selection. - - **Auto grid** - Panels are automatically resized to create a uniform grid based on the column and row settings. - -1. Click **+ Add visualization**. -1. In the dialog box that opens, do one of the following: - - Select one of your existing data sources. - - Select one of the Grafana [built-in special data sources](ref:built-in-special-data-sources). - - Click **Configure a new data source** to set up a new one (Admins only). - - {{< figure class="float-right" src="/media/docs/grafana/dashboards/screenshot-data-source-selector-10.0.png" max-width="800px" alt="Select data source modal" >}} - - The **Edit panel** view opens with your data source selected. - You can change the panel data source later using the drop-down in the **Query** tab of the panel editor if needed. - - For more information about data sources, refer to [Data sources](ref:data-sources) for specific guidelines. - -1. Write or construct a query in the query language of your data source. -1. Click **Refresh** to query the data source. -1. In the visualization list, select a visualization type. - - {{< figure src="/media/docs/grafana/dashboards/screenshot-select-visualization-v12.png" max-width="350px" alt="Visualization selector" >}} - - Grafana displays a preview of your query results with the visualization applied. - - For more information about configuring individual visualizations, refer to [Visualizations options](ref:visualizations-options). - -1. Under **Panel options**, enter a title and description for your panel or have Grafana create them using [generative AI features](ref:generative-ai-features). -1. Refer to the following documentation for ways you can adjust panel settings. - - While not required, most visualizations need some adjustment before they properly display the information that you need. - - [Configure value mappings](ref:configure-value-mappings) - - [Visualization-specific options](ref:visualization-specific-options) - - [Override field values](ref:override-field-values) - - [Configure thresholds](ref:configure-thresholds) - - [Configure standard options](ref:configure-standard-options) - -1. When you've finished editing your panel, click **Save**. - - Alternatively, click **Back to dashboard** if you want to see your changes applied to the dashboard first. Then click **Save** when you're ready. - -1. Enter a title and description for your dashboard if you haven't already or have Grafana create them using [generative AI features](ref:generative-ai-features). -1. Select a folder, if applicable. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. -1. To add more panels to the dashboard, click **Back to dashboard** and at the bottom-left corner of the dashboard, click **+ Add panel**. - - {{< figure src="/media/docs/grafana/dashboards/screenshot-add-panel-v12.png" max-width="500px" alt="Add panel button" >}} - -1. (Optional) In the edit pane, enter a title and description for the panel and set the panel transparency and repeat options, if applicable. -1. Click **Configure** in either the edit pane or on the panel to the configuration process. -1. When you've saved all the changes you want to make to the dashboard, click **Back to dashboard**. -1. Toggle off the edit mode switch. - -{{< admonition type="caution" >}} - -Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). - -**Do not enable this feature in production environments as it may result in the irreversible loss of data.** - -{{< /admonition >}} - -## Group panels - -To help create meaningful sections in your dashboard, you can group panels into rows or tabs. -Rows and tabs let you break up big dashboards or make one dashboard out of several smaller ones. -You can nest tabs and rows within each other or themselves. -Also, tabs are included in the dashboard URL. - -The following sections describe the configuration options for adding tabs and rows. -While grouping is meant for multiple panels, you can start a grouping with just one panel. - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. -1. At the bottom-left corner of the dashboard, click **Group panels**. -1. Select **Group into row** or **Group into tab**. - - A dotted line surrounds the panels and the **Row** or **Tab** edit pane is displayed on the right side of the dashboard. - -1. Set the [grouping configuration options](#grouping-configuration-options). -1. When you're finished, click **Save** at the top-right corner of the dashboard. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. - -### Grouping configuration options - -The following table describes the options you can set for a row. - - - -| Option | Description | -| ------ | ----------- | -| Title | Title of the row or tab. | -| Fill screen | Toggle the switch on to make the row fill the screen. Only applies to rows. | -| Hide row header | Toggle the switch on to hide the header. In edit mode, the row header is visible, but crossed out with the hidden icon next to it. Only applies to rows. | -| Group layout | Select the grouping option, between **Rows** and **Tabs**. Only available when there's a nested grouping and applies to the nested grouping. | -| Panel layout | Select whether panels are sized and positioned manually, **Custom**, or automatically, **Auto grid**. Only available when a grouping contains panels. | -| Repeat options > [Repeat by variable](#configure-repeat-options) | Configure the dashboard to dynamically add rows or tabs based on the value of a variable. | -| Show / hide rules > [Row/Tab visibility](#configure-showhide-rules) | Control whether or not rows or tabs are displayed based on variables or a time range. | - - - -## Configure repeat options - - - -You can configure Grafana to dynamically add panels, rows, or tabs to a dashboard based on the value of that variable. -Variables dynamically change your queries across all rows in a dashboard. - -This only applies to queries that include a multi-value variable. - - - -To configure repeats, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. - - The **Dashboard** edit pane opens on the right side of the dashboard. - -1. Click in the panel, row, or tab you want to work with to bring it into focus and display the associated options in the edit pane. -1. Expand the **Repeat options** section. -1. Select the **Repeat by variable**. -1. For panels only, set the following options: - - Under **Repeat direction**, choose one of the following: - - **Horizontal** - Arrange panels side-by-side. Grafana adjusts the width of a repeated panel. You can’t mix other panels on a row with a repeated panel. - - **Vertical** - Arrange panels in a column. The width of repeated panels is the same as the original, repeated panel. - - - If you selected **Horizontal**, select a value in the **Max per row** drop-down list to control the maximum number of panels that can be in a row. - -1. (Optional) To provide context to dashboard users, add the variable name to the panel, row, or tab title. -1. When you've finished setting the repeat option, click **Save**. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. -1. Toggle off the edit mode switch. - -### Repeating rows and tabs and the Dashboard special data source - - - -If a row includes panels using the special [Dashboard data source](ref:built-in-special-data-sources)—the data source that uses a result set from another panel in the same dashboard—then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows. -The same behavior applies to tabs. - -For example, in a dashboard: - -- `Row 1` includes `Panel 1A` and `Panel 1B` -- `Panel 1B` uses the results from `Panel 1A` by way of the `-- Dashboard --` data source -- Repeating row, `Row 2`, includes `Panel 2A` and `Panel 2B` -- `Panel 2B` references `Panel 1A`, not `Panel 2A` - -## Configure show/hide rules - -You can configure panels, rows, and tabs to be shown or hidden based on rules. -For example, you might want to set a panel to be hidden if there's no data returned by a query or a tab to only be shown based on a variable being present. - -{{< admonition type="note" >}} -You can only configure show/hide rules for panels when the dashboard is using the **Auto grid** panel layout. -{{< /admonition >}} - -To configure show/hide rules, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. - - The **Dashboard** edit pane opens on the right side of the dashboard. - -1. Click in the panel, row, or tab you want to work with to bring it into focus and display the associated options in the edit pane. -1. Expand the **Show / hide rules** section. -1. Select **Show** or **Hide** to set whether the panel, row, or tab is shown or hidden based on the rules outcome. -1. Click **+ Add rule**. -1. Select a rule type: - - **Query result** - Show or hide a panel based on query results. Choose from **Has data** and **No data**. For panels only. - - **Template variable** - Show or hide the panel, row, or tab dynamically based on the variable value. Select a variable and operator and enter a value. - - **Time range less than** - Show or hide the panel, row, or tab if the dashboard time range is shorter than the selected time frame. Select or enter a time range. - -1. Configure the rule. -1. Under **Match rules**, select one of the following: - - **Match all** - The panel, row, or tab is shown or hidden only if _all_ the rules are matched. - - **Match any** - The panel, row, or tab is shown or hidden if _any_ of the rules are matched. - - This option is only displayed if you add multiple rules. - -1. When you've finished setting rules, click **Save**. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. -1. Toggle off the edit mode switch. - -{{< admonition type="caution" >}} - -Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). - -**Do not enable this feature in production environments as it may result in the irreversible loss of data.** - -{{< /admonition >}} - -## Edit dashboards - -When the dashboard is in edit mode, the edit pane that opens displays options associated with the part of the dashboard that it's in focus. -For example, if you click in the area of a panel, row, or tab, that area comes into focus and the edit pane shows the options for that area: - -{{< figure src="/media/docs/grafana/dashboards/screenshot-edit-pane-focus-v12.png" max-width="750px" alt="Dashboard with a panel in focus" >}} - -- For rows and tabs, all of the available options are in the edit pane. -- For panels, high-level options are in the edit pane and further configuration options are in the **Edit panel** view. -- For dashboards, high-level options are in the edit pane and further configuration options are in the **Settings** page. - -To edit dashboards, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. - - The **Dashboard** edit pane opens on the right side of the dashboard. - -1. Click in the area you want to work with to bring it into focus and display the associated options in the edit pane. -1. Do one of the following: - - For rows or tabs, make the required changes using the edit pane. - - For panels, update the panel title, description, repeat options or show/hide rules in the edit pane. For more changes, click **Configure** and continue in **Edit panel** view. - - For dashboards, update the dashboard title, description, grouping or panel layout. For more changes, click the settings (gear) icon in the top-right corner. - -1. When you've finished making changes, click **Save**. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. -1. Toggle off the edit mode switch. - -### Undo and redo - -When a dashboard is in edit mode, you can undo and redo changes you've made using the buttons on the toolbar: - -{{< figure src="/media/docs/grafana/dashboards/screenshot-undo-redo-icons-v12.0.png" max-width="500px" alt="Undo and redo buttons" >}} - -When you've made a change and hover the cursor over the buttons, the tooltip displays the change you're about to undo or redo. -Also, you can continue undoing or redoing as many changes as you need: - -{{< video-embed src="/media/docs/grafana/dashboards/screen-record-undo-redo-v12.0.mp4" >}} - -The undo and redo buttons are only available at the dashboard level and only apply to changes made there, such as dashboard layout and grouping and high-level dashboard or panel updates. -They aren't visible and don't apply when you're configuring a panel or making changes in the dashboard settings. - -{{< admonition type="note" >}} -Not all dashboard edit actions can be undone or redone yet. -{{< /admonition >}} - -## Move or resize a panel - - - -When you're dashboard has a **Custom** layout, you can resize or move a panel to any location on the dashboard. - -To move or resize, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. -1. Do one of the following: - - Click the panel title and drag the panel to the new location. - - Click and drag the lower-right corner of the panel to change the size of the panel. - -1. Click **Save**. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. -1. Toggle off the edit mode switch. - -## Navigate using the dashboard outline - -The dashboard **Outline** provides a tree-like structure that shows you all of the parts of your dashboard and their relationships to each other including panels, rows, tabs, and variables. -The outline also lets you quickly navigate the dashboard so that you don't have to spend time finding a particular element to work with it. -By default, the outline is collapsed except for the part that's currently in focus. - -{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-outline-v12.png" max-width="750px" alt="Dashboard with outline open showing panel in focus" >}} - -To navigate the dashboard using the outline, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. - - The **Dashboard** edit pane opens on the right side of the dashboard. - -1. In the edit pane, expand the **Outline** section. -1. Expand the outline to find the dashboard part to which you want to navigate. -1. Click the tree item to navigate that part of the dashboard. - -## Copy a dashboard - -To make a copy of a dashboard, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. -1. Click the **Save** drop-down and select **Save as copy**. -1. (Optional) Specify the name, folder, description, and whether or not to copy the original dashboard tags for the copied dashboard. - - By default, the copied dashboard has the same name as the original dashboard with the word "Copy" appended and is in the same folder. - -1. Click **Save**. - -{{< admonition type="caution" >}} - -Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). - -**Do not enable this feature in production environments as it may result in the irreversible loss of data.** - -{{< /admonition >}} diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-template-dashboards/_index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-template-dashboards/_index.md index e737b240189..d741b3b53da 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-template-dashboards/_index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/create-template-dashboards/_index.md @@ -3,20 +3,25 @@ keywords: - grafana - dashboard - template + - suggestions labels: products: - cloud - enterprise - oss -menuTitle: Create template dashboards -title: Create dashboards from templates -description: Learn how to create dashboards from templates +menuTitle: Create template and suggested dashboards +title: Create dashboards from templates and suggestions +description: Learn how to create dashboards from templates and suggestions weight: 3 --- -{{< docs/public-preview product="Dashboard templates" >}} +# Create dashboards from templates and suggestions -# Create dashboards from templates +Grafana provides alternative ways to start building a dashboard. + +## Create dashboards from templates + +{{< docs/public-preview product="Dashboard templates" >}} Grafana provides a variety of pre-built dashboard templates that you can use to quickly set up visualizations for your data. These dashboards use sample data, which you can replace with your own data, making it easier to get started with monitoring and analysis. @@ -48,3 +53,23 @@ To create a dashboard from a template, follow these steps: {{< figure src="/media/docs/grafana/dashboards/screenshot-remove-banner-v12.3.png" max-width="750px" alt="Removing the sample data banner panel" >}} 1. Click **Save dashboard**. + +## Create dashboards from suggestions + +{{< docs/public-preview product="Suggested dashboards" >}} + +You can start the process of creating a dashboard directly from a data source rather than from the **Dashboards** page, which gives you access to suggestions based on the data source. + +To begin building a dashboard directly from a data source, follow these steps: + +1. Navigate to **Connections > Data sources**. +1. On the row of the data source for which you want to build a dashboard, click **Build a dashboard**. + + The empty dashboard page opens. + +1. Select one of the suggested dashboards by clicking its **Use dashboard** button. This can be helpful when you're not sure how to most effectively visualize your data. + The suggested dashboards are specific to your data source type (for example, Prometheus, Loki, or Elasticsearch). If there are more than three dashboard suggestions, you can click **View all** to see the rest of them. + + ![Empty dashboard with add visualization and suggested dashboard options](/media/docs/grafana/dashboards/screenshot-suggested-dashboards-v12.3.png) + +1. Complete the rest of the dashboard configuration. For more detailed steps, refer to [Create a dashboard](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/create-dashboard/), beginning at step five. diff --git a/docs/sources/visualizations/dashboards/build-dashboards/manage-dashboard-links/index.md b/docs/sources/visualizations/dashboards/build-dashboards/manage-dashboard-links/index.md index 3e94ec5aa22..104f3e69b07 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/manage-dashboard-links/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/manage-dashboard-links/index.md @@ -85,7 +85,8 @@ Once you've added a dashboard link, it appears in the upper right corner of your Add links to other dashboards at the top of your current dashboard. 1. In the dashboard you want to link, click **Edit**. -1. Click **Settings**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **Links** tab and then click **Add dashboard link**. The default link type is **Dashboards**. @@ -109,7 +110,8 @@ Add links to other dashboards at the top of your current dashboard. Add a link to a URL at the top of your current dashboard. You can link to any available URL, including dashboards, panels, or external sites. You can even control the time range to ensure the user is zoomed in on the right data in Grafana. 1. In the dashboard you want to link, click **Edit**. -1. Click **Settings**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **Links** tab and then click **Add dashboard link**. 1. In the **Type** drop-down, select **Link**. 1. In the **URL** field, enter the URL to which you want to link. @@ -132,7 +134,8 @@ Add a link to a URL at the top of your current dashboard. You can link to any av To edit, duplicate, or delete dashboard link, follow these steps: 1. In the dashboard you want to link, click **Edit**. -1. Click **Settings**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **Links** tab. 1. Do one of the following: - **Edit** - Click the name of the link and update the link settings. diff --git a/docs/sources/visualizations/dashboards/build-dashboards/manage-version-history/index.md b/docs/sources/visualizations/dashboards/build-dashboards/manage-version-history/index.md index 712cb4c4205..2f08fbed199 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/manage-version-history/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/manage-version-history/index.md @@ -14,7 +14,7 @@ labels: - cloud - enterprise - oss -menutitle: Manage version history +menuTitle: Manage version history title: Manage dashboard version history description: View and compare previous versions of your dashboard weight: 400 @@ -32,8 +32,9 @@ The dashboard version history feature lets you compare and restore to previously To compare two dashboard versions, follow these steps: -1. Click **Edit** in the top-right corner of the dashboard. -1. Click **Settings**. +1. Click **Edit**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **Versions** tab. 1. Select the two dashboard versions that you want to compare. 1. Click **Compare versions** to view the diff between the two versions. @@ -49,8 +50,9 @@ When you're comparing versions, if one of the versions you've selected is the la To restore to a previously saved dashboard version, follow these steps: -1. Click **Edit** in the top-right corner of the dashboard. -1. Click **Settings**. +1. Click **Edit**. +1. Click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **Versions** tab. 1. Click the **Restore** button next to the version. diff --git a/docs/sources/visualizations/dashboards/build-dashboards/modify-dashboard-settings/index.md b/docs/sources/visualizations/dashboards/build-dashboards/modify-dashboard-settings/index.md index dbc49c40bde..e9f1f408c0d 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/modify-dashboard-settings/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/modify-dashboard-settings/index.md @@ -50,8 +50,9 @@ The dashboard settings page allows you to: To access the dashboard setting page: -1. Click **Edit** in the top-right corner of the dashboard. -1. Click **Settings**. +1. Click **Edit**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. ## Modify dashboard time settings diff --git a/docs/sources/visualizations/dashboards/build-dashboards/view-dashboard-json-model/index.md b/docs/sources/visualizations/dashboards/build-dashboards/view-dashboard-json-model/index.md index 7720a4c946a..b09283bc190 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/view-dashboard-json-model/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/view-dashboard-json-model/index.md @@ -3,45 +3,75 @@ aliases: - ../../../reference/dashboard/ # /docs/grafana/next/reference/dashboard/ - ../../../dashboards/json-model/ # /docs/grafana/next/dashboards/json-model/ - ../../../dashboards/build-dashboards/view-dashboard-json-model/ # /docs/grafana/next/dashboards/build-dashboards/view-dashboard-json-model/ + - ../../../as-code/observability-as-code/schema-v2/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/ + - ../../../as-code/observability-as-code/schema-v2/annotations-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/annotations-schema/ + - ../../../as-code/observability-as-code/schema-v2/panel-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/panel-schema/ + - ../../../as-code/observability-as-code/schema-v2/librarypanel-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/librarypanel-schema/ + - ../../../as-code/observability-as-code/schema-v2/layout-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/layout-schema/ + - ../../../as-code/observability-as-code/schema-v2/links-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/links-schema/ + - ../../../as-code/observability-as-code/schema-v2/timesettings-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/timesettings-schema/ + - ../../../as-code/observability-as-code/schema-v2/variables-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/variables-schema/ + - ../../../observability-as-code/schema-v2/ # /docs/grafana/latest/observability-as-code/schema-v2/ + - ../../../../next/observability-as-code/schema-v2/annotations-schema/ # /docs/grafana/next/observability-as-code/schema-v2/annotations-schema/ + - ../../../../next/observability-as-code/schema-v2/panel-schema/ # /docs/grafana/next/observability-as-code/schema-v2/panel-schema/ + - ../../../../next/observability-as-code/schema-v2/librarypanel-schema/ # /docs/grafana/next/observability-as-code/schema-v2/librarypanel-schema/ + - ../../../../next/observability-as-code/schema-v2/layout-schema/ # /docs/grafana/next/observability-as-code/schema-v2/layout-schema/ + - ../../../../next/observability-as-code/schema-v2/links-schema/ # /docs/grafana/next/observability-as-code/schema-v2/links-schema/ + - ../../../../next/observability-as-code/schema-v2/timesettings-schema/ # /docs/grafana/next/observability-as-code/schema-v2/timesettings-schema/ + - ../../../../next/observability-as-code/schema-v2/variables-schema/ # /docs/grafana/next/observability-as-code/schema-v2/variables-schema/ keywords: - grafana - dashboard - documentation - json - model + - schema v2 + - v1 resource + - v2 resource + - classic labels: products: - cloud - enterprise - oss title: JSON model -description: View your Grafana dashboard JSON object +description: View and update your Grafana dashboard JSON object weight: 700 -refs: - annotations: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/annotate-visualizations/ --- # Dashboard JSON model -A dashboard in Grafana is represented by a JSON object, which stores metadata of its dashboard. Dashboard metadata includes dashboard properties, metadata from panels, template variables, panel queries, etc. +Grafana dashboards are represented as JSON objects that store metadata, panels, variables, and settings. -To view the JSON of a dashboard: +## Different dashboard schema models -1. Click **Edit** in the top-right corner of the dashboard. -1. Click **Settings**. +There are currently three dashboard JSON schema models: + +- [Classic](#classic-model) - A non-Kubernetes resource used before the adoption of the Kubernetes API by Grafana in v12.2.0. It's been widely used for exporting, importing, and sharing dashboards in the Grafana dashboards collection at [grafana.com/dashboards](https://grafana.com/grafana/dashboards/). +- [V1 Resource](#v1-resource-model) - The Classic dashboard schema formatted as a Kubernetes-style resource. Its `spec` property contains the Classic model of the schema. This is the default format for API communication after Grafana v12.2.0, which enabled the Kubernetes Platform API as default backend for Grafana dashboards. Dashboards created using the Classic model can be exported using either the Classic or the V1 Resource format. +- [V2 Resource](#v2-resource-model) - The latest format, supporting new features such as advanced layouts and conditional rendering. It models all dashboard elements as Kubernetes kinds, following Kubernetes conventions for declaring dashboard components. This format is future-proof and represents the evolving standard for dashboards. + +{{< admonition type="note" >}} +[Observability as Code](https://grafana.com/docs/grafana/latest/as-code/observability-as-code/) works with all versions of the JSON model, and it's fully compatible with version 2. +{{< /admonition >}} + +## Access and update the JSON model (#view-json) + +To access the JSON representation of a dashboard: + +1. Click **Edit**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **JSON Model** tab. 1. When you've finished viewing the JSON, click **Back to dashboard** and **Exit edit**. -## JSON fields +## Classic model -When a user creates a new dashboard, a new dashboard JSON object is initialized with the following fields: +When you create a new dashboard in self-managed Grafana, a new dashboard JSON object was initialized with the following fields: {{< admonition type="note" >}} -In the following JSON, id is shown as null which is the default value assigned to it until a dashboard is saved. Once a dashboard is saved, an integer value is assigned to the `id` field. +In the following JSON, id is shown as null which is the default value assigned to it until a dashboard is saved. +After a dashboard is saved, an integer value is assigned to the `id` field. {{< /admonition >}} ```json @@ -76,26 +106,30 @@ In the following JSON, id is shown as null which is the default value assigned t Each field in the dashboard JSON is explained below with its usage: -| Name | Usage | -| ----------------- | ----------------------------------------------------------------------------------------------------------------- | -| **id** | unique numeric identifier for the dashboard. (generated by the db) | -| **uid** | unique dashboard identifier that can be generated by anyone. string (8-40) | -| **title** | current title of dashboard | -| **tags** | tags associated with dashboard, an array of strings | -| **style** | theme of dashboard, i.e. `dark` or `light` | -| **timezone** | timezone of dashboard, i.e. `utc` or `browser` | -| **editable** | whether a dashboard is editable or not | -| **graphTooltip** | 0 for no shared crosshair or tooltip (default), 1 for shared crosshair, 2 for shared crosshair AND shared tooltip | -| **time** | time range for dashboard, i.e. last 6 hours, last 7 days, etc | -| **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details | -| **templating** | templating metadata, see [templating section](#templating) for details | -| **annotations** | annotations metadata, see [annotations](ref:annotations) for how to add them | -| **refresh** | auto-refresh interval | -| **schemaVersion** | version of the JSON schema (integer), incremented each time a Grafana update brings changes to said schema | -| **version** | version of the dashboard (integer), incremented each time the dashboard is updated | -| **panels** | panels array, see below for detail. | + -## Panels +| Name | Usage | +| ----------------- | ------------------------------------------------------------------------------------------ | +| **id** | unique numeric identifier for the dashboard. (generated by the db) | +| **uid** | unique dashboard identifier that can be generated by anyone. string (8-40) | +| **title** | current title of dashboard | +| **tags** | tags associated with dashboard, an array of strings | +| **style** | theme of dashboard, i.e. `dark` or `light` | +| **timezone** | timezone of dashboard, i.e. `utc` or `browser` | +| **editable** | whether a dashboard is editable or not | +| **graphTooltip** | 0 for no shared crosshair or tooltip (default), 1 for shared crosshair, 2 for shared crosshair AND shared tooltip | +| **time** | time range for dashboard, i.e. last 6 hours, last 7 days, etc | +| **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details | +| **templating** | templating metadata, see [templating section](#templating) for details | +| **annotations** | annotations metadata, see [annotations](https://grafana.com/docs/grafana//dashboards/build-dashboards/annotate-visualizations/) for how to add them | +| **refresh** | auto-refresh interval| +| **schemaVersion** | version of the JSON schema (integer), incremented each time a Grafana update brings changes to said schema | +| **version** | version of the dashboard (integer), incremented each time the dashboard is updated | +| **panels** | panels array, see below for detail. | + + + +### Panels Panels are the building blocks of a dashboard. It consists of data source queries, type of graphs, aliases, etc. Panel JSON consists of an array of JSON objects, each representing a different panel. Most of the fields are common for all panels but some fields depend on the panel type. Following is an example of panel JSON of a text panel. @@ -168,18 +202,22 @@ The grid has a negative gravity that moves panels up if there is empty space abo Usage of the fields is explained below: -| Name | Usage | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| **collapse** | whether timepicker is collapsed or not | -| **enable** | whether timepicker is enabled or not | -| **notice** | | -| **now** | | -| **hidden** | whether timepicker is hidden or not | -| **nowDelay** | override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. | -| **quick_ranges** | custom quick ranges | -| **refresh_intervals** | interval options available in the refresh picker dropdown | -| **status** | | -| **type** | | + + +| Name | Usage | +| --------------------- | --------------------------------------------------------- | +| **collapse** | whether timepicker is collapsed or not | +| **enable** | whether timepicker is enabled or not | +| **notice** | | +| **now** | | +| **hidden** | whether timepicker is hidden or not | +| **nowDelay** | override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. | +| **quick_ranges** | custom quick ranges | +| **refresh_intervals** | interval options available in the refresh picker dropdown | +| **status** | | +| **type** | | + + ### templating @@ -270,3 +308,82 @@ Usage of the above mentioned fields in the templating section is explained below | **refresh** | configures when to refresh a variable | | **regex** | extracts part of a series name or metric node segment | | **type** | type of variable, i.e. `custom`, `query` or `interval` | + +## V1 Resource model + +The V1 Resource schema model formats the [Classic JSON model](#classic-model) schema as a Kubernetes-style resource. +The `spec` property of the schema contains the Classic-style model of the schema. + +Dashboards created using the Classic model can be exported using either this model or the Classic one. + +The following code snippet shows the fields included in the V1 Resource model. + +```json +{ + "apiVersion": "dashboard.grafana.app/v1beta1", + "kind": "Dashboard", + "metadata": { + "name": "isnt5ss", + "namespace": "stacks-521104", + "uid": "92674c0e-0360-4bb4-99ab-fb150581376d", + "resourceVersion": "1764705030717045", + "generation": 1, + "creationTimestamp": "2025-12-02T19:50:30Z", + "labels": { + "grafana.app/deprecatedInternalID": "1329" + }, + "annotations": { + "grafana.app/createdBy": "user:u000000002", + "grafana.app/folder": "", + "grafana.app/saved-from-ui": "Grafana Cloud (instant)" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1329, + "links": [], + "panels": [], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "Africa/Abidjan", + "title": "Graphite suggestions", + "uid": "isnt5ss", + "version": 1, + "weekStart": "" + }, + "status": {} +} +``` + +## V2 Resource model + +{{< docs/public-preview product="Dashboard JSON schema v2" >}} + +For the detailed V2 Resource model schema, refer to the [Swagger documentation](https://play.grafana.org/swagger?api=dashboard.grafana.app-v2beta1). diff --git a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md index e7749ba5b88..7087b3ac692 100644 --- a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md +++ b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md @@ -213,7 +213,7 @@ To export a dashboard in its current state as a PDF, follow these steps: 1. Click **Dashboards** in the main menu. 1. Open the dashboard you want to export. -1. Click the **Export** drop-down in the top-right corner and select **Export as PDF**. +1. Click the **Export** drop-down in the sidebar and select **Export as PDF**. 1. In the **Export dashboard PDF** drawer that opens, select either **Landscape** or **Portrait** for the PDF orientation. 1. Select either **Grid** or **Simple** for the PDF layout. 1. Set the **Zoom** level; zoom in to enlarge text, or zoom out to see more data (like table columns) per panel. @@ -229,7 +229,7 @@ Export a Grafana JSON file that contains everything you need, including layout, 1. Click **Dashboards** in the main menu. 1. Open the dashboard you want to export. -1. Click the **Export** drop-down list in the top-right corner and select **Export as code**. +1. Click the **Export** drop-down list in the sidebar and select **Export as code**. The **Export dashboard** drawer opens. @@ -255,7 +255,7 @@ To export a dashboard in its current state as a PNG image file, follow these ste 1. Click **Dashboards** in the main menu. 1. Open the dashboard you want to export. -1. Click the **Export** drop-down list in the top-right corner and select **Export as image**. +1. Click the **Export** drop-down list in the sidebar and select **Export as image**. The **Export as image** drawer opens. diff --git a/docs/sources/visualizations/dashboards/use-dashboards/index.md b/docs/sources/visualizations/dashboards/use-dashboards/index.md index 108d6bc8136..5c7141a22bc 100644 --- a/docs/sources/visualizations/dashboards/use-dashboards/index.md +++ b/docs/sources/visualizations/dashboards/use-dashboards/index.md @@ -21,67 +21,144 @@ menuTitle: Use dashboards title: Use dashboards description: Learn about the features of a Grafana dashboard weight: 100 -refs: - dashboard-analytics: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/assess-dashboard-usage/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/assess-dashboard-usage/ - generative-ai-features: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - dashboard-settings: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/build-dashboards/modify-dashboard-settings/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/modify-dashboard-settings/ - repeating-rows: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/build-dashboards/create-dashboard/#configure-repeating-rows - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows - variables: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/variables/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/variables/ - dashboard-folders: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/manage-dashboards/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/manage-dashboards/ - sharing: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/share-dashboards-panels/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/ - dashboard-links: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/build-dashboards/manage-dashboard-links/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/manage-dashboard-links/ - panel-overview: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/panel-overview/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/panel-overview/ - export-dashboards: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/share-dashboards-panels/#export-dashboards - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/#export-dashboards - add-ad-hoc-filters: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/variables/add-template-variables/#add-ad-hoc-filters - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/variables/add-template-variables/#add-ad-hoc-filters - shared-dashboards: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/shared-dashboards/ +image_maps: + - key: annotated-dashboard + src: /media/docs/grafana/dashboards/screenshot-ann-dashboards-v12.4.png + alt: An annotated image of a Grafana dashboard + points: + - x_coord: 8 + y_coord: 5 + content: | + **Dashboard folder** + + Click the dashboard folder name to access the folder and perform other [folder management tasks](https://grafana.com/docs/grafana//visualizations/dashboards/manage-dashboards/). + - x_coord: 17 + y_coord: 5 + content: | + **Dashboard title** + + Create your own dashboard titles or have Grafana create them for you using [generative AI features](https://grafana.com/docs/grafana//visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards). + - x_coord: 23 + y_coord: 5 + content: | + **Mark as favorite** + + Mark the dashboard as one of your favorites to include it in your list of **Starred** dashboards in the main menu. + - x_coord: 27 + y_coord: 5 + content: | + **Public label** + + [Externally shared dashboards](https://grafana.com/docs/grafana//visualizations/dashboards/share-dashboards-panels/shared-dashboards/), it's marked with the **Public** label. + - x_coord: 84 + y_coord: 5 + content: | + **Grafana Assistant** + + [Grafana Assistant](https://grafana.com/docs/grafana-cloud/machine-learning/assistant/introduction/) combines large language models with Grafana-integrated tools. + - x_coord: 89 + y_coord: 5 + content: | + **Invite new users** + + Invite new users to join your Grafana organization. + - x_coord: 32 + y_coord: 23 + content: | + **Variables** + + Use [variables](https://grafana.com/docs/grafana//visualizations/dashboards/variables/), including ad hoc filters, to create more interactive and dynamic dashboards. + - x_coord: 45 + y_coord: 23 + content: | + **Dashboard links** + + Link to other dashboards, panels, and external websites. Learn more about [dashboard links](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/manage-dashboard-links/). + - x_coord: 59 + y_coord: 29 + content: | + **Current dashboard time range and time picker** + + Select [relative time range](#relative-time-range) options or set custom [absolute time ranges](#absolute-time-range). + You can also change the **Timezone** and **Fiscal year** settings by clicking the **Change time settings** button. + - x_coord: 67 + y_coord: 29 + content: | + **Time range zoom out** + + Click to zoom out the time range. Learn more about [common time range controls](#common-time-range-controls). + - x_coord: 73 + y_coord: 29 + content: | + **Refresh dashboard** + + Trigger queries and refresh dashboard data. + - x_coord: 78 + y_coord: 29 + content: | + **Auto refresh control** + + Select a dashboard auto refresh time interval. + - x_coord: 85 + y_coord: 29 + content: | + **Share dashboard** + + Access [dashboard sharing](https://grafana.com/docs/grafana//visualizations/dashboards/share-dashboards-panels/) options. + - x_coord: 98 + y_coord: 22.5 + content: | + **Edit** + + Enter edit mode, so you can make changes and access dashboard settings. + - x_coord: 98 + y_coord: 31 + content: | + **Export** + + Access [dashboard exporting](https://grafana.com/docs/grafana//visualizations/dashboards/share-dashboards-panels/#export-dashboards) options. + - x_coord: 98 + y_coord: 39 + content: | + **Content outline** + + The outline provides a tree-like structure that lets you quickly navigate the dashboard. + - x_coord: 98 + y_coord: 47 + content: | + **Dashboard insights** + + View [dashboard analytics](https://grafana.com/docs/grafana//visualizations/dashboards/assess-dashboard-usage/) including information about users, activity, query counts. + - x_coord: 11.5 + y_coord: 30 + content: | + **Row title** + + A row is one way you can [group panels](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/create-dashboard/#panel-groupings) in a dashboard. + - x_coord: 20 + y_coord: 36 + content: | + **Tab title** + + A tab is one way you can [group panels](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/create-dashboard/#panel-groupings) in a dashboard. + - x_coord: 21 + y_coord: 45 + content: | + **Panel title** + + Create your own panel titles or have Grafana create them for you using [generative AI features](https://grafana.com/docs/grafana//visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards). + - x_coord: 27 + y_coord: 63 + content: | + **Dashboard panel** + + The [panel](https://grafana.com/docs/grafana//panels-visualizations/panel-overview/) is the primary building block of a dashboard. + - x_coord: 19.5 + y_coord: 91 + content: | + **Panel legend** + + Change series colors as well as y-axis and series visibility directly from the legend. --- # Use dashboards @@ -95,32 +172,9 @@ This topic provides an overview of dashboard features and shortcuts, and describ The dashboard user interface provides a number of features that you can use to customize the presentation of your data. The following image and descriptions highlight all dashboard features. +Hover your cursor over a number to display information about the dashboard element. -![An annotated image of a dashboard](/media/docs/grafana/dashboards/screenshot-dashboard-annotated-v11.3-2.png) - -1. **Dashboard folder** - When you click the dashboard folder name, you can search for other dashboards contained in the folder and perform other [folder management tasks](ref:dashboard-folders). -1. **Dashboard title** - You can create your own dashboard titles or have Grafana create them for you using [generative AI features](ref:generative-ai-features). -1. **Kiosk mode** - Click to display the dashboard on a large screen such as a TV or a kiosk. Kiosk mode hides the main menu, navbar, and dashboard controls. Learn more about kiosk mode in our [How to Create Kiosks to Display Dashboards on a TV blog post](https://grafana.com/blog/2019/05/02/grafana-tutorial-how-to-create-kiosks-to-display-dashboards-on-a-tv/). Press `Esc` to leave kiosk mode. -1. **Mark as favorite** - Mark the dashboard as one of your favorites so it's included in your list of **Starred** dashboards in the main menu. -1. **Public label** - When you [share a dashboard externally](ref:shared-dashboards), it's marked with the **Public** label. -1. **Dashboard insights** - Click to view analytics about your dashboard including information about users, activity, query counts. Learn more about [dashboard analytics](ref:dashboard-analytics). -1. **Edit** - Click to leave view-only mode and enter edit mode, where you can make changes directly to the dashboard and access dashboard settings, as well as several panel editing functions. -1. **Export** - Access [dashboard exporting](ref:export-dashboards) options. -1. **Share dashboard** - Access several [dashboard sharing](ref:sharing) options. -1. **Variables** - Use [variables](ref:variables), including ad hoc filters, to create more interactive and dynamic dashboards. -1. **Dashboard links** - Link to other dashboards, panels, and external websites. Learn more about [dashboard links](ref:dashboard-links). -1. **Current dashboard time range and time picker** - Click to select [relative time range](#relative-time-range) options and set custom [absolute time ranges](#absolute-time-range). - - You can change the **Timezone** and **Fiscal year** settings from the time range controls by clicking the **Change time settings** button. - - Time settings are saved on a per-dashboard basis. -1. **Time range zoom out** - Click to zoom out the time range. Learn more about how to use [common time range controls](#common-time-range-controls). -1. **Refresh dashboard** - Click to immediately trigger queries and refresh dashboard data. -1. **Auto refresh control** - Click to select a dashboard auto refresh time interval. -1. **Dashboard row** - A dashboard row is a logical divider within a dashboard that groups panels together. - - Rows can be collapsed or expanded allowing you to hide parts of the dashboard. - - Panels inside a collapsed row do not issue queries. - - Use [repeating rows](ref:repeating-rows) to dynamically create rows based on a template variable. -1. **Dashboard panel** - The [panel](ref:panel-overview) is the primary building block of a dashboard. -1. **Panel legend** - Change series colors as well as y-axis and series visibility directly from the legend. +{{< image-map key="annotated-dashboard" >}} ## Keyboard shortcuts @@ -134,7 +188,7 @@ Grafana has a number of keyboard shortcuts available. Press `?` on your keyboard - `Ctrl+K`: Opens the command palette. - `Esc`: Exits panel when in full screen view or edit mode. Also returns you to the dashboard from dashboard settings. -**Focused panel** +### Focused panel By hovering over a panel with the mouse you can use some shortcuts that will target that panel. @@ -263,13 +317,16 @@ Click the **Copy time range to clipboard** icon to copy the current time range t You can also copy and paste a time range using the keyboard shortcuts `t+c` and `t+v` respectively. -#### Zoom out (Cmd+Z or Ctrl+Z) +#### Zoom out -Click the **Zoom out** icon to view a larger time range in the dashboard or panel visualization. +- Click the **Zoom out** icon to view a larger time range in the dashboard or panel visualizations +- Double click on the panel graph area (time series family visualizations only) +- Type the `t-` keyboard shortcut -#### Zoom in (only applicable to graph visualizations) +#### Zoom in -Click and drag to select the time range in the visualization that you want to view. +- Click and drag horizontally in the panel graph area to select a time range (time series family visualizations only) +- Type the `t+` keyboard shortcut #### Refresh dashboard @@ -285,7 +342,7 @@ Selecting the **Auto** interval schedules a refresh based on the query time rang ## Filter dashboard data -Once you've [added an ad hoc filter](ref:add-ad-hoc-filters) in the dashboard settings, you can create label/value filter pairs on the dashboard. +Once you've [added an ad hoc filter](https://grafana.com/docs/grafana//visualizations/dashboards/variables/add-template-variables/#add-ad-hoc-filters) in the dashboard settings, you can create label/value filter pairs on the dashboard. These filters are applied to all metric queries that use the specified data source and to all panels on the dashboard. To filter dashboard data, follow these steps: diff --git a/docs/sources/visualizations/panels-visualizations/configure-panel-options/index.md b/docs/sources/visualizations/panels-visualizations/configure-panel-options/index.md index 3a2185d699a..68d41969eda 100644 --- a/docs/sources/visualizations/panels-visualizations/configure-panel-options/index.md +++ b/docs/sources/visualizations/panels-visualizations/configure-panel-options/index.md @@ -35,9 +35,9 @@ refs: destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/manage-dashboard-links/#panel-links configure-repeating-rows: - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/build-dashboards/create-dashboard/#configure-repeating-rows + destination: /docs/grafana//dashboards/build-dashboards/create-dashboard/#configure-repeat-options - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/create-dashboard/#configure-repeat-options set-up-generative-ai-features-for-dashboards: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards diff --git a/docs/sources/visualizations/panels-visualizations/panel-overview/index.md b/docs/sources/visualizations/panels-visualizations/panel-overview/index.md index 8f6708492a3..9f0d07367a1 100644 --- a/docs/sources/visualizations/panels-visualizations/panel-overview/index.md +++ b/docs/sources/visualizations/panels-visualizations/panel-overview/index.md @@ -175,9 +175,10 @@ By hovering over a panel with the mouse you can use some shortcuts that will tar - `pl`: Hide or show legend - `pr`: Remove Panel -## Zoom panel time range +## Pan and zoom panel time range -You can zoom the panel time range in and out, which in turn, changes the dashboard time range. +You can pan the panel time range left and right, and zoom it and in and out. +This, in turn, changes the dashboard time range. This feature is supported for the following visualizations: @@ -191,7 +192,7 @@ This feature is supported for the following visualizations: Click and drag on the panel to zoom in on a particular time range. -The following screen recordings show this interaction in the time series and x visualizations: +The following screen recordings show this interaction in the time series and candlestick visualizations: Time series @@ -211,7 +212,7 @@ For example, if the original time range is from 9:00 to 9:59, the time range cha - Next range: 8:30 - 10:29 - Next range: 7:30 - 11:29 -The following screen recordings demonstrate the preceding example in the time series and x visualizations: +The following screen recordings demonstrate the preceding example in the time series and heatmap visualizations: Time series @@ -221,6 +222,19 @@ Heatmap {{< video-embed src="/media/docs/grafana/panels-visualizations/recording-heatmap-panel-time-zoom-out-mouse.mp4" >}} +### Pan + +Click and drag the x-axis area of the panel to pan the time range. + +The time range shifts by the distance you drag. +For example, if the original time range is from 9:00 to 9:59 and you drag 30 minutes to the right, the time range changes to 9:30 to 10:29. + +The following screen recordings show this interaction in the time series visualization: + +Time series + +{{< video-embed src="/media/docs/grafana/panels-visualizations/recording-ts-time-pan-mouse.mp4" >}} + ## Add a panel To add a panel in a new dashboard click **+ Add visualization** in the middle of the dashboard: diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md index e5760e78199..7748dd749f1 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md @@ -92,9 +92,9 @@ The data is converted as follows: {{< figure src="/media/docs/grafana/panels-visualizations/screenshot-candles-volume-v11.6.png" max-width="750px" alt="A candlestick visualization showing the price movements of specific asset." >}} -## Zoom panel time range +## Pan and zoom panel time range -{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} +{{< docs/shared lookup="visualizations/panel-pan-zoom.md" source="grafana" version="" >}} ## Configuration options diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md index b54326968a6..3a4a448ae85 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md @@ -30,7 +30,9 @@ refs: # Datagrid -{{< docs/experimental product="The datagrid visualization" featureFlag="`enableDatagridEditing`" >}} +{{< admonition type="caution" >}} +Starting with Grafana 12.4, Datagrid is deprecated. It will be removed in version 13.0. +{{< /admonition >}} Datagrids offer you the ability to create, edit, and fine-tune data within Grafana. As such, this panel can act as a data source for other panels inside a dashboard. diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md index 56c91db0e0d..b6005c1761e 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md @@ -79,9 +79,9 @@ The data is converted as follows: {{< figure src="/static/img/docs/heatmap-panel/heatmap.png" max-width="1025px" alt="A heatmap visualization showing the random walk distribution over time" >}} -## Zoom panel time range +## Pan and zoom panel time range -{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} +{{< docs/shared lookup="visualizations/panel-pan-zoom.md" source="grafana" version="" >}} ## Configuration options diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md index ef4066c2b6c..0a25b3153a1 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md @@ -93,9 +93,9 @@ You can also create a state timeline visualization using time series data. To do ![State timeline with time series](/media/docs/grafana/panels-visualizations/screenshot-state-timeline-time-series-v11.4.png) -## Zoom panel time range +## Pan and zoom panel time range -{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} +{{< docs/shared lookup="visualizations/panel-pan-zoom.md" source="grafana" version="" >}} ## Configuration options diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md index 834f50ca733..c3ed5504ac5 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md @@ -85,9 +85,9 @@ The data is converted as follows: {{< figure src="/static/img/docs/status-history-panel/status_history.png" max-width="1025px" alt="A status history panel with two time columns showing the status of two servers" >}} -## Zoom panel time range +## Pan and zoom panel time range -{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} +{{< docs/shared lookup="visualizations/panel-pan-zoom.md" source="grafana" version="" >}} ## Configuration options diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md index a171c88259a..86ff2290b48 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md @@ -167,9 +167,9 @@ The following example shows three series: Min, Max, and Value. The Min and Max s {{< docs/shared lookup="visualizations/multiple-y-axes.md" source="grafana" version="" leveloffset="+2" >}} -## Zoom panel time range +## Pan and zoom panel time range -{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} +{{< docs/shared lookup="visualizations/panel-pan-zoom.md" source="grafana" version="" >}} ## Configuration options diff --git a/e2e-playwright/alerting-suite/saved-searches.spec.ts b/e2e-playwright/alerting-suite/saved-searches.spec.ts index 28de805a5a1..b4f6431a56b 100644 --- a/e2e-playwright/alerting-suite/saved-searches.spec.ts +++ b/e2e-playwright/alerting-suite/saved-searches.spec.ts @@ -2,6 +2,15 @@ import { Page } from '@playwright/test'; import { test, expect } from '@grafana/plugin-e2e'; +// Enable required feature toggles for Saved Searches (part of RuleList.v2) +test.use({ + featureToggles: { + alertingListViewV2: true, + alertingFilterV2: true, + alertingSavedSearches: true, + }, +}); + /** * UI selectors for Saved Searches e2e tests. * Each selector is a function that takes the page and returns a locator. @@ -26,26 +35,50 @@ const ui = { // Indicators emptyState: (page: Page) => page.getByText(/no saved searches/i), - defaultIcon: (page: Page) => page.locator('[title="Default search"]'), + defaultIcon: (page: Page) => page.getByRole('img', { name: /default search/i }), duplicateError: (page: Page) => page.getByText(/already exists/i), }; /** - * Helper to clear saved searches storage. - * UserStorage uses localStorage as fallback, so we clear both potential keys. + * Helper to clear saved searches from UserStorage. + * UserStorage persists data server-side via k8s API, so we need to delete via API. */ async function clearSavedSearches(page: Page) { - await page.evaluate(() => { - // Clear localStorage keys that might contain saved searches - // UserStorage stores under 'grafana.userstorage.alerting' pattern - const keysToRemove = Object.keys(localStorage).filter( - (key) => key.includes('alerting') && (key.includes('savedSearches') || key.includes('userstorage')) - ); - keysToRemove.forEach((key) => localStorage.removeItem(key)); + // Get namespace and user info from Grafana config + const storageInfo = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bootData = (window as any).grafanaBootData; + const user = bootData?.user; + const userUID = user?.uid === '' || !user?.uid ? String(user?.id ?? 'anonymous') : user.uid; + const resourceName = `alerting:${userUID}`; + const namespace = bootData?.settings?.namespace || 'default'; - // Also clear session storage visited flag - const sessionKeysToRemove = Object.keys(sessionStorage).filter((key) => key.includes('alerting')); - sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key)); + return { namespace, resourceName }; + }); + + // Delete the UserStorage resource + try { + await page.request.delete( + `/apis/userstorage.grafana.app/v0alpha1/namespaces/${storageInfo.namespace}/user-storage/${storageInfo.resourceName}` + ); + } catch (error) { + // Ignore 404 errors (resource doesn't exist) + if (!(error && typeof error === 'object' && 'status' in error && error.status === 404)) { + console.warn('Failed to clear saved searches:', error); + } + } + + // Also clear localStorage as fallback storage + await page.evaluate(({ resourceName }) => { + // The UserStorage key pattern is always `{resourceName}:{key}` + // For saved searches, the key is 'savedSearches' + const key = `${resourceName}:savedSearches`; + window.localStorage.removeItem(key); + }, storageInfo); + + // Clear session storage visited flag + await page.evaluate(() => { + window.sessionStorage.removeItem('grafana.alerting.ruleList.visited'); }); } @@ -150,7 +183,7 @@ test.describe( await ui.saveButton(page).click(); - await ui.saveNameInput(page).fill('Apply Test'); + await ui.saveNameInput(page).fill('Firing Rules'); await ui.saveConfirmButton(page).click(); // Clear the search @@ -159,7 +192,7 @@ test.describe( // Apply the saved search await ui.savedSearchesButton(page).click(); - await page.getByRole('button', { name: /apply search.*apply test/i }).click(); + await page.getByRole('button', { name: /apply.*search.*firing rules/i }).click(); // Verify the search input is updated await expect(ui.searchInput(page)).toHaveValue('state:firing'); @@ -182,7 +215,7 @@ test.describe( await ui.renameMenuItem(page).click(); // Enter new name - const renameInput = page.getByDisplayValue('Original Name'); + const renameInput = page.getByRole('textbox', { name: /enter a name/i }); await renameInput.clear(); await renameInput.fill('Renamed Search'); await page.keyboard.press('Enter'); @@ -260,12 +293,12 @@ test.describe( await expect(ui.saveNameInput(page)).toBeVisible(); - // Press Escape to cancel + // Press Escape to cancel - this closes the entire dropdown await page.keyboard.press('Escape'); - // Verify we're back to list mode - await expect(ui.saveNameInput(page)).not.toBeVisible(); - await expect(ui.saveButton(page)).toBeVisible(); + // Verify the entire dialog is closed + await expect(ui.dropdown(page)).not.toBeVisible(); + await expect(ui.saveButton(page)).not.toBeVisible(); }); } ); diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f633ed5b4eb..25d3225375e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1337,6 +1337,11 @@ "count": 2 } }, + "public/app/features/alerting/unified/api/onCallApi.test.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, "public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -1377,6 +1382,11 @@ "count": 1 } }, + "public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx": { "no-restricted-syntax": { "count": 2 @@ -1617,11 +1627,31 @@ "count": 1 } }, + "public/app/features/alerting/unified/mocks/server/configure.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "public/app/features/alerting/unified/mocks/server/handlers/plugins.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "public/app/features/alerting/unified/rule-editor/clone.utils.test.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, "public/app/features/alerting/unified/rule-editor/formDefaults.ts": { "no-restricted-syntax": { "count": 6 } }, + "public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/alerting/unified/types/alerting.ts": { "@typescript-eslint/no-explicit-any": { "count": 5 @@ -1632,6 +1662,16 @@ "count": 1 } }, + "public/app/features/alerting/unified/utils/config.test.ts": { + "no-restricted-syntax": { + "count": 6 + } + }, + "public/app/features/alerting/unified/utils/config.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/alerting/unified/utils/datasource.ts": { "no-restricted-syntax": { "count": 2 @@ -1663,12 +1703,20 @@ "count": 1 } }, + "public/app/features/alerting/unified/utils/rules.test.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/alerting/unified/utils/rules.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 3 }, "@typescript-eslint/no-explicit-any": { "count": 1 + }, + "no-restricted-syntax": { + "count": 1 } }, "public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx": { @@ -1724,6 +1772,16 @@ "count": 1 } }, + "public/app/features/connections/components/AdvisorRedirectNotice/AdvisorRedirectNotice.test.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, + "public/app/features/connections/components/AdvisorRedirectNotice/AdvisorRedirectNotice.tsx": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/connections/tabs/ConnectData/ConnectData.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -2063,6 +2121,11 @@ "count": 1 } }, + "public/app/features/dashboard/components/GenAI/utils.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx": { "no-restricted-syntax": { "count": 3 @@ -2889,6 +2952,71 @@ "count": 1 } }, + "public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts": { + "no-restricted-syntax": { + "count": 6 + } + }, + "public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts": { + "no-restricted-syntax": { + "count": 6 + } + }, + "public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts": { + "no-restricted-syntax": { + "count": 6 + } + }, + "public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts": { + "no-restricted-syntax": { + "count": 6 + } + }, + "public/app/features/plugins/extensions/usePluginComponent.test.tsx": { + "no-restricted-syntax": { + "count": 3 + } + }, + "public/app/features/plugins/extensions/usePluginComponents.test.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, + "public/app/features/plugins/extensions/usePluginFunctions.test.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, + "public/app/features/plugins/extensions/usePluginLinks.test.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, + "public/app/features/plugins/extensions/utils.test.tsx": { + "no-restricted-syntax": { + "count": 27 + } + }, + "public/app/features/plugins/extensions/utils.tsx": { + "no-restricted-syntax": { + "count": 7 + } + }, + "public/app/features/plugins/extensions/validators.test.tsx": { + "no-restricted-syntax": { + "count": 30 + } + }, + "public/app/features/plugins/extensions/validators.ts": { + "no-restricted-syntax": { + "count": 4 + } + }, + "public/app/features/plugins/sandbox/codeLoader.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/plugins/sandbox/distortions.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -3615,46 +3743,21 @@ "count": 1 } }, - "public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 } }, - "public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 } }, - "public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 2 - } - }, "public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 } }, - "public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx": { "no-restricted-syntax": { "count": 1 diff --git a/eslint.config.js b/eslint.config.js index 5e44ffebaf4..479f11aac66 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -117,6 +117,8 @@ module.exports = [ 'scripts/grafana-server/tmp', 'packages/grafana-ui/src/graveyard', // deprecated UI components slated for removal 'public/build-swagger', // swagger build output + 'apps/plugins/plugin/src/generated/meta/v0alpha1', + 'apps/plugins/plugin/src/generated/plugin/v0alpha1', ], }, ...grafanaConfig, @@ -575,6 +577,42 @@ module.exports = [ "Property[key.name='a11y'][value.type='ObjectExpression'] Property[key.name='test'][value.value='off']", message: 'Skipping a11y tests is not allowed. Please fix the component or story instead.', }, + { + selector: 'MemberExpression[object.name="config"][property.name="apps"]', + message: + 'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead', + }, + ], + }, + }, + { + files: [...commonTestIgnores], + ignores: [ + // FIXME: Remove once all enterprise issues are fixed - + // we don't have a suppressions file/approach for enterprise code yet + ...enterpriseIgnores, + ], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'MemberExpression[object.name="config"][property.name="apps"]', + message: + 'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead', + }, + ], + }, + }, + { + files: [...enterpriseIgnores], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'MemberExpression[object.name="config"][property.name="apps"]', + message: + 'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead', + }, ], }, }, diff --git a/go.mod b/go.mod index fe3e62e3fde..ade26f2e7d1 100644 --- a/go.mod +++ b/go.mod @@ -44,8 +44,8 @@ require ( github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend github.com/blang/semver/v4 v4.0.0 // indirect; @grafana/grafana-developer-enablement-squad - github.com/blevesearch/bleve/v2 v2.5.0 // @grafana/grafana-search-and-storage - github.com/blevesearch/bleve_index_api v1.2.7 // @grafana/grafana-search-and-storage + github.com/blevesearch/bleve/v2 v2.5.7 // @grafana/grafana-search-and-storage + github.com/blevesearch/bleve_index_api v1.3.0 // @grafana/grafana-search-and-storage github.com/blugelabs/bluge v0.2.2 // @grafana/grafana-backend-group github.com/blugelabs/bluge_segment_api v0.2.0 // @grafana/grafana-backend-group github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // @grafana/grafana-backend-group @@ -365,22 +365,22 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.22.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect - github.com/blevesearch/geo v0.1.20 // indirect - github.com/blevesearch/go-faiss v1.0.25 // indirect + github.com/blevesearch/geo v0.2.4 // indirect + github.com/blevesearch/go-faiss v1.0.26 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect github.com/blevesearch/gtreap v0.1.1 // indirect github.com/blevesearch/mmap-go v1.0.4 // indirect - github.com/blevesearch/scorch_segment_api/v2 v2.3.9 // indirect + github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect github.com/blevesearch/segment v0.9.1 // indirect github.com/blevesearch/snowballstem v0.9.0 // indirect github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect github.com/blevesearch/vellum v1.1.0 // indirect - github.com/blevesearch/zapx/v11 v11.4.1 // indirect - github.com/blevesearch/zapx/v12 v12.4.1 // indirect - github.com/blevesearch/zapx/v13 v13.4.1 // indirect - github.com/blevesearch/zapx/v14 v14.4.1 // indirect - github.com/blevesearch/zapx/v15 v15.4.1 // indirect - github.com/blevesearch/zapx/v16 v16.2.2 // indirect + github.com/blevesearch/zapx/v11 v11.4.2 // indirect + github.com/blevesearch/zapx/v12 v12.4.2 // indirect + github.com/blevesearch/zapx/v13 v13.4.2 // indirect + github.com/blevesearch/zapx/v14 v14.4.2 // indirect + github.com/blevesearch/zapx/v15 v15.4.2 // indirect + github.com/blevesearch/zapx/v16 v16.2.8 // indirect github.com/bluele/gcache v0.0.2 // indirect github.com/blugelabs/ice v1.0.0 // indirect github.com/blugelabs/ice/v2 v2.0.1 // indirect @@ -443,7 +443,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect - github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect github.com/gomodule/redigo v1.8.9 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.1 // indirect diff --git a/go.sum b/go.sum index 67d95c625b6..f997af7c68e 100644 --- a/go.sum +++ b/go.sum @@ -931,14 +931,14 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/blevesearch/bleve/v2 v2.5.0 h1:HzYqBy/5/M9Ul9ESEmXzN/3Jl7YpmWBdHM/+zzv/3k4= -github.com/blevesearch/bleve/v2 v2.5.0/go.mod h1:PcJzTPnEynO15dCf9isxOga7YFRa/cMSsbnRwnszXUk= -github.com/blevesearch/bleve_index_api v1.2.7 h1:c8r9vmbaYQroAMSGag7zq5gEVPiuXrUQDqfnj7uYZSY= -github.com/blevesearch/bleve_index_api v1.2.7/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= -github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM= -github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w= -github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U= -github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= +github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8= +github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA= +github.com/blevesearch/bleve_index_api v1.3.0 h1:DsMpWVjFNlBw9/6pyWf59XoqcAkhHj3H0UWiQsavb6E= +github.com/blevesearch/bleve_index_api v1.3.0/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= +github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= +github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= +github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI= +github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= @@ -947,8 +947,8 @@ github.com/blevesearch/mmap-go v1.0.2/go.mod h1:ol2qBqYaOUsGdm7aRMRrYGgPvnwLe6Y+ github.com/blevesearch/mmap-go v1.0.3/go.mod h1:pYvKl/grLQrBxuaRYgoTssa4rVujYYeenDp++2E+yvs= github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= -github.com/blevesearch/scorch_segment_api/v2 v2.3.9 h1:X6nJXnNHl7nasXW+U6y2Ns2Aw8F9STszkYkyBfQ+p0o= -github.com/blevesearch/scorch_segment_api/v2 v2.3.9/go.mod h1:IrzspZlVjhf4X29oJiEhBxEteTqOY9RlYlk1lCmYHr4= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc= github.com/blevesearch/segment v0.9.0/go.mod h1:9PfHYUdQCgHktBgvtUOF4x+pc4/l8rdH0u5spnW85UQ= github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= @@ -960,18 +960,18 @@ github.com/blevesearch/vellum v1.0.5/go.mod h1:atE0EH3fvk43zzS7t1YNdNC7DbmcC3uz+ github.com/blevesearch/vellum v1.0.7/go.mod h1:doBZpmRhwTsASB4QdUZANlJvqVAUdUyX0ZK7QJCTeBE= github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w= github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y= -github.com/blevesearch/zapx/v11 v11.4.1 h1:qFCPlFbsEdwbbckJkysptSQOsHn4s6ZOHL5GMAIAVHA= -github.com/blevesearch/zapx/v11 v11.4.1/go.mod h1:qNOGxIqdPC1MXauJCD9HBG487PxviTUUbmChFOAosGs= -github.com/blevesearch/zapx/v12 v12.4.1 h1:K77bhypII60a4v8mwvav7r4IxWA8qxhNjgF9xGdb9eQ= -github.com/blevesearch/zapx/v12 v12.4.1/go.mod h1:QRPrlPOzAxBNMI0MkgdD+xsTqx65zbuPr3Ko4Re49II= -github.com/blevesearch/zapx/v13 v13.4.1 h1:EnkEMZFUK0lsW/jOJJF2xOcp+W8TjEsyeN5BeAZEYYE= -github.com/blevesearch/zapx/v13 v13.4.1/go.mod h1:e6duBMlCvgbH9rkzNMnUa9hRI9F7ri2BRcHfphcmGn8= -github.com/blevesearch/zapx/v14 v14.4.1 h1:G47kGCshknBZzZAtjcnIAMn3oNx8XBLxp8DMq18ogyE= -github.com/blevesearch/zapx/v14 v14.4.1/go.mod h1:O7sDxiaL2r2PnCXbhh1Bvm7b4sP+jp4unE9DDPWGoms= -github.com/blevesearch/zapx/v15 v15.4.1 h1:B5IoTMUCEzFdc9FSQbhVOxAY+BO17c05866fNruiI7g= -github.com/blevesearch/zapx/v15 v15.4.1/go.mod h1:b/MreHjYeQoLjyY2+UaM0hGZZUajEbE0xhnr1A2/Q6Y= -github.com/blevesearch/zapx/v16 v16.2.2 h1:MifKJVRTEhMTgSlle2bDRTb39BGc9jXFRLPZc6r0Rzk= -github.com/blevesearch/zapx/v16 v16.2.2/go.mod h1:B9Pk4G1CqtErgQV9DyCSA9Lb7WZe4olYfGw7fVDZ4sk= +github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs= +github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= +github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE= +github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= +github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks= +github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= +github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0= +github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= +github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= +github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= +github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI= +github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14= github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= github.com/blugelabs/bluge v0.2.2 h1:gat8CqE6P6tOgeX30XGLOVNTC26cpM2RWVcreXWtYcM= @@ -1442,8 +1442,6 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2V github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo= -github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= diff --git a/go.work.sum b/go.work.sum index d064248a16a..6388285d2bf 100644 --- a/go.work.sum +++ b/go.work.sum @@ -520,14 +520,40 @@ github.com/benbjohnson/immutable v0.4.0 h1:CTqXbEerYso8YzVPxmWxh2gnoRQbbB9X1quUC github.com/benbjohnson/immutable v0.4.0/go.mod h1:iAr8OjJGLnLmVUr9MZ/rz4PWUy6Ouc2JLYuMArmvAJM= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= +github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8= +github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA= +github.com/blevesearch/bleve_index_api v1.2.8/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= +github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s= +github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= +github.com/blevesearch/bleve_index_api v1.3.0 h1:DsMpWVjFNlBw9/6pyWf59XoqcAkhHj3H0UWiQsavb6E= +github.com/blevesearch/bleve_index_api v1.3.0/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= +github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= +github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= +github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:kDy+zgJFJJoJYBvdfBSiZYBbdsUL0XcjHYWezpQBGPA= github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:9eJDeqxJ3E7WnLebQUlPD7ZjSce7AnDb9vjGmMCbD0A= github.com/blevesearch/goleveldb v1.0.1 h1:iAtV2Cu5s0GD1lwUiekkFHe2gTMCCNVj2foPclDLIFI= github.com/blevesearch/goleveldb v1.0.1/go.mod h1:WrU8ltZbIp0wAoig/MHbrPCXSOLpe79nz5lv5nqfYrQ= +github.com/blevesearch/scorch_segment_api/v2 v2.3.10/go.mod h1:Z3e6ChN3qyN35yaQpl00MfI5s8AxUJbpTR/DL8QOQ+8= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc= github.com/blevesearch/snowball v0.6.1 h1:cDYjn/NCH+wwt2UdehaLpr2e4BwLIjN4V/TdLsL+B5A= github.com/blevesearch/snowball v0.6.1/go.mod h1:ZF0IBg5vgpeoUhnMza2v0A/z8m1cWPlwhke08LpNusg= github.com/blevesearch/stempel v0.2.0 h1:CYzVPaScODMvgE9o+kf6D4RJ/VRomyi9uHF+PtB+Afc= github.com/blevesearch/stempel v0.2.0/go.mod h1:wjeTHqQv+nQdbPuJ/YcvOjTInA2EIc6Ks1FoSUzSLvc= +github.com/blevesearch/vellum v1.0.10/go.mod h1:ul1oT0FhSMDIExNjIxHqJoGpVrBpKCdgDQNxfqgJt7k= +github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs= +github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= +github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE= +github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= +github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks= +github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= +github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0= +github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= +github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= +github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= +github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI= +github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= @@ -998,8 +1024,6 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f h1:9tRhudagkQO2s61SLFLSziIdCm7XlkfypVKDxpcHokg= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f/go.mod h1:AsVdCBeDFN9QbgpJg+8voDAcgsW0RmNvBd70ecMMdC0= github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ= github.com/grafana/sqlds/v4 v4.2.4/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= @@ -1092,6 +1116,7 @@ github.com/jon-whit/go-grpc-prometheus v1.4.0/go.mod h1:iTPm+Iuhh3IIqR0iGZ91JJEg github.com/joncrlsn/dque v0.0.0-20211108142734-c2ef48c5192a h1:sfe532Ipn7GX0V6mHdynBk393rDmqgI0QmjLK7ct7TU= github.com/joncrlsn/dque v0.0.0-20211108142734-c2ef48c5192a/go.mod h1:dNKs71rs2VJGBAmttu7fouEsRQlRjxy0p1Sx+T5wbpY= github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= +github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= github.com/jsternberg/zap-logfmt v1.3.0 h1:z1n1AOHVVydOOVuyphbOKyR4NICDQFiJMn1IK5hVQ5Y= github.com/jsternberg/zap-logfmt v1.3.0/go.mod h1:N3DENp9WNmCZxvkBD/eReWwz1149BK6jEN9cQ4fNwZE= diff --git a/jest.config.js b/jest.config.js index 17a2ce9ca32..f9d431cf5d3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -82,6 +82,7 @@ module.exports = { // Decoupled plugins run their own tests so ignoring them here. '/public/app/plugins/datasource/azuremonitor', '/public/app/plugins/datasource/cloud-monitoring', + '/public/app/plugins/datasource/elasticsearch', '/public/app/plugins/datasource/grafana-postgresql-datasource', '/public/app/plugins/datasource/grafana-pyroscope-datasource', '/public/app/plugins/datasource/grafana-testdata-datasource', diff --git a/package.json b/package.json index a6887b216e4..0a39ff67aea 100644 --- a/package.json +++ b/package.json @@ -293,8 +293,8 @@ "@grafana/plugin-ui": "^0.11.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "v6.52.1", - "@grafana/scenes-react": "v6.52.1", + "@grafana/scenes": "6.52.2", + "@grafana/scenes-react": "6.52.2", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index 4ceed793cad..0fbbb9cddc9 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts @@ -727,17 +727,6 @@ const injectedRtkApi = api }), invalidatesTags: ['dashboards', 'permissions'], }), - restoreDashboardVersionByUid: build.mutation< - RestoreDashboardVersionByUidApiResponse, - RestoreDashboardVersionByUidApiArg - >({ - query: (queryArg) => ({ - url: `/dashboards/uid/${queryArg.uid}/restore`, - method: 'POST', - body: queryArg.restoreDashboardVersionCommand, - }), - invalidatesTags: ['dashboards', 'versions'], - }), getDashboardVersionsByUid: build.query({ query: (queryArg) => ({ url: `/dashboards/uid/${queryArg.uid}/versions`, @@ -2628,26 +2617,6 @@ export type UpdateDashboardPermissionsByUidApiArg = { uid: string; updateDashboardAclCommand: UpdateDashboardAclCommand; }; -export type RestoreDashboardVersionByUidApiResponse = /** status 200 (empty) */ { - /** FolderUID The unique identifier (uid) of the folder the dashboard belongs to. */ - folderUid?: string; - /** ID The unique identifier (id) of the created/updated dashboard. */ - id: number; - /** Status status of the response. */ - status: string; - /** Slug The slug of the dashboard. */ - title: string; - /** UID The unique identifier (uid) of the created/updated dashboard. */ - uid: string; - /** URL The relative URL for accessing the created/updated dashboard. */ - url: string; - /** Version The version of the dashboard. */ - version: number; -}; -export type RestoreDashboardVersionByUidApiArg = { - uid: string; - restoreDashboardVersionCommand: RestoreDashboardVersionCommand; -}; export type GetDashboardVersionsByUidApiResponse = /** status 200 (empty) */ DashboardVersionResponseMeta; export type GetDashboardVersionsByUidApiArg = { uid: string; @@ -4568,9 +4537,6 @@ export type DashboardAclUpdateItem = { export type UpdateDashboardAclCommand = { items?: DashboardAclUpdateItem[]; }; -export type RestoreDashboardVersionCommand = { - version?: number; -}; export type DashboardVersionMeta = { created?: string; createdBy?: string; @@ -6633,7 +6599,6 @@ export const { useGetDashboardPermissionsListByUidQuery, useLazyGetDashboardPermissionsListByUidQuery, useUpdateDashboardPermissionsByUidMutation, - useRestoreDashboardVersionByUidMutation, useGetDashboardVersionsByUidQuery, useLazyGetDashboardVersionsByUidQuery, useGetDashboardVersionByUidQuery, diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 384666ea7f8..60db9295fb4 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -35,6 +35,14 @@ }, "./test": { "@grafana-app/source": "./test/index.ts" + }, + "./themes/schema.generated.json": { + "@grafana-app/source": "./src/themes/schema.generated.json", + "default": "./dist/esm/themes/schema.generated.json" + }, + "./themes/definitions/*.json": { + "@grafana-app/source": "./src/themes/themeDefinitions/*.json", + "default": "./dist/esm/themes/themeDefinitions/*.json" } }, "publishConfig": { @@ -52,7 +60,7 @@ "typecheck": "tsc --emitDeclarationOnly false --noEmit", "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", "postpack": "mv package.json.bak package.json", - "themes-schema": "tsx ./src/themes/scripts/generateSchema.ts" + "themes-schema": "tsx ./scripts/generateSchema.ts" }, "dependencies": { "@braintree/sanitize-url": "7.0.1", @@ -102,6 +110,7 @@ "react-dom": "18.3.1", "rimraf": "6.0.1", "rollup": "^4.22.4", + "rollup-plugin-copy": "3.5.0", "rollup-plugin-esbuild": "6.2.1", "rollup-plugin-node-externals": "^8.0.0", "tsx": "^4.21.0", diff --git a/packages/grafana-data/rollup.config.ts b/packages/grafana-data/rollup.config.ts index 0c40d731724..50af331c37c 100644 --- a/packages/grafana-data/rollup.config.ts +++ b/packages/grafana-data/rollup.config.ts @@ -1,21 +1,40 @@ import json from '@rollup/plugin-json'; import { createRequire } from 'node:module'; +import copy from 'rollup-plugin-copy'; import { entryPoint, plugins, esmOutput, cjsOutput } from '../rollup.config.parts'; const rq = createRequire(import.meta.url); const pkg = rq('./package.json'); +const grafanaDataPlugins = [ + ...plugins, + copy({ + targets: [ + { + src: 'src/themes/schema.generated.json', + dest: 'dist/esm/', + }, + { + src: 'src/themes/themeDefinitions/*.json', + dest: 'dist/esm/', + }, + ], + flatten: false, + }), + json(), +]; + export default [ { input: entryPoint, - plugins: [...plugins, json()], + plugins: grafanaDataPlugins, output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, { input: 'src/unstable.ts', - plugins: [...plugins, json()], + plugins: grafanaDataPlugins, output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, diff --git a/packages/grafana-data/scripts/generateSchema.ts b/packages/grafana-data/scripts/generateSchema.ts new file mode 100644 index 00000000000..f461999376e --- /dev/null +++ b/packages/grafana-data/scripts/generateSchema.ts @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { NewThemeOptionsSchema } from '../src/themes/createTheme'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const jsonOut = path.join(__dirname, '..', 'src', 'themes', 'schema.generated.json'); + +fs.writeFileSync( + jsonOut, + JSON.stringify( + NewThemeOptionsSchema.toJSONSchema({ + target: 'draft-07', + }), + undefined, + 2 + ) +); + +console.log('Successfully generated theme schema'); diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 6027b566764..5ed081b00f0 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -844,7 +844,6 @@ export { DataLinkConfigOrigin, SupportedTransformationType, type InternalDataLink, - type LinkTarget, type LinkModel, type LinkModelSupplier, VariableOrigin, @@ -852,6 +851,7 @@ export { VariableSuggestionsScope, OneClickMode, } from './types/dataLink'; +export { type LinkTarget } from './types/linkTarget'; export { type Action, type ActionModel, diff --git a/packages/grafana-data/src/internal/index.ts b/packages/grafana-data/src/internal/index.ts index 1b1e3c64a7d..230cdd2cbf9 100644 --- a/packages/grafana-data/src/internal/index.ts +++ b/packages/grafana-data/src/internal/index.ts @@ -93,7 +93,6 @@ export { DataTransformerID } from '../transformations/transformers/ids'; export { mergeTransformer } from '../transformations/transformers/merge'; export { getThemeById } from '../themes/registry'; -export * as experimentalThemeDefinitions from '../themes/themeDefinitions'; export { GrafanaEdition } from '../types/config'; export { SIPrefix } from '../valueFormats/symbolFormatters'; diff --git a/packages/grafana-data/src/themes/registry.ts b/packages/grafana-data/src/themes/registry.ts index 4fca3c5d7be..cfa4a10c6e4 100644 --- a/packages/grafana-data/src/themes/registry.ts +++ b/packages/grafana-data/src/themes/registry.ts @@ -1,7 +1,18 @@ import { Registry, RegistryItem } from '../utils/Registry'; import { createTheme, NewThemeOptionsSchema } from './createTheme'; -import * as extraThemes from './themeDefinitions'; +import aubergine from './themeDefinitions/aubergine.json'; +import debug from './themeDefinitions/debug.json'; +import desertbloom from './themeDefinitions/desertbloom.json'; +import gildedgrove from './themeDefinitions/gildedgrove.json'; +import gloom from './themeDefinitions/gloom.json'; +import mars from './themeDefinitions/mars.json'; +import matrix from './themeDefinitions/matrix.json'; +import sapphiredusk from './themeDefinitions/sapphiredusk.json'; +import synthwave from './themeDefinitions/synthwave.json'; +import tron from './themeDefinitions/tron.json'; +import victorian from './themeDefinitions/victorian.json'; +import zen from './themeDefinitions/zen.json'; import { GrafanaTheme2 } from './types'; export interface ThemeRegistryItem extends RegistryItem { @@ -9,6 +20,21 @@ export interface ThemeRegistryItem extends RegistryItem { build: () => GrafanaTheme2; } +const extraThemes: { [key: string]: unknown } = { + aubergine, + debug, + desertbloom, + gildedgrove, + gloom, + mars, + matrix, + sapphiredusk, + synthwave, + tron, + victorian, + zen, +}; + /** * @internal * Only for internal use, never use this from a plugin diff --git a/packages/grafana-data/src/themes/scripts/generateSchema.ts b/packages/grafana-data/src/themes/scripts/generateSchema.ts deleted file mode 100644 index 09369f5e67f..00000000000 --- a/packages/grafana-data/src/themes/scripts/generateSchema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -import { NewThemeOptionsSchema } from '../createTheme'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -fs.writeFileSync( - path.join(__dirname, '../schema.generated.json'), - JSON.stringify( - NewThemeOptionsSchema.toJSONSchema({ - target: 'draft-07', - }), - undefined, - 2 - ) -); diff --git a/packages/grafana-data/src/themes/themeDefinitions/index.ts b/packages/grafana-data/src/themes/themeDefinitions/index.ts deleted file mode 100644 index b4270192032..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { default as aubergine } from './aubergine.json'; -export { default as debug } from './debug.json'; -export { default as desertbloom } from './desertbloom.json'; -export { default as gildedgrove } from './gildedgrove.json'; -export { default as mars } from './mars.json'; -export { default as matrix } from './matrix.json'; -export { default as sapphiredusk } from './sapphiredusk.json'; -export { default as synthwave } from './synthwave.json'; -export { default as tron } from './tron.json'; -export { default as victorian } from './victorian.json'; -export { default as zen } from './zen.json'; -export { default as gloom } from './gloom.json'; diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index b2d3c16a3b1..922d9273699 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -32,6 +32,7 @@ export type AppPluginConfig = { path: string; version: string; preload: boolean; + /** @deprecated it will be removed in a future release */ angular: AngularMeta; loadingStrategy: PluginLoadingStrategy; dependencies: PluginDependencies; @@ -219,6 +220,7 @@ export interface GrafanaConfig { snapshotEnabled: boolean; datasources: { [str: string]: DataSourceInstanceSettings }; panels: { [key: string]: PanelPluginMeta }; + /** @deprecated it will be removed in a future release */ apps: Record; auth: AuthSettings; minRefreshInterval: string; diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index 815b67f0352..ad556a75c76 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -1,5 +1,6 @@ import { ScopedVars } from './ScopedVars'; import { ExploreCorrelationHelperData, ExplorePanelsState } from './explore'; +import { LinkTarget } from './linkTarget'; import { InterpolateFunction } from './panel'; import { DataQuery } from './query'; import { TimeRange } from './time'; @@ -88,8 +89,6 @@ export interface InternalDataLink { range?: TimeRange; } -export type LinkTarget = '_blank' | '_self' | undefined; - /** * Processed Link Model. The values are ready to use */ diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index aa0581004e1..eed0d330481 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -356,7 +356,7 @@ export interface FeatureToggles { */ dashboardScene?: boolean; /** - * Enables experimental new dashboard layouts + * Enables new dashboard layouts */ dashboardNewLayouts?: boolean; /** @@ -531,6 +531,10 @@ export interface FeatureToggles { */ alertingListViewV2?: boolean; /** + * Enables the new Alerting navigation structure with improved menu grouping + */ + alertingNavigationV2?: boolean; + /** * Enables saved searches for alert rules list */ alertingSavedSearches?: boolean; @@ -1251,4 +1255,8 @@ export interface FeatureToggles { * Enables profiles exemplars support in profiles drilldown */ profilesExemplars?: boolean; + /** + * Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods + */ + alertingSyncDispatchTimer?: boolean; } diff --git a/packages/grafana-data/src/types/linkTarget.ts b/packages/grafana-data/src/types/linkTarget.ts new file mode 100644 index 00000000000..2cdd963da7a --- /dev/null +++ b/packages/grafana-data/src/types/linkTarget.ts @@ -0,0 +1,4 @@ +/** + * Target for links - controls whether link opens in new tab or same tab + */ +export type LinkTarget = '_blank' | '_self' | undefined; diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index f9ebb23fc07..815b9d04e2d 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -1,7 +1,7 @@ import { ComponentType } from 'react'; -import { LinkTarget } from './dataLink'; import { IconName } from './icon'; +import { LinkTarget } from './linkTarget'; export interface NavLinkDTO { id?: string; diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index acf1e36c905..b9d6491cf9b 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -11,6 +11,7 @@ import { DataFrame } from './dataFrame'; import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource'; import { FieldConfigSource } from './fieldOverrides'; import { IconName } from './icon'; +import { LinkTarget } from './linkTarget'; import { OptionEditorConfig } from './options'; import { PluginMeta } from './plugin'; import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; @@ -191,6 +192,7 @@ export interface PanelMenuItem { onClick?: (event: React.MouseEvent) => void; shortcut?: string; href?: string; + target?: LinkTarget; subMenu?: PanelMenuItem[]; } diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 045dfdcee0b..8b96ac8f70f 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -53,6 +53,7 @@ export interface PluginError { pluginType?: PluginType; } +/** @deprecated it will be removed in a future release */ export interface AngularMeta { detected: boolean; hideDeprecation: boolean; diff --git a/packages/grafana-data/src/unstable.ts b/packages/grafana-data/src/unstable.ts index 3200085428a..43c2ff3071f 100644 --- a/packages/grafana-data/src/unstable.ts +++ b/packages/grafana-data/src/unstable.ts @@ -9,4 +9,4 @@ * and be subject to the standard policies */ -export { default as themeJsonSchema } from './themes/schema.generated.json'; +export {}; diff --git a/packages/grafana-data/tsconfig.json b/packages/grafana-data/tsconfig.json index 8e6013e32d9..3513caf9127 100644 --- a/packages/grafana-data/tsconfig.json +++ b/packages/grafana-data/tsconfig.json @@ -8,7 +8,8 @@ "emitDeclarationOnly": true, "isolatedModules": true, "rootDirs": ["."], - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "resolveJsonModule": true }, "exclude": ["dist/**/*"], "include": [ diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 18cce14f236..99809235cab 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -86,6 +86,7 @@ export class GrafanaBootConfig { snapshotEnabled = true; datasources: { [str: string]: DataSourceInstanceSettings } = {}; panels: { [key: string]: PanelPluginMeta } = {}; + /** @deprecated it will be removed in a future release, use isAppPluginInstalled or getAppPluginVersion instead */ apps: Record = {}; auth: AuthSettings = {}; minRefreshInterval = ''; diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 58b30be8542..380b87fee7d 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -77,3 +77,5 @@ export { getCorrelationsService, setCorrelationsService, } from './services/CorrelationsService'; +export { getAppPluginVersion, isAppPluginInstalled } from './services/pluginMeta/apps'; +export { useAppPluginInstalled, useAppPluginVersion } from './services/pluginMeta/hooks'; diff --git a/packages/grafana-runtime/src/internal/index.ts b/packages/grafana-runtime/src/internal/index.ts index aed6b86ebfb..fa13873c094 100644 --- a/packages/grafana-runtime/src/internal/index.ts +++ b/packages/grafana-runtime/src/internal/index.ts @@ -29,3 +29,5 @@ export { export { UserStorage } from '../utils/userStorage'; export { initOpenFeature, evaluateBooleanFlag } from './openFeature'; +export { getAppPluginMeta, getAppPluginMetas, setAppPluginMetas } from '../services/pluginMeta/apps'; +export { useAppPluginMeta, useAppPluginMetas } from '../services/pluginMeta/hooks'; diff --git a/packages/grafana-runtime/src/services/pluginMeta/apps.test.ts b/packages/grafana-runtime/src/services/pluginMeta/apps.test.ts new file mode 100644 index 00000000000..554917041cc --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/apps.test.ts @@ -0,0 +1,258 @@ +import { evaluateBooleanFlag } from '../../internal/openFeature'; + +import { + getAppPluginMeta, + getAppPluginMetas, + getAppPluginVersion, + isAppPluginInstalled, + setAppPluginMetas, +} from './apps'; +import { initPluginMetas } from './plugins'; +import { app } from './test-fixtures/config.apps'; + +jest.mock('./plugins', () => ({ ...jest.requireActual('./plugins'), initPluginMetas: jest.fn() })); +jest.mock('../../internal/openFeature', () => ({ + ...jest.requireActual('../../internal/openFeature'), + evaluateBooleanFlag: jest.fn(), +})); + +const initPluginMetasMock = jest.mocked(initPluginMetas); +const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag); + +describe('when useMTPlugins flag is enabled and apps is not initialized', () => { + beforeEach(() => { + setAppPluginMetas({}); + jest.resetAllMocks(); + initPluginMetasMock.mockResolvedValue({ items: [] }); + evaluateBooleanFlagMock.mockReturnValue(true); + }); + + it('getAppPluginMetas should call initPluginMetas and return correct result', async () => { + const apps = await getAppPluginMetas(); + + expect(apps).toEqual([]); + expect(initPluginMetasMock).toHaveBeenCalledTimes(1); + }); + + it('getAppPluginMeta should call initPluginMetas and return correct result', async () => { + const result = await getAppPluginMeta('myorg-someplugin-app'); + + expect(result).toEqual(null); + expect(initPluginMetasMock).toHaveBeenCalledTimes(1); + }); + + it('isAppPluginInstalled should call initPluginMetas and return false', async () => { + const installed = await isAppPluginInstalled('myorg-someplugin-app'); + + expect(installed).toEqual(false); + expect(initPluginMetasMock).toHaveBeenCalledTimes(1); + }); + + it('getAppPluginVersion should call initPluginMetas and return null', async () => { + const result = await getAppPluginVersion('myorg-someplugin-app'); + + expect(result).toEqual(null); + expect(initPluginMetasMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('when useMTPlugins flag is enabled and apps is initialized', () => { + beforeEach(() => { + setAppPluginMetas({ 'myorg-someplugin-app': app }); + jest.resetAllMocks(); + evaluateBooleanFlagMock.mockReturnValue(true); + }); + + it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => { + const apps = await getAppPluginMetas(); + + expect(apps).toEqual([app]); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginMeta('myorg-someplugin-app'); + + expect(result).toEqual(app); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginMeta should return null if the pluginId is not found', async () => { + const result = await getAppPluginMeta('otherorg-otherplugin-app'); + + expect(result).toEqual(null); + }); + + it('isAppPluginInstalled should not call initPluginMetas and return true', async () => { + const installed = await isAppPluginInstalled('myorg-someplugin-app'); + + expect(installed).toEqual(true); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('isAppPluginInstalled should return false if the pluginId is not found', async () => { + const result = await isAppPluginInstalled('otherorg-otherplugin-app'); + + expect(result).toEqual(false); + }); + + it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginVersion('myorg-someplugin-app'); + + expect(result).toEqual('1.0.0'); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginVersion should return null if the pluginId is not found', async () => { + const result = await getAppPluginVersion('otherorg-otherplugin-app'); + + expect(result).toEqual(null); + }); +}); + +describe('when useMTPlugins flag is disabled and apps is not initialized', () => { + beforeEach(() => { + setAppPluginMetas({}); + jest.resetAllMocks(); + evaluateBooleanFlagMock.mockReturnValue(false); + }); + + it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => { + const apps = await getAppPluginMetas(); + + expect(apps).toEqual([]); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginMeta('myorg-someplugin-app'); + + expect(result).toEqual(null); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('isAppPluginInstalled should not call initPluginMetas and return false', async () => { + const result = await isAppPluginInstalled('myorg-someplugin-app'); + + expect(result).toEqual(false); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginVersion('myorg-someplugin-app'); + + expect(result).toEqual(null); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); +}); + +describe('when useMTPlugins flag is disabled and apps is initialized', () => { + beforeEach(() => { + setAppPluginMetas({ 'myorg-someplugin-app': app }); + jest.resetAllMocks(); + evaluateBooleanFlagMock.mockReturnValue(false); + }); + + it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => { + const apps = await getAppPluginMetas(); + + expect(apps).toEqual([app]); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginMeta('myorg-someplugin-app'); + + expect(result).toEqual(app); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginMeta should return null if the pluginId is not found', async () => { + const result = await getAppPluginMeta('otherorg-otherplugin-app'); + + expect(result).toEqual(null); + }); + + it('isAppPluginInstalled should not call initPluginMetas and return true', async () => { + const result = await isAppPluginInstalled('myorg-someplugin-app'); + + expect(result).toEqual(true); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('isAppPluginInstalled should return false if the pluginId is not found', async () => { + const result = await isAppPluginInstalled('otherorg-otherplugin-app'); + + expect(result).toEqual(false); + }); + + it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginVersion('myorg-someplugin-app'); + + expect(result).toEqual('1.0.0'); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginVersion should return null if the pluginId is not found', async () => { + const result = await getAppPluginVersion('otherorg-otherplugin-app'); + + expect(result).toEqual(null); + }); +}); + +describe('immutability', () => { + beforeEach(() => { + setAppPluginMetas({ 'myorg-someplugin-app': app }); + jest.resetAllMocks(); + evaluateBooleanFlagMock.mockReturnValue(false); + }); + + it('getAppPluginMetas should return a deep clone', async () => { + const mutatedApps = await getAppPluginMetas(); + + // assert we have correct props + expect(mutatedApps).toHaveLength(1); + expect(mutatedApps[0].dependencies.grafanaDependency).toEqual('>=10.4.0'); + expect(mutatedApps[0].extensions.addedLinks).toHaveLength(0); + + // mutate deep props + mutatedApps[0].dependencies.grafanaDependency = ''; + mutatedApps[0].extensions.addedLinks.push({ targets: [], title: '', description: '' }); + + // assert we have mutated props + expect(mutatedApps[0].dependencies.grafanaDependency).toEqual(''); + expect(mutatedApps[0].extensions.addedLinks).toHaveLength(1); + expect(mutatedApps[0].extensions.addedLinks[0]).toEqual({ targets: [], title: '', description: '' }); + + const apps = await getAppPluginMetas(); + + // assert that we have not mutated the source + expect(apps[0].dependencies.grafanaDependency).toEqual('>=10.4.0'); + expect(apps[0].extensions.addedLinks).toHaveLength(0); + }); + + it('getAppPluginMeta should return a deep clone', async () => { + const mutatedApp = await getAppPluginMeta('myorg-someplugin-app'); + + // assert we have correct props + expect(mutatedApp).toBeDefined(); + expect(mutatedApp!.dependencies.grafanaDependency).toEqual('>=10.4.0'); + expect(mutatedApp!.extensions.addedLinks).toHaveLength(0); + + // mutate deep props + mutatedApp!.dependencies.grafanaDependency = ''; + mutatedApp!.extensions.addedLinks.push({ targets: [], title: '', description: '' }); + + // assert we have mutated props + expect(mutatedApp!.dependencies.grafanaDependency).toEqual(''); + expect(mutatedApp!.extensions.addedLinks).toHaveLength(1); + expect(mutatedApp!.extensions.addedLinks[0]).toEqual({ targets: [], title: '', description: '' }); + + const result = await getAppPluginMeta('myorg-someplugin-app'); + + // assert that we have not mutated the source + expect(result).toBeDefined(); + expect(result!.dependencies.grafanaDependency).toEqual('>=10.4.0'); + expect(result!.extensions.addedLinks).toHaveLength(0); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/apps.ts b/packages/grafana-runtime/src/services/pluginMeta/apps.ts new file mode 100644 index 00000000000..7db359b5a4b --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/apps.ts @@ -0,0 +1,71 @@ +import type { AppPluginConfig } from '@grafana/data'; + +import { config } from '../../config'; +import { evaluateBooleanFlag } from '../../internal/openFeature'; + +import { getAppPluginMapper } from './mappers/mappers'; +import { initPluginMetas } from './plugins'; +import type { AppPluginMetas } from './types'; + +let apps: AppPluginMetas = {}; + +function initialized(): boolean { + return Boolean(Object.keys(apps).length); +} + +async function initAppPluginMetas(): Promise { + if (!evaluateBooleanFlag('useMTPlugins', false)) { + // eslint-disable-next-line no-restricted-syntax + apps = config.apps; + return; + } + + const metas = await initPluginMetas(); + const mapper = getAppPluginMapper(); + apps = mapper(metas); +} + +export async function getAppPluginMetas(): Promise { + if (!initialized()) { + await initAppPluginMetas(); + } + + return Object.values(structuredClone(apps)); +} + +export async function getAppPluginMeta(pluginId: string): Promise { + if (!initialized()) { + await initAppPluginMetas(); + } + + const app = apps[pluginId]; + return app ? structuredClone(app) : null; +} + +/** + * Check if an app plugin is installed. The function does not check if the app plugin is enabled. + * @param pluginId - The id of the app plugin. + * @returns True if the app plugin is installed, false otherwise. + */ +export async function isAppPluginInstalled(pluginId: string): Promise { + const app = await getAppPluginMeta(pluginId); + return Boolean(app); +} + +/** + * Get the version of an app plugin. + * @param pluginId - The id of the app plugin. + * @returns The version of the app plugin, or null if the plugin is not installed. + */ +export async function getAppPluginVersion(pluginId: string): Promise { + const app = await getAppPluginMeta(pluginId); + return app?.version ?? null; +} + +export function setAppPluginMetas(override: AppPluginMetas): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error('setAppPluginMetas() function can only be called from tests.'); + } + + apps = structuredClone(override); +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/hooks.test.tsx b/packages/grafana-runtime/src/services/pluginMeta/hooks.test.tsx new file mode 100644 index 00000000000..1e3c7311118 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/hooks.test.tsx @@ -0,0 +1,214 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { + getAppPluginMeta, + getAppPluginMetas, + getAppPluginVersion, + isAppPluginInstalled, + setAppPluginMetas, +} from './apps'; +import { useAppPluginMeta, useAppPluginMetas, useAppPluginInstalled, useAppPluginVersion } from './hooks'; +import { apps } from './test-fixtures/config.apps'; + +const actualApps = jest.requireActual('./apps'); +jest.mock('./apps', () => ({ + ...jest.requireActual('./apps'), + getAppPluginMetas: jest.fn(), + getAppPluginMeta: jest.fn(), + isAppPluginInstalled: jest.fn(), + getAppPluginVersion: jest.fn(), +})); +const getAppPluginMetaMock = jest.mocked(getAppPluginMeta); +const getAppPluginMetasMock = jest.mocked(getAppPluginMetas); +const isAppPluginInstalledMock = jest.mocked(isAppPluginInstalled); +const getAppPluginVersionMock = jest.mocked(getAppPluginVersion); + +describe('useAppPluginMeta', () => { + beforeEach(() => { + setAppPluginMetas(apps); + jest.resetAllMocks(); + getAppPluginMetaMock.mockImplementation(actualApps.getAppPluginMeta); + }); + + it('should return correct default values', async () => { + const { result } = renderHook(() => useAppPluginMeta('grafana-exploretraces-app')); + + expect(result.current.loading).toEqual(true); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toEqual(true)); + }); + + it('should return correct values after loading', async () => { + const { result } = renderHook(() => useAppPluginMeta('grafana-exploretraces-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(apps['grafana-exploretraces-app']); + }); + + it('should return correct values if the pluginId does not exist', async () => { + const { result } = renderHook(() => useAppPluginMeta('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(null); + }); + + it('should return correct values if useAppPluginMeta throws', async () => { + getAppPluginMetaMock.mockRejectedValue(new Error('Some error')); + + const { result } = renderHook(() => useAppPluginMeta('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toEqual(new Error('Some error')); + expect(result.current.value).toBeUndefined(); + }); +}); + +describe('useAppPluginMetas', () => { + beforeEach(() => { + setAppPluginMetas(apps); + jest.resetAllMocks(); + getAppPluginMetasMock.mockImplementation(actualApps.getAppPluginMetas); + }); + + it('should return correct default values', async () => { + const { result } = renderHook(() => useAppPluginMetas()); + + expect(result.current.loading).toEqual(true); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toEqual(true)); + }); + + it('should return correct values after loading', async () => { + const { result } = renderHook(() => useAppPluginMetas()); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(Object.values(apps)); + }); + + it('should return correct values if useAppPluginMetas throws', async () => { + getAppPluginMetasMock.mockRejectedValue(new Error('Some error')); + + const { result } = renderHook(() => useAppPluginMetas()); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toEqual(new Error('Some error')); + expect(result.current.value).toBeUndefined(); + }); +}); + +describe('useAppPluginInstalled', () => { + beforeEach(() => { + setAppPluginMetas(apps); + jest.resetAllMocks(); + isAppPluginInstalledMock.mockImplementation(actualApps.isAppPluginInstalled); + }); + + it('should return correct default values', async () => { + const { result } = renderHook(() => useAppPluginInstalled('grafana-exploretraces-app')); + + expect(result.current.loading).toEqual(true); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toEqual(true)); + }); + + it('should return correct values after loading', async () => { + const { result } = renderHook(() => useAppPluginInstalled('grafana-exploretraces-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(true); + }); + + it('should return correct values if the pluginId does not exist', async () => { + const { result } = renderHook(() => useAppPluginInstalled('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(false); + }); + + it('should return correct values if isAppPluginInstalled throws', async () => { + isAppPluginInstalledMock.mockRejectedValue(new Error('Some error')); + + const { result } = renderHook(() => useAppPluginInstalled('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toEqual(new Error('Some error')); + expect(result.current.value).toBeUndefined(); + }); +}); + +describe('useAppPluginVersion', () => { + beforeEach(() => { + setAppPluginMetas(apps); + jest.resetAllMocks(); + getAppPluginVersionMock.mockImplementation(actualApps.getAppPluginVersion); + }); + + it('should return correct default values', async () => { + const { result } = renderHook(() => useAppPluginVersion('grafana-exploretraces-app')); + + expect(result.current.loading).toEqual(true); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toEqual(true)); + }); + + it('should return correct values after loading', async () => { + const { result } = renderHook(() => useAppPluginVersion('grafana-exploretraces-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual('1.2.2'); + }); + + it('should return correct values if the pluginId does not exist', async () => { + const { result } = renderHook(() => useAppPluginVersion('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(null); + }); + + it('should return correct values if getAppPluginVersion throws', async () => { + getAppPluginVersionMock.mockRejectedValue(new Error('Some error')); + + const { result } = renderHook(() => useAppPluginVersion('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toEqual(new Error('Some error')); + expect(result.current.value).toBeUndefined(); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/hooks.tsx b/packages/grafana-runtime/src/services/pluginMeta/hooks.tsx new file mode 100644 index 00000000000..58ac42bbdd2 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/hooks.tsx @@ -0,0 +1,35 @@ +import { useAsync } from 'react-use'; + +import { getAppPluginMeta, getAppPluginMetas, getAppPluginVersion, isAppPluginInstalled } from './apps'; + +export function useAppPluginMetas() { + const { loading, error, value } = useAsync(async () => getAppPluginMetas()); + return { loading, error, value }; +} + +export function useAppPluginMeta(pluginId: string) { + const { loading, error, value } = useAsync(async () => getAppPluginMeta(pluginId)); + return { loading, error, value }; +} + +/** + * Hook that checks if an app plugin is installed. The hook does not check if the app plugin is enabled. + * @param pluginId - The ID of the app plugin. + * @returns loading, error, value of the app plugin installed status. + * The value is true if the app plugin is installed, false otherwise. + */ +export function useAppPluginInstalled(pluginId: string) { + const { loading, error, value } = useAsync(async () => isAppPluginInstalled(pluginId)); + return { loading, error, value }; +} + +/** + * Hook that gets the version of an app plugin. + * @param pluginId - The ID of the app plugin. + * @returns loading, error, value of the app plugin version. + * The value is the version of the app plugin, or null if the plugin is not installed. + */ +export function useAppPluginVersion(pluginId: string) { + const { loading, error, value } = useAsync(async () => getAppPluginVersion(pluginId)); + return { loading, error, value }; +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/mappers/mappers.ts b/packages/grafana-runtime/src/services/pluginMeta/mappers/mappers.ts new file mode 100644 index 00000000000..15505b2edc0 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/mappers/mappers.ts @@ -0,0 +1,7 @@ +import { AppPluginMetasMapper, PluginMetasResponse } from '../types'; + +import { v0alpha1AppMapper } from './v0alpha1AppMapper'; + +export function getAppPluginMapper(): AppPluginMetasMapper { + return v0alpha1AppMapper; +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.test.ts b/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.test.ts new file mode 100644 index 00000000000..dfc82d41b3e --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.test.ts @@ -0,0 +1,84 @@ +import { apps } from '../test-fixtures/config.apps'; +import { v0alpha1Response } from '../test-fixtures/v0alpha1Response'; + +import { v0alpha1AppMapper } from './v0alpha1AppMapper'; + +const PLUGIN_IDS = v0alpha1Response.items + .filter((i) => i.spec.pluginJson.type === 'app') + .map((i) => ({ pluginId: i.spec.pluginJson.id })); + +describe('v0alpha1AppMapper', () => { + describe.each(PLUGIN_IDS)('when called for pluginId:$pluginId', ({ pluginId }) => { + it('should map id property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].id).toEqual(apps[pluginId].id); + }); + + it('should map path property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].path).toEqual(apps[pluginId].path); + }); + + it('should map version property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].version).toEqual(apps[pluginId].version); + }); + + it('should map preload property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].preload).toEqual(apps[pluginId].preload); + }); + + it('should map angular property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].angular).toEqual({}); + }); + + it('should map loadingStrategy property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].loadingStrategy).toEqual(apps[pluginId].loadingStrategy); + }); + + it('should map dependencies property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].dependencies).toEqual(apps[pluginId].dependencies); + }); + + it('should map extensions property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].extensions.addedComponents).toEqual(apps[pluginId].extensions.addedComponents); + expect(result[pluginId].extensions.addedFunctions).toEqual(apps[pluginId].extensions.addedFunctions); + expect(result[pluginId].extensions.addedLinks).toEqual(apps[pluginId].extensions.addedLinks); + expect(result[pluginId].extensions.exposedComponents).toEqual(apps[pluginId].extensions.exposedComponents); + expect(result[pluginId].extensions.extensionPoints).toEqual(apps[pluginId].extensions.extensionPoints); + }); + + it('should map moduleHash property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].moduleHash).toEqual(apps[pluginId].moduleHash); + }); + + it('should map buildMode property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].buildMode).toEqual(apps[pluginId].buildMode); + }); + }); + + it('should only map specs with type app', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(v0alpha1Response.items).toHaveLength(58); + expect(Object.keys(result)).toHaveLength(5); + expect(Object.keys(result)).toEqual(Object.keys(apps)); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.ts b/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.ts new file mode 100644 index 00000000000..aa5ca6e2ce0 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.ts @@ -0,0 +1,111 @@ +import { + type AngularMeta, + type AppPluginConfig, + type PluginDependencies, + type PluginExtensions, + PluginLoadingStrategy, + type PluginType, +} from '@grafana/data'; + +import type { AppPluginMetas, AppPluginMetasMapper, PluginMetasResponse } from '../types'; +import type { Spec as v0alpha1Spec } from '../types/types.spec.gen'; + +function angularyMapper(spec: v0alpha1Spec): AngularMeta { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return {} as AngularMeta; +} + +function dependenciesMapper(spec: v0alpha1Spec): PluginDependencies { + const plugins = (spec.pluginJson.dependencies?.plugins ?? []).map((v) => ({ + ...v, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + type: v.type as PluginType, + version: '', + })); + + const dependencies: PluginDependencies = { + ...spec.pluginJson.dependencies, + extensions: { + exposedComponents: spec.pluginJson.dependencies.extensions?.exposedComponents ?? [], + }, + grafanaDependency: spec.pluginJson.dependencies.grafanaDependency, + grafanaVersion: spec.pluginJson.dependencies.grafanaVersion ?? '', + plugins, + }; + + return dependencies; +} + +function extensionsMapper(spec: v0alpha1Spec): PluginExtensions { + const addedComponents = spec.pluginJson.extensions?.addedComponents ?? []; + const addedFunctions = spec.pluginJson.extensions?.addedFunctions ?? []; + const addedLinks = spec.pluginJson.extensions?.addedLinks ?? []; + const exposedComponents = (spec.pluginJson.extensions?.exposedComponents ?? []).map((v) => ({ + ...v, + description: v.description ?? '', + title: v.title ?? '', + })); + const extensionPoints = (spec.pluginJson.extensions?.extensionPoints ?? []).map((v) => ({ + ...v, + description: v.description ?? '', + title: v.title ?? '', + })); + + const extensions: PluginExtensions = { + addedComponents, + addedFunctions, + addedLinks, + exposedComponents, + extensionPoints, + }; + + return extensions; +} + +function loadingStrategyMapper(spec: v0alpha1Spec): PluginLoadingStrategy { + const loadingStrategy = spec.module?.loadingStrategy ?? PluginLoadingStrategy.fetch; + if (loadingStrategy === PluginLoadingStrategy.script) { + return PluginLoadingStrategy.script; + } + + return PluginLoadingStrategy.fetch; +} + +function specMapper(spec: v0alpha1Spec): AppPluginConfig { + const { id, info, preload = false } = spec.pluginJson; + const angular = angularyMapper(spec); + const dependencies = dependenciesMapper(spec); + const extensions = extensionsMapper(spec); + const loadingStrategy = loadingStrategyMapper(spec); + const path = spec.module?.path ?? ''; + const version = info.version; + const buildMode = spec.pluginJson.buildMode ?? 'production'; + const moduleHash = spec.module?.hash; + + return { + id, + angular, + dependencies, + extensions, + loadingStrategy, + path, + preload, + version, + buildMode, + moduleHash, + }; +} + +export const v0alpha1AppMapper: AppPluginMetasMapper = (response) => { + const result: AppPluginMetas = {}; + + return response.items.reduce((acc, curr) => { + if (curr.spec.pluginJson.type !== 'app') { + return acc; + } + + const config = specMapper(curr.spec); + acc[config.id] = config; + return acc; + }, result); +}; diff --git a/packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts b/packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts new file mode 100644 index 00000000000..9a5077d1b2b --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts @@ -0,0 +1,153 @@ +import { evaluateBooleanFlag } from '../../internal/openFeature'; + +import { clearCache, initPluginMetas } from './plugins'; +import { v0alpha1Meta } from './test-fixtures/v0alpha1Response'; + +jest.mock('../../internal/openFeature', () => ({ + ...jest.requireActual('../../internal/openFeature'), + evaluateBooleanFlag: jest.fn(), +})); + +const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag); + +describe('when useMTPlugins toggle is enabled and cache is not initialized', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.resetAllMocks(); + clearCache(); + evaluateBooleanFlagMock.mockReturnValue(true); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ items: [v0alpha1Meta] }), + }); + + const response = await initPluginMetas(); + + expect(response.items).toHaveLength(1); + expect(response.items[0]).toEqual(v0alpha1Meta); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith('/apis/plugins.grafana.app/v0alpha1/namespaces/default/metas'); + }); + + it('initPluginMetas should call loadPluginMetas and return correct result if response is not ok', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not found', + }); + + await expect(initPluginMetas()).rejects.toThrow(new Error(`Failed to load plugin metas 404:Not found`)); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith('/apis/plugins.grafana.app/v0alpha1/namespaces/default/metas'); + }); +}); + +describe('when useMTPlugins toggle is enabled and cache is initialized', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.resetAllMocks(); + clearCache(); + evaluateBooleanFlagMock.mockReturnValue(true); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('initPluginMetas should return cache', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ items: [v0alpha1Meta] }), + }); + + const original = await initPluginMetas(); + const cached = await initPluginMetas(); + + expect(original).toEqual(cached); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('initPluginMetas should return inflight promise', async () => { + jest.useFakeTimers(); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ items: [v0alpha1Meta] }), + }); + + const original = initPluginMetas(); + const cached = initPluginMetas(); + await jest.runAllTimersAsync(); + + expect(original).toEqual(cached); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('when useMTPlugins toggle is disabled and cache is not initialized', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.resetAllMocks(); + clearCache(); + global.fetch = jest.fn(); + evaluateBooleanFlagMock.mockReturnValue(false); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => { + const response = await initPluginMetas(); + + expect(response.items).toHaveLength(0); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +describe('when useMTPlugins toggle is disabled and cache is initialized', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.resetAllMocks(); + clearCache(); + global.fetch = jest.fn(); + evaluateBooleanFlagMock.mockReturnValue(false); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('initPluginMetas should return cache', async () => { + const original = await initPluginMetas(); + const cached = await initPluginMetas(); + + expect(original).toEqual(cached); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('initPluginMetas should return inflight promise', async () => { + jest.useFakeTimers(); + + const original = initPluginMetas(); + const cached = initPluginMetas(); + await jest.runAllTimersAsync(); + + expect(original).toEqual(cached); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/plugins.ts b/packages/grafana-runtime/src/services/pluginMeta/plugins.ts new file mode 100644 index 00000000000..ec2fa4a9d11 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/plugins.ts @@ -0,0 +1,41 @@ +import { config } from '../../config'; +import { evaluateBooleanFlag } from '../../internal/openFeature'; + +import type { PluginMetasResponse } from './types'; + +let initPromise: Promise | null = null; + +function getApiVersion(): string { + return 'v0alpha1'; +} + +async function loadPluginMetas(): Promise { + if (!evaluateBooleanFlag('useMTPlugins', false)) { + const result = { items: [] }; + return result; + } + + const metas = await fetch(`/apis/plugins.grafana.app/${getApiVersion()}/namespaces/${config.namespace}/metas`); + if (!metas.ok) { + throw new Error(`Failed to load plugin metas ${metas.status}:${metas.statusText}`); + } + + const result = await metas.json(); + return result; +} + +export function initPluginMetas(): Promise { + if (!initPromise) { + initPromise = loadPluginMetas(); + } + + return initPromise; +} + +export function clearCache() { + if (process.env.NODE_ENV !== 'test') { + throw new Error('clearCache() function can only be called from tests.'); + } + + initPromise = null; +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/config.apps.ts b/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/config.apps.ts new file mode 100644 index 00000000000..365308bd76c --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/config.apps.ts @@ -0,0 +1,303 @@ +import { cloneDeep } from 'lodash'; + +import { AngularMeta, AppPluginConfig, PluginLoadingStrategy } from '@grafana/data'; + +import { AppPluginMetas } from '../types'; + +export const app: AppPluginConfig = cloneDeep({ + id: 'myorg-someplugin-app', + path: 'public/plugins/myorg-someplugin-app/module.js', + version: '1.0.0', + preload: false, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + angular: { detected: false } as AngularMeta, + loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + addedFunctions: [], + }, + dependencies: { + grafanaDependency: '>=10.4.0', + grafanaVersion: '*', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + buildMode: 'production', +}); + +export const apps: AppPluginMetas = cloneDeep({ + 'grafana-exploretraces-app': { + id: 'grafana-exploretraces-app', + path: 'public/plugins/grafana-exploretraces-app/module.js', + version: '1.2.2', + preload: true, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + angular: { detected: false } as AngularMeta, + loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [ + { + targets: ['grafana/dashboard/panel/menu'], + title: 'Open in Traces Drilldown', + description: 'Open current query in the Traces Drilldown app', + }, + { + targets: ['grafana/explore/toolbar/action'], + title: 'Open in Grafana Traces Drilldown', + description: 'Try our new queryless experience for traces', + }, + ], + addedComponents: [ + { + targets: ['grafana-asserts-app/entity-assertions-widget/v1'], + title: 'Asserts widget', + description: 'A block with assertions for a given service', + }, + { + targets: ['grafana-asserts-app/insights-timeline-widget/v1'], + title: 'Insights Timeline Widget', + description: 'Widget for displaying insights timeline in other apps', + }, + ], + exposedComponents: [ + { + id: 'grafana-exploretraces-app/open-in-explore-traces-button/v1', + title: 'Open in Traces Drilldown button', + description: 'A button that opens a traces view in the Traces Drilldown app.', + }, + { + id: 'grafana-exploretraces-app/embedded-trace-exploration/v1', + title: 'Embedded Trace Exploration', + description: + 'A component that renders a trace exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-exploretraces-app/investigation/v1', + title: '', + description: '', + }, + { + id: 'grafana-exploretraces-app/get-logs-drilldown-link/v1', + title: '', + description: '', + }, + ], + addedFunctions: [], + }, + dependencies: { + grafanaDependency: '>=11.5.0', + grafanaVersion: '*', + plugins: [], + extensions: { + exposedComponents: [ + 'grafana-asserts-app/entity-assertions-widget/v1', + 'grafana-asserts-app/insights-timeline-widget/v1', + ], + }, + }, + buildMode: 'production', + }, + 'grafana-lokiexplore-app': { + id: 'grafana-lokiexplore-app', + path: 'public/plugins/grafana-lokiexplore-app/module.js', + version: '1.0.32', + preload: true, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + angular: { detected: false } as AngularMeta, + loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [ + { + targets: [ + 'grafana/dashboard/panel/menu', + 'grafana/explore/toolbar/action', + 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1', + 'grafana-assistant-app/navigateToDrilldown/v1', + ], + title: 'Open in Grafana Logs Drilldown', + description: 'Open current query in the Grafana Logs Drilldown view', + }, + ], + addedComponents: [ + { + targets: ['grafana-asserts-app/insights-timeline-widget/v1'], + title: 'Insights Timeline Widget', + description: 'Widget for displaying insights timeline in other apps', + }, + ], + exposedComponents: [ + { + id: 'grafana-lokiexplore-app/open-in-explore-logs-button/v1', + title: 'Open in Logs Drilldown button', + description: 'A button that opens a logs view in the Logs Drilldown app.', + }, + { + id: 'grafana-lokiexplore-app/embedded-logs-exploration/v1', + title: 'Embedded Logs Exploration', + description: + 'A component that renders a logs exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-lokiexplore-app/investigation/v1', + title: '', + description: '', + }, + ], + addedFunctions: [ + { + targets: ['grafana-exploretraces-app/get-logs-drilldown-link/v1'], + title: 'Open Logs Drilldown', + description: 'Returns url to logs drilldown app', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.6.0', + grafanaVersion: '*', + plugins: [], + extensions: { + exposedComponents: [ + 'grafana-adaptivelogs-app/temporary-exemptions/v1', + 'grafana-lokiexplore-app/embedded-logs-exploration/v1', + 'grafana-asserts-app/insights-timeline-widget/v1', + 'grafana/add-to-dashboard-form/v1', + ], + }, + }, + buildMode: 'production', + }, + 'grafana-metricsdrilldown-app': { + id: 'grafana-metricsdrilldown-app', + path: 'public/plugins/grafana-metricsdrilldown-app/module.js', + version: '1.0.26', + preload: true, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + angular: { detected: false } as AngularMeta, + loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [ + { + targets: [ + 'grafana/dashboard/panel/menu', + 'grafana/explore/toolbar/action', + 'grafana-assistant-app/navigateToDrilldown/v1', + 'grafana/alerting/alertingrule/queryeditor', + ], + title: 'Open in Grafana Metrics Drilldown', + description: 'Open current query in the Grafana Metrics Drilldown view', + }, + { + targets: ['grafana-metricsdrilldown-app/grafana-assistant-app/navigateToDrilldown/v0-alpha'], + title: 'Navigate to metrics drilldown', + description: 'Build a url path to the metrics drilldown', + }, + { + targets: ['grafana/datasources/config/actions', 'grafana/datasources/config/status'], + title: 'Open in Metrics Drilldown', + description: 'Browse metrics in Grafana Metrics Drilldown', + }, + ], + addedComponents: [], + exposedComponents: [ + { + id: 'grafana-metricsdrilldown-app/label-breakdown-component/v1', + title: 'Label Breakdown', + description: 'A metrics label breakdown view from the Metrics Drilldown app.', + }, + { + id: 'grafana-metricsdrilldown-app/knowledge-graph-insight-metrics/v1', + title: 'Knowledge Graph Source Metrics', + description: 'Explore the underlying metrics related to a Knowledge Graph insight', + }, + ], + extensionPoints: [ + { + id: 'grafana-exploremetrics-app/investigation/v1', + title: '', + description: '', + }, + { + id: 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1', + title: '', + description: '', + }, + ], + addedFunctions: [], + }, + dependencies: { + grafanaDependency: '>=11.6.0', + grafanaVersion: '*', + plugins: [], + extensions: { + exposedComponents: ['grafana/add-to-dashboard-form/v1'], + }, + }, + buildMode: 'production', + }, + 'grafana-pyroscope-app': { + id: 'grafana-pyroscope-app', + path: 'public/plugins/grafana-pyroscope-app/module.js', + version: '1.14.2', + preload: true, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + angular: { detected: false } as AngularMeta, + loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [ + { + targets: [ + 'grafana/explore/toolbar/action', + 'grafana/traceview/details', + 'grafana-assistant-app/navigateToDrilldown/v1', + ], + title: 'Open in Grafana Profiles Drilldown', + description: 'Try our new queryless experience for profiles', + }, + ], + addedComponents: [], + exposedComponents: [ + { + id: 'grafana-pyroscope-app/embedded-profiles-exploration/v1', + title: 'Embedded Profiles Exploration', + description: + 'A component that renders a profiles exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-pyroscope-app/investigation/v1', + title: '', + description: '', + }, + { + id: 'grafana-pyroscope-app/settings/v1', + title: '', + description: '', + }, + ], + addedFunctions: [], + }, + dependencies: { + grafanaDependency: '>=11.5.0', + grafanaVersion: '*', + plugins: [], + extensions: { + exposedComponents: [ + 'grafana-o11yinsights-app/insights-launcher/v1', + 'grafana-adaptiveprofiles-app/resolution-boost/v1', + ], + }, + }, + buildMode: 'production', + }, + [app.id]: app, +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/v0alpha1Response.ts b/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/v0alpha1Response.ts new file mode 100644 index 00000000000..7bd4c38d9fa --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/v0alpha1Response.ts @@ -0,0 +1,4378 @@ +import { cloneDeep } from 'lodash'; + +import type { PluginMetasResponse } from '../types'; +import type { Meta } from '../types/meta_object_gen'; + +export const v0alpha1Meta: Meta = cloneDeep({ + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'myorg-someplugin-app', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'myorg-someplugin-app', + type: 'app', + name: 'Some-Plugin', + info: { + keywords: ['app'], + logos: { + small: 'public/plugins/myorg-someplugin-app/img/logo.svg', + large: 'public/plugins/myorg-someplugin-app/img/logo.svg', + }, + updated: '2025-12-15', + version: '1.0.0', + author: { + name: 'Myorg', + }, + }, + dependencies: { + grafanaDependency: '>=10.4.0', + grafanaVersion: '*', + }, + includes: [ + { + type: 'page', + name: 'Page One', + role: 'Viewer', + action: 'plugins.app:access', + path: '/a/myorg-someplugin-app/one', + addToNav: true, + defaultNav: true, + }, + { + type: 'page', + name: 'Page Two', + role: 'Viewer', + action: 'plugins.app:access', + path: '/a/myorg-someplugin-app/two', + addToNav: true, + }, + { + type: 'page', + name: 'Page Three', + role: 'Viewer', + action: 'plugins.app:access', + path: '/a/myorg-someplugin-app/three', + addToNav: true, + }, + { + type: 'page', + name: 'Page Four', + role: 'Viewer', + action: 'plugins.app:access', + path: '/a/myorg-someplugin-app/four', + addToNav: true, + }, + { + type: 'page', + name: 'Configuration', + role: 'Admin', + path: '/plugins/myorg-someplugin-app', + addToNav: true, + icon: 'cog', + }, + ], + }, + class: 'external', + module: { + path: 'public/plugins/myorg-someplugin-app/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/myorg-someplugin-app', + signature: { + status: 'unsigned', + }, + angular: { + detected: false, + }, + }, + status: {}, +}); + +export const v0alpha1Response: PluginMetasResponse = cloneDeep({ + items: [ + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'alertlist', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'alertlist', + type: 'panel', + name: 'Alert list', + info: { + keywords: [], + logos: { + small: 'public/plugins/alertlist/img/icn-singlestat-panel.svg', + large: 'public/plugins/alertlist/img/icn-singlestat-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Shows list of alerts and their current status', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/alert-list/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/alertlist', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/alertlist', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'alertmanager', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'alertmanager', + type: 'datasource', + name: 'Alertmanager', + info: { + keywords: ['alerts', 'alerting', 'prometheus', 'alertmanager', 'mimir', 'cortex'], + logos: { + small: 'public/plugins/alertmanager/img/logo.svg', + large: 'public/plugins/alertmanager/img/logo.svg', + }, + updated: '', + version: '', + author: { + name: 'Prometheus alertmanager', + url: 'https://grafana.com', + }, + description: + 'Add external Alertmanagers (supports Prometheus and Mimir implementations) so you can use the Grafana Alerting UI to manage silences, contact points, and notification policies.', + links: [ + { + name: 'Learn more', + url: 'https://prometheus.io/docs/alerting/latest/alertmanager/', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/alertmanager/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + routes: [ + { + path: 'alertmanager/api/v2/silences', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v2/silences', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'alertmanager/api/v2/silences', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'api/v2/silences', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'alertmanager/api/v2/silence/', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v2/silence/', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'alertmanager/api/v2/silence/', + method: 'DELETE', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'api/v2/silence/', + method: 'DELETE', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'alertmanager/api/v2/alerts/groups', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v2/alerts/groups', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'alertmanager/api/v2/alerts', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v2/alerts', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'alertmanager/api/v2/alerts', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'api/v2/alerts', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'alertmanager/api/v2/status', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.notifications.external:read', + }, + { + path: 'api/v2/status', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.notifications.external:read', + }, + { + path: 'alertmanager/api/v2/receivers', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v2/receivers', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v1/alerts', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.notifications.external:read', + }, + { + path: 'api/v1/alerts', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.notifications.external:write', + }, + { + path: 'api/v1/alerts', + method: 'DELETE', + reqRole: 'Editor', + reqAction: 'alert.notifications.external:write', + }, + { + method: 'POST', + reqRole: 'Admin', + }, + { + method: 'PUT', + reqRole: 'Admin', + }, + { + method: 'DELETE', + reqRole: 'Admin', + }, + { + method: 'GET', + reqRole: 'Admin', + }, + ], + }, + class: 'core', + module: { + path: 'core:plugin/alertmanager', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/alertmanager', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'annolist', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'annolist', + type: 'panel', + name: 'Annotations list', + info: { + keywords: [], + logos: { + small: 'public/plugins/annolist/img/icn-annolist-panel.svg', + large: 'public/plugins/annolist/img/icn-annolist-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'List annotations', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/annotations/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/annolist', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/annolist', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'barchart', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'barchart', + type: 'panel', + name: 'Bar chart', + info: { + keywords: [], + logos: { + small: 'public/plugins/barchart/img/barchart.svg', + large: 'public/plugins/barchart/img/barchart.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Categorical charts with group support', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/bar-chart/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/barchart', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/barchart', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'bargauge', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'bargauge', + type: 'panel', + name: 'Bar gauge', + info: { + keywords: [], + logos: { + small: 'public/plugins/bargauge/img/icon_bar_gauge.svg', + large: 'public/plugins/bargauge/img/icon_bar_gauge.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Horizontal and vertical gauges', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/bar-gauge/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/bargauge', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/bargauge', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'candlestick', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'candlestick', + type: 'panel', + name: 'Candlestick', + info: { + keywords: ['financial', 'price', 'currency', 'k-line'], + logos: { + small: 'public/plugins/candlestick/img/candlestick.svg', + large: 'public/plugins/candlestick/img/candlestick.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Graphical representation of price movements of a security, derivative, or currency.', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/candlestick/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/candlestick', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/candlestick', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'canvas', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'canvas', + type: 'panel', + name: 'Canvas', + info: { + keywords: [], + logos: { + small: 'public/plugins/canvas/img/icn-canvas.svg', + large: 'public/plugins/canvas/img/icn-canvas.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Explicit element placement', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/canvas/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/canvas', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/canvas', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'cloudwatch', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'cloudwatch', + type: 'datasource', + name: 'CloudWatch', + info: { + keywords: ['aws', 'amazon'], + logos: { + small: 'public/plugins/cloudwatch/img/amazon-web-services.png', + large: 'public/plugins/cloudwatch/img/amazon-web-services.png', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Data source for Amazon AWS monitoring service', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'cloud', + includes: [ + { + type: 'dashboard', + name: 'EC2', + role: 'Viewer', + path: 'dashboards/ec2.json', + }, + { + type: 'dashboard', + name: 'EBS', + role: 'Viewer', + path: 'dashboards/EBS.json', + }, + { + type: 'dashboard', + name: 'Lambda', + role: 'Viewer', + path: 'dashboards/Lambda.json', + }, + { + type: 'dashboard', + name: 'Logs', + role: 'Viewer', + path: 'dashboards/Logs.json', + }, + { + type: 'dashboard', + name: 'RDS', + role: 'Viewer', + path: 'dashboards/RDS.json', + }, + ], + logs: true, + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'core:plugin/cloudwatch', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/cloudwatch', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'dashboard', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'dashboard', + type: 'datasource', + name: '-- Dashboard --', + info: { + keywords: [], + logos: { + small: 'public/plugins/dashboard/img/icn-reusequeries.svg', + large: 'public/plugins/dashboard/img/icn-reusequeries.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Uses the result set from another panel in the same dashboard', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + builtIn: true, + metrics: true, + }, + class: 'core', + module: { + path: 'core:plugin/dashboard', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/dashboard', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'dashlist', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'dashlist', + type: 'panel', + name: 'Dashboard list', + info: { + keywords: [], + logos: { + small: 'public/plugins/dashlist/img/icn-dashlist-panel.svg', + large: 'public/plugins/dashlist/img/icn-dashlist-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'List of dynamic links to other dashboards', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/dashboard-list/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/dashlist', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/dashlist', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'datagrid', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'datagrid', + type: 'panel', + name: 'Datagrid', + info: { + keywords: [], + logos: { + small: 'public/plugins/datagrid/img/icn-table-panel.svg', + large: 'public/plugins/datagrid/img/icn-table-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/datagrid/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + state: 'beta', + }, + class: 'core', + module: { + path: 'core:plugin/datagrid', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/datagrid', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'debug', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'debug', + type: 'panel', + name: 'Debug', + info: { + keywords: [], + logos: { + small: 'public/plugins/debug/img/icn-debug.svg', + large: 'public/plugins/debug/img/icn-debug.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Debug Panel for Grafana', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + state: 'alpha', + }, + class: 'core', + module: { + path: 'core:plugin/debug', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/debug', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'elasticsearch', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'elasticsearch', + type: 'datasource', + name: 'Elasticsearch', + info: { + keywords: ['elasticsearch', 'datasource', 'database', 'logs', 'nosql', 'traces'], + logos: { + small: 'public/plugins/elasticsearch/img/elasticsearch.svg', + large: 'public/plugins/elasticsearch/img/elasticsearch.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source logging & analytics database', + links: [ + { + name: 'Learn more', + url: 'https://grafana.com/docs/features/datasources/elasticsearch/', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/elasticsearch/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'logging', + logs: true, + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'core:plugin/elasticsearch', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/elasticsearch', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'flamegraph', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'flamegraph', + type: 'panel', + name: 'Flame Graph', + info: { + keywords: [], + logos: { + small: 'public/plugins/flamegraph/img/icn-flamegraph.svg', + large: 'public/plugins/flamegraph/img/icn-flamegraph.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/flame-graph/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/flamegraph', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/flamegraph', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'gauge', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'gauge', + type: 'panel', + name: 'Gauge', + info: { + keywords: [], + logos: { + small: 'public/plugins/gauge/img/icon_gauge.svg', + large: 'public/plugins/gauge/img/icon_gauge.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Standard gauge visualization', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/gauge/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/gauge', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/gauge', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'geomap', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'geomap', + type: 'panel', + name: 'Geomap', + info: { + keywords: [], + logos: { + small: 'public/plugins/geomap/img/icn-geomap.svg', + large: 'public/plugins/geomap/img/icn-geomap.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Geomap panel', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/geomap/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/geomap', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/geomap', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'gettingstarted', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'gettingstarted', + type: 'panel', + name: 'Getting Started', + info: { + keywords: [], + logos: { + small: 'public/plugins/gettingstarted/img/icn-dashlist-panel.svg', + large: 'public/plugins/gettingstarted/img/icn-dashlist-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + hideFromList: true, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/gettingstarted', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/gettingstarted', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana', + type: 'datasource', + name: '-- Grafana --', + info: { + keywords: [], + logos: { + small: 'public/plugins/grafana/img/icn-grafanadb.svg', + large: 'public/plugins/grafana/img/icn-grafanadb.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: + 'A built-in data source that generates random walk data and can poll the Testdata data source. This helps you test visualizations and run experiments.', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + annotations: true, + backend: true, + builtIn: true, + metrics: true, + }, + class: 'core', + module: { + path: 'core:plugin/grafana', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-azure-monitor-datasource', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-azure-monitor-datasource', + type: 'datasource', + name: 'Azure Monitor', + info: { + keywords: ['azure', 'monitor', 'Application Insights', 'Log Analytics', 'App Insights'], + logos: { + small: 'public/plugins/grafana-azure-monitor-datasource/img/logo.jpg', + large: 'public/plugins/grafana-azure-monitor-datasource/img/logo.jpg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Data source for Microsoft Azure Monitor & Application Insights', + links: [ + { + name: 'Learn more', + url: 'https://grafana.com/docs/grafana/latest/datasources/azuremonitor/', + }, + { + name: 'License', + url: 'https://github.com/grafana/grafana/blob/HEAD/LICENSE', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/azure-monitor/', + }, + ], + screenshots: [ + { + name: 'Azure Contoso Loans', + path: 'public/plugins/grafana-azure-monitor-datasource/img/contoso_loans_grafana_dashboard.png', + }, + { + name: 'Azure Monitor Network', + path: 'public/plugins/grafana-azure-monitor-datasource/img/azure_monitor_network.png', + }, + { + name: 'Azure Monitor CPU', + path: 'public/plugins/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'cloud', + executable: 'gpx_azuremonitor', + includes: [ + { + type: 'dashboard', + name: 'Azure / Alert Consumption', + role: 'Viewer', + path: 'dashboards/v1Alerts.json', + }, + { + type: 'dashboard', + name: 'Azure / Infrastructure / Apps Monitoring', + role: 'Viewer', + path: 'dashboards/azureInfraApps.json', + }, + { + type: 'dashboard', + name: 'Azure / Infrastructure / Compute Monitoring', + role: 'Viewer', + path: 'dashboards/azureInfraCompute.json', + }, + { + type: 'dashboard', + name: 'Azure / Infrastructure / Data Monitoring', + role: 'Viewer', + path: 'dashboards/azureInfraData.json', + }, + { + type: 'dashboard', + name: 'Azure / Infrastructure / Network Monitoring', + role: 'Viewer', + path: 'dashboards/azureInfraNetwork.json', + }, + { + type: 'dashboard', + name: 'Azure / Infrastructure / Storage and Key Vaults Monitoring', + role: 'Viewer', + path: 'dashboards/azureInfraStorageVaults.json', + }, + { + type: 'dashboard', + name: 'Azure / Azure PostgreSQL / Flexible Server Monitoring', + role: 'Viewer', + path: 'dashboards/postgresFlexibleServer.json', + }, + { + type: 'dashboard', + name: 'Azure Monitor / Container Insights / Syslog', + role: 'Viewer', + path: 'dashboards/containerInsightsSyslog.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications', + role: 'Viewer', + path: 'dashboards/appInsights.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications / Performance / Operations', + role: 'Viewer', + path: 'dashboards/appInsightsPerfOperations.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications / Performance / Dependencies', + role: 'Viewer', + path: 'dashboards/appInsightsPerfDependencies.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications / Failures / Operations', + role: 'Viewer', + path: 'dashboards/appInsightsFailureOperations.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications / Failures / Dependencies', + role: 'Viewer', + path: 'dashboards/appInsightsFailureDependencies.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications / Failures / Exceptions', + role: 'Viewer', + path: 'dashboards/appInsightsFailureExceptions.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications Test Availability Geo Map', + role: 'Viewer', + path: 'dashboards/appInsightsGeoMap.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / CosmosDB', + role: 'Viewer', + path: 'dashboards/cosmosdb.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Data Explorer Clusters', + role: 'Viewer', + path: 'dashboards/adx.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Key Vaults', + role: 'Viewer', + path: 'dashboards/keyvault.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Networks', + role: 'Viewer', + path: 'dashboards/networkInsightsDashboard.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / SQL Database', + role: 'Viewer', + path: 'dashboards/sqldb.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Storage Accounts', + role: 'Viewer', + path: 'dashboards/storage.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Virtual Machines by Resource Group', + role: 'Viewer', + path: 'dashboards/vMInsightsRG.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Virtual Machines by Workspace', + role: 'Viewer', + path: 'dashboards/vMInsightsWorkspace.json', + }, + { + type: 'dashboard', + name: 'Azure / Resources Overview', + role: 'Viewer', + path: 'dashboards/arg.json', + }, + ], + logs: true, + metrics: true, + tracing: true, + }, + class: 'core', + module: { + path: 'public/plugins/grafana-azure-monitor-datasource/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-azure-monitor-datasource', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + translations: { + 'cs-CZ': + 'public/plugins/grafana-azure-monitor-datasource/locales/cs-CZ/grafana-azure-monitor-datasource.json', + 'de-DE': + 'public/plugins/grafana-azure-monitor-datasource/locales/de-DE/grafana-azure-monitor-datasource.json', + 'en-US': + 'public/plugins/grafana-azure-monitor-datasource/locales/en-US/grafana-azure-monitor-datasource.json', + 'es-ES': + 'public/plugins/grafana-azure-monitor-datasource/locales/es-ES/grafana-azure-monitor-datasource.json', + 'fr-FR': + 'public/plugins/grafana-azure-monitor-datasource/locales/fr-FR/grafana-azure-monitor-datasource.json', + 'hu-HU': + 'public/plugins/grafana-azure-monitor-datasource/locales/hu-HU/grafana-azure-monitor-datasource.json', + 'id-ID': + 'public/plugins/grafana-azure-monitor-datasource/locales/id-ID/grafana-azure-monitor-datasource.json', + 'it-IT': + 'public/plugins/grafana-azure-monitor-datasource/locales/it-IT/grafana-azure-monitor-datasource.json', + 'ja-JP': + 'public/plugins/grafana-azure-monitor-datasource/locales/ja-JP/grafana-azure-monitor-datasource.json', + 'ko-KR': + 'public/plugins/grafana-azure-monitor-datasource/locales/ko-KR/grafana-azure-monitor-datasource.json', + 'nl-NL': + 'public/plugins/grafana-azure-monitor-datasource/locales/nl-NL/grafana-azure-monitor-datasource.json', + 'pl-PL': + 'public/plugins/grafana-azure-monitor-datasource/locales/pl-PL/grafana-azure-monitor-datasource.json', + 'pt-BR': + 'public/plugins/grafana-azure-monitor-datasource/locales/pt-BR/grafana-azure-monitor-datasource.json', + 'pt-PT': + 'public/plugins/grafana-azure-monitor-datasource/locales/pt-PT/grafana-azure-monitor-datasource.json', + 'ru-RU': + 'public/plugins/grafana-azure-monitor-datasource/locales/ru-RU/grafana-azure-monitor-datasource.json', + 'sv-SE': + 'public/plugins/grafana-azure-monitor-datasource/locales/sv-SE/grafana-azure-monitor-datasource.json', + 'tr-TR': + 'public/plugins/grafana-azure-monitor-datasource/locales/tr-TR/grafana-azure-monitor-datasource.json', + 'zh-Hans': + 'public/plugins/grafana-azure-monitor-datasource/locales/zh-Hans/grafana-azure-monitor-datasource.json', + 'zh-Hant': + 'public/plugins/grafana-azure-monitor-datasource/locales/zh-Hant/grafana-azure-monitor-datasource.json', + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-exploretraces-app', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-exploretraces-app', + type: 'app', + name: 'Grafana Traces Drilldown', + info: { + keywords: ['app', 'tempo', 'traces', 'explore'], + logos: { + small: 'public/plugins/grafana-exploretraces-app/img/logo.svg', + large: 'public/plugins/grafana-exploretraces-app/img/logo.svg', + }, + updated: '2025-12-04', + version: '1.2.2', + author: { + name: 'Grafana', + }, + description: + 'Use Rate, Errors, and Duration (RED) metrics derived from traces to investigate errors within complex distributed systems.', + links: [ + { + name: 'Github', + url: 'https://github.com/grafana/explore-traces', + }, + { + name: 'Report bug', + url: 'https://github.com/grafana/explore-traces/issues/new', + }, + ], + screenshots: [ + { + name: 'histogram-breakdown', + path: 'public/plugins/grafana-exploretraces-app/img/histogram-breakdown.png', + }, + { + name: 'errors-metric-flow', + path: 'public/plugins/grafana-exploretraces-app/img/errors-metric-flow.png', + }, + { + name: 'errors-root-cause', + path: 'public/plugins/grafana-exploretraces-app/img/errors-root-cause.png', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.5.0', + grafanaVersion: '*', + extensions: { + exposedComponents: [ + 'grafana-asserts-app/entity-assertions-widget/v1', + 'grafana-asserts-app/insights-timeline-widget/v1', + ], + }, + }, + autoEnabled: true, + includes: [ + { + type: 'page', + name: 'Explore', + role: 'Viewer', + action: 'datasources:explore', + path: '/a/grafana-exploretraces-app/', + addToNav: true, + defaultNav: true, + }, + ], + preload: true, + extensions: { + addedComponents: [ + { + targets: ['grafana-asserts-app/entity-assertions-widget/v1'], + title: 'Asserts widget', + description: 'A block with assertions for a given service', + }, + { + targets: ['grafana-asserts-app/insights-timeline-widget/v1'], + title: 'Insights Timeline Widget', + description: 'Widget for displaying insights timeline in other apps', + }, + ], + addedLinks: [ + { + targets: ['grafana/dashboard/panel/menu'], + title: 'Open in Traces Drilldown', + description: 'Open current query in the Traces Drilldown app', + }, + { + targets: ['grafana/explore/toolbar/action'], + title: 'Open in Grafana Traces Drilldown', + description: 'Try our new queryless experience for traces', + }, + ], + exposedComponents: [ + { + id: 'grafana-exploretraces-app/open-in-explore-traces-button/v1', + title: 'Open in Traces Drilldown button', + description: 'A button that opens a traces view in the Traces Drilldown app.', + }, + { + id: 'grafana-exploretraces-app/embedded-trace-exploration/v1', + title: 'Embedded Trace Exploration', + description: + 'A component that renders a trace exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-exploretraces-app/investigation/v1', + }, + { + id: 'grafana-exploretraces-app/get-logs-drilldown-link/v1', + }, + ], + }, + }, + class: 'external', + module: { + path: 'public/plugins/grafana-exploretraces-app/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-exploretraces-app', + signature: { + status: 'valid', + type: 'grafana', + org: 'Grafana Labs', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-lokiexplore-app', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-lokiexplore-app', + type: 'app', + name: 'Grafana Logs Drilldown', + info: { + keywords: ['app', 'loki', 'explore', 'logs', 'drilldown', 'drill', 'down', 'drill-down'], + logos: { + small: 'public/plugins/grafana-lokiexplore-app/img/logo.svg', + large: 'public/plugins/grafana-lokiexplore-app/img/logo.svg', + }, + updated: '2025-12-09', + version: '1.0.32', + author: { + name: 'Grafana', + }, + description: + 'Visualize log volumes to easily detect anomalies or significant changes over time, without needing to compose LogQL queries.', + links: [ + { + name: 'Github', + url: 'https://github.com/grafana/explore-logs', + }, + { + name: 'Report bug', + url: 'https://github.com/grafana/explore-logs/issues/new', + }, + ], + screenshots: [ + { + name: 'patterns', + path: 'public/plugins/grafana-lokiexplore-app/img/patterns.png', + }, + { + name: 'fields', + path: 'public/plugins/grafana-lokiexplore-app/img/fields.png', + }, + { + name: 'table', + path: 'public/plugins/grafana-lokiexplore-app/img/table.png', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.6.0', + grafanaVersion: '*', + extensions: { + exposedComponents: [ + 'grafana-adaptivelogs-app/temporary-exemptions/v1', + 'grafana-lokiexplore-app/embedded-logs-exploration/v1', + 'grafana-asserts-app/insights-timeline-widget/v1', + 'grafana/add-to-dashboard-form/v1', + ], + }, + }, + autoEnabled: true, + includes: [ + { + type: 'page', + name: 'Grafana Logs Drilldown', + role: 'Viewer', + action: 'datasources:explore', + path: '/a/grafana-lokiexplore-app/explore', + addToNav: true, + defaultNav: true, + }, + ], + preload: true, + extensions: { + addedComponents: [ + { + targets: ['grafana-asserts-app/insights-timeline-widget/v1'], + title: 'Insights Timeline Widget', + description: 'Widget for displaying insights timeline in other apps', + }, + ], + addedLinks: [ + { + targets: [ + 'grafana/dashboard/panel/menu', + 'grafana/explore/toolbar/action', + 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1', + 'grafana-assistant-app/navigateToDrilldown/v1', + ], + title: 'Open in Grafana Logs Drilldown', + description: 'Open current query in the Grafana Logs Drilldown view', + }, + ], + addedFunctions: [ + { + targets: ['grafana-exploretraces-app/get-logs-drilldown-link/v1'], + title: 'Open Logs Drilldown', + description: 'Returns url to logs drilldown app', + }, + ], + exposedComponents: [ + { + id: 'grafana-lokiexplore-app/open-in-explore-logs-button/v1', + title: 'Open in Logs Drilldown button', + description: 'A button that opens a logs view in the Logs Drilldown app.', + }, + { + id: 'grafana-lokiexplore-app/embedded-logs-exploration/v1', + title: 'Embedded Logs Exploration', + description: + 'A component that renders a logs exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-lokiexplore-app/investigation/v1', + }, + ], + }, + }, + class: 'external', + module: { + path: 'public/plugins/grafana-lokiexplore-app/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-lokiexplore-app', + signature: { + status: 'valid', + type: 'grafana', + org: 'Grafana Labs', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-metricsdrilldown-app', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-metricsdrilldown-app', + type: 'app', + name: 'Grafana Metrics Drilldown', + info: { + keywords: ['drilldown', 'metrics', 'app', 'prometheus', 'mimir'], + logos: { + small: 'public/plugins/grafana-metricsdrilldown-app/img/logo.svg', + large: 'public/plugins/grafana-metricsdrilldown-app/img/logo.svg', + }, + updated: '2025-12-17', + version: '1.0.26', + author: { + name: 'Grafana', + }, + description: + 'Quickly find related metrics with a few clicks, without needing to write PromQL queries to retrieve metrics.', + links: [ + { + name: 'GitHub', + url: 'https://github.com/grafana/metrics-drilldown', + }, + { + name: 'Report a bug', + url: 'https://github.com/grafana/metrics-drilldown/issues/new', + }, + ], + screenshots: [ + { + name: 'metricselect', + path: 'public/plugins/grafana-metricsdrilldown-app/img/metrics-drilldown.png', + }, + { + name: 'breakdown', + path: 'public/plugins/grafana-metricsdrilldown-app/img/breakdown.png', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.6.0', + grafanaVersion: '*', + extensions: { + exposedComponents: ['grafana/add-to-dashboard-form/v1'], + }, + }, + autoEnabled: true, + includes: [ + { + type: 'page', + name: 'Grafana Metrics Drilldown', + role: 'Viewer', + action: 'datasources:explore', + path: '/a/grafana-metricsdrilldown-app/drilldown', + addToNav: true, + defaultNav: true, + }, + ], + preload: true, + extensions: { + addedLinks: [ + { + targets: [ + 'grafana/dashboard/panel/menu', + 'grafana/explore/toolbar/action', + 'grafana-assistant-app/navigateToDrilldown/v1', + 'grafana/alerting/alertingrule/queryeditor', + ], + title: 'Open in Grafana Metrics Drilldown', + description: 'Open current query in the Grafana Metrics Drilldown view', + }, + { + targets: ['grafana-metricsdrilldown-app/grafana-assistant-app/navigateToDrilldown/v0-alpha'], + title: 'Navigate to metrics drilldown', + description: 'Build a url path to the metrics drilldown', + }, + { + targets: ['grafana/datasources/config/actions', 'grafana/datasources/config/status'], + title: 'Open in Metrics Drilldown', + description: 'Browse metrics in Grafana Metrics Drilldown', + }, + ], + exposedComponents: [ + { + id: 'grafana-metricsdrilldown-app/label-breakdown-component/v1', + title: 'Label Breakdown', + description: 'A metrics label breakdown view from the Metrics Drilldown app.', + }, + { + id: 'grafana-metricsdrilldown-app/knowledge-graph-insight-metrics/v1', + title: 'Knowledge Graph Source Metrics', + description: 'Explore the underlying metrics related to a Knowledge Graph insight', + }, + ], + extensionPoints: [ + { + id: 'grafana-exploremetrics-app/investigation/v1', + }, + { + id: 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1', + }, + ], + }, + }, + class: 'external', + module: { + path: 'public/plugins/grafana-metricsdrilldown-app/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-metricsdrilldown-app', + signature: { + status: 'valid', + type: 'grafana', + org: 'Grafana Labs', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-postgresql-datasource', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-postgresql-datasource', + type: 'datasource', + name: 'PostgreSQL', + info: { + keywords: [], + logos: { + small: 'public/plugins/grafana-postgresql-datasource/img/postgresql_logo.svg', + large: 'public/plugins/grafana-postgresql-datasource/img/postgresql_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Data source for PostgreSQL and compatible databases', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/postgres/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.6.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'sql', + executable: 'gpx_grafana-postgresql-datasource', + logs: true, + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/grafana-postgresql-datasource/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-postgresql-datasource', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-pyroscope-app', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-pyroscope-app', + type: 'app', + name: 'Grafana Profiles Drilldown', + info: { + keywords: ['app', 'pyroscope', 'profiling', 'explore', 'profiles', 'performance', 'drilldown'], + logos: { + small: 'public/plugins/grafana-pyroscope-app/img/logo.svg', + large: 'public/plugins/grafana-pyroscope-app/img/logo.svg', + }, + updated: '2025-12-18', + version: '1.14.2', + author: { + name: 'Grafana', + }, + description: + 'View and analyze high-level service performance, identify problem processes for optimization, and diagnose issues to determine root causes.', + links: [ + { + name: 'GitHub', + url: 'https://github.com/grafana/profiles-drilldown', + }, + { + name: 'Report bug', + url: 'https://github.com/grafana/profiles-drilldown/issues/new', + }, + ], + screenshots: [ + { + name: 'Hero Image', + path: 'public/plugins/grafana-pyroscope-app/img/hero-image.png', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.5.0', + grafanaVersion: '*', + extensions: { + exposedComponents: [ + 'grafana-o11yinsights-app/insights-launcher/v1', + 'grafana-adaptiveprofiles-app/resolution-boost/v1', + ], + }, + }, + autoEnabled: true, + includes: [ + { + type: 'page', + name: 'Profiles', + role: 'Viewer', + action: 'datasources:explore', + path: '/a/grafana-pyroscope-app/explore', + addToNav: true, + defaultNav: true, + }, + ], + preload: true, + extensions: { + addedLinks: [ + { + targets: [ + 'grafana/explore/toolbar/action', + 'grafana/traceview/details', + 'grafana-assistant-app/navigateToDrilldown/v1', + ], + title: 'Open in Grafana Profiles Drilldown', + description: 'Try our new queryless experience for profiles', + }, + ], + exposedComponents: [ + { + id: 'grafana-pyroscope-app/embedded-profiles-exploration/v1', + title: 'Embedded Profiles Exploration', + description: + 'A component that renders a profiles exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-pyroscope-app/investigation/v1', + }, + { + id: 'grafana-pyroscope-app/settings/v1', + }, + ], + }, + }, + class: 'external', + module: { + path: 'public/plugins/grafana-pyroscope-app/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-pyroscope-app', + signature: { + status: 'valid', + type: 'grafana', + org: 'Grafana Labs', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-pyroscope-datasource', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-pyroscope-datasource', + type: 'datasource', + name: 'Grafana Pyroscope', + info: { + keywords: [ + 'grafana', + 'datasource', + 'phlare', + 'flamegraph', + 'profiling', + 'continuous profiling', + 'pyroscope', + ], + logos: { + small: 'public/plugins/grafana-pyroscope-datasource/img/grafana_pyroscope_icon.svg', + large: 'public/plugins/grafana-pyroscope-datasource/img/grafana_pyroscope_icon.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://www.grafana.com', + }, + description: + 'Data source for Grafana Pyroscope, horizontally-scalable, highly-available, multi-tenant continuous profiling aggregation system.', + links: [ + { + name: 'GitHub Project', + url: 'https://github.com/grafana/pyroscope', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/pyroscope/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/pyroscope/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + backend: true, + category: 'profiling', + executable: 'gpx_grafana-pyroscope-datasource', + metrics: true, + }, + class: 'core', + module: { + path: 'public/plugins/grafana-pyroscope-datasource/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-pyroscope-datasource', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-testdata-datasource', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-testdata-datasource', + type: 'datasource', + name: 'TestData', + info: { + keywords: [], + logos: { + small: 'public/plugins/grafana-testdata-datasource/img/testdata.svg', + large: 'public/plugins/grafana-testdata-datasource/img/testdata.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Generates test data in different forms', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/testdata/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + executable: 'gpx_testdata', + includes: [ + { + type: 'dashboard', + name: 'Streaming Example', + role: 'Viewer', + path: 'dashboards/streaming.json', + }, + ], + logs: true, + metrics: true, + queryOptions: { + maxDataPoints: true, + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/grafana-testdata-datasource/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-testdata-datasource', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'graphite', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'graphite', + type: 'datasource', + name: 'Graphite', + info: { + keywords: [], + logos: { + small: 'public/plugins/graphite/img/graphite_logo.png', + large: 'public/plugins/graphite/img/graphite_logo.png', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source time series database', + links: [ + { + name: 'Learn more', + url: 'https://graphiteapp.org/', + }, + { + name: 'Graphite 1.1 Release', + url: 'https://grafana.com/blog/2018/01/11/graphite-1.1-teaching-an-old-dog-new-tricks/', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/graphite/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'tsdb', + executable: 'gpx_graphite', + includes: [ + { + type: 'dashboard', + name: 'Graphite Carbon Metrics', + role: 'Viewer', + path: 'dashboards/carbon_metrics.json', + }, + { + type: 'dashboard', + name: 'Metrictank (Graphite alternative)', + role: 'Viewer', + path: 'dashboards/metrictank.json', + }, + ], + metrics: true, + queryOptions: { + maxDataPoints: true, + cacheTimeout: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/graphite/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/graphite', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'heatmap', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'heatmap', + type: 'panel', + name: 'Heatmap', + info: { + keywords: [], + logos: { + small: 'public/plugins/heatmap/img/icn-heatmap-panel.svg', + large: 'public/plugins/heatmap/img/icn-heatmap-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Like a histogram over time', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/heatmap/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/heatmap', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/heatmap', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'histogram', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'histogram', + type: 'panel', + name: 'Histogram', + info: { + keywords: ['distribution', 'bar chart', 'frequency', 'proportional'], + logos: { + small: 'public/plugins/histogram/img/histogram.svg', + large: 'public/plugins/histogram/img/histogram.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Distribution of values presented as a bar chart.', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/histogram/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/histogram', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/histogram', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'influxdb', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'influxdb', + type: 'datasource', + name: 'InfluxDB', + info: { + keywords: [], + logos: { + small: 'public/plugins/influxdb/img/influxdb_logo.svg', + large: 'public/plugins/influxdb/img/influxdb_logo.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source time series database', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/influxdb/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'tsdb', + logs: true, + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'core:plugin/influxdb', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/influxdb', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'jaeger', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'jaeger', + type: 'datasource', + name: 'Jaeger', + info: { + keywords: [], + logos: { + small: 'public/plugins/jaeger/img/jaeger_logo.svg', + large: 'public/plugins/jaeger/img/jaeger_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source, end-to-end distributed tracing', + links: [ + { + name: 'Learn more', + url: 'https://www.jaegertracing.io', + }, + { + name: 'Jaeger GitHub Project', + url: 'https://github.com/jaegertracing/jaeger', + }, + { + name: 'Repository', + url: 'https://github.com/grafana/grafana', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/jaeger/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + backend: true, + category: 'tracing', + executable: 'gpx_jaeger', + metrics: true, + tracing: true, + }, + class: 'core', + module: { + path: 'public/plugins/jaeger/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/jaeger', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'live', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'live', + type: 'panel', + name: 'Live', + info: { + keywords: [], + logos: { + small: 'public/plugins/live/img/live.svg', + large: 'public/plugins/live/img/live.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + state: 'alpha', + }, + class: 'core', + module: { + path: 'core:plugin/live', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/live', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'logs', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'logs', + type: 'panel', + name: 'Logs', + info: { + keywords: [], + logos: { + small: 'public/plugins/logs/img/icn-logs-panel.svg', + large: 'public/plugins/logs/img/icn-logs-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/logs/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/logs', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/logs', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'loki', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'loki', + type: 'datasource', + name: 'Loki', + info: { + keywords: [], + logos: { + small: 'public/plugins/loki/img/loki_icon.svg', + large: 'public/plugins/loki/img/loki_icon.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Like Prometheus but for logs. OSS logging solution from Grafana Labs', + links: [ + { + name: 'Learn more', + url: 'https://grafana.com/loki', + }, + { + name: 'GitHub Project', + url: 'https://github.com/grafana/loki', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/loki/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.4.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'logging', + executable: 'gpx_loki', + logs: true, + metrics: true, + queryOptions: { + maxDataPoints: true, + }, + streaming: true, + }, + class: 'core', + module: { + path: 'public/plugins/loki/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/loki', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'mixed', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'mixed', + type: 'datasource', + name: '-- Mixed --', + info: { + keywords: [], + logos: { + small: 'public/plugins/mixed/img/icn-mixeddatasources.svg', + large: 'public/plugins/mixed/img/icn-mixeddatasources.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Lets you query multiple data sources in the same panel.', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/#special-data-sources', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + builtIn: true, + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'core:plugin/mixed', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/mixed', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'mssql', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'mssql', + type: 'datasource', + name: 'Microsoft SQL Server', + info: { + keywords: [], + logos: { + small: 'public/plugins/mssql/img/sql_server_logo.svg', + large: 'public/plugins/mssql/img/sql_server_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Data source for Microsoft SQL Server compatible databases', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/mssql/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.4.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'sql', + executable: 'gpx_mssql', + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/mssql/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/mssql', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + translations: { + 'cs-CZ': 'public/plugins/mssql/locales/cs-CZ/mssql.json', + 'de-DE': 'public/plugins/mssql/locales/de-DE/mssql.json', + 'en-US': 'public/plugins/mssql/locales/en-US/mssql.json', + 'es-ES': 'public/plugins/mssql/locales/es-ES/mssql.json', + 'fr-FR': 'public/plugins/mssql/locales/fr-FR/mssql.json', + 'hu-HU': 'public/plugins/mssql/locales/hu-HU/mssql.json', + 'id-ID': 'public/plugins/mssql/locales/id-ID/mssql.json', + 'it-IT': 'public/plugins/mssql/locales/it-IT/mssql.json', + 'ja-JP': 'public/plugins/mssql/locales/ja-JP/mssql.json', + 'ko-KR': 'public/plugins/mssql/locales/ko-KR/mssql.json', + 'nl-NL': 'public/plugins/mssql/locales/nl-NL/mssql.json', + 'pl-PL': 'public/plugins/mssql/locales/pl-PL/mssql.json', + 'pt-BR': 'public/plugins/mssql/locales/pt-BR/mssql.json', + 'pt-PT': 'public/plugins/mssql/locales/pt-PT/mssql.json', + 'ru-RU': 'public/plugins/mssql/locales/ru-RU/mssql.json', + 'sv-SE': 'public/plugins/mssql/locales/sv-SE/mssql.json', + 'tr-TR': 'public/plugins/mssql/locales/tr-TR/mssql.json', + 'zh-Hans': 'public/plugins/mssql/locales/zh-Hans/mssql.json', + 'zh-Hant': 'public/plugins/mssql/locales/zh-Hant/mssql.json', + }, + }, + status: {}, + }, + v0alpha1Meta, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'mysql', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'mysql', + type: 'datasource', + name: 'MySQL', + info: { + keywords: [], + logos: { + small: 'public/plugins/mysql/img/mysql_logo.svg', + large: 'public/plugins/mysql/img/mysql_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Data source for MySQL databases', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/mysql/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.4.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'sql', + executable: 'gpx_mysql', + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/mysql/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/mysql', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'news', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'news', + type: 'panel', + name: 'News', + info: { + keywords: [], + logos: { + small: 'public/plugins/news/img/news.svg', + large: 'public/plugins/news/img/news.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'RSS feed reader', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/news/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + state: 'beta', + }, + class: 'core', + module: { + path: 'core:plugin/news', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/news', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'nodeGraph', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'nodeGraph', + type: 'panel', + name: 'Node Graph', + info: { + keywords: [], + logos: { + small: 'public/plugins/nodeGraph/img/icn-node-graph.svg', + large: 'public/plugins/nodeGraph/img/icn-node-graph.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/node-graph/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/nodeGraph', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/nodeGraph', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'opentsdb', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'opentsdb', + type: 'datasource', + name: 'OpenTSDB', + info: { + keywords: [], + logos: { + small: 'public/plugins/opentsdb/img/opentsdb_logo.png', + large: 'public/plugins/opentsdb/img/opentsdb_logo.png', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source time series database', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/opentsdb/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'tsdb', + executable: 'gpx_opentsdb', + metrics: true, + }, + class: 'core', + module: { + path: 'public/plugins/opentsdb/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/opentsdb', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'parca', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'parca', + type: 'datasource', + name: 'Parca', + info: { + keywords: ['grafana', 'datasource', 'parca', 'profiling'], + logos: { + small: 'public/plugins/parca/img/logo-small.svg', + large: 'public/plugins/parca/img/logo-small.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://www.grafana.com', + }, + description: + 'Continuous profiling for analysis of CPU and memory usage, down to the line number and throughout time. Saving infrastructure cost, improving performance, and increasing reliability.', + links: [ + { + name: 'GitHub Project', + url: 'https://github.com/parca-dev/parca', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/parca/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + backend: true, + category: 'profiling', + executable: 'gpx_parca', + metrics: true, + }, + class: 'core', + module: { + path: 'public/plugins/parca/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/parca', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'piechart', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'piechart', + type: 'panel', + name: 'Pie chart', + info: { + keywords: [], + logos: { + small: 'public/plugins/piechart/img/icon_piechart.svg', + large: 'public/plugins/piechart/img/icon_piechart.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'The new core pie chart visualization', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/pie-chart/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/piechart', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/piechart', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'prometheus', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'prometheus', + type: 'datasource', + name: 'Prometheus', + info: { + keywords: [], + logos: { + small: 'public/plugins/prometheus/img/prometheus_logo.svg', + large: 'public/plugins/prometheus/img/prometheus_logo.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source time series database & alerting', + links: [ + { + name: 'Learn more', + url: 'https://prometheus.io/', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/prometheus/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'tsdb', + includes: [ + { + type: 'dashboard', + name: 'Prometheus Stats', + role: 'Viewer', + path: 'dashboards/prometheus_stats.json', + }, + { + type: 'dashboard', + name: 'Prometheus 2.0 Stats', + role: 'Viewer', + path: 'dashboards/prometheus_2_stats.json', + }, + { + type: 'dashboard', + name: 'Grafana Stats', + role: 'Viewer', + path: 'dashboards/grafana_stats.json', + }, + ], + metrics: true, + multiValueFilterOperators: true, + queryOptions: { + minInterval: true, + }, + routes: [ + { + path: 'api/v1/query', + method: 'POST', + reqRole: 'Viewer', + reqAction: 'datasources:query', + }, + { + path: 'api/v1/query_range', + method: 'POST', + reqRole: 'Viewer', + reqAction: 'datasources:query', + }, + { + path: 'api/v1/series', + method: 'POST', + reqRole: 'Viewer', + reqAction: 'datasources:query', + }, + { + path: 'api/v1/labels', + method: 'POST', + reqRole: 'Viewer', + reqAction: 'datasources:query', + }, + { + path: 'api/v1/query_exemplars', + method: 'POST', + reqRole: 'Viewer', + reqAction: 'datasources:query', + }, + { + path: '/rules', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.rules.external:read', + }, + { + path: '/rules', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.rules.external:write', + }, + { + path: '/rules', + method: 'DELETE', + reqRole: 'Editor', + reqAction: 'alert.rules.external:write', + }, + { + path: '/config/v1/rules', + method: 'DELETE', + reqRole: 'Editor', + reqAction: 'alert.rules.external:write', + }, + { + path: '/config/v1/rules', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.rules.external:write', + }, + ], + }, + class: 'core', + module: { + path: 'core:plugin/prometheus', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/prometheus', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'radialbar', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'radialbar', + type: 'panel', + name: 'New Gauge', + info: { + keywords: [], + logos: { + small: 'public/plugins/radialbar/img/icon_gauge.svg', + large: 'public/plugins/radialbar/img/icon_gauge.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Standard gauge visualization', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/gauge/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + state: 'alpha', + }, + class: 'core', + module: { + path: 'core:plugin/radialbar', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/radialbar', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'stackdriver', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'stackdriver', + type: 'datasource', + name: 'Google Cloud Monitoring', + info: { + keywords: [], + logos: { + small: 'public/plugins/stackdriver/img/cloud_monitoring_logo.svg', + large: 'public/plugins/stackdriver/img/cloud_monitoring_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: "Data source for Google's monitoring service (formerly named Stackdriver)", + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/google-cloud-monitoring/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'cloud', + executable: 'gpx_cloudmonitoring', + includes: [ + { + type: 'dashboard', + name: 'Data Processing Monitoring', + role: 'Viewer', + path: 'dashboards/dataprocessing-monitoring.json', + }, + { + type: 'dashboard', + name: 'Cloud Functions Monitoring', + role: 'Viewer', + path: 'dashboards/cloudfunctions-monitoring.json', + }, + { + type: 'dashboard', + name: 'GCE VM Instance Monitoring', + role: 'Viewer', + path: 'dashboards/gce-vm-instance-monitoring.json', + }, + { + type: 'dashboard', + name: 'GKE Prometheus Pod/Node Monitoring', + role: 'Viewer', + path: 'dashboards/gke-prometheus-pod-node-monitoring.json', + }, + { + type: 'dashboard', + name: 'Firewall Insights Monitoring', + role: 'Viewer', + path: 'dashboards/firewall-insight-monitoring.json', + }, + { + type: 'dashboard', + name: 'GCE Network Monitoring', + role: 'Viewer', + path: 'dashboards/gce-network-monitoring.json', + }, + { + type: 'dashboard', + name: 'HTTP/S LB Backend Services', + role: 'Viewer', + path: 'dashboards/https-lb-backend-services-monitoring.json', + }, + { + type: 'dashboard', + name: 'HTTP/S Load Balancer Monitoring', + role: 'Viewer', + path: 'dashboards/https-loadbalancer-monitoring.json', + }, + { + type: 'dashboard', + name: 'Network TCP Load Balancer Monitoring', + role: 'Viewer', + path: 'dashboards/network-tcp-loadbalancer-monitoring.json', + }, + { + type: 'dashboard', + name: 'MicroService Monitoring', + role: 'Viewer', + path: 'dashboards/micro-service-monitoring.json', + }, + { + type: 'dashboard', + name: 'Cloud Storage Monitoring', + role: 'Viewer', + path: 'dashboards/cloud-storage-monitoring.json', + }, + { + type: 'dashboard', + name: 'Cloud SQL Monitoring', + role: 'Viewer', + path: 'dashboards/cloudsql-monitoring.json', + }, + { + type: 'dashboard', + name: 'Cloud SQL(MySQL) Monitoring', + role: 'Viewer', + path: 'dashboards/cloudsql-mysql-monitoring.json', + }, + ], + logs: true, + metrics: true, + queryOptions: { + maxDataPoints: true, + cacheTimeout: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/stackdriver/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/stackdriver', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'stat', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'stat', + type: 'panel', + name: 'Stat', + info: { + keywords: [], + logos: { + small: 'public/plugins/stat/img/icn-singlestat-panel.svg', + large: 'public/plugins/stat/img/icn-singlestat-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Big stat values & sparklines', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/stat/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/stat', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/stat', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'state-timeline', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'state-timeline', + type: 'panel', + name: 'State timeline', + info: { + keywords: [], + logos: { + small: 'public/plugins/state-timeline/img/timeline.svg', + large: 'public/plugins/state-timeline/img/timeline.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'State changes and durations', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/state-timeline/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/state-timeline', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/state-timeline', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'status-history', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'status-history', + type: 'panel', + name: 'Status history', + info: { + keywords: [], + logos: { + small: 'public/plugins/status-history/img/status.svg', + large: 'public/plugins/status-history/img/status.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Periodic status history', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/status-history/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/status-history', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/status-history', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'table', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'table', + type: 'panel', + name: 'Table', + info: { + keywords: [], + logos: { + small: 'public/plugins/table/img/icn-table-panel.svg', + large: 'public/plugins/table/img/icn-table-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Supports many column styles', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/table/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/table', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/table', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'tempo', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'tempo', + type: 'datasource', + name: 'Tempo', + info: { + keywords: [], + logos: { + small: 'public/plugins/tempo/img/tempo_logo.svg', + large: 'public/plugins/tempo/img/tempo_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'High volume, minimal dependency trace storage. OSS tracing solution from Grafana Labs.', + links: [ + { + name: 'GitHub Project', + url: 'https://github.com/grafana/tempo', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/tempo/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + backend: true, + category: 'tracing', + executable: 'gpx_tempo', + metrics: true, + tracing: true, + }, + class: 'core', + module: { + path: 'public/plugins/tempo/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/tempo', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'text', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'text', + type: 'panel', + name: 'Text', + info: { + keywords: [], + logos: { + small: 'public/plugins/text/img/icn-text-panel.svg', + large: 'public/plugins/text/img/icn-text-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Supports markdown and html content', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/text/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/text', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/text', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'timeseries', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'timeseries', + type: 'panel', + name: 'Time series', + info: { + keywords: [], + logos: { + small: 'public/plugins/timeseries/img/icn-timeseries-panel.svg', + large: 'public/plugins/timeseries/img/icn-timeseries-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Time based line, area and bar charts', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/time-series/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/timeseries', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/timeseries', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'traces', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'traces', + type: 'panel', + name: 'Traces', + info: { + keywords: [], + logos: { + small: 'public/plugins/traces/img/traces-panel.svg', + large: 'public/plugins/traces/img/traces-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/traces/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/traces', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/traces', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'trend', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'trend', + type: 'panel', + name: 'Trend', + info: { + keywords: [], + logos: { + small: 'public/plugins/trend/img/trend.svg', + large: 'public/plugins/trend/img/trend.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Like timeseries, but when x != time', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/trend/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + state: 'beta', + }, + class: 'core', + module: { + path: 'core:plugin/trend', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/trend', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'welcome', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'welcome', + type: 'panel', + name: 'Welcome', + info: { + keywords: [], + logos: { + small: 'public/plugins/welcome/img/icn-dashlist-panel.svg', + large: 'public/plugins/welcome/img/icn-dashlist-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + hideFromList: true, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/welcome', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/welcome', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'xychart', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'xychart', + type: 'panel', + name: 'XY Chart', + info: { + keywords: ['scatter', 'plot'], + logos: { + small: 'public/plugins/xychart/img/icn-xychart.svg', + large: 'public/plugins/xychart/img/icn-xychart.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Supports arbitrary X vs Y in a graph to visualize the relationship between two variables.', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/xy-chart/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/xychart', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/xychart', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'zipkin', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'zipkin', + type: 'datasource', + name: 'Zipkin', + info: { + keywords: [], + logos: { + small: 'public/plugins/zipkin/img/zipkin-logo.svg', + large: 'public/plugins/zipkin/img/zipkin-logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Placeholder for the distributed tracing system.', + links: [ + { + name: 'Learn more', + url: 'https://zipkin.io', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/zipkin/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + backend: true, + category: 'tracing', + executable: 'gpx_zipkin', + metrics: true, + tracing: true, + }, + class: 'core', + module: { + path: 'public/plugins/zipkin/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/zipkin', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + ], +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/types.ts b/packages/grafana-runtime/src/services/pluginMeta/types.ts new file mode 100644 index 00000000000..81efe0df7b3 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/types.ts @@ -0,0 +1,10 @@ +import type { AppPluginConfig } from '@grafana/data'; + +import type { Meta } from './types/meta_object_gen'; + +export type AppPluginMetas = Record; + +export type AppPluginMetasMapper = (response: T) => AppPluginMetas; +export interface PluginMetasResponse { + items: Meta[]; +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts b/packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts new file mode 100644 index 00000000000..044ec1f4cd8 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts @@ -0,0 +1,49 @@ +/* + * This file was generated by grafana-app-sdk. DO NOT EDIT. + */ +import { Spec } from './types.spec.gen'; +import { Status } from './types.status.gen'; + +export interface Metadata { + name: string; + namespace: string; + generateName?: string; + selfLink?: string; + uid?: string; + resourceVersion?: string; + generation?: number; + creationTimestamp?: string; + deletionTimestamp?: string; + deletionGracePeriodSeconds?: number; + labels?: Record; + annotations?: Record; + ownerReferences?: OwnerReference[]; + finalizers?: string[]; + managedFields?: ManagedFieldsEntry[]; +} + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export interface ManagedFieldsEntry { + manager?: string; + operation?: string; + apiVersion?: string; + time?: string; + fieldsType?: string; + subresource?: string; +} + +export interface Meta { + kind: string; + apiVersion: string; + metadata: Metadata; + spec: Spec; + status: Status; +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts b/packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts new file mode 100644 index 00000000000..51845e98454 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts @@ -0,0 +1,278 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// JSON configuration schema for Grafana plugins +// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json +export interface JSONData { + // Unique name of the plugin + id: string; + // Plugin type + type: "app" | "datasource" | "panel" | "renderer"; + // Human-readable name of the plugin + name: string; + // Metadata for the plugin + info: Info; + // Dependency information + dependencies: Dependencies; + // Optional fields + alerting?: boolean; + annotations?: boolean; + autoEnabled?: boolean; + backend?: boolean; + buildMode?: string; + builtIn?: boolean; + category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other"; + enterpriseFeatures?: EnterpriseFeatures; + executable?: string; + hideFromList?: boolean; + // +listType=atomic + includes?: Include[]; + logs?: boolean; + metrics?: boolean; + multiValueFilterOperators?: boolean; + pascalName?: string; + preload?: boolean; + queryOptions?: QueryOptions; + // +listType=atomic + routes?: Route[]; + skipDataQuery?: boolean; + state?: "alpha" | "beta"; + streaming?: boolean; + suggestions?: boolean; + tracing?: boolean; + iam?: IAM; + // +listType=atomic + roles?: Role[]; + extensions?: Extensions; +} + +export const defaultJSONData = (): JSONData => ({ + id: "", + type: "app", + name: "", + info: defaultInfo(), + dependencies: defaultDependencies(), +}); + +export interface Info { + // Required fields + // +listType=set + keywords: string[]; + logos: { + small: string; + large: string; + }; + updated: string; + version: string; + // Optional fields + author?: { + name?: string; + email?: string; + url?: string; + }; + description?: string; + // +listType=atomic + links?: { + name?: string; + url?: string; + }[]; + // +listType=atomic + screenshots?: { + name?: string; + path?: string; + }[]; +} + +export const defaultInfo = (): Info => ({ + keywords: [], + logos: { + small: "", + large: "", +}, + updated: "", + version: "", +}); + +export interface Dependencies { + // Required field + grafanaDependency: string; + // Optional fields + grafanaVersion?: string; + // +listType=set + // +listMapKey=id + plugins?: { + id: string; + type: "app" | "datasource" | "panel"; + name: string; + }[]; + extensions?: { + // +listType=set + exposedComponents?: string[]; + }; +} + +export const defaultDependencies = (): Dependencies => ({ + grafanaDependency: "", +}); + +export interface EnterpriseFeatures { + // Allow additional properties + healthDiagnosticsErrors?: boolean; +} + +export const defaultEnterpriseFeatures = (): EnterpriseFeatures => ({ + healthDiagnosticsErrors: false, +}); + +export interface Include { + uid?: string; + type?: "dashboard" | "page" | "panel" | "datasource"; + name?: string; + component?: string; + role?: "Admin" | "Editor" | "Viewer" | "None"; + action?: string; + path?: string; + addToNav?: boolean; + defaultNav?: boolean; + icon?: string; +} + +export const defaultInclude = (): Include => ({ +}); + +export interface QueryOptions { + maxDataPoints?: boolean; + minInterval?: boolean; + cacheTimeout?: boolean; +} + +export const defaultQueryOptions = (): QueryOptions => ({ +}); + +export interface Route { + path?: string; + method?: string; + url?: string; + reqSignedIn?: boolean; + reqRole?: string; + reqAction?: string; + // +listType=atomic + headers?: string[]; + body?: Record; + tokenAuth?: { + url?: string; + // +listType=set + scopes?: string[]; + params?: Record; + }; + jwtTokenAuth?: { + url?: string; + // +listType=set + scopes?: string[]; + params?: Record; + }; + // +listType=atomic + urlParams?: { + name?: string; + content?: string; + }[]; +} + +export const defaultRoute = (): Route => ({ +}); + +export interface IAM { + // +listType=atomic + permissions?: { + action?: string; + scope?: string; + }[]; +} + +export const defaultIAM = (): IAM => ({ +}); + +export interface Role { + role?: { + name?: string; + description?: string; + // +listType=atomic + permissions?: { + action?: string; + scope?: string; + }[]; + }; + // +listType=set + grants?: string[]; +} + +export const defaultRole = (): Role => ({ +}); + +export interface Extensions { + // +listType=atomic + addedComponents?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=atomic + addedLinks?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=atomic + addedFunctions?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=set + // +listMapKey=id + exposedComponents?: { + id: string; + title?: string; + description?: string; + }[]; + // +listType=set + // +listMapKey=id + extensionPoints?: { + id: string; + title?: string; + description?: string; + }[]; +} + +export const defaultExtensions = (): Extensions => ({ +}); + +export interface Spec { + pluginJson: JSONData; + class: "core" | "external"; + module?: { + path: string; + hash?: string; + loadingStrategy?: "fetch" | "script"; + }; + baseURL?: string; + signature?: { + status: "internal" | "valid" | "invalid" | "modified" | "unsigned"; + type?: "grafana" | "commercial" | "community" | "private" | "private-glob"; + org?: string; + }; + angular?: { + detected: boolean; + }; + translations?: Record; + // +listType=atomic + children?: string[]; +} + +export const defaultSpec = (): Spec => ({ + pluginJson: defaultJSONData(), + class: "core", +}); + diff --git a/packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts b/packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface OperatorState { + // lastEvaluation is the ResourceVersion last evaluated + lastEvaluation: string; + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + state: "success" | "in_progress" | "failed"; + // descriptiveState is an optional more descriptive state field which has no requirements on format + descriptiveState?: string; + // details contains any extra information that is operator-specific + details?: Record; +} + +export const defaultOperatorState = (): OperatorState => ({ + lastEvaluation: "", + state: "success", +}); + +export interface Status { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + operatorStates?: Record; + // additionalFields is reserved for future use + additionalFields?: Record; +} + +export const defaultStatus = (): Status => ({ +}); + diff --git a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts index 8d06591b46b..1627b2dc29b 100644 --- a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.4.0-pre"; +export const pluginVersion = "%VERSION%"; export type BucketAggregation = (DateHistogram | Histogram | Terms | Filters | GeoHashGrid | Nested); diff --git a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts index c0f8481a7f5..daead8f5295 100644 --- a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts @@ -29,11 +29,14 @@ export interface Options extends common.SingleStatBaseOptions { barWidthFactor: number; effects: GaugePanelEffects; endpointMarker?: ('point' | 'glow' | 'none'); + minVizHeight: number; + minVizWidth: number; segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); showThresholdLabels: boolean; showThresholdMarkers: boolean; + sizing: common.BarGaugeSizing; sparkline?: boolean; textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none'); } @@ -43,11 +46,14 @@ export const defaultOptions: Partial = { barWidthFactor: 0.5, effects: {}, endpointMarker: 'point', + minVizHeight: 75, + minVizWidth: 75, segmentCount: 1, segmentSpacing: 0.3, shape: 'gauge', showThresholdLabels: false, showThresholdMarkers: true, + sizing: common.BarGaugeSizing.Auto, sparkline: true, textMode: 'auto', }; diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx index c5c04a59cf6..cefa72df356 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx @@ -117,6 +117,44 @@ export const MyComponent = () => { }; ``` +### Custom Header Rendering + +Column headers can be customized using strings, React elements, or renderer functions. The `header` property accepts any value that matches React Table's `Renderer` type. + +**Important:** When using custom header content, prefer inline elements (like ``) over block elements (like `
        `) to avoid layout issues. Block-level elements can cause extra spacing and alignment problems in table headers because they disrupt the table's inline flow. Use `display: inline-flex` or `display: inline-block` when you need flexbox or block-like behavior. + +```tsx +const columns: Array> = [ + // React element header + { + id: 'checkbox', + header: ( + <> + + + + ), + cell: () => , + }, + + // Function renderer header + { + id: 'firstName', + header: () => ( + + + First Name + + ), + }, + + // String header + { id: 'lastName', header: 'Last name' }, +]; +``` + ### Custom Cell Rendering Individual cells can be rendered using custom content dy defining a `cell` property on the column definition. diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx index e0df9d9782f..d2b65c531a4 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx @@ -3,8 +3,11 @@ import { useCallback, useMemo, useState } from 'react'; import { CellProps } from 'react-table'; import { LinkButton } from '../Button/Button'; +import { Checkbox } from '../Forms/Checkbox'; import { Field } from '../Forms/Field'; +import { Icon } from '../Icon/Icon'; import { Input } from '../Input/Input'; +import { Text } from '../Text/Text'; import { FetchDataArgs, InteractiveTable, InteractiveTableHeaderTooltip } from './InteractiveTable'; import mdx from './InteractiveTable.mdx'; @@ -297,4 +300,40 @@ export const WithControlledSort: StoryFn = (args) => { return ; }; +export const WithCustomHeader: TableStoryObj = { + args: { + columns: [ + // React element header + { + id: 'checkbox', + header: ( + <> + + + + ), + cell: () => , + }, + // Function renderer header + { + id: 'firstName', + header: () => ( + + + First Name + + ), + sortType: 'string', + }, + // String header + { id: 'lastName', header: 'Last name', sortType: 'string' }, + { id: 'car', header: 'Car', sortType: 'string' }, + { id: 'age', header: 'Age', sortType: 'number' }, + ], + data: pageableData.slice(0, 10), + getRowId: (r) => r.id, + }, +}; export default meta; diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx index 651532409af..d3bc9918ad4 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx @@ -2,6 +2,9 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { Checkbox } from '../Forms/Checkbox'; +import { Icon } from '../Icon/Icon'; + import { InteractiveTable } from './InteractiveTable'; import { Column } from './types'; @@ -247,4 +250,104 @@ describe('InteractiveTable', () => { expect(fetchData).toHaveBeenCalledWith({ sortBy: [{ id: 'id', desc: false }] }); }); }); + + describe('custom header rendering', () => { + it('should render string headers', () => { + const columns: Array> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render(); + + expect(screen.getByRole('columnheader', { name: 'ID' })).toBeInTheDocument(); + }); + + it('should render React element headers', () => { + const columns: Array> = [ + { + id: 'checkbox', + header: ( + <> + + + + ), + cell: () => , + }, + ]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render(); + + expect(screen.getByTestId('header-checkbox')).toBeInTheDocument(); + expect(screen.getByTestId('cell-checkbox')).toBeInTheDocument(); + expect(screen.getByLabelText('Select all rows')).toBeInTheDocument(); + expect(screen.getByLabelText('Select row')).toBeInTheDocument(); + expect(screen.getByText('Select all rows')).toBeInTheDocument(); + }); + + it('should render function renderer headers', () => { + const columns: Array> = [ + { + id: 'firstName', + header: () => ( + + + First Name + + ), + sortType: 'string', + }, + ]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render(); + + expect(screen.getByTestId('header-icon')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /first name/i })).toBeInTheDocument(); + }); + + it('should render all header types together', () => { + const columns: Array> = [ + { + id: 'checkbox', + header: ( + <> + + + + ), + cell: () => , + }, + { + id: 'id', + header: () => ( + + + ID + + ), + sortType: 'string', + }, + { id: 'country', header: 'Country', sortType: 'string' }, + { id: 'value', header: 'Value' }, + ]; + const data: TableData[] = [ + { id: '1', value: 'Value 1', country: 'Sweden' }, + { id: '2', value: 'Value 2', country: 'Norway' }, + ]; + render(); + + expect(screen.getByTestId('header-checkbox')).toBeInTheDocument(); + expect(screen.getByTestId('header-icon')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Country' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Value' })).toBeInTheDocument(); + + // Verify data is rendered + expect(screen.getByText('Sweden')).toBeInTheDocument(); + expect(screen.getByText('Norway')).toBeInTheDocument(); + expect(screen.getByText('Value 1')).toBeInTheDocument(); + expect(screen.getByText('Value 2')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/InteractiveTable/types.ts b/packages/grafana-ui/src/components/InteractiveTable/types.ts index 5b84f4c568b..f466e7d9c6c 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/types.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/types.ts @@ -1,5 +1,5 @@ import { ReactNode } from 'react'; -import { CellProps, DefaultSortTypes, IdType, SortByFn } from 'react-table'; +import { CellProps, DefaultSortTypes, HeaderProps, IdType, Renderer, SortByFn } from 'react-table'; export interface Column { /** @@ -11,9 +11,9 @@ export interface Column { */ cell?: (props: CellProps) => ReactNode; /** - * Header name. if `undefined` the header will be empty. Useful for action columns. + * Header name. Can be a string, renderer function, or undefined. If `undefined` the header will be empty. Useful for action columns. */ - header?: string; + header?: Renderer>; /** * Column sort type. If `undefined` the column will not be sortable. * */ diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index a560ff47c5d..2e3b955a247 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -795,6 +795,10 @@ func (hs *HTTPServer) GetDashboardVersion(c *contextmodel.ReqContext) response.R // swagger:route POST /dashboards/uid/{uid}/restore dashboards versions restoreDashboardVersionByUID // // Restore a dashboard to a given dashboard version using UID. +// This API will be removed when /apis/dashboards.grafana.app/v1 is released. +// You can restore a dashboard by reading it from history, then creating it again. +// +// Deprecated: true // // Responses: // 200: postDashboardResponse diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 49194a625f4..62035a0e749 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -76,21 +76,27 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) { return } - // Do not check permissions when the instance snapshot public mode is enabled - if !hs.Cfg.SnapshotPublicMode { - evaluator := ac.EvalAll(ac.EvalPermission(dashboards.ActionSnapshotsCreate), ac.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(cmd.Dashboard.GetNestedString("uid")))) - if canSave, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canSave { - c.JsonApiErr(http.StatusForbidden, "forbidden", err) - return - } - } - - dashboardsnapshots.CreateDashboardSnapshot(c, snapshot.SnapshotSharingOptions{ + cfg := snapshot.SnapshotSharingOptions{ SnapshotsEnabled: hs.Cfg.SnapshotEnabled, ExternalEnabled: hs.Cfg.ExternalEnabled, ExternalSnapshotName: hs.Cfg.ExternalSnapshotName, ExternalSnapshotURL: hs.Cfg.ExternalSnapshotUrl, - }, cmd, hs.dashboardsnapshotsService) + } + + if hs.Cfg.SnapshotPublicMode { + // Public mode: no user or dashboard validation needed + dashboardsnapshots.CreateDashboardSnapshotPublic(c, cfg, cmd, hs.dashboardsnapshotsService) + return + } + + // Regular mode: check permissions + evaluator := ac.EvalAll(ac.EvalPermission(dashboards.ActionSnapshotsCreate), ac.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(cmd.Dashboard.GetNestedString("uid")))) + if canSave, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canSave { + c.JsonApiErr(http.StatusForbidden, "forbidden", err) + return + } + + dashboardsnapshots.CreateDashboardSnapshot(c, cfg, cmd, hs.dashboardsnapshotsService) } // GET /api/snapshots/:key @@ -213,13 +219,6 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon return response.Error(http.StatusUnauthorized, "OrgID mismatch", nil) } - if queryResult.External { - err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err) - } - } - // Dashboard can be empty (creation error or external snapshot). This means that the mustInt here returns a 0, // which before RBAC would result in a dashboard which has no ACL. A dashboard without an ACL would fallback // to the user’s org role, which for editors and admins would essentially always be allowed here. With RBAC, @@ -239,6 +238,13 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon } } + if queryResult.External { + err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err) + } + } + cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey} if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index de38697f613..d0c8a58ebd8 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -32,6 +32,8 @@ import ( var ( logger = glog.New("data-proxy-log") client = newHTTPClient() + + errPluginProxyRouteAccessDenied = errors.New("plugin proxy route access denied") ) type DataSourceProxy struct { @@ -308,12 +310,21 @@ func (proxy *DataSourceProxy) validateRequest() error { if err != nil { return err } + // issues/116273: When we have an empty input route (or input that becomes relative to "."), we do not want it + // to be ".". This is because the `CleanRelativePath` function will never return "./" prefixes, and as such, + // the common prefix we need is an empty string. + if r1 == "." && proxy.proxyPath != "." { + r1 = "" + } + if r2 == "." && route.Path != "." { + r2 = "" + } if !strings.HasPrefix(r1, r2) { continue } if !proxy.hasAccessToRoute(route) { - return errors.New("plugin proxy route access denied") + return errPluginProxyRouteAccessDenied } proxy.matchedRoute = route diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 002965a1606..38fd0006d4d 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -673,6 +673,94 @@ func TestIntegrationDataSourceProxy_routeRule(t *testing.T) { runDatasourceAuthTest(t, secretsService, secretsStore, cfg, test) } }) + + t.Run("Regression of 116273: Fallback routes should apply fallback route roles", func(t *testing.T) { + for _, tc := range []struct { + InputPath string + ConfigurationPath string + ExpectError bool + }{ + { + InputPath: "api/v2/leak-ur-secrets", + ConfigurationPath: "", + ExpectError: true, + }, + { + InputPath: "", + ConfigurationPath: "", + ExpectError: true, + }, + { + InputPath: ".", + ConfigurationPath: ".", + ExpectError: true, + }, + { + InputPath: "", + ConfigurationPath: ".", + ExpectError: false, + }, + { + InputPath: "api", + ConfigurationPath: ".", + ExpectError: false, + }, + } { + orEmptyStr := func(s string) string { + if s == "" { + return "" + } + return s + } + t.Run( + fmt.Sprintf("with inputPath=%s, configurationPath=%s, expectError=%v", + orEmptyStr(tc.InputPath), orEmptyStr(tc.ConfigurationPath), tc.ExpectError), + func(t *testing.T) { + ds := &datasources.DataSource{ + UID: "dsUID", + JsonData: simplejson.New(), + } + routes := []*plugins.Route{ + { + Path: tc.ConfigurationPath, + ReqRole: org.RoleAdmin, + Method: "GET", + }, + { + Path: tc.ConfigurationPath, + ReqRole: org.RoleAdmin, + Method: "POST", + }, + { + Path: tc.ConfigurationPath, + ReqRole: org.RoleAdmin, + Method: "PUT", + }, + { + Path: tc.ConfigurationPath, + ReqRole: org.RoleAdmin, + Method: "DELETE", + }, + } + + req, err := http.NewRequestWithContext(t.Context(), "GET", "http://localhost/"+tc.InputPath, nil) + require.NoError(t, err, "failed to create HTTP request") + ctx := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{OrgRole: org.RoleViewer}, + } + proxy, err := setupDSProxyTest(t, ctx, ds, routes, tc.InputPath) + require.NoError(t, err, "failed to setup proxy test") + err = proxy.validateRequest() + if tc.ExpectError { + require.ErrorIs(t, err, errPluginProxyRouteAccessDenied, "request was not denied due to access denied?") + } else { + require.NoError(t, err, "request was unexpectedly denied access") + } + }, + ) + } + }) } // test DataSourceProxy request handling. diff --git a/pkg/services/dashboardsnapshots/service.go b/pkg/services/dashboardsnapshots/service.go index 281afee38ac..98954b585a6 100644 --- a/pkg/services/dashboardsnapshots/service.go +++ b/pkg/services/dashboardsnapshots/service.go @@ -36,6 +36,9 @@ var client = &http.Client{ Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, } +// CreateDashboardSnapshot creates a snapshot when running Grafana in regular mode. +// It validates the user and dashboard exist before creating the snapshot. +// This mode supports both local and external snapshots. func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) { if !cfg.SnapshotsEnabled { c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) @@ -43,6 +46,7 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh } uid := cmd.Dashboard.GetNestedString("uid") + user, err := identity.GetRequester(c.Req.Context()) if err != nil { c.JsonApiErr(http.StatusBadRequest, "missing user in context", nil) @@ -59,21 +63,18 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh return } + cmd.ExternalURL = "" + cmd.OrgID = user.GetOrgID() + cmd.UserID, _ = identity.UserIdentifier(user.GetID()) + if cmd.Name == "" { cmd.Name = "Unnamed snapshot" } - var snapshotUrl string - cmd.ExternalURL = "" - cmd.OrgID = user.GetOrgID() - cmd.UserID, _ = identity.UserIdentifier(user.GetID()) - originalDashboardURL, err := createOriginalDashboardURL(&cmd) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err) - return - } + var snapshotURL string if cmd.External { + // Handle external snapshot creation if !cfg.ExternalEnabled { c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) return @@ -85,40 +86,83 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh return } - snapshotUrl = resp.Url cmd.Key = resp.Key cmd.DeleteKey = resp.DeleteKey cmd.ExternalURL = resp.Url cmd.ExternalDeleteURL = resp.DeleteUrl cmd.Dashboard = &common.Unstructured{} + snapshotURL = resp.Url metrics.MApiDashboardSnapshotExternal.Inc() } else { - cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl") - - if cmd.Key == "" { - var err error - cmd.Key, err = util.GetRandomString(32) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) - return - } + // Handle local snapshot creation + originalDashboardURL, err := createOriginalDashboardURL(&cmd) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err) + return } - if cmd.DeleteKey == "" { - var err error - cmd.DeleteKey, err = util.GetRandomString(32) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) - return - } + snapshotURL, err = prepareLocalSnapshot(&cmd, originalDashboardURL) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) + return } - snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key) - metrics.MApiDashboardSnapshotCreate.Inc() } + saveAndRespond(c, svc, cmd, snapshotURL) +} + +// CreateDashboardSnapshotPublic creates a snapshot when running Grafana in public mode. +// In public mode, there is no user or dashboard information to validate. +// Only local snapshots are supported (external snapshots are not available). +func CreateDashboardSnapshotPublic(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) { + if !cfg.SnapshotsEnabled { + c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return + } + + if cmd.Name == "" { + cmd.Name = "Unnamed snapshot" + } + + snapshotURL, err := prepareLocalSnapshot(&cmd, "") + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) + return + } + + metrics.MApiDashboardSnapshotCreate.Inc() + + saveAndRespond(c, svc, cmd, snapshotURL) +} + +// prepareLocalSnapshot prepares the command for a local snapshot and returns the snapshot URL. +func prepareLocalSnapshot(cmd *CreateDashboardSnapshotCommand, originalDashboardURL string) (string, error) { + cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl") + + if cmd.Key == "" { + key, err := util.GetRandomString(32) + if err != nil { + return "", err + } + cmd.Key = key + } + + if cmd.DeleteKey == "" { + deleteKey, err := util.GetRandomString(32) + if err != nil { + return "", err + } + cmd.DeleteKey = deleteKey + } + + return setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key), nil +} + +// saveAndRespond saves the snapshot and sends the response. +func saveAndRespond(c *contextmodel.ReqContext, svc Service, cmd CreateDashboardSnapshotCommand, snapshotURL string) { result, err := svc.CreateDashboardSnapshot(c.Req.Context(), &cmd) if err != nil { c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err) @@ -128,7 +172,7 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh c.JSON(http.StatusOK, snapshot.DashboardCreateResponse{ Key: result.Key, DeleteKey: result.DeleteKey, - URL: snapshotUrl, + URL: snapshotURL, DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + result.DeleteKey), }) } diff --git a/pkg/services/dashboardsnapshots/service_test.go b/pkg/services/dashboardsnapshots/service_test.go index c8e817b720e..7add1233581 100644 --- a/pkg/services/dashboardsnapshots/service_test.go +++ b/pkg/services/dashboardsnapshots/service_test.go @@ -20,40 +20,30 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func TestCreateDashboardSnapshot_DashboardNotFound(t *testing.T) { - mockService := &MockService{} - cfg := snapshot.SnapshotSharingOptions{ - SnapshotsEnabled: true, - ExternalEnabled: false, +func createTestDashboard(t *testing.T) *common.Unstructured { + t.Helper() + dashboard := &common.Unstructured{} + dashboardData := map[string]any{ + "uid": "test-dashboard-uid", + "id": 123, } - testUser := &user.SignedInUser{ + dashboardBytes, _ := json.Marshal(dashboardData) + _ = json.Unmarshal(dashboardBytes, dashboard) + return dashboard +} + +func createTestUser() *user.SignedInUser { + return &user.SignedInUser{ UserID: 1, OrgID: 1, Login: "testuser", Name: "Test User", Email: "test@example.com", } - dashboard := &common.Unstructured{} - dashboardData := map[string]interface{}{ - "uid": "test-dashboard-uid", - "id": 123, - } - dashboardBytes, _ := json.Marshal(dashboardData) - _ = json.Unmarshal(dashboardBytes, dashboard) - - cmd := CreateDashboardSnapshotCommand{ - DashboardCreateCommand: snapshot.DashboardCreateCommand{ - Dashboard: dashboard, - Name: "Test Snapshot", - }, - } - - mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). - Return(dashboards.ErrDashboardNotFound) - - req, _ := http.NewRequest("POST", "/api/snapshots", nil) - req = req.WithContext(identity.WithRequester(req.Context(), testUser)) +} +func createReqContext(t *testing.T, req *http.Request, testUser *user.SignedInUser) (*contextmodel.ReqContext, *httptest.ResponseRecorder) { + t.Helper() recorder := httptest.NewRecorder() ctx := &contextmodel.ReqContext{ Context: &web.Context{ @@ -63,13 +53,319 @@ func TestCreateDashboardSnapshot_DashboardNotFound(t *testing.T) { SignedInUser: testUser, Logger: log.NewNopLogger(), } + return ctx, recorder +} - CreateDashboardSnapshot(ctx, cfg, cmd, mockService) +// TestCreateDashboardSnapshot tests snapshot creation in regular mode (non-public instance). +// These tests cover scenarios when Grafana is running as a regular server with user authentication. +func TestCreateDashboardSnapshot(t *testing.T) { + t.Run("should return error when dashboard not found", func(t *testing.T) { + mockService := &MockService{} + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + ExternalEnabled: false, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) - mockService.AssertExpectations(t) - assert.Equal(t, http.StatusBadRequest, recorder.Code) - var response map[string]interface{} - err := json.Unmarshal(recorder.Body.Bytes(), &response) - require.NoError(t, err) - assert.Equal(t, "Dashboard not found", response["message"]) + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Snapshot", + }, + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(dashboards.ErrDashboardNotFound) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "Dashboard not found", response["message"]) + }) + + t.Run("should create external snapshot when external is enabled", func(t *testing.T) { + externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/snapshots", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + response := map[string]any{ + "key": "external-key", + "deleteKey": "external-delete-key", + "url": "https://external.example.com/dashboard/snapshot/external-key", + "deleteUrl": "https://external.example.com/api/snapshots-delete/external-delete-key", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer externalServer.Close() + + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + ExternalEnabled: true, + ExternalSnapshotURL: externalServer.URL, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test External Snapshot", + External: true, + }, + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(nil) + mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything). + Return(&DashboardSnapshot{ + Key: "external-key", + DeleteKey: "external-delete-key", + }, nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusOK, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "external-key", response["key"]) + assert.Equal(t, "external-delete-key", response["deleteKey"]) + assert.Equal(t, "https://external.example.com/dashboard/snapshot/external-key", response["url"]) + }) + + t.Run("should return forbidden when external is disabled", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + ExternalEnabled: false, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test External Snapshot", + External: true, + }, + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusForbidden, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "External dashboard creation is disabled", response["message"]) + }) + + t.Run("should create local snapshot", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Local Snapshot", + }, + Key: "local-key", + DeleteKey: "local-delete-key", + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(nil) + mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything). + Return(&DashboardSnapshot{ + Key: "local-key", + DeleteKey: "local-delete-key", + }, nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusOK, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "local-key", response["key"]) + assert.Equal(t, "local-delete-key", response["deleteKey"]) + assert.Contains(t, response["url"], "dashboard/snapshot/local-key") + assert.Contains(t, response["deleteUrl"], "api/snapshots-delete/local-delete-key") + }) +} + +// TestCreateDashboardSnapshotPublic tests snapshot creation in public mode. +// These tests cover scenarios when Grafana is running as a public snapshot server +// where no user authentication or dashboard validation is required. +func TestCreateDashboardSnapshotPublic(t *testing.T) { + t.Run("should create local snapshot without user context", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + } + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Snapshot", + }, + Key: "test-key", + DeleteKey: "test-delete-key", + } + + mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything). + Return(&DashboardSnapshot{ + Key: "test-key", + DeleteKey: "test-delete-key", + }, nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + recorder := httptest.NewRecorder() + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + Resp: web.NewResponseWriter("POST", recorder), + }, + Logger: log.NewNopLogger(), + } + + CreateDashboardSnapshotPublic(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusOK, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "test-key", response["key"]) + assert.Equal(t, "test-delete-key", response["deleteKey"]) + assert.Contains(t, response["url"], "dashboard/snapshot/test-key") + assert.Contains(t, response["deleteUrl"], "api/snapshots-delete/test-delete-key") + }) + + t.Run("should return forbidden when snapshots are disabled", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: false, + } + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Snapshot", + }, + } + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + recorder := httptest.NewRecorder() + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + Resp: web.NewResponseWriter("POST", recorder), + }, + Logger: log.NewNopLogger(), + } + + CreateDashboardSnapshotPublic(ctx, cfg, cmd, mockService) + + assert.Equal(t, http.StatusForbidden, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "Dashboard Snapshots are disabled", response["message"]) + }) +} + +// TestDeleteExternalDashboardSnapshot tests deletion of external snapshots. +// This function is called in public mode and doesn't require user context. +func TestDeleteExternalDashboardSnapshot(t *testing.T) { + t.Run("should return nil on successful deletion", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.NoError(t, err) + }) + + t.Run("should gracefully handle already deleted snapshot", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + response := map[string]any{ + "message": "Failed to get dashboard snapshot", + } + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.NoError(t, err) + }) + + t.Run("should return error on unexpected status code", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected response when deleting external snapshot") + assert.Contains(t, err.Error(), "404") + }) + + t.Run("should return error on 500 with different message", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + response := map[string]any{ + "message": "Some other error", + } + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "500") + }) } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 4c0456e9457..72623cba2fa 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -574,8 +574,8 @@ var ( }, { Name: "dashboardNewLayouts", - Description: "Enables experimental new dashboard layouts", - Stage: FeatureStageExperimental, + Description: "Enables new dashboard layouts", + Stage: FeatureStagePublicPreview, FrontendOnly: false, // The restore backend feature changes behavior based on this flag Owner: grafanaDashboardsSquad, }, @@ -879,6 +879,13 @@ var ( Owner: grafanaAlertingSquad, FrontendOnly: true, }, + { + Name: "alertingNavigationV2", + Description: "Enables the new Alerting navigation structure with improved menu grouping", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + FrontendOnly: false, + }, { Name: "alertingSavedSearches", Description: "Enables saved searches for alert rules list", @@ -981,7 +988,8 @@ var ( Stage: FeatureStageDeprecated, Owner: grafanaPartnerPluginsSquad, Expression: "true", // Enabled by default for now - }, { + }, + { Name: "alertingFilterV2", Description: "Enable the new alerting search experience", Stage: FeatureStageExperimental, @@ -2069,6 +2077,14 @@ var ( Owner: grafanaObservabilityTracesAndProfilingSquad, FrontendOnly: false, }, + { + Name: "alertingSyncDispatchTimer", + Description: "Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + RequiresRestart: true, + HideFromDocs: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index caba7cdab90..61505b65571 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -79,7 +79,7 @@ annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true -dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false +dashboardNewLayouts,preview,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true @@ -121,6 +121,7 @@ dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false dashboardTemplates,preview,@grafana/sharing-squad,false,false,false alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true +alertingNavigationV2,experimental,@grafana/alerting-squad,false,false,false alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false @@ -280,3 +281,4 @@ multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true smoothingTransformation,experimental,@grafana/datapro,false,false,true secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false +alertingSyncDispatchTimer,experimental,@grafana/alerting-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d68fa56ec8c..db2b4484e42 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -260,7 +260,7 @@ const ( FlagAnnotationPermissionUpdate = "annotationPermissionUpdate" // FlagDashboardNewLayouts - // Enables experimental new dashboard layouts + // Enables new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" // FlagPdfTables @@ -371,6 +371,10 @@ const ( // Enables a flow to get started with a new dashboard from a template FlagDashboardTemplates = "dashboardTemplates" + // FlagAlertingNavigationV2 + // Enables the new Alerting navigation structure with improved menu grouping + FlagAlertingNavigationV2 = "alertingNavigationV2" + // FlagAlertingDisableSendAlertsExternal // Disables the ability to send alerts to an external Alertmanager datasource. FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal" @@ -789,4 +793,8 @@ const ( // FlagProfilesExemplars // Enables profiles exemplars support in profiles drilldown FlagProfilesExemplars = "profilesExemplars" + + // FlagAlertingSyncDispatchTimer + // Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods + FlagAlertingSyncDispatchTimer = "alertingSyncDispatchTimer" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index bfefc20f08b..4832f93c309 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -348,6 +348,18 @@ "expression": "true" } }, + { + "metadata": { + "name": "alertingNavigationV2", + "resourceVersion": "1768320918269", + "creationTimestamp": "2026-01-13T16:15:18Z" + }, + "spec": { + "description": "Enables the new Alerting navigation structure with improved menu grouping", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, { "metadata": { "name": "alertingNotificationHistory", @@ -511,6 +523,20 @@ "frontend": true } }, + { + "metadata": { + "name": "alertingSyncDispatchTimer", + "resourceVersion": "1766161788928", + "creationTimestamp": "2025-12-19T16:29:48Z" + }, + "spec": { + "description": "Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "alertingTriage", @@ -662,7 +688,8 @@ "metadata": { "name": "auditLoggingAppPlatform", "resourceVersion": "1767013056996", - "creationTimestamp": "2025-12-29T12:57:36Z" + "creationTimestamp": "2025-12-29T12:57:36Z", + "deletionTimestamp": "2026-01-06T09:18:36Z" }, "spec": { "description": "Enable audit logging with Kubernetes under app platform", @@ -1015,12 +1042,15 @@ { "metadata": { "name": "dashboardNewLayouts", - "resourceVersion": "1764664939750", - "creationTimestamp": "2024-10-23T08:55:45Z" + "resourceVersion": "1768382835527", + "creationTimestamp": "2024-10-23T08:55:45Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-14 09:27:15.527103 +0000 UTC" + } }, "spec": { - "description": "Enables experimental new dashboard layouts", - "stage": "experimental", + "description": "Enables new dashboard layouts", + "stage": "preview", "codeowner": "@grafana/dashboards-squad" } }, diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index ca91ab4f915..c1da10781d6 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -54,8 +54,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink } //nolint:staticcheck // not yet migrated to OpenFeature if c.HasRole(identity.RoleAdmin) && - (s.cfg.StackID == "" || // show OnPrem even when provisioning is disabled - s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning)) { + s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{ Text: "Provisioning", Id: "provisioning", diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 53e49621117..5177107a602 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -213,6 +213,9 @@ func (ng *AlertNG) init() error { SkipVerify: ng.Cfg.Smtp.SkipVerify, StaticHeaders: ng.Cfg.Smtp.StaticHeaders, } + runtimeConfig := remoteClient.RuntimeConfig{ + DispatchTimer: notifier.GetDispatchTimer(ng.FeatureToggles).String(), + } cfg := remote.AlertmanagerConfig{ BasicAuthPassword: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Password, @@ -222,6 +225,7 @@ func (ng *AlertNG) init() error { ExternalURL: ng.Cfg.AppURL, SmtpConfig: smtpCfg, Timeout: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Timeout, + RuntimeConfig: runtimeConfig, } autogenFn := func(ctx context.Context, logger log.Logger, orgID int64, cfg *definitions.PostableApiAlertingConfig, invalidReceiverAction notifier.InvalidReceiversAction) error { return notifier.AddAutogenConfig(ctx, logger, ng.store, orgID, cfg, invalidReceiverAction, ng.FeatureToggles) diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index f192ed88058..6d81d51dc75 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -33,6 +33,9 @@ const ( // How long we keep silences in the kvstore after they've expired. silenceRetention = 5 * 24 * time.Hour + + // How long we keep flushes in the kvstore after they've expired. + flushRetention = 5 * 24 * time.Hour ) type AlertingStore interface { @@ -44,8 +47,10 @@ type AlertingStore interface { type stateStore interface { SaveSilences(ctx context.Context, st alertingNotify.State) (int64, error) SaveNotificationLog(ctx context.Context, st alertingNotify.State) (int64, error) + SaveFlushLog(ctx context.Context, st alertingNotify.State) (int64, error) GetSilences(ctx context.Context) (string, error) GetNotificationLog(ctx context.Context) (string, error) + GetFlushLog(ctx context.Context) (string, error) } type alertmanager struct { @@ -101,6 +106,10 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A if err != nil { return nil, err } + flushLog, err := stateStore.GetFlushLog(ctx) + if err != nil { + return nil, err + } silencesOptions := maintenanceOptions{ initialState: silences, @@ -123,12 +132,29 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A } l := log.New("ngalert.notifier") + dispatchTimer := GetDispatchTimer(featureToggles) + + var flushLogOptions *maintenanceOptions + if dispatchTimer == alertingNotify.DispatchTimerSync { + flushLogOptions = &maintenanceOptions{ + initialState: flushLog, + retention: flushRetention, + maintenanceFrequency: maintenanceInterval, + maintenanceFunc: func(state alertingNotify.State) (int64, error) { + // Detached context here is to make sure that when the service is shut down the persist operation is executed. + return stateStore.SaveFlushLog(context.Background(), state) + }, + } + } + opts := alertingNotify.GrafanaAlertmanagerOpts{ ExternalURL: cfg.AppURL, AlertStoreCallback: nil, PeerTimeout: cfg.UnifiedAlerting.HAPeerTimeout, Silences: silencesOptions, Nflog: nflogOptions, + FlushLog: flushLogOptions, + DispatchTimer: dispatchTimer, Limits: alertingNotify.Limits{ MaxSilences: cfg.UnifiedAlerting.AlertmanagerMaxSilencesCount, MaxSilenceSizeBytes: cfg.UnifiedAlerting.AlertmanagerMaxSilenceSizeBytes, diff --git a/pkg/services/ngalert/notifier/dispatch_timer.go b/pkg/services/ngalert/notifier/dispatch_timer.go new file mode 100644 index 00000000000..04eaf8cb296 --- /dev/null +++ b/pkg/services/ngalert/notifier/dispatch_timer.go @@ -0,0 +1,16 @@ +package notifier + +import ( + alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +// GetDispatchTimer returns the appropriate dispatch timer based on feature toggles. +func GetDispatchTimer(features featuremgmt.FeatureToggles) (dt alertingNotify.DispatchTimer) { + //nolint:staticcheck // not yet migrated to OpenFeature + enabled := features.IsEnabledGlobally(featuremgmt.FlagAlertingSyncDispatchTimer) + if enabled { + dt = alertingNotify.DispatchTimerSync + } + return +} diff --git a/pkg/services/ngalert/notifier/dispatch_timer_test.go b/pkg/services/ngalert/notifier/dispatch_timer_test.go new file mode 100644 index 00000000000..3b42a562a32 --- /dev/null +++ b/pkg/services/ngalert/notifier/dispatch_timer_test.go @@ -0,0 +1,36 @@ +package notifier + +import ( + "testing" + + alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/stretchr/testify/require" +) + +func TestGetDispatchTimer(t *testing.T) { + tests := []struct { + name string + featureFlagValue bool + expected alertingNotify.DispatchTimer + }{ + { + name: "feature flag enabled returns sync timer", + featureFlagValue: true, + expected: alertingNotify.DispatchTimerSync, + }, + { + name: "feature flag disabled returns default timer", + featureFlagValue: false, + expected: alertingNotify.DispatchTimerDefault, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + features := featuremgmt.WithFeatures(featuremgmt.FlagAlertingSyncDispatchTimer, tt.featureFlagValue) + result := GetDispatchTimer(features) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/services/ngalert/notifier/file_store.go b/pkg/services/ngalert/notifier/file_store.go index e9628cb536e..3bb128d692d 100644 --- a/pkg/services/ngalert/notifier/file_store.go +++ b/pkg/services/ngalert/notifier/file_store.go @@ -15,6 +15,7 @@ const ( KVNamespace = "alertmanager" NotificationLogFilename = "notifications" SilencesFilename = "silences" + FlushLogFilename = "flushes" ) // FileStore is in charge of persisting the alertmanager files to the database. @@ -42,6 +43,10 @@ func (fileStore *FileStore) GetNotificationLog(ctx context.Context) (string, err return fileStore.contentFor(ctx, NotificationLogFilename) } +func (fileStore *FileStore) GetFlushLog(ctx context.Context) (string, error) { + return fileStore.contentFor(ctx, FlushLogFilename) +} + // contentFor returns the content for the given Alertmanager kvstore key. func (fileStore *FileStore) contentFor(ctx context.Context, filename string) (string, error) { // Then, let's attempt to read it from the database. @@ -74,6 +79,11 @@ func (fileStore *FileStore) SaveNotificationLog(ctx context.Context, st alerting return fileStore.persist(ctx, NotificationLogFilename, st) } +// SaveFlushLog saves the flush log to the database and returns the size of the unencoded state. +func (fileStore *FileStore) SaveFlushLog(ctx context.Context, st alertingNotify.State) (int64, error) { + return fileStore.persist(ctx, FlushLogFilename, st) +} + // persist takes care of persisting the binary representation of internal state to the database as a base64 encoded string. func (fileStore *FileStore) persist(ctx context.Context, filename string, st alertingNotify.State) (int64, error) { var size int64 diff --git a/pkg/services/ngalert/notifier/file_store_test.go b/pkg/services/ngalert/notifier/file_store_test.go index 1952eb5a0f1..7d4de602a0f 100644 --- a/pkg/services/ngalert/notifier/file_store_test.go +++ b/pkg/services/ngalert/notifier/file_store_test.go @@ -106,3 +106,48 @@ func TestFileStore_NotificationLog(t *testing.T) { t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded)) } } + +func TestFileStore_FlushLog(t *testing.T) { + store := fakes.NewFakeKVStore(t) + ctx := context.Background() + var orgId int64 = 1 + + // Initialize kvstore with empty flush log state. + initialState := flushLogState{} // FlushLog uses the same structure as nflog + decodedState, err := initialState.MarshalBinary() + require.NoError(t, err) + encodedState := base64.StdEncoding.EncodeToString(decodedState) + err = store.Set(ctx, orgId, KVNamespace, FlushLogFilename, encodedState) + require.NoError(t, err) + + fs := NewFileStore(orgId, store) + + // Load initial (empty). + flushLog, err := fs.GetFlushLog(ctx) + require.NoError(t, err) + decoded, err := decodeFlushLogState(strings.NewReader(flushLog)) + require.NoError(t, err) + if !cmp.Equal(initialState, decoded) { + t.Errorf("Unexpected Diff: %v", cmp.Diff(initialState, decoded)) + } + + // Save new flush log state. + now := time.Now() + oneHour := now.Add(time.Hour) + + v1 := createFlushLog(1, now, oneHour) + v2 := createFlushLog(2, now, oneHour) + newState := flushLogState{1: v1, 2: v2} + size, err := fs.SaveFlushLog(ctx, newState) + require.NoError(t, err) + require.Greater(t, size, int64(0)) + + // Load new. + flushLog, err = fs.GetFlushLog(ctx) + require.NoError(t, err) + decoded, err = decodeFlushLogState(strings.NewReader(flushLog)) + require.NoError(t, err) + if !cmp.Equal(newState, decoded) { + t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded)) + } +} diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 4aa0151d18f..a10aee29ef6 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -82,6 +82,7 @@ type Alertmanager interface { type ExternalState struct { Silences []byte Nflog []byte + FlushLog []byte } // StateMerger describes a type that is able to merge external state (nflog, silences) with its own. @@ -378,7 +379,7 @@ func (moa *MultiOrgAlertmanager) SyncAlertmanagersForOrgs(ctx context.Context, o func (moa *MultiOrgAlertmanager) cleanupOrphanLocalOrgState(ctx context.Context, activeOrganizations map[int64]struct{}, ) { - storedFiles := []string{NotificationLogFilename, SilencesFilename} + storedFiles := []string{NotificationLogFilename, SilencesFilename, FlushLogFilename} for _, fileName := range storedFiles { keys, err := moa.kvStore.Keys(ctx, kvstore.AllOrganizations, KVNamespace, fileName) if err != nil { diff --git a/pkg/services/ngalert/notifier/state.go b/pkg/services/ngalert/notifier/state.go index c8551d2ed1a..04ba9d9a31d 100644 --- a/pkg/services/ngalert/notifier/state.go +++ b/pkg/services/ngalert/notifier/state.go @@ -5,5 +5,8 @@ func (am *alertmanager) MergeState(state ExternalState) error { if err := am.Base.MergeNflog(state.Nflog); err != nil { return err } - return am.Base.MergeSilences(state.Silences) + if err := am.Base.MergeSilences(state.Silences); err != nil { + return err + } + return am.Base.MergeFlushLog(state.FlushLog) } diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index 9fccf6d2f0d..c2b2190183f 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -11,6 +11,7 @@ import ( "time" "github.com/matttproud/golang_protobuf_extensions/pbutil" + "github.com/prometheus/alertmanager/flushlog/flushlogpb" "github.com/prometheus/alertmanager/nflog/nflogpb" "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/common/model" @@ -228,15 +229,13 @@ func (f *FakeOrgStore) FetchOrgIds(_ context.Context) ([]int64, error) { return f.orgs, nil } -type NoValidation struct { -} +type NoValidation struct{} func (n NoValidation) Validate(_ models.NotificationSettings) error { return nil } -type RejectingValidation struct { -} +type RejectingValidation struct{} func (n RejectingValidation) Validate(s models.NotificationSettings) error { return ErrorReceiverDoesNotExist{ErrorReferenceInvalid: ErrorReferenceInvalid{Reference: s.Receiver}} @@ -365,6 +364,51 @@ func createNotificationLog(groupKey string, receiverName string, sentAt, expires } } +// https://github.com/grafana/prometheus-alertmanager/blob/main/flushlog/flushlog.go#L136-L136 +type flushLogState map[uint64]*flushlogpb.MeshFlushLog + +func (s flushLogState) MarshalBinary() ([]byte, error) { + var buf bytes.Buffer + + for _, e := range s { + if _, err := pbutil.WriteDelimited(&buf, e); err != nil { + return nil, err + } + } + return buf.Bytes(), nil +} + +func createFlushLog(groupFingerprint uint64, ts, expiresAt time.Time) *flushlogpb.MeshFlushLog { + return &flushlogpb.MeshFlushLog{ + FlushLog: &flushlogpb.FlushLog{ + GroupFingerprint: groupFingerprint, + Timestamp: ts, + }, + ExpiresAt: expiresAt, + } +} + +// decodeFlushLogState copied from decodeState in prometheus-alertmanager/flushlog/flushlog.go +func decodeFlushLogState(r io.Reader) (flushLogState, error) { + st := flushLogState{} + for { + var e flushlogpb.MeshFlushLog + _, err := pbutil.ReadDelimited(r, &e) + if err == nil { + if e.FlushLog == nil || e.FlushLog.GroupFingerprint == 0 || e.FlushLog.Timestamp.IsZero() { + return nil, errInvalidState + } + st[e.FlushLog.GroupFingerprint] = &e + continue + } + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + return st, nil +} + type call struct { Method string Args []interface{} diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index 60740d935af..07fa5e3138f 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -47,6 +47,7 @@ import ( type stateStore interface { GetSilences(ctx context.Context) (string, error) GetNotificationLog(ctx context.Context) (string, error) + GetFlushLog(ctx context.Context) (string, error) } // AutogenFn is a function that adds auto-generated routes to a configuration. @@ -86,6 +87,8 @@ type Alertmanager struct { promoteConfig bool externalURL string + + runtimeConfig remoteClient.RuntimeConfig } type AlertmanagerConfig struct { @@ -111,6 +114,9 @@ type AlertmanagerConfig struct { // Timeout for the HTTP client. Timeout time.Duration + + // RuntimeConfig specifies runtime behavior settings for the remote Alertmanager. + RuntimeConfig remoteClient.RuntimeConfig } func (cfg *AlertmanagerConfig) Validate() error { @@ -203,6 +209,7 @@ func NewAlertmanager(ctx context.Context, cfg AlertmanagerConfig, store stateSto externalURL: cfg.ExternalURL, promoteConfig: cfg.PromoteConfig, smtp: cfg.SmtpConfig, + runtimeConfig: cfg.RuntimeConfig, } // Parse the default configuration once and remember its hash so we can compare it later. @@ -331,10 +338,11 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea AlertmanagerConfig: mergeResult.Config, Templates: templates, }, - CreatedAt: createdAtEpoch, - Promoted: am.promoteConfig, - ExternalURL: am.externalURL, - SmtpConfig: am.smtp, + CreatedAt: createdAtEpoch, + Promoted: am.promoteConfig, + ExternalURL: am.externalURL, + SmtpConfig: am.smtp, + RuntimeConfig: am.runtimeConfig, } cfgHash, err := calculateUserGrafanaConfigHash(payload) @@ -388,6 +396,8 @@ func (am *Alertmanager) GetRemoteState(ctx context.Context) (notifier.ExternalSt rs.Silences = p.Data case "nfl": rs.Nflog = p.Data + case "fls": + rs.FlushLog = p.Data default: return rs, fmt.Errorf("unknown part key %q", p.Key) } @@ -677,6 +687,12 @@ func (am *Alertmanager) getFullState(ctx context.Context) (string, error) { } parts = append(parts, alertingClusterPB.Part{Key: notifier.NotificationLogFilename, Data: []byte(notificationLog)}) + flushLog, err := am.state.GetFlushLog(ctx) + if err != nil { + return "", fmt.Errorf("error getting flush log: %w", err) + } + parts = append(parts, alertingClusterPB.Part{Key: notifier.FlushLogFilename, Data: []byte(flushLog)}) + fs := alertingClusterPB.FullState{ Parts: parts, } diff --git a/pkg/services/ngalert/remote/client/alertmanager_configuration.go b/pkg/services/ngalert/remote/client/alertmanager_configuration.go index a53132a8812..75246a6f32d 100644 --- a/pkg/services/ngalert/remote/client/alertmanager_configuration.go +++ b/pkg/services/ngalert/remote/client/alertmanager_configuration.go @@ -29,6 +29,10 @@ func (u *GrafanaAlertmanagerConfig) MarshalJSON() ([]byte, error) { return definition.MarshalJSONWithSecrets((*cfg)(u)) } +type RuntimeConfig struct { + DispatchTimer string `json:"dispatch_timer"` +} + type UserGrafanaConfig struct { GrafanaAlertmanagerConfig GrafanaAlertmanagerConfig `json:"configuration"` Hash string `json:"configuration_hash"` @@ -37,6 +41,7 @@ type UserGrafanaConfig struct { Promoted bool `json:"promoted"` ExternalURL string `json:"external_url"` SmtpConfig SmtpConfig `json:"smtp_config"` + RuntimeConfig RuntimeConfig `json:"runtime_config"` } func (mc *Mimir) GetGrafanaAlertmanagerConfig(ctx context.Context) (*UserGrafanaConfig, error) { diff --git a/pkg/services/updatemanager/plugins.go b/pkg/services/updatemanager/plugins.go index 7ee11b261f9..815e9e67779 100644 --- a/pkg/services/updatemanager/plugins.go +++ b/pkg/services/updatemanager/plugins.go @@ -13,6 +13,8 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/open-feature/go-sdk/openfeature" "go.opentelemetry.io/otel/codes" @@ -47,6 +49,7 @@ type PluginsService struct { updateStrategy string features featuremgmt.FeatureToggles + cfg *setting.Cfg } func ProvidePluginsService(cfg *setting.Cfg, @@ -89,6 +92,7 @@ func ProvidePluginsService(cfg *setting.Cfg, features: features, updateChecker: updateChecker, updateStrategy: cfg.PluginUpdateStrategy, + cfg: cfg, }, nil } @@ -136,7 +140,7 @@ func (s *PluginsService) HasUpdate(ctx context.Context, pluginID string) (string // checkAndUpdate checks for updates and applies them if auto-update is enabled. func (s *PluginsService) checkAndUpdate(ctx context.Context) { s.instrumentedCheckForUpdates(ctx) - if openfeature.NewDefaultClient().Boolean(ctx, featuremgmt.FlagPluginsAutoUpdate, false, openfeature.TransactionContext(ctx)) { + if s.checkFlagPluginsAutoUpdate(ctx) { s.updateAll(ctx) } } @@ -218,6 +222,17 @@ func (s *PluginsService) checkForUpdates(ctx context.Context) error { return nil } +func (s *PluginsService) checkFlagPluginsAutoUpdate(ctx context.Context) bool { + ns := request.GetNamespaceMapper(s.cfg)(1) + ctx = identity.WithServiceIdentityForSingleNamespaceContext(ctx, ns) + flag, err := openfeature.NewDefaultClient().BooleanValueDetails(ctx, featuremgmt.FlagPluginsAutoUpdate, false, openfeature.TransactionContext(ctx)) + if err != nil { + s.log.Error("flag evaluation error", "flag", featuremgmt.FlagPluginsAutoUpdate, "error", err) + } + + return flag.Value +} + func (s *PluginsService) canUpdate(ctx context.Context, plugin pluginstore.Plugin, gcomVersion string) bool { if !s.updateChecker.IsUpdatable(ctx, plugin) { return false @@ -227,7 +242,7 @@ func (s *PluginsService) canUpdate(ctx context.Context, plugin pluginstore.Plugi return false } - if openfeature.NewDefaultClient().Boolean(ctx, featuremgmt.FlagPluginsAutoUpdate, false, openfeature.TransactionContext(ctx)) { + if s.checkFlagPluginsAutoUpdate(ctx) { return s.updateChecker.CanUpdate(plugin.ID, plugin.Info.Version, gcomVersion, s.updateStrategy == setting.PluginUpdateStrategyMinor) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 9667b82b9fa..1e26b9067ef 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -600,6 +600,7 @@ type Cfg struct { IndexRebuildInterval time.Duration IndexCacheTTL time.Duration IndexMinUpdateInterval time.Duration // Don't update index if it was updated less than this interval ago. + IndexScoringModel string // Note: Temporary config to switch the index scoring model and will be removed soon. MaxFileIndexAge time.Duration // Max age of file-based indexes. Index older than this will be rebuilt asynchronously. MinFileIndexBuildVersion string // Minimum version of Grafana that built the file-based index. If index was built with older Grafana, it will be rebuilt asynchronously. EnableSharding bool diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 21a3f455993..b47e8879826 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -123,6 +123,10 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.IndexRebuildInterval = section.Key("index_rebuild_interval").MustDuration(24 * time.Hour) cfg.IndexCacheTTL = section.Key("index_cache_ttl").MustDuration(10 * time.Minute) cfg.IndexMinUpdateInterval = section.Key("index_min_update_interval").MustDuration(0) + cfg.IndexScoringModel = section.Key("index_scoring_model").MustString("") + if cfg.IndexScoringModel != "" { + cfg.Logger.Info("Index scoring model set", "model", cfg.IndexScoringModel) + } cfg.SprinklesApiServer = section.Key("sprinkles_api_server").String() cfg.SprinklesApiServerPageLimit = section.Key("sprinkles_api_server_page_limit").MustInt(10000) cfg.CACertPath = section.Key("ca_cert_path").String() diff --git a/pkg/storage/unified/proto/search.proto b/pkg/storage/unified/proto/search.proto index 5018c97c9db..62c6afb323a 100644 --- a/pkg/storage/unified/proto/search.proto +++ b/pkg/storage/unified/proto/search.proto @@ -9,11 +9,13 @@ import "resource.proto"; // Unlike the ResourceStore, this service can be exposed to clients directly // It should be implemented with efficient indexes and does not need read-after-write semantics service ResourceIndex { + // Query for documents rpc Search(ResourceSearchRequest) returns (ResourceSearchResponse); // Get the resource stats rpc GetStats(ResourceStatsRequest) returns (ResourceStatsResponse); + // Rebuild the search index rpc RebuildIndexes(RebuildIndexesRequest) returns (RebuildIndexesResponse); } @@ -49,6 +51,20 @@ message ResourceStatsResponse { repeated Stats stats = 2; } +// This controls what query and analyzers are applied to the specified field +// See: https://blevesearch.com/docs/Analyzers/ +enum QueryFieldType { + // Picks a reasonable analyzer given the input. Currently this always uses TEXT + // In the future, it may change to depend on the indexed field type + DEFAULT = 0; + // Use free text analyzer. The query is broken into a normalized set of tokens + TEXT = 1; + // The query must exactly match the indexed token + KEYWORD = 2; + // Like a text query, but the position and offsets influence the score + PHRASE = 3; +} + // Search within a single resource message ResourceSearchRequest { message Sort { @@ -64,6 +80,18 @@ message ResourceSearchRequest { // date queries } + // Defines the field in the index to query + // Boost is optional, and allows weighting the field higher in the results + message QueryField { + // The field name in the index to query + string name = 1; + + QueryFieldType type = 2; + + // Boost value for this field + float boost = 3; + } + // The key must include namespace + group + resource ListOptions options = 1; @@ -99,6 +127,9 @@ message ResourceSearchRequest { int64 page = 11; int64 permission = 12; + + // Optionally specify which fields are included in the query + repeated QueryField query_fields = 13; } message ResourceSearchResponse { diff --git a/pkg/storage/unified/resource/datastore.go b/pkg/storage/unified/resource/datastore.go index 931a5b20560..0cc56e51d72 100644 --- a/pkg/storage/unified/resource/datastore.go +++ b/pkg/storage/unified/resource/datastore.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/validation" "github.com/grafana/grafana/pkg/storage/unified/sql/db" "github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" gocache "github.com/patrickmn/go-cache" ) @@ -868,10 +869,18 @@ func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.T if key.Action == DataActionDeleted { generation = 0 } + + // In compatibility mode, the previous RV, when available, is saved as a microsecond + // timestamp, as is done in the SQL backend. + previousRV := event.PreviousRV + if event.PreviousRV > 0 && isSnowflake(event.PreviousRV) { + previousRV = rvmanager.RVFromSnowflake(event.PreviousRV) + } + _, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResourceHistory, sqlKVLegacyUpdateHistoryRequest{ SQLTemplate: sqltemplate.New(kv.dialect), GUID: key.GUID, - PreviousRV: event.PreviousRV, + PreviousRV: previousRV, Generation: generation, }) @@ -900,7 +909,7 @@ func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.T Name: key.Name, Action: action, Folder: key.Folder, - PreviousRV: event.PreviousRV, + PreviousRV: previousRV, }) if err != nil { @@ -916,7 +925,7 @@ func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.T Name: key.Name, Action: action, Folder: key.Folder, - PreviousRV: event.PreviousRV, + PreviousRV: previousRV, }) if err != nil { @@ -938,3 +947,15 @@ func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.T return nil } + +// isSnowflake returns whether the argument passed is a snowflake ID (new) or a microsecond timestamp (old). +// We try to interpret the number as a microsecond timestamp first. If it represents a time in the past, +// it is considered a microsecond timestamp. Snowflake IDs are much larger integers and would lead +// to dates in the future if interpreted as a microsecond timestamp. +func isSnowflake(rv int64) bool { + ts := time.UnixMicro(rv) + oneHourFromNow := time.Now().Add(time.Hour) + isMicroSecRV := ts.Before(oneHourFromNow) + + return !isMicroSecRV +} diff --git a/pkg/storage/unified/resource/document.go b/pkg/storage/unified/resource/document.go index 4e528b96df0..6a41689c0da 100644 --- a/pkg/storage/unified/resource/document.go +++ b/pkg/storage/unified/resource/document.go @@ -290,7 +290,6 @@ const SEARCH_FIELD_NAMESPACE = "namespace" const SEARCH_FIELD_NAME = "name" const SEARCH_FIELD_RV = "rv" const SEARCH_FIELD_TITLE = "title" -const SEARCH_FIELD_TITLE_NGRAM = "title_ngram" const SEARCH_FIELD_TITLE_PHRASE = "title_phrase" // filtering/sorting on title by full phrase const SEARCH_FIELD_DESCRIPTION = "description" const SEARCH_FIELD_TAGS = "tags" diff --git a/pkg/storage/unified/resource/notifier.go b/pkg/storage/unified/resource/notifier.go index 3d3b2024d7e..12cefa5add5 100644 --- a/pkg/storage/unified/resource/notifier.go +++ b/pkg/storage/unified/resource/notifier.go @@ -19,13 +19,18 @@ const ( defaultBufferSize = 10000 ) -type notifier struct { +type notifier interface { + Watch(context.Context, watchOptions) <-chan Event +} + +type pollingNotifier struct { eventStore *eventStore log logging.Logger } type notifierOptions struct { - log logging.Logger + log logging.Logger + useChannelNotifier bool } type watchOptions struct { @@ -44,15 +49,26 @@ func defaultWatchOptions() watchOptions { } } -func newNotifier(eventStore *eventStore, opts notifierOptions) *notifier { +func newNotifier(eventStore *eventStore, opts notifierOptions) notifier { if opts.log == nil { opts.log = &logging.NoOpLogger{} } - return ¬ifier{eventStore: eventStore, log: opts.log} + + if opts.useChannelNotifier { + return &channelNotifier{} + } + + return &pollingNotifier{eventStore: eventStore, log: opts.log} +} + +type channelNotifier struct{} + +func (cn *channelNotifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { + return nil } // Return the last resource version from the event store -func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error) { +func (n *pollingNotifier) lastEventResourceVersion(ctx context.Context) (int64, error) { e, err := n.eventStore.LastEventKey(ctx) if err != nil { return 0, err @@ -60,11 +76,11 @@ func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error) return e.ResourceVersion, nil } -func (n *notifier) cacheKey(evt Event) string { +func (n *pollingNotifier) cacheKey(evt Event) string { return fmt.Sprintf("%s~%s~%s~%s~%d", evt.Namespace, evt.Group, evt.Resource, evt.Name, evt.ResourceVersion) } -func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { +func (n *pollingNotifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { if opts.MinBackoff <= 0 { opts.MinBackoff = defaultMinBackoff } diff --git a/pkg/storage/unified/resource/notifier_test.go b/pkg/storage/unified/resource/notifier_test.go index f78629ebeb7..5e1c0dacea8 100644 --- a/pkg/storage/unified/resource/notifier_test.go +++ b/pkg/storage/unified/resource/notifier_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -func setupTestNotifier(t *testing.T) (*notifier, *eventStore) { +func setupTestNotifier(t *testing.T) (*pollingNotifier, *eventStore) { db := setupTestBadgerDB(t) t.Cleanup(func() { err := db.Close() @@ -22,10 +22,10 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) { kv := NewBadgerKV(db) eventStore := newEventStore(kv) notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}}) - return notifier, eventStore + return notifier.(*pollingNotifier), eventStore } -func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) { +func setupTestNotifierSqlKv(t *testing.T) (*pollingNotifier, *eventStore) { dbstore := db.InitTestDB(t) eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) require.NoError(t, err) @@ -33,7 +33,7 @@ func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) { require.NoError(t, err) eventStore := newEventStore(kv) notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}}) - return notifier, eventStore + return notifier.(*pollingNotifier), eventStore } func TestNewNotifier(t *testing.T) { @@ -49,7 +49,7 @@ func TestDefaultWatchOptions(t *testing.T) { assert.Equal(t, defaultBufferSize, opts.BufferSize) } -func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*notifier, *eventStore), testFn func(*testing.T, context.Context, *notifier, *eventStore)) { +func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*pollingNotifier, *eventStore), testFn func(*testing.T, context.Context, *pollingNotifier, *eventStore)) { t.Run(storeName, func(t *testing.T) { ctx := context.Background() notifier, eventStore := newStoreFn(t) @@ -62,7 +62,7 @@ func TestNotifier_lastEventResourceVersion(t *testing.T) { runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion) } -func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { // Test with no events rv, err := notifier.lastEventResourceVersion(ctx) assert.Error(t, err) @@ -113,7 +113,7 @@ func TestNotifier_cachekey(t *testing.T) { runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey) } -func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { tests := []struct { name string event Event @@ -167,7 +167,7 @@ func TestNotifier_Watch_NoEvents(t *testing.T) { runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents) } -func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() @@ -208,7 +208,7 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) { runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents) } -func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() @@ -282,7 +282,7 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) { runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication) } -func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() @@ -348,7 +348,7 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) { runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation) } -func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { ctx, cancel := context.WithCancel(ctx) // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound @@ -394,7 +394,7 @@ func TestNotifier_Watch_MultipleEvents(t *testing.T) { runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents) } -func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() rv := time.Now().UnixNano() @@ -456,33 +456,27 @@ func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier }, } + errCh := make(chan error) go func() { for _, event := range testEvents { - err := eventStore.Save(ctx, event) - require.NoError(t, err) + errCh <- eventStore.Save(ctx, event) } }() // Receive events - receivedEvents := make([]Event, 0, len(testEvents)) - for i := 0; i < len(testEvents); i++ { + receivedEvents := make([]string, 0, len(testEvents)) + for len(receivedEvents) != len(testEvents) { select { case event := <-events: - receivedEvents = append(receivedEvents, event) + receivedEvents = append(receivedEvents, event.Name) + case err := <-errCh: + require.NoError(t, err) case <-time.After(1 * time.Second): - t.Fatalf("Timed out waiting for event %d", i+1) + t.Fatalf("Timed out waiting for event %d", len(receivedEvents)+1) } } - // Verify all events were received - assert.Len(t, receivedEvents, len(testEvents)) - // Verify the events match and ordered by resource version - receivedNames := make([]string, len(receivedEvents)) - for i, event := range receivedEvents { - receivedNames[i] = event.Name - } - expectedNames := []string{"test-resource-1", "test-resource-2", "test-resource-3"} - assert.ElementsMatch(t, expectedNames, receivedNames) + assert.ElementsMatch(t, expectedNames, receivedEvents) } diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 9651c65f2f6..a0d47b69c35 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -473,8 +473,6 @@ func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { return ErrNotFound } - // TODO reflect change to resource table - return nil } diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index 4db6da89d9a..56272662c24 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -61,7 +61,7 @@ type kvStorageBackend struct { bulkLock *BulkLock dataStore *dataStore eventStore *eventStore - notifier *notifier + notifier notifier builder DocumentBuilder log logging.Logger withPruner bool @@ -91,6 +91,7 @@ type KVBackendOptions struct { Tracer trace.Tracer // TODO add tracing Reg prometheus.Registerer // TODO add metrics + UseChannelNotifier bool // Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with // unified/sql RvManager *rvmanager.ResourceVersionManager @@ -121,7 +122,7 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) { bulkLock: NewBulkLock(), dataStore: newDataStore(kv), eventStore: eventStore, - notifier: newNotifier(eventStore, notifierOptions{}), + notifier: newNotifier(eventStore, notifierOptions{useChannelNotifier: opts.UseChannelNotifier}), snowflake: s, builder: StandardDocumentBuilder(), // For now we use the standard document builder. log: &logging.NoOpLogger{}, // Make this configurable @@ -346,7 +347,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in return 0, fmt.Errorf("failed to write data: %w", err) } - rv = rvmanager.SnowflakeFromRv(rv) + rv = rvmanager.SnowflakeFromRV(rv) dataKey.ResourceVersion = rv } else { err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) diff --git a/pkg/storage/unified/resourcepb/search.pb.go b/pkg/storage/unified/resourcepb/search.pb.go index 459e9aa3429..e523c112093 100644 --- a/pkg/storage/unified/resourcepb/search.pb.go +++ b/pkg/storage/unified/resourcepb/search.pb.go @@ -21,6 +21,65 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// This controls what query and analyzers are applied to the specified field +// See: https://blevesearch.com/docs/Analyzers/ +type QueryFieldType int32 + +const ( + // Picks a reasonable analyzer given the input. Currently this always uses TEXT + // In the future, it may change to depend on the indexed field type + QueryFieldType_DEFAULT QueryFieldType = 0 + // Use free text analyzer. The query is broken into a normalized set of tokens + QueryFieldType_TEXT QueryFieldType = 1 + // The query must exactly match the indexed token + QueryFieldType_KEYWORD QueryFieldType = 2 + // Like a text query, but the position and offsets influence the score + QueryFieldType_PHRASE QueryFieldType = 3 +) + +// Enum value maps for QueryFieldType. +var ( + QueryFieldType_name = map[int32]string{ + 0: "DEFAULT", + 1: "TEXT", + 2: "KEYWORD", + 3: "PHRASE", + } + QueryFieldType_value = map[string]int32{ + "DEFAULT": 0, + "TEXT": 1, + "KEYWORD": 2, + "PHRASE": 3, + } +) + +func (x QueryFieldType) Enum() *QueryFieldType { + p := new(QueryFieldType) + *p = x + return p +} + +func (x QueryFieldType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (QueryFieldType) Descriptor() protoreflect.EnumDescriptor { + return file_search_proto_enumTypes[0].Descriptor() +} + +func (QueryFieldType) Type() protoreflect.EnumType { + return &file_search_proto_enumTypes[0] +} + +func (x QueryFieldType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use QueryFieldType.Descriptor instead. +func (QueryFieldType) EnumDescriptor() ([]byte, []int) { + return file_search_proto_rawDescGZIP(), []int{0} +} + // Get statistics across multiple resources // For these queries, we do not need authorization to see the actual values type ResourceStatsRequest struct { @@ -165,10 +224,12 @@ type ResourceSearchRequest struct { // the return fields (empty will return everything) Fields []string `protobuf:"bytes,8,rep,name=fields,proto3" json:"fields,omitempty"` // explain each result (added to the each row) - Explain bool `protobuf:"varint,9,opt,name=explain,proto3" json:"explain,omitempty"` - IsDeleted bool `protobuf:"varint,10,opt,name=is_deleted,json=isDeleted,proto3" json:"is_deleted,omitempty"` - Page int64 `protobuf:"varint,11,opt,name=page,proto3" json:"page,omitempty"` - Permission int64 `protobuf:"varint,12,opt,name=permission,proto3" json:"permission,omitempty"` + Explain bool `protobuf:"varint,9,opt,name=explain,proto3" json:"explain,omitempty"` + IsDeleted bool `protobuf:"varint,10,opt,name=is_deleted,json=isDeleted,proto3" json:"is_deleted,omitempty"` + Page int64 `protobuf:"varint,11,opt,name=page,proto3" json:"page,omitempty"` + Permission int64 `protobuf:"varint,12,opt,name=permission,proto3" json:"permission,omitempty"` + // Optionally specify which fields are included in the query + QueryFields []*ResourceSearchRequest_QueryField `protobuf:"bytes,13,rep,name=query_fields,json=queryFields,proto3" json:"query_fields,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -287,6 +348,13 @@ func (x *ResourceSearchRequest) GetPermission() int64 { return 0 } +func (x *ResourceSearchRequest) GetQueryFields() []*ResourceSearchRequest_QueryField { + if x != nil { + return x.QueryFields + } + return nil +} + type ResourceSearchResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Error details @@ -670,6 +738,70 @@ func (x *ResourceSearchRequest_Facet) GetLimit() int64 { return 0 } +// Defines the field in the index to query +// Boost is optional, and allows weighting the field higher in the results +type ResourceSearchRequest_QueryField struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The field name in the index to query + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type QueryFieldType `protobuf:"varint,2,opt,name=type,proto3,enum=resource.QueryFieldType" json:"type,omitempty"` + // Boost value for this field + Boost float32 `protobuf:"fixed32,3,opt,name=boost,proto3" json:"boost,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceSearchRequest_QueryField) Reset() { + *x = ResourceSearchRequest_QueryField{} + mi := &file_search_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceSearchRequest_QueryField) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceSearchRequest_QueryField) ProtoMessage() {} + +func (x *ResourceSearchRequest_QueryField) ProtoReflect() protoreflect.Message { + mi := &file_search_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceSearchRequest_QueryField.ProtoReflect.Descriptor instead. +func (*ResourceSearchRequest_QueryField) Descriptor() ([]byte, []int) { + return file_search_proto_rawDescGZIP(), []int{2, 2} +} + +func (x *ResourceSearchRequest_QueryField) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ResourceSearchRequest_QueryField) GetType() QueryFieldType { + if x != nil { + return x.Type + } + return QueryFieldType_DEFAULT +} + +func (x *ResourceSearchRequest_QueryField) GetBoost() float32 { + if x != nil { + return x.Boost + } + return 0 +} + type ResourceSearchResponse_Facet struct { state protoimpl.MessageState `protogen:"open.v1"` Field string `protobuf:"bytes,1,opt,name=field,proto3" json:"field,omitempty"` @@ -685,7 +817,7 @@ type ResourceSearchResponse_Facet struct { func (x *ResourceSearchResponse_Facet) Reset() { *x = ResourceSearchResponse_Facet{} - mi := &file_search_proto_msgTypes[10] + mi := &file_search_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -697,7 +829,7 @@ func (x *ResourceSearchResponse_Facet) String() string { func (*ResourceSearchResponse_Facet) ProtoMessage() {} func (x *ResourceSearchResponse_Facet) ProtoReflect() protoreflect.Message { - mi := &file_search_proto_msgTypes[10] + mi := &file_search_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -751,7 +883,7 @@ type ResourceSearchResponse_TermFacet struct { func (x *ResourceSearchResponse_TermFacet) Reset() { *x = ResourceSearchResponse_TermFacet{} - mi := &file_search_proto_msgTypes[11] + mi := &file_search_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -763,7 +895,7 @@ func (x *ResourceSearchResponse_TermFacet) String() string { func (*ResourceSearchResponse_TermFacet) ProtoMessage() {} func (x *ResourceSearchResponse_TermFacet) ProtoReflect() protoreflect.Message { - mi := &file_search_proto_msgTypes[11] + mi := &file_search_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -818,7 +950,7 @@ var file_search_proto_rawDesc = string([]byte{ 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x22, 0x8e, 0x05, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, + 0x74, 0x22, 0xc3, 0x06, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, @@ -846,93 +978,109 @@ var file_search_proto_rawDesc = string([]byte{ 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x67, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, - 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, - 0x72, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, - 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, - 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, - 0x74, 0x1a, 0x5f, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x3b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x4d, 0x0a, 0x0c, 0x71, 0x75, + 0x65, 0x72, 0x79, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x22, 0xea, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, - 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x31, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x07, 0x72, - 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, - 0x68, 0x69, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x6f, 0x74, 0x61, - 0x6c, 0x48, 0x69, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x71, 0x75, 0x65, 0x72, 0x79, 0x5f, 0x63, - 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x09, 0x71, 0x75, 0x65, 0x72, 0x79, - 0x43, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, 0x5f, 0x73, 0x63, 0x6f, 0x72, - 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x6d, 0x61, 0x78, 0x53, 0x63, 0x6f, 0x72, - 0x65, 0x12, 0x41, 0x0a, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x74, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x52, 0x0b, 0x71, 0x75, + 0x65, 0x72, 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, + 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, 0x46, + 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, + 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, + 0x1a, 0x64, 0x0a, 0x0a, 0x51, 0x75, 0x65, 0x72, 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x51, 0x75, 0x65, 0x72, + 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, + 0x12, 0x14, 0x0a, 0x05, 0x62, 0x6f, 0x6f, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x52, + 0x05, 0x62, 0x6f, 0x6f, 0x73, 0x74, 0x1a, 0x5f, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xea, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, - 0x61, 0x63, 0x65, 0x74, 0x1a, 0x8f, 0x01, 0x0a, 0x05, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, - 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, - 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x69, - 0x73, 0x73, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x6d, 0x69, 0x73, - 0x73, 0x69, 0x6e, 0x67, 0x12, 0x40, 0x0a, 0x05, 0x74, 0x65, 0x72, 0x6d, 0x73, 0x18, 0x04, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, - 0x05, 0x74, 0x65, 0x72, 0x6d, 0x73, 0x1a, 0x35, 0x0a, 0x09, 0x54, 0x65, 0x72, 0x6d, 0x46, 0x61, - 0x63, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x1a, 0x60, 0x0a, - 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3c, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x72, + 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, + 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, - 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, - 0x60, 0x0a, 0x15, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, - 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x29, 0x0a, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x02, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x04, 0x6b, 0x65, 0x79, - 0x73, 0x22, 0x83, 0x01, 0x0a, 0x16, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, - 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x22, 0x0a, 0x0c, - 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x0c, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, - 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, - 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x32, 0xfe, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x4b, 0x0a, 0x06, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x12, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, - 0x74, 0x73, 0x12, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, - 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x3b, 0x5a, 0x39, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, - 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, - 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x31, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, + 0x6c, 0x65, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, + 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x48, 0x69, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x71, 0x75, + 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x09, + 0x71, 0x75, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, + 0x5f, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x6d, 0x61, + 0x78, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x41, 0x0a, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x18, + 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x52, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x1a, 0x8f, 0x01, 0x0a, 0x05, 0x46, 0x61, + 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x74, + 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x12, + 0x18, 0x0a, 0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x40, 0x0a, 0x05, 0x74, 0x65, 0x72, + 0x6d, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x46, + 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x74, 0x65, 0x72, 0x6d, 0x73, 0x1a, 0x35, 0x0a, 0x09, 0x54, + 0x65, 0x72, 0x6d, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x72, 0x6d, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x12, 0x14, 0x0a, 0x05, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x1a, 0x60, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x3c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x26, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x3a, 0x02, 0x38, 0x01, 0x22, 0x60, 0x0a, 0x15, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, + 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, + 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x29, 0x0a, 0x04, 0x6b, + 0x65, 0x79, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, + 0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x22, 0x83, 0x01, 0x0a, 0x16, 0x52, 0x65, 0x62, 0x75, 0x69, + 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, + 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, + 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, + 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2a, 0x40, 0x0a, 0x0e, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, + 0x0a, 0x07, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x54, + 0x45, 0x58, 0x54, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x4b, 0x45, 0x59, 0x57, 0x4f, 0x52, 0x44, + 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x48, 0x52, 0x41, 0x53, 0x45, 0x10, 0x03, 0x32, 0xfe, + 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, + 0x12, 0x4b, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1f, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, + 0x08, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, + 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, + 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0e, 0x52, 0x65, + 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x1f, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, + 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, + 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, + 0x3b, 0x5a, 0x39, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, + 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, + 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, + 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -947,53 +1095,58 @@ func file_search_proto_rawDescGZIP() []byte { return file_search_proto_rawDescData } -var file_search_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_search_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_search_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_search_proto_goTypes = []any{ - (*ResourceStatsRequest)(nil), // 0: resource.ResourceStatsRequest - (*ResourceStatsResponse)(nil), // 1: resource.ResourceStatsResponse - (*ResourceSearchRequest)(nil), // 2: resource.ResourceSearchRequest - (*ResourceSearchResponse)(nil), // 3: resource.ResourceSearchResponse - (*RebuildIndexesRequest)(nil), // 4: resource.RebuildIndexesRequest - (*RebuildIndexesResponse)(nil), // 5: resource.RebuildIndexesResponse - (*ResourceStatsResponse_Stats)(nil), // 6: resource.ResourceStatsResponse.Stats - (*ResourceSearchRequest_Sort)(nil), // 7: resource.ResourceSearchRequest.Sort - (*ResourceSearchRequest_Facet)(nil), // 8: resource.ResourceSearchRequest.Facet - nil, // 9: resource.ResourceSearchRequest.FacetEntry - (*ResourceSearchResponse_Facet)(nil), // 10: resource.ResourceSearchResponse.Facet - (*ResourceSearchResponse_TermFacet)(nil), // 11: resource.ResourceSearchResponse.TermFacet - nil, // 12: resource.ResourceSearchResponse.FacetEntry - (*ErrorResult)(nil), // 13: resource.ErrorResult - (*ListOptions)(nil), // 14: resource.ListOptions - (*ResourceKey)(nil), // 15: resource.ResourceKey - (*ResourceTable)(nil), // 16: resource.ResourceTable + (QueryFieldType)(0), // 0: resource.QueryFieldType + (*ResourceStatsRequest)(nil), // 1: resource.ResourceStatsRequest + (*ResourceStatsResponse)(nil), // 2: resource.ResourceStatsResponse + (*ResourceSearchRequest)(nil), // 3: resource.ResourceSearchRequest + (*ResourceSearchResponse)(nil), // 4: resource.ResourceSearchResponse + (*RebuildIndexesRequest)(nil), // 5: resource.RebuildIndexesRequest + (*RebuildIndexesResponse)(nil), // 6: resource.RebuildIndexesResponse + (*ResourceStatsResponse_Stats)(nil), // 7: resource.ResourceStatsResponse.Stats + (*ResourceSearchRequest_Sort)(nil), // 8: resource.ResourceSearchRequest.Sort + (*ResourceSearchRequest_Facet)(nil), // 9: resource.ResourceSearchRequest.Facet + (*ResourceSearchRequest_QueryField)(nil), // 10: resource.ResourceSearchRequest.QueryField + nil, // 11: resource.ResourceSearchRequest.FacetEntry + (*ResourceSearchResponse_Facet)(nil), // 12: resource.ResourceSearchResponse.Facet + (*ResourceSearchResponse_TermFacet)(nil), // 13: resource.ResourceSearchResponse.TermFacet + nil, // 14: resource.ResourceSearchResponse.FacetEntry + (*ErrorResult)(nil), // 15: resource.ErrorResult + (*ListOptions)(nil), // 16: resource.ListOptions + (*ResourceKey)(nil), // 17: resource.ResourceKey + (*ResourceTable)(nil), // 18: resource.ResourceTable } var file_search_proto_depIdxs = []int32{ - 13, // 0: resource.ResourceStatsResponse.error:type_name -> resource.ErrorResult - 6, // 1: resource.ResourceStatsResponse.stats:type_name -> resource.ResourceStatsResponse.Stats - 14, // 2: resource.ResourceSearchRequest.options:type_name -> resource.ListOptions - 15, // 3: resource.ResourceSearchRequest.federated:type_name -> resource.ResourceKey - 7, // 4: resource.ResourceSearchRequest.sortBy:type_name -> resource.ResourceSearchRequest.Sort - 9, // 5: resource.ResourceSearchRequest.facet:type_name -> resource.ResourceSearchRequest.FacetEntry - 13, // 6: resource.ResourceSearchResponse.error:type_name -> resource.ErrorResult - 15, // 7: resource.ResourceSearchResponse.key:type_name -> resource.ResourceKey - 16, // 8: resource.ResourceSearchResponse.results:type_name -> resource.ResourceTable - 12, // 9: resource.ResourceSearchResponse.facet:type_name -> resource.ResourceSearchResponse.FacetEntry - 15, // 10: resource.RebuildIndexesRequest.keys:type_name -> resource.ResourceKey - 13, // 11: resource.RebuildIndexesResponse.error:type_name -> resource.ErrorResult - 8, // 12: resource.ResourceSearchRequest.FacetEntry.value:type_name -> resource.ResourceSearchRequest.Facet - 11, // 13: resource.ResourceSearchResponse.Facet.terms:type_name -> resource.ResourceSearchResponse.TermFacet - 10, // 14: resource.ResourceSearchResponse.FacetEntry.value:type_name -> resource.ResourceSearchResponse.Facet - 2, // 15: resource.ResourceIndex.Search:input_type -> resource.ResourceSearchRequest - 0, // 16: resource.ResourceIndex.GetStats:input_type -> resource.ResourceStatsRequest - 4, // 17: resource.ResourceIndex.RebuildIndexes:input_type -> resource.RebuildIndexesRequest - 3, // 18: resource.ResourceIndex.Search:output_type -> resource.ResourceSearchResponse - 1, // 19: resource.ResourceIndex.GetStats:output_type -> resource.ResourceStatsResponse - 5, // 20: resource.ResourceIndex.RebuildIndexes:output_type -> resource.RebuildIndexesResponse - 18, // [18:21] is the sub-list for method output_type - 15, // [15:18] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 15, // 0: resource.ResourceStatsResponse.error:type_name -> resource.ErrorResult + 7, // 1: resource.ResourceStatsResponse.stats:type_name -> resource.ResourceStatsResponse.Stats + 16, // 2: resource.ResourceSearchRequest.options:type_name -> resource.ListOptions + 17, // 3: resource.ResourceSearchRequest.federated:type_name -> resource.ResourceKey + 8, // 4: resource.ResourceSearchRequest.sortBy:type_name -> resource.ResourceSearchRequest.Sort + 11, // 5: resource.ResourceSearchRequest.facet:type_name -> resource.ResourceSearchRequest.FacetEntry + 10, // 6: resource.ResourceSearchRequest.query_fields:type_name -> resource.ResourceSearchRequest.QueryField + 15, // 7: resource.ResourceSearchResponse.error:type_name -> resource.ErrorResult + 17, // 8: resource.ResourceSearchResponse.key:type_name -> resource.ResourceKey + 18, // 9: resource.ResourceSearchResponse.results:type_name -> resource.ResourceTable + 14, // 10: resource.ResourceSearchResponse.facet:type_name -> resource.ResourceSearchResponse.FacetEntry + 17, // 11: resource.RebuildIndexesRequest.keys:type_name -> resource.ResourceKey + 15, // 12: resource.RebuildIndexesResponse.error:type_name -> resource.ErrorResult + 0, // 13: resource.ResourceSearchRequest.QueryField.type:type_name -> resource.QueryFieldType + 9, // 14: resource.ResourceSearchRequest.FacetEntry.value:type_name -> resource.ResourceSearchRequest.Facet + 13, // 15: resource.ResourceSearchResponse.Facet.terms:type_name -> resource.ResourceSearchResponse.TermFacet + 12, // 16: resource.ResourceSearchResponse.FacetEntry.value:type_name -> resource.ResourceSearchResponse.Facet + 3, // 17: resource.ResourceIndex.Search:input_type -> resource.ResourceSearchRequest + 1, // 18: resource.ResourceIndex.GetStats:input_type -> resource.ResourceStatsRequest + 5, // 19: resource.ResourceIndex.RebuildIndexes:input_type -> resource.RebuildIndexesRequest + 4, // 20: resource.ResourceIndex.Search:output_type -> resource.ResourceSearchResponse + 2, // 21: resource.ResourceIndex.GetStats:output_type -> resource.ResourceStatsResponse + 6, // 22: resource.ResourceIndex.RebuildIndexes:output_type -> resource.RebuildIndexesResponse + 20, // [20:23] is the sub-list for method output_type + 17, // [17:20] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_search_proto_init() } @@ -1007,13 +1160,14 @@ func file_search_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_search_proto_rawDesc), len(file_search_proto_rawDesc)), - NumEnums: 0, - NumMessages: 13, + NumEnums: 1, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, GoTypes: file_search_proto_goTypes, DependencyIndexes: file_search_proto_depIdxs, + EnumInfos: file_search_proto_enumTypes, MessageInfos: file_search_proto_msgTypes, }.Build() File_search_proto = out.File diff --git a/pkg/storage/unified/resourcepb/search_grpc.pb.go b/pkg/storage/unified/resourcepb/search_grpc.pb.go index d69cbd14e38..d8db878ef55 100644 --- a/pkg/storage/unified/resourcepb/search_grpc.pb.go +++ b/pkg/storage/unified/resourcepb/search_grpc.pb.go @@ -31,9 +31,11 @@ const ( // Unlike the ResourceStore, this service can be exposed to clients directly // It should be implemented with efficient indexes and does not need read-after-write semantics type ResourceIndexClient interface { + // Query for documents Search(ctx context.Context, in *ResourceSearchRequest, opts ...grpc.CallOption) (*ResourceSearchResponse, error) // Get the resource stats GetStats(ctx context.Context, in *ResourceStatsRequest, opts ...grpc.CallOption) (*ResourceStatsResponse, error) + // Rebuild the search index RebuildIndexes(ctx context.Context, in *RebuildIndexesRequest, opts ...grpc.CallOption) (*RebuildIndexesResponse, error) } @@ -82,9 +84,11 @@ func (c *resourceIndexClient) RebuildIndexes(ctx context.Context, in *RebuildInd // Unlike the ResourceStore, this service can be exposed to clients directly // It should be implemented with efficient indexes and does not need read-after-write semantics type ResourceIndexServer interface { + // Query for documents Search(context.Context, *ResourceSearchRequest) (*ResourceSearchResponse, error) // Get the resource stats GetStats(context.Context, *ResourceStatsRequest) (*ResourceStatsResponse, error) + // Rebuild the search index RebuildIndexes(context.Context, *RebuildIndexesRequest) (*RebuildIndexesResponse, error) } diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index eec7290633b..09ef2dc9230 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -81,6 +81,11 @@ type BleveOptions struct { // Indexes that are not owned by current instance are eligible for cleanup. // If nil, all indexes are owned by the current instance. OwnsIndex func(key resource.NamespacedResource) (bool, error) + + // ScoringModel defines the scoring model used for the bleve indexes + // Default: index.TFIDFScoring + // Supported values: index.TFIDFScoring and index.BM25Scoring + ScoringModel string } type bleveBackend struct { @@ -368,7 +373,7 @@ func (b *bleveBackend) BuildIndex( attribute.String("reason", indexBuildReason), ) - mapper, err := GetBleveMappings(fields) + mapper, err := GetBleveMappings(b.opts.ScoringModel, fields) if err != nil { return nil, err } @@ -1177,6 +1182,7 @@ func (b *bleveIndex) getIndex( return b.index, nil } +// nolint:gocyclo func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.ResourceSearchRequest, access authlib.AccessClient) (*bleve.SearchRequest, *resourcepb.ErrorResult) { ctx, span := tracer.Start(ctx, "search.bleveIndex.toBleveSearchRequest") defer span.End() @@ -1235,42 +1241,62 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.R } } - if len(req.Query) > 1 && strings.Contains(req.Query, "*") { - // wildcard query is expensive - should be used with caution - wildcard := bleve.NewWildcardQuery(req.Query) - queries = append(queries, wildcard) - } + if len(req.Query) > 1 { + if strings.Contains(req.Query, "*") { + // wildcard query is expensive - should be used with caution + wildcard := bleve.NewWildcardQuery(req.Query) + queries = append(queries, wildcard) + } else { + // When using a + searchrequest.Fields = append(searchrequest.Fields, resource.SEARCH_FIELD_SCORE) + disjoin := bleve.NewDisjunctionQuery() + queries = append(queries, disjoin) - if req.Query != "" && !strings.Contains(req.Query, "*") { - // Add a text query - searchrequest.Fields = append(searchrequest.Fields, resource.SEARCH_FIELD_SCORE) + queryFields := req.QueryFields + if len(queryFields) == 0 { + queryFields = []*resourcepb.ResourceSearchRequest_QueryField{ + { + Name: resource.SEARCH_FIELD_TITLE, + Type: resourcepb.QueryFieldType_KEYWORD, + Boost: 10, // exact match -- includes ngrams! If they lived on their own field, we could score them differently + }, { + Name: resource.SEARCH_FIELD_TITLE, + Type: resourcepb.QueryFieldType_TEXT, + Boost: 2, // standard analyzer (with ngrams!) + }, { + Name: resource.SEARCH_FIELD_TITLE_PHRASE, + Type: resourcepb.QueryFieldType_TEXT, + Boost: 5, // standard analyzer + }, + } + } - // There are multiple ways to match the query string to documents. The following queries are ordered by priority: + for _, field := range queryFields { + switch field.Type { + case resourcepb.QueryFieldType_TEXT, resourcepb.QueryFieldType_DEFAULT: + q := bleve.NewMatchQuery(removeSmallTerms(req.Query)) // removeSmallTerms should be part of the analyzer + q.SetBoost(float64(field.Boost)) + q.SetField(field.Name) + q.Analyzer = standard.Name // analyze the text + q.Operator = query.MatchQueryOperatorAnd // all terms must match + disjoin.AddQuery(q) - // Query 1: Match the exact query string - queryExact := bleve.NewMatchQuery(req.Query) - queryExact.SetBoost(10.0) - queryExact.SetField(resource.SEARCH_FIELD_TITLE) - queryExact.Analyzer = keyword.Name // don't analyze the query input - treat it as a single token - queryExact.Operator = query.MatchQueryOperatorAnd // This doesn't make a difference for keyword analyzer, we add it just to be explicit. - searchQuery := bleve.NewDisjunctionQuery(queryExact) + case resourcepb.QueryFieldType_KEYWORD: + q := bleve.NewMatchQuery(req.Query) + q.SetBoost(float64(field.Boost)) + q.SetField(field.Name) + q.Analyzer = keyword.Name // don't analyze the query input - treat it as a single token + disjoin.AddQuery(q) - // Query 2: Phrase query with standard analyzer - queryPhrase := bleve.NewMatchPhraseQuery(req.Query) - queryPhrase.SetBoost(5.0) - queryPhrase.SetField(resource.SEARCH_FIELD_TITLE) - queryPhrase.Analyzer = standard.Name - searchQuery.AddQuery(queryPhrase) - - // Query 3: Match query with standard analyzer - queryAnalyzed := bleve.NewMatchQuery(removeSmallTerms(req.Query)) - queryAnalyzed.SetField(resource.SEARCH_FIELD_TITLE) - queryAnalyzed.SetBoost(2.0) - queryAnalyzed.Analyzer = standard.Name - queryAnalyzed.Operator = query.MatchQueryOperatorAnd // Make sure all terms from the query are matched - searchQuery.AddQuery(queryAnalyzed) - - queries = append(queries, searchQuery) + case resourcepb.QueryFieldType_PHRASE: + q := bleve.NewMatchPhraseQuery(req.Query) + q.SetBoost(float64(field.Boost)) + q.SetField(field.Name) + q.Analyzer = standard.Name + disjoin.AddQuery(q) + } + } + } } switch len(queries) { @@ -1872,7 +1898,7 @@ func (q *permissionScopedQuery) Searcher(ctx context.Context, i index.IndexReade if err != nil { return nil, err } - filteringSearcher := bleveSearch.NewFilteringSearcher(ctx, searcher, func(d *search.DocumentMatch) bool { + filteringSearcher := bleveSearch.NewFilteringSearcher(ctx, searcher, func(_ *search.SearchContext, d *search.DocumentMatch) bool { // The doc ID has the format: /// // IndexInternalID will be the same as the doc ID when using an in-memory index, but when using a file-based // index it becomes a binary encoded number that has some other internal meaning. Using ExternalID() will get the diff --git a/pkg/storage/unified/search/bleve_integration_test.go b/pkg/storage/unified/search/bleve_integration_test.go index 819fd5a8d9a..1f34444574f 100644 --- a/pkg/storage/unified/search/bleve_integration_test.go +++ b/pkg/storage/unified/search/bleve_integration_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + index "github.com/blevesearch/bleve_index_api" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -19,6 +20,7 @@ func TestBleveSearchBackend(t *testing.T) { backend, err := NewBleveBackend(BleveOptions{ Root: tempDir, FileThreshold: 5, + ScoringModel: index.BM25Scoring, }, nil) require.NoError(t, err) require.NotNil(t, backend) @@ -52,3 +54,32 @@ func TestSearchBackendBenchmark(t *testing.T) { unitest.BenchmarkSearchBackend(t, backend, opts) } + +func BenchmarkScoringModels(b *testing.B) { + models := []string{index.TFIDFScoring, index.BM25Scoring} + + for _, model := range models { + b.Run(model, func(b *testing.B) { + tempDir := b.TempDir() + + backend, err := NewBleveBackend(BleveOptions{ + Root: tempDir, + ScoringModel: model, + }, nil) + require.NoError(b, err) + require.NotNil(b, backend) + + b.Cleanup(backend.Stop) + + opts := &unitest.BenchmarkOptions{ + NumResources: 1000, + Concurrency: 4, + NumNamespaces: 10, + NumGroups: 10, + NumResourceTypes: 10, + } + + unitest.BenchmarkSearchBackend(b, backend, opts) + }) + } +} diff --git a/pkg/storage/unified/search/bleve_mappings.go b/pkg/storage/unified/search/bleve_mappings.go index 43adcbc607e..20eb2ffb8df 100644 --- a/pkg/storage/unified/search/bleve_mappings.go +++ b/pkg/storage/unified/search/bleve_mappings.go @@ -5,13 +5,15 @@ import ( "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword" "github.com/blevesearch/bleve/v2/analysis/analyzer/standard" "github.com/blevesearch/bleve/v2/mapping" - "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) -func GetBleveMappings(fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) { +func GetBleveMappings(scoringModel string, fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) { mapper := bleve.NewIndexMapping() + if scoringModel != "" { + mapper.ScoringModel = scoringModel + } err := RegisterCustomAnalyzers(mapper) if err != nil { diff --git a/pkg/storage/unified/search/bleve_mappings_test.go b/pkg/storage/unified/search/bleve_mappings_test.go index 3b8027ee06e..821cf987990 100644 --- a/pkg/storage/unified/search/bleve_mappings_test.go +++ b/pkg/storage/unified/search/bleve_mappings_test.go @@ -13,7 +13,7 @@ import ( ) func TestDocumentMapping(t *testing.T) { - mappings, err := search.GetBleveMappings(nil) + mappings, err := search.GetBleveMappings("", nil) require.NoError(t, err) data := resource.IndexableDocument{ Title: "title", diff --git a/pkg/storage/unified/search/bleve_search_test.go b/pkg/storage/unified/search/bleve_search_test.go index c10aa3f6726..b221a60a7d6 100644 --- a/pkg/storage/unified/search/bleve_search_test.go +++ b/pkg/storage/unified/search/bleve_search_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/blevesearch/bleve/v2" + index "github.com/blevesearch/bleve_index_api" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -258,6 +259,7 @@ func newTestDashboardsIndex(t testing.TB, threshold int64, size int64, writer re backend, err := search.NewBleveBackend(search.BleveOptions{ Root: t.TempDir(), FileThreshold: threshold, // use in-memory for tests + ScoringModel: index.BM25Scoring, }, nil) require.NoError(t, err) diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index c9c3967cd58..a88951a100a 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/blevesearch/bleve/v2" + index "github.com/blevesearch/bleve_index_api" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" @@ -50,6 +51,7 @@ func TestBleveBackend(t *testing.T) { backend, err := NewBleveBackend(BleveOptions{ Root: tmpdir, FileThreshold: 5, // with more than 5 items we create a file on disk + ScoringModel: index.BM25Scoring, }, nil) require.NoError(t, err) t.Cleanup(backend.Stop) @@ -773,6 +775,7 @@ func setupBleveBackend(t *testing.T, options ...setupOption) (*bleveBackend, pro IndexCacheTTL: defaultIndexCacheTTL, Logger: log.NewNopLogger(), BuildVersion: buildVersion, + ScoringModel: index.BM25Scoring, } for _, opt := range options { opt(&opts) diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index d450e9ae24b..64cf074f52c 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -46,6 +46,7 @@ func NewSearchOptions( BuildVersion: cfg.BuildVersion, OwnsIndex: ownsIndexFn, IndexMinUpdateInterval: cfg.IndexMinUpdateInterval, + ScoringModel: cfg.IndexScoringModel, }, indexMetrics) if err != nil { diff --git a/pkg/storage/unified/sql/rvmanager/rv_manager.go b/pkg/storage/unified/sql/rvmanager/rv_manager.go index b10685f22ad..42e1f9fb74c 100644 --- a/pkg/storage/unified/sql/rvmanager/rv_manager.go +++ b/pkg/storage/unified/sql/rvmanager/rv_manager.go @@ -307,7 +307,7 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource // Allocate the RVs for i, guid := range guids { guidToRV[guid] = rv - guidToSnowflakeRV[guid] = SnowflakeFromRv(rv) + guidToSnowflakeRV[guid] = SnowflakeFromRV(rv) rvs[i] = rv rv++ } @@ -364,12 +364,20 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource } } -// takes a unix microsecond rv and transforms into a snowflake format. The timestamp is converted from microsecond to +// takes a unix microsecond RV and transforms into a snowflake format. The timestamp is converted from microsecond to // millisecond (the integer division) and the remainder is saved in the stepbits section. machine id is always 0 -func SnowflakeFromRv(rv int64) int64 { +func SnowflakeFromRV(rv int64) int64 { return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000) } +// It is generally not possible to convert from a snowflakeID to a microsecond RV due to the loss in precision +// (snowflake ID stores timestamp in milliseconds). However, this implementation stores the microsecond fraction +// in the step bits (see SnowflakeFromRV), allowing us to compute the microsecond timestamp. +func RVFromSnowflake(snowflakeID int64) int64 { + microSecFraction := snowflakeID & ((1 << snowflake.StepBits) - 1) + return ((snowflakeID>>(snowflake.NodeBits+snowflake.StepBits))+snowflake.Epoch)*1000 + microSecFraction +} + // helper utility to compare two RVs. The first RV must be in snowflake format. Will convert rv2 to snowflake and retry // if comparison fails func IsRvEqual(rv1, rv2 int64) bool { @@ -377,7 +385,7 @@ func IsRvEqual(rv1, rv2 int64) bool { return true } - return rv1 == SnowflakeFromRv(rv2) + return rv1 == SnowflakeFromRV(rv2) } // Lock locks the resource version for the given key diff --git a/pkg/storage/unified/sql/rvmanager/rv_manager_test.go b/pkg/storage/unified/sql/rvmanager/rv_manager_test.go index 9a2e105aa2f..9da98d967d0 100644 --- a/pkg/storage/unified/sql/rvmanager/rv_manager_test.go +++ b/pkg/storage/unified/sql/rvmanager/rv_manager_test.go @@ -63,3 +63,13 @@ func TestResourceVersionManager(t *testing.T) { require.Equal(t, rv, int64(200)) }) } + +func TestSnowflakeFromRVRoundtrips(t *testing.T) { + // 2026-01-12 19:33:58.806211 +0000 UTC + offset := int64(1768246438806211) // in microseconds + + for n := range int64(100) { + ts := offset + n + require.Equal(t, ts, RVFromSnowflake(SnowflakeFromRV(ts))) + } +} diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index f4a1ee3ce77..b96d559ba55 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -99,6 +99,9 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { return nil, err } + isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"), + opts.Cfg.SectionWithEnvOverrides("resource_api")) + if opts.Cfg.EnableSQLKVBackend { sqlkv, err := resource.NewSQLKV(eDB) if err != nil { @@ -106,9 +109,10 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { } kvBackendOpts := resource.KVBackendOptions{ - KvStore: sqlkv, - Tracer: opts.Tracer, - Reg: opts.Reg, + KvStore: sqlkv, + Tracer: opts.Tracer, + Reg: opts.Reg, + UseChannelNotifier: !isHA, } ctx := context.Background() @@ -140,9 +144,6 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { serverOptions.Backend = kvBackend serverOptions.Diagnostics = kvBackend } else { - isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"), - opts.Cfg.SectionWithEnvOverrides("resource_api")) - backend, err := NewBackend(BackendOptions{ DBProvider: eDB, Reg: opts.Reg, diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index 166e2dac372..f73bb61d679 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + index "github.com/blevesearch/bleve_index_api" "github.com/go-jose/go-jose/v4/jwt" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" @@ -129,21 +130,28 @@ func TestIntegrationSearchAndStorage(t *testing.T) { ctx := context.Background() - // Create a new bleve backend - search, err := search.NewBleveBackend(search.BleveOptions{ - FileThreshold: 0, - Root: t.TempDir(), - }, nil) - require.NoError(t, err) - require.NotNil(t, search) - t.Cleanup(search.Stop) + scoringModels := []string{index.TFIDFScoring, index.BM25Scoring} - // Create a new resource backend - storage, _ := newTestBackend(t, false, 0) - require.NotNil(t, storage) + for _, model := range scoringModels { + t.Run(model, func(t *testing.T) { + // Create a new bleve backend + search, err := search.NewBleveBackend(search.BleveOptions{ + FileThreshold: 0, + Root: t.TempDir(), + ScoringModel: model, + }, nil) + require.NoError(t, err) + require.NotNil(t, search) + t.Cleanup(search.Stop) - // Run the shared storage and search tests - unitest.RunTestSearchAndStorage(t, ctx, storage, search) + // Create a new resource backend + storage, _ := newTestBackend(t, false, 0) + require.NotNil(t, storage) + + // Run the shared storage and search tests + unitest.RunTestSearchAndStorage(t, ctx, storage, search) + }) + } } func TestClientServer(t *testing.T) { diff --git a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go index 5e1423fa1ec..d498ebba2f4 100644 --- a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go +++ b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go @@ -200,7 +200,7 @@ func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resource var keyPathRV int64 if isSqlBackend { // Convert microsecond RV to snowflake for key_path construction - keyPathRV = rvmanager.SnowflakeFromRv(resourceVersion) + keyPathRV = rvmanager.SnowflakeFromRV(resourceVersion) } else { // KV backend already provides snowflake RV keyPathRV = resourceVersion @@ -434,9 +434,6 @@ func verifyResourceHistoryTable(t *testing.T, db sqldb.DB, namespace string, res rows, err := db.QueryContext(ctx, query, namespace) require.NoError(t, err) - defer func() { - _ = rows.Close() - }() var records []ResourceHistoryRecord for rows.Next() { @@ -460,33 +457,34 @@ func verifyResourceHistoryTable(t *testing.T, db sqldb.DB, namespace string, res for resourceIdx, res := range resources { // Check create record (action=1, generation=1) createRecord := records[recordIndex] - verifyResourceHistoryRecord(t, createRecord, res, resourceIdx, 1, 0, 1, resourceVersions[resourceIdx][0]) + verifyResourceHistoryRecord(t, createRecord, namespace, res, resourceIdx, 1, 0, 1, resourceVersions[resourceIdx][0]) recordIndex++ } for resourceIdx, res := range resources { // Check update record (action=2, generation=2) updateRecord := records[recordIndex] - verifyResourceHistoryRecord(t, updateRecord, res, resourceIdx, 2, resourceVersions[resourceIdx][0], 2, resourceVersions[resourceIdx][1]) + verifyResourceHistoryRecord(t, updateRecord, namespace, res, resourceIdx, 2, resourceVersions[resourceIdx][0], 2, resourceVersions[resourceIdx][1]) recordIndex++ } for resourceIdx, res := range resources[:2] { // Check delete record (action=3, generation=0) - only first 2 resources were deleted deleteRecord := records[recordIndex] - verifyResourceHistoryRecord(t, deleteRecord, res, resourceIdx, 3, resourceVersions[resourceIdx][1], 0, resourceVersions[resourceIdx][2]) + verifyResourceHistoryRecord(t, deleteRecord, namespace, res, resourceIdx, 3, resourceVersions[resourceIdx][1], 0, resourceVersions[resourceIdx][2]) recordIndex++ } } // verifyResourceHistoryRecord validates a single resource_history record -func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, expectedRes struct{ name, folder string }, resourceIdx, expectedAction int, expectedPrevRV int64, expectedGeneration int, expectedRV int64) { +func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, namespace string, expectedRes struct{ name, folder string }, resourceIdx, expectedAction int, expectedPrevRV int64, expectedGeneration int, expectedRV int64) { // Validate GUID (should be non-empty) require.NotEmpty(t, record.GUID, "GUID should not be empty") // Validate group/resource/namespace/name require.Equal(t, "playlist.grafana.app", record.Group) require.Equal(t, "playlists", record.Resource) + require.Equal(t, namespace, record.Namespace) require.Equal(t, expectedRes.name, record.Name) // Validate value contains expected JSON - server modifies/formats the JSON differently for different operations @@ -513,8 +511,12 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp // For KV backend operations, expectedPrevRV is now in snowflake format (returned by KV backend) // but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison if strings.Contains(record.Namespace, "-kv") { - require.True(t, rvmanager.IsRvEqual(expectedPrevRV, record.PreviousResourceVersion), - "Previous resource version should match (KV backend snowflake format)") + if expectedPrevRV == 0 { + require.Zero(t, record.PreviousResourceVersion) + } else { + require.Equal(t, expectedPrevRV, rvmanager.SnowflakeFromRV(record.PreviousResourceVersion), + "Previous resource version should match (KV backend snowflake format)") + } } else { require.Equal(t, expectedPrevRV, record.PreviousResourceVersion) } @@ -546,9 +548,6 @@ func verifyResourceTable(t *testing.T, db sqldb.DB, namespace string, resources rows, err := db.QueryContext(ctx, query, namespace) require.NoError(t, err) - defer func() { - _ = rows.Close() - }() var records []ResourceRecord for rows.Next() { @@ -612,9 +611,6 @@ func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, res // Check that we have exactly one entry for playlist.grafana.app/playlists rows, err := db.QueryContext(ctx, query, "playlist.grafana.app", "playlists") require.NoError(t, err) - defer func() { - _ = rows.Close() - }() var records []ResourceVersionRecord for rows.Next() { @@ -649,7 +645,7 @@ func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, res isKvBackend := strings.Contains(namespace, "-kv") recordResourceVersion := record.ResourceVersion if isKvBackend { - recordResourceVersion = rvmanager.SnowflakeFromRv(record.ResourceVersion) + recordResourceVersion = rvmanager.SnowflakeFromRV(record.ResourceVersion) } require.Less(t, recordResourceVersion, int64(9223372036854775807), "resource_version should be reasonable") @@ -841,24 +837,20 @@ func runMixedConcurrentOperations(t *testing.T, sqlServer, kvServer resource.Res } // SQL backend operations - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { <-startBarrier // Wait for signal to start if err := runBackendOperationsWithCounts(ctx, sqlServer, namespace+"-sql", "sql", opCounts); err != nil { errors <- fmt.Errorf("SQL backend operations failed: %w", err) } - }() + }) // KV backend operations - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { <-startBarrier // Wait for signal to start if err := runBackendOperationsWithCounts(ctx, kvServer, namespace+"-kv", "kv", opCounts); err != nil { errors <- fmt.Errorf("KV backend operations failed: %w", err) } - }() + }) // Start both goroutines simultaneously close(startBarrier) diff --git a/pkg/storage/unified/testing/storage_backend_test.go b/pkg/storage/unified/testing/storage_backend_test.go index 3046967adee..236a6c510dc 100644 --- a/pkg/storage/unified/testing/storage_backend_test.go +++ b/pkg/storage/unified/testing/storage_backend_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/util/testutil" ) func TestBadgerKVStorageBackend(t *testing.T) { @@ -36,7 +37,9 @@ func TestBadgerKVStorageBackend(t *testing.T) { }) } -func TestSQLKVStorageBackend(t *testing.T) { +func TestIntegrationSQLKVStorageBackend(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + skipTests := map[string]bool{ TestWatchWriteEvents: true, TestList: true, diff --git a/pkg/tests/api/elasticsearch/elasticsearch_test.go b/pkg/tests/api/elasticsearch/elasticsearch_test.go index 09277c944f0..651dd74e9e2 100644 --- a/pkg/tests/api/elasticsearch/elasticsearch_test.go +++ b/pkg/tests/api/elasticsearch/elasticsearch_test.go @@ -24,6 +24,24 @@ func TestMain(m *testing.M) { testsuite.Run(m) } +// mockElasticsearchHandler returns a handler that mocks Elasticsearch endpoints. +// It responds to GET / with cluster info (required for datasource initialization) +// and returns 401 Unauthorized for all other requests. +func mockElasticsearchHandler(onRequest func(r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":{"build_flavor":"default","number":"8.0.0"}}`)) + default: + if onRequest != nil { + onRequest(r) + } + w.WriteHeader(http.StatusUnauthorized) + } + } +} + func TestIntegrationElasticsearch(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -35,9 +53,8 @@ func TestIntegrationElasticsearch(t *testing.T) { ctx := context.Background() var outgoingRequest *http.Request - outgoingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + outgoingServer := httptest.NewServer(mockElasticsearchHandler(func(r *http.Request) { outgoingRequest = r - w.WriteHeader(http.StatusUnauthorized) })) t.Cleanup(outgoingServer.Close) diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index 24f705eccd1..83debc4c410 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -209,7 +209,7 @@ "path": "public/plugins/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png" } ], - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": [ "azure", @@ -589,7 +589,7 @@ "hasUpdate": false, "defaultNavUrl": "/plugins/datagrid/", "category": "", - "state": "beta", + "state": "deprecated", "signature": "internal", "signatureType": "", "signatureOrg": "", @@ -639,7 +639,7 @@ ] }, "dependencies": { - "grafanaDependency": "", + "grafanaDependency": "\u003e=11.6.0", "grafanaVersion": "*", "plugins": [], "extensions": { @@ -880,7 +880,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -934,7 +934,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": [ "grafana", @@ -1000,7 +1000,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1217,7 +1217,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1325,7 +1325,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1375,7 +1375,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1425,7 +1425,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1575,7 +1575,7 @@ }, "build": {}, "screenshots": null, - "version": "", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1629,7 +1629,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": [ "grafana", @@ -1734,7 +1734,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -2042,7 +2042,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -2092,7 +2092,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -2445,7 +2445,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, diff --git a/pkg/tests/apis/dashboard/search_test.go b/pkg/tests/apis/dashboard/search_test.go index 2227c67287e..df03e6a9670 100644 --- a/pkg/tests/apis/dashboard/search_test.go +++ b/pkg/tests/apis/dashboard/search_test.go @@ -97,7 +97,7 @@ func TestIntegrationSearchDevDashboards(t *testing.T) { require.Equal(t, 16, fileCount, "file count from %s", devenv) // Helper to call search - callSearch := func(user apis.User, params string) dashboardV0.SearchResults { + callSearch := func(user apis.User, params map[string]string) dashboardV0.SearchResults { require.NotNil(t, user) ns := user.Identity.GetNamespace() cfg := dynamic.ConfigFor(user.NewRestConfig()) @@ -107,17 +107,12 @@ func TestIntegrationSearchDevDashboards(t *testing.T) { var statusCode int req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search"). + //Param("explain", "true") // helpful to understand which field made things match Param("limit", "1000"). Param("type", "dashboard") // Only search dashboards - for kv := range strings.SplitSeq(params, "&") { - if kv == "" { - continue - } - parts := strings.SplitN(kv, "=", 2) - if len(parts) == 2 { - req = req.Param(parts[0], parts[1]) - } + for k, v := range params { + req = req.Param(k, v) } res := req.Do(ctx).StatusCode(&statusCode) require.NoError(t, res.Error()) @@ -140,22 +135,47 @@ func TestIntegrationSearchDevDashboards(t *testing.T) { testCases := []struct { name string user apis.User - params string + params map[string]string }{ { - name: "all", - user: helper.Org1.Admin, - params: "", // only dashboards + name: "all", + user: helper.Org1.Admin, }, { - name: "simple-query", - user: helper.Org1.Admin, - params: "query=stacking", + name: "query-single-word", + user: helper.Org1.Admin, + params: map[string]string{ + "query": "stacking", + }, }, { - name: "with-text-panel", - user: helper.Org1.Admin, - params: "field=panel_types&panelType=text", + name: "query-multiple-words", + user: helper.Org1.Admin, + params: map[string]string{ + "query": "graph softMin", // must match ALL terms + }, + }, + { + name: "with-text-panel", + user: helper.Org1.Admin, + params: map[string]string{ + "field": "panel_types", // return panel types + "panelType": "text", + }, + }, + { + name: "title-ngram-prefix", + user: helper.Org1.Admin, + params: map[string]string{ + "query": "zer", // should match "Zero Decimals Y Ticks" + }, + }, + { + name: "title-ngram-middle-word", + user: helper.Org1.Admin, + params: map[string]string{ + "query": "decim", // should match "Zero Decimals Y Ticks" + }, }, } for i, tc := range testCases { diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json b/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json similarity index 88% rename from pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json rename to pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json index 6c9a935dfe8..02eed11383a 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json @@ -10,7 +10,7 @@ "panel-tests", "graph-ng" ], - "score": 0.658 + "score": 0.284 }, { "resource": "dashboards", @@ -21,8 +21,8 @@ "panel-tests", "graph-ng" ], - "score": 0.625 + "score": 0.269 } ], - "maxScore": 0.658 + "maxScore": 0.284 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json b/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json new file mode 100644 index 00000000000..270801994c0 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json @@ -0,0 +1,17 @@ +{ + "totalHits": 1, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-soft-limits", + "title": "Panel Tests - Graph NG - softMin/softMax", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.024 + } + ], + "maxScore": 0.024 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t02-with-text-panel.json b/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json similarity index 100% rename from pkg/tests/apis/dashboard/testdata/searchV0/t02-with-text-panel.json rename to pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json b/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json new file mode 100644 index 00000000000..8059db130a0 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json @@ -0,0 +1,17 @@ +{ + "totalHits": 1, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-y-ticks-zero-decimals", + "title": "Zero Decimals Y Ticks", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.35 + } + ], + "maxScore": 0.35 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json b/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json new file mode 100644 index 00000000000..8059db130a0 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json @@ -0,0 +1,17 @@ +{ + "totalHits": 1, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-y-ticks-zero-decimals", + "title": "Zero Decimals Y Ticks", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.35 + } + ], + "maxScore": 0.35 +} \ No newline at end of file diff --git a/pkg/tsdb/cloud-monitoring/cloudmonitoring.go b/pkg/tsdb/cloud-monitoring/cloudmonitoring.go index c88feb30a1a..37401de02c4 100644 --- a/pkg/tsdb/cloud-monitoring/cloudmonitoring.go +++ b/pkg/tsdb/cloud-monitoring/cloudmonitoring.go @@ -92,7 +92,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque }, nil } - url := fmt.Sprintf("%v/v3/projects/%v/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject) + url := fmt.Sprintf("%s/v3/projects/%s/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject) request, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err @@ -139,6 +139,7 @@ type datasourceInfo struct { defaultProject string clientEmail string tokenUri string + universeDomain string services map[string]datasourceService privateKey string usingImpersonation bool @@ -150,6 +151,7 @@ type datasourceJSONData struct { DefaultProject string `json:"defaultProject"` ClientEmail string `json:"clientEmail"` TokenURI string `json:"tokenUri"` + UniverseDomain string `json:"universeDomain"` UsingImpersonation bool `json:"usingImpersonation"` ServiceAccountToImpersonate string `json:"serviceAccountToImpersonate"` } @@ -179,6 +181,7 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst defaultProject: jsonData.DefaultProject, clientEmail: jsonData.ClientEmail, tokenUri: jsonData.TokenURI, + universeDomain: jsonData.UniverseDomain, usingImpersonation: jsonData.UsingImpersonation, serviceAccountToImpersonate: jsonData.ServiceAccountToImpersonate, services: map[string]datasourceService{}, @@ -194,13 +197,13 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst return nil, err } - for name, info := range routes { + for name := range routes { client, err := newHTTPClient(dsInfo, opts, &httpClientProvider, name) if err != nil { return nil, err } dsInfo.services[name] = datasourceService{ - url: info.url, + url: buildURL(name, dsInfo.universeDomain), client: client, } } diff --git a/pkg/tsdb/cloud-monitoring/httpclient.go b/pkg/tsdb/cloud-monitoring/httpclient.go index 5a57e5f4ac8..aa0ed084194 100644 --- a/pkg/tsdb/cloud-monitoring/httpclient.go +++ b/pkg/tsdb/cloud-monitoring/httpclient.go @@ -23,12 +23,12 @@ type routeInfo struct { var routes = map[string]routeInfo{ cloudMonitor: { method: "GET", - url: "https://monitoring.googleapis.com", + url: "https://monitoring.", scopes: []string{cloudMonitorScope}, }, resourceManager: { method: "GET", - url: "https://cloudresourcemanager.googleapis.com", + url: "https://cloudresourcemanager.", scopes: []string{resourceManagerScope}, }, } @@ -68,6 +68,13 @@ func getMiddleware(model *datasourceInfo, routePath string) (httpclient.Middlewa return tokenprovider.AuthMiddleware(provider), nil } +func buildURL(route string, universeDomain string) string { + if universeDomain == "" { + universeDomain = "googleapis.com" + } + return routes[route].url + universeDomain +} + func newHTTPClient(model *datasourceInfo, opts httpclient.Options, clientProvider *httpclient.Provider, route string) (*http.Client, error) { m, err := getMiddleware(model, route) if err != nil { diff --git a/pkg/tsdb/cloud-monitoring/resource_handler_test.go b/pkg/tsdb/cloud-monitoring/resource_handler_test.go index 5f315ef9a59..64742233453 100644 --- a/pkg/tsdb/cloud-monitoring/resource_handler_test.go +++ b/pkg/tsdb/cloud-monitoring/resource_handler_test.go @@ -111,7 +111,7 @@ func Test_setRequestVariables(t *testing.T) { im: &fakeInstance{ services: map[string]datasourceService{ cloudMonitor: { - url: routes[cloudMonitor].url, + url: buildURL(cloudMonitor, "googleapis.com"), client: &http.Client{}, }, }, diff --git a/pkg/tsdb/elasticsearch/aggregation_factory.go b/pkg/tsdb/elasticsearch/aggregation_factory.go index cc3e597e50b..3f702b745b4 100644 --- a/pkg/tsdb/elasticsearch/aggregation_factory.go +++ b/pkg/tsdb/elasticsearch/aggregation_factory.go @@ -3,8 +3,8 @@ package elasticsearch import ( "regexp" - "github.com/grafana/grafana/pkg/components/simplejson" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // addDateHistogramAgg adds a date histogram aggregation to the aggregation builder diff --git a/pkg/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go index fbb3e09f092..12e1a8f5df4 100644 --- a/pkg/tsdb/elasticsearch/client/client.go +++ b/pkg/tsdb/elasticsearch/client/client.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" - "github.com/grafana/grafana/pkg/services/featuremgmt" ) // Used in logging to mark a stage @@ -35,6 +34,7 @@ type DatasourceInfo struct { Interval string MaxConcurrentShardRequests int64 IncludeFrozen bool + ClusterInfo ClusterInfo } type ConfiguredFields struct { @@ -159,7 +159,7 @@ func (c *baseClientImpl) ExecuteMultisearch(r *MultiSearchRequest) (*MultiSearch resSpan.End() }() - improvedParsingEnabled := isFeatureEnabled(c.ctx, featuremgmt.FlagElasticsearchImprovedParsing) + improvedParsingEnabled := isFeatureEnabled(c.ctx, "elasticsearchImprovedParsing") msr, err := c.parser.parseMultiSearchResponse(res.Body, improvedParsingEnabled) if err != nil { return nil, err @@ -197,7 +197,11 @@ func (c *baseClientImpl) createMultiSearchRequests(searchRequests []*SearchReque func (c *baseClientImpl) getMultiSearchQueryParameters() string { var qs []string - qs = append(qs, fmt.Sprintf("max_concurrent_shard_requests=%d", c.ds.MaxConcurrentShardRequests)) + // if the build flavor is not serverless, we can use the max concurrent shard requests + // this is because serverless clusters do not support max concurrent shard requests + if !c.ds.ClusterInfo.IsServerless() && c.ds.MaxConcurrentShardRequests > 0 { + qs = append(qs, fmt.Sprintf("max_concurrent_shard_requests=%d", c.ds.MaxConcurrentShardRequests)) + } if c.ds.IncludeFrozen { qs = append(qs, "ignore_throttled=false") diff --git a/pkg/tsdb/elasticsearch/client/client_test.go b/pkg/tsdb/elasticsearch/client/client_test.go index 8f257873232..b8afb048c00 100644 --- a/pkg/tsdb/elasticsearch/client/client_test.go +++ b/pkg/tsdb/elasticsearch/client/client_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) func TestClient_ExecuteMultisearch(t *testing.T) { diff --git a/pkg/tsdb/elasticsearch/client/cluster_info.go b/pkg/tsdb/elasticsearch/client/cluster_info.go new file mode 100644 index 00000000000..eb89189804f --- /dev/null +++ b/pkg/tsdb/elasticsearch/client/cluster_info.go @@ -0,0 +1,51 @@ +package es + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type VersionInfo struct { + BuildFlavor string `json:"build_flavor"` +} + +// ClusterInfo represents Elasticsearch cluster information returned from the root endpoint. +// It is used to determine cluster capabilities and configuration like whether the cluster is serverless. +type ClusterInfo struct { + Version VersionInfo `json:"version"` +} + +const ( + BuildFlavorServerless = "serverless" +) + +// GetClusterInfo fetches cluster information from the Elasticsearch root endpoint. +// It returns the cluster build flavor which is used to determine if the cluster is serverless. +func GetClusterInfo(httpCli *http.Client, url string) (clusterInfo ClusterInfo, err error) { + resp, err := httpCli.Get(url) + if err != nil { + return ClusterInfo{}, fmt.Errorf("error getting ES cluster info: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return ClusterInfo{}, fmt.Errorf("unexpected status code %d getting ES cluster info", resp.StatusCode) + } + + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("error closing response body: %w", closeErr) + } + }() + + err = json.NewDecoder(resp.Body).Decode(&clusterInfo) + if err != nil { + return ClusterInfo{}, fmt.Errorf("error decoding ES cluster info: %w", err) + } + + return clusterInfo, nil +} + +func (ci ClusterInfo) IsServerless() bool { + return ci.Version.BuildFlavor == BuildFlavorServerless +} diff --git a/pkg/tsdb/elasticsearch/client/cluster_info_test.go b/pkg/tsdb/elasticsearch/client/cluster_info_test.go new file mode 100644 index 00000000000..0fdcc46e813 --- /dev/null +++ b/pkg/tsdb/elasticsearch/client/cluster_info_test.go @@ -0,0 +1,188 @@ +package es + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetClusterInfo(t *testing.T) { + t.Run("Should successfully get cluster info", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, err := rw.Write([]byte(`{ + "name": "test-cluster", + "cluster_name": "elasticsearch", + "cluster_uuid": "abc123", + "version": { + "number": "8.0.0", + "build_flavor": "default", + "build_type": "tar", + "build_hash": "abc123", + "build_date": "2023-01-01T00:00:00.000Z", + "build_snapshot": false, + "lucene_version": "9.0.0" + } + }`)) + require.NoError(t, err) + })) + + t.Cleanup(func() { + ts.Close() + }) + + clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL) + + require.NoError(t, err) + require.NotNil(t, clusterInfo) + assert.Equal(t, "default", clusterInfo.Version.BuildFlavor) + }) + + t.Run("Should successfully get serverless cluster info", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, err := rw.Write([]byte(`{ + "name": "serverless-cluster", + "cluster_name": "elasticsearch", + "cluster_uuid": "def456", + "version": { + "number": "8.11.0", + "build_flavor": "serverless", + "build_type": "docker", + "build_hash": "def456", + "build_date": "2023-11-01T00:00:00.000Z", + "build_snapshot": false, + "lucene_version": "9.8.0" + } + }`)) + require.NoError(t, err) + })) + + t.Cleanup(func() { + ts.Close() + }) + + clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL) + + require.NoError(t, err) + require.NotNil(t, clusterInfo) + assert.Equal(t, "serverless", clusterInfo.Version.BuildFlavor) + assert.True(t, clusterInfo.IsServerless()) + }) + + t.Run("Should return error when HTTP request fails", func(t *testing.T) { + clusterInfo, err := GetClusterInfo(http.DefaultClient, "http://invalid-url-that-does-not-exist.local:9999") + + require.Error(t, err) + require.Equal(t, ClusterInfo{}, clusterInfo) + assert.Contains(t, err.Error(), "error getting ES cluster info") + }) + + t.Run("Should return error when response body is invalid JSON", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, err := rw.Write([]byte(`{"invalid json`)) + require.NoError(t, err) + })) + + t.Cleanup(func() { + ts.Close() + }) + + clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL) + + require.Error(t, err) + require.Equal(t, ClusterInfo{}, clusterInfo) + assert.Contains(t, err.Error(), "error decoding ES cluster info") + }) + + t.Run("Should handle empty version object", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, err := rw.Write([]byte(`{ + "name": "test-cluster", + "version": {} + }`)) + require.NoError(t, err) + })) + + t.Cleanup(func() { + ts.Close() + }) + + clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL) + + require.NoError(t, err) + require.Equal(t, ClusterInfo{}, clusterInfo) + assert.Equal(t, "", clusterInfo.Version.BuildFlavor) + assert.False(t, clusterInfo.IsServerless()) + }) + + t.Run("Should handle HTTP error status codes", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(http.StatusUnauthorized) + _, err := rw.Write([]byte(`{"error": "Unauthorized"}`)) + require.NoError(t, err) + })) + + t.Cleanup(func() { + ts.Close() + }) + + clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL) + + require.Error(t, err) + require.Equal(t, ClusterInfo{}, clusterInfo) + assert.Contains(t, err.Error(), "unexpected status code 401 getting ES cluster info") + }) +} + +func TestClusterInfo_IsServerless(t *testing.T) { + t.Run("Should return true when build_flavor is serverless", func(t *testing.T) { + clusterInfo := ClusterInfo{ + Version: VersionInfo{ + BuildFlavor: BuildFlavorServerless, + }, + } + + assert.True(t, clusterInfo.IsServerless()) + }) + + t.Run("Should return false when build_flavor is default", func(t *testing.T) { + clusterInfo := ClusterInfo{ + Version: VersionInfo{ + BuildFlavor: "default", + }, + } + + assert.False(t, clusterInfo.IsServerless()) + }) + + t.Run("Should return false when build_flavor is empty", func(t *testing.T) { + clusterInfo := ClusterInfo{ + Version: VersionInfo{ + BuildFlavor: "", + }, + } + + assert.False(t, clusterInfo.IsServerless()) + }) + + t.Run("Should return false when build_flavor is unknown value", func(t *testing.T) { + clusterInfo := ClusterInfo{ + Version: VersionInfo{ + BuildFlavor: "unknown", + }, + } + + assert.False(t, clusterInfo.IsServerless()) + }) + + t.Run("should return false when cluster info is empty", func(t *testing.T) { + clusterInfo := ClusterInfo{} + assert.False(t, clusterInfo.IsServerless()) + }) +} diff --git a/pkg/tsdb/elasticsearch/client/search_request_test.go b/pkg/tsdb/elasticsearch/client/search_request_test.go index 80113b4996e..7e2c592dddb 100644 --- a/pkg/tsdb/elasticsearch/client/search_request_test.go +++ b/pkg/tsdb/elasticsearch/client/search_request_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) func TestSearchRequest(t *testing.T) { diff --git a/pkg/tsdb/elasticsearch/data_query_processor.go b/pkg/tsdb/elasticsearch/data_query_processor.go index 288d6ce30de..4dc0afb109a 100644 --- a/pkg/tsdb/elasticsearch/data_query_processor.go +++ b/pkg/tsdb/elasticsearch/data_query_processor.go @@ -6,8 +6,8 @@ import ( "strconv" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/components/simplejson" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // processQuery processes a single query and adds it to the multi-search request builder diff --git a/pkg/tsdb/elasticsearch/data_query_settings.go b/pkg/tsdb/elasticsearch/data_query_settings.go index fe286ccaeda..519eb6dc96d 100644 --- a/pkg/tsdb/elasticsearch/data_query_settings.go +++ b/pkg/tsdb/elasticsearch/data_query_settings.go @@ -3,7 +3,7 @@ package elasticsearch import ( "strconv" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // setFloatPath converts a string value at the specified path to float64 diff --git a/pkg/tsdb/elasticsearch/elasticsearch.go b/pkg/tsdb/elasticsearch/elasticsearch.go index 40073bf1740..0432bbcee20 100644 --- a/pkg/tsdb/elasticsearch/elasticsearch.go +++ b/pkg/tsdb/elasticsearch/elasticsearch.go @@ -88,6 +88,14 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins httpCliOpts.SigV4.Service = "es" } + apiKeyAuth, ok := jsonData["apiKeyAuth"].(bool) + if ok && apiKeyAuth { + apiKey := settings.DecryptedSecureJSONData["apiKey"] + if apiKey != "" { + httpCliOpts.Header.Add("Authorization", "ApiKey "+apiKey) + } + } + httpCli, err := httpClientProvider.New(httpCliOpts) if err != nil { return nil, err @@ -151,6 +159,11 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins includeFrozen = false } + clusterInfo, err := es.GetClusterInfo(httpCli, settings.URL) + if err != nil { + return nil, err + } + configuredFields := es.ConfiguredFields{ TimeField: timeField, LogLevelField: logLevelField, @@ -166,6 +179,7 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins ConfiguredFields: configuredFields, Interval: interval, IncludeFrozen: includeFrozen, + ClusterInfo: clusterInfo, } return model, nil } diff --git a/pkg/tsdb/elasticsearch/elasticsearch_test.go b/pkg/tsdb/elasticsearch/elasticsearch_test.go index 8ab3cabc7e5..35ec1f814ce 100644 --- a/pkg/tsdb/elasticsearch/elasticsearch_test.go +++ b/pkg/tsdb/elasticsearch/elasticsearch_test.go @@ -3,6 +3,8 @@ package elasticsearch import ( "context" "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -18,8 +20,26 @@ type datasourceInfo struct { Interval string `json:"interval"` } +// mockElasticsearchServer creates a test HTTP server that mocks Elasticsearch cluster info endpoint +func mockElasticsearchServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Return a mock Elasticsearch cluster info response + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "version": map[string]interface{}{ + "build_flavor": "serverless", + "number": "8.0.0", + }, + }) + })) +} + func TestNewInstanceSettings(t *testing.T) { t.Run("fields exist", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: 5, @@ -28,6 +48,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -37,6 +58,9 @@ func TestNewInstanceSettings(t *testing.T) { t.Run("timeField", func(t *testing.T) { t.Run("is nil", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ MaxConcurrentShardRequests: 5, Interval: "Daily", @@ -46,6 +70,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -54,6 +79,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("is empty", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ MaxConcurrentShardRequests: 5, Interval: "Daily", @@ -64,6 +92,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -74,6 +103,9 @@ func TestNewInstanceSettings(t *testing.T) { t.Run("maxConcurrentShardRequests", func(t *testing.T) { t.Run("no maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", } @@ -81,6 +113,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -90,6 +123,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("string maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: "10", @@ -98,6 +134,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -107,6 +144,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("number maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: 10, @@ -115,6 +155,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -124,6 +165,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("zero maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: 0, @@ -132,6 +176,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -141,6 +186,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("negative maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: -10, @@ -149,6 +197,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -158,6 +207,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("float maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: 10.5, @@ -166,6 +218,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -175,6 +228,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("invalid maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: "invalid", @@ -183,6 +239,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } diff --git a/pkg/tsdb/elasticsearch/healthcheck.go b/pkg/tsdb/elasticsearch/healthcheck.go index 928945691de..cb5d0a866db 100644 --- a/pkg/tsdb/elasticsearch/healthcheck.go +++ b/pkg/tsdb/elasticsearch/healthcheck.go @@ -28,7 +28,6 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque Message: "Health check failed: Failed to get data source info", }, nil } - healthStatusUrl, err := url.Parse(ds.URL) if err != nil { logger.Error("Failed to parse data source URL", "error", err) @@ -38,6 +37,14 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque }, nil } + // If the cluster is serverless, return a healthy result + if ds.ClusterInfo.IsServerless() { + return &backend.CheckHealthResult{ + Status: backend.HealthStatusOk, + Message: "Elasticsearch Serverless data source is healthy.", + }, nil + } + // check that ES is healthy healthStatusUrl.Path = path.Join(healthStatusUrl.Path, "_cluster/health") healthStatusUrl.RawQuery = "wait_for_status=yellow" diff --git a/pkg/tsdb/elasticsearch/metrics_response_processor.go b/pkg/tsdb/elasticsearch/metrics_response_processor.go index 1e60a732d64..619180ccf90 100644 --- a/pkg/tsdb/elasticsearch/metrics_response_processor.go +++ b/pkg/tsdb/elasticsearch/metrics_response_processor.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // metricsResponseProcessor handles processing of metrics query responses diff --git a/pkg/tsdb/elasticsearch/models.go b/pkg/tsdb/elasticsearch/models.go index adb18554339..8df08182588 100644 --- a/pkg/tsdb/elasticsearch/models.go +++ b/pkg/tsdb/elasticsearch/models.go @@ -4,7 +4,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // Query represents the time series query model of the datasource diff --git a/pkg/tsdb/elasticsearch/parse_query.go b/pkg/tsdb/elasticsearch/parse_query.go index e1bfa189ab9..4d7b0cf7d5e 100644 --- a/pkg/tsdb/elasticsearch/parse_query.go +++ b/pkg/tsdb/elasticsearch/parse_query.go @@ -6,7 +6,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, error) { diff --git a/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go b/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go index b092763b57d..a569c92e7db 100644 --- a/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go +++ b/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go @@ -5,7 +5,7 @@ import ( "fmt" "strconv" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // AggregationParser parses raw Elasticsearch DSL aggregations diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index d05ca92e19b..2c0c5d33810 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -15,9 +15,9 @@ import ( "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/components/simplejson" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" "github.com/grafana/grafana/pkg/tsdb/elasticsearch/instrumentation" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) const ( diff --git a/pkg/tsdb/elasticsearch/response_utils.go b/pkg/tsdb/elasticsearch/response_utils.go index c101633d0c2..5dfd1f38360 100644 --- a/pkg/tsdb/elasticsearch/response_utils.go +++ b/pkg/tsdb/elasticsearch/response_utils.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/components/simplejson" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // flatten flattens multi-level objects to single level objects. It uses dot notation to join keys. diff --git a/pkg/tsdb/elasticsearch/simplejson/simplejson.go b/pkg/tsdb/elasticsearch/simplejson/simplejson.go new file mode 100644 index 00000000000..d7759ac3c2b --- /dev/null +++ b/pkg/tsdb/elasticsearch/simplejson/simplejson.go @@ -0,0 +1,582 @@ +// Package simplejson provides a wrapper for arbitrary JSON objects that adds methods to access properties. +// Use of this package in place of types and the standard library's encoding/json package is strongly discouraged. +// +// Don't lint for stale code, since it's a copied library and we might as well keep the whole thing. +// nolint:unused +package simplejson + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "log" +) + +// returns the current implementation version +func Version() string { + return "0.5.0" +} + +type Json struct { + data any +} + +func (j *Json) FromDB(data []byte) error { + j.data = make(map[string]any) + + dec := json.NewDecoder(bytes.NewBuffer(data)) + dec.UseNumber() + return dec.Decode(&j.data) +} + +func (j *Json) ToDB() ([]byte, error) { + if j == nil || j.data == nil { + return nil, nil + } + + return j.Encode() +} + +func (j *Json) Scan(val any) error { + switch v := val.(type) { + case []byte: + if len(v) == 0 { + return nil + } + return json.Unmarshal(v, &j) + case string: + if len(v) == 0 { + return nil + } + return json.Unmarshal([]byte(v), &j) + default: + return fmt.Errorf("unsupported type: %T", v) + } +} + +func (j *Json) Value() (driver.Value, error) { + return j.ToDB() +} + +// DeepCopyInto creates a copy by serializing JSON +func (j *Json) DeepCopyInto(out *Json) { + b, err := j.Encode() + if err == nil { + _ = out.UnmarshalJSON(b) + } +} + +// DeepCopy will make a deep copy of the JSON object +func (j *Json) DeepCopy() *Json { + if j == nil { + return nil + } + out := new(Json) + j.DeepCopyInto(out) + return out +} + +// NewJson returns a pointer to a new `Json` object +// after unmarshaling `body` bytes +func NewJson(body []byte) (*Json, error) { + j := new(Json) + err := j.UnmarshalJSON(body) + if err != nil { + return nil, err + } + return j, nil +} + +// MustJson returns a pointer to a new `Json` object, panicking if `body` cannot be parsed. +func MustJson(body []byte) *Json { + j, err := NewJson(body) + + if err != nil { + panic(fmt.Sprintf("could not unmarshal JSON: %q", err)) + } + + return j +} + +// New returns a pointer to a new, empty `Json` object +func New() *Json { + return &Json{ + data: make(map[string]any), + } +} + +// NewFromAny returns a pointer to a new `Json` object with provided data. +func NewFromAny(data any) *Json { + return &Json{data: data} +} + +// Interface returns the underlying data +func (j *Json) Interface() any { + return j.data +} + +// Encode returns its marshaled data as `[]byte` +func (j *Json) Encode() ([]byte, error) { + return j.MarshalJSON() +} + +// EncodePretty returns its marshaled data as `[]byte` with indentation +func (j *Json) EncodePretty() ([]byte, error) { + return json.MarshalIndent(&j.data, "", " ") +} + +// Implements the json.Marshaler interface. +func (j *Json) MarshalJSON() ([]byte, error) { + return json.Marshal(&j.data) +} + +// Set modifies `Json` map by `key` and `value` +// Useful for changing single key/value in a `Json` object easily. +func (j *Json) Set(key string, val any) { + m, err := j.Map() + if err != nil { + return + } + m[key] = val +} + +// SetPath modifies `Json`, recursively checking/creating map keys for the supplied path, +// and then finally writing in the value +func (j *Json) SetPath(branch []string, val any) { + if len(branch) == 0 { + j.data = val + return + } + + // in order to insert our branch, we need map[string]any + if _, ok := (j.data).(map[string]any); !ok { + // have to replace with something suitable + j.data = make(map[string]any) + } + curr := j.data.(map[string]any) + + for i := 0; i < len(branch)-1; i++ { + b := branch[i] + // key exists? + if _, ok := curr[b]; !ok { + n := make(map[string]any) + curr[b] = n + curr = n + continue + } + + // make sure the value is the right sort of thing + if _, ok := curr[b].(map[string]any); !ok { + // have to replace with something suitable + n := make(map[string]any) + curr[b] = n + } + + curr = curr[b].(map[string]any) + } + + // add remaining k/v + curr[branch[len(branch)-1]] = val +} + +// Del modifies `Json` map by deleting `key` if it is present. +func (j *Json) Del(key string) { + m, err := j.Map() + if err != nil { + return + } + delete(m, key) +} + +// Get returns a pointer to a new `Json` object +// for `key` in its `map` representation +// +// useful for chaining operations (to traverse a nested JSON): +// +// js.Get("top_level").Get("dict").Get("value").Int() +func (j *Json) Get(key string) *Json { + m, err := j.Map() + if err == nil { + if val, ok := m[key]; ok { + return &Json{val} + } + } + return &Json{nil} +} + +// GetPath searches for the item as specified by the branch +// without the need to deep dive using Get()'s. +// +// js.GetPath("top_level", "dict") +func (j *Json) GetPath(branch ...string) *Json { + jin := j + for _, p := range branch { + jin = jin.Get(p) + } + return jin +} + +// GetIndex returns a pointer to a new `Json` object +// for `index` in its `array` representation +// +// this is the analog to Get when accessing elements of +// a json array instead of a json object: +// +// js.Get("top_level").Get("array").GetIndex(1).Get("key").Int() +func (j *Json) GetIndex(index int) *Json { + a, err := j.Array() + if err == nil { + if len(a) > index { + return &Json{a[index]} + } + } + return &Json{nil} +} + +// CheckGetIndex returns a pointer to a new `Json` object +// for `index` in its `array` representation, and a `bool` +// indicating success or failure +// +// useful for chained operations when success is important: +// +// if data, ok := js.Get("top_level").CheckGetIndex(0); ok { +// log.Println(data) +// } +func (j *Json) CheckGetIndex(index int) (*Json, bool) { + a, err := j.Array() + if err == nil { + if len(a) > index { + return &Json{a[index]}, true + } + } + return nil, false +} + +// SetIndex modifies `Json` array by `index` and `value` +// for `index` in its `array` representation +func (j *Json) SetIndex(index int, val any) { + a, err := j.Array() + if err == nil { + if len(a) > index { + a[index] = val + } + } +} + +// CheckGet returns a pointer to a new `Json` object and +// a `bool` identifying success or failure +// +// useful for chained operations when success is important: +// +// if data, ok := js.Get("top_level").CheckGet("inner"); ok { +// log.Println(data) +// } +func (j *Json) CheckGet(key string) (*Json, bool) { + m, err := j.Map() + if err == nil { + if val, ok := m[key]; ok { + return &Json{val}, true + } + } + return nil, false +} + +// Map type asserts to `map` +func (j *Json) Map() (map[string]any, error) { + if m, ok := (j.data).(map[string]any); ok { + return m, nil + } + return nil, errors.New("type assertion to map[string]any failed") +} + +// Array type asserts to an `array` +func (j *Json) Array() ([]any, error) { + if a, ok := (j.data).([]any); ok { + return a, nil + } + return nil, errors.New("type assertion to []any failed") +} + +// Bool type asserts to `bool` +func (j *Json) Bool() (bool, error) { + if s, ok := (j.data).(bool); ok { + return s, nil + } + return false, errors.New("type assertion to bool failed") +} + +// String type asserts to `string` +func (j *Json) String() (string, error) { + if s, ok := (j.data).(string); ok { + return s, nil + } + return "", errors.New("type assertion to string failed") +} + +// Bytes type asserts to `[]byte` +func (j *Json) Bytes() ([]byte, error) { + if s, ok := (j.data).(string); ok { + return []byte(s), nil + } + return nil, errors.New("type assertion to []byte failed") +} + +// StringArray type asserts to an `array` of `string` +func (j *Json) StringArray() ([]string, error) { + arr, err := j.Array() + if err != nil { + return nil, err + } + retArr := make([]string, 0, len(arr)) + for _, a := range arr { + if a == nil { + retArr = append(retArr, "") + continue + } + s, ok := a.(string) + if !ok { + return nil, err + } + retArr = append(retArr, s) + } + return retArr, nil +} + +// MustArray guarantees the return of a `[]any` (with optional default) +// +// useful when you want to iterate over array values in a succinct manner: +// +// for i, v := range js.Get("results").MustArray() { +// fmt.Println(i, v) +// } +func (j *Json) MustArray(args ...[]any) []any { + var def []any + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustArray() received too many arguments %d", len(args)) + } + + a, err := j.Array() + if err == nil { + return a + } + + return def +} + +// MustMap guarantees the return of a `map[string]any` (with optional default) +// +// useful when you want to iterate over map values in a succinct manner: +// +// for k, v := range js.Get("dictionary").MustMap() { +// fmt.Println(k, v) +// } +func (j *Json) MustMap(args ...map[string]any) map[string]any { + var def map[string]any + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustMap() received too many arguments %d", len(args)) + } + + a, err := j.Map() + if err == nil { + return a + } + + return def +} + +// MustString guarantees the return of a `string` (with optional default) +// +// useful when you explicitly want a `string` in a single value return context: +// +// myFunc(js.Get("param1").MustString(), js.Get("optional_param").MustString("my_default")) +func (j *Json) MustString(args ...string) string { + var def string + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustString() received too many arguments %d", len(args)) + } + + s, err := j.String() + if err == nil { + return s + } + + return def +} + +// MustStringArray guarantees the return of a `[]string` (with optional default) +// +// useful when you want to iterate over array values in a succinct manner: +// +// for i, s := range js.Get("results").MustStringArray() { +// fmt.Println(i, s) +// } +func (j *Json) MustStringArray(args ...[]string) []string { + var def []string + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustStringArray() received too many arguments %d", len(args)) + } + + a, err := j.StringArray() + if err == nil { + return a + } + + return def +} + +// MustInt guarantees the return of an `int` (with optional default) +// +// useful when you explicitly want an `int` in a single value return context: +// +// myFunc(js.Get("param1").MustInt(), js.Get("optional_param").MustInt(5150)) +func (j *Json) MustInt(args ...int) int { + var def int + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustInt() received too many arguments %d", len(args)) + } + + i, err := j.Int() + if err == nil { + return i + } + + return def +} + +// MustFloat64 guarantees the return of a `float64` (with optional default) +// +// useful when you explicitly want a `float64` in a single value return context: +// +// myFunc(js.Get("param1").MustFloat64(), js.Get("optional_param").MustFloat64(5.150)) +func (j *Json) MustFloat64(args ...float64) float64 { + var def float64 + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustFloat64() received too many arguments %d", len(args)) + } + + f, err := j.Float64() + if err == nil { + return f + } + + return def +} + +// MustBool guarantees the return of a `bool` (with optional default) +// +// useful when you explicitly want a `bool` in a single value return context: +// +// myFunc(js.Get("param1").MustBool(), js.Get("optional_param").MustBool(true)) +func (j *Json) MustBool(args ...bool) bool { + var def bool + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustBool() received too many arguments %d", len(args)) + } + + b, err := j.Bool() + if err == nil { + return b + } + + return def +} + +// MustInt64 guarantees the return of an `int64` (with optional default) +// +// useful when you explicitly want an `int64` in a single value return context: +// +// myFunc(js.Get("param1").MustInt64(), js.Get("optional_param").MustInt64(5150)) +func (j *Json) MustInt64(args ...int64) int64 { + var def int64 + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustInt64() received too many arguments %d", len(args)) + } + + i, err := j.Int64() + if err == nil { + return i + } + + return def +} + +// MustUInt64 guarantees the return of an `uint64` (with optional default) +// +// useful when you explicitly want an `uint64` in a single value return context: +// +// myFunc(js.Get("param1").MustUint64(), js.Get("optional_param").MustUint64(5150)) +func (j *Json) MustUint64(args ...uint64) uint64 { + var def uint64 + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustUint64() received too many arguments %d", len(args)) + } + + i, err := j.Uint64() + if err == nil { + return i + } + + return def +} + +// MarshalYAML implements yaml.Marshaller. +func (j *Json) MarshalYAML() (any, error) { + return j.data, nil +} + +// UnmarshalYAML implements yaml.Unmarshaller. +func (j *Json) UnmarshalYAML(unmarshal func(any) error) error { + var data any + if err := unmarshal(&data); err != nil { + return err + } + j.data = data + return nil +} diff --git a/pkg/tsdb/elasticsearch/simplejson/simplejson_go11.go b/pkg/tsdb/elasticsearch/simplejson/simplejson_go11.go new file mode 100644 index 00000000000..88748985576 --- /dev/null +++ b/pkg/tsdb/elasticsearch/simplejson/simplejson_go11.go @@ -0,0 +1,90 @@ +package simplejson + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "reflect" + "strconv" +) + +// Implements the json.Unmarshaler interface. +func (j *Json) UnmarshalJSON(p []byte) error { + dec := json.NewDecoder(bytes.NewBuffer(p)) + dec.UseNumber() + return dec.Decode(&j.data) +} + +// NewFromReader returns a *Json by decoding from an io.Reader +func NewFromReader(r io.Reader) (*Json, error) { + j := new(Json) + dec := json.NewDecoder(r) + dec.UseNumber() + err := dec.Decode(&j.data) + return j, err +} + +// Float64 coerces into a float64 +func (j *Json) Float64() (float64, error) { + switch n := j.data.(type) { + case json.Number: + return n.Float64() + case float32, float64: + return reflect.ValueOf(j.data).Float(), nil + case int, int8, int16, int32, int64: + return float64(reflect.ValueOf(j.data).Int()), nil + case uint, uint8, uint16, uint32, uint64: + return float64(reflect.ValueOf(j.data).Uint()), nil + } + return 0, errors.New("invalid value type") +} + +// Int coerces into an int +func (j *Json) Int() (int, error) { + switch n := j.data.(type) { + case json.Number: + i, err := n.Int64() + if err != nil { + return 0, err + } + return int(i), nil + case float32, float64: + return int(reflect.ValueOf(j.data).Float()), nil + case int, int8, int16, int32, int64: + return int(reflect.ValueOf(j.data).Int()), nil + case uint, uint8, uint16, uint32, uint64: + return int(reflect.ValueOf(j.data).Uint()), nil + } + return 0, errors.New("invalid value type") +} + +// Int64 coerces into an int64 +func (j *Json) Int64() (int64, error) { + switch n := j.data.(type) { + case json.Number: + return n.Int64() + case float32, float64: + return int64(reflect.ValueOf(j.data).Float()), nil + case int, int8, int16, int32, int64: + return reflect.ValueOf(j.data).Int(), nil + case uint, uint8, uint16, uint32, uint64: + return int64(reflect.ValueOf(j.data).Uint()), nil + } + return 0, errors.New("invalid value type") +} + +// Uint64 coerces into an uint64 +func (j *Json) Uint64() (uint64, error) { + switch n := j.data.(type) { + case json.Number: + return strconv.ParseUint(n.String(), 10, 64) + case float32, float64: + return uint64(reflect.ValueOf(j.data).Float()), nil + case int, int8, int16, int32, int64: + return uint64(reflect.ValueOf(j.data).Int()), nil + case uint, uint8, uint16, uint32, uint64: + return reflect.ValueOf(j.data).Uint(), nil + } + return 0, errors.New("invalid value type") +} diff --git a/pkg/tsdb/elasticsearch/simplejson/simplejson_test.go b/pkg/tsdb/elasticsearch/simplejson/simplejson_test.go new file mode 100644 index 00000000000..efc786bc745 --- /dev/null +++ b/pkg/tsdb/elasticsearch/simplejson/simplejson_test.go @@ -0,0 +1,274 @@ +package simplejson + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSimplejson(t *testing.T) { + var ok bool + var err error + + js, err := NewJson([]byte(`{ + "test": { + "string_array": ["asdf", "ghjk", "zxcv"], + "string_array_null": ["abc", null, "efg"], + "array": [1, "2", 3], + "arraywithsubs": [{"subkeyone": 1}, + {"subkeytwo": 2, "subkeythree": 3}], + "int": 10, + "float": 5.150, + "string": "simplejson", + "bool": true, + "sub_obj": {"a": 1} + } + }`)) + + assert.NotEqual(t, nil, js) + assert.Equal(t, nil, err) + + _, ok = js.CheckGet("test") + assert.Equal(t, true, ok) + + _, ok = js.CheckGet("missing_key") + assert.Equal(t, false, ok) + + aws := js.Get("test").Get("arraywithsubs") + assert.NotEqual(t, nil, aws) + var awsval int + awsval, _ = aws.GetIndex(0).Get("subkeyone").Int() + assert.Equal(t, 1, awsval) + awsval, _ = aws.GetIndex(1).Get("subkeytwo").Int() + assert.Equal(t, 2, awsval) + awsval, _ = aws.GetIndex(1).Get("subkeythree").Int() + assert.Equal(t, 3, awsval) + + arr := js.Get("test").Get("array") + assert.NotEqual(t, nil, arr) + val, ok := arr.CheckGetIndex(0) + assert.Equal(t, ok, true) + valInt, _ := val.Int() + assert.Equal(t, valInt, 1) + val, ok = arr.CheckGetIndex(1) + assert.Equal(t, ok, true) + valStr, _ := val.String() + assert.Equal(t, valStr, "2") + val, ok = arr.CheckGetIndex(2) + assert.Equal(t, ok, true) + valInt, _ = val.Int() + assert.Equal(t, valInt, 3) + _, ok = arr.CheckGetIndex(3) + assert.Equal(t, ok, false) + + i, _ := js.Get("test").Get("int").Int() + assert.Equal(t, 10, i) + + f, _ := js.Get("test").Get("float").Float64() + assert.Equal(t, 5.150, f) + + s, _ := js.Get("test").Get("string").String() + assert.Equal(t, "simplejson", s) + + b, _ := js.Get("test").Get("bool").Bool() + assert.Equal(t, true, b) + + mi := js.Get("test").Get("int").MustInt() + assert.Equal(t, 10, mi) + + mi2 := js.Get("test").Get("missing_int").MustInt(5150) + assert.Equal(t, 5150, mi2) + + ms := js.Get("test").Get("string").MustString() + assert.Equal(t, "simplejson", ms) + + ms2 := js.Get("test").Get("missing_string").MustString("fyea") + assert.Equal(t, "fyea", ms2) + + ma2 := js.Get("test").Get("missing_array").MustArray([]any{"1", 2, "3"}) + assert.Equal(t, ma2, []any{"1", 2, "3"}) + + msa := js.Get("test").Get("string_array").MustStringArray() + assert.Equal(t, msa[0], "asdf") + assert.Equal(t, msa[1], "ghjk") + assert.Equal(t, msa[2], "zxcv") + + msa2 := js.Get("test").Get("string_array").MustStringArray([]string{"1", "2", "3"}) + assert.Equal(t, msa2[0], "asdf") + assert.Equal(t, msa2[1], "ghjk") + assert.Equal(t, msa2[2], "zxcv") + + msa3 := js.Get("test").Get("missing_array").MustStringArray([]string{"1", "2", "3"}) + assert.Equal(t, msa3, []string{"1", "2", "3"}) + + mm2 := js.Get("test").Get("missing_map").MustMap(map[string]any{"found": false}) + assert.Equal(t, mm2, map[string]any{"found": false}) + + strs, err := js.Get("test").Get("string_array").StringArray() + assert.Equal(t, err, nil) + assert.Equal(t, strs[0], "asdf") + assert.Equal(t, strs[1], "ghjk") + assert.Equal(t, strs[2], "zxcv") + + strs2, err := js.Get("test").Get("string_array_null").StringArray() + assert.Equal(t, err, nil) + assert.Equal(t, strs2[0], "abc") + assert.Equal(t, strs2[1], "") + assert.Equal(t, strs2[2], "efg") + + gp, _ := js.GetPath("test", "string").String() + assert.Equal(t, "simplejson", gp) + + gp2, _ := js.GetPath("test", "int").Int() + assert.Equal(t, 10, gp2) + + assert.Equal(t, js.Get("test").Get("bool").MustBool(), true) + + js.Set("float2", 300.0) + assert.Equal(t, js.Get("float2").MustFloat64(), 300.0) + + js.Set("test2", "setTest") + assert.Equal(t, "setTest", js.Get("test2").MustString()) + + js.Del("test2") + assert.NotEqual(t, "setTest", js.Get("test2").MustString()) + + js.Get("test").Get("sub_obj").Set("a", 2) + assert.Equal(t, 2, js.Get("test").Get("sub_obj").Get("a").MustInt()) + + js.GetPath("test", "sub_obj").Set("a", 3) + assert.Equal(t, 3, js.GetPath("test", "sub_obj", "a").MustInt()) +} + +func TestStdlibInterfaces(t *testing.T) { + val := new(struct { + Name string `json:"name"` + Params *Json `json:"params"` + }) + val2 := new(struct { + Name string `json:"name"` + Params *Json `json:"params"` + }) + + raw := `{"name":"myobject","params":{"string":"simplejson"}}` + + assert.Equal(t, nil, json.Unmarshal([]byte(raw), val)) + + assert.Equal(t, "myobject", val.Name) + assert.NotEqual(t, nil, val.Params.data) + s, _ := val.Params.Get("string").String() + assert.Equal(t, "simplejson", s) + + p, err := json.Marshal(val) + assert.Equal(t, nil, err) + assert.Equal(t, nil, json.Unmarshal(p, val2)) + assert.Equal(t, val, val2) // stable +} + +func TestSet(t *testing.T) { + js, err := NewJson([]byte(`{}`)) + assert.Equal(t, nil, err) + + js.Set("baz", "bing") + + s, err := js.GetPath("baz").String() + assert.Equal(t, nil, err) + assert.Equal(t, "bing", s) +} + +func TestReplace(t *testing.T) { + js, err := NewJson([]byte(`{}`)) + assert.Equal(t, nil, err) + + err = js.UnmarshalJSON([]byte(`{"baz":"bing"}`)) + assert.Equal(t, nil, err) + + s, err := js.GetPath("baz").String() + assert.Equal(t, nil, err) + assert.Equal(t, "bing", s) +} + +func TestSetPath(t *testing.T) { + js, err := NewJson([]byte(`{}`)) + assert.Equal(t, nil, err) + + js.SetPath([]string{"foo", "bar"}, "baz") + + s, err := js.GetPath("foo", "bar").String() + assert.Equal(t, nil, err) + assert.Equal(t, "baz", s) +} + +func TestSetPathNoPath(t *testing.T) { + js, err := NewJson([]byte(`{"some":"data","some_number":1.0,"some_bool":false}`)) + assert.Equal(t, nil, err) + + f := js.GetPath("some_number").MustFloat64(99.0) + assert.Equal(t, f, 1.0) + + js.SetPath([]string{}, map[string]any{"foo": "bar"}) + + s, err := js.GetPath("foo").String() + assert.Equal(t, nil, err) + assert.Equal(t, "bar", s) + + f = js.GetPath("some_number").MustFloat64(99.0) + assert.Equal(t, f, 99.0) +} + +func TestPathWillAugmentExisting(t *testing.T) { + js, err := NewJson([]byte(`{"this":{"a":"aa","b":"bb","c":"cc"}}`)) + assert.Equal(t, nil, err) + + js.SetPath([]string{"this", "d"}, "dd") + + cases := []struct { + path []string + outcome string + }{ + { + path: []string{"this", "a"}, + outcome: "aa", + }, + { + path: []string{"this", "b"}, + outcome: "bb", + }, + { + path: []string{"this", "c"}, + outcome: "cc", + }, + { + path: []string{"this", "d"}, + outcome: "dd", + }, + } + + for _, tc := range cases { + s, err := js.GetPath(tc.path...).String() + assert.Equal(t, nil, err) + assert.Equal(t, tc.outcome, s) + } +} + +func TestPathWillOverwriteExisting(t *testing.T) { + // notice how "a" is 0.1 - but then we'll try to set at path a, foo + js, err := NewJson([]byte(`{"this":{"a":0.1,"b":"bb","c":"cc"}}`)) + assert.Equal(t, nil, err) + + js.SetPath([]string{"this", "a", "foo"}, "bar") + + s, err := js.GetPath("this", "a", "foo").String() + assert.Equal(t, nil, err) + assert.Equal(t, "bar", s) +} + +func TestMustJson(t *testing.T) { + js := MustJson([]byte(`{"foo": "bar"}`)) + assert.Equal(t, js.Get("foo").MustString(), "bar") + + assert.PanicsWithValue(t, "could not unmarshal JSON: \"unexpected EOF\"", func() { + MustJson([]byte(`{`)) + }) +} diff --git a/pkg/tsdb/elasticsearch/standalone/datasource.go b/pkg/tsdb/elasticsearch/standalone/datasource.go new file mode 100644 index 00000000000..6b9b8ac3f82 --- /dev/null +++ b/pkg/tsdb/elasticsearch/standalone/datasource.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + elasticsearch "github.com/grafana/grafana/pkg/tsdb/elasticsearch" +) + +var ( + _ backend.QueryDataHandler = (*Datasource)(nil) + _ backend.CheckHealthHandler = (*Datasource)(nil) + _ backend.CallResourceHandler = (*Datasource)(nil) +) + +func NewDatasource(context.Context, backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return &Datasource{ + Service: elasticsearch.ProvideService(httpclient.NewProvider()), + }, nil +} + +type Datasource struct { + Service *elasticsearch.Service +} + +func contextualMiddlewares(ctx context.Context) context.Context { + cfg := backend.GrafanaConfigFromContext(ctx) + responseLimitMiddleware := httpclient.ResponseLimitMiddleware(cfg.ResponseLimit()) + ctx = httpclient.WithContextualMiddleware(ctx, responseLimitMiddleware) + return ctx +} + +func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + ctx = contextualMiddlewares(ctx) + return d.Service.QueryData(ctx, req) +} + +func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + ctx = contextualMiddlewares(ctx) + return d.Service.CallResource(ctx, req, sender) +} + +func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + ctx = contextualMiddlewares(ctx) + return d.Service.CheckHealth(ctx, req) +} diff --git a/pkg/tsdb/elasticsearch/standalone/main.go b/pkg/tsdb/elasticsearch/standalone/main.go new file mode 100644 index 00000000000..22bd4169339 --- /dev/null +++ b/pkg/tsdb/elasticsearch/standalone/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + + "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func main() { + // Start listening to requests sent from Grafana. This call is blocking so + // it won't finish until Grafana shuts down the process or the plugin choose + // to exit by itself using os.Exit. Manage automatically manages life cycle + // of datasource instances. It accepts datasource instance factory as first + // argument. This factory will be automatically called on incoming request + // from Grafana to create different instances of SampleDatasource (per datasource + // ID). When datasource configuration changed Dispose method will be called and + // new datasource instance created using NewSampleDatasource factory. + if err := datasource.Manage("elasticsearch", NewDatasource, datasource.ManageOpts{}); err != nil { + log.DefaultLogger.Error(err.Error()) + os.Exit(1) + } +} diff --git a/playwright.config.ts b/playwright.config.ts index 27ec8b97f13..d90602f0a46 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -179,6 +179,10 @@ export default defineConfig({ name: 'cloud-plugins', testDir: path.join(testDirRoot, '/cloud-plugins-suite'), }), + withAuth({ + name: 'alerting', + testDir: path.join(testDirRoot, '/alerting-suite'), + }), withAuth({ name: 'dashboard-new-layouts', testDir: path.join(testDirRoot, '/dashboard-new-layouts'), diff --git a/public/api-merged.json b/public/api-merged.json index d511139c7bd..8dc5868bb41 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -4024,12 +4024,14 @@ }, "/dashboards/uid/{uid}/restore": { "post": { + "description": "This API will be removed when /apis/dashboards.grafana.app/v1 is released.\nYou can restore a dashboard by reading it from history, then creating it again.", "tags": [ "dashboards", "versions" ], "summary": "Restore a dashboard to a given dashboard version using UID.", "operationId": "restoreDashboardVersionByUID", + "deprecated": true, "parameters": [ { "name": "Body", diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index 6f9a99194cc..b0c41c84acd 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -108,7 +108,9 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ }), grafanaNotifiers: build.query({ - query: () => ({ url: '/api/alert-notifiers' }), + // NOTE: version=2 parameter required for versioned schema (PR #109969) + // This parameter will be removed in future when v2 becomes default + query: () => ({ url: '/api/alert-notifiers?version=2' }), transformResponse: (response: NotifierDTO[]) => { const populateSecureFieldKey = ( option: NotificationChannelOption, @@ -121,11 +123,16 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ ), }); + // Keep versions array intact for version-specific options lookup + // Transform options with secureFieldKey population return response.map((notifier) => ({ ...notifier, - options: notifier.options.map((option) => { - return populateSecureFieldKey(option, ''); - }), + options: (notifier.options || []).map((option) => populateSecureFieldKey(option, '')), + // Also transform options within each version + versions: notifier.versions?.map((version) => ({ + ...version, + options: (version.options || []).map((option) => populateSecureFieldKey(option, '')), + })), })); }, }), diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 9432da368b6..c03b52eab4e 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -46,6 +46,7 @@ export type GrafanaPromRulesOptions = Omit { + describe('when the provenance is file', () => { + it('should render the badge with the correct text', () => { + render(); + + expect(screen.getByText('Provisioned')).toBeInTheDocument(); + expect(screen.queryByText('Imported')).not.toBeInTheDocument(); + }); + + it('should render correct tooltip text', async () => { + const { user } = render(); + + const badge = screen.getByText('Provisioned'); + await user.hover(badge); + + expect( + screen.getByText('This resource has been provisioned via file and cannot be edited through the UI') + ).toBeInTheDocument(); + }); + }); + + describe('when the provenance is ConvertedPrometheus', () => { + it('should render the badge with the correct text', () => { + render(); + + expect(screen.getByText('Imported')).toBeInTheDocument(); + expect(screen.queryByText('Provisioned')).not.toBeInTheDocument(); + }); + + it('should render correct tooltip text', async () => { + const { user } = render(); + + const badge = screen.getByText('Imported'); + await user.hover(badge); + + expect( + screen.getByText('This resource has been provisioned via Prometheus/Mimir and cannot be edited through the UI') + ).toBeInTheDocument(); + }); + }); + + describe('when the provenance is API', () => { + it('should render the badge with the correct text', () => { + render(); + + expect(screen.getByText('Provisioned')).toBeInTheDocument(); + expect(screen.queryByText('Imported')).not.toBeInTheDocument(); + }); + + it('should render correct tooltip text', async () => { + const { user } = render(); + + const badge = screen.getByText('Provisioned'); + await user.hover(badge); + + expect( + screen.getByText('This resource has been provisioned via api and cannot be edited through the UI') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/Provisioning.tsx b/public/app/features/alerting/unified/components/Provisioning.tsx index 73beb8a0865..5a9deb8a6bd 100644 --- a/public/app/features/alerting/unified/components/Provisioning.tsx +++ b/public/app/features/alerting/unified/components/Provisioning.tsx @@ -3,6 +3,8 @@ import { ComponentPropsWithoutRef } from 'react'; import { Trans, t } from '@grafana/i18n'; import { Alert, Badge, Tooltip } from '@grafana/ui'; +import { KnownProvenance } from '../types/knownProvenance'; + export enum ProvisionedResource { ContactPoint = 'contact point', Template = 'template', @@ -36,6 +38,24 @@ export const ProvisioningAlert = ({ resource, ...rest }: ProvisioningAlertProps) ); }; +export const ImportedContactPointAlert = (props: ExtraAlertProps) => { + return ( + + + This contact point contains integrations that were imported from an external Alertmanager and is currently + read-only. The integrations will become editable after the migration process is complete. + + + ); +}; + export const ProvisioningBadge = ({ tooltip, provenance, @@ -46,11 +66,17 @@ export const ProvisioningBadge = ({ */ provenance?: string; }) => { - const badge = ; + const isConvertedPrometheus = provenance === KnownProvenance.ConvertedPrometheus; + const badgeText = isConvertedPrometheus + ? t('alerting.provisioning-badge.badge.text-converted-prometheus', 'Imported') + : t('alerting.provisioning-badge.badge.text-provisioned', 'Provisioned'); + const badgeColor = isConvertedPrometheus ? 'blue' : 'purple'; + const badge = ; if (tooltip) { + const provenanceText = isConvertedPrometheus ? 'Prometheus/Mimir' : provenance; const provenanceTooltip = ( - + This resource has been provisioned via {{ provenance }} and cannot be edited through the UI ); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx new file mode 100644 index 00000000000..2879bbb57e1 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from 'test/test-utils'; + +import { AccessControlAction } from 'app/types/accessControl'; + +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; +import { KnownProvenance } from '../../types/knownProvenance'; + +import { ContactPointHeader } from './ContactPointHeader'; +import { ContactPointWithMetadata } from './utils'; + +setupMswServer(); + +const renderWithProvider = (component: React.ReactElement, alertmanagerSourceName?: string) => { + return render( + + {component} + + ); +}; + +describe('ContactPointHeader', () => { + beforeEach(() => { + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsWrite, + ]); + }); + + const mockContactPoint: ContactPointWithMetadata = { + id: 'test-contact-point', + name: 'Test Contact Point', + provenance: KnownProvenance.API, + policies: [], + grafana_managed_receiver_configs: [], + }; + + it('shows Provisioned badge when contact point has file provenance via K8s annotations', () => { + const contactPointWithFile = { + ...mockContactPoint, + provenance: KnownProvenance.File, + }; + + renderWithProvider(); + + expect(screen.getByText('Provisioned')).toBeInTheDocument(); + }); + + it('shows correct badge when contact point has converted_prometheus provenance', () => { + const contactPointWithConvertedPrometheus = { + ...mockContactPoint, + provenance: KnownProvenance.ConvertedPrometheus, + }; + + renderWithProvider(); + + expect(screen.getByText('Imported')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx index 0fb1403cb35..6e45c1b6512 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx @@ -13,6 +13,7 @@ import { canDeleteEntity, canEditEntity, getAnnotation, + isProvisionedResource, shouldUseK8sApi, } from 'app/features/alerting/unified/utils/k8s/utils'; @@ -31,13 +32,15 @@ interface ContactPointHeaderProps { } export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeaderProps) => { - const { name, id, provisioned, policies = [] } = contactPoint; + const { name, id, provenance, policies = [] } = contactPoint; const styles = useStyles2(getStyles); const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false); const { selectedAlertmanager } = useAlertmanager(); const usingK8sApi = shouldUseK8sApi(selectedAlertmanager!); + const isProvisioned = isProvisionedResource(provenance); + const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportContactPoint); const [editSupported, editAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint); const [deleteSupported, deleteAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint); @@ -70,14 +73,14 @@ export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeade /** Does the current user have permissions to edit the contact point? */ const hasAbilityToEdit = usingK8sApi ? canEditEntity(contactPoint) : editAllowed; /** Can the contact point actually be edited via the UI? */ - const contactPointIsEditable = !provisioned; + const contactPointIsEditable = !isProvisioned; /** Given the alertmanager, the user's permissions, and the state of the contact point - can it actually be edited? */ const canEdit = editSupported && hasAbilityToEdit && contactPointIsEditable; /** Does the current user have permissions to delete the contact point? */ const hasAbilityToDelete = usingK8sApi ? canDeleteEntity(contactPoint) : deleteAllowed; /** Can the contact point actually be deleted, regardless of permissions? i.e. ensuring it isn't provisioned and isn't referenced elsewhere */ - const contactPointIsDeleteable = !provisioned && !numberOfPoliciesPreventingDeletion && !numberOfRules; + const contactPointIsDeleteable = !isProvisioned && !numberOfPoliciesPreventingDeletion && !numberOfRules; /** Given the alertmanager, the user's permissions, and the state of the contact point - can it actually be deleted? */ const canBeDeleted = deleteSupported && hasAbilityToDelete && contactPointIsDeleteable; @@ -130,7 +133,7 @@ export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeade const reasonsDeleteIsDisabled = [ !hasAbilityToDelete ? cannotDeleteNoPermissions : '', - provisioned ? cannotDeleteProvisioned : '', + isProvisioned ? cannotDeleteProvisioned : '', numberOfPoliciesPreventingDeletion > 0 ? cannotDeletePolicies : '', numberOfRules ? cannotDeleteRules : '', ].filter(Boolean); @@ -209,15 +212,13 @@ export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeade {referencedByRulesText} )} - {provisioned && ( - - )} + {isProvisioned && } {!isReferencedByAnything && } { }); it('should disable buttons when provisioned', async () => { - const { user } = renderWithProvider(); + const { user } = renderWithProvider( + + ); expect(screen.getByText(/provisioned/i)).toBeInTheDocument(); diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap index 18a5bae9e28..7524d3ba37a 100644 --- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap +++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap @@ -50,7 +50,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, }, ], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -93,7 +93,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, "name": "lotsa-emails", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -129,7 +129,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, "name": "OnCall Conctact point", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -178,7 +178,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, }, ], - "provisioned": true, + "provenance": "api", }, { "grafana_managed_receiver_configs": [ @@ -243,7 +243,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, "name": "Slack with multiple channels", "policies": [], - "provisioned": false, + "provenance": undefined, }, ], "error": undefined, @@ -301,7 +301,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, }, ], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -344,7 +344,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, "name": "lotsa-emails", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -383,7 +383,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, "name": "OnCall Conctact point", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -432,7 +432,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, }, ], - "provisioned": true, + "provenance": "api", }, { "grafana_managed_receiver_configs": [ @@ -497,7 +497,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, "name": "Slack with multiple channels", "policies": [], - "provisioned": false, + "provenance": undefined, }, ], "error": undefined, diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx index 2ac9c04f981..b539a239bd6 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx @@ -6,10 +6,13 @@ import { disablePlugin } from 'app/features/alerting/unified/mocks/server/config import { setOnCallIntegrations } from 'app/features/alerting/unified/mocks/server/handlers/plugins/configure-plugins'; import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types/accessControl'; import { setupMswServer } from '../../mockApi'; import { grantUserPermissions } from '../../mocks'; +import { setAlertmanagerConfig } from '../../mocks/server/entities/alertmanagers'; +import { KnownProvenance } from '../../types/knownProvenance'; import { useContactPointsWithStatus } from './useContactPoints'; @@ -69,4 +72,235 @@ describe('useContactPoints', () => { expect(snapshot).toMatchSnapshot(); }); }); + + describe('Provenance handling', () => { + it('should extract provenance when provenance is "api"', async () => { + // Set up alertmanager config with a receiver that has API provenance + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'api-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-1', + name: 'api-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + provenance: 'api', // This will be used by the K8s mock handler + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'api-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + expect(contactPoint?.provenance).toBe(KnownProvenance.API); + }); + + it('should extract provenance when provenance is "file"', async () => { + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'file-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-2', + name: 'file-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + provenance: 'file', + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'file-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + expect(contactPoint?.provenance).toBe(KnownProvenance.File); + }); + + it('should extract provenance when provenance is "converted_prometheus"', async () => { + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'mimir-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-3', + name: 'mimir-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + provenance: 'converted_prometheus', + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'mimir-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + expect(contactPoint?.provenance).toBe(KnownProvenance.ConvertedPrometheus); + }); + + it('should map "none" provenance annotation to undefined', async () => { + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'none-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-4', + name: 'none-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + // No provenance field - will default to PROVENANCE_NONE in mock handler + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'none-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + // The mock handler sets PROVENANCE_NONE ('none') when no provenance is found + // parseK8sReceiver converts 'none' to undefined + expect(contactPoint?.provenance).toBeUndefined(); + }); + + it('should handle missing annotations gracefully', async () => { + // This test verifies that when annotations are undefined, provenance is handled correctly + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'no-annotations-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-5', + name: 'no-annotations-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'no-annotations-contact-point'); + expect(contactPoint).toBeDefined(); + // When annotations are missing, the mock handler should set provenance to undefined + expect(contactPoint?.provenance).toBeUndefined(); + }); + }); }); diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts b/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts index 6627d5f69d2..bf5e8e5fcdf 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts @@ -11,7 +11,7 @@ import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver } f import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; import { cloudNotifierTypes } from 'app/features/alerting/unified/utils/cloud-alertmanager-notifier-types'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { isK8sEntityProvisioned, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; +import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; import { GrafanaManagedContactPoint, Receiver } from 'app/plugins/datasource/alertmanager/types'; import { getAPINamespace } from '../../../../../api/utils'; @@ -21,7 +21,9 @@ import { useAsync } from '../../hooks/useAsync'; import { usePluginBridge } from '../../hooks/usePluginBridge'; import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig'; import { addReceiverAction, deleteReceiverAction, updateReceiverAction } from '../../reducers/alertmanager/receivers'; +import { KnownProvenance } from '../../types/knownProvenance'; import { getIrmIfPresentOrOnCallPluginId } from '../../utils/config'; +import { K8sAnnotations } from '../../utils/k8s/constants'; import { enhanceContactPointsWithMetadata } from './utils'; @@ -78,10 +80,13 @@ const useOnCallIntegrations = ({ skip }: Skippable = {}) => { type K8sReceiver = ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver; const parseK8sReceiver = (item: K8sReceiver): GrafanaManagedContactPoint => { + const metadataProvenance = item.metadata.annotations?.[K8sAnnotations.Provenance]; + const provenance = metadataProvenance === KnownProvenance.None ? undefined : metadataProvenance; + return { id: item.metadata.name || item.metadata.uid || item.spec.title, name: item.spec.title, - provisioned: isK8sEntityProvisioned(item), + provenance: provenance, grafana_managed_receiver_configs: item.spec.integrations, metadata: item.metadata, }; diff --git a/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts b/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts index 91739aeca61..3083b66d300 100644 --- a/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts +++ b/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts @@ -16,7 +16,8 @@ import { deleteNotificationTemplateAction, updateNotificationTemplateAction, } from '../../reducers/alertmanager/notificationTemplates'; -import { K8sAnnotations, PROVENANCE_NONE } from '../../utils/k8s/constants'; +import { KnownProvenance } from '../../types/knownProvenance'; +import { K8sAnnotations } from '../../utils/k8s/constants'; import { getAnnotation, shouldUseK8sApi } from '../../utils/k8s/utils'; import { ensureDefine } from '../../utils/templates'; import { TemplateFormValues } from '../receivers/TemplateForm'; @@ -79,7 +80,7 @@ function templateGroupsToTemplates( function templateGroupToTemplate( templateGroup: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TemplateGroup ): NotificationTemplate { - const provenance = getAnnotation(templateGroup, K8sAnnotations.Provenance) ?? PROVENANCE_NONE; + const provenance = getAnnotation(templateGroup, K8sAnnotations.Provenance) ?? KnownProvenance.None; return { // K8s entities should always have a metadata.name property. The type is marked as optional because it's also used in other places uid: templateGroup.metadata.name ?? templateGroup.spec.title, @@ -96,8 +97,8 @@ function amConfigToTemplates(config: AlertManagerCortexConfig): NotificationTemp uid: title, title, content, - // Undefined, null or empty string should be converted to PROVENANCE_NONE - provenance: (config.template_file_provenances ?? {})[title] || PROVENANCE_NONE, + // Undefined, null or empty string should be converted to KnownProvenance.None + provenance: (config.template_file_provenances ?? {})[title] || KnownProvenance.None, missing: !templates.includes(title), })); } @@ -272,7 +273,7 @@ export function useValidateNotificationTemplate({ } interface NotificationTemplateMetadata { - isProvisioned: boolean; + provenance?: string; } export function useNotificationTemplateMetadata( @@ -280,11 +281,11 @@ export function useNotificationTemplateMetadata( ): NotificationTemplateMetadata { if (!template) { return { - isProvisioned: false, + provenance: KnownProvenance.None, }; } return { - isProvisioned: Boolean(template.provenance) && template.provenance !== PROVENANCE_NONE, + provenance: template.provenance, }; } diff --git a/public/app/features/alerting/unified/components/contact-points/utils.test.ts b/public/app/features/alerting/unified/components/contact-points/utils.test.ts index e8ded92bf74..ebf064ff6b0 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.test.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.test.ts @@ -1,8 +1,12 @@ +import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; + +import { KnownProvenance } from '../../types/knownProvenance'; import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall'; import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY } from './constants'; import { ReceiverConfigWithMetadata, + enhanceContactPointsWithMetadata, getReceiverDescription, isAutoGeneratedPolicy, summarizeEmailAddresses, @@ -128,3 +132,110 @@ describe('summarizeEmailAddresses', () => { expect(summarizeEmailAddresses('foo@foo.com\n bar@bar.com ')).toBe(output); }); }); + +describe('enhanceContactPointsWithMetadata', () => { + it('should extract provenance from receiver configs when contact point has no provenance', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + provenance: KnownProvenance.API, + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBe(KnownProvenance.API); + }); + + it('should prefer contact point provenance over receiver config provenance', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + provenance: KnownProvenance.File, // Provenance on contact point (from K8s) + grafana_managed_receiver_configs: [ + { + uid: 'test-uid', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + provenance: KnownProvenance.API, // Different provenance on receiver config + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBe(KnownProvenance.File); + }); + + it('should extract provenance from first receiver config that has it', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-1', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + // No provenance on first receiver + }, + { + uid: 'test-uid-2', + name: 'test-contact-point', + type: 'slack', + settings: { recipient: '#channel' }, + secureFields: {}, + provenance: KnownProvenance.ConvertedPrometheus, // Provenance on second receiver + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBe(KnownProvenance.ConvertedPrometheus); + }); + + it('should have undefined provenance when neither contact point nor receiver configs have provenance', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + // No provenance + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBeUndefined(); + }); +}); diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index d2cc43901c1..d24cfc2b0af 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -146,9 +146,16 @@ export function enhanceContactPointsWithMetadata({ const id = getContactPointIdentifier(contactPoint); + // Extract provenance from contactPoint first; else, search in its receivers + const contactPointProvenance = + 'provenance' in contactPoint && contactPoint.provenance !== undefined + ? contactPoint.provenance + : receivers.find((receiver) => Boolean(receiver.provenance))?.provenance; + return { ...contactPoint, id, + provenance: contactPointProvenance, policies: alertmanagerConfiguration && usedContactPointsByName && (usedContactPointsByName[contactPoint.name] ?? []), grafana_managed_receiver_configs: receivers.map((receiver, index) => { diff --git a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx index 94e290a2087..ce0871ac2cd 100644 --- a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx @@ -9,7 +9,7 @@ import { IoK8SApimachineryPkgApisMetaV1ObjectMeta, } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; -import { PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { isK8sEntityProvisioned, shouldUseK8sApi, @@ -62,7 +62,7 @@ const parseAmTimeInterval: (interval: MuteTimeInterval, provenance: string) => M return { ...interval, id: interval.name, - provisioned: Boolean(provenance && provenance !== PROVENANCE_NONE), + provisioned: Boolean(provenance && provenance !== KnownProvenance.None), }; }; diff --git a/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx b/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx index 448c409b673..5e85c5f575c 100644 --- a/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx @@ -11,7 +11,7 @@ import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alertin import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes'; import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes'; import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; -import { ObjectMatcher, ROUTES_META_SYMBOL, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; +import { ObjectMatcher, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { anyOfRequestState, isError } from '../../hooks/useAsync'; import { useAlertmanager } from '../../state/AlertmanagerContext'; @@ -27,6 +27,7 @@ import { useAddPolicyModal, useAlertGroupsModal, useDeletePolicyModal, useEditPo import { Policy } from './Policy'; import { TIMING_OPTIONS_DEFAULTS } from './timingOptions'; import { + isRouteProvisioned, useAddNotificationPolicy, useDeleteNotificationPolicy, useNotificationPolicyRoute, @@ -99,6 +100,8 @@ export const NotificationPoliciesList = () => { } return; }, [defaultPolicy]); + const routeProvenance = defaultPolicy?.provenance; + const isRootRouteProvisioned = rootRoute ? isRouteProvisioned(rootRoute) : false; // useAsync could also work but it's hard to wait until it's done in the tests // Combining with useEffect gives more predictable results because the condition is in useEffect @@ -244,7 +247,8 @@ export const NotificationPoliciesList = () => { currentRoute={defaults(rootRoute, TIMING_OPTIONS_DEFAULTS)} contactPointsState={contactPointsState.receivers} readOnly={!hasConfigurationAPI} - provisioned={rootRoute[ROUTES_META_SYMBOL]?.provisioned} + provisioned={isRootRouteProvisioned} + provenance={routeProvenance} alertManagerSourceName={selectedAlertmanager} onAddPolicy={openAddModal} onEditPolicy={openEditModal} diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx index a10bca42100..62f9a57ad73 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx @@ -17,6 +17,7 @@ import { import { useAlertmanagerAbilities } from '../../hooks/useAbilities'; import { mockReceiversState } from '../../mocks'; import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; +import { KnownProvenance } from '../../types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { @@ -331,6 +332,60 @@ describe('Policy', () => { const customPolicy = screen.getByTestId('am-route-container'); expect(within(customPolicy).getByTestId('matches-all')).toBeInTheDocument(); }); + + it('shows correct badge when policy has file provenance', () => { + const mockRoute: RouteWithID = { + id: 'test-route', + receiver: 'test-receiver', + routes: [], + }; + + renderPolicy( + + ); + + const badge = screen.getByText('Provisioned'); + expect(badge).toBeInTheDocument(); + }); + + it('shows correct badge when policy has converted_prometheus provenance', () => { + const mockRoute: RouteWithID = { + id: 'test-route', + receiver: 'test-receiver', + routes: [], + }; + + renderPolicy( + + ); + + const badge = screen.getByText('Imported'); + expect(badge).toBeInTheDocument(); + }); }); // Doesn't matter which path the routes use, it just needs to match the initialEntries history entry to render the element diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index c4b6a0c55b7..d638273e006 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -61,6 +61,7 @@ interface PolicyComponentProps { contactPointsState?: ReceiversState; readOnly?: boolean; provisioned?: boolean; + provenance?: string; inheritedProperties?: InheritableProperties; routesMatchingFilters?: RoutesMatchingFilters; @@ -89,6 +90,7 @@ const Policy = (props: PolicyComponentProps) => { contactPointsState, readOnly = false, provisioned = false, + provenance, alertManagerSourceName, currentRoute, inheritedProperties, @@ -255,7 +257,7 @@ const Policy = (props: PolicyComponentProps) => { {/* TODO maybe we should move errors to the gutter instead? */} {errors.length > 0 && } - {provisioned && } + {provisioned && } {!isAutoGenerated && !readOnly && ( diff --git a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx index 512f8f2ac66..a57886534ff 100644 --- a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx @@ -1,9 +1,15 @@ import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route } from '../../openapi/routesApi.gen'; +import { KnownProvenance } from '../../types/knownProvenance'; import { ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; -import { createKubernetesRoutingTreeSpec, k8sSubRouteToRoute, routeToK8sSubRoute } from './useNotificationPolicyRoute'; +import { + createKubernetesRoutingTreeSpec, + isRouteProvisioned, + k8sSubRouteToRoute, + routeToK8sSubRoute, +} from './useNotificationPolicyRoute'; test('k8sSubRouteToRoute', () => { const input: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route = { @@ -115,3 +121,86 @@ test('createKubernetesRoutingTreeSpec', () => { expect(tree.metadata.name).toBe(ROOT_ROUTE_NAME); expect(tree).toMatchSnapshot(); }); + +describe('isRouteProvisioned', () => { + it('returns false when route has no provenance', () => { + const route: Route = { + receiver: 'test-receiver', + }; + + expect(isRouteProvisioned(route)).toBeFalsy(); + }); + + it('returns false when route has KnownProvenance.None in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.None, + }, + }; + + expect(isRouteProvisioned(route)).toBeFalsy(); + }); + + it('returns false when route has KnownProvenance.None at top level', () => { + const route: Route = { + receiver: 'test-receiver', + provenance: KnownProvenance.None, + }; + expect(isRouteProvisioned(route)).toBeFalsy(); + }); + + it('returns true when route has file provenance in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.File, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('returns true when route has api provenance in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.API, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('returns true when route has converted_prometheus provenance in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.ConvertedPrometheus, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('returns true when route has file provenance at top level', () => { + const route: Route = { + receiver: 'test-receiver', + provenance: KnownProvenance.File, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('falls back to top-level provenance when metadata provenance is missing', () => { + const route: Route = { + receiver: 'test-receiver', + provenance: KnownProvenance.File, + [ROUTES_META_SYMBOL]: { + provenance: undefined, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); +}); diff --git a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts index e6a0b7a0cc5..ca9be820463 100644 --- a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts +++ b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts @@ -22,8 +22,8 @@ import { } from '../../reducers/alertmanager/notificationPolicyRoutes'; import { FormAmRoute } from '../../types/amroutes'; import { addUniqueIdentifierToRoute } from '../../utils/amroutes'; -import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; -import { isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils'; +import { K8sAnnotations, ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; +import { getAnnotation, isProvisionedResource, shouldUseK8sApi } from '../../utils/k8s/utils'; import { routeAdapter } from '../../utils/routeAdapter'; import { InsertPosition, @@ -33,6 +33,11 @@ import { omitRouteFromRouteTree, } from '../../utils/routeTree'; +export function isRouteProvisioned(route: Route): boolean { + const provenance = route[ROUTES_META_SYMBOL]?.provenance ?? route.provenance; + return isProvisionedResource(provenance); +} + const k8sRoutesToRoutesMemoized = memoize(k8sRoutesToRoutes, { maxSize: 1 }); const { @@ -82,7 +87,7 @@ const parseAmConfigRoute = memoize((route: Route): Route => { return { ...route, [ROUTES_META_SYMBOL]: { - provisioned: Boolean(route.provenance && route.provenance !== PROVENANCE_NONE), + provenance: route.provenance, }, }; }); @@ -232,10 +237,11 @@ function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotific ...route.spec.defaults, routes: route.spec.routes?.map(k8sSubRouteToRoute), [ROUTES_META_SYMBOL]: { - provisioned: isK8sEntityProvisioned(route), + provenance: getAnnotation(route, K8sAnnotations.Provenance), resourceVersion: route.metadata.resourceVersion, name: route.metadata.name, }, + provenance: getAnnotation(route, K8sAnnotations.Provenance), }; }); } diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index 5a50ab55cd4..92872e56bef 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -33,6 +33,7 @@ import { AccessControlAction } from 'app/types/accessControl'; import { AITemplateButtonComponent } from '../../enterprise-components/AI/AIGenTemplateButton/addAITemplateButton'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { isProvisionedResource } from '../../utils/k8s/utils'; import { makeAMLink, stringifyErrorLike } from '../../utils/misc'; import { EditorColumnHeader } from '../EditorColumnHeader'; import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; @@ -122,7 +123,8 @@ export const TemplateForm = ({ originalTemplate, prefill, alertmanager }: Props) // AI feedback state const [aiGeneratedTemplate, setAiGeneratedTemplate] = useState(false); - const { isProvisioned } = useNotificationTemplateMetadata(originalTemplate); + const { provenance } = useNotificationTemplateMetadata(originalTemplate); + const isProvisioned = isProvisionedResource(provenance); const originalTemplatePrefill: TemplateFormValues | undefined = originalTemplate ? { title: originalTemplate.title, content: originalTemplate.content } : undefined; diff --git a/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx b/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx new file mode 100644 index 00000000000..f707d1d6b79 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx @@ -0,0 +1,98 @@ +import { render, screen, within } from 'test/test-utils'; + +import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; +import { AccessControlAction } from 'app/types/accessControl'; + +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; +import { KnownProvenance } from '../../types/knownProvenance'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { NotificationTemplate } from '../contact-points/useNotificationTemplates'; + +import { TemplatesTable } from './TemplatesTable'; + +const mockTemplates: Array> = [ + { + uid: 'mimir-template', + title: 'mimir-template', + content: '{{ define "mimir-template" }}Template from Mimir{{ end }}', + provenance: KnownProvenance.ConvertedPrometheus, + }, + { + uid: 'file-template', + title: 'file-template', + content: '{{ define "file-template" }}File provisioned template{{ end }}', + provenance: KnownProvenance.File, + }, + { + uid: 'api-template', + title: 'api-template', + content: '{{ define "api-template" }}API provisioned template{{ end }}', + provenance: KnownProvenance.API, + }, + { + uid: 'no-provenance-template', + title: 'no-provenance-template', + content: '{{ define "no-provenance-template" }}No provenance template{{ end }}', + provenance: KnownProvenance.None, + }, + { + uid: 'undefined-provenance-template', + title: 'undefined-provenance-template', + content: '{{ define "undefined-provenance-template" }}Undefined provenance template{{ end }}', + provenance: undefined, + }, +]; + +const renderWithProvider = (templates: Array>) => { + return render( + + + + + ); +}; + +setupMswServer(); + +describe('TemplatesTable', () => { + beforeEach(() => { + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalRead, + AccessControlAction.AlertingNotificationsExternalWrite, + ]); + }); + + it('shows "Imported" badge for templates with converted_prometheus provenance', () => { + const templates = [mockTemplates[0]]; // mimir-template + renderWithProvider(templates); + + const templateRow = screen.getByRole('row', { name: /mimir-template/i }); + const badge = within(templateRow).getByText('Imported'); + expect(badge).toBeInTheDocument(); + }); + + it('shows "Provisioned" badge for templates with other provenance', () => { + // api and file templates + [mockTemplates[1], mockTemplates[2]].forEach((template) => { + renderWithProvider([template]); + + const templateRow = screen.getByRole('row', { name: new RegExp(template.title ?? '', 'i') }); + const badge = within(templateRow).getByText('Provisioned'); + expect(badge).toBeInTheDocument(); + }); + }); + + it('does not show badge for templates with KnownProvenance.None or empty string provenance', () => { + // no-provenance-template and undefined-provenance-template + [mockTemplates[3], mockTemplates[4]].forEach((template) => { + renderWithProvider([template]); + + const templateRow = screen.getByRole('row', { name: new RegExp(template.title ?? '', 'i') }); + expect(within(templateRow).queryByText('Provisioned')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx b/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx index ea00e22b280..4f71904dd73 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx @@ -10,6 +10,7 @@ import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/d import { Authorize } from '../../components/Authorize'; import { AlertmanagerAction } from '../../hooks/useAbilities'; import { getAlertTableStyles } from '../../styles/table'; +import { isProvisionedResource } from '../../utils/k8s/utils'; import { makeAMLink, stringifyErrorLike } from '../../utils/misc'; import { CollapseToggle } from '../CollapseToggle'; import { DetailsField } from '../DetailsField'; @@ -128,7 +129,8 @@ function TemplateRow({ notificationTemplate, idx, alertManagerName, onDeleteClic const isGrafanaAlertmanager = alertManagerName === GRAFANA_RULES_SOURCE_NAME; const [isExpanded, setIsExpanded] = useState(false); - const { isProvisioned } = useNotificationTemplateMetadata(notificationTemplate); + const { provenance } = useNotificationTemplateMetadata(notificationTemplate); + const isProvisioned = isProvisionedResource(provenance); const { uid, title: name, content: template, missing } = notificationTemplate; const misconfiguredBadgeText = t('alerting.templates.misconfigured-badge-text', 'Misconfigured'); @@ -139,7 +141,7 @@ function TemplateRow({ notificationTemplate, idx, alertManagerName, onDeleteClic setIsExpanded(!isExpanded)} /> - {name} {isProvisioned && }{' '} + {name} {isProvisioned && }{' '} {missing && !isGrafanaAlertmanager && ( ; secureFields: Record; + version?: string; }; type TestReceiverFormValues = { @@ -246,4 +248,241 @@ describe('ChannelSubForm', () => { expect(slackUrl).toBeEnabled(); expect(slackUrl).toHaveValue(''); }); + + describe('version-specific options display', () => { + // Create a mock notifier with different options for v0 and v1 + const legacyOptions = [ + { + element: 'input' as const, + inputType: 'text', + label: 'Legacy URL', + description: 'The legacy endpoint URL', + placeholder: '', + propertyName: 'legacyUrl', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + const webhookWithVersions: NotifierDTO = { + ...grafanaAlertNotifiers.webhook, + versions: [ + { + version: 'v0mimir1', + label: 'Webhook (Legacy)', + description: 'Legacy webhook from Mimir', + canCreate: false, + options: legacyOptions, + }, + { + version: 'v0mimir2', + label: 'Webhook (Legacy v2)', + description: 'Legacy webhook v2 from Mimir', + canCreate: false, + options: legacyOptions, + }, + { + version: 'v1', + label: 'Webhook', + description: 'Sends HTTP POST request', + canCreate: true, + options: grafanaAlertNotifiers.webhook.options, + }, + ], + }; + + const versionedNotifiers: Notifier[] = [ + { dto: webhookWithVersions, meta: { enabled: true, order: 1 } }, + { dto: grafanaAlertNotifiers.slack, meta: { enabled: true, order: 2 } }, + ]; + + function VersionedTestFormWrapper({ + defaults, + initial, + }: { + defaults: TestChannelValues; + initial?: TestChannelValues; + }) { + const form = useForm({ + defaultValues: { + name: 'test-contact-point', + items: [defaults], + }, + }); + + return ( + + + + + + ); + } + + function renderVersionedForm(defaults: TestChannelValues, initial?: TestChannelValues) { + return render(); + } + + it('should display v1 options when integration has v1 version', () => { + const webhookV1: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v1', + settings: { url: 'https://example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV1, webhookV1); + + // Should show v1 URL field (from default options) + expect(ui.settings.webhook.url.get()).toBeInTheDocument(); + // Should NOT show legacy URL field + expect(screen.queryByRole('textbox', { name: /Legacy URL/i })).not.toBeInTheDocument(); + }); + + it('should display v0 options when integration has legacy version', () => { + const webhookV0: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v0mimir1', + settings: { legacyUrl: 'https://legacy.example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV0, webhookV0); + + // Should show legacy URL field (from v0 options) + expect(screen.getByRole('textbox', { name: /Legacy URL/i })).toBeInTheDocument(); + // Should NOT show v1 URL field + expect(ui.settings.webhook.url.query()).not.toBeInTheDocument(); + }); + + it('should display "Legacy" badge for v0mimir1 integration', () => { + const webhookV0: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v0mimir1', + settings: { legacyUrl: 'https://legacy.example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV0, webhookV0); + + // Should show "Legacy" badge for v0mimir1 integrations + expect(screen.getByText('Legacy')).toBeInTheDocument(); + }); + + it('should display "Legacy v2" badge for v0mimir2 integration', () => { + const webhookV0v2: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v0mimir2', + settings: { legacyUrl: 'https://legacy.example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV0v2, webhookV0v2); + + // Should show "Legacy v2" badge for v0mimir2 integrations + expect(screen.getByText('Legacy v2')).toBeInTheDocument(); + }); + + it('should NOT display version badge for v1 integration', () => { + const webhookV1: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v1', + settings: { url: 'https://example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV1, webhookV1); + + // Should NOT show version badge for non-legacy v1 integrations + expect(screen.queryByText('v1')).not.toBeInTheDocument(); + }); + + it('should filter out notifiers with canCreate: false from dropdown', () => { + // Create a notifier that only has v0 versions (cannot be created) + const legacyOnlyNotifier: NotifierDTO = { + type: 'wechat', + name: 'WeChat', + heading: 'WeChat settings', + description: 'Sends notifications to WeChat', + options: [], + versions: [ + { + version: 'v0mimir1', + label: 'WeChat (Legacy)', + description: 'Legacy WeChat', + canCreate: false, + options: [], + }, + ], + }; + + const notifiersWithLegacyOnly: Notifier[] = [ + { dto: webhookWithVersions, meta: { enabled: true, order: 1 } }, + { dto: legacyOnlyNotifier, meta: { enabled: true, order: 2 } }, + ]; + + function LegacyOnlyTestWrapper({ defaults }: { defaults: TestChannelValues }) { + const form = useForm({ + defaultValues: { + name: 'test-contact-point', + items: [defaults], + }, + }); + + return ( + + + + + + ); + } + + render( + + ); + + // Webhook should be in dropdown (has v1 with canCreate: true) + expect(ui.typeSelector.get()).toHaveTextContent('Webhook'); + + // WeChat should NOT be in the options (only has v0 with canCreate: false) + // We can't easily check dropdown options without opening it, but the filter should work + }); + }); }); diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx index c49b5184623..cb1d79025f8 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -6,7 +6,7 @@ import { Controller, FieldErrors, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Alert, Badge, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui'; import { NotificationChannelOption } from 'app/features/alerting/unified/types/alerting'; import { @@ -16,6 +16,12 @@ import { GrafanaChannelValues, ReceiverFormValues, } from '../../../types/receiver-form'; +import { + canCreateNotifier, + getLegacyVersionLabel, + getOptionsForVersion, + isLegacyVersion, +} from '../../../utils/notifier-versions'; import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration'; import { ChannelOptions } from './ChannelOptions'; @@ -62,6 +68,7 @@ export function ChannelSubForm({ const channelFieldPath = `items.${integrationIndex}` as const; const typeFieldPath = `${channelFieldPath}.type` as const; + const versionFieldPath = `${channelFieldPath}.version` as const; const settingsFieldPath = `${channelFieldPath}.settings` as const; const secureFieldsPath = `${channelFieldPath}.secureFields` as const; @@ -104,6 +111,9 @@ export function ChannelSubForm({ setValue(settingsFieldPath, defaultNotifierSettings); setValue(secureFieldsPath, {}); + + // Reset version when changing type - backend will use its default + setValue(versionFieldPath, undefined); } // Restore initial value of an existing oncall integration @@ -123,6 +133,7 @@ export function ChannelSubForm({ setValue, settingsFieldPath, typeFieldPath, + versionFieldPath, secureFieldsPath, getValues, watch, @@ -164,24 +175,30 @@ export function ChannelSubForm({ setValue(`${settingsFieldPath}.${fieldPath}`, undefined); }; - const typeOptions = useMemo( - (): SelectableValue[] => - sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map( - ({ dto: { name, type }, meta }) => ({ - // @ts-expect-error ReactNode is supported + const typeOptions = useMemo((): SelectableValue[] => { + // Filter out notifiers that can't be created (e.g., v0-only integrations like WeChat) + // These are legacy integrations that only exist in Mimir and can't be created in Grafana + const creatableNotifiers = notifiers.filter(({ dto }) => canCreateNotifier(dto)); + + return sortBy(creatableNotifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map( + ({ dto: { name, type }, meta }) => { + return { + // ReactNode is supported in Select label, but types don't reflect it + /* eslint-disable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */ label: ( {name} {meta?.badge} - ), + ) as any, + /* eslint-enable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */ value: type, description: meta?.description, isDisabled: meta ? !meta.enabled : false, - }) - ), - [notifiers] - ); + }; + } + ); + }, [notifiers]); const handleTest = async () => { await trigger(); @@ -198,10 +215,21 @@ export function ChannelSubForm({ // Cloud AM takes no value at all const isParseModeNone = parse_mode === 'None' || !parse_mode; const showTelegramWarning = isTelegram && !isParseModeNone; + + // Check if current integration is a legacy version (canCreate: false) + // Legacy integrations are read-only and cannot be edited + // Read version from existing integration data (stored in receiver config) + const integrationVersion = initialValues?.version || defaultValues.version; + const isLegacy = notifier ? isLegacyVersion(notifier.dto, integrationVersion) : false; + + // Get the correct options based on the integration's version + // This ensures legacy (v0) integrations display the correct schema + const versionedOptions = notifier ? getOptionsForVersion(notifier.dto, integrationVersion) : []; + // if there are mandatory options defined, optional options will be hidden by a collapse // if there aren't mandatory options, all options will be shown without collapse - const mandatoryOptions = notifier?.dto.options.filter((o) => o.required) ?? []; - const optionalOptions = notifier?.dto.options.filter((o) => !o.required) ?? []; + const mandatoryOptions = versionedOptions.filter((o) => o.required); + const optionalOptions = versionedOptions.filter((o) => !o.required); const contactPointTypeInputId = `contact-point-type-${pathPrefix}`; return ( @@ -214,21 +242,35 @@ export function ChannelSubForm({ data-testid={`${pathPrefix}type`} noMargin > - ( - onChange(value?.value)} + /> + )} + /> + {isLegacy && integrationVersion && ( + )} - /> +
        @@ -292,7 +334,7 @@ export function ChannelSubForm({ name: notifier.dto.name, })} > - {notifier.dto.info !== '' && ( + {notifier.dto.info && ( {notifier.dto.info} diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx index df68ab185dc..30f26055983 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -9,7 +9,11 @@ import { } from 'app/features/alerting/unified/components/contact-points/useContactPoints'; import { showManageContactPointPermissions } from 'app/features/alerting/unified/components/contact-points/utils'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { canEditEntity, canModifyProtectedEntity } from 'app/features/alerting/unified/utils/k8s/utils'; +import { + canEditEntity, + canModifyProtectedEntity, + isProvisionedResource, +} from 'app/features/alerting/unified/utils/k8s/utils'; import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig, @@ -18,12 +22,13 @@ import { import { alertmanagerApi } from '../../../api/alertmanagerApi'; import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form'; +import { hasLegacyIntegrations } from '../../../utils/notifier-versions'; import { formChannelValuesToGrafanaChannelConfig, formValuesToGrafanaReceiver, grafanaReceiverToFormValues, } from '../../../utils/receiver-form'; -import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; +import { ImportedContactPointAlert, ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; import { ReceiverTypes } from '../grafanaAppReceivers/onCall/onCall'; import { useOnCallIntegration } from '../grafanaAppReceivers/onCall/useOnCallIntegration'; @@ -39,6 +44,8 @@ const defaultChannelValues: GrafanaChannelValues = Object.freeze({ secureFields: {}, disableResolveMessage: false, type: 'email', + // version is intentionally not set here - it will be determined by the notifier's currentVersion + // when the integration is created/type is changed. The backend will use its default if not provided. }); interface Props { @@ -67,7 +74,6 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } } = useOnCallIntegration(); const { data: grafanaNotifiers = [], isLoading: isLoadingNotifiers } = useGrafanaNotifiersQuery(); - const [testReceivers, setTestReceivers] = useState(); // transform receiver DTO to form values @@ -125,7 +131,8 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } // If there is no contact point it means we're creating a new one, so scoped permissions doesn't exist yet const hasScopedEditPermissions = contactPoint ? canEditEntity(contactPoint) : true; const hasScopedEditProtectedPermissions = contactPoint ? canModifyProtectedEntity(contactPoint) : true; - const isEditable = !readOnly && hasScopedEditPermissions && !contactPoint?.provisioned; + const isProvisioned = isProvisionedResource(contactPoint?.provenance); + const isEditable = !readOnly && hasScopedEditPermissions && !isProvisioned; const isTestable = !readOnly; const canEditProtectedFields = editMode ? hasScopedEditProtectedPermissions : true; @@ -135,15 +142,20 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } ); } + // Map notifiers to Notifier[] format for ReceiverForm + // The grafanaNotifiers include version-specific options via the versions array from the backend + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions const notifiers: Notifier[] = grafanaNotifiers.map((n) => { if (n.type === ReceiverTypes.OnCall) { return { - dto: extendOnCallNotifierFeatures(n), + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions + dto: extendOnCallNotifierFeatures(n as any) as any, meta: onCallNotifierMeta, }; } - return { dto: n }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions + return { dto: n as any }; }); return ( @@ -163,7 +175,10 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } )} - {contactPoint?.provisioned && } + {isProvisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && } + {isProvisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( + + )} contactPointId={contactPoint?.id} diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx index 6134ace8fbb..866f489e6fa 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx @@ -7,8 +7,8 @@ import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; import { getAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { NotificationChannelOption } from 'app/features/alerting/unified/types/alerting'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; import { DEFAULT_TEMPLATES } from 'app/features/alerting/unified/utils/template-constants'; import { AccessControlAction } from 'app/types/accessControl'; @@ -68,7 +68,7 @@ describe('getTemplateOptions function', () => { uid: title, title, content, - provenance: PROVENANCE_NONE, + provenance: KnownProvenance.None, }; }); const defaultTemplates = parseTemplates(DEFAULT_TEMPLATES); diff --git a/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts b/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts index 9babb1332bf..1005267badf 100644 --- a/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts +++ b/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts @@ -4,7 +4,8 @@ import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route, ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree, } from 'app/features/alerting/unified/openapi/routesApi.gen'; -import { K8sAnnotations, PROVENANCE_NONE, ROOT_ROUTE_NAME } from 'app/features/alerting/unified/utils/k8s/constants'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; +import { K8sAnnotations, ROOT_ROUTE_NAME } from 'app/features/alerting/unified/utils/k8s/constants'; import { AlertManagerCortexConfig, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; /** @@ -66,7 +67,7 @@ export const getUserDefinedRoutingTree: ( name: ROOT_ROUTE_NAME, namespace: 'default', annotations: { - [K8sAnnotations.Provenance]: PROVENANCE_NONE, + [K8sAnnotations.Provenance]: KnownProvenance.None, }, // Resource versions are much shorter than this in reality, but this is an easy way // for us to mock the concurrency logic and check if the policies have updated since the last fetch diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts index a620fe528c2..177b5c23499 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts @@ -6,8 +6,9 @@ import { } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse } from 'app/features/alerting/unified/mocks/server/utils'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver } from 'app/features/alerting/unified/openapi/receiversApi.gen'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { K8sAnnotations, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants'; const usedByPolicies = ['grafana-default-email']; const usedByRules = ['grafana-default-email']; @@ -23,7 +24,7 @@ const getReceiversList = () => { const provenance = contactPoint.grafana_managed_receiver_configs?.find((integration) => { return integration.provenance; - })?.provenance || PROVENANCE_NONE; + })?.provenance || KnownProvenance.None; return { metadata: { // This isn't exactly accurate, but its the cleanest way to use the same data for AM config and K8S responses diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts index f3baa318342..fff0d120629 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts @@ -3,8 +3,9 @@ import { HttpResponse, http } from 'msw'; import { getAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse } from 'app/features/alerting/unified/mocks/server/utils'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TemplateGroup } from 'app/features/alerting/unified/openapi/templatesApi.gen'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { PROVENANCE_ANNOTATION, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { PROVENANCE_ANNOTATION } from 'app/features/alerting/unified/utils/k8s/constants'; const config = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME); @@ -14,7 +15,7 @@ const mappedTemplates = Object.entries( ).map(([title, template]) => ({ metadata: { name: titleToK8sResourceName(title), // K8s uses unique identifiers for resources - annotations: { [PROVENANCE_ANNOTATION]: config.template_file_provenances?.[title] || PROVENANCE_NONE }, + annotations: { [PROVENANCE_ANNOTATION]: config.template_file_provenances?.[title] || KnownProvenance.None }, }, spec: { title: title, diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts index 84503c2ce13..77f6eef9f50 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts @@ -4,7 +4,8 @@ import { base64UrlEncode } from '@grafana/alerting'; import { filterBySelector } from 'app/features/alerting/unified/mocks/server/handlers/k8s/utils'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse } from 'app/features/alerting/unified/mocks/server/utils'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; -import { K8sAnnotations, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; +import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants'; /** UID of a time interval that we expect to follow all happy paths within tests/mocks */ export const TIME_INTERVAL_UID_HAPPY_PATH = 'f4eae7a4895fa786'; @@ -21,7 +22,7 @@ const allTimeIntervals = getK8sResponse { expect(frontendFilter.ruleMatches(regularRule)).toBe(true); expect(frontendFilter.ruleMatches(pluginRule)).toBe(true); }); + + it('should include searchFolder in backend filter when namespace is provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ namespace: 'my-folder' })); + + expect(backendFilter.searchFolder).toBe('my-folder'); + }); + + it('should skip namespace filtering on frontend when backend filtering is enabled', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter } = getGrafanaFilter(getFilter({ namespace: 'staging' })); + // Should return true because namespace filter is null (handled by backend) + expect(frontendFilter.groupMatches(group)).toBe(true); + }); }); describe('when alertingUIUseBackendFilters is disabled', () => { @@ -537,6 +556,12 @@ describe('grafana-managed rules', () => { expect(backendFilter.searchGroupName).toBeUndefined(); }); + it('should not include searchFolder in backend filter', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ namespace: 'my-folder' })); + + expect(backendFilter.searchFolder).toBeUndefined(); + }); + it('should perform groupName filtering on frontend', () => { const group: PromRuleGroupDTO = { name: 'CPU Usage Alerts', @@ -706,8 +731,8 @@ describe('grafana-managed rules', () => { expect(frontendFilter.groupMatches(group)).toBe(true); }); - it('should still apply always-frontend filters (namespace)', () => { - // Namespace filter should still work + it('should skip namespace filtering on frontend', () => { + // Namespace filter should be handled by backend const group: PromRuleGroupDTO = { name: 'Test Group', file: 'production/alerts', @@ -719,7 +744,7 @@ describe('grafana-managed rules', () => { expect(nsFilter.groupMatches(group)).toBe(true); const { frontendFilter: nsFilter2 } = getGrafanaFilter(getFilter({ namespace: 'staging' })); - expect(nsFilter2.groupMatches(group)).toBe(false); + expect(nsFilter2.groupMatches(group)).toBe(true); }); it('should skip dataSourceNames filtering on frontend (handled by backend)', () => { @@ -807,8 +832,8 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false); }); - it('should return true for client-side only filters', () => { - expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + it('should return false for namespace filter (handled by backend)', () => { + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(false); }); it('should return false for plugins filter (handled by backend when feature toggle is enabled)', () => { @@ -862,8 +887,8 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); - // Should return true for: always-frontend filters only (namespace) - expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + // Should return false for: namespace (handled by backend) + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(false); // plugins is backend-handled when both feature toggles are enabled expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index e8c4cf3c44a..cca395a9cb2 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -96,6 +96,7 @@ export function getGrafanaFilter(filterState: Partial) { datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids, ruleMatchers: ruleMatchersBackendFilter, plugins: ruleFilterConfig.plugins ? undefined : normalizedFilterState.plugins, + searchFolder: groupFilterConfig.namespace ? undefined : normalizedFilterState.namespace, }; return { @@ -134,7 +135,7 @@ function buildGrafanaFilterConfigs() { }; const groupFilterConfig: GroupFilterConfig = { - namespace: namespaceFilter, + namespace: useBackendFilters ? null : namespaceFilter, groupName: useBackendFilters ? null : groupNameFilter, }; diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index add1097fa0f..e2cb1247ac8 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -45,6 +45,7 @@ interface GrafanaPromApiFilter { contactPoint?: string; title?: string; searchGroupName?: string; + searchFolder?: string; type?: 'alerting' | 'recording'; dashboardUid?: string; } diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts index 5d6b7c97782..648cfc18190 100644 --- a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts @@ -75,6 +75,7 @@ describe('paginationLimits', () => { { contactPoint: 'slack' }, { dataSourceNames: ['prometheus'] }, { labels: ['severity=critical'] }, + { namespace: 'production' }, ])( 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', (filterState) => { @@ -84,16 +85,6 @@ describe('paginationLimits', () => { expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); } ); - - it.each>([ - { namespace: 'production' }, - { ruleState: PromAlertingRuleState.Firing, namespace: 'production' }, - ])('should return large limits for both when frontend filters are used: %p', (filterState) => { - const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); - - expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); - expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); - }); }); describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => { @@ -158,6 +149,7 @@ describe('paginationLimits', () => { { contactPoint: 'slack' }, { dataSourceNames: ['prometheus'] }, { labels: ['severity=critical'] }, + { namespace: 'production' }, ])( 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', (filterState) => { @@ -167,16 +159,6 @@ describe('paginationLimits', () => { expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); } ); - - it.each>([{ namespace: 'production' }])( - 'should return large limits for both when frontend filters are used: %p', - (filterState) => { - const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); - - expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); - expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); - } - ); }); }); }); diff --git a/public/app/features/alerting/unified/types/alerting.ts b/public/app/features/alerting/unified/types/alerting.ts index c6fe667982a..6436f132391 100644 --- a/public/app/features/alerting/unified/types/alerting.ts +++ b/public/app/features/alerting/unified/types/alerting.ts @@ -80,6 +80,20 @@ export type CloudNotifierType = | 'jira'; export type NotifierType = GrafanaNotifierType | CloudNotifierType; + +/** + * Represents a specific version of a notifier integration + * Used for integration versioning during Single Alert Manager migration + */ +export interface NotifierVersion { + version: string; + label: string; + description: string; + options: NotificationChannelOption[]; + /** Whether this version can be used to create new integrations */ + canCreate?: boolean; +} + export interface NotifierDTO { name: string; description: string; @@ -88,6 +102,23 @@ export interface NotifierDTO { options: NotificationChannelOption[]; info?: string; secure?: boolean; + /** + * Available versions for this notifier from the backend + * Each version contains version-specific options and metadata + */ + versions?: NotifierVersion[]; + /** + * The default version that the backend will use when creating new integrations. + * Returned by the backend from /api/alert-notifiers?version=2 + * + * - "v1" for most notifiers (modern Grafana version) + * - "v0mimir1" for legacy-only notifiers (e.g., WeChat) + * + * Note: Currently not used in the frontend. The backend handles version + * selection automatically. Could be used in the future to display + * version information or validate notifier capabilities. + */ + currentVersion?: string; } export interface NotificationChannelType { diff --git a/public/app/features/alerting/unified/types/knownProvenance.ts b/public/app/features/alerting/unified/types/knownProvenance.ts new file mode 100644 index 00000000000..4bf5fe4bb32 --- /dev/null +++ b/public/app/features/alerting/unified/types/knownProvenance.ts @@ -0,0 +1,6 @@ +export enum KnownProvenance { + None = 'none' /** Provenance value given for entities that were not provisioned */, + API = 'api', + File = 'file', + ConvertedPrometheus = 'converted_prometheus', +} diff --git a/public/app/features/alerting/unified/types/receiver-form.ts b/public/app/features/alerting/unified/types/receiver-form.ts index 09d87d06845..0e5c95f04ae 100644 --- a/public/app/features/alerting/unified/types/receiver-form.ts +++ b/public/app/features/alerting/unified/types/receiver-form.ts @@ -8,6 +8,7 @@ import { ControlledField } from '../hooks/useControlledFieldArray'; export interface ChannelValues { __id: string; // used to correlate form values to original DTOs type: string; + version?: string; // Integration version (e.g. "v0" for Mimir legacy, "v1" for Grafana) settings: Record; secureFields: Record; } diff --git a/public/app/features/alerting/unified/utils/k8s/constants.ts b/public/app/features/alerting/unified/utils/k8s/constants.ts index cf297261733..4fdc2628e10 100644 --- a/public/app/features/alerting/unified/utils/k8s/constants.ts +++ b/public/app/features/alerting/unified/utils/k8s/constants.ts @@ -4,9 +4,6 @@ * */ export const PROVENANCE_ANNOTATION = 'grafana.com/provenance'; -/** Value of {@link PROVENANCE_ANNOTATION} given for entities that were not provisioned */ -export const PROVENANCE_NONE = 'none'; - export enum K8sAnnotations { Provenance = 'grafana.com/provenance', diff --git a/public/app/features/alerting/unified/utils/k8s/utils.test.ts b/public/app/features/alerting/unified/utils/k8s/utils.test.ts index a04a1ea16ec..5b6214846e7 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.test.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.test.ts @@ -1,4 +1,6 @@ -import { encodeFieldSelector } from './utils'; +import { KnownProvenance } from '../../types/knownProvenance'; + +import { encodeFieldSelector, isProvisionedResource } from './utils'; describe('encodeFieldSelector', () => { it('should escape backslashes', () => { @@ -25,3 +27,29 @@ describe('encodeFieldSelector', () => { expect(encodeFieldSelector('foo=bar,bar=baz,qux\\foo')).toBe('foo\\=bar\\,bar\\=baz\\,qux\\\\foo'); }); }); + +describe('isProvisionedResource', () => { + it('should return true when provenance is API', () => { + expect(isProvisionedResource(KnownProvenance.API)).toBe(true); + }); + + it('should return true when provenance is File', () => { + expect(isProvisionedResource(KnownProvenance.File)).toBe(true); + }); + + it('should return true when provenance is ConvertedPrometheus', () => { + expect(isProvisionedResource(KnownProvenance.ConvertedPrometheus)).toBe(true); + }); + + it('should return false when provenance is none', () => { + expect(isProvisionedResource(KnownProvenance.None)).toBe(false); + }); + + it('should return false when provenance is undefined', () => { + expect(isProvisionedResource(undefined)).toBe(false); + }); + + it('should return true for any other non-empty string', () => { + expect(isProvisionedResource('custom-provenance')).toBe(true); + }); +}); diff --git a/public/app/features/alerting/unified/utils/k8s/utils.ts b/public/app/features/alerting/unified/utils/k8s/utils.ts index 48ec5685a69..015ba8f17a2 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.ts @@ -1,6 +1,8 @@ import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { K8sAnnotations, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants'; + +import { KnownProvenance } from '../../types/knownProvenance'; /** * Should we call the kubernetes-style API for managing alertmanager entities? @@ -22,7 +24,7 @@ type EntityToCheck = { */ export const isK8sEntityProvisioned = (k8sEntity: EntityToCheck) => { const provenance = getAnnotation(k8sEntity, K8sAnnotations.Provenance); - return Boolean(provenance && provenance !== PROVENANCE_NONE); + return isProvisionedResource(provenance); }; export const ANNOTATION_PREFIX_ACCESS = 'grafana.com/access/'; @@ -59,3 +61,7 @@ export const stringifyFieldSelector = (fieldSelectors: FieldSelector[]): string .map(([key, value, operator = '=']) => `${key}${operator}${encodeFieldSelector(value)}`) .join(','); }; + +export function isProvisionedResource(provenance?: string): boolean { + return Boolean(provenance && provenance !== KnownProvenance.None); +} diff --git a/public/app/features/alerting/unified/utils/notifier-versions.test.ts b/public/app/features/alerting/unified/utils/notifier-versions.test.ts new file mode 100644 index 00000000000..d11ac74665d --- /dev/null +++ b/public/app/features/alerting/unified/utils/notifier-versions.test.ts @@ -0,0 +1,429 @@ +import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; + +import { NotificationChannelOption, NotifierDTO, NotifierVersion } from '../types/alerting'; + +import { + canCreateNotifier, + getLegacyVersionLabel, + getOptionsForVersion, + hasLegacyIntegrations, + isLegacyVersion, +} from './notifier-versions'; + +// Helper to create a minimal NotifierDTO for testing +function createNotifier(overrides: Partial = {}): NotifierDTO { + return { + name: 'Test Notifier', + description: 'Test description', + type: 'webhook', + heading: 'Test heading', + options: [ + { + element: 'input', + inputType: 'text', + label: 'Default Option', + description: 'Default option description', + placeholder: '', + propertyName: 'defaultOption', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ], + ...overrides, + }; +} + +// Helper to create a NotifierVersion for testing +function createVersion(overrides: Partial = {}): NotifierVersion { + return { + version: 'v1', + label: 'Test Version', + description: 'Test version description', + options: [ + { + element: 'input', + inputType: 'text', + label: 'Version Option', + description: 'Version option description', + placeholder: '', + propertyName: 'versionOption', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ], + ...overrides, + }; +} + +describe('notifier-versions utilities', () => { + describe('canCreateNotifier', () => { + it('should return true if notifier has no versions array', () => { + const notifier = createNotifier({ versions: undefined }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return true if notifier has empty versions array', () => { + const notifier = createNotifier({ versions: [] }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return true if at least one version has canCreate: true', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return true if at least one version has canCreate: undefined (defaults to true)', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: undefined }), + ], + }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return false if all versions have canCreate: false', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v0mimir2', canCreate: false }), + ], + }); + expect(canCreateNotifier(notifier)).toBe(false); + }); + + it('should return false for notifiers like WeChat that only have legacy versions', () => { + const wechatNotifier = createNotifier({ + name: 'WeChat', + type: 'wechat', + versions: [createVersion({ version: 'v0mimir1', canCreate: false })], + }); + expect(canCreateNotifier(wechatNotifier)).toBe(false); + }); + }); + + describe('isLegacyVersion', () => { + it('should return false if no version is specified', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v0mimir1', canCreate: false })], + }); + expect(isLegacyVersion(notifier, undefined)).toBe(false); + expect(isLegacyVersion(notifier, '')).toBe(false); + }); + + it('should return false if notifier has no versions array', () => { + const notifier = createNotifier({ versions: undefined }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false); + }); + + it('should return false if notifier has empty versions array', () => { + const notifier = createNotifier({ versions: [] }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false); + }); + + it('should return false if version is not found in versions array', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v1', canCreate: true })], + }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false); + }); + + it('should return false if version has canCreate: true', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v1', canCreate: true })], + }); + expect(isLegacyVersion(notifier, 'v1')).toBe(false); + }); + + it('should return false if version has canCreate: undefined', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v1', canCreate: undefined })], + }); + expect(isLegacyVersion(notifier, 'v1')).toBe(false); + }); + + it('should return true if version has canCreate: false', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true); + }); + + it('should correctly identify legacy versions in a mixed notifier', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v0mimir2', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true); + expect(isLegacyVersion(notifier, 'v0mimir2')).toBe(true); + expect(isLegacyVersion(notifier, 'v1')).toBe(false); + }); + }); + + describe('getOptionsForVersion', () => { + const defaultOptions: NotificationChannelOption[] = [ + { + element: 'input', + inputType: 'text', + label: 'Default URL', + description: 'Default URL description', + placeholder: '', + propertyName: 'url', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + const v0Options: NotificationChannelOption[] = [ + { + element: 'input', + inputType: 'text', + label: 'Legacy URL', + description: 'Legacy URL description', + placeholder: '', + propertyName: 'legacyUrl', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + const v1Options: NotificationChannelOption[] = [ + { + element: 'input', + inputType: 'text', + label: 'Modern URL', + description: 'Modern URL description', + placeholder: '', + propertyName: 'modernUrl', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + it('should return options from default creatable version if no version is specified', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })], + }); + // When no version specified, should use options from the default creatable version + expect(getOptionsForVersion(notifier, undefined)).toBe(v1Options); + }); + + it('should return default options if no version is specified and empty string is passed', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })], + }); + // Empty string is still a falsy version, so should use default creatable version + expect(getOptionsForVersion(notifier, '')).toBe(v1Options); + }); + + it('should return default options if notifier has no versions array', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: undefined, + }); + expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions); + }); + + it('should return default options if notifier has empty versions array', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [], + }); + expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions); + }); + + it('should return default options if version is not found', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [createVersion({ version: 'v1', options: v1Options })], + }); + expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(defaultOptions); + }); + + it('should return version-specific options when version is found', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [ + createVersion({ version: 'v0mimir1', options: v0Options }), + createVersion({ version: 'v1', options: v1Options }), + ], + }); + expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(v0Options); + expect(getOptionsForVersion(notifier, 'v1')).toBe(v1Options); + }); + + it('should return default options if version found but has no options', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [ + { + version: 'v1', + label: 'V1', + description: 'V1 description', + options: undefined as unknown as NotificationChannelOption[], + }, + ], + }); + expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions); + }); + }); + + describe('hasLegacyIntegrations', () => { + // Helper to create a minimal contact point for testing + function createContactPoint(overrides: Partial = {}): GrafanaManagedContactPoint { + return { + name: 'Test Contact Point', + ...overrides, + }; + } + + // Create notifiers with version info for testing + const notifiersWithVersions: NotifierDTO[] = [ + createNotifier({ + type: 'slack', + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }), + createNotifier({ + type: 'webhook', + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v0mimir2', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }), + ]; + + it('should return false if contact point is undefined', () => { + expect(hasLegacyIntegrations(undefined, notifiersWithVersions)).toBe(false); + }); + + it('should return false if notifiers is undefined', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [{ type: 'slack', settings: {}, version: 'v0mimir1' }], + }); + expect(hasLegacyIntegrations(contactPoint, undefined)).toBe(false); + }); + + it('should return false if contact point has no integrations', () => { + const contactPoint = createContactPoint({ grafana_managed_receiver_configs: undefined }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return false if contact point has empty integrations array', () => { + const contactPoint = createContactPoint({ grafana_managed_receiver_configs: [] }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return false if all integrations have v1 version (canCreate: true)', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {}, version: 'v1' }, + { type: 'webhook', settings: {}, version: 'v1' }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return false if all integrations have no version', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {} }, + { type: 'webhook', settings: {} }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return true if any integration has a legacy version (canCreate: false)', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {}, version: 'v0mimir1' }, + { type: 'webhook', settings: {}, version: 'v1' }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true); + }); + + it('should return true if all integrations have legacy versions', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {}, version: 'v0mimir1' }, + { type: 'webhook', settings: {}, version: 'v0mimir2' }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true); + }); + + it('should return false if notifier type is not found in notifiers array', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [{ type: 'unknown', settings: {}, version: 'v0mimir1' }], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + }); + + describe('getLegacyVersionLabel', () => { + it('should return "Legacy" for undefined version', () => { + expect(getLegacyVersionLabel(undefined)).toBe('Legacy'); + }); + + it('should return "Legacy" for empty string version', () => { + expect(getLegacyVersionLabel('')).toBe('Legacy'); + }); + + it('should return "Legacy" for v0mimir1', () => { + expect(getLegacyVersionLabel('v0mimir1')).toBe('Legacy'); + }); + + it('should return "Legacy v2" for v0mimir2', () => { + expect(getLegacyVersionLabel('v0mimir2')).toBe('Legacy v2'); + }); + + it('should return "Legacy v3" for v0mimir3', () => { + expect(getLegacyVersionLabel('v0mimir3')).toBe('Legacy v3'); + }); + + it('should return "Legacy" for v1 (trailing 1)', () => { + expect(getLegacyVersionLabel('v1')).toBe('Legacy'); + }); + + it('should return "Legacy v2" for v2 (trailing 2)', () => { + expect(getLegacyVersionLabel('v2')).toBe('Legacy v2'); + }); + + it('should return "Legacy" for version strings without trailing number', () => { + expect(getLegacyVersionLabel('legacy')).toBe('Legacy'); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/notifier-versions.ts b/public/app/features/alerting/unified/utils/notifier-versions.ts new file mode 100644 index 00000000000..b4e3b7902b1 --- /dev/null +++ b/public/app/features/alerting/unified/utils/notifier-versions.ts @@ -0,0 +1,126 @@ +/** + * Utilities for integration versioning + * + * These utilities help get version-specific options from the backend response + * (via /api/alert-notifiers?version=2) + */ + +import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; + +import { NotificationChannelOption, NotifierDTO } from '../types/alerting'; + +/** + * Checks if a notifier can be used to create new integrations. + * A notifier can be created if it has at least one version with canCreate: true, + * or if it has no versions array (legacy behavior). + * + * @param notifier - The notifier DTO to check + * @returns True if the notifier can be used to create new integrations + */ +export function canCreateNotifier(notifier: NotifierDTO): boolean { + // If no versions array, assume it can be created (legacy behavior) + if (!notifier.versions || notifier.versions.length === 0) { + return true; + } + + // Check if any version has canCreate: true (or undefined, which defaults to true) + return notifier.versions.some((v) => v.canCreate !== false); +} + +/** + * Checks if a specific version is legacy (cannot be created). + * A version is legacy if it has canCreate: false in the notifier's versions array. + * + * @param notifier - The notifier DTO containing versions array + * @param version - The version string to check (e.g., 'v0mimir1', 'v1') + * @returns True if the version is legacy (canCreate: false) + */ +export function isLegacyVersion(notifier: NotifierDTO, version?: string): boolean { + // If no version specified or no versions array, it's not legacy + if (!version || !notifier.versions || notifier.versions.length === 0) { + return false; + } + + // Find the matching version and check its canCreate property + const versionData = notifier.versions.find((v) => v.version === version); + + // A version is legacy if canCreate is explicitly false + return versionData?.canCreate === false; +} + +/** + * Gets the options for a specific version of a notifier. + * Used to display the correct form fields based on integration version. + * + * @param notifier - The notifier DTO containing versions array + * @param version - The version to get options for (e.g., 'v0', 'v1') + * @returns The options for the specified version, or default options if version not found + */ +export function getOptionsForVersion(notifier: NotifierDTO, version?: string): NotificationChannelOption[] { + // If no versions array, use default options + if (!notifier.versions || notifier.versions.length === 0) { + return notifier.options; + } + + // If version is specified, find the matching version + if (version) { + const versionData = notifier.versions.find((v) => v.version === version); + // Return version-specific options if found, otherwise fall back to default + return versionData?.options ?? notifier.options; + } + + // If no version specified, find the default creatable version (canCreate !== false) + const defaultVersion = notifier.versions.find((v) => v.canCreate !== false); + return defaultVersion?.options ?? notifier.options; +} + +/** + * Checks if a contact point has any legacy (imported) integrations. + * A contact point has legacy integrations if any of its integrations uses a version + * with canCreate: false in the corresponding notifier's versions array. + * + * @param contactPoint - The contact point to check + * @param notifiers - Array of notifier DTOs to look up version info + * @returns True if the contact point has at least one legacy/imported integration + */ +export function hasLegacyIntegrations(contactPoint?: GrafanaManagedContactPoint, notifiers?: NotifierDTO[]): boolean { + if (!contactPoint?.grafana_managed_receiver_configs || !notifiers) { + return false; + } + + return contactPoint.grafana_managed_receiver_configs.some((config) => { + const notifier = notifiers.find((n) => n.type === config.type); + return notifier ? isLegacyVersion(notifier, config.version) : false; + }); +} + +/** + * Gets a user-friendly label for a legacy version. + * Extracts the version number from the version string and formats it as: + * - "Legacy" for version 1 (e.g., v0mimir1) + * - "Legacy v2" for version 2 (e.g., v0mimir2) + * - etc. + * + * Precondition: This function assumes the version is already known to be legacy + * (i.e., canCreate: false). Use isLegacyVersion() to check before calling this. + * + * @param version - The version string (e.g., 'v0mimir1', 'v0mimir2') + * @returns A user-friendly label like "Legacy" or "Legacy v2" + */ +export function getLegacyVersionLabel(version?: string): string { + if (!version) { + return 'Legacy'; + } + + // Extract trailing number from version string (e.g., v0mimir1 → 1, v0mimir2 → 2) + const match = version.match(/(\d+)$/); + if (match) { + const num = parseInt(match[1], 10); + if (num === 1) { + return 'Legacy'; + } + return `Legacy v${num}`; + } + + return 'Legacy'; +} diff --git a/public/app/features/alerting/unified/utils/receiver-form.ts b/public/app/features/alerting/unified/utils/receiver-form.ts index bea5503ad85..31927a1080a 100644 --- a/public/app/features/alerting/unified/utils/receiver-form.ts +++ b/public/app/features/alerting/unified/utils/receiver-form.ts @@ -185,6 +185,7 @@ function grafanaChannelConfigToFormChannelValues( const values: GrafanaChannelValues = { __id: id, type: channel.type as NotifierType, + version: channel.version, provenance: channel.provenance, settings: { ...channel.settings }, secureFields: { ...channel.secureFields }, @@ -239,6 +240,7 @@ export function formChannelValuesToGrafanaChannelConfig( }), secureFields: secureFieldsFromValues, type: values.type, + version: values.version ?? existing?.version, name, disableResolveMessage: values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.disableResolveMessage, diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 4a7c5ec8a7b..c1e3d519d8f 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -1108,12 +1108,7 @@ export class ElementState implements LayerElement { tabIndex={0} style={{ userSelect: 'none' }} > - +
        {this.showActionConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())} {this.showActionVarsModal && this.renderVariablesInputModal(this.getPrimaryAction())} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx index 69f81584a48..266b6cd571b 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx @@ -134,7 +134,7 @@ function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) { Learn more diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index c5d01b433e3..4b0e109e864 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -781,6 +781,10 @@ export function tabItemToSaveModel( panels: [], }; + if (tab.state.repeatByVariable) { + rowPanel.repeat = tab.state.repeatByVariable; + } + panelsArray.push(rowPanel); // The base Y position for panels in this tab (after the row panel) @@ -912,6 +916,15 @@ function autoGridLayoutToPanels(layout: AutoGridLayoutManager, isSnapshot = fals }, isSnapshot ); + + // Handle repeat properties for AutoGridItem + // AutoGrid always uses horizontal direction, and maxPerRow is derived from maxColumnCount + if (item.state.variableName) { + panel.repeat = item.state.variableName; + panel.repeatDirection = 'h'; + panel.maxPerRow = maxColumnCount; + } + panels.push(panel); // Move to next position diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx index 5673dff8068..33a0eb15c4c 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx @@ -25,6 +25,10 @@ export class ExportAsCode extends ShareExportTab { public getTabLabel(): string { return t('export.json.title', 'Export dashboard'); } + + public getSubtitle(): string | undefined { + return t('export.json.info-text', 'Copy or download a file containing the definition of your dashboard'); + } } function ExportAsCodeRenderer({ model }: SceneComponentProps) { @@ -53,12 +57,6 @@ function ExportAsCodeRenderer({ model }: SceneComponentProps) { return (
        -

        - - Copy or download a file containing the definition of your dashboard - -

        - {config.featureToggles.kubernetesDashboards ? ( ; + +const selector = e2eSelectors.pages.ExportDashboardDrawer.ExportAsJson; + +const createDefaultProps = (overrides?: Partial[0]>) => { + const defaultProps: Parameters[0] = { + dashboardJson: { + loading: false, + value: { + json: { title: 'Test Dashboard' } as Dashboard, + hasLibraryPanels: false, + initialSaveModelVersion: 'v1', + }, + } as DashboardJsonState, + isSharingExternally: false, + exportMode: ExportMode.Classic, + isViewingYAML: false, + onExportModeChange: jest.fn(), + onShareExternallyChange: jest.fn(), + onViewYAML: jest.fn(), + }; + + return { ...defaultProps, ...overrides }; +}; + +const createV2DashboardJson = (hasLibraryPanels = false): DashboardJsonState => ({ + loading: false, + value: { + json: { + title: 'Test V2 Dashboard', + spec: { + elements: {}, + }, + } as unknown as DashboardV2Spec, + hasLibraryPanels, + initialSaveModelVersion: 'v2', + }, +}); + +const expandOptions = async () => { + const button = screen.getByRole('button', { expanded: false }); + await userEvent.click(button); +}; + +describe('ResourceExport', () => { + describe('export mode options for v1 dashboard', () => { + it('should show three export mode options in correct order: Classic, V1 Resource, V2 Resource', async () => { + render(); + await expandOptions(); + + const radioGroup = screen.getByRole('radiogroup', { name: /model/i }); + const labels = within(radioGroup) + .getAllByRole('radio') + .map((radio) => radio.parentElement?.textContent?.trim()); + + expect(labels).toHaveLength(3); + expect(labels).toEqual(['Classic', 'V1 Resource', 'V2 Resource']); + }); + + it('should have first option selected by default when exportMode is Classic', async () => { + render(); + await expandOptions(); + + const radioGroup = screen.getByRole('radiogroup', { name: /model/i }); + const radios = within(radioGroup).getAllByRole('radio'); + expect(radios[0]).toBeChecked(); + }); + + it('should call onExportModeChange when export mode is changed', async () => { + const onExportModeChange = jest.fn(); + render(); + await expandOptions(); + + const radioGroup = screen.getByRole('radiogroup', { name: /model/i }); + const radios = within(radioGroup).getAllByRole('radio'); + await userEvent.click(radios[1]); // V1 Resource + expect(onExportModeChange).toHaveBeenCalledWith(ExportMode.V1Resource); + }); + }); + + describe('export mode options for v2 dashboard', () => { + it('should not show export mode options', async () => { + render(); + await expandOptions(); + + expect(screen.queryByRole('radiogroup', { name: /model/i })).not.toBeInTheDocument(); + }); + }); + + describe('format options', () => { + it('should not show format options when export mode is Classic', async () => { + render(); + await expandOptions(); + + expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument(); + expect(screen.queryByRole('radiogroup', { name: /format/i })).not.toBeInTheDocument(); + }); + + it.each([ExportMode.V1Resource, ExportMode.V2Resource])( + 'should show format options when export mode is %s', + async (exportMode) => { + render(); + await expandOptions(); + + expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument(); + expect(screen.getByRole('radiogroup', { name: /format/i })).toBeInTheDocument(); + } + ); + + it('should have first format option selected when isViewingYAML is false', async () => { + render(); + await expandOptions(); + + const formatGroup = screen.getByRole('radiogroup', { name: /format/i }); + const formatRadios = within(formatGroup).getAllByRole('radio'); + expect(formatRadios[0]).toBeChecked(); // JSON + }); + + it('should have second format option selected when isViewingYAML is true', async () => { + render(); + await expandOptions(); + + const formatGroup = screen.getByRole('radiogroup', { name: /format/i }); + const formatRadios = within(formatGroup).getAllByRole('radio'); + expect(formatRadios[1]).toBeChecked(); // YAML + }); + + it('should call onViewYAML when format is changed', async () => { + const onViewYAML = jest.fn(); + render(); + await expandOptions(); + + const formatGroup = screen.getByRole('radiogroup', { name: /format/i }); + const formatRadios = within(formatGroup).getAllByRole('radio'); + await userEvent.click(formatRadios[1]); // YAML + expect(onViewYAML).toHaveBeenCalled(); + }); + }); + + describe('share externally switch', () => { + it('should show share externally switch for Classic mode', () => { + render(); + + expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument(); + }); + + it('should show share externally switch for V2Resource mode with V2 dashboard', () => { + render( + + ); + + expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument(); + }); + + it('should call onShareExternallyChange when switch is toggled', async () => { + const onShareExternallyChange = jest.fn(); + render(); + + const switchElement = screen.getByTestId(selector.exportExternallyToggle); + await userEvent.click(switchElement); + expect(onShareExternallyChange).toHaveBeenCalled(); + }); + + it('should reflect isSharingExternally value in switch', () => { + render(); + + expect(screen.getByTestId(selector.exportExternallyToggle)).toBeChecked(); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx index b58bf8377af..356175cd77f 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx @@ -4,7 +4,8 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Dashboard } from '@grafana/schema'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { Alert, Label, RadioButtonGroup, Stack, Switch } from '@grafana/ui'; +import { Alert, Icon, Label, RadioButtonGroup, Stack, Switch, Box, Tooltip } from '@grafana/ui'; +import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { ExportableResource } from '../ShareExportTab'; @@ -48,80 +49,90 @@ export function ResourceExport({ const switchExportLabel = exportMode === ExportMode.V2Resource - ? t('export.json.export-remove-ds-refs', 'Remove deployment details') - : t('share-modal.export.share-externally-label', `Export for sharing externally`); + ? t('dashboard-scene.resource-export.share-externally', 'Share dashboard with another instance') + : t('share-modal.export.share-externally-label', 'Export for sharing externally'); + const switchExportTooltip = t( + 'dashboard-scene.resource-export.share-externally-tooltip', + 'Removes all instance-specific metadata and data source references from the resource before export.' + ); const switchExportModeLabel = t('export.json.export-mode', 'Model'); const switchExportFormatLabel = t('export.json.export-format', 'Format'); + const exportResourceOptions = [ + { + label: t('dashboard-scene.resource-export.label.classic', 'Classic'), + value: ExportMode.Classic, + }, + { + label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'), + value: ExportMode.V1Resource, + }, + { + label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'), + value: ExportMode.V2Resource, + }, + ]; + return ( - - - {initialSaveModelVersion === 'v1' && ( - - - onExportModeChange(value)} - /> + <> + + + + {initialSaveModelVersion === 'v1' && ( + + + onExportModeChange(value)} + aria-label={switchExportModeLabel} + /> + + )} + + {exportMode !== ExportMode.Classic && ( + + + + + )} - )} - {initialSaveModelVersion === 'v2' && ( - - - onExportModeChange(value)} - /> - - )} - {exportMode !== ExportMode.Classic && ( - - - - - )} - {(isV2Dashboard || - exportMode === ExportMode.Classic || - (initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && ( - - - - - )} - + + + + {(isV2Dashboard || + exportMode === ExportMode.Classic || + (initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && ( + + + + + )} {showV2LibPanelAlert && ( Due to limitations in the new dashboard schema (V2), library panels will be converted to regular panels with @@ -137,6 +149,6 @@ export function ResourceExport({ )} - + ); } diff --git a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx index 80a880a04c9..9b90ccdb030 100644 --- a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx @@ -66,7 +66,12 @@ function ShareDrawerRenderer({ model }: SceneComponentProps) { const dashboard = getDashboardSceneFor(model); return ( - + {activeShare && } diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx index c255cd4af5b..4bf62bbce73 100644 --- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx @@ -66,6 +66,10 @@ export class ShareExportTab extends SceneObjectBase impleme return t('share-modal.tab-title.export', 'Export'); } + public getSubtitle(): string | undefined { + return undefined; + } + public onShareExternallyChange = () => { this.setState({ isSharingExternally: !this.state.isSharingExternally, diff --git a/public/app/features/dashboard-scene/sharing/types.ts b/public/app/features/dashboard-scene/sharing/types.ts index bf344638d86..4424ae16e66 100644 --- a/public/app/features/dashboard-scene/sharing/types.ts +++ b/public/app/features/dashboard-scene/sharing/types.ts @@ -15,5 +15,6 @@ export interface SceneShareTab void; } diff --git a/public/app/features/datasources/state/buildCategories.test.ts b/public/app/features/datasources/state/buildCategories.test.ts index 3231a029ff7..da64a6b412d 100644 --- a/public/app/features/datasources/state/buildCategories.test.ts +++ b/public/app/features/datasources/state/buildCategories.test.ts @@ -53,7 +53,7 @@ describe('buildCategories', () => { it('should add enterprise phantom plugins', () => { const enterprisePluginsCategory = categories[3]; expect(enterprisePluginsCategory.title).toBe('Enterprise plugins'); - expect(enterprisePluginsCategory.plugins.length).toBe(31); + expect(enterprisePluginsCategory.plugins.length).toBe(32); expect(enterprisePluginsCategory.plugins[0].name).toBe('Adobe Analytics'); expect(enterprisePluginsCategory.plugins[enterprisePluginsCategory.plugins.length - 1].name).toBe('Zendesk'); }); diff --git a/public/app/features/datasources/state/buildCategories.ts b/public/app/features/datasources/state/buildCategories.ts index 7aeeed6abb4..1be01fae638 100644 --- a/public/app/features/datasources/state/buildCategories.ts +++ b/public/app/features/datasources/state/buildCategories.ts @@ -13,6 +13,7 @@ import catchpointSvg from 'img/plugins/catchpoint.svg'; import cloudflareJpg from 'img/plugins/cloudflare.jpg'; import cockroachdbJpg from 'img/plugins/cockroachdb.jpg'; import datadogPng from 'img/plugins/datadog.png'; +import db2Svg from 'img/plugins/db2.svg'; import droneSvg from 'img/plugins/drone.svg'; import dynatracePng from 'img/plugins/dynatrace.png'; import gitlabSvg from 'img/plugins/gitlab.svg'; @@ -418,6 +419,12 @@ function getEnterprisePhantomPlugins(): DataSourcePluginMeta[] { name: 'SolarWinds', imgUrl: solarWindsSvg, }), + getPhantomPlugin({ + id: 'grafana-ibmdb2-datasource', + description: t('datasources.get-enterprise-phantom-plugins.description.ibmdb2-datasource', 'IBM Db2 data source'), + name: 'IBM Db2', + imgUrl: db2Svg, + }), ]; } diff --git a/public/app/features/panel/components/PanelDataErrorView.test.tsx b/public/app/features/panel/components/PanelDataErrorView.test.tsx index 7040b80418f..c5c4f96e982 100644 --- a/public/app/features/panel/components/PanelDataErrorView.test.tsx +++ b/public/app/features/panel/components/PanelDataErrorView.test.tsx @@ -2,8 +2,9 @@ import { render, screen } from '@testing-library/react'; import { defaultsDeep } from 'lodash'; import { Provider } from 'react-redux'; -import { FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data'; -import { PanelDataErrorViewProps } from '@grafana/runtime'; +import { CoreApp, EventBusSrv, FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data'; +import { config, PanelDataErrorViewProps } from '@grafana/runtime'; +import { usePanelContext } from '@grafana/ui'; import { configureStore } from 'app/store/configureStore'; import { PanelDataErrorView } from './PanelDataErrorView'; @@ -16,7 +17,24 @@ jest.mock('app/features/dashboard/services/DashboardSrv', () => ({ }, })); +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + usePanelContext: jest.fn(), +})); + +const mockUsePanelContext = jest.mocked(usePanelContext); +const RUN_QUERY_MESSAGE = 'Run a query to visualize it here or go to all visualizations to add other panel types'; +const panelContextRoot = { + app: CoreApp.Dashboard, + eventsScope: 'global', + eventBus: new EventBusSrv(), +}; + describe('PanelDataErrorView', () => { + beforeEach(() => { + mockUsePanelContext.mockReturnValue(panelContextRoot); + }); + it('show No data when there is no data', () => { renderWithProps(); @@ -70,6 +88,45 @@ describe('PanelDataErrorView', () => { expect(screen.getByText('Query returned nothing')).toBeInTheDocument(); }); + + it('should show "Run a query..." message when no query is configured and feature toggle is enabled', () => { + mockUsePanelContext.mockReturnValue(panelContextRoot); + + const originalFeatureToggle = config.featureToggles.newVizSuggestions; + config.featureToggles.newVizSuggestions = true; + + renderWithProps({ + data: { + state: LoadingState.Done, + series: [], + timeRange: getDefaultTimeRange(), + }, + }); + + expect(screen.getByText(RUN_QUERY_MESSAGE)).toBeInTheDocument(); + + config.featureToggles.newVizSuggestions = originalFeatureToggle; + }); + + it('should show "No data" message when feature toggle is disabled even without queries', () => { + mockUsePanelContext.mockReturnValue(panelContextRoot); + + const originalFeatureToggle = config.featureToggles.newVizSuggestions; + config.featureToggles.newVizSuggestions = false; + + renderWithProps({ + data: { + state: LoadingState.Done, + series: [], + timeRange: getDefaultTimeRange(), + }, + }); + + expect(screen.getByText('No data')).toBeInTheDocument(); + expect(screen.queryByText(RUN_QUERY_MESSAGE)).not.toBeInTheDocument(); + + config.featureToggles.newVizSuggestions = originalFeatureToggle; + }); }); function renderWithProps(overrides?: Partial) { diff --git a/public/app/features/panel/components/PanelDataErrorView.tsx b/public/app/features/panel/components/PanelDataErrorView.tsx index 93723b3bff1..74c0d494c3f 100644 --- a/public/app/features/panel/components/PanelDataErrorView.tsx +++ b/public/app/features/panel/components/PanelDataErrorView.tsx @@ -5,14 +5,15 @@ import { FieldType, getPanelDataSummary, GrafanaTheme2, + PanelData, PanelDataSummary, PanelPluginVisualizationSuggestion, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; -import { PanelDataErrorViewProps, locationService } from '@grafana/runtime'; +import { PanelDataErrorViewProps, locationService, config } from '@grafana/runtime'; import { VizPanel } from '@grafana/scenes'; -import { usePanelContext, useStyles2 } from '@grafana/ui'; +import { Icon, usePanelContext, useStyles2 } from '@grafana/ui'; import { CardButton } from 'app/core/components/CardButton'; import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants'; import store from 'app/core/store'; @@ -24,6 +25,11 @@ import { findVizPanelByKey, getVizPanelKeyForPanelId } from 'app/features/dashbo import { useDispatch } from 'app/types/store'; import { changePanelPlugin } from '../state/actions'; +import { hasData } from '../suggestions/utils'; + +function hasNoQueryConfigured(data: PanelData): boolean { + return !data.request?.targets || data.request.targets.length === 0; +} export function PanelDataErrorView(props: PanelDataErrorViewProps) { const styles = useStyles2(getStyles); @@ -93,8 +99,14 @@ export function PanelDataErrorView(props: PanelDataErrorViewProps) { } }; + const noData = !hasData(props.data); + const noQueryConfigured = hasNoQueryConfigured(props.data); + const showEmptyState = + config.featureToggles.newVizSuggestions && context.app === CoreApp.PanelEditor && noQueryConfigured && noData; + return (
        + {showEmptyState && }
        {message}
        @@ -131,7 +143,17 @@ function getMessageFor( return message; } - if (!data.series || data.series.length === 0 || data.series.every((frame) => frame.length === 0)) { + const noData = !hasData(data); + const noQueryConfigured = hasNoQueryConfigured(data); + + if (config.featureToggles.newVizSuggestions && noQueryConfigured && noData) { + return t( + 'dashboard.new-panel.empty-state-message', + 'Run a query to visualize it here or go to all visualizations to add other panel types' + ); + } + + if (noData) { return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data'); } @@ -176,5 +198,9 @@ const getStyles = (theme: GrafanaTheme2) => { width: '100%', maxWidth: '600px', }), + emptyStateIcon: css({ + color: theme.colors.text.secondary, + marginBottom: theme.spacing(2), + }), }; }; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index a529952eff3..03025e484cb 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -4,8 +4,6 @@ const cloudwatchPlugin = async () => await import(/* webpackChunkName: "cloudwatchPlugin" */ 'app/plugins/datasource/cloudwatch/module'); const dashboardDSPlugin = async () => await import(/* webpackChunkName "dashboardDSPlugin" */ 'app/plugins/datasource/dashboard/module'); -const elasticsearchPlugin = async () => - await import(/* webpackChunkName: "elasticsearchPlugin" */ 'app/plugins/datasource/elasticsearch/module'); const grafanaPlugin = async () => await import(/* webpackChunkName: "grafanaPlugin" */ 'app/plugins/datasource/grafana/module'); const influxdbPlugin = async () => @@ -75,7 +73,6 @@ const builtInPlugins: Record Promise = ['provisioning', 'kubernetesDashboards']; +export const requiredFeatureToggles: Array = ['kubernetesDashboards']; /** * Checks if all required feature toggles are enabled diff --git a/public/app/features/provisioning/utils/routes.ts b/public/app/features/provisioning/utils/routes.ts index 13106188c6a..57d62d3987f 100644 --- a/public/app/features/provisioning/utils/routes.ts +++ b/public/app/features/provisioning/utils/routes.ts @@ -1,3 +1,4 @@ +import { config } from '@grafana/runtime'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { RouteDescriptor } from 'app/core/navigation/types'; import { DashboardRoutes } from 'app/types/dashboard'; @@ -6,6 +7,11 @@ import { checkRequiredFeatures } from '../GettingStarted/features'; import { CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL, PROVISIONING_URL } from '../constants'; export function getProvisioningRoutes(): RouteDescriptor[] { + const featureToggles = config.featureToggles || {}; + if (!featureToggles.provisioning) { + return []; + } + if (!checkRequiredFeatures()) { return [ { diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 15280602202..ab6e599ea93 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -1,6 +1,9 @@ import { isEmpty } from 'lodash'; -import { BASE_URL as v0alphaBaseURL } from '@grafana/api-clients/rtkq/dashboard/v0alpha1'; +import { + API_GROUP as DASHBOARD_API_GROUP, + BASE_URL as v0alphaBaseURL, +} from '@grafana/api-clients/rtkq/dashboard/v0alpha1'; import { generatedAPI as legacyUserAPI } from '@grafana/api-clients/rtkq/legacy/user'; import { DataFrame, DataFrameView, getDisplayProcessor, SelectableValue, toDataFrame } from '@grafana/data'; import { t } from '@grafana/i18n'; @@ -85,10 +88,11 @@ export class UnifiedSearcher implements GrafanaSearcher { fieldSelector: `metadata.name=${name}`, }) ); - starsIds = - result.data.items?.[0].spec.resource.find( - (info) => info.group === 'dashboard.grafana.app' && info.kind === 'Dashboard' - )?.names || []; + const items = result.data.items; + starsIds = items?.length + ? items[0].spec.resource.find(({ group, kind }) => group === DASHBOARD_API_GROUP && kind === 'Dashboard') + ?.names || [] + : []; } else { starsIds = await dispatch(legacyUserAPI.endpoints.getStars.initiate()).unwrap(); } @@ -331,7 +335,7 @@ export class UnifiedSearcher implements GrafanaSearcher { } if (query.deleted) { - uri = `${getAPIBaseURL('dashboard.grafana.app', 'v1beta1')}/dashboards/?labelSelector=grafana.app/get-trash=true`; + uri = `${getAPIBaseURL(DASHBOARD_API_GROUP, 'v1beta1')}/dashboards/?labelSelector=grafana.app/get-trash=true`; } return uri; } diff --git a/public/app/features/theme-playground/ThemePlayground.tsx b/public/app/features/theme-playground/ThemePlayground.tsx index 85dee240c7f..d331f3932bc 100644 --- a/public/app/features/theme-playground/ThemePlayground.tsx +++ b/public/app/features/theme-playground/ThemePlayground.tsx @@ -2,8 +2,20 @@ import { css } from '@emotion/css'; import { useId, useState } from 'react'; import { createTheme, GrafanaTheme2, NewThemeOptions } from '@grafana/data'; -import { experimentalThemeDefinitions, NewThemeOptionsSchema } from '@grafana/data/internal'; -import { themeJsonSchema } from '@grafana/data/unstable'; +import { NewThemeOptionsSchema } from '@grafana/data/internal'; +import aubergine from '@grafana/data/themes/definitions/aubergine.json'; +import debug from '@grafana/data/themes/definitions/debug.json'; +import desertbloom from '@grafana/data/themes/definitions/desertbloom.json'; +import gildedgrove from '@grafana/data/themes/definitions/gildedgrove.json'; +import gloom from '@grafana/data/themes/definitions/gloom.json'; +import mars from '@grafana/data/themes/definitions/mars.json'; +import matrix from '@grafana/data/themes/definitions/matrix.json'; +import sapphiredusk from '@grafana/data/themes/definitions/sapphiredusk.json'; +import synthwave from '@grafana/data/themes/definitions/synthwave.json'; +import tron from '@grafana/data/themes/definitions/tron.json'; +import victorian from '@grafana/data/themes/definitions/victorian.json'; +import zen from '@grafana/data/themes/definitions/zen.json'; +import themeJsonSchema from '@grafana/data/themes/schema.generated.json'; import { t } from '@grafana/i18n'; import { useChromeHeaderHeight } from '@grafana/runtime'; import { CodeEditor, Combobox, Field, Stack, useStyles2 } from '@grafana/ui'; @@ -34,8 +46,23 @@ const themeMap: Record = { }, }; +const experimentalDefinitions: Record = { + aubergine, + debug, + desertbloom, + gildedgrove, + gloom, + mars, + matrix, + sapphiredusk, + synthwave, + tron, + victorian, + zen, +}; + // Add additional themes -for (const [name, json] of Object.entries(experimentalThemeDefinitions)) { +for (const [name, json] of Object.entries(experimentalDefinitions)) { const result = NewThemeOptionsSchema.safeParse(json); if (!result.success) { console.error(`Invalid theme definition for theme ${name}: ${result.error.message}`); diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index ef56b44c767..1f964e6b664 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -85,6 +85,10 @@ export type GrafanaManagedReceiverConfig = { // SecureSettings?: GrafanaManagedReceiverConfigSettings; settings: GrafanaManagedReceiverConfigSettings; type: string; + /** + * Version of the integration (e.g. "v0" for Mimir legacy, "v1" for Grafana) + */ + version?: string; /** * Name of the _receiver_, which in most cases will be the * same as the contact point's name. This should not be used, and is optional because the @@ -104,7 +108,7 @@ export interface GrafanaManagedContactPoint { /** If parsed from k8s API, we'll have an ID property */ id?: string; metadata?: IoK8SApimachineryPkgApisMetaV1ObjectMeta; - provisioned?: boolean; + provenance?: string; grafana_managed_receiver_configs?: GrafanaManagedReceiverConfig[]; } @@ -144,7 +148,7 @@ export type Route = { provenance?: string; /** this is used to add additional metadata to the routes without interfering with original route definition (symbols aren't iterable) */ [ROUTES_META_SYMBOL]?: { - provisioned?: boolean; + provenance?: string; resourceVersion?: string; name?: string; }; diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.test.tsx b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.test.tsx new file mode 100644 index 00000000000..5b365cb98d6 --- /dev/null +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.test.tsx @@ -0,0 +1,247 @@ +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CoreApp, LoadingState, PanelData } from '@grafana/data'; +import { config, reportInteraction } from '@grafana/runtime'; + +import { AzureQueryType, LogsEditorMode } from '../../dataquery.gen'; +import { selectors } from '../../e2e/selectors'; +import createMockQuery from '../../mocks/query'; +import { AzureMonitorQuery } from '../../types/query'; +import { selectOptionInTest } from '../../utils/testUtils'; + +import { QueryHeader } from './QueryHeader'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), +})); + +describe('Azure Monitor QueryHeader', () => { + const setAzureLogsCheatSheetModalOpen = jest.fn(); + const onRunQuery = jest.fn(); + + const renderComponent = (query: AzureMonitorQuery, props?: Partial>) => { + return render( + + ); + }; + + beforeEach(() => { + config.featureToggles = {}; + }); + + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('renders the service selector', async () => { + const query = createMockQuery(); + + renderComponent(query); + + expect(screen.getByTestId(selectors.components.queryEditor.header.select)).toBeInTheDocument(); + expect(screen.getByLabelText(/Service/i)).toBeInTheDocument(); + }); + + it('changes query type when a new service is selected', async () => { + const query = createMockQuery(); + const onQueryChange = jest.fn(); + + renderComponent(query, { onQueryChange }); + + const serviceSelect = await screen.findByLabelText(/Service/i); + + await selectOptionInTest(serviceSelect, 'Logs'); + + await waitFor(() => { + expect(onQueryChange).toHaveBeenCalled(); + }); + + const lastCall = onQueryChange.mock.calls[onQueryChange.mock.calls.length - 1][0]; + + expect(lastCall).toEqual( + expect.objectContaining({ + queryType: AzureQueryType.LogAnalytics, + }) + ); + }); + + it('initializes logs editor mode to Raw when a raw query exists and builder is enabled', async () => { + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + query: 'SecurityEvent | take 10', + }, + }; + + const onQueryChange = jest.fn(); + + renderComponent(query, { onQueryChange }); + + await waitFor(() => + expect(onQueryChange).toHaveBeenCalledWith( + expect.objectContaining({ + azureLogAnalytics: expect.objectContaining({ + mode: LogsEditorMode.Raw, + }), + }) + ) + ); + }); + + it('renders the logs editor mode radio buttons when builder is enabled', async () => { + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Builder, + }, + }; + + renderComponent(query); + + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + + expect(screen.getByLabelText('Builder')).toBeInTheDocument(); + expect(screen.getByLabelText('KQL')).toBeInTheDocument(); + }); + + it('shows the kick start button when in Logs + Raw mode', async () => { + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Raw, + }, + }; + + renderComponent(query); + + expect(screen.getByRole('button', { name: /Kick start your query/i })).toBeInTheDocument(); + }); + + it('opens the logs cheat sheet modal and reports interaction when kick start button is clicked', async () => { + const user = userEvent.setup(); + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Raw, + }, + }; + + renderComponent(query); + + await user.click(screen.getByRole('button', { name: /Kick start your query/i })); + + expect(setAzureLogsCheatSheetModalOpen).toHaveBeenCalled(); + expect(reportInteraction).toHaveBeenCalledWith( + 'grafana_azure_logs_query_patterns_opened', + expect.objectContaining({ + version: 'v2', + }) + ); + }); + + it('shows confirmation modal when switching from Raw to Builder with existing KQL', async () => { + const user = userEvent.setup(); + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Raw, + query: 'SecurityEvent | take 10', + }, + }; + + renderComponent(query); + + await user.click(screen.getByLabelText('Builder')); + + expect(screen.getByText(/Switch editor mode\?/i)).toBeInTheDocument(); + }); + + it('applies mode change when confirming the switch modal', async () => { + const user = userEvent.setup(); + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Raw, + query: 'SecurityEvent | take 10', + }, + }; + + const onQueryChange = jest.fn(); + + renderComponent(query, { onQueryChange }); + + await user.click(screen.getByLabelText('Builder')); + await user.click(screen.getByText(/Switch to Builder/i)); + + await waitFor(() => + expect(onQueryChange).toHaveBeenCalledWith( + expect.objectContaining({ + azureLogAnalytics: expect.objectContaining({ + mode: LogsEditorMode.Builder, + query: undefined, + }), + }) + ) + ); + }); + + it('renders the Run query button in Builder mode when not in Explore', async () => { + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Builder, + }, + }; + + renderComponent(query, { app: CoreApp.Dashboard }); + + expect(screen.getByTestId(selectors.components.queryEditor.logsQueryEditor.runQuery.button)).toBeInTheDocument(); + }); + + it('disables the Run query button spinner while loading', async () => { + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Builder, + }, + }; + + renderComponent(query, { + app: CoreApp.Dashboard, + data: { state: LoadingState.Loading } as PanelData, + }); + + expect(screen.getByTestId(selectors.components.queryEditor.logsQueryEditor.runQuery.button)).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx index ab7c817c935..7c371b7df11 100644 --- a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx @@ -84,12 +84,9 @@ export const QueryHeader = ({ } const goingToBuilder = newMode === LogsEditorMode.Builder; - const goingToRaw = newMode === LogsEditorMode.Raw; - const hasRawKql = !!query.azureLogAnalytics?.query; - const hasBuilderQuery = !!query.azureLogAnalytics?.builderQuery; - if ((goingToBuilder && hasRawKql) || (goingToRaw && hasBuilderQuery)) { + if (goingToBuilder && hasRawKql) { setPendingModeChange(newMode); setShowModeSwitchWarning(true); } else { @@ -103,7 +100,7 @@ export const QueryHeader = ({ azureLogAnalytics: { ...query.azureLogAnalytics, mode, - query: '', + query: mode === LogsEditorMode.Builder ? undefined : query.azureLogAnalytics?.query, builderQuery: mode === LogsEditorMode.Raw ? undefined : query.azureLogAnalytics?.builderQuery, dashboardTime: mode === LogsEditorMode.Builder ? true : undefined, }, @@ -123,10 +120,7 @@ export const QueryHeader = ({ 'components.query-header.body-switching-to-builder', 'Switching to Builder will discard your current KQL query and clear the KQL editor. Are you sure?' ) - : t( - 'components.query-header.body-switching-to-kql', - 'Switching to KQL will discard your current builder settings. Are you sure?' - ) + : null } confirmText={t('components.query-header.confirmText-switch-to', 'Switch to {{newMode}}', { newMode: pendingModeChange === LogsEditorMode.Builder ? 'Builder' : 'KQL', diff --git a/public/app/plugins/datasource/azuremonitor/locales/en-US/grafana-azure-monitor-datasource.json b/public/app/plugins/datasource/azuremonitor/locales/en-US/grafana-azure-monitor-datasource.json index 47d60ee13f7..cea54779bb1 100644 --- a/public/app/plugins/datasource/azuremonitor/locales/en-US/grafana-azure-monitor-datasource.json +++ b/public/app/plugins/datasource/azuremonitor/locales/en-US/grafana-azure-monitor-datasource.json @@ -204,7 +204,6 @@ "query-header": { "aria-label-kick-start": "Azure logs kick start your query button", "body-switching-to-builder": "Switching to Builder will discard your current KQL query and clear the KQL editor. Are you sure?", - "body-switching-to-kql": "Switching to KQL will discard your current builder settings. Are you sure?", "button-kick-start-your-query": "Kick start your query", "button-run-query": "Run query", "confirmText-switch-to": "Switch to {{newMode}}", diff --git a/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx index d866ad49185..e3ddd378078 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx @@ -1,10 +1,10 @@ import { memo } from 'react'; -import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data'; import { ConnectionConfig } from '@grafana/google-sdk'; import { ConfigSection, DataSourceDescription } from '@grafana/plugin-ui'; -import { reportInteraction, config } from '@grafana/runtime'; -import { Divider, SecureSocksProxySettings } from '@grafana/ui'; +import { config, reportInteraction } from '@grafana/runtime'; +import { Divider, Field, Input, SecureSocksProxySettings, Stack } from '@grafana/ui'; import { CloudMonitoringOptions, CloudMonitoringSecureJsonData } from '../../types/types'; @@ -36,14 +36,33 @@ export const ConfigEditor = memo(({ options, onOptionsChange }: Props) => { - + + + + updateDatasourcePluginJsonDataOption( + { options, onOptionsChange }, + 'universeDomain', + event.currentTarget.value + ) + } + placeholder="googleapis.com" + > + + + )} + ); }); diff --git a/public/app/plugins/datasource/cloud-monitoring/types/types.ts b/public/app/plugins/datasource/cloud-monitoring/types/types.ts index 849f72ea69b..f2e17444fb0 100644 --- a/public/app/plugins/datasource/cloud-monitoring/types/types.ts +++ b/public/app/plugins/datasource/cloud-monitoring/types/types.ts @@ -38,6 +38,7 @@ export interface Aggregation { export interface CloudMonitoringOptions extends DataSourceOptions { gceDefaultProject?: string; enableSecureSocksProxy?: boolean; + universeDomain?: string; } export interface CloudMonitoringSecureJsonData extends DataSourceSecureJsonData {} diff --git a/public/app/plugins/datasource/elasticsearch/CHANGELOG.md b/public/app/plugins/datasource/elasticsearch/CHANGELOG.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx index a9d0ad0710c..110e57e33f2 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx @@ -1,8 +1,7 @@ import { render, screen } from '@testing-library/react'; import { select } from 'react-select-event'; -import { DateHistogram } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { DateHistogram } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { DateHistogramSettingsEditor } from './DateHistogramSettingsEditor'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx index 0dda5b13703..3ca8cb46631 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx @@ -4,9 +4,9 @@ import { GroupBase, OptionsOrGroups } from 'react-select'; import { InternalTimeZones, SelectableValue } from '@grafana/data'; import { InlineField, Input, Select, TimeZonePicker } from '@grafana/ui'; -import { DateHistogram } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; import { calendarIntervals } from '../../../../QueryBuilder'; +import { DateHistogram } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { useCreatableSelectPersistedBehaviour } from '../../../hooks/useCreatableSelectPersistedBehaviour'; import { changeBucketAggregationSetting } from '../state/actions'; @@ -37,11 +37,11 @@ const hasValue = const isValidNewOption = ( inputValue: string, _: SelectableValue | null, - options: OptionsOrGroups> + options: OptionsOrGroups, GroupBase>> ) => { // TODO: would be extremely nice here to allow only template variables and values that are // valid date histogram's Interval options - const valueExists = (options as Array>).some(hasValue(inputValue)); + const valueExists = options.some(hasValue(inputValue)); // we also don't want users to create "empty" values return !valueExists && inputValue.trim().length > 0; }; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx index 6fded5be996..ddfd02b0cc4 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx @@ -3,8 +3,8 @@ import { uniqueId } from 'lodash'; import { useEffect, useRef } from 'react'; import { InlineField, Input, QueryField } from '@grafana/ui'; -import { Filters } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { Filters } from '../../../../../dataquery.gen'; import { useDispatch, useStatelessReducer } from '../../../../../hooks/useStatelessReducer'; import { AddRemove } from '../../../../AddRemove'; import { changeBucketAggregationSetting } from '../../state/actions'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts index e60a664f066..a5e3cb3d172 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts @@ -1,6 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; -import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { Filter } from '../../../../../../dataquery.gen'; export const addFilter = createAction('@bucketAggregations/filter/add'); export const removeFilter = createAction('@bucketAggregations/filter/remove'); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts index 56b5ac9555c..eb03fcc96a3 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts @@ -1,6 +1,5 @@ -import { reducerTester } from 'test/core/redux/reducerTester'; - -import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { Filter } from '../../../../../../dataquery.gen'; +import { reducerTester } from '../../../../../reducerTester'; import { addFilter, changeFilter, removeFilter } from './actions'; import { reducer } from './reducer'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts index 022de8233b4..b99818d1850 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts @@ -1,7 +1,6 @@ import { Action } from 'redux'; -import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { Filter } from '../../../../../../dataquery.gen'; import { defaultFilter } from '../utils'; import { addFilter, changeFilter, removeFilter } from './actions'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts index adf5646381d..3538a497bf4 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts @@ -1,3 +1,3 @@ -import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { Filter } from '../../../../../dataquery.gen'; export const defaultFilter = (): Filter => ({ label: '', query: '*' }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx index 14862e8f664..9012730c8e2 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx @@ -1,14 +1,7 @@ import { fireEvent, screen } from '@testing-library/react'; import selectEvent from 'react-select-event'; -import { - Average, - Derivative, - ElasticsearchDataQuery, - Terms, - TopMetrics, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { Average, Derivative, ElasticsearchDataQuery, Terms, TopMetrics } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { renderWithESProvider } from '../../../../test-helpers/render'; import { describeMetric } from '../../../../utils'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx index e1bc64980ab..852b7d8bb84 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx @@ -2,15 +2,9 @@ import { uniqueId } from 'lodash'; import { useRef } from 'react'; import { SelectableValue } from '@grafana/data'; -import { InlineField, Select, Input } from '@grafana/ui'; -import { - Terms, - ExtendedStats, - ExtendedStatMetaType, - Percentiles, - MetricAggregation, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { InlineField, Input, Select } from '@grafana/ui'; +import { ExtendedStats, MetricAggregation, Percentiles, Terms } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { describeMetric } from '../../../../utils'; import { useQuery } from '../../ElasticsearchQueryContext'; @@ -105,7 +99,7 @@ function createOrderByOptionsForExtendedStats(metric: ExtendedStats): Selectable if (!metric.meta) { return []; } - const metaKeys = Object.keys(metric.meta) as ExtendedStatMetaType[]; + const metaKeys = Object.keys(metric.meta); return metaKeys .filter((key) => metric.meta?.[key]) .map((key) => { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx index 9191ca25d1c..63be91fad00 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx @@ -2,8 +2,8 @@ import { uniqueId } from 'lodash'; import { ComponentProps, useRef } from 'react'; import { InlineField, Input } from '@grafana/ui'; -import { BucketAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { BucketAggregation } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { SettingsEditorContainer } from '../../SettingsEditorContainer'; import { changeBucketAggregationSetting } from '../state/actions'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts index 3e4e5cfea7c..a0f60b799a4 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts @@ -1,7 +1,6 @@ -import { BucketAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { BucketAggregation } from '../../../../dataquery.gen'; import { defaultGeoHashPrecisionString } from '../../../../queryDef'; -import { describeMetric, convertOrderByToMetricId } from '../../../../utils'; +import { convertOrderByToMetricId, describeMetric } from '../../../../utils'; import { useQuery } from '../../ElasticsearchQueryContext'; import { bucketAggregationConfig, orderByOptions, orderOptions } from '../utils'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts index dfab9ac0279..e3dff091246 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts @@ -1,10 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; -import { - BucketAggregation, - BucketAggregationType, - BucketAggregationWithField, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { BucketAggregation, BucketAggregationType, BucketAggregationWithField } from '../../../../dataquery.gen'; export const addBucketAggregation = createAction('@bucketAggs/add'); export const removeBucketAggregation = createAction('@bucketAggs/remove'); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts index f4a5cc02dde..462f5938b81 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts @@ -1,9 +1,4 @@ -import { - BucketAggregation, - DateHistogram, - ElasticsearchDataQuery, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { BucketAggregation, DateHistogram, ElasticsearchDataQuery } from '../../../../dataquery.gen'; import { defaultBucketAgg } from '../../../../queryDef'; import { reducerTester } from '../../../reducerTester'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts index 5ba29e656d8..789405c97be 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts @@ -1,7 +1,6 @@ import { Action } from '@reduxjs/toolkit'; -import { BucketAggregation, ElasticsearchDataQuery, Terms } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { BucketAggregation, ElasticsearchDataQuery, Terms } from '../../../../dataquery.gen'; import { defaultBucketAgg } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; @@ -47,11 +46,12 @@ export const createReducer = } /* - TODO: The previous version of the query editor was keeping some of the old bucket aggregation's configurations - in the new selected one (such as field or some settings). - It the future would be nice to have the same behavior but it's hard without a proper definition, - as Elasticsearch will error sometimes if some settings are not compatible. - */ + TODO: The previous version of the query editor was keeping some of the old bucket aggregation's configurations + in the new selected one (such as field or some settings). + It the future would be nice to have the same behavior but it's hard without a proper definition, + as Elasticsearch will error sometimes if some settings are not compatible. + */ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { id: bucketAgg.id, type: action.payload.newType, diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx index c9d52ecd49d..5939f413163 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx @@ -1,29 +1,26 @@ import { SelectableValue } from '@grafana/data'; import { RadioButtonGroup } from '@grafana/ui'; -import { useDispatch } from '../../hooks/useStatelessReducer'; import { EditorType } from '../../types'; -import { useQuery } from './ElasticsearchQueryContext'; -import { changeEditorTypeAndResetQuery } from './state'; - const BASE_OPTIONS: Array> = [ { value: 'builder', label: 'Builder' }, { value: 'code', label: 'Code' }, ]; -export const EditorTypeSelector = () => { - const query = useQuery(); - const dispatch = useDispatch(); - - // Default to 'builder' if editorType is empty - const editorType: EditorType = query.editorType === 'code' ? 'code' : 'builder'; - - const onChange = (newEditorType: EditorType) => { - dispatch(changeEditorTypeAndResetQuery(newEditorType)); - }; +interface Props { + value: EditorType; + onChange: (editorType: EditorType) => void; +} +export const EditorTypeSelector = ({ value, onChange }: Props) => { return ( - fullWidth={false} options={BASE_OPTIONS} value={editorType} onChange={onChange} /> + + data-testid="elasticsearch-editor-type-toggle" + size="sm" + options={BASE_OPTIONS} + value={value} + onChange={onChange} + /> ); }; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx index 70d53eddbc4..348a8a630ce 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx @@ -2,10 +2,10 @@ import { css } from '@emotion/css'; import { uniqueId } from 'lodash'; import { Fragment, useEffect } from 'react'; -import { Input, InlineLabel } from '@grafana/ui'; -import { BucketScript, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { InlineLabel, Input } from '@grafana/ui'; -import { useStatelessReducer, useDispatch } from '../../../../../hooks/useStatelessReducer'; +import { BucketScript, MetricAggregation } from '../../../../../dataquery.gen'; +import { useDispatch, useStatelessReducer } from '../../../../../hooks/useStatelessReducer'; import { AddRemove } from '../../../../AddRemove'; import { MetricPicker } from '../../../../MetricPicker'; import { changeMetricAttribute } from '../../state/actions'; @@ -13,9 +13,9 @@ import { SettingField } from '../SettingField'; import { addPipelineVariable, + changePipelineVariableMetric, removePipelineVariable, renamePipelineVariable, - changePipelineVariableMetric, } from './state/actions'; import { reducer } from './state/reducer'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts index 02fc628ecc3..276ca285c64 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts @@ -1,5 +1,4 @@ -import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { PipelineVariable } from '../../../../../../dataquery.gen'; import { reducerTester } from '../../../../../reducerTester'; import { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts index 406b1f5b590..8d798a5717b 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts @@ -1,7 +1,6 @@ import { Action } from '@reduxjs/toolkit'; -import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { PipelineVariable } from '../../../../../../dataquery.gen'; import { defaultPipelineVariable, generatePipelineVariableName } from '../utils'; import { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts index e2da781d190..4c3991c69a9 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts @@ -1,4 +1,4 @@ -import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { PipelineVariable } from '../../../../../dataquery.gen'; export const defaultPipelineVariable = (name: string): PipelineVariable => ({ name, pipelineAgg: '' }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx index e4a3ad907a9..588b4692f5e 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx @@ -2,11 +2,8 @@ import { uniqueId } from 'lodash'; import { ComponentProps, useState } from 'react'; import { InlineField, Input, TextArea } from '@grafana/ui'; -import { - MetricAggregationWithSettings, - MetricAggregationWithInlineScript, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { MetricAggregationWithInlineScript, MetricAggregationWithSettings } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { getScriptValue } from '../../../../utils'; import { SettingKeyOf } from '../../../types'; @@ -33,9 +30,11 @@ export function SettingField (object: { value: string }) => object.value === value; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts index 9adff8781b1..b0b52dd39e7 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts @@ -1,7 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; -import { MetricAggregation, MetricAggregationWithSettings } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { MetricAggregation, MetricAggregationWithSettings } from '../../../../dataquery.gen'; import { MetricAggregationWithMeta } from '../../../../types'; export const addMetric = createAction('@metrics/add'); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts index 9dcbaa9f974..38a4e0f05d1 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts @@ -1,10 +1,4 @@ -import { - MetricAggregation, - ElasticsearchDataQuery, - Derivative, - ExtendedStats, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { Derivative, ElasticsearchDataQuery, ExtendedStats, MetricAggregation } from '../../../../dataquery.gen'; import { defaultMetricAgg } from '../../../../queryDef'; import { reducerTester } from '../../../reducerTester'; import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts index c0dab7bd4b1..57acf1e87e5 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts @@ -1,7 +1,6 @@ import { Action } from '@reduxjs/toolkit'; -import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { ElasticsearchDataQuery, MetricAggregation } from '../../../../dataquery.gen'; import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; @@ -57,6 +56,7 @@ export const reducer = ( It the future would be nice to have the same behavior but it's hard without a proper definition, as Elasticsearch will error sometimes if some settings are not compatible. */ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { id: metric.id, type: action.payload.type, diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx index 92a5a8b0b9e..bda3cf85e76 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx @@ -10,9 +10,13 @@ interface Props { onRunQuery: () => void; } +// This offset was chosen by testing to match Prometheus behavior +const EDITOR_HEIGHT_OFFSET = 2; + export function RawQueryEditor({ value, onChange, onRunQuery }: Props) { const styles = useStyles2(getStyles); const editorRef = useRef(null); + const containerRef = useRef(null); const handleEditorDidMount = useCallback( (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => { @@ -22,6 +26,22 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) { editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { onRunQuery(); }); + + // Make the editor resize itself so that the content fits (grows taller when necessary) + // this code comes from the Prometheus query editor. + // We may wish to consider abstracting it into the grafana/ui repo in the future + const updateElementHeight = () => { + const containerDiv = containerRef.current; + if (containerDiv !== null) { + const pixelHeight = editor.getContentHeight(); + containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`; + const pixelWidth = containerDiv.clientWidth; + editor.layout({ width: pixelWidth, height: pixelHeight }); + } + }; + + editor.onDidContentSizeChange(updateElementHeight); + updateElementHeight(); }, [onRunQuery] ); @@ -65,7 +85,17 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) { return ( -
        +
        + +
        +
        -
        - ); } @@ -100,7 +118,11 @@ const getStyles = (theme: GrafanaTheme2) => ({ flexDirection: 'column', gap: theme.spacing(1), }), - header: css({ + editorContainer: css({ + width: '100%', + overflow: 'hidden', + }), + footer: css({ display: 'flex', justifyContent: 'flex-end', padding: theme.spacing(0.5, 0), diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx index b54de44bb63..a3731c3c99a 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx @@ -1,16 +1,16 @@ import { css } from '@emotion/css'; -import { useEffect, useId, useState } from 'react'; +import { useCallback, useEffect, useId, useState } from 'react'; import { SemVer } from 'semver'; import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { Alert, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui'; +import { Alert, ConfirmModal, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui'; import { ElasticsearchDataQuery } from '../../dataquery.gen'; import { ElasticDatasource } from '../../datasource'; import { useNextId } from '../../hooks/useNextId'; import { useDispatch } from '../../hooks/useStatelessReducer'; -import { ElasticsearchOptions } from '../../types'; +import { EditorType, ElasticsearchOptions } from '../../types'; import { isSupportedVersion, isTimeSeriesQuery, unsupportedVersionMessage } from '../../utils'; import { BucketAggregationsEditor } from './BucketAggregationsEditor'; @@ -20,7 +20,7 @@ import { MetricAggregationsEditor } from './MetricAggregationsEditor'; import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; import { QueryTypeSelector } from './QueryTypeSelector'; import { RawQueryEditor } from './RawQueryEditor'; -import { changeAliasPattern, changeQuery, changeRawDSLQuery } from './state'; +import { changeAliasPattern, changeEditorTypeAndResetQuery, changeQuery, changeRawDSLQuery } from './state'; export type ElasticQueryEditorProps = QueryEditorProps; @@ -97,31 +97,61 @@ const QueryEditorForm = ({ value, onRunQuery }: Props & { onRunQuery: () => void const inputId = useId(); const styles = useStyles2(getStyles); + const [switchModalOpen, setSwitchModalOpen] = useState(false); + const [pendingEditorType, setPendingEditorType] = useState(null); + const isTimeSeries = isTimeSeriesQuery(value); const isCodeEditor = value.editorType === 'code'; const rawDSLFeatureEnabled = config.featureToggles.elasticsearchRawDSLQuery; + // Default to 'builder' if editorType is empty + const currentEditorType: EditorType = value.editorType === 'code' ? 'code' : 'builder'; + const showBucketAggregationsEditor = value.metrics?.every( (metric) => metricAggregationConfig[metric.type].impliedQueryType === 'metrics' ); + const onEditorTypeChange = useCallback((newEditorType: EditorType) => { + // Show warning modal when switching modes + setPendingEditorType(newEditorType); + setSwitchModalOpen(true); + }, []); + + const confirmEditorTypeChange = useCallback(() => { + if (pendingEditorType) { + dispatch(changeEditorTypeAndResetQuery(pendingEditorType)); + } + setSwitchModalOpen(false); + setPendingEditorType(null); + }, [dispatch, pendingEditorType]); + + const cancelEditorTypeChange = useCallback(() => { + setSwitchModalOpen(false); + setPendingEditorType(null); + }, []); + return ( <> +
        Query type
        -
        - {rawDSLFeatureEnabled && ( -
        - Editor type -
        - + {rawDSLFeatureEnabled && ( +
        +
        -
        - )} + )} +
        {isCodeEditor && rawDSLFeatureEnabled && ( = (state: S, action: A) => S; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ApiKeyConfig.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ApiKeyConfig.tsx new file mode 100644 index 00000000000..8433160d4f2 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/ApiKeyConfig.tsx @@ -0,0 +1,22 @@ +import { onUpdateDatasourceSecureJsonDataOption, updateDatasourcePluginResetOption } from '@grafana/data'; +import { InlineField, SecretInput } from '@grafana/ui'; + +import { Props } from './ConfigEditor'; + +export const ApiKeyConfig = (props: Props) => { + const { options } = props; + + return ( + + updateDatasourcePluginResetOption(props, 'apiKey')} + onChange={onUpdateDatasourceSecureJsonDataOption(props, 'apiKey')} + /> + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx index 5961ebef510..57e3f8dc92a 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx @@ -14,14 +14,15 @@ import { import { config } from '@grafana/runtime'; import { Alert, SecureSocksProxySettings, Divider, Stack } from '@grafana/ui'; -import { ElasticsearchOptions } from '../types'; +import { ElasticsearchOptions, ElasticsearchSecureJsonData } from '../types'; +import { ApiKeyConfig } from './ApiKeyConfig'; import { DataLinks } from './DataLinks'; import { ElasticDetails } from './ElasticDetails'; import { LogsConfig } from './LogsConfig'; import { coerceOptions, isValidOptions } from './utils'; -export type Props = DataSourcePluginOptionsEditorProps; +export type Props = DataSourcePluginOptionsEditorProps; export const ConfigEditor = (props: Props) => { const { options, onOptionsChange } = props; @@ -48,6 +49,16 @@ export const ConfigEditor = (props: Props) => { authProps.selectedMethod = options.jsonData.sigV4Auth ? 'custom-sigv4' : authProps.selectedMethod; } + authProps.customMethods = [ + { + id: 'custom-api-key', + label: 'API Key', + description: 'API Key authentication', + component: , + }, + ]; + authProps.selectedMethod = options.jsonData.apiKeyAuth ? 'custom-api-key' : authProps.selectedMethod; + return ( <> {options.access === 'direct' && ( @@ -73,6 +84,7 @@ export const ConfigEditor = (props: Props) => { jsonData: { ...options.jsonData, sigV4Auth: method === 'custom-sigv4', + apiKeyAuth: method === 'custom-api-key', oauthPassThru: method === AuthMethod.OAuthForward, }, }); diff --git a/public/app/plugins/datasource/elasticsearch/jest-setup.js b/public/app/plugins/datasource/elasticsearch/jest-setup.js new file mode 100644 index 00000000000..c85bf9d3a57 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/jest-setup.js @@ -0,0 +1 @@ +import '@grafana/plugin-configs/jest/jest-setup'; diff --git a/public/app/plugins/datasource/elasticsearch/jest.config.js b/public/app/plugins/datasource/elasticsearch/jest.config.js new file mode 100644 index 00000000000..fabef448081 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/jest.config.js @@ -0,0 +1,3 @@ +import defaultConfig from '@grafana/plugin-configs/jest/jest.config.js'; + +export default defaultConfig; diff --git a/public/app/plugins/datasource/elasticsearch/package.json b/public/app/plugins/datasource/elasticsearch/package.json new file mode 100644 index 00000000000..bd1470fa2c6 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/package.json @@ -0,0 +1,62 @@ +{ + "name": "@grafana-plugins/elasticsearch", + "description": "Grafana data source for Elasticsearch", + "private": true, + "version": "12.4.0-pre", + "dependencies": { + "@emotion/css": "11.13.5", + "@grafana/aws-sdk": "0.8.3", + "@grafana/data": "12.4.0-pre", + "@grafana/plugin-ui": "^0.11.1", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", + "@reduxjs/toolkit": "2.10.1", + "lodash": "4.17.21", + "lucene": "^2.1.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-redux": "9.2.0", + "react-select": "5.10.2", + "react-use": "17.6.0", + "redux": "5.0.1", + "redux-thunk": "3.1.0", + "rxjs": "7.8.2", + "semver": "7.7.3", + "tslib": "2.8.1" + }, + "devDependencies": { + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "6.6.4", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", + "@types/jest": "29.5.14", + "@types/lodash": "4.17.20", + "@types/lucene": "^2", + "@types/node": "24.10.1", + "@types/react": "18.3.18", + "@types/react-dom": "18.3.5", + "@types/semver": "7.7.1", + "jest": "29.7.0", + "react-select-event": "5.5.1", + "ts-node": "10.9.2", + "typescript": "5.9.2", + "webpack": "5.101.0" + }, + "peerDependencies": { + "@grafana/runtime": "*" + }, + "resolutions": { + "redux": "^5.0.0" + }, + "scripts": { + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", + "test": "jest --watch --onlyChanged", + "test:ci": "jest --maxWorkers 4" + }, + "packageManager": "yarn@4.11.0" +} diff --git a/public/app/plugins/datasource/elasticsearch/plugin.json b/public/app/plugins/datasource/elasticsearch/plugin.json index 0e056ffa447..9440fcfed54 100644 --- a/public/app/plugins/datasource/elasticsearch/plugin.json +++ b/public/app/plugins/datasource/elasticsearch/plugin.json @@ -2,6 +2,7 @@ "type": "datasource", "name": "Elasticsearch", "id": "elasticsearch", + "executable": "gpx_elasticsearch", "category": "logging", "info": { "description": "Open source logging & analytics database", @@ -27,7 +28,8 @@ "name": "Documentation", "url": "https://grafana.com/docs/grafana/latest/datasources/elasticsearch/" } - ] + ], + "version": "%VERSION%" }, "alerting": true, "annotations": true, @@ -36,5 +38,9 @@ "backend": true, "queryOptions": { "minInterval": true + }, + "dependencies": { + "grafanaDependency": ">=11.6.0", + "plugins": [] } } diff --git a/public/app/plugins/datasource/elasticsearch/project.json b/public/app/plugins/datasource/elasticsearch/project.json new file mode 100644 index 00000000000..4247352791d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/project.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "tags": ["scope:plugin", "type:datasource"], + "targets": { + "build": {}, + "dev": {} + } +} diff --git a/public/app/plugins/datasource/elasticsearch/reducers/actions/cleanUp.ts b/public/app/plugins/datasource/elasticsearch/reducers/actions/cleanUp.ts new file mode 100644 index 00000000000..3aa81581e06 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/reducers/actions/cleanUp.ts @@ -0,0 +1,11 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { StoreState } from '../../types/store'; + +export type CleanUpAction = (state: StoreState) => void; + +export interface CleanUpPayload { + cleanupAction: CleanUpAction; +} + +export const cleanUpAction = createAction('core/cleanUpState'); diff --git a/public/app/plugins/datasource/elasticsearch/reducers/root.ts b/public/app/plugins/datasource/elasticsearch/reducers/root.ts new file mode 100644 index 00000000000..5e13826691a --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/reducers/root.ts @@ -0,0 +1,21 @@ +import { ReducersMapObject } from '@reduxjs/toolkit'; +import { Action as AnyAction, combineReducers } from 'redux'; + +const addedReducers = { + defaultReducer: (state = {}) => state, + templating: (state = { lastKey: 'key' }) => state, +}; + +export const addReducer = (newReducers: ReducersMapObject) => { + Object.assign(addedReducers, newReducers); +}; + +export const createRootReducer = () => { + const appReducer = combineReducers({ + ...addedReducers, + }); + + return (state: Parameters[0], action: AnyAction) => { + return appReducer(state, action); + }; +}; diff --git a/public/app/plugins/datasource/elasticsearch/store/configureStore.ts b/public/app/plugins/datasource/elasticsearch/store/configureStore.ts new file mode 100644 index 00000000000..319cccd193d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/store/configureStore.ts @@ -0,0 +1,47 @@ +import { createListenerMiddleware, configureStore as reduxConfigureStore } from '@reduxjs/toolkit'; +import { setupListeners } from '@reduxjs/toolkit/query'; +import { Middleware } from 'redux'; + +import { addReducer, createRootReducer } from '../reducers/root'; +import { StoreState } from '../types/store'; + +import { setStore } from './store'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function addRootReducer(reducers: any) { + // this is ok now because we add reducers before configureStore is called + // in the future if we want to add reducers during runtime + // we'll have to solve this in a more dynamic way + addReducer(reducers); +} + +const listenerMiddleware = createListenerMiddleware(); +const extraMiddleware: Middleware[] = []; + +export function addExtraMiddleware(middleware: Middleware) { + extraMiddleware.push(middleware); +} + +export function configureStore(initialState?: Partial) { + const store = reduxConfigureStore({ + reducer: createRootReducer(), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat( + listenerMiddleware.middleware, + ...extraMiddleware + ), + devTools: process.env.NODE_ENV !== 'production', + preloadedState: { + ...initialState, + }, + }); + + // this enables "refetchOnFocus" and "refetchOnReconnect" for RTK Query + setupListeners(store.dispatch); + + setStore(store); + return store; +} + +export type RootState = ReturnType['getState']>; +export type AppDispatch = ReturnType['dispatch']; diff --git a/public/app/plugins/datasource/elasticsearch/store/store.ts b/public/app/plugins/datasource/elasticsearch/store/store.ts new file mode 100644 index 00000000000..aaccaca84f5 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/store/store.ts @@ -0,0 +1,26 @@ +import { Store } from 'redux'; + +import { StoreState } from '../types/store'; + +export let store: Store; + +export function setStore(newStore: Store) { + store = newStore; +} + +export function getState(): StoreState { + if (!store || !store.getState) { + return { defaultReducer: () => ({}), templating: { lastKey: 'key' } }; // used by tests + } + + return store.getState(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function dispatch(action: any) { + if (!store || !store.getState) { + return; + } + + return store.dispatch(action); +} diff --git a/public/app/plugins/datasource/elasticsearch/tsconfig.json b/public/app/plugins/datasource/elasticsearch/tsconfig.json new file mode 100644 index 00000000000..40352099203 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "types": ["node", "jest", "@testing-library/jest-dom"] + }, + "extends": "@grafana/plugin-configs/tsconfig.json", + "include": ["."] +} diff --git a/public/app/plugins/datasource/elasticsearch/types.ts b/public/app/plugins/datasource/elasticsearch/types.ts index 4645a2a824f..d435f1b4594 100644 --- a/public/app/plugins/datasource/elasticsearch/types.ts +++ b/public/app/plugins/datasource/elasticsearch/types.ts @@ -64,6 +64,11 @@ export interface ElasticsearchOptions extends DataSourceJsonData { sigV4Auth?: boolean; oauthPassThru?: boolean; defaultQueryMode?: QueryType; + apiKeyAuth?: boolean; +} + +export interface ElasticsearchSecureJsonData { + apiKey?: string; } export type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document'; diff --git a/public/app/plugins/datasource/elasticsearch/types/store.ts b/public/app/plugins/datasource/elasticsearch/types/store.ts new file mode 100644 index 00000000000..1ff65f1a7af --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/types/store.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-restricted-imports */ +import { + Action, + addListener as addListenerUntyped, + AsyncThunk, + AsyncThunkOptions, + AsyncThunkPayloadCreator, + createAsyncThunk as createAsyncThunkUntyped, + PayloadAction, + TypedAddListener, +} from '@reduxjs/toolkit'; +import { + TypedUseSelectorHook, + useDispatch as useDispatchUntyped, + useSelector as useSelectorUntyped, +} from 'react-redux'; +import { ThunkDispatch as GenericThunkDispatch, ThunkAction } from 'redux-thunk'; + +import type { createRootReducer } from '../reducers/root'; +import { AppDispatch, RootState } from '../store/configureStore'; +import { dispatch as storeDispatch } from '../store/store'; + +export type StoreState = ReturnType>; + +/* + * Utility type to get strongly types thunks + */ +export type ThunkResult = ThunkAction>; + +export type ThunkDispatch = GenericThunkDispatch; + +// Typed useDispatch & useSelector hooks +export const useDispatch: () => AppDispatch = useDispatchUntyped; +export const useSelector: TypedUseSelectorHook = useSelectorUntyped; + +type DefaultThunkApiConfig = { dispatch: AppDispatch; state: StoreState }; +export const createAsyncThunk = ( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator, + options?: AsyncThunkOptions +): AsyncThunk => + createAsyncThunkUntyped(typePrefix, payloadCreator, options); + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +export const addListener = addListenerUntyped as TypedAddListener; +export const dispatch: AppDispatch = storeDispatch; diff --git a/public/app/plugins/datasource/elasticsearch/webpack.config.ts b/public/app/plugins/datasource/elasticsearch/webpack.config.ts new file mode 100644 index 00000000000..f64bb95e3c0 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/webpack.config.ts @@ -0,0 +1,9 @@ +import type { Configuration } from 'webpack'; + +import grafanaConfig, { type Env } from '@grafana/plugin-configs/webpack.config.ts'; + +const config = async (env: Env): Promise => { + return await grafanaConfig(env); +}; + +export default config; diff --git a/public/app/plugins/panel/datagrid/plugin.json b/public/app/plugins/panel/datagrid/plugin.json index 72d430b0b87..4baeb456ea1 100644 --- a/public/app/plugins/panel/datagrid/plugin.json +++ b/public/app/plugins/panel/datagrid/plugin.json @@ -2,7 +2,7 @@ "type": "panel", "name": "Datagrid", "id": "datagrid", - "state": "beta", + "state": "deprecated", "info": { "author": { diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index 8b32e651f84..3b952381554 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -85,9 +85,6 @@ export function RadialBarPanel({ }); } - const minVizHeight = 60; - const minVizWidth = 60; - if (getValues()[0]?.display?.text === 'No data') { return ; } @@ -104,8 +101,8 @@ export function RadialBarPanel({ itemSpacing={16} renderCounter={renderCounter} orientation={options.orientation} - minVizHeight={minVizHeight} - minVizWidth={minVizWidth} + minVizHeight={options.sizing === 'auto' ? 0 : options.minVizHeight} + minVizWidth={options.sizing === 'auto' ? 0 : options.minVizWidth} getAlignmentFactors={getDisplayValueAlignmentFactors} /> diff --git a/public/app/plugins/panel/radialbar/module.tsx b/public/app/plugins/panel/radialbar/module.tsx index f7afa0ef2c9..b40eb2c594b 100644 --- a/public/app/plugins/panel/radialbar/module.tsx +++ b/public/app/plugins/panel/radialbar/module.tsx @@ -1,5 +1,6 @@ import { PanelPlugin } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { BarGaugeSizing, VizOrientation } from '@grafana/schema'; import { commonOptionsBuilder } from '@grafana/ui'; import { addOrientationOption, addStandardDataReduceOptions } from '../stat/common'; @@ -16,7 +17,7 @@ export const plugin = new PanelPlugin(RadialBarPanel) const category = [t('gauge.category-radial-bar', 'Gauge')]; addStandardDataReduceOptions(builder); - addOrientationOption(builder, category); + commonOptionsBuilder.addTextSizeOptions(builder, { withTitle: true, withValue: true }); builder.addRadio({ @@ -32,6 +33,51 @@ export const plugin = new PanelPlugin(RadialBarPanel) }, }); + addOrientationOption(builder, category); + + builder + .addRadio({ + path: 'sizing', + name: t('gauge.name-gauge-size', 'Gauge size'), + settings: { + options: [ + { value: BarGaugeSizing.Auto, label: t('gauge.gauge-size-options.label-auto', 'Auto') }, + { value: BarGaugeSizing.Manual, label: t('gauge.gauge-size-options.label-manual', 'Manual') }, + ], + }, + category, + defaultValue: defaultOptions.sizing, + showIf: (options: Options) => options.orientation !== VizOrientation.Auto, + }) + .addSliderInput({ + path: 'minVizWidth', + name: t('gauge.name-min-width', 'Min width'), + description: t('gauge.description-min-width', 'Minimum column width (vertical orientation)'), + defaultValue: defaultOptions.minVizWidth, + settings: { + min: 0, + max: 600, + step: 1, + }, + category, + showIf: (options: Options) => + options.sizing === BarGaugeSizing.Manual && options.orientation === VizOrientation.Vertical, + }) + .addSliderInput({ + path: 'minVizHeight', + name: t('gauge.name-min-height', 'Min height'), + description: t('gauge.description-min-height', 'Minimum row height (horizontal orientation)'), + defaultValue: defaultOptions.minVizHeight, + category, + settings: { + min: 0, + max: 600, + step: 1, + }, + showIf: (options: Options) => + options.sizing === BarGaugeSizing.Manual && options.orientation === VizOrientation.Horizontal, + }); + builder.addSliderInput({ path: 'barWidthFactor', name: t('radialbar.config.bar-width', 'Bar width'), diff --git a/public/app/plugins/panel/radialbar/panelcfg.cue b/public/app/plugins/panel/radialbar/panelcfg.cue index 6e5fd7eec21..01e2b5e3b98 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.cue +++ b/public/app/plugins/panel/radialbar/panelcfg.cue @@ -44,6 +44,9 @@ composableKinds: PanelCfg: { endpointMarker?: "point" | "glow" | "none" | *"point" textMode?: "auto" | "value_and_name" | "value" | "name" | "none" | *"auto" effects: GaugePanelEffects | *{} + sizing: common.BarGaugeSizing & (*"auto" | _) + minVizWidth: uint32 | *75 + minVizHeight: uint32 | *75 } @cuetsy(kind="interface") } }] diff --git a/public/app/plugins/panel/radialbar/panelcfg.gen.ts b/public/app/plugins/panel/radialbar/panelcfg.gen.ts index 594747136ef..58b17cbd293 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.gen.ts +++ b/public/app/plugins/panel/radialbar/panelcfg.gen.ts @@ -27,11 +27,14 @@ export interface Options extends common.SingleStatBaseOptions { barWidthFactor: number; effects: GaugePanelEffects; endpointMarker?: ('point' | 'glow' | 'none'); + minVizHeight: number; + minVizWidth: number; segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); showThresholdLabels: boolean; showThresholdMarkers: boolean; + sizing: common.BarGaugeSizing; sparkline?: boolean; textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none'); } @@ -41,11 +44,14 @@ export const defaultOptions: Partial = { barWidthFactor: 0.5, effects: {}, endpointMarker: 'point', + minVizHeight: 75, + minVizWidth: 75, segmentCount: 1, segmentSpacing: 0.3, shape: 'gauge', showThresholdLabels: false, showThresholdMarkers: true, + sizing: common.BarGaugeSizing.Auto, sparkline: true, textMode: 'auto', }; diff --git a/public/img/plugins/db2.svg b/public/img/plugins/db2.svg new file mode 100644 index 00000000000..950f0a73e32 --- /dev/null +++ b/public/img/plugins/db2.svg @@ -0,0 +1,937 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 52d56bb948c..4e8a700bc26 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -11906,7 +11906,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Odstranit", "confirm-delete-keep-resources": "Opravdu chcete odstranit konfiguraci úložiště, ale ponechat jeho zdroje?", "confirm-delete-with-resources": "Opravdu chcete odstranit konfiguraci úložiště a všechny jeho zdroje?", @@ -12174,6 +12220,7 @@ "jobs": "Práce" }, "repository-actions": { + "connections": "", "settings": "Nastavení", "source-code": "Zdrojový kód" }, diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 616960f15e7..1613801e0af 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -11806,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Löschen", "confirm-delete-keep-resources": "Sind Sie sicher, dass Sie die Repository-Konfiguration löschen, aber ihre Ressourcen behalten möchten?", "confirm-delete-with-resources": "Sind Sie sicher, dass Sie die Repository-Konfiguration und alle ihre Ressourcen löschen möchten?", @@ -12070,6 +12116,7 @@ "jobs": "Aufträge" }, "repository-actions": { + "connections": "", "settings": "Einstellungen", "source-code": "Quellcode" }, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4b99a5811e7..c0f6224e69b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -807,7 +807,8 @@ "label-integration": "Integration", "label-notification-settings": "Notification settings", "label-section": "Optional {{name}} settings", - "test": "Test" + "test": "Test", + "tooltip-legacy-version": "This is a legacy integration (version: {{version}}). It cannot be modified." }, "classic-condition-viewer": { "of": "OF", @@ -2176,11 +2177,14 @@ "provisioning": { "badge-tooltip-provenance": "This resource has been provisioned via {{provenance}} and cannot be edited through the UI", "badge-tooltip-standard": "This resource has been provisioned and cannot be edited through the UI", + "body-imported": "This contact point contains integrations that were imported from an external Alertmanager and is currently read-only. The integrations will become editable after the migration process is complete.", "body-provisioned": "This {{resource}} has been provisioned, that means it was created by config. Please contact your server admin to update this {{resource}}.", + "title-imported": "This contact point was imported and cannot be edited through the UI", "title-provisioned": "This {{resource}} cannot be edited through the UI" }, "provisioning-badge": { "badge": { + "text-converted-prometheus": "Imported", "text-provisioned": "Provisioned" } }, @@ -2677,8 +2681,7 @@ }, "saved-searches": { "actions-aria-label": "Actions", - "apply-aria-label": "Apply search \"{{name}}\"", - "apply-tooltip": "Apply this search", + "apply-tooltip": "Apply search \"{{name}}\"", "button-label": "Saved searches", "cancel": "Cancel", "default-indicator": "Default search", @@ -6380,12 +6383,15 @@ }, "resource-export": { "label": { + "advanced-options": "Advanced options", "classic": "Classic", "json": "JSON", "v1-resource": "V1 Resource", "v2-resource": "V2 Resource", "yaml": "YAML" - } + }, + "share-externally": "Share dashboard with another instance", + "share-externally-tooltip": "Removes all instance-specific metadata and data source references from the resource before export." }, "revert-dashboard-modal": { "body-restore-version": "Are you sure you want to restore the dashboard to version {{version}}? All unsaved changes will be lost.", @@ -6980,6 +6986,7 @@ "drone-datasource": "Drone datasource", "git-lab-integration-and-datasource": "GitLab integration and datasource", "honeycomb-integration-and-datasource": "Honeycomb integration and datasource", + "ibmdb2-datasource": "IBM Db2 data source", "jira-integration-and-datasource": "Jira integration and datasource", "logic-monitor-devices-datasource": "LogicMonitor Devices datasource", "mongo-db-integration-and-data-source": "MongoDB integration and data source", @@ -7838,7 +7845,6 @@ "export-externally-label": "Export the dashboard to use in another instance", "export-format": "Format", "export-mode": "Model", - "export-remove-ds-refs": "Remove deployment details", "info-text": "Copy or download a file containing the definition of your dashboard", "title": "Export dashboard" }, diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index e4a00ce24fe..e94cd155216 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -11806,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Eliminar", "confirm-delete-keep-resources": "¿Seguro que quieres eliminar la configuración del repositorio pero conservar sus recursos?", "confirm-delete-with-resources": "¿Seguro que quieres eliminar la configuración del repositorio y todos sus recursos?", @@ -12070,6 +12116,7 @@ "jobs": "Trabajos" }, "repository-actions": { + "connections": "", "settings": "Configuración", "source-code": "Código fuente" }, diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 7d1ad65f31a..0954b9d56b2 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -11806,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Supprimer", "confirm-delete-keep-resources": "Voulez-vous vraiment supprimer la configuration du référentiel tout en conservant ses ressources ?", "confirm-delete-with-resources": "Voulez-vous vraiment supprimer la configuration du référentiel ainsi que toutes ses ressources ?", @@ -12070,6 +12116,7 @@ "jobs": "Missions" }, "repository-actions": { + "connections": "", "settings": "Paramètres", "source-code": "Code source" }, diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 88e927039ac..deb86e3a541 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -11806,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Törlés", "confirm-delete-keep-resources": "Biztosan törli az adattár konfigurációját, és megtartja az erőforrásait?", "confirm-delete-with-resources": "Biztosan törli az adattár konfigurációját és az összes erőforrását?", @@ -12070,6 +12116,7 @@ "jobs": "Feladatok" }, "repository-actions": { + "connections": "", "settings": "Beállítások", "source-code": "Forráskód" }, diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index df8ee95d6aa..d0aea63ab48 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -11756,7 +11756,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Hapus", "confirm-delete-keep-resources": "Anda yakin ingin menghapus konfigurasi repositori, tetapi menyimpan sumber dayanya?", "confirm-delete-with-resources": "Anda yakin ingin menghapus konfigurasi repositori dan semua sumber dayanya?", @@ -12018,6 +12064,7 @@ "jobs": "Pekerjaan" }, "repository-actions": { + "connections": "", "settings": "Pengaturan", "source-code": "Kode sumber" }, diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 554727b9577..8b92ef5753d 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -11806,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Elimina", "confirm-delete-keep-resources": "Vuoi davvero eliminare la configurazione del repository ma conservarne le risorse?", "confirm-delete-with-resources": "Vuoi davvero eliminare la configurazione del repository e tutte le sue risorse?", @@ -12070,6 +12116,7 @@ "jobs": "Attività" }, "repository-actions": { + "connections": "", "settings": "Impostazioni", "source-code": "Codice sorgente" }, diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 16cd9eb957c..376bd220001 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -11756,7 +11756,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "削除", "confirm-delete-keep-resources": "リポジトリ設定を削除するものの、そのリソースを保持してもよろしいですか?", "confirm-delete-with-resources": "リポジトリ設定とそのすべてのリソースを削除してもよろしいですか?", @@ -12018,6 +12064,7 @@ "jobs": "ジョブ" }, "repository-actions": { + "connections": "", "settings": "設定", "source-code": "ソースコード" }, diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 2ea09fb8471..836406edf6a 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -11756,7 +11756,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "삭제", "confirm-delete-keep-resources": "정말 리포지토리 구성만 삭제하고 해당 리소스는 그대로 유지하시겠어요?", "confirm-delete-with-resources": "정말 리포지토리 구성과 해당하는 모든 리소스를 삭제하시겠어요?", @@ -12018,6 +12064,7 @@ "jobs": "작업" }, "repository-actions": { + "connections": "", "settings": "설정", "source-code": "소스 코드" }, diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index 7cf226932bc..aa02d1f7445 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -11806,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Verwijderen", "confirm-delete-keep-resources": "Weet je zeker dat je de repository-configuratie wilt verwijderen, maar de bronnen wilt behouden?", "confirm-delete-with-resources": "Weet je zeker dat je de repository-configuratie en alle bronnen wilt verwijderen?", @@ -12070,6 +12116,7 @@ "jobs": "Taken" }, "repository-actions": { + "connections": "", "settings": "Instellingen", "source-code": "Broncode" }, diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 4cf8c601bfc..715da5ac969 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -11906,7 +11906,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Usuń", "confirm-delete-keep-resources": "Na pewno chcesz usunąć konfigurację repozytorium, ale zachować jego zasoby?", "confirm-delete-with-resources": "Na pewno chcesz usunąć konfigurację repozytorium i wszystkie jego zasoby?", @@ -12174,6 +12220,7 @@ "jobs": "Zadania" }, "repository-actions": { + "connections": "", "settings": "Ustawienia", "source-code": "Kod źródłowy" }, diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index aeb840530ca..c91b4cc89cc 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -11806,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Excluir", "confirm-delete-keep-resources": "Tem certeza de que deseja excluir a configuração do repositório, mas manter seus recursos?", "confirm-delete-with-resources": "Tem certeza de que deseja excluir a configuração do repositório e todos os recursos dele?", @@ -12070,6 +12116,7 @@ "jobs": "Tarefas" }, "repository-actions": { + "connections": "", "settings": "Configurações", "source-code": "Código fonte" }, diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 2a4c53052d8..42eec6cc55f 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -11806,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Eliminar", "confirm-delete-keep-resources": "Tem a certeza de que pretende eliminar a configuração do repositório, mas manter os seus recursos?", "confirm-delete-with-resources": "Tem a certeza de que pretende eliminar a configuração do repositório e todos os seus recursos?", @@ -12070,6 +12116,7 @@ "jobs": "Trabalhos" }, "repository-actions": { + "connections": "", "settings": "Definições", "source-code": "Código-fonte" }, diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 571918fa148..c3f5952cb21 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -11906,7 +11906,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Удалить", "confirm-delete-keep-resources": "Вы уверены, что хотите удалить конфигурацию репозитория, но сохранить его ресурсы?", "confirm-delete-with-resources": "Вы уверены, что хотите удалить конфигурацию репозитория и все его ресурсы?", @@ -12174,6 +12220,7 @@ "jobs": "Задания" }, "repository-actions": { + "connections": "", "settings": "Параметры", "source-code": "Исходный код" }, diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 27b318f57e3..aaa8f3197c1 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -11806,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Ta bort", "confirm-delete-keep-resources": "Är du säker på att du vill radera lagringsplatskonfigurationen men behålla dess resurser?", "confirm-delete-with-resources": "Är du säker på att du vill radera lagringsplatskonfigurationen och alla dess resurser?", @@ -12070,6 +12116,7 @@ "jobs": "Jobb" }, "repository-actions": { + "connections": "", "settings": "Inställningar", "source-code": "Källkod" }, diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 59d394e89a2..11425218c88 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -11806,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Sil", "confirm-delete-keep-resources": "", "confirm-delete-with-resources": "", @@ -12070,6 +12116,7 @@ "jobs": "İşler" }, "repository-actions": { + "connections": "", "settings": "Ayarlar", "source-code": "Kaynak kodu" }, diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 2a3c4b999d7..4a2a27e1ef4 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -11756,7 +11756,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "删除", "confirm-delete-keep-resources": "您确定要删除存储库配置但保留其资源吗?", "confirm-delete-with-resources": "您确定要删除存储库配置及其所有资源吗?", @@ -12018,6 +12064,7 @@ "jobs": "作业" }, "repository-actions": { + "connections": "", "settings": "设置", "source-code": "源代码" }, diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 246ad78138e..331313dccce 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -11756,7 +11756,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "刪除", "confirm-delete-keep-resources": "確定要刪除儲存庫設定,但保留其資源嗎?", "confirm-delete-with-resources": "確定要刪除儲存庫設定及其所有資源嗎?", @@ -12018,6 +12064,7 @@ "jobs": "作業" }, "repository-actions": { + "connections": "", "settings": "設定", "source-code": "原始碼" }, diff --git a/public/openapi3.json b/public/openapi3.json index 4a7c5f6be08..8dac2bbc044 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -18377,6 +18377,8 @@ }, "/dashboards/uid/{uid}/restore": { "post": { + "deprecated": true, + "description": "This API will be removed when /apis/dashboards.grafana.app/v1 is released.\nYou can restore a dashboard by reading it from history, then creating it again.", "operationId": "restoreDashboardVersionByUID", "parameters": [ { diff --git a/scripts/validate-npm-packages.sh b/scripts/validate-npm-packages.sh index a3e07c4d7be..80e36b968d1 100755 --- a/scripts/validate-npm-packages.sh +++ b/scripts/validate-npm-packages.sh @@ -11,6 +11,7 @@ failed_checks=() for file in "$ARTIFACTS_DIR"/*.tgz; do echo "🔍 Checking NPM package: $file" + # If you need to debug ATTW issues, pass "--format json" to get verbose output. if ! NODE_OPTIONS="-C @grafana-app/source" yarn attw "$file" --ignore-rules "false-cjs" --profile "node16"; then echo "attw check failed for $file" echo "" diff --git a/yarn.lock b/yarn.lock index 41cc10d8353..5559a4bed77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2610,6 +2610,53 @@ __metadata: languageName: node linkType: hard +"@grafana-plugins/elasticsearch@workspace:public/app/plugins/datasource/elasticsearch": + version: 0.0.0-use.local + resolution: "@grafana-plugins/elasticsearch@workspace:public/app/plugins/datasource/elasticsearch" + dependencies: + "@emotion/css": "npm:11.13.5" + "@grafana/aws-sdk": "npm:0.8.3" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" + "@grafana/plugin-ui": "npm:^0.11.1" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" + "@reduxjs/toolkit": "npm:2.10.1" + "@testing-library/dom": "npm:10.4.1" + "@testing-library/jest-dom": "npm:6.6.4" + "@testing-library/react": "npm:16.3.0" + "@testing-library/user-event": "npm:14.6.1" + "@types/jest": "npm:29.5.14" + "@types/lodash": "npm:4.17.20" + "@types/lucene": "npm:^2" + "@types/node": "npm:24.10.1" + "@types/react": "npm:18.3.18" + "@types/react-dom": "npm:18.3.5" + "@types/semver": "npm:7.7.1" + jest: "npm:29.7.0" + lodash: "npm:4.17.21" + lucene: "npm:^2.1.1" + react: "npm:18.3.1" + react-dom: "npm:18.3.1" + react-redux: "npm:9.2.0" + react-select: "npm:5.10.2" + react-select-event: "npm:5.5.1" + react-use: "npm:17.6.0" + redux: "npm:5.0.1" + redux-thunk: "npm:3.1.0" + rxjs: "npm:7.8.2" + semver: "npm:7.7.3" + ts-node: "npm:10.9.2" + tslib: "npm:2.8.1" + typescript: "npm:5.9.2" + webpack: "npm:5.101.0" + peerDependencies: + "@grafana/runtime": "*" + languageName: unknown + linkType: soft + "@grafana-plugins/grafana-azure-monitor-datasource@workspace:public/app/plugins/datasource/azuremonitor": version: 0.0.0-use.local resolution: "@grafana-plugins/grafana-azure-monitor-datasource@workspace:public/app/plugins/datasource/azuremonitor" @@ -3324,6 +3371,7 @@ __metadata: react-use: "npm:17.6.0" rimraf: "npm:6.0.1" rollup: "npm:^4.22.4" + rollup-plugin-copy: "npm:3.5.0" rollup-plugin-esbuild: "npm:6.2.1" rollup-plugin-node-externals: "npm:^8.0.0" rxjs: "npm:7.8.2" @@ -3789,11 +3837,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:v6.52.1": - version: 6.52.1 - resolution: "@grafana/scenes-react@npm:6.52.1" +"@grafana/scenes-react@npm:6.52.2": + version: 6.52.2 + resolution: "@grafana/scenes-react@npm:6.52.2" dependencies: - "@grafana/scenes": "npm:6.52.1" + "@grafana/scenes": "npm:6.52.2" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3805,7 +3853,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/2f7c6ca8e26befd331808afb0cb934e2991e889a4de78be1122c536219676261c59c6204510761a1d4250fd44a3767818f0f225d23b2e7243cfc17baf8ca6ca3 + checksum: 10/c393faf6612e78254dab79b15cc970448d74ba9784ccda623953c5dbc21d91a8da94b7ad7d0d294eac51314cc193c419a7cb48295fd50b1f9c4472699669eb3e languageName: node linkType: hard @@ -3835,9 +3883,9 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:6.52.1, @grafana/scenes@npm:v6.52.1": - version: 6.52.1 - resolution: "@grafana/scenes@npm:6.52.1" +"@grafana/scenes@npm:6.52.2": + version: 6.52.2 + resolution: "@grafana/scenes@npm:6.52.2" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3857,7 +3905,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/d6172b51121e03c7dcbf30046772f99fc45922c1f7b360a7c3d2c0391300e378f306cb78251dda3b30895679379c38db30e4d52fee67a56cd95f18f38aadf3fb + checksum: 10/f6dbe20db78bb1aa09cc38025534917887713d73119a172febb44700837ed859363ee0436b5f4bda6bc063f9432115e32519ab4c8da7834cf1fc22d43fea7711 languageName: node linkType: hard @@ -19790,8 +19838,8 @@ __metadata: "@grafana/plugin-ui": "npm:^0.11.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:v6.52.1" - "@grafana/scenes-react": "npm:v6.52.1" + "@grafana/scenes": "npm:6.52.2" + "@grafana/scenes-react": "npm:6.52.2" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*"