Compare commits

..

3 Commits

Author SHA1 Message Date
Piotr Jamróz c558072c0c Update tests 2026-01-14 14:05:12 +01:00
Piotr Jamróz 2ce89f099f Merge branch 'main' into ifrost/track-local-storage-errors 2026-01-14 13:29:35 +01:00
Piotr Jamróz 829022d488 add save retries and track stats 2026-01-14 12:53:06 +01:00
112 changed files with 367 additions and 2875 deletions
-2
View File
@@ -121,8 +121,6 @@ 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
@@ -800,8 +800,6 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -804,8 +804,6 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -241,8 +241,6 @@ lineage: schemas: [{
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} @cuetsy(kind="interface")
// Options to config when to refresh a variable
@@ -241,8 +241,6 @@ lineage: schemas: [{
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} @cuetsy(kind="interface")
// Options to config when to refresh a variable
@@ -804,8 +804,6 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -1426,8 +1426,6 @@ type DashboardVariableOption struct {
Text DashboardStringOrArrayOfString `json:"text"`
// Value of the option
Value DashboardStringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewDashboardVariableOption creates a new DashboardVariableOption object.
@@ -5133,22 +5133,6 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardVariableOption(ref common.Refer
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrArrayOfString"),
},
},
"properties": {
SchemaProps: spec.SchemaProps{
Description: "Additional properties for multi-props variables",
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
},
Required: []string{"text", "value"},
},
@@ -808,8 +808,6 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -1429,8 +1429,6 @@ type DashboardVariableOption struct {
Text DashboardStringOrArrayOfString `json:"text"`
// Value of the option
Value DashboardStringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewDashboardVariableOption creates a new DashboardVariableOption object.
@@ -5196,22 +5196,6 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardVariableOption(ref common.Refere
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStringOrArrayOfString"),
},
},
"properties": {
SchemaProps: spec.SchemaProps{
Description: "Additional properties for multi-props variables",
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
},
Required: []string{"text", "value"},
},
File diff suppressed because one or more lines are too long
@@ -103,11 +103,10 @@ 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. |
| **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`. |
| 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. |
### Provision the data source
@@ -130,7 +129,6 @@ datasources:
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
authenticationType: jwt
defaultProject: my-project-name
universeDomain: googleapis.com
secureJsonData:
privateKey: |
-----BEGIN PRIVATE KEY-----
@@ -154,7 +152,6 @@ datasources:
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
authenticationType: jwt
defaultProject: my-project-name
universeDomain: googleapis.com
privateKeyPath: /etc/secrets/gce.pem
```
@@ -169,7 +166,6 @@ datasources:
access: proxy
jsonData:
authenticationType: gce
universeDomain: googleapis.com
```
## Import pre-configured dashboards
@@ -87,7 +87,6 @@ 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)
+25
View File
@@ -3743,21 +3743,46 @@
"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
-1
View File
@@ -82,7 +82,6 @@ module.exports = {
// Decoupled plugins run their own tests so ignoring them here.
'<rootDir>/public/app/plugins/datasource/azuremonitor',
'<rootDir>/public/app/plugins/datasource/cloud-monitoring',
'<rootDir>/public/app/plugins/datasource/elasticsearch',
'<rootDir>/public/app/plugins/datasource/grafana-postgresql-datasource',
'<rootDir>/public/app/plugins/datasource/grafana-pyroscope-datasource',
'<rootDir>/public/app/plugins/datasource/grafana-testdata-datasource',
-2
View File
@@ -237,8 +237,6 @@ lineage: schemas: [{
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} @cuetsy(kind="interface")
// Options to config when to refresh a variable
@@ -91,8 +91,6 @@ export interface VariableOption {
text: string | string[];
value: string | string[];
isNone?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
properties?: Record<string, any>;
}
export interface IntervalVariableModel extends VariableWithOptions {
@@ -120,7 +118,6 @@ export interface QueryVariableModel extends VariableWithMultiSupport {
definition: string;
sort: VariableSort;
queryValue?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
query: any;
regex: string;
regexApplyTo?: VariableRegexApplyTo;
@@ -196,7 +193,6 @@ export interface BaseVariableModel {
skipUrlSync: boolean;
index: number;
state: LoadingState;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any | null;
description: string | null;
usedInRepeat?: boolean;
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
export const pluginVersion = "%VERSION%";
export const pluginVersion = "12.4.0-pre";
export type BucketAggregation = (DateHistogram | Histogram | Terms | Filters | GeoHashGrid | Nested);
@@ -231,10 +231,6 @@ export const defaultVariableModel: Partial<VariableModel> = {
* Option to be selected in a variable.
*/
export interface VariableOption {
/**
* Additional properties for multi-props variables
*/
properties?: Record<string, string>;
/**
* Whether the option is selected or not
*/
@@ -715,9 +715,7 @@ VariableOption: {
// Text to be displayed for the option
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
value: string | [...string]
}
// Query variable specification
-2
View File
@@ -903,8 +903,6 @@ type VariableOption struct {
Text StringOrArrayOfString `json:"text"`
// Value of the option
Value StringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewVariableOption creates a new VariableOption object.
@@ -24,24 +24,6 @@ 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)
@@ -53,8 +35,9 @@ func TestIntegrationElasticsearch(t *testing.T) {
ctx := context.Background()
var outgoingRequest *http.Request
outgoingServer := httptest.NewServer(mockElasticsearchHandler(func(r *http.Request) {
outgoingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
outgoingRequest = r
w.WriteHeader(http.StatusUnauthorized)
}))
t.Cleanup(outgoingServer.Close)
@@ -639,7 +639,7 @@
]
},
"dependencies": {
"grafanaDependency": "\u003e=11.6.0",
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": [],
"extensions": {
@@ -3912,13 +3912,6 @@
"value"
],
"properties": {
"properties": {
"description": "Additional properties for multi-props variables",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"selected": {
"description": "Whether the option is selected or not",
"type": "boolean"
@@ -3939,13 +3939,6 @@
"value"
],
"properties": {
"properties": {
"description": "Additional properties for multi-props variables",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"selected": {
"description": "Whether the option is selected or not",
"type": "boolean"
+3 -6
View File
@@ -92,7 +92,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
}, nil
}
url := fmt.Sprintf("%s/v3/projects/%s/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject)
url := fmt.Sprintf("%v/v3/projects/%v/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject)
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -139,7 +139,6 @@ type datasourceInfo struct {
defaultProject string
clientEmail string
tokenUri string
universeDomain string
services map[string]datasourceService
privateKey string
usingImpersonation bool
@@ -151,7 +150,6 @@ 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"`
}
@@ -181,7 +179,6 @@ 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{},
@@ -197,13 +194,13 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst
return nil, err
}
for name := range routes {
for name, info := range routes {
client, err := newHTTPClient(dsInfo, opts, &httpClientProvider, name)
if err != nil {
return nil, err
}
dsInfo.services[name] = datasourceService{
url: buildURL(name, dsInfo.universeDomain),
url: info.url,
client: client,
}
}
+2 -9
View File
@@ -23,12 +23,12 @@ type routeInfo struct {
var routes = map[string]routeInfo{
cloudMonitor: {
method: "GET",
url: "https://monitoring.",
url: "https://monitoring.googleapis.com",
scopes: []string{cloudMonitorScope},
},
resourceManager: {
method: "GET",
url: "https://cloudresourcemanager.",
url: "https://cloudresourcemanager.googleapis.com",
scopes: []string{resourceManagerScope},
},
}
@@ -68,13 +68,6 @@ 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 {
@@ -111,7 +111,7 @@ func Test_setRequestVariables(t *testing.T) {
im: &fakeInstance{
services: map[string]datasourceService{
cloudMonitor: {
url: buildURL(cloudMonitor, "googleapis.com"),
url: routes[cloudMonitor].url,
client: &http.Client{},
},
},
@@ -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
+3 -7
View File
@@ -16,6 +16,7 @@ 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
@@ -34,7 +35,6 @@ 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, "elasticsearchImprovedParsing")
improvedParsingEnabled := isFeatureEnabled(c.ctx, featuremgmt.FlagElasticsearchImprovedParsing)
msr, err := c.parser.parseMultiSearchResponse(res.Body, improvedParsingEnabled)
if err != nil {
return nil, err
@@ -197,11 +197,7 @@ func (c *baseClientImpl) createMultiSearchRequests(searchRequests []*SearchReque
func (c *baseClientImpl) getMultiSearchQueryParameters() string {
var qs []string
// 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))
}
qs = append(qs, fmt.Sprintf("max_concurrent_shard_requests=%d", c.ds.MaxConcurrentShardRequests))
if c.ds.IncludeFrozen {
qs = append(qs, "ignore_throttled=false")
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/simplejson"
)
func TestClient_ExecuteMultisearch(t *testing.T) {
@@ -1,51 +0,0 @@
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
}
@@ -1,188 +0,0 @@
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())
})
}
@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/simplejson"
)
func TestSearchRequest(t *testing.T) {
@@ -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
@@ -3,7 +3,7 @@ package elasticsearch
import (
"strconv"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/simplejson"
)
// setFloatPath converts a string value at the specified path to float64
-14
View File
@@ -88,14 +88,6 @@ 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
@@ -159,11 +151,6 @@ 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,
@@ -179,7 +166,6 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins
ConfiguredFields: configuredFields,
Interval: interval,
IncludeFrozen: includeFrozen,
ClusterInfo: clusterInfo,
}
return model, nil
}
@@ -3,8 +3,6 @@ package elasticsearch
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
@@ -20,26 +18,8 @@ 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,
@@ -48,7 +28,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -58,9 +37,6 @@ 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",
@@ -70,7 +46,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -79,9 +54,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("is empty", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
MaxConcurrentShardRequests: 5,
Interval: "Daily",
@@ -92,7 +64,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -103,9 +74,6 @@ 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",
}
@@ -113,7 +81,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -123,9 +90,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("string maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: "10",
@@ -134,7 +98,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -144,9 +107,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("number maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: 10,
@@ -155,7 +115,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -165,9 +124,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("zero maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: 0,
@@ -176,7 +132,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -186,9 +141,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("negative maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: -10,
@@ -197,7 +149,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -207,9 +158,6 @@ 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,
@@ -218,7 +166,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -228,9 +175,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("invalid maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: "invalid",
@@ -239,7 +183,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
+1 -8
View File
@@ -28,6 +28,7 @@ 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)
@@ -37,14 +38,6 @@ 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"
@@ -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/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/simplejson"
)
// metricsResponseProcessor handles processing of metrics query responses
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/simplejson"
)
// Query represents the time series query model of the datasource
+1 -1
View File
@@ -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/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/simplejson"
)
func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, error) {
@@ -5,7 +5,7 @@ import (
"fmt"
"strconv"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/simplejson"
)
// AggregationParser parses raw Elasticsearch DSL aggregations
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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.
@@ -1,582 +0,0 @@
// 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
}
@@ -1,90 +0,0 @@
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")
}
@@ -1,274 +0,0 @@
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(`{`))
})
}
@@ -1,48 +0,0 @@
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)
}
-23
View File
@@ -1,23 +0,0 @@
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)
}
}
@@ -1,4 +1,5 @@
import { DataQuery } from '@grafana/data';
import { createMonitoringLogger, MonitoringLogger } from '@grafana/runtime';
import store from 'app/core/store';
import { RichHistoryQuery } from 'app/types/explore';
@@ -26,8 +27,15 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
getDataSourceSrv: () => dsMock,
createMonitoringLogger: jest.fn().mockReturnValue({ logWarning: jest.fn() }),
}));
// logger is created at import so we cannot initialize inside the test
const loggerIndex = (createMonitoringLogger as jest.Mock).mock.calls.findIndex(
(args) => args[0] === 'features.query-history.local-storage'
);
const loggerMock: MonitoringLogger = (createMonitoringLogger as jest.Mock).mock.results[loggerIndex]?.value;
interface MockQuery extends DataQuery {
query: string;
}
@@ -75,6 +83,8 @@ describe('RichHistoryLocalStorage', () => {
jest.setSystemTime(now);
storage = new RichHistoryLocalStorage();
await storage.deleteAll();
(loggerMock.logWarning as jest.Mock).mockReset();
});
afterEach(() => {
@@ -223,6 +233,90 @@ describe('RichHistoryLocalStorage', () => {
});
});
describe('quota errors and retries', () => {
it('should rotate and retry saving when QuotaExceededError occurs once', async () => {
const initial = [
{ ts: Date.now(), starred: true, comment: 'starred1', queries: [], datasourceName: 'name-of-dev-test' },
{ ts: Date.now(), starred: false, comment: 'notStarred1', queries: [], datasourceName: 'name-of-dev-test' },
{ ts: Date.now(), starred: true, comment: 'starred2', queries: [], datasourceName: 'name-of-dev-test' },
];
store.setObject(key, initial);
// Spy on setObject to throw once with QuotaExceededError, then call through
const originalSetObject = store.setObject.bind(store);
jest
.spyOn(store, 'setObject')
// first attempt throws and errors
.mockImplementationOnce(() => {
const err = new Error('quota hit');
err.name = 'QuotaExceededError';
throw err;
})
// second attempt calls through
.mockImplementation((k: string, value: unknown) => {
return originalSetObject(k, value);
});
const result = await storage.addToRichHistory({
starred: false,
datasourceUid: 'dev-test',
datasourceName: 'name-of-dev-test',
comment: 'new',
queries: [{ refId: 'A' }],
});
expect(result.richHistoryQuery).toBeDefined();
// After one failure, rotation removes one unstarred entry
const saved = store.getObject<RichHistoryQuery[]>(key)!;
expect(saved).toHaveLength(3);
expect(saved).toMatchObject([
expect.objectContaining({ comment: 'new' }),
expect.objectContaining({ comment: 'starred1' }),
expect.objectContaining({ comment: 'starred2' }),
]);
// Ensure logger was called for the failure, with expected flags
expect(loggerMock.logWarning).toHaveBeenCalled();
const [message, payload] = (loggerMock.logWarning as jest.Mock).mock.calls[0];
expect(message).toContain('Failed to save rich history to local storage');
expect(payload.saveRetriesLeft).toBe('3');
expect(payload.quotaExceededError).toBe('true');
});
it('should throw StorageFull when QuotaExceededError persists for all retries and track attempts', async () => {
store.setObject(key, [
{ ts: Date.now(), starred: false, comment: 'notStarred1', queries: [], datasourceName: 'name-of-dev-test' },
]);
const setSpy = jest.spyOn(store, 'setObject').mockImplementation(() => {
const err = new Error('quota still hit');
err.name = 'QuotaExceededError';
throw err;
});
await expect(
storage.addToRichHistory({
starred: false,
datasourceUid: 'dev-test',
datasourceName: 'name-of-dev-test',
comment: 'new',
queries: [{ refId: 'B' }],
})
).rejects.toMatchObject({ name: 'StorageFull' });
// 4 failed tracking attempts (1 save + 3 retries) should be logged (for each failed try)
expect(loggerMock.logWarning).toHaveBeenCalledTimes(4);
const calls = (loggerMock.logWarning as jest.Mock).mock.calls;
expect(calls[0][0]).toContain('Failed to save rich history to local storage');
expect(calls[0][1].saveRetriesLeft).toBe('3');
expect(calls[1][1].saveRetriesLeft).toBe('2');
expect(calls[2][1].saveRetriesLeft).toBe('1');
expect(calls[3][1].saveRetriesLeft).toBe('0');
setSpy.mockRestore();
});
});
describe('migration', () => {
afterEach(() => {
storage.deleteAll();
@@ -1,7 +1,8 @@
import { find, isEqual, omit } from 'lodash';
import { DataQuery, SelectableValue } from '@grafana/data';
import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes';
import { createMonitoringLogger } from '@grafana/runtime';
import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from 'app/core/utils/richHistoryTypes';
import { RichHistoryQuery } from 'app/types/explore';
import store from '../store';
@@ -26,10 +27,18 @@ export type RichHistoryLocalStorageDTO = {
queries: DataQuery[];
};
const logger = createMonitoringLogger('features.query-history.local-storage');
/**
* Local storage implementation for Rich History. It keeps all entries in browser's local storage.
*/
export default class RichHistoryLocalStorage implements RichHistoryStorage {
public static getLocalStorageUsageInBytes(): number {
const richHistory: RichHistoryLocalStorageDTO[] = store.get(RICH_HISTORY_KEY) || '';
// each character is 2 bytes
return richHistory.length * 2;
}
/**
* Return history entries based on provided filters, perform migration and clean up entries not matching retention policy.
*/
@@ -77,21 +86,43 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
throw error;
}
const { queriesToKeep, limitExceeded } = checkLimits(currentRichHistoryDTOs);
let { queriesToKeep, limitExceeded } = cleanUpUnstarredQuery(currentRichHistoryDTOs, MAX_HISTORY_ITEMS);
const updatedHistory: RichHistoryLocalStorageDTO[] = [newRichHistoryQueryDTO, ...queriesToKeep];
let updatedHistory: RichHistoryLocalStorageDTO[] = [newRichHistoryQueryDTO, ...queriesToKeep];
try {
store.setObject(RICH_HISTORY_KEY, updatedHistory);
} catch (error) {
if (error instanceof Error && error.name === 'QuotaExceededError') {
throwError(RichHistoryServiceError.StorageFull, `Saving rich history failed: ${error.message}`);
} else {
throw error;
let saveRetriesLeft = 3;
let saved = false;
while (!saved && saveRetriesLeft >= 0) {
try {
store.setObject(RICH_HISTORY_KEY, updatedHistory);
saved = true;
} catch (error) {
await this.trackLocalStorageUsage('Failed to save rich history to local storage', {
saveRetriesLeft: saveRetriesLeft.toString(),
quotaExceededError: error instanceof Error && error.name === 'QuotaExceededError' ? 'true' : 'false',
errorMessage: error instanceof Error ? error?.message : 'unknown',
});
if (saveRetriesLeft >= 1) {
saveRetriesLeft--;
const { queriesToKeep: newQueriesToKeep } = cleanUpUnstarredQuery(queriesToKeep, queriesToKeep.length - 1);
updatedHistory = [newRichHistoryQueryDTO, ...newQueriesToKeep];
queriesToKeep = newQueriesToKeep;
continue;
}
if (error instanceof Error && error.name === 'QuotaExceededError') {
throwError(RichHistoryServiceError.StorageFull, `Saving rich history failed: ${error.message}`);
} else {
throw error;
}
}
}
if (limitExceeded) {
await this.trackLocalStorageUsage('Rich history query limit exceeded.');
return {
warning: {
type: RichHistoryStorageWarning.LimitExceeded,
@@ -148,6 +179,33 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
})
);
}
private async trackLocalStorageUsage(message: string, additionalInfo?: Record<string, string>) {
const allQueriesCount =
(
await this.getRichHistory({
search: '',
sortOrder: SortOrder.Ascending,
datasourceFilters: [],
starred: false,
})
).total || -1;
const allQueriesSizeInBytes = RichHistoryLocalStorage.getLocalStorageUsageInBytes();
const totalLocalStorageSize = calculateTotalLocalStorageSize();
const localStats = {
totalLocalStorageSize: totalLocalStorageSize?.toString(),
allQueriesSizeInBytes: allQueriesSizeInBytes?.toString(),
allQueriesCount: allQueriesCount?.toString(),
};
logger.logWarning(message, {
...localStats,
...additionalInfo,
});
}
}
function updateRichHistory(
@@ -185,17 +243,20 @@ function cleanUp(richHistory: RichHistoryLocalStorageDTO[]): RichHistoryLocalSto
}
/**
* Ensures the entry can be added. Throws an error if current limit has been hit.
* Ensures the entry can be added.
* Returns queries that should be saved back giving space for one extra query.
*/
export function checkLimits(queriesToKeep: RichHistoryLocalStorageDTO[]): {
export function cleanUpUnstarredQuery(
queriesToKeep: RichHistoryLocalStorageDTO[],
max: number
): {
queriesToKeep: RichHistoryLocalStorageDTO[];
limitExceeded: boolean;
} {
// remove oldest non-starred items to give space for the recent query
let limitExceeded = false;
let current = queriesToKeep.length - 1;
while (current >= 0 && queriesToKeep.length >= MAX_HISTORY_ITEMS) {
while (current >= 0 && queriesToKeep.length >= max) {
if (!queriesToKeep[current].starred) {
queriesToKeep.splice(current, 1);
limitExceeded = true;
@@ -247,3 +308,26 @@ function throwError(name: string, message: string) {
error.name = name;
throw error;
}
function calculateTotalLocalStorageSize() {
try {
let total = 0;
// eslint-disable-next-line
const ls = window.localStorage;
for (let i = 0; i < ls.length; i++) {
const key = ls.key(i);
if (key) {
const value = ls.getItem(key);
if (value) {
total += key.length + value.length;
}
}
}
// each character is 2 bytes
return total * 2;
} catch (e) {
return -1;
}
}
@@ -28,6 +28,7 @@ export type RichHistorySearchFilters = {
// so the resulting timerange from this will be [now - from, now - to].
from?: number;
to?: number;
// true if only starred entries should be returned, false if ALL entries should be returned,
starred: boolean;
page?: number;
};
@@ -284,7 +284,6 @@ function variableValueOptionsToVariableOptions(varState: MultiValueVariable['sta
value: String(o.value),
text: o.label,
selected: Array.isArray(varState.value) ? varState.value.includes(o.value) : varState.value === o.value,
...(o.properties && { properties: o.properties }),
}));
}
@@ -69,6 +69,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
const isHasVariableOptions = hasVariableOptions(variable);
const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : [];
const hasMultiProps = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json';
const onDeleteVariable = (hideModal: () => void) => () => {
reportInteraction('Delete variable');
@@ -124,7 +125,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} />}
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
<div className={styles.buttonContainer}>
<Stack gap={2}>
@@ -10,16 +10,13 @@ import { Button, InlineFieldRow, InlineLabel, InteractiveTable, Text, useStyles2
export interface Props {
options: VariableValueOption[];
hasMultiProps?: boolean;
}
const hasMultiProps = (options: Props['options']) => {
return Object.keys(options[1]?.properties ?? options[0]?.properties ?? {}).length > 0;
};
export const VariableValuesPreview = ({ options }: Props) => {
export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => {
const styles = useStyles2(getStyles);
const hasOptions = options.length > 0;
const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasOptions && hasMultiProps(options);
const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasMultiProps;
return (
<div className={styles.previewContainer} style={{ gap: '8px' }}>
@@ -46,8 +43,7 @@ function VariableValuesWithPropsPreview({ options }: { options: VariableValueOpt
return {
data,
// the option at index 0 can be "All" so we try to grab the column names from the 2nd option
columns: Object.keys(data[1] ?? data[0] ?? {}).map((id) => ({
columns: Object.keys(data[0] ?? {}).map((id) => ({
id,
// see https://github.com/TanStack/table/issues/1671
header: unsanitizeKey(id),
@@ -66,6 +62,7 @@ function VariableValuesWithPropsPreview({ options }: { options: VariableValueOpt
/>
);
}
const sanitizeKey = (key: string) => key.replace(/\./g, '__dot__');
const unsanitizeKey = (key: string) => key.replace(/__dot__/g, '.');
@@ -69,7 +69,7 @@ function ModalEditorMultiProps(props: ModalEditorProps) {
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
</div>
<div>
<VariableValuesPreview options={options} />
<VariableValuesPreview options={options} hasMultiProps={valuesFormat === 'json'} />
</div>
</Stack>
<Modal.ButtonRow>
@@ -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(32);
expect(enterprisePluginsCategory.plugins.length).toBe(31);
expect(enterprisePluginsCategory.plugins[0].name).toBe('Adobe Analytics');
expect(enterprisePluginsCategory.plugins[enterprisePluginsCategory.plugins.length - 1].name).toBe('Zendesk');
});
@@ -13,7 +13,6 @@ 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';
@@ -419,12 +418,6 @@ 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,
}),
];
}
@@ -200,7 +200,7 @@ describe('Explore: Query History', () => {
await waitForExplore();
await openQueryHistory();
jest.spyOn(localStorage, 'checkLimits').mockImplementationOnce((queries) => {
jest.spyOn(localStorage, 'cleanUpUnstarredQuery').mockImplementationOnce((queries) => {
return { queriesToKeep: queries, limitExceeded: true };
});
@@ -81,17 +81,16 @@ const buildLabelPath = (label: string) => {
};
const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
if (!('options' in variable) || !variable.options[0].properties) {
if (!('valuesFormat' in variable) || variable.valuesFormat !== 'json') {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function collectFieldPaths(properties: Record<string, any>, currentPath: string) {
function collectFieldPaths(option: Record<string, string>, currentPath: string) {
let paths: string[] = [];
for (const field in properties) {
if (properties.hasOwnProperty(field)) {
for (const field in option) {
if (option.hasOwnProperty(field)) {
const newPath = `${currentPath}.${field}`;
const value = properties[field];
const value = option[field];
if (typeof value === 'object' && value !== null) {
paths = [...paths, ...collectFieldPaths(value, newPath)];
}
@@ -101,7 +100,11 @@ const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
return paths;
}
return collectFieldPaths(variable.options[0].properties, variable.name);
try {
return collectFieldPaths(JSON.parse(variable.query)[0], variable.name);
} catch {
return [];
}
};
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
@@ -503,16 +503,13 @@ describe('linkSrv', () => {
});
describe('getPanelLinksVariableSuggestions', () => {
it('then it should return template variables, options properties and built-ins', () => {
it('then it should return template variables, json properties and built-ins', () => {
const templateSrvWithJsonValues = initTemplateSrv('key', [
{
type: 'custom',
name: 'customServers',
valuesFormat: 'json',
options: [
{ text: 'web', value: 'web', properties: { name: 'web', ip: '192.168.0.100' } },
{ text: 'ads', value: 'ads', properties: { name: 'ads', ip: '192.168.0.142' } },
],
query: '[{"name":"web","ip":"192.168.0.100"},{"name":"ads","ip":"192.168.0.142"}]',
},
]);
setTemplateSrv(templateSrvWithJsonValues);
@@ -4,6 +4,8 @@ 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 () =>
@@ -73,6 +75,7 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul
// datasources
'core:plugin/cloudwatch': cloudwatchPlugin,
'core:plugin/dashboard': dashboardDSPlugin,
'core:plugin/elasticsearch': elasticsearchPlugin,
'core:plugin/grafana': grafanaPlugin,
'core:plugin/influxdb': influxdbPlugin,
'core:plugin/mixed': mixedPlugin,
@@ -1,10 +1,10 @@
import { memo } from 'react';
import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { ConnectionConfig } from '@grafana/google-sdk';
import { ConfigSection, DataSourceDescription } from '@grafana/plugin-ui';
import { config, reportInteraction } from '@grafana/runtime';
import { Divider, Field, Input, SecureSocksProxySettings, Stack } from '@grafana/ui';
import { reportInteraction, config } from '@grafana/runtime';
import { Divider, SecureSocksProxySettings } from '@grafana/ui';
import { CloudMonitoringOptions, CloudMonitoringSecureJsonData } from '../../types/types';
@@ -36,33 +36,14 @@ export const ConfigEditor = memo(({ options, onOptionsChange }: Props) => {
<Divider />
<ConfigSection
title="Additional settings"
description="Additional settings are optional settings that can be configured for more control over your data source. This includes Secure Socks Proxy and Universe Domain."
description="Additional settings are optional settings that can be configured for more control over your data source. This includes Secure Socks Proxy."
isCollapsible
isInitiallyOpen={
options.jsonData.enableSecureSocksProxy !== undefined || options.jsonData.universeDomain !== undefined
}
isInitiallyOpen={options.jsonData.enableSecureSocksProxy !== undefined}
>
<Stack direction={'column'}>
<Field noMargin label="Universe Domain">
<Input
width={50}
value={options.jsonData.universeDomain}
onChange={(event) =>
updateDatasourcePluginJsonDataOption(
{ options, onOptionsChange },
'universeDomain',
event.currentTarget.value
)
}
placeholder="googleapis.com"
></Input>
</Field>
<SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} />
</Stack>
<SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} />
</ConfigSection>
</>
)}
<Divider />
</>
);
});
@@ -38,7 +38,6 @@ export interface Aggregation {
export interface CloudMonitoringOptions extends DataSourceOptions {
gceDefaultProject?: string;
enableSecureSocksProxy?: boolean;
universeDomain?: string;
}
export interface CloudMonitoringSecureJsonData extends DataSourceSecureJsonData {}
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { select } from 'react-select-event';
import { DateHistogram } from '../../../../dataquery.gen';
import { DateHistogram } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { DateHistogramSettingsEditor } from './DateHistogramSettingsEditor';
@@ -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<string> | null,
options: OptionsOrGroups<SelectableValue<string>, GroupBase<SelectableValue<string>>>
options: OptionsOrGroups<unknown, GroupBase<unknown>>
) => {
// TODO: would be extremely nice here to allow only template variables and values that are
// valid date histogram's Interval options
const valueExists = options.some(hasValue(inputValue));
const valueExists = (options as Array<SelectableValue<string>>).some(hasValue(inputValue));
// we also don't want users to create "empty" values
return !valueExists && inputValue.trim().length > 0;
};
@@ -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';
@@ -1,6 +1,6 @@
import { createAction } from '@reduxjs/toolkit';
import { Filter } from '../../../../../../dataquery.gen';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
export const addFilter = createAction('@bucketAggregations/filter/add');
export const removeFilter = createAction<number>('@bucketAggregations/filter/remove');
@@ -1,5 +1,6 @@
import { Filter } from '../../../../../../dataquery.gen';
import { reducerTester } from '../../../../../reducerTester';
import { reducerTester } from 'test/core/redux/reducerTester';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { addFilter, changeFilter, removeFilter } from './actions';
import { reducer } from './reducer';
@@ -1,6 +1,7 @@
import { Action } from 'redux';
import { Filter } from '../../../../../../dataquery.gen';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultFilter } from '../utils';
import { addFilter, changeFilter, removeFilter } from './actions';
@@ -1,3 +1,3 @@
import { Filter } from '../../../../../dataquery.gen';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
export const defaultFilter = (): Filter => ({ label: '', query: '*' });
@@ -1,7 +1,14 @@
import { fireEvent, screen } from '@testing-library/react';
import selectEvent from 'react-select-event';
import { Average, Derivative, ElasticsearchDataQuery, Terms, TopMetrics } from '../../../../dataquery.gen';
import {
Average,
Derivative,
ElasticsearchDataQuery,
Terms,
TopMetrics,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { renderWithESProvider } from '../../../../test-helpers/render';
import { describeMetric } from '../../../../utils';
@@ -2,9 +2,15 @@ import { uniqueId } from 'lodash';
import { useRef } from 'react';
import { SelectableValue } from '@grafana/data';
import { InlineField, Input, Select } from '@grafana/ui';
import { InlineField, Select, Input } from '@grafana/ui';
import {
Terms,
ExtendedStats,
ExtendedStatMetaType,
Percentiles,
MetricAggregation,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { ExtendedStats, MetricAggregation, Percentiles, Terms } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { describeMetric } from '../../../../utils';
import { useQuery } from '../../ElasticsearchQueryContext';
@@ -99,7 +105,7 @@ function createOrderByOptionsForExtendedStats(metric: ExtendedStats): Selectable
if (!metric.meta) {
return [];
}
const metaKeys = Object.keys(metric.meta);
const metaKeys = Object.keys(metric.meta) as ExtendedStatMetaType[];
return metaKeys
.filter((key) => metric.meta?.[key])
.map((key) => {
@@ -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';
@@ -1,6 +1,7 @@
import { BucketAggregation } from '../../../../dataquery.gen';
import { BucketAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultGeoHashPrecisionString } from '../../../../queryDef';
import { convertOrderByToMetricId, describeMetric } from '../../../../utils';
import { describeMetric, convertOrderByToMetricId } from '../../../../utils';
import { useQuery } from '../../ElasticsearchQueryContext';
import { bucketAggregationConfig, orderByOptions, orderOptions } from '../utils';
@@ -1,6 +1,10 @@
import { createAction } from '@reduxjs/toolkit';
import { BucketAggregation, BucketAggregationType, BucketAggregationWithField } from '../../../../dataquery.gen';
import {
BucketAggregation,
BucketAggregationType,
BucketAggregationWithField,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
export const addBucketAggregation = createAction<BucketAggregation['id']>('@bucketAggs/add');
export const removeBucketAggregation = createAction<BucketAggregation['id']>('@bucketAggs/remove');
@@ -1,4 +1,9 @@
import { BucketAggregation, DateHistogram, ElasticsearchDataQuery } from '../../../../dataquery.gen';
import {
BucketAggregation,
DateHistogram,
ElasticsearchDataQuery,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultBucketAgg } from '../../../../queryDef';
import { reducerTester } from '../../../reducerTester';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
@@ -1,6 +1,7 @@
import { Action } from '@reduxjs/toolkit';
import { BucketAggregation, ElasticsearchDataQuery, Terms } from '../../../../dataquery.gen';
import { BucketAggregation, ElasticsearchDataQuery, Terms } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultBucketAgg } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
@@ -46,12 +47,11 @@ 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.
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
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.
*/
return {
id: bucketAgg.id,
type: action.payload.newType,
@@ -2,10 +2,10 @@ import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import { Fragment, useEffect } from 'react';
import { InlineLabel, Input } from '@grafana/ui';
import { Input, InlineLabel } from '@grafana/ui';
import { BucketScript, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { BucketScript, MetricAggregation } from '../../../../../dataquery.gen';
import { useDispatch, useStatelessReducer } from '../../../../../hooks/useStatelessReducer';
import { useStatelessReducer, useDispatch } 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';
@@ -1,4 +1,5 @@
import { PipelineVariable } from '../../../../../../dataquery.gen';
import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { reducerTester } from '../../../../../reducerTester';
import {
@@ -1,6 +1,7 @@
import { Action } from '@reduxjs/toolkit';
import { PipelineVariable } from '../../../../../../dataquery.gen';
import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultPipelineVariable, generatePipelineVariableName } from '../utils';
import {
@@ -1,4 +1,4 @@
import { PipelineVariable } from '../../../../../dataquery.gen';
import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
export const defaultPipelineVariable = (name: string): PipelineVariable => ({ name, pipelineAgg: '' });
@@ -2,8 +2,11 @@ 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';
@@ -30,11 +33,9 @@ export function SettingField<T extends MetricAggregationWithSettings, K extends
const [id] = useState(uniqueId(`es-field-id-`));
const settings = metric.settings;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
let defaultValue = settings?.[settingName as keyof typeof settings] || '';
if (settingName === 'script') {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
defaultValue = getScriptValue(metric as MetricAggregationWithInlineScript);
}
@@ -2,8 +2,8 @@ import { css } from '@emotion/css';
import { SelectableValue } from '@grafana/data';
import { AsyncMultiSelect, InlineField, SegmentAsync, Select } from '@grafana/ui';
import { TopMetrics } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { TopMetrics } from '../../../../dataquery.gen';
import { useFields } from '../../../../hooks/useFields';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { orderOptions } from '../../BucketAggregationsEditor/utils';
@@ -1,8 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getDefaultTimeRange } from '@grafana/data';
import { ElasticsearchDataQuery } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { ElasticsearchDataQuery } from '../../../../dataquery.gen';
import { ElasticDatasource } from '../../../../datasource';
import { ElasticsearchProvider } from '../../ElasticsearchQueryContext';
@@ -1,10 +1,10 @@
import { uniqueId } from 'lodash';
import * as React from 'react';
import { ComponentProps, useId, useRef, useState } from 'react';
import * as React from 'react';
import { InlineField, InlineSwitch, Input, Select } from '@grafana/ui';
import { InlineField, Input, InlineSwitch, Select } from '@grafana/ui';
import { MetricAggregation, ExtendedStat } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { ExtendedStat, MetricAggregation } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { extendedStats } from '../../../../queryDef';
import { SettingsEditorContainer } from '../../SettingsEditorContainer';
@@ -1,4 +1,5 @@
import { MetricAggregation } from '../../../../dataquery.gen';
import { MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { extendedStats } from '../../../../queryDef';
const hasValue = (value: string) => (object: { value: string }) => object.value === value;
@@ -1,6 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import { MetricAggregation, MetricAggregationWithSettings } from '../../../../dataquery.gen';
import { MetricAggregation, MetricAggregationWithSettings } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { MetricAggregationWithMeta } from '../../../../types';
export const addMetric = createAction<MetricAggregation['id']>('@metrics/add');
@@ -1,4 +1,10 @@
import { Derivative, ElasticsearchDataQuery, ExtendedStats, MetricAggregation } from '../../../../dataquery.gen';
import {
MetricAggregation,
ElasticsearchDataQuery,
Derivative,
ExtendedStats,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultMetricAgg } from '../../../../queryDef';
import { reducerTester } from '../../../reducerTester';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
@@ -1,6 +1,7 @@
import { Action } from '@reduxjs/toolkit';
import { ElasticsearchDataQuery, MetricAggregation } from '../../../../dataquery.gen';
import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
@@ -56,7 +57,6 @@ 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,
@@ -2,7 +2,7 @@ import { AnyAction } from '@reduxjs/toolkit';
import { cloneDeep } from 'lodash';
import { Action } from 'redux';
import { StoreState } from '../types/store';
import { StoreState } from 'app/types/store';
type GrafanaReducer<S = StoreState, A extends Action = AnyAction> = (state: S, action: A) => S;
@@ -1,22 +0,0 @@
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 (
<InlineField label="API Key" labelWidth={14} interactive tooltip={'API Key authentication'}>
<SecretInput
required
id="config-editor-api-key"
isConfigured={!!options.secureJsonFields?.apiKey}
placeholder="Enter your API key"
width={40}
onReset={() => updateDatasourcePluginResetOption(props, 'apiKey')}
onChange={onUpdateDatasourceSecureJsonDataOption(props, 'apiKey')}
/>
</InlineField>
);
};
@@ -14,15 +14,14 @@ import {
import { config } from '@grafana/runtime';
import { Alert, SecureSocksProxySettings, Divider, Stack } from '@grafana/ui';
import { ElasticsearchOptions, ElasticsearchSecureJsonData } from '../types';
import { ElasticsearchOptions } 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<ElasticsearchOptions, ElasticsearchSecureJsonData>;
export type Props = DataSourcePluginOptionsEditorProps<ElasticsearchOptions>;
export const ConfigEditor = (props: Props) => {
const { options, onOptionsChange } = props;
@@ -49,16 +48,6 @@ 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: <ApiKeyConfig {...props} />,
},
];
authProps.selectedMethod = options.jsonData.apiKeyAuth ? 'custom-api-key' : authProps.selectedMethod;
return (
<>
{options.access === 'direct' && (
@@ -84,7 +73,6 @@ export const ConfigEditor = (props: Props) => {
jsonData: {
...options.jsonData,
sigV4Auth: method === 'custom-sigv4',
apiKeyAuth: method === 'custom-api-key',
oauthPassThru: method === AuthMethod.OAuthForward,
},
});
@@ -1 +0,0 @@
import '@grafana/plugin-configs/jest/jest-setup';
@@ -1,3 +0,0 @@
import defaultConfig from '@grafana/plugin-configs/jest/jest.config.js';
export default defaultConfig;
@@ -1,62 +0,0 @@
{
"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"
}
@@ -2,7 +2,6 @@
"type": "datasource",
"name": "Elasticsearch",
"id": "elasticsearch",
"executable": "gpx_elasticsearch",
"category": "logging",
"info": {
"description": "Open source logging & analytics database",
@@ -28,8 +27,7 @@
"name": "Documentation",
"url": "https://grafana.com/docs/grafana/latest/datasources/elasticsearch/"
}
],
"version": "%VERSION%"
]
},
"alerting": true,
"annotations": true,
@@ -38,9 +36,5 @@
"backend": true,
"queryOptions": {
"minInterval": true
},
"dependencies": {
"grafanaDependency": ">=11.6.0",
"plugins": []
}
}

Some files were not shown because too many files have changed in this diff Show More