update openapi

This commit is contained in:
Ryan McKinley
2025-07-01 09:14:01 -07:00
110 changed files with 4160 additions and 1131 deletions
-4
View File
@@ -60,8 +60,6 @@ jobs:
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Generate Go code
run: make gen-go
- name: Run unit tests
env:
SHARD: ${{ matrix.shard }}
@@ -109,8 +107,6 @@ jobs:
- run: go install github.com/jstemmer/go-junit-report/v2@85bf4716ac1f025f2925510a9f5e9f5bb347c009
# Run code
- name: Generate Go code
run: make gen-go
- name: Run unit tests
env:
SHARD: ${{ matrix.shard }}
-1
View File
@@ -22,7 +22,6 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- run: make gen-go
- name: golangci-lint
uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd
with:
@@ -41,8 +41,6 @@ jobs:
with:
go-version-file: go.mod
cache: true
- name: Generate Go code
run: make gen-go
- name: Run tests
env:
SHARD: ${{ matrix.shard }}
@@ -89,8 +87,6 @@ jobs:
cache: true
- name: Setup MySQL devenv
run: mysql -h 127.0.0.1 -P 3306 -u root -prootpass < devenv/docker/blocks/mysql_tests/setup.sql
- name: Generate Go code
run: make gen-go
- name: Run tests
env:
SHARD: ${{ matrix.shard }}
@@ -136,8 +132,6 @@ jobs:
cache: true
- name: Setup Postgres devenv
run: psql -p 5432 -h 127.0.0.1 -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql
- name: Generate Go code
run: make gen-go
- name: Run tests
env:
SHARD: ${{ matrix.shard }}
+1 -3
View File
@@ -115,6 +115,7 @@ profile.cov
# Extensions
/pkg/cmd/grafana-cli/runner/wireexts_enterprise.go
/pkg/server/wireexts_enterprise.go
/pkg/server/enterprise_wire_gen.go
/pkg/build/cmd/enterprise.go
/pkg/extensions/*
!/pkg/extensions/.keep
@@ -204,9 +205,6 @@ compilation-stats.json
# auto generated frontend docs
/docs/sources/packages_api
# wire generated files
**/wire_gen.go
# Auto-generated internationalization files
public/locales/_build/
public/locales/*/*.js
+4
View File
@@ -103,6 +103,10 @@ linters:
- '**/pkg/tsdb/cloudwatch/**/*'
- '**/pkg/tsdb/loki/*'
- '**/pkg/tsdb/loki/**/*'
- '**/pkg/tsdb/zipkin/*'
- '**/pkg/tsdb/zipkin/**/*'
- '**/pkg/tsdb/jaeger/*'
- '**/pkg/tsdb/jaeger/**/*'
deny:
- pkg: github.com/grafana/grafana/pkg/api
desc: Core plugins are not allowed to depend on Grafana core packages
+2
View File
@@ -40,6 +40,8 @@ check: {
itemID: string
// Links to actions that can be taken to resolve the failure
links: [...#ErrorLink]
// More information about the failure, not meant to be displayed to the user. Used for LLM suggestions.
moreInfo?: string
}
#Report: {
// Number of elements analyzed
@@ -27,6 +27,8 @@ type CheckReportFailure struct {
ItemID string `json:"itemID"`
// Links to actions that can be taken to resolve the failure
Links []CheckErrorLink `json:"links"`
// More information about the failure, not meant to be displayed to the user. Used for LLM suggestions.
MoreInfo *string `json:"moreInfo,omitempty"`
}
// NewCheckReportFailure creates a new CheckReportFailure object.
@@ -205,6 +205,13 @@ func schema_pkg_apis_advisor_v0alpha1_CheckReportFailure(ref common.ReferenceCal
},
},
},
"moreInfo": {
SchemaProps: spec.SchemaProps{
Description: "More information about the failure, not meant to be displayed to the user. Used for LLM suggestions.",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"severity", "stepID", "item", "itemID", "links"},
},
+1 -1
View File
@@ -12,7 +12,7 @@ import (
)
var (
rawSchemaCheckv0alpha1 = []byte(`{"spec":{"properties":{"data":{"additionalProperties":{"type":"string"},"description":"Generic data input that a check can receive","type":"object"}},"type":"object"},"status":{"properties":{"additionalFields":{"description":"additionalFields is reserved for future use","type":"object","x-kubernetes-preserve-unknown-fields":true},"operatorStates":{"additionalProperties":{"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"description":"details contains any extra information that is operator-specific","type":"object","x-kubernetes-preserve-unknown-fields":true},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"},"report":{"properties":{"count":{"description":"Number of elements analyzed","type":"integer"},"failures":{"description":"List of failures","items":{"properties":{"item":{"description":"Human readable identifier of the item that failed","type":"string"},"itemID":{"description":"ID of the item that failed","type":"string"},"links":{"description":"Links to actions that can be taken to resolve the failure","items":{"properties":{"message":{"description":"Human readable error message","type":"string"},"url":{"description":"URL to a page with more information about the error","type":"string"}},"required":["url","message"],"type":"object"},"type":"array"},"severity":{"description":"Severity of the failure","enum":["high","low"],"type":"string"},"stepID":{"description":"Step ID that the failure is associated with","type":"string"}},"required":["severity","stepID","item","itemID","links"],"type":"object"},"type":"array"}},"required":["count","failures"],"type":"object"}},"required":["report"],"type":"object","x-kubernetes-preserve-unknown-fields":true}}`)
rawSchemaCheckv0alpha1 = []byte(`{"spec":{"properties":{"data":{"additionalProperties":{"type":"string"},"description":"Generic data input that a check can receive","type":"object"}},"type":"object"},"status":{"properties":{"additionalFields":{"description":"additionalFields is reserved for future use","type":"object","x-kubernetes-preserve-unknown-fields":true},"operatorStates":{"additionalProperties":{"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"description":"details contains any extra information that is operator-specific","type":"object","x-kubernetes-preserve-unknown-fields":true},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"},"report":{"properties":{"count":{"description":"Number of elements analyzed","type":"integer"},"failures":{"description":"List of failures","items":{"properties":{"item":{"description":"Human readable identifier of the item that failed","type":"string"},"itemID":{"description":"ID of the item that failed","type":"string"},"links":{"description":"Links to actions that can be taken to resolve the failure","items":{"properties":{"message":{"description":"Human readable error message","type":"string"},"url":{"description":"URL to a page with more information about the error","type":"string"}},"required":["url","message"],"type":"object"},"type":"array"},"moreInfo":{"description":"More information about the failure, not meant to be displayed to the user. Used for LLM suggestions.","type":"string"},"severity":{"description":"Severity of the failure","enum":["high","low"],"type":"string"},"stepID":{"description":"Step ID that the failure is associated with","type":"string"}},"required":["severity","stepID","item","itemID","links"],"type":"object"},"type":"array"}},"required":["count","failures"],"type":"object"}},"required":["report"],"type":"object","x-kubernetes-preserve-unknown-fields":true}}`)
versionSchemaCheckv0alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaCheckv0alpha1, &versionSchemaCheckv0alpha1)
rawSchemaCheckTypev0alpha1 = []byte(`{"spec":{"properties":{"name":{"type":"string"},"steps":{"items":{"properties":{"description":{"type":"string"},"resolution":{"type":"string"},"stepID":{"type":"string"},"title":{"type":"string"}},"required":["title","description","stepID","resolution"],"type":"object"},"type":"array"}},"required":["name","steps"],"type":"object"},"status":{"properties":{"additionalFields":{"description":"additionalFields is reserved for future use","type":"object","x-kubernetes-preserve-unknown-fields":true},"operatorStates":{"additionalProperties":{"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"description":"details contains any extra information that is operator-specific","type":"object","x-kubernetes-preserve-unknown-fields":true},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object","x-kubernetes-preserve-unknown-fields":true}}`)
@@ -3,19 +3,14 @@ package datasourcecheck
import (
"context"
"errors"
"fmt"
sysruntime "runtime"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-plugin-sdk-go/backend"
advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/util"
)
const (
@@ -110,179 +105,6 @@ func (c *check) Steps() []checks.Step {
}
}
type uidValidationStep struct{}
func (s *uidValidationStep) ID() string {
return UIDValidationStepID
}
func (s *uidValidationStep) Title() string {
return "UID validation"
}
func (s *uidValidationStep) Description() string {
return "Checks if the UID of a data source is valid."
}
func (s *uidValidationStep) Resolution() string {
return "Check the <a href='https://grafana.com/docs/grafana/latest/upgrade-guide/upgrade-v11.2/#grafana-data-source-uid-format-enforcement'" +
"target=_blank>documentation</a> for more information or delete the data source and create a new one."
}
func (s *uidValidationStep) Run(ctx context.Context, log logging.Logger, obj *advisor.CheckSpec, i any) ([]advisor.CheckReportFailure, error) {
ds, ok := i.(*datasources.DataSource)
if !ok {
return nil, fmt.Errorf("invalid item type %T", i)
}
// Data source UID validation
err := util.ValidateUID(ds.UID)
if err != nil {
return []advisor.CheckReportFailure{checks.NewCheckReportFailure(
advisor.CheckReportFailureSeverityLow,
s.ID(),
fmt.Sprintf("%s (%s)", ds.Name, ds.UID),
ds.UID,
[]advisor.CheckErrorLink{},
)}, nil
}
return nil, nil
}
type healthCheckStep struct {
PluginContextProvider pluginContextProvider
PluginClient plugins.Client
}
func (s *healthCheckStep) Title() string {
return "Health check"
}
func (s *healthCheckStep) Description() string {
return "Checks if a data source is healthy."
}
func (s *healthCheckStep) Resolution() string {
return "Go to the data source configuration page and address the issues reported."
}
func (s *healthCheckStep) ID() string {
return HealthCheckStepID
}
func (s *healthCheckStep) Run(ctx context.Context, log logging.Logger, obj *advisor.CheckSpec, i any) ([]advisor.CheckReportFailure, error) {
ds, ok := i.(*datasources.DataSource)
if !ok {
return nil, fmt.Errorf("invalid item type %T", i)
}
// Health check execution
requester, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
pCtx, err := s.PluginContextProvider.GetWithDataSource(ctx, ds.Type, requester, ds)
if err != nil {
if errors.Is(err, plugins.ErrPluginNotRegistered) {
// The plugin is not installed, handle this in the missing plugin step
return nil, nil
}
// Unable to check health check
log.Error("Failed to get plugin context", "datasource_uid", ds.UID, "error", err)
return nil, nil
}
req := &backend.CheckHealthRequest{
PluginContext: pCtx,
Headers: map[string]string{},
}
resp, err := s.PluginClient.CheckHealth(ctx, req)
if err != nil || resp.Status != backend.HealthStatusOk {
if err != nil {
log.Debug("Failed to check health", "datasource_uid", ds.UID, "error", err)
if errors.Is(err, plugins.ErrMethodNotImplemented) || errors.Is(err, plugins.ErrPluginUnavailable) {
// The plugin does not support backend health checks
return nil, nil
}
} else {
log.Debug("Failed to check health", "datasource_uid", ds.UID, "status", resp.Status, "message", resp.Message)
}
return []advisor.CheckReportFailure{checks.NewCheckReportFailure(
advisor.CheckReportFailureSeverityHigh,
s.ID(),
ds.Name,
ds.UID,
[]advisor.CheckErrorLink{
{
Message: "Fix me",
Url: fmt.Sprintf("/connections/datasources/edit/%s", ds.UID),
},
},
)}, nil
}
return nil, nil
}
type missingPluginStep struct {
PluginStore pluginstore.Store
PluginRepo repo.Service
GrafanaVersion string
}
func (s *missingPluginStep) Title() string {
return "Missing plugin check"
}
func (s *missingPluginStep) Description() string {
return "Checks if the plugin associated with the data source is installed and available."
}
func (s *missingPluginStep) Resolution() string {
return "Delete the datasource or install the plugin."
}
func (s *missingPluginStep) ID() string {
return MissingPluginStepID
}
func (s *missingPluginStep) Run(ctx context.Context, log logging.Logger, obj *advisor.CheckSpec, i any) ([]advisor.CheckReportFailure, error) {
ds, ok := i.(*datasources.DataSource)
if !ok {
return nil, fmt.Errorf("invalid item type %T", i)
}
_, exists := s.PluginStore.Plugin(ctx, ds.Type)
if !exists {
links := []advisor.CheckErrorLink{
{
Message: "Delete data source",
Url: fmt.Sprintf("/connections/datasources/edit/%s", ds.UID),
},
}
plugins, err := s.PluginRepo.GetPluginsInfo(ctx, repo.GetPluginsInfoOptions{
IncludeDeprecated: true,
Plugins: []string{ds.Type},
}, repo.NewCompatOpts(s.GrafanaVersion, sysruntime.GOOS, sysruntime.GOARCH))
if err != nil {
return nil, err
}
if len(plugins) > 0 {
// Plugin is available in the repo
links = append(links, advisor.CheckErrorLink{
Message: "View plugin",
Url: fmt.Sprintf("/plugins/%s", ds.Type),
})
}
// The plugin is not installed
return []advisor.CheckReportFailure{checks.NewCheckReportFailure(
advisor.CheckReportFailureSeverityHigh,
s.ID(),
ds.Name,
ds.UID,
links,
)}, nil
}
return nil, nil
}
type pluginContextProvider interface {
GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error)
}
@@ -107,7 +107,7 @@ func TestCheck_Run(t *testing.T) {
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusError}}
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "test message"}}
mockPluginRepo := &MockPluginRepo{plugins: []repo.PluginInfo{
{ID: 1, Slug: "prometheus", Status: "active"},
}}
@@ -125,6 +125,7 @@ func TestCheck_Run(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, failures, 1)
assert.Equal(t, "health-check", failures[0].StepID)
assert.Contains(t, *failures[0].MoreInfo, "test message")
})
t.Run("should skip health check when plugin does not support backend health checks", func(t *testing.T) {
@@ -0,0 +1,93 @@
package datasourcecheck
import (
"context"
"errors"
"fmt"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-plugin-sdk-go/backend"
advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/datasources"
)
type healthCheckStep struct {
PluginContextProvider pluginContextProvider
PluginClient plugins.Client
}
func (s *healthCheckStep) Title() string {
return "Health check"
}
func (s *healthCheckStep) Description() string {
return "Checks if a data source is healthy."
}
func (s *healthCheckStep) Resolution() string {
return "Go to the data source configuration page and address the issues reported."
}
func (s *healthCheckStep) ID() string {
return HealthCheckStepID
}
func (s *healthCheckStep) Run(ctx context.Context, log logging.Logger, obj *advisor.CheckSpec, i any) ([]advisor.CheckReportFailure, error) {
ds, ok := i.(*datasources.DataSource)
if !ok {
return nil, fmt.Errorf("invalid item type %T", i)
}
// Health check execution
requester, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
pCtx, err := s.PluginContextProvider.GetWithDataSource(ctx, ds.Type, requester, ds)
if err != nil {
if errors.Is(err, plugins.ErrPluginNotRegistered) {
// The plugin is not installed, handle this in the missing plugin step
return nil, nil
}
// Unable to check health check
log.Error("Failed to get plugin context", "datasource_uid", ds.UID, "error", err)
return nil, nil
}
req := &backend.CheckHealthRequest{
PluginContext: pCtx,
Headers: map[string]string{},
}
resp, err := s.PluginClient.CheckHealth(ctx, req)
if err != nil || (resp != nil && resp.Status != backend.HealthStatusOk) {
if err != nil {
log.Debug("Failed to check health", "datasource_uid", ds.UID, "error", err)
if errors.Is(err, plugins.ErrMethodNotImplemented) || errors.Is(err, plugins.ErrPluginUnavailable) {
// The plugin does not support backend health checks
return nil, nil
}
} else {
log.Debug("Failed to check health", "datasource_uid", ds.UID, "status", resp.Status, "message", resp.Message)
}
moreInfo := ""
if resp != nil {
moreInfo = fmt.Sprintf("Status: %s\nMessage: %s\nJSONDetails: %s", resp.Status, resp.Message, resp.JSONDetails)
}
return []advisor.CheckReportFailure{checks.NewCheckReportFailureWithMoreInfo(
advisor.CheckReportFailureSeverityHigh,
s.ID(),
ds.Name,
ds.UID,
[]advisor.CheckErrorLink{
{
Message: "Fix me",
Url: fmt.Sprintf("/connections/datasources/edit/%s", ds.UID),
},
},
moreInfo,
)}, nil
}
return nil, nil
}
@@ -0,0 +1,77 @@
package datasourcecheck
import (
"context"
"fmt"
sysruntime "runtime"
"github.com/grafana/grafana-app-sdk/logging"
advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
type missingPluginStep struct {
PluginStore pluginstore.Store
PluginRepo repo.Service
GrafanaVersion string
}
func (s *missingPluginStep) Title() string {
return "Missing plugin check"
}
func (s *missingPluginStep) Description() string {
return "Checks if the plugin associated with the data source is installed and available."
}
func (s *missingPluginStep) Resolution() string {
return "Delete the datasource or install the plugin."
}
func (s *missingPluginStep) ID() string {
return MissingPluginStepID
}
func (s *missingPluginStep) Run(ctx context.Context, log logging.Logger, obj *advisor.CheckSpec, i any) ([]advisor.CheckReportFailure, error) {
ds, ok := i.(*datasources.DataSource)
if !ok {
return nil, fmt.Errorf("invalid item type %T", i)
}
_, exists := s.PluginStore.Plugin(ctx, ds.Type)
if !exists {
links := []advisor.CheckErrorLink{
{
Message: "Delete data source",
Url: fmt.Sprintf("/connections/datasources/edit/%s", ds.UID),
},
}
plugins, err := s.PluginRepo.GetPluginsInfo(ctx, repo.GetPluginsInfoOptions{
IncludeDeprecated: true,
Plugins: []string{ds.Type},
}, repo.NewCompatOpts(s.GrafanaVersion, sysruntime.GOOS, sysruntime.GOARCH))
if err != nil {
return nil, err
}
if len(plugins) > 0 {
// Plugin is available in the repo
links = append(links, advisor.CheckErrorLink{
Message: "View plugin",
Url: fmt.Sprintf("/plugins/%s", ds.Type),
})
}
// The plugin is not installed
return []advisor.CheckReportFailure{checks.NewCheckReportFailureWithMoreInfo(
advisor.CheckReportFailureSeverityHigh,
s.ID(),
ds.Name,
ds.UID,
links,
fmt.Sprintf("Plugin: %s", ds.Type),
)}, nil
}
return nil, nil
}
@@ -0,0 +1,50 @@
package datasourcecheck
import (
"context"
"fmt"
"github.com/grafana/grafana-app-sdk/logging"
advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/util"
)
type uidValidationStep struct{}
func (s *uidValidationStep) ID() string {
return UIDValidationStepID
}
func (s *uidValidationStep) Title() string {
return "UID validation"
}
func (s *uidValidationStep) Description() string {
return "Checks if the UID of a data source is valid."
}
func (s *uidValidationStep) Resolution() string {
return "Check the <a href='https://grafana.com/docs/grafana/latest/upgrade-guide/upgrade-v11.2/#grafana-data-source-uid-format-enforcement'" +
"target=_blank>documentation</a> for more information or delete the data source and create a new one."
}
func (s *uidValidationStep) Run(ctx context.Context, log logging.Logger, obj *advisor.CheckSpec, i any) ([]advisor.CheckReportFailure, error) {
ds, ok := i.(*datasources.DataSource)
if !ok {
return nil, fmt.Errorf("invalid item type %T", i)
}
// Data source UID validation
err := util.ValidateUID(ds.UID)
if err != nil {
return []advisor.CheckReportFailure{checks.NewCheckReportFailure(
advisor.CheckReportFailureSeverityLow,
s.ID(),
fmt.Sprintf("%s (%s)", ds.Name, ds.UID),
ds.UID,
[]advisor.CheckErrorLink{},
)}, nil
}
return nil, nil
}
+18
View File
@@ -39,6 +39,24 @@ func NewCheckReportFailure(
}
}
func NewCheckReportFailureWithMoreInfo(
severity advisor.CheckReportFailureSeverity,
stepID string,
item string,
itemID string,
links []advisor.CheckErrorLink,
moreInfo string,
) advisor.CheckReportFailure {
return advisor.CheckReportFailure{
Severity: severity,
StepID: stepID,
Item: item,
ItemID: itemID,
Links: links,
MoreInfo: &moreInfo,
}
}
func GetNamespace(stackID string) (string, error) {
if stackID == "" {
return metav1.NamespaceDefault, nil
@@ -39,6 +39,7 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -70,7 +71,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -96,10 +97,12 @@
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -144,6 +147,7 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -175,7 +179,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -201,10 +205,12 @@
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -248,6 +254,7 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -279,7 +286,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -305,11 +312,12 @@
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "10.3.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"csvContent": "time, val\n2023-11-20 12:09:00, 1\n2023-11-20 12:09:02, 2\n2023-11-20 12:09:03, 3\n2023-11-20 12:09:04, 4\n2023-11-20 12:09:05, 5\n2023-11-20 12:09:06, 6\n2023-11-20 12:09:07, 2\n2023-11-20 12:09:08, 3\n2023-11-20 12:09:09, 4\n2023-11-20 12:09:10, 1\n2023-11-20 12:09:11, 2\n2023-11-20 12:09:12, 3\n2023-11-20 12:09:13, 4",
@@ -328,6 +336,7 @@
"options": {
"conversions": [
{
"dateFormat": "YYYY-MM-DD hh:mm:ss",
"destinationType": "time",
"targetField": "time"
}
@@ -374,6 +383,7 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -405,7 +415,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -431,10 +441,12 @@
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"csvContent": "time, val\n2023-11-20 12:09:00, 1\n2023-11-20 12:09:02, 2\n2023-11-20 12:09:03, 3\n2023-11-20 12:09:04, 4\n2023-11-20 12:09:05, null\n2023-11-20 12:09:06, 6\n2023-11-20 12:09:07, 2\n2023-11-20 12:09:08, null\n2023-11-20 12:09:09, 4\n2023-11-20 12:09:10, 1\n2023-11-20 12:09:11, 2\n2023-11-20 12:09:12, 3\n2023-11-20 12:09:13, 4",
@@ -453,6 +465,7 @@
"options": {
"conversions": [
{
"dateFormat": "YYYY-MM-DD hh:mm:ss",
"destinationType": "time",
"targetField": "time"
}
@@ -499,14 +512,17 @@
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"fillOpacity": 50,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"pointShape": "circle",
"pointSize": {
"fixed": 5
},
"pointStrokeWidth": 1,
"scaleDistribution": {
"type": "linear"
},
@@ -518,7 +534,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -562,22 +578,42 @@
},
"id": 3,
"options": {
"dims": {
"frame": 0
},
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"series": [],
"seriesMapping": "auto",
"mapping": "auto",
"series": [
{
"frame": {
"matcher": {
"id": "byIndex",
"options": 0
}
},
"x": {
"matcher": {
"id": "byType",
"options": "number"
}
},
"y": {
"matcher": {
"id": "byType",
"options": "number"
}
}
}
],
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"csvContent": "x,y\n1,4\n2,1\n3,2\n4,-2\n5,6\n3,2\n1,7\n3,9\n6,3\n5,-3\n2,-2\n7,15",
@@ -654,7 +690,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -677,6 +713,7 @@
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
@@ -684,10 +721,16 @@
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "10.3.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"refId": "A"
}
],
"title": "stat panel",
"transformations": [
{
@@ -728,6 +771,7 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -758,7 +802,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -809,11 +854,12 @@
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "10.3.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"csvContent": "x, val\n6,2\n8,1\n10,5\n15,1\n22,10\n",
@@ -877,14 +923,17 @@
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"fillOpacity": 50,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"pointShape": "circle",
"pointSize": {
"fixed": 5
},
"pointStrokeWidth": 1,
"scaleDistribution": {
"type": "linear"
},
@@ -895,7 +944,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -939,37 +989,42 @@
},
"id": 8,
"options": {
"dims": {
"exclude": [],
"frame": 0,
"x": "foo"
},
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"mapping": "auto",
"series": [
{
"pointColor": {},
"pointSize": {
"field": "baz",
"fixed": 50.5,
"max": 100,
"min": 1
"frame": {
"matcher": {
"id": "byIndex",
"options": 0
}
},
"x": "foo",
"y": "foo"
"x": {
"matcher": {
"id": "byName",
"options": "foo"
}
},
"y": {
"matcher": {
"id": "byType",
"options": "number"
}
}
}
],
"seriesMapping": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "10.3.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -1014,8 +1069,9 @@
"type": "xychart"
}
],
"preload": false,
"refresh": "",
"schemaVersion": 39,
"schemaVersion": 41,
"tags": [
"gdev",
"transform"
@@ -1031,6 +1087,5 @@
"timezone": "",
"title": "Transforms - Regression analysis",
"uid": "d2d2bb99-42e4-44b8-b93e-3ad1aae31c6b",
"version": 39,
"weekStart": ""
"version": 1
}
+49 -76
View File
@@ -2,7 +2,7 @@
aliases:
- ../data-sources/graphite/
- ../features/datasources/graphite/
description: Guide for using Graphite in Grafana
description: Introduction to the Graphite data source in Grafana.
keywords:
- grafana
- graphite
@@ -46,6 +46,36 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
transformations:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/transform-data/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/transform-data/
alerting:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
visualizations:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/
variables:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/dashboards/variables/
annotate-visualizations:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/annotate-visualizations/
set-up-grafana-monitoring:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/set-up-grafana-monitoring/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/set-up-grafana-monitoring/
---
# Graphite data source
@@ -54,87 +84,30 @@ Grafana includes built-in support for Graphite.
This topic explains options, variables, querying, and other features specific to the Graphite data source, which include its feature-rich query editor.
For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:data-source-management).
Only users with the organization administrator role can add data sources.
Once you've added the Graphite data source, you can [configure it](#configure-the-data-source) so that your Grafana instance's users can create queries in its [query editor](query-editor/) when they [build dashboards](ref:build-dashboards) and use [Explore](ref:explore).
{{< docs/play title="Graphite: Sample Website Dashboard" url="https://play.grafana.org/d/000000003/" >}}
## Configure the data source
To configure basic settings for the data source, complete the following steps:
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `Graphite` in the search bar.
1. Click **Graphite**.
The **Settings** tab of the data source is displayed.
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. You can set only one default data source per organization. |
| **URL** | Sets the HTTP protocol, IP, and port of your graphite-web or graphite-api installation. |
| **Auth** | For details, refer to [Configure Authentication](ref:configure-authentication). |
| **Basic Auth** | Enables basic authentication to the data source. |
| **User** | Sets the user name for basic authentication. |
| **Password** | Sets the password for basic authentication. |
| **Custom HTTP Headers** | Click **Add header** to add a custom HTTP header. |
| **Header** | Defines the custom header name. |
| **Value** | Defines the custom header value. |
You can also configure settings specific to the Graphite data source:
| Name | Description |
| ----------- | -------------------------------------------------------------------------------------------------------- |
| **Version** | Select your version of Graphite. If you are using Grafana Cloud Graphite, this should be set to `1.1.x`. |
| **Type** | Select your type of Graphite. If you are using Grafana Cloud Graphite, this should be set to `Default`. |
### Integrate with Loki
When you change the data source selection in [Explore](ref:explore), Graphite queries are converted to Loki queries.
Grafana extracts Loki label names and values from the Graphite queries according to mappings provided in the Graphite data source configuration.
Queries using tags with `seriesByTags()` are also transformed without any additional setup.
### Provision the data source
You can define and configure the data source in YAML files as part of Grafana's provisioning system.
For more information about provisioning, and for lists of common configuration options and JSON data options, refer to [Provisioning data sources](ref:provisioning-data-sources).
#### Provisioning example
```yaml
apiVersion: 1
datasources:
- name: Graphite
type: graphite
access: proxy
url: http://localhost:8080
jsonData:
graphiteVersion: '1.1'
```
## Query the data source
Grafana includes a Graphite-specific query editor to help you build queries.
The query editor helps you quickly navigate the metric space, add functions, and change function parameters.
It can handle all types of Graphite queries, including complex nested queries through the use of query references.
For details, refer to the [query editor documentation](query-editor/).
## Use template variables
Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables.
Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed in your dashboard.
Grafana refers to such variables as template variables.
For details, see the [template variables documentation](template-variables/).
Grafana exposes metrics for Graphite on the `/metrics` endpoint.
For detailed instructions, refer to [Internal Grafana metrics](ref:internal-grafana-metrics).
## Get Grafana metrics into Graphite
Grafana exposes metrics for Graphite on the `/metrics` endpoint.
For detailed instructions, refer to [Internal Grafana metrics](ref:internal-grafana-metrics).
Refer to [Internal Grafana metrics](ref:set-up-grafana-monitoring) for more information.
## Graphite and Loki integration
When you change the data source selection in [Explore](ref:explore), Graphite queries are converted to Loki queries.
Grafana extracts Loki label names and values from the Graphite queries according to mappings provided in the Graphite data source configuration. Grafana automatically transforms queries using tags with `seriesByTags()` without requiring additional setup.
## Get the most out of the data source
After installing and configuring the Graphite data source you can:
- Create a wide variety of [visualizations](ref:visualizations)
- Configure and use [templates and variables](ref:variables)
- Add [transformations](ref:transformations)
- Add [annotations](ref:annotate-visualizations)
- Set up [alerting](ref:alerting)
@@ -0,0 +1,179 @@
---
aliases:
- ../data-sources/graphite/
- ../datasources/graphite/
- ../features/datasources/graphite/
description: This document provides instructions for configuring the Graphite data source.
keywords:
- grafana
- graphite
- guide
labels:
products:
- cloud
- enterprise
- oss
menuTitle: Configure
title: Configure the Graphite data source
weight: 100
refs:
explore:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
provisioning-data-sources:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
internal-grafana-metrics:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/set-up-grafana-monitoring/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/set-up-grafana-monitoring/
build-dashboards:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
configure-authentication:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/
data-source-management:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
private-data-source-connect:
- pattern: /docs/grafana/
destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
- pattern: /docs/grafana-cloud/
destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
configure-pdc:
- pattern: /docs/grafana/
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc
---
# Configure the Graphite data source
This document provides instructions for configuring the Graphite data source and explains available configuration options. For general information on managing data sources, refer to [Data source management](ref:data-source-management).
## Before you begin
- You must have the `Organization administrator` role to configure the Graphite data source.
Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system.
- Grafana comes with a built-in Graphite data source plugin, eliminating the need to install a plugin.
- Familiarize yourself with your Graphite security configuration and gather any necessary security certificates and client keys.
## Add the Graphite data source
To configure basic settings for the data source, complete the following steps:
1. Click **Connections** in the left-side menu.
1. Click **Add new connection**
1. Type `Graphite` in the search bar.
1. Select the **Graphite data source**.
1. Click **Add new data source** in the upper right.
Grafana takes you to the **Settings** tab, where you will set up your Graphite configuration.
## Configuration options in the UI
Following is a list of configuration options for Graphite.
| Setting | Description |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | The display name for the data source. This is how you'll reference it in panels and queries. <br>Examples: `graphite-1`, `graphite-metrics`. |
| **Default** | When enabled, sets this data source as the default for dashboard panels. It will be automatically selected when creating new panels. |
**HTTP:**
| Setting | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **URL** | Sets the HTTP protocol, IP, and port of your `graphite-web` or `graphite-api` installation. <br>Since the access method is set to _Server_, the URL must be accessible from the Grafana backend. |
| **Allowed cookies** | By default, Grafana removes forwarded cookies. Specify cookie names here to allow them to be forwarded to the data source. |
| **Timeout** | Sets the HTTP request timeout in seconds. |
**Auth:**
| **Setting** | **Description** |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Basic Auth** | Toggle on to enable basic authentication to the data source. |
| &nbsp;&nbsp;**User** | Sets the username used for basic authentication. |
| &nbsp;&nbsp;**Password** | Enter the password used for basic authentication. |
| **With Credentials** | Toggle on to include cookies and authentication headers in cross-origin requests. |
| **TLS Client Auth** | Toggle on to enable TLS client authentication (both server and client are verified). |
| &nbsp;&nbsp;**ServerName** | The server name used to verify the hostname on the certificate returned by the server. |
| &nbsp;&nbsp;**Client Cert** | Client certificate generated by a Certificate Authority (CA) or self-signed. |
| &nbsp;&nbsp;**Client Key** | Private key used to encrypt communication between the client and server. Also generated by a CA or self-signed. |
| **With CA Cert** | Toggle on to authenticate with a CA certificate. |
| &nbsp;&nbsp;**CA Cert** | CA certificate used to validate the server certificate. |
| **Skip TLS Verify** | Toggle on to bypass TLS certificate validation. Not recommended unless necessary or for testing purposes. |
| **Forward OAuth Identity** | Toggle on to forward the user's upstream OAuth identity to the data source. Grafana includes the access token in the request. |
**Custom HTTP Headers:**
Pass along additional information and metadata about the request or response.
| **Setting** | **Description** |
| ----------- | ---------------------------------------------------------------------------------------------------------- |
| **Header** | Add a custom header. This allows custom headers to be passed based on the needs of your Graphite instance. |
| **Value** | The value of the header. |
**Graphite details:**
| **Setting** | **Description** |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Version** | Select your Graphite version from the drop-down. This controls which functions are available in the Graphite query editor. Use `1.1.x` for Grafana Cloud Graphite. |
| **Graphite backend type** | Select the Graphite backend type. Choosing `Metrictank` enables additional features like query processing metadata. (`Metrictank` is a multi-tenant time series engine compatible with Graphite.) Use `Default` for Grafana Cloud Graphite. |
| **Rollup indicator** | Toggle on to display an info icon in panel headers when data aggregation (rollup) occurs. Only available when `Metrictank` is selected. |
**Label mappings:**
Label mappings are the rules you define to tell Grafana how to pull pieces of the Graphite metric path into Loki labels when switching data sources. They are currently only supported between Graphite and Loki queries.
When you change your data source from Graphite to Loki, your queries are automatically mapped based on the rules you define. To create a mapping, specify the full path of the metric and replace the nodes you want to map with label names, using parentheses. The corresponding label values are extracted from your Graphite query during the data source switch.
Grafana automatically maps all Graphite tags to labels, even if you haven’t defined explicit mappings. When using matching patterns with `{}`(e.g., `metric.{a,b}.value`), Grafana converts them to Loki’s regular expression matching syntax. If your queries include functions, Graphite extracts the relevant metrics and tags, then matches them against your mappings.
| **Graphite Query** | **Mapped to Loki Query** |
| -------------------------------------------------------- | -------------------------------- |
| `alias(servers.west.001.cpu,1,2)` | `{cluster="west", server="001"}` |
| `alias(servers.*.{001,002}.*,1,2)` | `{server=~"(001,002)"}` |
| `interpolate(seriesByTag('foo=bar', 'server=002'), inf)` | `{foo="bar", server="002"}` |
| **Setting** | **Description** |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Private data source connect** | _Only for Grafana Cloud users._ Establishes a private, secured connection between a Grafana Cloud stack and data sources within a private network. Use the drop-down to locate the PDC URL. For setup instructions, refer to [Private data source connect (PDC)](ref:private-data-source-connect) and [Configure PDC](ref:configure-pdc). Click **Manage private data source connect** to open your PDC connection page and view your configuration details. |
|
After configuring your Graphite data source options, click **Save & test** at the bottom to test the connection. You should see a confirmation dialog box that says:
**Data source is working**
## Provision the data source
You can define and configure the data source in YAML files as part of the Grafana provisioning system.
For more information about provisioning, and for lists of common configuration options and JSON data options, refer to [Provisioning data sources](ref:provisioning-data-sources).
Example Graphite YAML provisioning file:
```yaml
apiVersion: 1
datasources:
- name: Graphite
type: graphite
access: proxy
url: http://localhost:8080
jsonData:
graphiteVersion: '1.1'
```
@@ -1,7 +1,7 @@
---
aliases:
- ../../data-sources/graphite/query-editor/
description: Guide for using the Graphite data source's query editor
description: Guide for using the Graphite data source query editor.
keywords:
- grafana
- microsoft
@@ -41,45 +41,53 @@ refs:
Grafana includes a Graphite-specific query editor to help you build queries.
The query editor helps you quickly navigate the metric space, add functions, and change function parameters.
It can handle all types of Graphite queries, including complex nested queries through the use of query references.
It supports a variety of Graphite queries, including complex nested queries, through the use of query references.
For general documentation on querying data sources in Grafana, see [Query and transform data](ref:query-transform-data).
## View the raw query
## Query editor elements
To see the raw text of the query that Grafana sends to Graphite, click the **Toggle text edit mode** (pencil) icon.
The query editor consists of the following elements:
- **Series** - A series in Graphite is a unique time-series dataset, represented by a specific metric name and timestamped values. Click **select metric** to select a metric from the drop-down.
- **Functions** - Graphite uses functions to manipulate data. Click the **+ sign** to view a list of functions in the drop-down. You can create a query with multiple functions.
To view the raw query, click the **Pencil icon** in the upper right. Click the **Pencil icon** again to continue adding series and functions.
## Choose metrics to query
Click **Select metric** to navigate the metric space.
Once you begin, you can use the mouse or keyboard arrow keys.
You can also select a wildcard and still continue.
Click **Select metric** to browse the available metrics. You can navigate using your mouse or arrow keys. You can also select a wildcard.
{{< figure src="/static/img/docs/graphite/graphite-query-editor-still.png" animated-gif="/static/img/docs/graphite/graphite-query-editor.gif" >}}
## Functions
Click the plus icon next to **Function** to add a function. You can search for the function or select it from the menu. Once
a function is selected, it will be added and your focus will be in the text box of the first parameter.
Click the **+ sign** next to **Function** to add a function from the drop-down. You can also search by typing the first few letters of the function name.
- To edit or change a parameter, click on it and it will turn into a text box.
- To delete a function, click the function name followed by the x icon.
After selecting a function, Grafana adds it to your query and automatically places your cursor in the first parameter field.
To edit a parameter, click it to open an editable text box.
To remove a function simply click on it, then click the **X icon** that appears above it.
{{< figure src="/static/img/docs/graphite/graphite-functions-still.png" animated-gif="/static/img/docs/graphite/graphite-functions-demo.gif" >}}
Some functions like aliasByNode support an optional second argument. To add an argument, hover your mouse over the first argument and then click the `+` symbol that appears. To remove the second optional parameter, click on it and leave it blank and the editor will remove it.
Some functions like `aliasByNode` support an optional second argument. To add this argument, hover your mouse over the argument and a dialog box appears. To remove the second optional parameter, click on it to delete it.
To learn more, refer to [Graphite's documentation on functions](https://graphite.readthedocs.io/en/latest/functions.html).
Refer to [Functions](https://graphite.readthedocs.io/en/latest/functions.html) in the Graphite documentation for more information.
{{< admonition type="warning" >}}
Some functions take a second argument that may be a function that returns a series. If you are adding a second argument that is a function, it is suggested to use a series reference from a second query instead of the function itself. The query editor does not currently support parsing of a second argument that is a function when switching between the query editor and the code editor.
{{< /admonition >}}
{{% admonition type="warning" %}}
Some functions accept a second argument, which can itself be another function that returns a series. If you need to add a second argument that is a function, Grafana recommends using a series reference from a second query instead of embedding the function directly.
Currently, the query editor does not support parsing a second function argument when switching between the query builder and the code editor.
{{% /admonition %}}
### Sort labels
If you have the same labels on multiple graphs, they are both sorted differently and use different colors.
If the same labels appear on multiple graphs, they may be sorted differently and assigned different colors.
To avoid this and consistently order labels by name, use the `sortByName()` function.
To ensure consistent sorting and coloring, use the `sortByName()` function to order labels alphabetically.
### Modify the metric name in my tables or charts
@@ -91,60 +99,52 @@ Grafana consolidates all Graphite metrics so that Graphite doesn't return more d
By default, Grafana consolidates data points using the `avg` function.
To control how Graphite consolidates metrics, use the Graphite `consolidateBy()` function.
{{< admonition type="note" >}}
Legend summary values (max, min, total) can't all be correct at the same time because they are calculated client-side by Grafana.
Depending on your consolidation function, only one or two can be correct at the same time.
{{< /admonition >}}
{{% admonition type="note" %}}
Grafana calculates legend summary values like `max`, `min`, and `total` on the client side, after data has been calculated.
Depending on the consolidation function used, only one or two of these values may be accurate at the same time.
{{% /admonition %}}
### Combine time series
To combine time series, click **Combine** in the **Functions** list.
### Select and explor data with tags
### Select and explore data with tags
In Graphite, _everything_ is a tag.
In Graphite, everything is a tag.
When exploring data, previously selected tags filter the remaining result set.
To select data, use the `seriesByTag` function, which takes tag expressions (`=`, `!=`, `=~`, `!=~`) to filter timeseries.
The Grafana query builder does this for you automatically when you select a tag.
{{< admonition type="note" >}}
The regular expression search can be slow on high-cardinality tags, so try to use other tags to reduce the scope first.
To help reduce the results, start by filtering on a particular name or namespace.
{{< /admonition >}}
{{% admonition type="note" %}}
Regular expression searches can be slow on high-cardinality tags, so try to use other tags to reduce the scope first. To help reduce the results, start by filtering on a particular name or namespace.
{{% /admonition %}}
## Nest queries
## Nested queries
You can reference a query by the "letter" of its row, similar to a spreadsheet.
Grafana lets you reference one query from another using its query letter, similar to how cell references work in a spreadsheet.
If you add a second query to a graph, you can reference the first query by entering `#A`.
This helps you build compounded queries.
For example, if you add a second query and want to build on the results of query A, you can reference it using #A.
This approach allows you to build compound or nested queries, making your panels more flexible and easier to manage.
## Use wildcards to make fewer queries
To view multiple time series plotted on the same graph, use wildcards in your search to return all of the matching time series in one query.
To display multiple time series on the same graph, use wildcards in your query to return all matching series at once.
For example, to see how the CPU is being utilized on a machine, you can create a graph and use the single query `cpu.percent.*.g` to retrieve all time series that match that pattern.
This is more efficient than adding a query for each time series, such as `cpu.percent.user.g`, `cpu.percent.system.g`, and so on, which results in many queries to the data source.
For example, to monitor CPU utilization across a variety of metrics, you can use a single query like `cpu.percent.*.g` to retrieve all matching time series.
This approach is more efficient than writing separate queries for each series, such as `cpu.percent.user.g`, `cpu.percent.system.g`, and others, which would result in multiple queries to the data source.
## Apply annotations
[Annotations](ref:annotate-visualizations) overlay rich event information on top of graphs.
You can add annotation queries in the Dashboard menu's Annotations view.
[Annotations](ref:annotate-visualizations) overlay rich event information on top of graphs. You can add annotation queries in the dashboard menu's **Annotations** view.
Graphite supports two ways to query annotations:
- A regular metric query, using the `Graphite query` textbox.
- A Graphite events query, using the `Graphite event tags` textbox with a tag, wildcard, or empty value
## Get Grafana metrics into Graphite
Grafana exposes metrics for Graphite on the `/metrics` endpoint.
For detailed instructions, refer to [Internal Grafana metrics](ref:set-up-grafana-monitoring).
## Integration with Loki
Graphite queries get converted to Loki queries when the data source selection changes in Explore. Loki label names and values are extracted from the Graphite queries according to mappings information provided in Graphite data source configuration. Queries using tags with `seriesByTags()` are also transformed without any additional setup.
Refer to the Graphite data source settings for more details.
When you change the data source to Loki in Explore, your Graphite queries are automatically converted to Loki queries. Loki label names and values are extracted based on the mapping information defined in your Graphite data source configuration. Grafana automatically transforms queries that use tags with `seriesByTags()` without requiring additional setup.
@@ -1,7 +1,7 @@
---
aliases:
- ../../data-sources/graphite/template-variables/
description: Guide for using template variables when querying the Graphite data source
description: Guide for using template variables when querying the Graphite data source.
keywords:
- grafana
- graphite
@@ -37,122 +37,153 @@ refs:
# Graphite template variables
Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables.
Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed in your dashboard.
Grafana lists these variables in drop-down selection boxes at the top of the dashboard to help you change the data displayed in your dashboard.
Grafana refers to such variables as template variables.
For an introduction to templating and template variables, refer to the [Templating](ref:variables) and [Add and manage variables](ref:add-template-variables) documentation.
## Select a query type
To view an example templated dashboard, refer to [Graphite Templated Nested dashboard](https://play.grafana.org/d/cvDFGseGz/graphite-templated-nested).
There are three query types for Graphite template variables
## Use query variables
| Query Type | Description |
| ----------------- | ------------------------------------------------------------------------------- |
| Default Query | Use functions such as `tags()`, `tag_values()`, `expand(<metric>)` and metrics. |
| Value Query | Returns all the values for a query that includes a metric and function. |
| Metric Name Query | Returns all the names for a query that includes a metric and function. |
With Graphite data sources, you can only create query variables. Grafana supports three specific query types for Graphite-based variables:
| Query type | Description | Example usage |
| --------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------- |
| **Default query** | Allows you to dynamically list metrics, nodes, or tag values using Graphite functions. | `tag_values(apps.*.requests.count, app)` |
| **Value query** | Returns all the values for a query that includes a metric and function. | `tag_values(apps.*.status.*, status)` |
| **Metric name query** | Returns all the names for a query that includes a metric and function. | `apps.*.requests.count` |
### Choose a variable syntax
The Graphite data source supports two variable syntaxes for use in the **Query** field.
![Variable syntax example](/static/img/docs/v2/templated_variable_parameter.png)
Grafana allows two ways to reference variables in a query:
| **Syntax** | **Example** |
| ------------ | ---------------------------------------- |
| `$varname` | `apps.frontend.$server.requests.count` |
| `${varname}` | `apps.frontend.${server}.requests.count` |
- **Shorthand syntax (`$varname`)** is convenient for simple paths but doesn't work when the variable is adjacent to characters (e.g., `cpu$coreLoad`).
- **Full syntax (`${varname}`)** is more flexible and works in any part of the string, including embedded within words.
Choose the format that best fits the structure of your Graphite metric path.
## Use tag variables
To create a variable using tag values, use the Grafana functions `tags` and `tag_values`.
Grafana supports tag-based variables for Graphite, allowing you to dynamically populate drop-downs based on tag keys and values in your metric series. To do this, use the Graphite functions `tags()` and `tag_values()` in your variable queries.
| Query | Description |
| --------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `tags()` | Returns all tags. |
| `tags(server=~backend\*)` | Returns only tags that occur in series matching the filter expression. |
| `tag_values(server)` | Returns tag values for the specified tag. |
| `tag_values(server, server=~backend\*)` | Returns filtered tag values that occur for the specified tag in series matching those expressions. |
| Query | Description |
| --------------------------------------- | ------------------------------------------------------------------------------------------ |
| `tags()` | Returns a list of all tag keys in the Graphite database. |
| `tags(server=~backend\*)` | Returns tag keys only from series that match the provided filter expression. |
| `tag_values(server)` | Returns all values for the specified tag key. |
| `tag_values(server, server=~backend\*)` | Returns tag values for a given key, filtered to only those that appear in matching series. |
Multiple filter expressions and expressions can contain other variables. For example:
You can use multiple filter expressions, and those expressions can include other Grafana variables. For example:
```
tag_values(server, server=~backend\*, app=~${apps:regex})
```
This query returns all server tag values from series where the `server` tag matches backend\* and the `app` tag matches the regex-filtered values from another variable ${apps}.
For details, refer to the [Graphite docs on the autocomplete API for tags](http://graphite.readthedocs.io/en/latest/tags.html#auto-complete-support).
### Use multi-value variables in tag queries
**Using regular expression formatting and the equal tilde operator `=~`:**
Multi-value variables in tag queries use the advanced formatting syntax for variables: `{var:regex}`.
Non-tag queries use the default glob formatting for multi-value variables.
#### Tag expression example
**Using regex formatting and the Equal Tilde operator, `=~`:**
```text
```
server=~${servers:regex}
```
This query tells Grafana to format the selected values in the `servers` variable as a regular expression (e.g., (`server1`|`server2`) if two servers are selected).
For more information, refer to [Advanced variable format options](ref:variable-syntax-advanced-variable-format-options).
### Filter with multiple expressions
When using multi-value variables in tag queries, append `${var:regex}` to the variable name to apply regex formatting.
```
tag_values(server, app=~${apps:regex})
```
This query returns only series where the app tag matches the selected values in $`{apps}`, formatted as a regular expression. `=~` is the regular expression operator
Non-tag queries use the default `glob` formatting for multi-value variables.
## Use other query variables
When writing queries, use the metric find type of query.
When writing queries, use the **metric find** query type to retrieve dynamic values.
For example, a query like `prod.servers.*` fills the variable with all possible values that exist in the wildcard position.
For example, the query `prod.servers.*` populates the variable with all values that exist at the wildcard position (\*).
The results contain all possible values occurring only at the last level of the query.
To get full metric names matching the query, use the `expand` function: `expand(*.servers.*)`.
Note that the results include only the values found at the last level of the query path.
To return full metric paths that match your query, use the expand() function:
```
expand(*.servers.*).
```
### Compare expanded and non-expanded metric search results
The expanded query returns the full names of matching metrics.
In combination with regular expressions, you can use it to extract any part of the metric name.
By contrast, a non-expanded query returns only the last part of the metric name, and doesn't let you extract other parts of metric names.
When querying Graphite metrics in Grafana, you can choose between using an **expanded** or **non-expanded** query:
Given these example metrics:
- **Expanded queries** (using the `expand()` function) return the **full metric paths** that match your query.
- **Non-expanded queries** return only the **last segment** of each matching metric path, which limits your ability to extract or filter based on deeper parts of the metric name.
Expanded queries are especially useful when working with regular expressions to match or extract specific parts of the metric path.
Suppose your Graphite database contains the following metrics:
- `prod.servers.001.cpu`
- `prod.servers.002.cpu`
- `test.servers.001.cpu`
These examples demonstrate how expanded and non-expanded queries can fetch specific parts of the metrics name:
The following table illustrates the difference between expanded and non-expanded queries:
| Non-expanded query | Results | Expanded query | Expanded results |
| ------------------ | ---------- | ------------------------- | ---------------------------------------------------------------- |
| `*` | prod, test | `expand(*)` | prod, test |
| `*.servers` | servers | `expand(*.servers)` | prod.servers, test.servers |
| `test.servers` | servers | `expand(test.servers)` | test.servers |
| `*.servers.*` | 001,002 | `expand(*.servers.*)` | prod.servers.001, prod.servers.002, test.servers.001 |
| `test.servers.*` | 001 | `expand(test.servers.*)` | test.servers.001 |
| `*.servers.*.cpu` | cpu | `expand(*.servers.*.cpu)` | prod.servers.001.cpu, prod.servers.002.cpu, test.servers.001.cpu |
| **Non-expanded query** | **Results** | **Expanded query** | **Expanded results** |
| ---------------------- | -------------- | ------------------------- | ---------------------------------------------------------------------- |
| `*` | `prod`, `test` | `expand(*)` | `prod`, `test` |
| `*.servers` | `servers` | `expand(*.servers)` | `prod.servers`, `test.servers` |
| `test.servers` | `servers` | `expand(test.servers)` | `test.servers` |
| `*.servers.*` | `001`, `002` | `expand(*.servers.*)` | `prod.servers.001`, `prod.servers.002`, `test.servers.001` |
| `test.servers.*` | `001` | `expand(test.servers.*)` | `test.servers.001` |
| `*.servers.*.cpu` | `cpu` | `expand(*.servers.*.cpu)` | `prod.servers.001.cpu`, `prod.servers.002.cpu`, `test.servers.001.cpu` |
The non-expanded query is the same as an expanded query, with a regex matching the last part of the name.
{{% admonition type="note" %}}
A non-expanded query query works like an expanded query but returns only the final segment of each matched metric.
{{% /admonition %}}
You can also create nested variables that use other variables in their definition.
For example, `apps.$app.servers.*` uses the variable `$app` in its query definition.
Grafana also supports **nested variables**, which allow you to reference other variables in a query.
### Use `__searchFilter` to filter query variable results
For example:
You can use `__searchFilter` in the query field to filter the query result based on what the user types in the dropdown select box.
The default value for `__searchFilter` is `*` if you've not entered anything, and `` when used as part of a regular expression.
```
apps.$app.servers.*
```
#### Search filter example
This query uses the selected value of the `$app` variable to dynamically filter the metric path. The variable `$app` contains one or more application names and `servers.*` matches all servers for the given application.
To use `__searchFilter` as part of the query field to enable searching for `server` while the user types in the dropdown select box:
### Filter query variable results with `__searchFilter`
Query
Grafana provides the variable `__searchFilter`, which you can use to dynamically filter query results based on what the user types into the variable drop-down.
When the drop-down is empty or blank, `__searchFilter` defaults to `*`, which means it returns all possible values. If you type a string, Grafana replaces `__searchFilter` with that input.
```bash
To use `__searchFilter` as part of the query field to enable searching for `server` while the user types in the drop-down select box:
Query:
```
apps.$app.servers.$__searchFilter
```
TagValues
TagValues:
```bash
```
tag_values(server, server=~${__searchFilter:regex})
```
## Choose a variable syntax
![variable](/static/img/docs/v2/templated_variable_parameter.png)
The Graphite data source supports two variable syntaxes for use in the **Query** field:
- `$<varname>`, for example `apps.frontend.$server.requests.count`, which is easier to read and write but does not allow you to use a variable in the middle of a word.
- `${varname}`, for example `apps.frontend.${server}.requests.count`, to use in expressions like `my.server${serverNumber}.count`.
### Templated dashboard example
To view an example templated dashboard, refer to [Graphite Templated Nested dashboard](https://play.grafana.org/d/cvDFGseGz/graphite-templated-nested).
@@ -1484,8 +1484,6 @@ There are two different models:
- **Polynomial regression** - Fits a polynomial function to the data.
{{< figure src="/static/img/docs/transformations/polynomial-regression.png" class="docs-image--no-shadow" max-width= "1100px" alt="A time series visualization with a curved line representing the polynomial function" >}}
> **Note:** This transformation is currently in public preview. Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. Enable the `regressionTransformation` feature toggle in Grafana to use this feature. Contact Grafana Support to enable this feature in Grafana Cloud.
[Table panel]: ref:table-panel
[Calculation types]: ref:calculation-types
[sparkline cell type]: ref:sparkline-cell-type
@@ -22,95 +22,94 @@ For more information about feature release stages, refer to [Release life cycle
Most [generally available](https://grafana.com/docs/release-life-cycle/#general-availability) features are enabled by default. You can disable these feature by setting the feature flag to "false" in the configuration.
| Feature toggle name | Description | Enabled by default |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | |
| `publicDashboardsScene` | Enables public dashboard rendering using scenes | Yes |
| `featureHighlights` | Highlight Grafana Enterprise features | |
| `correlations` | Correlations page | Yes |
| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes |
| `nestedFolders` | Enable folder nesting | Yes |
| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes |
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes |
| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes |
| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes |
| `unifiedRequestLog` | Writes error logs to the request logger | Yes |
| `pluginsDetailsRightPanel` | Enables right panel for the plugins details page | Yes |
| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | Yes |
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes |
| `transformationsRedesign` | Enables the transformations redesign | Yes |
| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes |
| `dashgpt` | Enable AI powered features in dashboards | Yes |
| `externalCorePlugins` | Allow core plugins to be loaded as external | Yes |
| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes |
| `formatString` | Enable format string transformer | Yes |
| `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | Yes |
| `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes |
| `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes |
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes |
| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes |
| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes |
| `ssoSettingsApi` | Enables the SSO settings API and the OAuth configuration UIs in Grafana | Yes |
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes |
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes |
| `lokiQueryHints` | Enables query hints for Loki | Yes |
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
| `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. | Yes |
| `groupToNestedTableTransformation` | Enables the group to nested table transformation | Yes |
| `newPDFRendering` | New implementation for the dashboard-to-PDF rendering | Yes |
| `tlsMemcached` | Use TLS-enabled memcached in the enterprise caching feature | Yes |
| `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector | Yes |
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes |
| `newDashboardSharingComponent` | Enables the new sharing drawer design | Yes |
| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | |
| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | Yes |
| `pinNavItems` | Enables pinning of nav items | Yes |
| `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP | Yes |
| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes |
| `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture | Yes |
| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | Yes |
| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | Yes |
| `pluginsSriChecks` | Enables SRI checks for plugin assets | |
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes |
| `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes |
| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes |
| `lokiLabelNamesQueryApi` | Defaults to using the Loki `/labels` API instead of `/series` | Yes |
| `teamHttpHeadersMimir` | Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams | Yes |
| `alertingMigrationUI` | Enables the alerting migration UI, to migrate data source-managed rules to Grafana-managed rules | Yes |
| `alertingImportYAMLUI` | Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules | Yes |
| `unifiedNavbars` | Enables unified navbars | |
| `tabularNumbers` | Use fixed-width numbers globally in the UI | Yes |
| Feature toggle name | Description | Enabled by default |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | |
| `publicDashboardsScene` | Enables public dashboard rendering using scenes | Yes |
| `featureHighlights` | Highlight Grafana Enterprise features | |
| `correlations` | Correlations page | Yes |
| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes |
| `nestedFolders` | Enable folder nesting | Yes |
| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes |
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes |
| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes |
| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes |
| `unifiedRequestLog` | Writes error logs to the request logger | Yes |
| `pluginsDetailsRightPanel` | Enables right panel for the plugins details page | Yes |
| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | Yes |
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes |
| `transformationsRedesign` | Enables the transformations redesign | Yes |
| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes |
| `dashgpt` | Enable AI powered features in dashboards | Yes |
| `externalCorePlugins` | Allow core plugins to be loaded as external | Yes |
| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes |
| `formatString` | Enable format string transformer | Yes |
| `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | Yes |
| `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes |
| `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes |
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes |
| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes |
| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes |
| `ssoSettingsApi` | Enables the SSO settings API and the OAuth configuration UIs in Grafana | Yes |
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes |
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes |
| `lokiQueryHints` | Enables query hints for Loki | Yes |
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
| `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. | Yes |
| `groupToNestedTableTransformation` | Enables the group to nested table transformation | Yes |
| `newPDFRendering` | New implementation for the dashboard-to-PDF rendering | Yes |
| `tlsMemcached` | Use TLS-enabled memcached in the enterprise caching feature | Yes |
| `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector | Yes |
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes |
| `newDashboardSharingComponent` | Enables the new sharing drawer design | Yes |
| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | |
| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | Yes |
| `pinNavItems` | Enables pinning of nav items | Yes |
| `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP | Yes |
| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes |
| `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture | Yes |
| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | Yes |
| `improvedExternalSessionHandling` | Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves. | Yes |
| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | Yes |
| `pluginsSriChecks` | Enables SRI checks for plugin assets | |
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes |
| `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes |
| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes |
| `lokiLabelNamesQueryApi` | Defaults to using the Loki `/labels` API instead of `/series` | Yes |
| `improvedExternalSessionHandlingSAML` | Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly. | Yes |
| `teamHttpHeadersMimir` | Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams | Yes |
| `alertingMigrationUI` | Enables the alerting migration UI, to migrate data source-managed rules to Grafana-managed rules | Yes |
| `alertingImportYAMLUI` | Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules | Yes |
| `unifiedNavbars` | Enables unified navbars | |
| `tabularNumbers` | Use fixed-width numbers globally in the UI | Yes |
## Public preview feature toggles
[Public preview](https://grafana.com/docs/release-life-cycle/#public-preview) features are supported by our Support teams, but might be limited to enablement, configuration, and some troubleshooting.
| Feature toggle name | Description |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `panelTitleSearch` | Search for dashboards using panel title |
| `grpcServer` | Run the GRPC server |
| `renderAuthJWT` | Uses JWT-based auth for rendering instead of relying on remote cache |
| `refactorVariablesTimeRange` | Refactor time range variables flow to reduce number of API calls made when query variables are chained |
| `faroDatasourceSelector` | Enable the data source selector within the Frontend Apps section of the Frontend Observability |
| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel |
| `sqlDatasourceDatabaseSelection` | Enables previous SQL data source dataset dropdown behavior |
| `reportingRetries` | Enables rendering retries for the reporting feature |
| `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches |
| `pdfTables` | Enables generating table data as PDF in reporting |
| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel |
| `regressionTransformation` | Enables regression analysis transformation |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage |
| `tableNextGen` | Allows access to the new react-data-grid based table component. |
| `improvedExternalSessionHandling` | Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves. |
| `enableSCIM` | Enables SCIM support for user and group management |
| `elasticsearchCrossClusterSearch` | Enables cross cluster search in the Elasticsearch datasource |
| `improvedExternalSessionHandlingSAML` | Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly. |
| `alertRuleRestore` | Enables the alert rule restore feature |
| `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source |
| `logsPanelControls` | Enables a control component for the logs panel in Explore |
| Feature toggle name | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `panelTitleSearch` | Search for dashboards using panel title |
| `grpcServer` | Run the GRPC server |
| `renderAuthJWT` | Uses JWT-based auth for rendering instead of relying on remote cache |
| `refactorVariablesTimeRange` | Refactor time range variables flow to reduce number of API calls made when query variables are chained |
| `faroDatasourceSelector` | Enable the data source selector within the Frontend Apps section of the Frontend Observability |
| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel |
| `sqlDatasourceDatabaseSelection` | Enables previous SQL data source dataset dropdown behavior |
| `reportingRetries` | Enables rendering retries for the reporting feature |
| `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches |
| `pdfTables` | Enables generating table data as PDF in reporting |
| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage |
| `tableNextGen` | Allows access to the new react-data-grid based table component. |
| `enableSCIM` | Enables SCIM support for user and group management |
| `elasticsearchCrossClusterSearch` | Enables cross cluster search in the Elasticsearch datasource |
| `alertRuleRestore` | Enables the alert rule restore feature |
| `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source |
| `logsPanelControls` | Enables a control component for the logs panel in Explore |
## Development feature toggles
@@ -365,6 +365,30 @@ RENDERING_DUMPIO=true
}
```
#### Tracing
{{< admonition type="note" >}}
Tracing is supported in the image renderer v3.12.6 and later.
{{< /admonition >}}
Set the tracing URL to enable OpenTelemetry Tracing. The default is empty (disabled).
You can also configure the service name that will be set in the traces. The default is `grafana-image-renderer`.
```bash
RENDERING_TRACING_URL="http://localhost:4318/v1/traces"
```
```json
{
"rendering": {
"tracing": {
"url": "http://localhost:4318/v1/traces",
"serviceName": "grafana-renderer"
}
}
}
```
#### Custom Chrome/Chromium
If you already have [Chrome](https://www.google.com/chrome/) or [Chromium](https://www.chromium.org/)
@@ -580,21 +604,3 @@ RENDERING_VIEWPORT_PAGE_ZOOM_LEVEL=1
}
}
```
#### Tracing
Enable OpenTelemetry Tracing by setting the tracing URL. Default is empty (disabled).
```bash
RENDERING_TRACING_URL="http://localhost:4318/v1/traces"
```
```json
{
"rendering": {
"tracing": {
"url": "http://localhost:4318/v1/traces"
}
}
}
```
@@ -420,10 +420,6 @@ export interface FeatureToggles {
*/
tableSharedCrosshair?: boolean;
/**
* Enables regression analysis transformation
*/
regressionTransformation?: boolean;
/**
* Enables query hints for Loki
* @default true
*/
@@ -667,6 +663,7 @@ export interface FeatureToggles {
alertingQueryAndExpressionsStepMode?: boolean;
/**
* Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves.
* @default true
*/
improvedExternalSessionHandling?: boolean;
/**
@@ -803,6 +800,7 @@ export interface FeatureToggles {
k8SFolderMove?: boolean;
/**
* Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly.
* @default true
*/
improvedExternalSessionHandlingSAML?: boolean;
/**
@@ -976,10 +974,6 @@ export interface FeatureToggles {
*/
alertingBulkActionsInUI?: boolean;
/**
* Use proxy-based read-only objects for plugin extensions instead of deep cloning
*/
extensionsReadOnlyProxy?: boolean;
/**
* Registers AuthZ /apis endpoint
*/
kubernetesAuthzApis?: boolean;
@@ -15,6 +15,7 @@ import {
getCellColors,
getCellOptions,
getDataLinksActionsTooltipUtils,
tooltipOnClickHandler,
} from '../utils';
export const DefaultCell = (props: TableCellProps) => {
@@ -88,9 +89,7 @@ export const DefaultCell = (props: TableCellProps) => {
{...rest}
className={cellStyle}
style={{ ...cellProps.style, cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
onClick={({ clientX, clientY }) => {
setTooltipCoords({ clientX, clientY });
}}
onClick={tooltipOnClickHandler(setTooltipCoords)}
>
{shouldShowLink ? (
renderSingleLink(links[0], value, getLinkStyle(tableStyles, cellOptions))
@@ -3,7 +3,12 @@ import { useState } from 'react';
import { getCellLinks } from '../../../utils/table';
import { DataLinksActionsTooltip, renderSingleLink } from '../DataLinksActionsTooltip';
import { TableCellDisplayMode, TableCellProps } from '../types';
import { DataLinksActionsTooltipCoords, getCellOptions, getDataLinksActionsTooltipUtils } from '../utils';
import {
tooltipOnClickHandler,
DataLinksActionsTooltipCoords,
getCellOptions,
getDataLinksActionsTooltipUtils,
} from '../utils';
const DATALINKS_HEIGHT_OFFSET = 10;
@@ -37,9 +42,7 @@ export const ImageCell = (props: TableCellProps) => {
{...cellProps}
className={tableStyles.cellContainer}
style={{ ...cellProps.style, cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
onClick={({ clientX, clientY }) => {
setTooltipCoords({ clientX, clientY });
}}
onClick={tooltipOnClickHandler(setTooltipCoords)}
>
{/* If there are data links/actions, we render them with image */}
{/* Otherwise we simply render the image */}
@@ -7,7 +7,7 @@ import { CellActions } from '../CellActions';
import { DataLinksActionsTooltip, renderSingleLink } from '../DataLinksActionsTooltip';
import { TableCellInspectorMode } from '../TableCellInspector';
import { TableCellProps } from '../types';
import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../utils';
import { tooltipOnClickHandler, DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../utils';
export function JSONViewCell(props: TableCellProps): JSX.Element {
const { cell, tableStyles, cellProps, field, row } = props;
@@ -37,10 +37,7 @@ export function JSONViewCell(props: TableCellProps): JSX.Element {
return (
<div {...cellProps} className={inspectEnabled ? tableStyles.cellContainerNoOverflow : tableStyles.cellContainer}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<div
className={cx(tableStyles.cellText, txt)}
onClick={({ clientX, clientY }) => setTooltipCoords({ clientX, clientY })}
>
<div className={cx(tableStyles.cellText, txt)} onClick={tooltipOnClickHandler(setTooltipCoords)}>
{shouldShowLink ? (
renderSingleLink(links[0], displayValue)
) : shouldShowTooltip ? (
@@ -89,7 +89,7 @@ export const DataLinksActionsTooltip = ({ links, actions, value, coords, onToolt
<Portal>
<div
ref={refCallback}
{...getReferenceProps({ onClick: (e) => e.stopPropagation() })}
{...getReferenceProps()}
{...getFloatingProps()}
style={floatingStyles}
className={styles.tooltipWrapper}
@@ -8,7 +8,7 @@ import { selectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip';
import { TableCellOptions, TableCellDisplayMode } from '../../types';
import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils';
import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils, tooltipOnClickHandler } from '../../utils';
import { AutoCellProps } from '../types';
import { getCellLinks } from '../utils';
@@ -27,7 +27,7 @@ export default function AutoCell({ value, field, justifyContent, rowIdx, cellOpt
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
className={styles.cell}
onClick={({ clientX, clientY }) => setTooltipCoords({ clientX, clientY })}
onClick={tooltipOnClickHandler(setTooltipCoords)}
style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
data-testid={selectors.components.TablePanel.autoCell}
>
@@ -5,7 +5,7 @@ import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@g
import { BarGauge } from '../../../BarGauge/BarGauge';
import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip';
import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils';
import { tooltipOnClickHandler, DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils';
import { BarGaugeCellProps } from '../types';
import { extractPixelValue, getCellOptions, getAlignmentFactor, getCellLinks } from '../utils';
@@ -78,7 +78,7 @@ export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx, actio
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
onClick={({ clientX, clientY }) => setTooltipCoords({ clientX, clientY })}
onClick={tooltipOnClickHandler(setTooltipCoords)}
>
{shouldShowLink ? (
renderSingleLink(links[0], renderComponent())
@@ -7,7 +7,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip';
import { TableCellDisplayMode } from '../../types';
import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils';
import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils, tooltipOnClickHandler } from '../../utils';
import { ImageCellProps } from '../types';
import { getCellLinks } from '../utils';
@@ -33,9 +33,7 @@ export const ImageCell = ({ cellOptions, field, height, justifyContent, value, r
<div
className={styles.imageContainer}
style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
onClick={({ clientX, clientY }) => {
setTooltipCoords({ clientX, clientY });
}}
onClick={tooltipOnClickHandler(setTooltipCoords)}
>
{shouldShowLink ? (
renderSingleLink(links[0], img)
@@ -6,7 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip';
import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils';
import { tooltipOnClickHandler, DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils';
import { JSONCellProps } from '../types';
import { getCellLinks } from '../utils';
@@ -43,7 +43,7 @@ export const JSONCell = ({ value, justifyContent, field, rowIdx, actions }: JSON
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
className={styles.jsonText}
onClick={({ clientX, clientY }) => setTooltipCoords({ clientX, clientY })}
onClick={tooltipOnClickHandler(setTooltipCoords)}
style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
>
{shouldShowLink ? (
@@ -1,7 +1,7 @@
import 'react-data-grid/lib/styles.css';
import { css, cx } from '@emotion/css';
import { Property } from 'csstype';
import { Key, useLayoutEffect, useMemo, useState } from 'react';
import { Key, ReactNode, useLayoutEffect, useMemo, useState } from 'react';
import {
Cell,
CellRendererProps,
@@ -22,7 +22,7 @@ import { MenuItem } from '../../Menu/MenuItem';
import { Pagination } from '../../Pagination/Pagination';
import { PanelContext, usePanelContext } from '../../PanelChrome';
import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspector';
import { CellColors } from '../types';
import { CellColors, TableCellDisplayMode } from '../types';
import { HeaderCell } from './Cells/HeaderCell';
import { RowExpander } from './Cells/RowExpander';
@@ -55,6 +55,8 @@ import {
getCellOptions,
} from './utils';
type CellRootRenderer = (key: React.Key, props: CellRendererProps<TableRow, TableSummaryRow>) => React.ReactNode;
export function TableNG(props: TableNGProps) {
const {
cellHeight,
@@ -137,10 +139,6 @@ export function TableNG(props: TableNGProps) {
// vt scrollbar accounting for column auto-sizing
const visibleFields = useMemo(() => getVisibleFields(data.fields), [data.fields]);
const visibleFieldsByDisplayName: Record<string, Field> = useMemo(
() => visibleFields.reduce((acc, f) => ({ ...acc, [getDisplayName(f)]: f }), {}),
[visibleFields]
);
const availableWidth = useMemo(
() => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width),
[width, hasNestedFrames]
@@ -175,11 +173,6 @@ export function TableNG(props: TableNGProps) {
[data, enableSharedCrosshair, expandedRows, panelContext]
);
const renderCell = useMemo(
() => renderCellFactory(columnTypes, applyToRowBgFn, rowHeight, textWraps, theme, visibleFieldsByDisplayName),
[columnTypes, applyToRowBgFn, rowHeight, textWraps, theme, visibleFieldsByDisplayName]
);
const commonDataGridProps = useMemo(
() =>
({
@@ -240,9 +233,19 @@ export function TableNG(props: TableNGProps) {
]
);
const columns = useMemo<TableColumn[]>((): TableColumn[] => {
const columnsFromFields = (f: Field[], w: number[]): TableColumn[] =>
f.map((field, i): TableColumn => {
interface Schema {
columns: TableColumn[];
cellRootRenderers: Record<string, CellRootRenderer>;
}
const { columns, cellRootRenderers } = useMemo(() => {
const fromFields = (f: Field[], widths: number[]) => {
const result: Schema = {
columns: [],
cellRootRenderers: {},
};
f.forEach((field, i) => {
const justifyContent = getTextAlign(field);
const footerStyles = getFooterStyles(justifyContent);
const displayName = getDisplayName(field);
@@ -253,7 +256,7 @@ export function TableNG(props: TableNGProps) {
const cellInspect = Boolean(field.config.custom?.inspect);
const showFilters = Boolean(field.config.filterable && onCellFilterAdded != null);
const showActions = cellInspect || showFilters;
const width = w[i];
const width = widths[i];
const frame = data;
// helps us avoid string cx and emotion per-cell
@@ -265,54 +268,99 @@ export function TableNG(props: TableNGProps) {
)
: undefined;
return {
const cellType = cellOptions.type;
const fieldType = columnTypes[displayName];
const shouldWrap = textWraps[displayName];
const shouldOverflow = shouldTextOverflow(fieldType, cellType, shouldWrap, cellInspect);
let lastRowIdx = -1;
let _rowHeight = 0;
// this fires first
const renderCellRoot = (key: Key, props: CellRendererProps<TableRow, TableSummaryRow>): ReactNode => {
const rowIdx = props.row.__index;
const value = props.row[props.column.key];
// meh, this should be cached by the renderRow() call?
if (rowIdx !== lastRowIdx) {
_rowHeight = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
lastRowIdx = rowIdx;
}
let colors: CellColors;
if (applyToRowBgFn != null) {
colors = applyToRowBgFn(props.rowIdx);
} else if (cellType !== TableCellDisplayMode.Auto) {
const displayValue = field.display!(value); // this fires here to get colors, then again to get rendered value?
colors = getCellColors(theme, cellOptions, displayValue);
} else {
colors = {};
}
const cellStyle = getCellStyles(theme, field, _rowHeight, shouldWrap, shouldOverflow, colors);
return (
<Cell
key={key}
{...props}
className={cx(props.className, cellStyle.cell)}
style={{ color: colors.textColor ?? 'inherit' }}
/>
);
};
result.cellRootRenderers[displayName] = renderCellRoot;
// this fires second
const renderCellContent = (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
const rowIdx = props.row.__index;
const value = props.row[props.column.key];
// TODO: defer until click?
const actions = getActions?.(frame, field, props.row.__index, replaceVariables);
return (
<>
{renderFieldCell({
actions,
cellOptions,
frame,
field,
height,
justifyContent,
rowIdx,
theme,
value,
width,
cellInspect,
showFilters,
})}
{showActions && (
<TableCellActions
field={field}
value={value}
cellOptions={cellOptions}
displayName={displayName}
cellInspect={cellInspect}
showFilters={showFilters}
className={cellActionClassName}
setIsInspecting={setIsInspecting}
setContextMenuProps={setContextMenuProps}
onCellFilterAdded={onCellFilterAdded}
/>
)}
</>
);
};
const column: TableColumn = {
field,
key: displayName,
name: displayName,
width,
headerCellClass,
renderCell: (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
// TODO: once per row
const height = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
// TODO: defer until click?
const actions = getActions?.(frame, field, props.row.__index, replaceVariables);
const rowIdx = props.row.__index;
const value = props.row[displayName];
return (
<>
{renderFieldCell({
actions,
cellOptions,
frame,
field,
height,
justifyContent,
rowIdx,
theme,
value,
width,
cellInspect,
showFilters,
})}
{showActions && (
<TableCellActions
field={field}
value={value}
cellOptions={cellOptions}
displayName={displayName}
cellInspect={cellInspect}
showFilters={showFilters}
className={cellActionClassName}
setIsInspecting={setIsInspecting}
setContextMenuProps={setContextMenuProps}
onCellFilterAdded={onCellFilterAdded}
/>
)}
</>
);
},
renderCell: renderCellContent,
renderHeaderCell: ({ column, sortDirection }): JSX.Element => (
<HeaderCell
column={column}
@@ -340,9 +388,14 @@ export function TableNG(props: TableNGProps) {
return <div className={footerStyles.footerCell}>{footerCalcs[i]}</div>;
},
};
result.columns.push(column);
});
const result: TableColumn[] = columnsFromFields(visibleFields, widths);
return result;
};
const result = fromFields(visibleFields, widths);
// handle nested frames rendering from here.
if (!hasNestedFrames) {
@@ -356,13 +409,17 @@ export function TableNG(props: TableNGProps) {
}
const renderRow = renderRowFactory(firstNestedData.fields, panelContext, expandedRows, enableSharedCrosshair);
const expandedColumns = columnsFromFields(
const { columns: nestedColumns, cellRootRenderers: nestedCellRootRenderers } = fromFields(
firstNestedData.fields,
computeColWidths(firstNestedData.fields, availableWidth)
);
const renderCellRoot: CellRootRenderer = (key, props) => nestedCellRootRenderers[props.column.key](key, props);
result.cellRootRenderers.expanded = (key, props) => <Cell key={key} {...props} />;
// If we have nested frames, we need to add a column for the row expansion
result.unshift({
result.columns.unshift({
key: 'expanded',
name: '',
field: {
@@ -372,16 +429,16 @@ export function TableNG(props: TableNGProps) {
values: [],
},
cellClass(row) {
if (Number(row.__depth) !== 0) {
if (row.__depth !== 0) {
return styles.cellNested;
}
return;
},
colSpan(args) {
return args.type === 'ROW' && Number(args.row.__depth) === 1 ? data.fields.length : 1;
return args.type === 'ROW' && args.row.__depth === 1 ? data.fields.length : 1;
},
renderCell: ({ row }) => {
if (Number(row.__depth) === 0) {
if (row.__depth === 0) {
return (
<RowExpander
height={defaultRowHeight}
@@ -405,9 +462,9 @@ export function TableNG(props: TableNGProps) {
<DataGrid<TableRow, TableSummaryRow>
{...commonDataGridProps}
className={cx(styles.grid, styles.gridNested)}
columns={expandedColumns}
columns={nestedColumns}
rows={expandedRecords}
renderers={{ renderRow, renderCell }}
renderers={{ renderRow, renderCell: renderCellRoot }}
/>
);
},
@@ -433,7 +490,6 @@ export function TableNG(props: TableNGProps) {
onCellFilterAdded,
panelContext,
replaceVariables,
renderCell,
rows,
rowHeight,
setFilter,
@@ -443,6 +499,10 @@ export function TableNG(props: TableNGProps) {
theme,
visibleFields,
widths,
applyToRowBgFn,
columnTypes,
height,
textWraps,
]);
// invalidate columns on every structureRev change. this supports width editing in the fieldConfig.
@@ -454,6 +514,10 @@ export function TableNG(props: TableNGProps) {
const displayedEnd = pageRangeEnd;
const numRows = sortedRows.length;
const renderCellRoot: CellRootRenderer = (key, props) => {
return cellRootRenderers[props.column.key](key, props);
};
return (
<>
<DataGrid<TableRow, TableSummaryRow>
@@ -471,7 +535,7 @@ export function TableNG(props: TableNGProps) {
}
: null
}
renderers={{ renderRow, renderCell }}
renderers={{ renderRow, renderCell: renderCellRoot }}
/>
{enablePagination && (
@@ -538,11 +602,11 @@ const renderRowFactory =
) =>
(key: React.Key, props: RenderRowProps<TableRow, TableSummaryRow>): React.ReactNode => {
const { row } = props;
const rowIdx = Number(row.__index);
const rowIdx = row.__index;
const isExpanded = !!expandedRows[rowIdx];
// Don't render non expanded child rows
if (Number(row.__depth) === 1 && !isExpanded) {
if (row.__depth === 1 && !isExpanded) {
return null;
}
@@ -573,63 +637,6 @@ const renderRowFactory =
return <Row key={key} {...props} {...handlers} />;
};
/**
* passed to the top-level `renderCell` prop on DataGrid. This applies all per-cell styles.
*/
const renderCellFactory =
(
columnTypes: Record<string, FieldType>,
applyToRowBgFn: ((rowIdx: number) => CellColors) | undefined,
rowHeight: number | ((row: TableRow) => number),
textWraps: Record<string, boolean>,
theme: GrafanaTheme2,
visibleFieldsByDisplayName: Record<string, Field>
) =>
(key: Key, props: CellRendererProps<TableRow, TableSummaryRow>) => {
const displayName = props.column.key;
const field = visibleFieldsByDisplayName[displayName];
// exit early if we fail to look up the field from the column key.
if (!field) {
return <Cell key={key} {...props} />;
}
const cellOptions = getCellOptions(field);
const cellType = cellOptions.type;
const value = props.row[props.column.key];
const colors: CellColors = (() => {
if (applyToRowBgFn) {
return applyToRowBgFn(props.rowIdx);
}
const displayValue = field.display?.(value);
if (displayValue && cellOptions) {
return getCellColors(theme, cellOptions, displayValue);
}
return {};
})();
const rh = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
const shouldOverflow = shouldTextOverflow(
displayName,
columnTypes,
textWraps[getDisplayName(field)],
field,
cellType
);
const shouldWrap = textWraps[displayName] ?? false;
const cellStyle = getCellStyles(theme, field, rh, shouldWrap, shouldOverflow, colors);
return (
<Cell
key={key}
{...props}
className={cx(props.className, cellStyle.cell)}
style={{ color: colors.textColor ?? 'inherit' }}
/>
);
};
const getGridStyles = (
theme: GrafanaTheme2,
{ enablePagination, noHeader }: { enablePagination?: boolean; noHeader?: boolean }
@@ -74,21 +74,14 @@ export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight?: TableCell
* Returns true if text overflow handling should be applied to the cell.
*/
export function shouldTextOverflow(
key: string,
columnTypes: ColumnTypes,
fieldType: FieldType,
cellType: TableCellDisplayMode,
textWrap: boolean,
field: Field,
cellType: TableCellDisplayMode
cellInspect: boolean
): boolean {
const cellInspect = field.config?.custom?.inspect ?? false;
// Tech debt: Technically image cells are of type string, which is misleading (kinda?)
// so we need to ensure we don't apply overflow hover states fo type image
if (textWrap || cellInspect || cellType === TableCellDisplayMode.Image || columnTypes[key] !== FieldType.string) {
return false;
}
return true;
return fieldType === FieldType.string && cellType !== TableCellDisplayMode.Image && !textWrap && !cellInspect;
}
/**
@@ -775,3 +775,21 @@ export const getDataLinksActionsTooltipUtils = (links: LinkModel[], actions?: Ac
return { shouldShowLink, hasMultipleLinksOrActions };
};
const shouldTriggerTooltip = (event: React.MouseEvent<HTMLElement>): boolean => {
return event.target === event.currentTarget;
};
/**
* Creates an onClick handler for table cells that only triggers tooltip when clicking directly on the cell
* @param setTooltipCoords - function to set tooltip coordinates
* @returns onClick handler
*/
export const tooltipOnClickHandler = (setTooltipCoords: (coords: DataLinksActionsTooltipCoords) => void) => {
return (event: React.MouseEvent<HTMLElement>) => {
if (shouldTriggerTooltip(event)) {
const { clientX, clientY } = event;
setTooltipCoords({ clientX, clientY });
}
};
};
+2
View File
@@ -59,7 +59,9 @@ type SecureValueSpec struct {
// The raw value is only valid for write. Read/List will always be empty.
// There is no support for mixing `value` and `ref`, you can't create a secret in a third-party keeper with a specified `ref`.
// Minimum and maximum lengths in bytes.
// +k8s:validation:minLength=1
// +k8s:validation:maxLength=24576
Value ExposedSecureValue `json:"value,omitempty"`
// When using a third-party keeper, the `ref` is used to reference a value inside the remote storage.
@@ -641,8 +641,9 @@ func schema_pkg_apis_secret_v0alpha1_SecureValueSpec(ref common.ReferenceCallbac
},
"value": {
SchemaProps: spec.SchemaProps{
Description: "The raw value is only valid for write. Read/List will always be empty. There is no support for mixing `value` and `ref`, you can't create a secret in a third-party keeper with a specified `ref`.",
Description: "The raw value is only valid for write. Read/List will always be empty. There is no support for mixing `value` and `ref`, you can't create a secret in a third-party keeper with a specified `ref`. Minimum and maximum lengths in bytes.",
MinLength: ptr.To[int64](1),
MaxLength: ptr.To[int64](24576),
Type: []string{"string"},
Format: "",
},
+18 -18
View File
@@ -4,25 +4,25 @@ const (
// All includes all modules necessary for Grafana to run as a standalone server
All string = "all"
Core string = "core"
MemberlistKV string = "memberlistkv"
GrafanaAPIServer string = "grafana-apiserver"
StorageRing string = "storage-ring"
Distributor string = "distributor"
StorageServer string = "storage-server"
ZanzanaServer string = "zanzana-server"
InstrumentationServer string = "instrumentation-server"
FrontendServer string = "frontend-server"
Core string = "core"
MemberlistKV string = "memberlistkv"
GrafanaAPIServer string = "grafana-apiserver"
SearchServerRing string = "search-server-ring"
SearchServerDistributor string = "search-server-distributor"
StorageServer string = "storage-server"
ZanzanaServer string = "zanzana-server"
InstrumentationServer string = "instrumentation-server"
FrontendServer string = "frontend-server"
)
var dependencyMap = map[string][]string{
MemberlistKV: {InstrumentationServer},
StorageRing: {InstrumentationServer, MemberlistKV},
GrafanaAPIServer: {InstrumentationServer},
StorageServer: {InstrumentationServer, StorageRing},
ZanzanaServer: {InstrumentationServer},
Distributor: {InstrumentationServer, MemberlistKV, StorageRing},
Core: {},
All: {Core},
FrontendServer: {},
MemberlistKV: {InstrumentationServer},
SearchServerRing: {InstrumentationServer, MemberlistKV},
GrafanaAPIServer: {InstrumentationServer},
StorageServer: {InstrumentationServer, SearchServerRing},
ZanzanaServer: {InstrumentationServer},
SearchServerDistributor: {InstrumentationServer, MemberlistKV, SearchServerRing},
Core: {},
All: {Core},
FrontendServer: {},
}
+6 -5
View File
@@ -100,7 +100,7 @@ func (c *Client) SendReq(ctx context.Context, url *url.URL, compatOpts CompatOpt
return io.ReadAll(bodyReader)
}
func (c *Client) downloadFile(ctx context.Context, tmpFile *os.File, pluginURL, checksum string, compatOpts CompatOpts) (err error) {
func (c *Client) downloadFile(ctx context.Context, tmpFile *os.File, pluginURL, expectedChecksum string, compatOpts CompatOpts) (err error) {
// Try handling URL as a local file path first
if _, err := os.Stat(pluginURL); err == nil {
// TODO re-verify
@@ -136,7 +136,7 @@ func (c *Client) downloadFile(ctx context.Context, tmpFile *os.File, pluginURL,
if err != nil {
return
}
err = c.downloadFile(ctx, tmpFile, pluginURL, checksum, compatOpts)
err = c.downloadFile(ctx, tmpFile, pluginURL, expectedChecksum, compatOpts)
} else {
c.retryCount = 0
failure := fmt.Sprintf("%v", r)
@@ -169,7 +169,7 @@ func (c *Client) downloadFile(ctx context.Context, tmpFile *os.File, pluginURL,
if c.retryCount < 3 {
c.retryCount++
c.log.Debug("Failed downloading. Will retry.")
err = c.downloadFile(ctx, tmpFile, pluginURL, checksum, compatOpts)
err = c.downloadFile(ctx, tmpFile, pluginURL, expectedChecksum, compatOpts)
}
return err
}
@@ -187,8 +187,9 @@ func (c *Client) downloadFile(ctx context.Context, tmpFile *os.File, pluginURL,
if err = w.Flush(); err != nil {
return fmt.Errorf("failed to write to %q: %w", tmpFile.Name(), err)
}
if len(checksum) > 0 && checksum != fmt.Sprintf("%x", h.Sum(nil)) {
return ErrChecksumMismatch(pluginURL)
computedChecksum := fmt.Sprintf("%x", h.Sum(nil))
if len(expectedChecksum) > 0 && expectedChecksum != computedChecksum {
return ErrChecksumMismatch(pluginURL, expectedChecksum, computedChecksum)
}
c.retryCount = 0
+3 -3
View File
@@ -60,7 +60,7 @@ var (
ErrArcNotFoundBase = errutil.NotFound("plugin.archNotFound").
MustTemplate(ErrArcNotFoundMsg, errutil.WithPublic(ErrArcNotFoundMsg))
ErrChecksumMismatchMsg = "expected SHA256 checksum does not match the downloaded archive ({{.Public.ArchiveURL}}) - please contact security@grafana.com"
ErrChecksumMismatchMsg = "expected SHA256 checksum ({{.Public.ExpectedSHA256}}) does not match the downloaded archive ({{.Public.ArchiveURL}}) computed SHA256 checksum ({{.Public.ComputedSHA256}}) - please contact security@grafana.com"
ErrChecksumMismatchBase = errutil.UnprocessableEntity("plugin.checksumMismatch").
MustTemplate(ErrChecksumMismatchMsg, errutil.WithPublic(ErrChecksumMismatchMsg))
@@ -85,8 +85,8 @@ func ErrArcNotFound(pluginID, systemInfo string) error {
return ErrArcNotFoundBase.Build(errutil.TemplateData{Public: map[string]any{"PluginID": pluginID, "SysInfo": systemInfo}})
}
func ErrChecksumMismatch(archiveURL string) error {
return ErrChecksumMismatchBase.Build(errutil.TemplateData{Public: map[string]any{"ArchiveURL": archiveURL}})
func ErrChecksumMismatch(archiveURL, expectedSHA256, computedSHA256 string) error {
return ErrChecksumMismatchBase.Build(errutil.TemplateData{Public: map[string]any{"ArchiveURL": archiveURL, "ExpectedSHA256": expectedSHA256, "ComputedSHA256": computedSHA256}})
}
func ErrCorePlugin(pluginID string) error {
+4 -2
View File
@@ -49,11 +49,13 @@ func TestErrorTemplates(t *testing.T) {
require.Equal(t, "plugin.archNotFound", base.Public().MessageID)
require.Equal(t, "grafana-test-app is not compatible with your system architecture: darwin-amd64", base.Public().Message)
err = ErrChecksumMismatch("http://localhost:6481/grafana-test-app/versions/1.0.0/download")
expectedChecksum := "abcdef1234567890"
computedChecksum := "abcdef0987654321"
err = ErrChecksumMismatch("http://localhost:6481/grafana-test-app/versions/1.0.0/download", expectedChecksum, computedChecksum)
require.True(t, errors.As(err, base))
require.Equal(t, http.StatusUnprocessableEntity, base.Public().StatusCode)
require.Equal(t, "plugin.checksumMismatch", base.Public().MessageID)
require.Equal(t, "expected SHA256 checksum does not match the downloaded archive (http://localhost:6481/grafana-test-app/versions/1.0.0/download) - please contact security@grafana.com", base.Public().Message)
require.Equal(t, "expected SHA256 checksum (abcdef1234567890) does not match the downloaded archive (http://localhost:6481/grafana-test-app/versions/1.0.0/download) computed SHA256 checksum (abcdef0987654321) - please contact security@grafana.com", base.Public().Message)
err = ErrCorePlugin("grafana-test-app")
require.True(t, errors.As(err, base))
+20 -9
View File
@@ -6,6 +6,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
@@ -91,11 +92,16 @@ func (r *converter) toAddCommand(ds *v0alpha1.GenericDataSource) (*datasources.A
if r.group != "" && !strings.HasPrefix(ds.APIVersion, r.group) {
return nil, fmt.Errorf("expecting APIGroup: %s", r.group)
}
info, err := types.ParseNamespace(ds.Namespace)
if err != nil {
return nil, err
}
cmd := &datasources.AddDataSourceCommand{
Name: ds.Spec.Title,
UID: ds.Name,
Type: r.dstype,
Name: ds.Spec.Title,
UID: ds.Name,
OrgID: info.OrgID,
Type: r.dstype,
Access: datasources.DsAccess(ds.Spec.Access),
URL: ds.Spec.URL,
@@ -121,11 +127,16 @@ func (r *converter) toUpdateCommand(ds *v0alpha1.GenericDataSource) (*datasource
if r.group != "" && !strings.HasPrefix(ds.APIVersion, r.group) {
return nil, fmt.Errorf("expecting APIGroup: %s", r.group)
}
info, err := types.ParseNamespace(ds.Namespace)
if err != nil {
return nil, err
}
cmd := &datasources.UpdateDataSourceCommand{
Name: ds.Spec.Title,
UID: ds.Name,
Type: r.dstype,
Name: ds.Spec.Title,
UID: ds.Name,
OrgID: info.OrgID,
Type: r.dstype,
Access: datasources.DsAccess(ds.Spec.Access),
URL: ds.Spec.URL,
@@ -136,15 +147,15 @@ func (r *converter) toUpdateCommand(ds *v0alpha1.GenericDataSource) (*datasource
WithCredentials: ds.Spec.WithCredentials,
IsDefault: ds.Spec.IsDefault,
ReadOnly: ds.Spec.ReadOnly,
// The only field different than add
Version: int(ds.Generation),
}
if len(ds.Spec.JsonData.Object) > 0 {
cmd.JsonData = simplejson.NewFromAny(ds.Spec.JsonData.Object)
}
cmd.SecureJsonData = toSecureJsonData(ds)
// The only thing differnet from the add command???
cmd.Version = int(ds.Generation)
return cmd, nil
}
@@ -8,6 +8,9 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
)
// The maximum size of a secure value in bytes when written as raw input.
const SECURE_VALUE_RAW_INPUT_MAX_SIZE_BYTES = 24576 // 24 KiB
type DecryptSecureValue struct {
Keeper *string
Ref string
@@ -13,6 +13,7 @@ import (
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/endpoints/request"
@@ -245,6 +246,13 @@ func ValidateSecureValue(sv, oldSv *secretv0alpha1.SecureValue, operation admiss
}
// General validations.
if len(sv.Spec.Value) > contracts.SECURE_VALUE_RAW_INPUT_MAX_SIZE_BYTES {
errs = append(
errs,
field.TooLong(field.NewPath("spec", "value"), len(sv.Spec.Value), contracts.SECURE_VALUE_RAW_INPUT_MAX_SIZE_BYTES),
)
}
if errs := validateDecrypters(sv.Spec.Decrypters, decryptersAllowList); len(errs) > 0 {
return errs
}
@@ -301,7 +309,7 @@ func validateSecureValueUpdate(sv, oldSv *secretv0alpha1.SecureValue) field.Erro
return errs
}
// validateDecrypters validates that (if populated) the `decrypters` must match "actor_{name}" and must be unique.
// validateDecrypters validates that (if populated) the `decrypters` must be unique.
func validateDecrypters(decrypters []string, decryptersAllowList map[string]struct{}) field.ErrorList {
errs := make(field.ErrorList, 0)
@@ -319,8 +327,17 @@ func validateDecrypters(decrypters []string, decryptersAllowList map[string]stru
decrypterNames := make(map[string]struct{}, 0)
for i, decrypter := range decrypters {
decrypter = strings.TrimSpace(decrypter)
if decrypter == "" {
errs = append(
errs,
field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, "decrypters cannot be empty if specified"),
)
continue
}
// Allow List: decrypters must match exactly and be in the allowed list to be able to decrypt.
// This means an allow list item should have the format "actor_{name}" and not just "{name}".
if len(decryptersAllowList) > 0 {
if _, exists := decryptersAllowList[decrypter]; !exists {
errs = append(
@@ -334,17 +351,19 @@ func validateDecrypters(decrypters []string, decryptersAllowList map[string]stru
continue
}
actor, name, found := strings.Cut(strings.TrimSpace(decrypter), "_")
if !found || actor != "actor" || name == "" {
errs = append(
errs,
field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, "a decrypter must have the format `actor_{name}`"),
)
// Use the same validation as labels for the decrypters.
if verrs := validation.IsValidLabelValue(decrypter); len(verrs) > 0 {
for _, verr := range verrs {
errs = append(
errs,
field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, verr),
)
}
continue
}
if _, exists := decrypterNames[name]; exists {
if _, exists := decrypterNames[decrypter]; exists {
errs = append(
errs,
field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, "decrypters must be unique"),
@@ -353,7 +372,7 @@ func validateDecrypters(decrypters []string, decryptersAllowList map[string]stru
continue
}
decrypterNames[name] = struct{}{}
decrypterNames[decrypter] = struct{}{}
}
return errs
@@ -4,12 +4,14 @@ import (
"fmt"
"maps"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/admission"
secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
)
func TestValidateSecureValue(t *testing.T) {
@@ -20,7 +22,7 @@ func TestValidateSecureValue(t *testing.T) {
Description: "description",
Value: "value",
Keeper: &keeper,
Decrypters: []string{"actor_app1", "actor_app2"},
Decrypters: []string{"app1", "app2"},
},
}
@@ -50,6 +52,16 @@ func TestValidateSecureValue(t *testing.T) {
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
})
t.Run("`value` cannot exceed 24576 bytes", func(t *testing.T) {
sv := validSecureValue.DeepCopy()
sv.Spec.Value = secretv0alpha1.NewExposedSecureValue(strings.Repeat("a", contracts.SECURE_VALUE_RAW_INPUT_MAX_SIZE_BYTES+1))
sv.Spec.Ref = nil
errs := ValidateSecureValue(sv, nil, admission.Create, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec.value", errs[0].Field)
})
})
t.Run("when updating a securevalue", func(t *testing.T) {
@@ -175,8 +187,8 @@ func TestValidateSecureValue(t *testing.T) {
Description: "description", Ref: &ref,
Decrypters: []string{
"actor_app1",
"actor_app1",
"app1",
"app1",
},
},
}
@@ -186,33 +198,8 @@ func TestValidateSecureValue(t *testing.T) {
require.Equal(t, "spec.decrypters.[1]", errs[0].Field)
})
t.Run("`decrypters` must match the expected format", func(t *testing.T) {
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "description", Ref: &ref,
Decrypters: []string{
"app1",
"_app1",
"actr_app1",
"actor_ ",
"actor_",
},
},
}
errs := ValidateSecureValue(sv, nil, admission.Create, nil)
require.Len(t, errs, len(sv.Spec.Decrypters))
for i, err := range errs {
require.Equal(t, fmt.Sprintf("spec.decrypters.[%d]", i), err.Field)
require.Contains(t, err.Error(), "a decrypter must have the format `actor_{name}`")
}
})
t.Run("when set, the `decrypters` must be one of the allowed in the allow list", func(t *testing.T) {
allowList := map[string]struct{}{"actor_app1": {}, "actor_app2": {}}
allowList := map[string]struct{}{"app1": {}, "app2": {}}
decrypters := slices.Collect(maps.Keys(allowList))
t.Run("no matches, returns an error", func(t *testing.T) {
@@ -221,7 +208,7 @@ func TestValidateSecureValue(t *testing.T) {
Spec: secretv0alpha1.SecureValueSpec{
Description: "description", Ref: &ref,
Decrypters: []string{"actor_app3"},
Decrypters: []string{"app3"},
},
}
@@ -272,10 +259,37 @@ func TestValidateSecureValue(t *testing.T) {
})
})
t.Run("`decrypters` must be a valid label value", func(t *testing.T) {
decrypters := []string{
"", // invalid
"is/this/valid", // invalid
"is this valid", // invalid
"is.this.valid",
"is-this-valid",
"is_this_valid",
"0isthisvalid9",
"isthisvalid9",
"0isthisvalid",
"isthisvalid",
}
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "description", Ref: &ref,
Decrypters: decrypters,
},
}
errs := ValidateSecureValue(sv, nil, admission.Create, nil)
require.Len(t, errs, 3)
})
t.Run("`decrypters` cannot have more than 64 items", func(t *testing.T) {
decrypters := make([]string, 0, 64+1)
for i := 0; i < 64+1; i++ {
decrypters = append(decrypters, fmt.Sprintf("actor_app%d", i))
decrypters = append(decrypters, fmt.Sprintf("app%d", i))
}
ref := "ref"
+17 -8
View File
@@ -53,7 +53,16 @@ func NewModule(opts Options,
return s, nil
}
func newModuleServer(opts Options, apiOpts api.ServerOptions, features featuremgmt.FeatureToggles, cfg *setting.Cfg, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics, reg prometheus.Registerer, promGatherer prometheus.Gatherer, license licensing.Licensing) (*ModuleServer, error) {
func newModuleServer(opts Options,
apiOpts api.ServerOptions,
features featuremgmt.FeatureToggles,
cfg *setting.Cfg,
storageMetrics *resource.StorageMetrics,
indexMetrics *resource.BleveIndexMetrics,
reg prometheus.Registerer,
promGatherer prometheus.Gatherer,
license licensing.Licensing,
) (*ModuleServer, error) {
rootCtx, shutdownFn := context.WithCancel(context.Background())
s := &ModuleServer{
@@ -107,10 +116,10 @@ type ModuleServer struct {
promGatherer prometheus.Gatherer
registerer prometheus.Registerer
MemberlistKVConfig kv.Config
httpServerRouter *mux.Router
storageRing *ring.Ring
storageRingClientPool *ringclient.Pool
MemberlistKVConfig kv.Config
httpServerRouter *mux.Router
searchServerRing *ring.Ring
searchServerRingClientPool *ringclient.Pool
}
// init initializes the server and its services.
@@ -153,8 +162,8 @@ func (s *ModuleServer) Run() error {
})
m.RegisterModule(modules.MemberlistKV, s.initMemberlistKV)
m.RegisterModule(modules.StorageRing, s.initRing)
m.RegisterModule(modules.Distributor, s.initDistributor)
m.RegisterModule(modules.SearchServerRing, s.initSearchServerRing)
m.RegisterModule(modules.SearchServerDistributor, s.initSearchServerDistributor)
m.RegisterModule(modules.Core, func() (services.Service, error) {
return NewService(s.cfg, s.opts, s.apiOpts)
@@ -174,7 +183,7 @@ func (s *ModuleServer) Run() error {
if err != nil {
return nil, err
}
return sql.ProvideUnifiedStorageGrpcService(s.cfg, s.features, nil, s.log, s.registerer, docBuilders, s.storageMetrics, s.indexMetrics, s.storageRing, s.MemberlistKVConfig)
return sql.ProvideUnifiedStorageGrpcService(s.cfg, s.features, nil, s.log, s.registerer, docBuilders, s.storageMetrics, s.indexMetrics, s.searchServerRing, s.MemberlistKVConfig)
})
m.RegisterModule(modules.ZanzanaServer, func() (services.Service, error) {
+7 -7
View File
@@ -25,7 +25,7 @@ import (
var metricsPrefix = resource.RingName + "_"
func (ms *ModuleServer) initRing() (services.Service, error) {
func (ms *ModuleServer) initSearchServerRing() (services.Service, error) {
if !ms.cfg.EnableSharding {
return nil, nil
}
@@ -48,7 +48,7 @@ func (ms *ModuleServer) initRing() (services.Service, error) {
return nil, fmt.Errorf("failed to create KV store client: %s", err)
}
storageRing, err := ring.NewWithStoreClientAndStrategy(
searchServerRing, err := ring.NewWithStoreClientAndStrategy(
toRingConfig(ms.cfg, ms.MemberlistKVConfig),
resource.RingName,
resource.RingKey,
@@ -58,11 +58,11 @@ func (ms *ModuleServer) initRing() (services.Service, error) {
logger,
)
if err != nil {
return nil, fmt.Errorf("failed to initialize storage-ring ring: %s", err)
return nil, fmt.Errorf("failed to initialize index-server-ring ring: %s", err)
}
startFn := func(ctx context.Context) error {
err = storageRing.StartAsync(ctx)
err = searchServerRing.StartAsync(ctx)
if err != nil {
return fmt.Errorf("failed to start the ring: %s", err)
}
@@ -74,10 +74,10 @@ func (ms *ModuleServer) initRing() (services.Service, error) {
return nil
}
ms.storageRing = storageRing
ms.storageRingClientPool = pool
ms.searchServerRing = searchServerRing
ms.searchServerRingClientPool = pool
ms.httpServerRouter.Path("/ring").Methods("GET", "POST").Handler(storageRing)
ms.httpServerRouter.Path("/ring").Methods("GET", "POST").Handler(searchServerRing)
svc := services.NewIdleService(startFn, nil)
@@ -10,18 +10,18 @@ import (
"go.opentelemetry.io/otel"
)
func (ms *ModuleServer) initDistributor() (services.Service, error) {
func (ms *ModuleServer) initSearchServerDistributor() (services.Service, error) {
var (
distributor = &distributorService{}
tracer = otel.Tracer("unified-storage-distributor")
tracer = otel.Tracer("index-server-distributor")
err error
)
distributor.grpcHandler, err = resource.ProvideDistributorServer(ms.cfg, ms.features, ms.registerer, tracer, ms.storageRing, ms.storageRingClientPool)
distributor.grpcHandler, err = resource.ProvideSearchDistributorServer(ms.cfg, ms.features, ms.registerer, tracer, ms.searchServerRing, ms.searchServerRingClientPool)
if err != nil {
return nil, err
}
return services.NewBasicService(nil, distributor.running, nil).WithName(modules.Distributor), nil
return services.NewBasicService(nil, distributor.running, nil).WithName(modules.SearchServerDistributor), nil
}
type distributorService struct {
@@ -273,7 +273,7 @@ func initDistributorServerForTest(t *testing.T, memberlistPort int) testModuleSe
cfg.MemberlistJoinMember = "127.0.0.1:" + strconv.Itoa(memberlistPort)
cfg.MemberlistAdvertiseAddr = "127.0.0.1"
cfg.MemberlistAdvertisePort = memberlistPort
cfg.Target = []string{modules.Distributor}
cfg.Target = []string{modules.SearchServerDistributor}
cfg.InstanceID = "distributor" // does nothing for the distributor but may be useful to debug tests
conn, err := grpc.NewClient(cfg.GRPCServer.Address,
@@ -352,7 +352,18 @@ func createBaselineServer(t *testing.T, dbType, dbConnStr string, testNamespaces
require.NoError(t, err)
searchOpts, err := search.NewSearchOptions(features, cfg, tracer, docBuilders, nil)
require.NoError(t, err)
server, err := sql.NewResourceServer(nil, cfg, tracer, nil, nil, searchOpts, nil, nil, features)
server, err := sql.NewResourceServer(sql.ServerOptions{
DB: nil,
Cfg: cfg,
Tracer: tracer,
Reg: nil,
AccessClient: nil,
SearchOptions: searchOpts,
StorageMetrics: nil,
IndexMetrics: nil,
Features: features,
QOSQueue: nil,
})
require.NoError(t, err)
testUserA := &identity.StaticRequester{
File diff suppressed because one or more lines are too long
+4 -18
View File
@@ -697,13 +697,6 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaDatavizSquad,
},
{
Name: "regressionTransformation",
Description: "Enables regression analysis transformation",
Stage: FeatureStagePublicPreview,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
},
{
// this is mainly used as a way to quickly disable query hints as a safeguard for our infrastructure
Name: "lokiQueryHints",
@@ -1145,7 +1138,8 @@ var (
{
Name: "improvedExternalSessionHandling",
Description: "Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves.",
Stage: FeatureStagePublicPreview,
Stage: FeatureStageGeneralAvailability,
Expression: "true", // enabled by default
Owner: identityAccessTeam,
AllowSelfServe: true,
},
@@ -1367,7 +1361,8 @@ var (
{
Name: "improvedExternalSessionHandlingSAML",
Description: "Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly.",
Stage: FeatureStagePublicPreview,
Stage: FeatureStageGeneralAvailability,
Expression: "true", // enabled by default
Owner: identityAccessTeam,
AllowSelfServe: true,
},
@@ -1675,15 +1670,6 @@ var (
HideFromDocs: true,
Expression: "true", // enabled by default
},
{
Name: "extensionsReadOnlyProxy",
Description: "Use proxy-based read-only objects for plugin extensions instead of deep cloning",
Stage: FeatureStageExperimental,
Owner: grafanaPluginsPlatformSquad,
HideFromAdminPage: true,
HideFromDocs: true,
FrontendOnly: true,
},
{
Name: "kubernetesAuthzApis",
Description: "Registers AuthZ /apis endpoint",
+2 -4
View File
@@ -92,7 +92,6 @@ logsInfiniteScrolling,GA,@grafana/observability-logs,false,false,true
logRowsPopoverMenu,GA,@grafana/observability-logs,false,false,true
pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false
tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true
regressionTransformation,preview,@grafana/dataviz-squad,false,false,true
lokiQueryHints,GA,@grafana/observability-logs,false,false,true
kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true
cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false
@@ -148,7 +147,7 @@ exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false
appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false
groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false
alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true
improvedExternalSessionHandling,preview,@grafana/identity-access-team,false,false,false
improvedExternalSessionHandling,GA,@grafana/identity-access-team,false,false,false
useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,false
rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false
@@ -179,7 +178,7 @@ lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false
investigationsBackend,experimental,@grafana/grafana-app-platform-squad,false,false,false
k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false
k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false
improvedExternalSessionHandlingSAML,preview,@grafana/identity-access-team,false,false,false
improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false
teamHttpHeadersMimir,GA,@grafana/identity-access-team,false,false,false
teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false
templateVariablesUsesCombobox,experimental,@grafana/grafana-frontend-platform,false,false,true
@@ -219,7 +218,6 @@ multiTenantFrontend,experimental,@grafana/grafana-frontend-platform,false,false,
alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true
alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false
alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true
extensionsReadOnlyProxy,experimental,@grafana/plugins-platform-backend,false,false,true
kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false
restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false
skipTokenRotationIfRecent,privatePreview,@grafana/identity-access-team,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
92 logRowsPopoverMenu GA @grafana/observability-logs false false true
93 pluginsSkipHostEnvVars experimental @grafana/plugins-platform-backend false false false
94 tableSharedCrosshair experimental @grafana/dataviz-squad false false true
regressionTransformation preview @grafana/dataviz-squad false false true
95 lokiQueryHints GA @grafana/observability-logs false false true
96 kubernetesFeatureToggles experimental @grafana/grafana-operator-experience-squad false false true
97 cloudRBACRoles preview @grafana/identity-access-team false true false
147 appPlatformGrpcClientAuth experimental @grafana/identity-access-team false false false
148 groupAttributeSync privatePreview @grafana/identity-access-team false false false
149 alertingQueryAndExpressionsStepMode GA @grafana/alerting-squad false false true
150 improvedExternalSessionHandling preview GA @grafana/identity-access-team false false false
151 useSessionStorageForRedirection GA @grafana/identity-access-team false false false
152 rolePickerDrawer experimental @grafana/identity-access-team false false false
153 unifiedStorageSearch experimental @grafana/search-and-storage false false false
178 investigationsBackend experimental @grafana/grafana-app-platform-squad false false false
179 k8SFolderCounts experimental @grafana/search-and-storage false false false
180 k8SFolderMove experimental @grafana/search-and-storage false false false
181 improvedExternalSessionHandlingSAML preview GA @grafana/identity-access-team false false false
182 teamHttpHeadersMimir GA @grafana/identity-access-team false false false
183 teamHttpHeadersTempo experimental @grafana/identity-access-team false false false
184 templateVariablesUsesCombobox experimental @grafana/grafana-frontend-platform false false true
218 alertingListViewV2PreviewToggle privatePreview @grafana/alerting-squad false false true
219 alertRuleUseFiredAtForStartsAt experimental @grafana/alerting-squad false false false
220 alertingBulkActionsInUI GA @grafana/alerting-squad false false true
extensionsReadOnlyProxy experimental @grafana/plugins-platform-backend false false true
221 kubernetesAuthzApis experimental @grafana/identity-access-team false false false
222 restoreDashboards experimental @grafana/grafana-frontend-platform false false false
223 skipTokenRotationIfRecent privatePreview @grafana/identity-access-team false false false
-8
View File
@@ -379,10 +379,6 @@ const (
// Enables shared crosshair in table panel
FlagTableSharedCrosshair = "tableSharedCrosshair"
// FlagRegressionTransformation
// Enables regression analysis transformation
FlagRegressionTransformation = "regressionTransformation"
// FlagLokiQueryHints
// Enables query hints for Loki
FlagLokiQueryHints = "lokiQueryHints"
@@ -887,10 +883,6 @@ const (
// Enables the alerting bulk actions in the UI
FlagAlertingBulkActionsInUI = "alertingBulkActionsInUI"
// FlagExtensionsReadOnlyProxy
// Use proxy-based read-only objects for plugin extensions instead of deep cloning
FlagExtensionsReadOnlyProxy = "extensionsReadOnlyProxy"
// FlagKubernetesAuthzApis
// Registers AuthZ /apis endpoint
FlagKubernetesAuthzApis = "kubernetesAuthzApis"
+20 -10
View File
@@ -1110,7 +1110,8 @@
"metadata": {
"name": "extensionsReadOnlyProxy",
"resourceVersion": "1750434297879",
"creationTimestamp": "2025-05-06T04:55:23Z"
"creationTimestamp": "2025-05-06T04:55:23Z",
"deletionTimestamp": "2025-06-30T08:24:11Z"
},
"spec": {
"description": "Use proxy-based read-only objects for plugin extensions instead of deep cloning",
@@ -1398,27 +1399,35 @@
{
"metadata": {
"name": "improvedExternalSessionHandling",
"resourceVersion": "1750434297879",
"creationTimestamp": "2024-09-17T10:54:39Z"
"resourceVersion": "1751355094344",
"creationTimestamp": "2024-09-17T10:54:39Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-07-01 07:31:34.344238 +0000 UTC"
}
},
"spec": {
"description": "Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves.",
"stage": "preview",
"stage": "GA",
"codeowner": "@grafana/identity-access-team",
"allowSelfServe": true
"allowSelfServe": true,
"expression": "true"
}
},
{
"metadata": {
"name": "improvedExternalSessionHandlingSAML",
"resourceVersion": "1750434297879",
"creationTimestamp": "2025-01-09T17:02:49Z"
"resourceVersion": "1751355094344",
"creationTimestamp": "2025-01-09T17:02:49Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-07-01 07:31:34.344238 +0000 UTC"
}
},
"spec": {
"description": "Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly.",
"stage": "preview",
"stage": "GA",
"codeowner": "@grafana/identity-access-team",
"allowSelfServe": true
"allowSelfServe": true,
"expression": "true"
}
},
{
@@ -2606,7 +2615,8 @@
"metadata": {
"name": "regressionTransformation",
"resourceVersion": "1750434297879",
"creationTimestamp": "2023-11-24T14:49:16Z"
"creationTimestamp": "2023-11-24T14:49:16Z",
"deletionTimestamp": "2025-07-01T13:24:02Z"
},
"spec": {
"description": "Enables regression analysis transformation",
+3
View File
@@ -567,6 +567,9 @@ type Cfg struct {
IndexRebuildInterval time.Duration
IndexCacheTTL time.Duration
EnableSharding bool
QOSEnabled bool
QOSNumberWorker int
QOSMaxSizePerTenant int
MemberlistBindAddr string
MemberlistAdvertiseAddr string
MemberlistAdvertisePort int
+4 -1
View File
@@ -49,13 +49,16 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
}
cfg.UnifiedStorage = storageConfig
// Set indexer config for unified storaae
// Set indexer config for unified storage
section := cfg.Raw.Section("unified_storage")
cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0)
cfg.IndexPath = section.Key("index_path").String()
cfg.IndexWorkers = section.Key("index_workers").MustInt(10)
cfg.IndexMaxBatchSize = section.Key("index_max_batch_size").MustInt(100)
cfg.EnableSharding = section.Key("enable_sharding").MustBool(false)
cfg.QOSEnabled = section.Key("qos_enabled").MustBool(false)
cfg.QOSNumberWorker = section.Key("qos_num_worker").MustInt(16)
cfg.QOSMaxSizePerTenant = section.Key("qos_max_size_per_tenant").MustInt(1000)
cfg.MemberlistBindAddr = section.Key("memberlist_bind_addr").String()
cfg.MemberlistAdvertiseAddr = section.Key("memberlist_advertise_addr").String()
cfg.MemberlistAdvertisePort = section.Key("memberlist_advertise_port").MustInt(7946)
+46 -3
View File
@@ -20,6 +20,7 @@ import (
"github.com/grafana/dskit/flagext"
"github.com/grafana/dskit/grpcclient"
"github.com/grafana/dskit/middleware"
"github.com/grafana/dskit/services"
infraDB "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -31,6 +32,7 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/search"
"github.com/grafana/grafana/pkg/storage/unified/sql"
"github.com/grafana/grafana/pkg/util/scheduler"
)
type Options struct {
@@ -49,7 +51,10 @@ type clientMetrics struct {
}
// This adds a UnifiedStorage client into the wire dependency tree
func ProvideUnifiedStorageClient(opts *Options, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics) (resource.ResourceClient, error) {
func ProvideUnifiedStorageClient(opts *Options,
storageMetrics *resource.StorageMetrics,
indexMetrics *resource.BleveIndexMetrics,
) (resource.ResourceClient, error) {
// See: apiserver.applyAPIServerConfig(cfg, features, o)
apiserverCfg := opts.Cfg.SectionWithEnvOverrides("grafana-apiserver")
client, err := newClient(options.StorageOptions{
@@ -83,6 +88,7 @@ func newClient(opts options.StorageOptions,
indexMetrics *resource.BleveIndexMetrics,
) (resource.ResourceClient, error) {
ctx := context.Background()
switch opts.StorageType {
case options.StorageTypeFile:
if opts.DataPath == "" {
@@ -146,13 +152,50 @@ func newClient(opts options.StorageOptions,
}
return client, nil
// Use the local SQL
default:
searchOptions, err := search.NewSearchOptions(features, cfg, tracer, docs, indexMetrics)
if err != nil {
return nil, err
}
server, err := sql.NewResourceServer(db, cfg, tracer, reg, authzc, searchOptions, storageMetrics, indexMetrics, features)
serverOptions := sql.ServerOptions{
DB: db,
Cfg: cfg,
Tracer: tracer,
Reg: reg,
AccessClient: authzc,
SearchOptions: searchOptions,
StorageMetrics: storageMetrics,
IndexMetrics: indexMetrics,
Features: features,
}
if cfg.QOSEnabled {
qosReg := prometheus.WrapRegistererWithPrefix("resource_server_qos_", reg)
queue := scheduler.NewQueue(&scheduler.QueueOptions{
MaxSizePerTenant: cfg.QOSMaxSizePerTenant,
Registerer: qosReg,
Logger: cfg.Logger,
})
if err := services.StartAndAwaitRunning(ctx, queue); err != nil {
return nil, fmt.Errorf("failed to start queue: %w", err)
}
scheduler, err := scheduler.NewScheduler(queue, &scheduler.Config{
NumWorkers: cfg.QOSNumberWorker,
Logger: cfg.Logger,
})
if err != nil {
return nil, fmt.Errorf("failed to create scheduler: %w", err)
}
err = services.StartAndAwaitRunning(ctx, scheduler)
if err != nil {
return nil, fmt.Errorf("failed to start scheduler: %w", err)
}
serverOptions.QOSQueue = queue
}
server, err := sql.NewResourceServer(serverOptions)
if err != nil {
return nil, err
}
+16
View File
@@ -12,6 +12,7 @@ import (
grpcstatus "google.golang.org/grpc/status"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/util/scheduler"
)
// Package-level errors.
@@ -50,6 +51,14 @@ func NewNotFoundError(key *resourcepb.ResourceKey) *resourcepb.ErrorResult {
}
}
func NewTooManyRequestsError(msg string) *resourcepb.ErrorResult {
return &resourcepb.ErrorResult{
Message: msg,
Code: http.StatusTooManyRequests,
Reason: string(metav1.StatusReasonTooManyRequests),
}
}
// Convert golang errors to status result errors that can be returned to a client
func AsErrorResult(err error) *resourcepb.ErrorResult {
if err == nil {
@@ -125,3 +134,10 @@ func GetError(res *resourcepb.ErrorResult) error {
}
return status
}
func HandleQueueError[T any](err error, makeResp func(*resourcepb.ErrorResult) *T) (*T, error) {
if errors.Is(err, scheduler.ErrTenantQueueFull) {
return makeResp(NewTooManyRequestsError("tenant queue is full, please try again later")), nil
}
return makeResp(AsErrorResult(err)), nil
}
@@ -21,7 +21,7 @@ import (
"google.golang.org/grpc/metadata"
)
func ProvideDistributorServer(cfg *setting.Cfg, features featuremgmt.FeatureToggles, registerer prometheus.Registerer, tracer trace.Tracer, ring *ring.Ring, ringClientPool *ringclient.Pool) (grpcserver.Provider, error) {
func ProvideSearchDistributorServer(cfg *setting.Cfg, features featuremgmt.FeatureToggles, registerer prometheus.Registerer, tracer trace.Tracer, ring *ring.Ring, ringClientPool *ringclient.Pool) (grpcserver.Provider, error) {
var err error
grpcHandler, err := grpcserver.ProvideService(cfg, features, nil, tracer, registerer)
if err != nil {
@@ -29,7 +29,7 @@ func ProvideDistributorServer(cfg *setting.Cfg, features featuremgmt.FeatureTogg
}
distributorServer := &distributorServer{
log: log.New("unified-storage-distributor"),
log: log.New("index-server-distributor"),
ring: ring,
clientPool: ringClientPool,
}
@@ -73,8 +73,8 @@ func (c *RingClient) RemoteAddress() string {
return c.Conn.Target()
}
const RingKey = "unified-storage-ring"
const RingName = "unified_storage_ring"
const RingKey = "search-server-ring"
const RingName = "search_server_ring"
const RingHeartbeatTimeout = time.Minute
const RingNumTokens = 128
+139
View File
@@ -19,9 +19,20 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
claims "github.com/grafana/authlib/types"
"github.com/grafana/dskit/backoff"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/util/scheduler"
)
const (
// DefaultMaxBackoff is the default maximum backoff duration for enqueue operations.
DefaultMaxBackoff = 1 * time.Second
// DefaultMinBackoff is the default minimum backoff duration for enqueue operations.
DefaultMinBackoff = 100 * time.Millisecond
// DefaultMaxRetries is the default maximum number of retries for enqueue operations.
DefaultMaxRetries = 3
)
// ResourceServer implements all gRPC services
@@ -134,6 +145,10 @@ type BlobSupport interface {
// TODO? List+Delete? This is for admin access
}
type QOSEnqueuer interface {
Enqueue(ctx context.Context, tenantID string, runnable func(ctx context.Context)) error
}
type BlobConfig struct {
// The CDK configuration URL
URL string
@@ -203,7 +218,11 @@ type ResourceServerOptions struct {
IndexMetrics *BleveIndexMetrics
// MaxPageSizeBytes is the maximum size of a page in bytes.
MaxPageSizeBytes int
// QOSQueue is the quality of service queue used to enqueue
QOSQueue QOSEnqueuer
}
func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) {
@@ -222,6 +241,7 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) {
if opts.Diagnostics == nil {
opts.Diagnostics = &noopService{}
}
if opts.Now == nil {
opts.Now = func() int64 {
return time.Now().UnixMilli()
@@ -233,6 +253,10 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) {
opts.MaxPageSizeBytes = 1024 * 1024 * 2
}
if opts.QOSQueue == nil {
opts.QOSQueue = scheduler.NewNoopQueue()
}
// Initialize the blob storage
blobstore := opts.Blob.Backend
if blobstore == nil {
@@ -275,6 +299,8 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) {
storageMetrics: opts.storageMetrics,
indexMetrics: opts.IndexMetrics,
maxPageSizeBytes: opts.MaxPageSizeBytes,
reg: opts.Reg,
queue: opts.QOSQueue,
}
if opts.Search.Resources != nil {
@@ -321,6 +347,8 @@ type server struct {
initErr error
maxPageSizeBytes int
reg prometheus.Registerer
queue QOSEnqueuer
}
// Init implements ResourceServer.
@@ -570,6 +598,25 @@ func (s *server) Create(ctx context.Context, req *resourcepb.CreateRequest) (*re
return rsp, nil
}
var (
res *resourcepb.CreateResponse
err error
)
runErr := s.runInQueue(ctx, req.Key.Namespace, func(ctx context.Context) {
res, err = s.create(ctx, user, req)
})
if runErr != nil {
return HandleQueueError(runErr, func(e *resourcepb.ErrorResult) *resourcepb.CreateResponse {
return &resourcepb.CreateResponse{Error: e}
})
}
return res, err
}
func (s *server) create(ctx context.Context, user claims.AuthInfo, req *resourcepb.CreateRequest) (*resourcepb.CreateResponse, error) {
rsp := &resourcepb.CreateResponse{}
event, e := s.newEvent(ctx, user, req.Key, req.Value, nil)
if e != nil {
rsp.Error = e
@@ -605,6 +652,24 @@ func (s *server) Update(ctx context.Context, req *resourcepb.UpdateRequest) (*re
return rsp, nil
}
var (
res *resourcepb.UpdateResponse
err error
)
runErr := s.runInQueue(ctx, req.Key.Namespace, func(ctx context.Context) {
res, err = s.update(ctx, user, req)
})
if runErr != nil {
return HandleQueueError(runErr, func(e *resourcepb.ErrorResult) *resourcepb.UpdateResponse {
return &resourcepb.UpdateResponse{Error: e}
})
}
return res, err
}
func (s *server) update(ctx context.Context, user claims.AuthInfo, req *resourcepb.UpdateRequest) (*resourcepb.UpdateResponse, error) {
rsp := &resourcepb.UpdateResponse{}
latest := s.backend.ReadResource(ctx, &resourcepb.ReadRequest{
Key: req.Key,
})
@@ -654,6 +719,25 @@ func (s *server) Delete(ctx context.Context, req *resourcepb.DeleteRequest) (*re
return rsp, nil
}
var (
res *resourcepb.DeleteResponse
err error
)
runErr := s.runInQueue(ctx, req.Key.Namespace, func(ctx context.Context) {
res, err = s.delete(ctx, user, req)
})
if runErr != nil {
return HandleQueueError(runErr, func(e *resourcepb.ErrorResult) *resourcepb.DeleteResponse {
return &resourcepb.DeleteResponse{Error: e}
})
}
return res, err
}
func (s *server) delete(ctx context.Context, user claims.AuthInfo, req *resourcepb.DeleteRequest) (*resourcepb.DeleteResponse, error) {
rsp := &resourcepb.DeleteResponse{}
latest := s.backend.ReadResource(ctx, &resourcepb.ReadRequest{
Key: req.Key,
})
@@ -744,6 +828,23 @@ func (s *server) Read(ctx context.Context, req *resourcepb.ReadRequest) (*resour
return &resourcepb.ReadResponse{Error: NewBadRequestError("missing resource")}, nil
}
var (
res *resourcepb.ReadResponse
err error
)
runErr := s.runInQueue(ctx, req.Key.Namespace, func(ctx context.Context) {
res, err = s.read(ctx, user, req)
})
if runErr != nil {
return HandleQueueError(runErr, func(e *resourcepb.ErrorResult) *resourcepb.ReadResponse {
return &resourcepb.ReadResponse{Error: e}
})
}
return res, err
}
func (s *server) read(ctx context.Context, user claims.AuthInfo, req *resourcepb.ReadRequest) (*resourcepb.ReadResponse, error) {
rsp := s.backend.ReadResource(ctx, req)
if rsp.Error != nil && rsp.Error.Code == http.StatusNotFound {
return &resourcepb.ReadResponse{Error: rsp.Error}, nil
@@ -1237,3 +1338,41 @@ func (s *server) GetBlob(ctx context.Context, req *resourcepb.GetBlobRequest) (*
}
return rsp, nil
}
func (s *server) runInQueue(ctx context.Context, tenantID string, runnable func(ctx context.Context)) error {
boff := backoff.New(ctx, backoff.Config{
MinBackoff: DefaultMinBackoff,
MaxBackoff: DefaultMaxBackoff,
MaxRetries: DefaultMaxRetries,
})
var (
wg sync.WaitGroup
err error
)
wg.Add(1)
wrapped := func(ctx context.Context) {
runnable(ctx)
wg.Done()
}
for boff.Ongoing() {
err = s.queue.Enqueue(ctx, tenantID, wrapped)
if err == nil {
break
}
s.log.Warn("failed to enqueue runnable, retrying",
"maxRetries", DefaultMaxRetries,
"tenantID", tenantID,
"error", err)
boff.Wait()
}
if err != nil {
s.log.Error("failed to enqueue runnable",
"maxRetries", DefaultMaxRetries,
"tenantID", tenantID,
"error", err)
return fmt.Errorf("failed to enqueue runnable for tenant %s: %w", tenantID, err)
}
wg.Wait()
return nil
}
@@ -87,9 +87,10 @@ func getEngineMySQL(getter confGetter) (*xorm.Engine, error) {
return nil, fmt.Errorf("open database: %w", err)
}
engine.SetMaxOpenConns(0)
engine.SetMaxIdleConns(2)
engine.SetConnMaxLifetime(4 * time.Hour)
engine.SetMaxOpenConns(getter.Int("max_open_conn", 0))
engine.SetMaxIdleConns(getter.Int("max_idle_conn", 4))
maxLifetime := time.Duration(getter.Int("conn_max_lifetime", 14400)) * time.Second
engine.SetConnMaxLifetime(maxLifetime)
return engine, nil
}
@@ -188,5 +189,10 @@ func getEnginePostgres(getter confGetter) (*xorm.Engine, error) {
return nil, fmt.Errorf("open database: %w", err)
}
engine.SetMaxOpenConns(getter.Int("max_open_conn", 0))
engine.SetMaxIdleConns(getter.Int("max_idle_conn", 4))
maxLifetime := time.Duration(getter.Int("conn_max_lifetime", 14400)) * time.Second
engine.SetConnMaxLifetime(maxLifetime)
return engine, nil
}
@@ -18,6 +18,7 @@ type confGetter interface {
Err() error
Bool(key string) bool
String(key string) string
Int(key string, def int) int
}
func newConfGetter(ds *setting.DynamicSection, keyPrefix string) confGetter {
@@ -52,6 +53,10 @@ func (g *sectionGetter) String(key string) string {
return v
}
func (g *sectionGetter) Int(key string, def int) int {
return g.ds.Key(g.keyPrefix + key).MustInt(def)
}
// MakeDSN creates a DSN from the given key/value pair. It validates the strings
// form valid UTF-8 sequences and escapes values if needed.
func MakeDSN(m map[string]string) (string, error) {
+29 -5
View File
@@ -28,11 +28,13 @@ func TestSectionGetter(t *testing.T) {
t.Parallel()
var (
key = "the key"
keyBoolTrue = "I'm true"
keyBoolFalse = "not me!"
prefix = "this is some prefix"
val = string(invalidUTF8ByteSequence)
key = "the key"
keyBoolTrue = "I'm true"
keyBoolFalse = "not me!"
keyIntValid = "valid_int"
keyIntMissing = "missing_int"
prefix = "this is some prefix"
val = string(invalidUTF8ByteSequence)
)
t.Run("with prefix", func(t *testing.T) {
@@ -42,6 +44,8 @@ func TestSectionGetter(t *testing.T) {
prefix + key: val,
prefix + keyBoolTrue: "YES",
prefix + keyBoolFalse: "0",
prefix + keyIntValid: "42",
// Note: keyIntMissing is intentionally not included to test default behavior
}, prefix)
require.False(t, g.Bool("whatever bool"))
@@ -53,6 +57,15 @@ func TestSectionGetter(t *testing.T) {
require.True(t, g.Bool(keyBoolTrue))
require.NoError(t, g.Err())
require.Equal(t, 999, g.Int("whatever int", 999))
require.NoError(t, g.Err())
require.Equal(t, 42, g.Int(keyIntValid, 100))
require.NoError(t, g.Err())
require.Equal(t, 200, g.Int(keyIntMissing, 200))
require.NoError(t, g.Err())
require.Empty(t, g.String("whatever string"))
require.NoError(t, g.Err())
@@ -68,6 +81,8 @@ func TestSectionGetter(t *testing.T) {
key: val,
keyBoolTrue: "true",
keyBoolFalse: "f",
keyIntValid: "123",
// Note: keyIntMissing is intentionally not included to test default behavior
}, "")
require.False(t, g.Bool("whatever bool"))
@@ -79,6 +94,15 @@ func TestSectionGetter(t *testing.T) {
require.True(t, g.Bool(keyBoolTrue))
require.NoError(t, g.Err())
require.Equal(t, 500, g.Int("whatever int", 500))
require.NoError(t, g.Err())
require.Equal(t, 123, g.Int(keyIntValid, 0))
require.NoError(t, g.Err())
require.Equal(t, 300, g.Int(keyIntMissing, 300))
require.NoError(t, g.Err())
require.Empty(t, g.String("whatever string"))
require.NoError(t, g.Err())
+50 -33
View File
@@ -1,6 +1,7 @@
package sql
import (
"context"
"os"
"strings"
@@ -8,6 +9,7 @@ import (
"go.opentelemetry.io/otel/trace"
"github.com/grafana/authlib/types"
"github.com/grafana/dskit/services"
infraDB "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -17,70 +19,85 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
)
type QOSEnqueueDequeuer interface {
services.Service
Enqueue(ctx context.Context, tenantID string, runnable func(ctx context.Context)) error
Dequeue(ctx context.Context) (func(ctx context.Context), error)
}
// ServerOptions contains the options for creating a new ResourceServer
type ServerOptions struct {
DB infraDB.DB
Cfg *setting.Cfg
Tracer trace.Tracer
Reg prometheus.Registerer
AccessClient types.AccessClient
SearchOptions resource.SearchOptions
StorageMetrics *resource.StorageMetrics
IndexMetrics *resource.BleveIndexMetrics
Features featuremgmt.FeatureToggles
QOSQueue QOSEnqueueDequeuer
}
// Creates a new ResourceServer
func NewResourceServer(db infraDB.DB, cfg *setting.Cfg,
tracer trace.Tracer, reg prometheus.Registerer, ac types.AccessClient,
searchOptions resource.SearchOptions, storageMetrics *resource.StorageMetrics,
indexMetrics *resource.BleveIndexMetrics, features featuremgmt.FeatureToggles) (resource.ResourceServer, error) {
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
opts := resource.ResourceServerOptions{
Tracer: tracer,
func NewResourceServer(
opts ServerOptions,
) (resource.ResourceServer, error) {
apiserverCfg := opts.Cfg.SectionWithEnvOverrides("grafana-apiserver")
serverOptions := resource.ResourceServerOptions{
Tracer: opts.Tracer,
Blob: resource.BlobConfig{
URL: apiserverCfg.Key("blob_url").MustString(""),
},
Reg: reg,
Reg: opts.Reg,
}
if ac != nil {
opts.AccessClient = resource.NewAuthzLimitedClient(ac, resource.AuthzOptions{Tracer: tracer, Registry: reg})
if opts.AccessClient != nil {
serverOptions.AccessClient = resource.NewAuthzLimitedClient(opts.AccessClient, resource.AuthzOptions{Tracer: opts.Tracer, Registry: opts.Reg})
}
// Support local file blob
if strings.HasPrefix(opts.Blob.URL, "./data/") {
dir := strings.Replace(opts.Blob.URL, "./data", cfg.DataPath, 1)
if strings.HasPrefix(serverOptions.Blob.URL, "./data/") {
dir := strings.Replace(serverOptions.Blob.URL, "./data", opts.Cfg.DataPath, 1)
err := os.MkdirAll(dir, 0700)
if err != nil {
return nil, err
}
opts.Blob.URL = "file:///" + dir
serverOptions.Blob.URL = "file:///" + dir
}
// This is mostly for testing, being able to influence when we paginate
// based on the page size during tests.
unifiedStorageCfg := cfg.SectionWithEnvOverrides("unified_storage")
unifiedStorageCfg := opts.Cfg.SectionWithEnvOverrides("unified_storage")
maxPageSizeBytes := unifiedStorageCfg.Key("max_page_size_bytes")
opts.MaxPageSizeBytes = maxPageSizeBytes.MustInt(0)
serverOptions.MaxPageSizeBytes = maxPageSizeBytes.MustInt(0)
eDB, err := dbimpl.ProvideResourceDB(db, cfg, tracer)
eDB, err := dbimpl.ProvideResourceDB(opts.DB, opts.Cfg, opts.Tracer)
if err != nil {
return nil, err
}
isHA := isHighAvailabilityEnabled(cfg.SectionWithEnvOverrides("database"),
cfg.SectionWithEnvOverrides("resource_api"))
withPruner := features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageHistoryPruner)
isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"),
opts.Cfg.SectionWithEnvOverrides("resource_api"))
withPruner := opts.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageHistoryPruner)
store, err := NewBackend(BackendOptions{
DBProvider: eDB,
Tracer: tracer,
Reg: reg,
Tracer: opts.Tracer,
Reg: opts.Reg,
IsHA: isHA,
withPruner: withPruner,
storageMetrics: storageMetrics,
storageMetrics: opts.StorageMetrics,
})
if err != nil {
return nil, err
}
opts.Backend = store
opts.Diagnostics = store
opts.Lifecycle = store
opts.Search = searchOptions
opts.IndexMetrics = indexMetrics
serverOptions.Backend = store
serverOptions.Diagnostics = store
serverOptions.Lifecycle = store
serverOptions.Search = opts.SearchOptions
serverOptions.IndexMetrics = opts.IndexMetrics
serverOptions.QOSQueue = opts.QOSQueue
rs, err := resource.NewResourceServer(opts)
if err != nil {
return nil, err
}
return rs, nil
return resource.NewResourceServer(serverOptions)
}
// isHighAvailabilityEnabled determines if high availability mode should
+87 -29
View File
@@ -34,6 +34,7 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/resource/grpc"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/search"
"github.com/grafana/grafana/pkg/util/scheduler"
)
var (
@@ -50,6 +51,11 @@ type UnifiedStorageGrpcService interface {
type service struct {
*services.BasicService
// Subservices manager
subservices *services.Manager
subservicesWatcher *services.FailureWatcher
hasSubservices bool
cfg *setting.Cfg
features featuremgmt.FeatureToggles
db infraDB.DB
@@ -71,6 +77,9 @@ type service struct {
storageRing *ring.Ring
lifecycler *ring.BasicLifecycler
queue QOSEnqueueDequeuer
scheduler *scheduler.Scheduler
}
func ProvideUnifiedStorageGrpcService(
@@ -85,6 +94,7 @@ func ProvideUnifiedStorageGrpcService(
storageRing *ring.Ring,
memberlistKVConfig kv.Config,
) (UnifiedStorageGrpcService, error) {
var err error
tracer := otel.Tracer("unified-storage")
// FIXME: This is a temporary solution while we are migrating to the new authn interceptor
@@ -95,20 +105,22 @@ func ProvideUnifiedStorageGrpcService(
})
s := &service{
cfg: cfg,
features: features,
stopCh: make(chan struct{}),
authenticator: authn,
tracing: tracer,
db: db,
log: log,
reg: reg,
docBuilders: docBuilders,
storageMetrics: storageMetrics,
indexMetrics: indexMetrics,
storageRing: storageRing,
cfg: cfg,
features: features,
stopCh: make(chan struct{}),
authenticator: authn,
tracing: tracer,
db: db,
log: log,
reg: reg,
docBuilders: docBuilders,
storageMetrics: storageMetrics,
indexMetrics: indexMetrics,
storageRing: storageRing,
subservicesWatcher: services.NewFailureWatcher(),
}
subservices := []services.Service{}
if cfg.EnableSharding {
ringStore, err := kv.NewClient(
memberlistKVConfig,
@@ -143,15 +155,50 @@ func ProvideUnifiedStorageGrpcService(
if err != nil {
return nil, fmt.Errorf("failed to initialize storage-ring lifecycler: %s", err)
}
subservices = append(subservices, s.lifecycler)
}
if cfg.QOSEnabled {
qosReg := prometheus.WrapRegistererWithPrefix("resource_server_qos_", reg)
queue := scheduler.NewQueue(&scheduler.QueueOptions{
MaxSizePerTenant: cfg.QOSMaxSizePerTenant,
Registerer: qosReg,
})
scheduler, err := scheduler.NewScheduler(queue, &scheduler.Config{
NumWorkers: cfg.QOSNumberWorker,
Logger: log,
})
if err != nil {
return nil, fmt.Errorf("failed to create qos scheduler: %s", err)
}
s.queue = queue
s.scheduler = scheduler
subservices = append(subservices, s.queue, s.scheduler)
}
if len(subservices) > 0 {
s.hasSubservices = true
s.subservices, err = services.NewManager(subservices...)
if err != nil {
return nil, fmt.Errorf("failed to create subservices manager: %w", err)
}
}
// This will be used when running as a dskit service
s.BasicService = services.NewBasicService(s.start, s.running, s.stopping).WithName(modules.StorageServer)
s.BasicService = services.NewBasicService(s.starting, s.running, s.stopping).WithName(modules.StorageServer)
return s, nil
}
func (s *service) start(ctx context.Context) error {
func (s *service) starting(ctx context.Context) error {
if s.hasSubservices {
s.subservicesWatcher.WatchManager(s.subservices)
if err := services.StartManagerAndAwaitHealthy(ctx, s.subservices); err != nil {
return fmt.Errorf("failed to start subservices: %w", err)
}
}
authzClient, err := authz.ProvideStandaloneAuthZClient(s.cfg, s.features, s.tracing)
if err != nil {
return err
@@ -162,7 +209,19 @@ func (s *service) start(ctx context.Context) error {
return err
}
server, err := NewResourceServer(s.db, s.cfg, s.tracing, s.reg, authzClient, searchOptions, s.storageMetrics, s.indexMetrics, s.features)
serverOptions := ServerOptions{
DB: s.db,
Cfg: s.cfg,
Tracer: s.tracing,
Reg: s.reg,
AccessClient: authzClient,
SearchOptions: searchOptions,
StorageMetrics: s.storageMetrics,
IndexMetrics: s.indexMetrics,
Features: s.features,
QOSQueue: s.queue,
}
server, err := NewResourceServer(serverOptions)
if err != nil {
return err
}
@@ -192,11 +251,6 @@ func (s *service) start(ctx context.Context) error {
}
if s.cfg.EnableSharding {
err = s.lifecycler.StartAsync(ctx)
if err != nil {
return fmt.Errorf("failed to start the lifecycler: %s", err)
}
s.log.Info("waiting until resource server is JOINING in the ring")
lfcCtx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
@@ -231,15 +285,27 @@ func (s *service) GetAddress() string {
func (s *service) running(ctx context.Context) error {
select {
case err := <-s.stoppedCh:
if err != nil {
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
case err := <-s.subservicesWatcher.Chan():
return fmt.Errorf("subservice failure: %w", err)
case <-ctx.Done():
close(s.stopCh)
}
return nil
}
func (s *service) stopping(_ error) error {
if s.hasSubservices {
err := services.StopManagerAndAwaitStopped(context.Background(), s.subservices)
if err != nil {
return fmt.Errorf("failed to stop subservices: %w", err)
}
}
return nil
}
type authenticatorWithFallback struct {
authenticator func(ctx context.Context) (context.Context, error)
fallback func(ctx context.Context) (context.Context, error)
@@ -309,14 +375,6 @@ func NewAuthenticatorWithFallback(cfg *setting.Cfg, reg prometheus.Registerer, t
}
}
func (s *service) stopping(err error) error {
if err != nil && !errors.Is(err, context.Canceled) {
s.log.Error("stopping unified storage grpc service", "error", err)
return err
}
return nil
}
func toLifecyclerConfig(cfg *setting.Cfg, logger log.Logger) (ring.BasicLifecyclerConfig, error) {
instanceAddr, err := ring.GetInstanceAddr(cfg.MemberlistBindAddr, netutil.PrivateNetworkInterfacesWithFallback([]string{"eth0", "en0"}, logger), logger, true)
if err != nil {
@@ -1851,6 +1851,10 @@
]
}
},
"moreInfo": {
"description": "More information about the failure, not meant to be displayed to the user. Used for LLM suggestions.",
"type": "string"
},
"severity": {
"description": "Severity of the failure",
"type": "string",
@@ -11,5 +11,5 @@ spec:
description: This is a secret
value: this is super duper secure
decrypters:
- actor_k6
- actor_synthetic-monitoring
- k6
- synthetic-monitoring
+2 -2
View File
@@ -12,5 +12,5 @@ spec:
keeper: my-keeper-1
value: super duper secure
decrypters:
- actor_k6
- actor_synthetic-monitoring
- k6
- synthetic-monitoring
+3 -4
View File
@@ -8,10 +8,9 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
"github.com/grafana/grafana/pkg/infra/httpclient"
)
var logger = backend.NewLoggerWith("logger", "tsdb.jaeger")
@@ -20,7 +19,7 @@ type Service struct {
im instancemgmt.InstanceManager
}
func ProvideService(httpClientProvider httpclient.Provider) *Service {
func ProvideService(httpClientProvider *httpclient.Provider) *Service {
return &Service{
im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)),
}
@@ -36,7 +35,7 @@ type datasourceJSONData struct {
} `json:"traceIdTimeParams"`
}
func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc {
func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.InstanceFactoryFunc {
return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
httpClientOptions, err := settings.HTTPClientOptions(ctx)
if err != nil {
+39
View File
@@ -0,0 +1,39 @@
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"
jaeger "github.com/grafana/grafana/pkg/tsdb/jaeger"
)
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: jaeger.ProvideService(httpclient.NewProvider()),
}, nil
}
type Datasource struct {
Service *jaeger.Service
}
func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
return d.Service.QueryData(ctx, req)
}
func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
return d.Service.CallResource(ctx, req, sender)
}
func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
return d.Service.CheckHealth(ctx, req)
}
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"os"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
)
func main() {
// Start listening to requests sent from Grafana. This call is blocking so
// it won't finish until Grafana shuts down the process or the plugin choose
// to exit by itself using os.Exit. Manage automatically manages life cycle
// of datasource instances. It accepts datasource instance factory as first
// argument. This factory will be automatically called on incoming request
// from Grafana to create different instances of SampleDatasource (per datasource
// ID). When datasource configuration changed Dispose method will be called and
// new datasource instance created using NewSampleDatasource factory.
if err := datasource.Manage("jaeger", NewDatasource, datasource.ManageOpts{}); err != nil {
log.DefaultLogger.Error(err.Error())
os.Exit(1)
}
}
+39
View File
@@ -0,0 +1,39 @@
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"
"github.com/grafana/grafana/pkg/tsdb/zipkin"
)
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: zipkin.ProvideService(httpclient.NewProvider()),
}, nil
}
type Datasource struct {
Service *zipkin.Service
}
func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
return d.Service.QueryData(ctx, req)
}
func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
return d.Service.CallResource(ctx, req, sender)
}
func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
return d.Service.CheckHealth(ctx, req)
}
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"os"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
)
func main() {
// Start listening to requests sent from Grafana. This call is blocking so
// it won't finish until Grafana shuts down the process or the plugin choose
// to exit by itself using os.Exit. Manage automatically manages life cycle
// of datasource instances. It accepts datasource instance factory as first
// argument. This factory will be automatically called on incoming request
// from Grafana to create different instances of SampleDatasource (per datasource
// ID). When datasource configuration changed Dispose method will be called and
// new datasource instance created using NewSampleDatasource factory.
if err := datasource.Manage("zipkin", NewDatasource, datasource.ManageOpts{}); err != nil {
log.DefaultLogger.Error(err.Error())
os.Exit(1)
}
}
+3 -4
View File
@@ -7,10 +7,9 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
"github.com/grafana/grafana/pkg/infra/httpclient"
)
var logger = backend.NewLoggerWith("logger", "tsdb.zipkin")
@@ -19,7 +18,7 @@ type Service struct {
im instancemgmt.InstanceManager
}
func ProvideService(httpClientProvider httpclient.Provider) *Service {
func ProvideService(httpClientProvider *httpclient.Provider) *Service {
return &Service{
im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)),
}
@@ -29,7 +28,7 @@ type datasourceInfo struct {
ZipkinClient ZipkinClient
}
func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc {
func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.InstanceFactoryFunc {
return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
httpClientOptions, err := settings.HTTPClientOptions(ctx)
if err != nil {
+18 -1
View File
@@ -9,6 +9,8 @@ import (
"github.com/grafana/dskit/services"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/grafana/grafana/pkg/infra/log"
)
const (
@@ -82,6 +84,8 @@ func NewNoopQueue() *NoopQueue {
type Queue struct {
services.Service
logger log.Logger
enqueueChan chan enqueueRequest
dequeueChan chan dequeueRequest
lenChan chan lenRequest
@@ -108,6 +112,7 @@ type Queue struct {
type QueueOptions struct {
MaxSizePerTenant int
Registerer prometheus.Registerer
Logger log.Logger
}
// NewQueue creates a new Queue and starts its dispatcher goroutine.
@@ -116,7 +121,13 @@ func NewQueue(opts *QueueOptions) *Queue {
opts.MaxSizePerTenant = DefaultMaxSizePerTenant
}
if opts.Logger == nil {
opts.Logger = log.NewNopLogger()
}
q := &Queue{
logger: opts.Logger,
enqueueChan: make(chan enqueueRequest),
dequeueChan: make(chan dequeueRequest),
lenChan: make(chan lenRequest),
@@ -226,6 +237,8 @@ func (q *Queue) handleLenRequest(req lenRequest) {
func (q *Queue) dispatcherLoop(ctx context.Context) error {
defer close(q.dispatcherStoppedChan)
q.logger.Info("queue running", "maxSizePerTenant", q.maxSizePerTenant)
for {
q.scheduleRoundRobin()
@@ -275,7 +288,6 @@ func (q *Queue) Enqueue(ctx context.Context, tenantID string, runnable func(ctx
select {
case q.enqueueChan <- req:
err = <-respChan
q.enqueueDuration.Observe(time.Since(start).Seconds())
case <-q.dispatcherStoppedChan:
q.discardedRequests.WithLabelValues(tenantID, "dispatcher_stopped").Inc()
err = ErrQueueClosed
@@ -283,6 +295,7 @@ func (q *Queue) Enqueue(ctx context.Context, tenantID string, runnable func(ctx
q.discardedRequests.WithLabelValues(tenantID, "context_canceled").Inc()
err = ctx.Err()
}
q.enqueueDuration.Observe(time.Since(start).Seconds())
return err
}
@@ -352,6 +365,8 @@ func (q *Queue) ActiveTenantsLen() int {
}
func (q *Queue) stopping(_ error) error {
q.logger.Info("queue stopping")
q.queueLength.Reset()
q.discardedRequests.Reset()
for _, tq := range q.tenantQueues {
@@ -359,5 +374,7 @@ func (q *Queue) stopping(_ error) error {
}
q.activeTenants.Init()
q.pendingDequeueRequests.Init()
q.logger.Info("queue stopped")
return nil
}
+4
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/grafana/dskit/services"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
@@ -25,6 +26,9 @@ func QueueOptionsWithDefaults(opts *QueueOptions) *QueueOptions {
if opts.Registerer == nil {
opts.Registerer = prometheus.NewRegistry()
}
if opts.Logger == nil {
opts.Logger = log.New("qos.test")
}
return opts
}
+8 -4
View File
@@ -2,6 +2,7 @@ package scheduler
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
@@ -130,16 +131,16 @@ func TestScheduler(t *testing.T) {
t.Run("ProcessItems", func(t *testing.T) {
t.Parallel()
q := NewQueue(QueueOptionsWithDefaults(nil))
q := NewQueue(QueueOptionsWithDefaults(&QueueOptions{MaxSizePerTenant: 1000}))
require.NoError(t, services.StartAndAwaitRunning(context.Background(), q))
const itemCount = 10
const itemCount = 1000
var processed sync.Map
var wg sync.WaitGroup
wg.Add(itemCount)
scheduler, err := NewScheduler(q, &Config{
NumWorkers: 2,
NumWorkers: 10,
MaxBackoff: 100 * time.Millisecond,
Logger: log.New("qos.test"),
})
@@ -148,8 +149,11 @@ func TestScheduler(t *testing.T) {
for i := 0; i < itemCount; i++ {
itemID := i
require.NoError(t, q.Enqueue(context.Background(), "tenant-1", func(_ context.Context) {
tenantIndex := itemID % 10
tenantID := fmt.Sprintf("tenant-%d", tenantIndex)
require.NoError(t, q.Enqueue(context.Background(), tenantID, func(_ context.Context) {
processed.Store(itemID, true)
time.Sleep(10 * time.Millisecond)
wg.Done()
}))
}
@@ -1,11 +1,15 @@
import { api } from './baseAPI';
export const addTagTypes = ['Check', 'CheckType'] as const;
export const addTagTypes = ['API Discovery', 'Check', 'CheckType'] as const;
const injectedRtkApi = api
.enhanceEndpoints({
addTagTypes,
})
.injectEndpoints({
endpoints: (build) => ({
getApiResources: build.query<GetApiResourcesApiResponse, GetApiResourcesApiArg>({
query: () => ({ url: `/apis/advisor.grafana.app/v0alpha1/` }),
providesTags: ['API Discovery'],
}),
listCheck: build.query<ListCheckApiResponse, ListCheckApiArg>({
query: (queryArg) => ({
url: `/checks`,
@@ -39,6 +43,29 @@ const injectedRtkApi = api
}),
invalidatesTags: ['Check'],
}),
deletecollectionCheck: build.mutation<DeletecollectionCheckApiResponse, DeletecollectionCheckApiArg>({
query: (queryArg) => ({
url: `/checks`,
method: 'DELETE',
params: {
pretty: queryArg.pretty,
continue: queryArg['continue'],
dryRun: queryArg.dryRun,
fieldSelector: queryArg.fieldSelector,
gracePeriodSeconds: queryArg.gracePeriodSeconds,
ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential,
labelSelector: queryArg.labelSelector,
limit: queryArg.limit,
orphanDependents: queryArg.orphanDependents,
propagationPolicy: queryArg.propagationPolicy,
resourceVersion: queryArg.resourceVersion,
resourceVersionMatch: queryArg.resourceVersionMatch,
sendInitialEvents: queryArg.sendInitialEvents,
timeoutSeconds: queryArg.timeoutSeconds,
},
}),
invalidatesTags: ['Check'],
}),
getCheck: build.query<GetCheckApiResponse, GetCheckApiArg>({
query: (queryArg) => ({
url: `/checks/${queryArg.name}`,
@@ -48,6 +75,20 @@ const injectedRtkApi = api
}),
providesTags: ['Check'],
}),
replaceCheck: build.mutation<ReplaceCheckApiResponse, ReplaceCheckApiArg>({
query: (queryArg) => ({
url: `/checks/${queryArg.name}`,
method: 'PUT',
body: queryArg.check,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
},
}),
invalidatesTags: ['Check'],
}),
deleteCheck: build.mutation<DeleteCheckApiResponse, DeleteCheckApiArg>({
query: (queryArg) => ({
url: `/checks/${queryArg.name}`,
@@ -97,6 +138,81 @@ const injectedRtkApi = api
}),
providesTags: ['CheckType'],
}),
createCheckType: build.mutation<CreateCheckTypeApiResponse, CreateCheckTypeApiArg>({
query: (queryArg) => ({
url: `/checktypes`,
method: 'POST',
body: queryArg.checkType,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
},
}),
invalidatesTags: ['CheckType'],
}),
deletecollectionCheckType: build.mutation<DeletecollectionCheckTypeApiResponse, DeletecollectionCheckTypeApiArg>({
query: (queryArg) => ({
url: `/checktypes`,
method: 'DELETE',
params: {
pretty: queryArg.pretty,
continue: queryArg['continue'],
dryRun: queryArg.dryRun,
fieldSelector: queryArg.fieldSelector,
gracePeriodSeconds: queryArg.gracePeriodSeconds,
ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential,
labelSelector: queryArg.labelSelector,
limit: queryArg.limit,
orphanDependents: queryArg.orphanDependents,
propagationPolicy: queryArg.propagationPolicy,
resourceVersion: queryArg.resourceVersion,
resourceVersionMatch: queryArg.resourceVersionMatch,
sendInitialEvents: queryArg.sendInitialEvents,
timeoutSeconds: queryArg.timeoutSeconds,
},
}),
invalidatesTags: ['CheckType'],
}),
getCheckType: build.query<GetCheckTypeApiResponse, GetCheckTypeApiArg>({
query: (queryArg) => ({
url: `/checktypes/${queryArg.name}`,
params: {
pretty: queryArg.pretty,
},
}),
providesTags: ['CheckType'],
}),
replaceCheckType: build.mutation<ReplaceCheckTypeApiResponse, ReplaceCheckTypeApiArg>({
query: (queryArg) => ({
url: `/checktypes/${queryArg.name}`,
method: 'PUT',
body: queryArg.checkType,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
},
}),
invalidatesTags: ['CheckType'],
}),
deleteCheckType: build.mutation<DeleteCheckTypeApiResponse, DeleteCheckTypeApiArg>({
query: (queryArg) => ({
url: `/checktypes/${queryArg.name}`,
method: 'DELETE',
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
gracePeriodSeconds: queryArg.gracePeriodSeconds,
ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential,
orphanDependents: queryArg.orphanDependents,
propagationPolicy: queryArg.propagationPolicy,
},
}),
invalidatesTags: ['CheckType'],
}),
updateCheckType: build.mutation<UpdateCheckTypeApiResponse, UpdateCheckTypeApiArg>({
query: (queryArg) => ({
url: `/checktypes/${queryArg.name}`,
@@ -116,6 +232,8 @@ const injectedRtkApi = api
overrideExisting: false,
});
export { injectedRtkApi as generatedAPI };
export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList;
export type GetApiResourcesApiArg = void;
export type ListCheckApiResponse = /** status 200 OK */ CheckList;
export type ListCheckApiArg = {
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
@@ -176,6 +294,57 @@ export type CreateCheckApiArg = {
fieldValidation?: string;
check: Check;
};
export type DeletecollectionCheckApiResponse = /** status 200 OK */ Status;
export type DeletecollectionCheckApiArg = {
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key".
This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */
continue?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** A selector to restrict the list of returned objects by their fields. Defaults to everything. */
fieldSelector?: string;
/** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */
gracePeriodSeconds?: number;
/** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */
ignoreStoreReadErrorWithClusterBreakingPotential?: boolean;
/** A selector to restrict the list of returned objects by their labels. Defaults to everything. */
labelSelector?: string;
/** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.
The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */
limit?: number;
/** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */
orphanDependents?: boolean;
/** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */
propagationPolicy?: string;
/** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
Defaults to unset */
resourceVersion?: string;
/** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
Defaults to unset */
resourceVersionMatch?: string;
/** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.
When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan
is interpreted as "data at least as new as the provided `resourceVersion`"
and the bookmark event is send when the state is synced
to a `resourceVersion` at least as fresh as the one provided by the ListOptions.
If `resourceVersion` is unset, this is interpreted as "consistent read" and the
bookmark event is send when the state is synced at least to the moment
when request started being processed.
- `resourceVersionMatch` set to any other value or unset
Invalid error is returned.
Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */
sendInitialEvents?: boolean;
/** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */
timeoutSeconds?: number;
};
export type GetCheckApiResponse = /** status 200 OK */ Check;
export type GetCheckApiArg = {
/** name of the Check */
@@ -183,6 +352,20 @@ export type GetCheckApiArg = {
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
};
export type ReplaceCheckApiResponse = /** status 200 OK */ Check | /** status 201 Created */ Check;
export type ReplaceCheckApiArg = {
/** name of the Check */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
check: Check;
};
export type DeleteCheckApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status;
export type DeleteCheckApiArg = {
/** name of the Check */
@@ -261,6 +444,110 @@ export type ListCheckTypeApiArg = {
/** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */
watch?: boolean;
};
export type CreateCheckTypeApiResponse = /** status 200 OK */
| CheckType
| /** status 201 Created */ CheckType
| /** status 202 Accepted */ CheckType;
export type CreateCheckTypeApiArg = {
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
checkType: CheckType;
};
export type DeletecollectionCheckTypeApiResponse = /** status 200 OK */ Status;
export type DeletecollectionCheckTypeApiArg = {
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key".
This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */
continue?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** A selector to restrict the list of returned objects by their fields. Defaults to everything. */
fieldSelector?: string;
/** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */
gracePeriodSeconds?: number;
/** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */
ignoreStoreReadErrorWithClusterBreakingPotential?: boolean;
/** A selector to restrict the list of returned objects by their labels. Defaults to everything. */
labelSelector?: string;
/** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.
The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */
limit?: number;
/** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */
orphanDependents?: boolean;
/** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */
propagationPolicy?: string;
/** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
Defaults to unset */
resourceVersion?: string;
/** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
Defaults to unset */
resourceVersionMatch?: string;
/** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.
When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan
is interpreted as "data at least as new as the provided `resourceVersion`"
and the bookmark event is send when the state is synced
to a `resourceVersion` at least as fresh as the one provided by the ListOptions.
If `resourceVersion` is unset, this is interpreted as "consistent read" and the
bookmark event is send when the state is synced at least to the moment
when request started being processed.
- `resourceVersionMatch` set to any other value or unset
Invalid error is returned.
Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */
sendInitialEvents?: boolean;
/** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */
timeoutSeconds?: number;
};
export type GetCheckTypeApiResponse = /** status 200 OK */ CheckType;
export type GetCheckTypeApiArg = {
/** name of the CheckType */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
};
export type ReplaceCheckTypeApiResponse = /** status 200 OK */ CheckType | /** status 201 Created */ CheckType;
export type ReplaceCheckTypeApiArg = {
/** name of the CheckType */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
checkType: CheckType;
};
export type DeleteCheckTypeApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status;
export type DeleteCheckTypeApiArg = {
/** name of the CheckType */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */
gracePeriodSeconds?: number;
/** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */
ignoreStoreReadErrorWithClusterBreakingPotential?: boolean;
/** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */
orphanDependents?: boolean;
/** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */
propagationPolicy?: string;
};
export type UpdateCheckTypeApiResponse = /** status 200 OK */ CheckType | /** status 201 Created */ CheckType;
export type UpdateCheckTypeApiArg = {
/** name of the CheckType */
@@ -277,6 +564,38 @@ export type UpdateCheckTypeApiArg = {
force?: boolean;
patch: Patch;
};
export type ApiResource = {
/** categories is a list of the grouped resources this resource belongs to (e.g. 'all') */
categories?: string[];
/** group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale". */
group?: string;
/** kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo') */
kind: string;
/** name is the plural name of the resource. */
name: string;
/** namespaced indicates if a resource is namespaced or not. */
namespaced: boolean;
/** shortNames is a list of suggested short names of the resource. */
shortNames?: string[];
/** singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface. */
singularName: string;
/** The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates. */
storageVersionHash?: string;
/** verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy) */
verbs: string[];
/** version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)". */
version?: string;
};
export type ApiResourceList = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
/** groupVersion is the group and version this APIResourceList is for. */
groupVersion: string;
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
/** resources contains the name of the resources and if they are namespaced. */
resources: ApiResource[];
};
export type Time = string;
export type FieldsV1 = object;
export type ManagedFieldsEntry = {
@@ -390,6 +709,8 @@ export type CheckReportFailure = {
itemID: string;
/** Links to actions that can be taken to resolve the failure */
links: CheckErrorLink[];
/** More information about the failure */
moreInfo?: string;
/** Severity of the failure */
severity: string;
/** Step ID that the failure is associated with */
@@ -4,6 +4,7 @@ exports[`Moving a Data source managed rule should move a rule in a namespace to
[
{
"body": {
"interval": "1m",
"name": "group-1",
"rules": [
{
@@ -49,6 +50,7 @@ exports[`Moving a Data source managed rule should move a rule in an existing gro
[
{
"body": {
"interval": "1m",
"name": "entirely new group name",
"rules": [
{
@@ -190,6 +192,7 @@ exports[`Moving a Grafana managed rule should move a rule from an existing group
[
{
"body": {
"interval": "1m",
"name": "empty-group",
"rules": [
{
@@ -4,6 +4,7 @@ exports[`Updating a Data source managed rule should be able to move a rule if ta
[
{
"body": {
"interval": "1m",
"name": "a new group",
"rules": [
{
@@ -144,7 +145,7 @@ exports[`Updating a Grafana managed rule should move a rule in to another group
[
{
"body": {
"interval": "1m",
"interval": "5m",
"name": "grafana-group-2",
"rules": [
{
@@ -6,7 +6,7 @@ import { PostableRulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi';
import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
import { notFoundToNullOrThrow } from '../../api/util';
import { ruleGroupReducer } from '../../reducers/ruler/ruleGroups';
import { addRuleAction, ruleGroupReducer } from '../../reducers/ruler/ruleGroups';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
import { getDatasourceAPIUid } from '../../utils/datasource';
@@ -62,10 +62,15 @@ export function useProduceNewRuleGroup() {
.catch(notFoundToNullOrThrow);
const initialRuleGroupDefinition = latestRuleGroupDefinition ?? createBlankRuleGroup(groupName);
const newRuleGroupDefinition = actions.reduce(
(ruleGroup, action) => ruleGroupReducer(ruleGroup, action),
initialRuleGroupDefinition
);
const newRuleGroupDefinition = actions.reduce((ruleGroup, action) => {
// This is a workaround to ensure that the interval is set correctly when adding a rule to an existing rule group.
// The interval is set to default for DMA rules even for existing rule groups with a non-default interval.
// We no longer allow setting the interval for existing groups, but still allow that when you create a new rule group.
if (latestRuleGroupDefinition && addRuleAction.match(action)) {
action.payload.interval = latestRuleGroupDefinition.interval;
}
return ruleGroupReducer(ruleGroup, action);
}, initialRuleGroupDefinition);
return { newRuleGroupDefinition, rulerConfig };
};
@@ -9,8 +9,8 @@ import { PostableRuleDTO } from 'app/types/unified-alerting-dto';
import { setupMswServer } from '../../mockApi';
import { grantUserPermissions } from '../../mocks';
import {
grafanaRulerGroupName,
grafanaRulerGroupName2,
grafanaRulerGroup,
grafanaRulerGroup2,
grafanaRulerNamespace,
grafanaRulerRule,
} from '../../mocks/grafanaRulerApi';
@@ -41,7 +41,7 @@ describe('Updating a Grafana managed rule', () => {
const ruleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName,
groupName: grafanaRulerGroup.name,
namespaceName: grafanaRulerNamespace.uid,
};
@@ -71,13 +71,13 @@ describe('Updating a Grafana managed rule', () => {
const ruleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName,
groupName: grafanaRulerGroup.name,
namespaceName: grafanaRulerNamespace.uid,
};
const targetRuleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName2,
groupName: grafanaRulerGroup2.name,
namespaceName: grafanaRulerNamespace.uid,
};
@@ -110,7 +110,7 @@ describe('Updating a Grafana managed rule', () => {
it('should fail if the rule does not exist in the group', async () => {
const ruleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName,
groupName: grafanaRulerGroup.name,
namespaceName: grafanaRulerNamespace.uid,
};
@@ -70,7 +70,7 @@ export const grafanaRulerGroup: RulerRuleGroupDTO<RulerGrafanaRuleDTO> = {
export const grafanaRulerGroup2: RulerRuleGroupDTO<RulerGrafanaRuleDTO> = {
name: grafanaRulerGroupName2,
interval: '1m',
interval: '5m',
rules: [grafanaRulerRule],
};
@@ -71,15 +71,17 @@ export const rulerRuleGroupHandler = (options?: HandlerOptions) => {
return options.response;
}
// This mimic API response as closely as possible.
// Invalid folderUid returns 403 but invalid group will return 202 with empty list of rules
// This should be fixed soon to return 404 instead of 202
const namespace = rulerTestDb.getNamespace(folderUid);
if (!namespace) {
return new HttpResponse(null, { status: 403 });
}
const matchingGroup = rulerTestDb.getGroup(folderUid, groupName);
if (!matchingGroup) {
return new HttpResponse({ message: 'group does not exist' }, { status: 404 });
}
return HttpResponse.json<RulerRuleGroupDTO>({
name: groupName,
interval: matchingGroup?.interval,
@@ -53,6 +53,11 @@ export const rulerRuleGroupHandler = (options?: HandlerOptions) => {
}
const matchingGroup = namespace.find((group) => group.name === groupName);
if (!matchingGroup) {
return HttpResponse.json({ message: 'group does not exist' }, { status: 404 });
}
return HttpResponse.json<RulerRuleGroupDTO>({
name: groupName,
interval: matchingGroup?.interval,
@@ -9,6 +9,8 @@ import { hashRulerRule } from '../../utils/rule-id';
import { isCloudRuleIdentifier, isGrafanaRuleIdentifier, rulerRuleType } from '../../utils/rules';
// rule-scoped actions
// TOOD The interval field only make sense when adding a rule to a new rule group.
// We need to split these into distinct actions and introduce a separete addNewRuleGroupAction.
export const addRuleAction = createAction<{ rule: PostableRuleDTO; groupName?: string; interval?: string }>(
'ruleGroup/rules/add'
);
@@ -8,7 +8,7 @@ import { AccessControlAction } from 'app/types';
import { ExpressionEditorProps } from '../components/rule-editor/ExpressionEditor';
import { setupMswServer } from '../mockApi';
import { grantUserPermissions } from '../mocks';
import { GROUP_3, NAMESPACE_2 } from '../mocks/mimirRulerApi';
import { GROUP_3, GROUP_4, NAMESPACE_2 } from '../mocks/mimirRulerApi';
import { mimirDataSource } from '../mocks/server/configure';
import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants';
import { captureRequests, serializeRequests } from '../mocks/server/events';
@@ -86,4 +86,52 @@ describe('RuleEditor cloud', () => {
const serializedRequests = await serializeRequests(requests);
expect(serializedRequests).toMatchSnapshot();
});
it('should keep existing rule interval duration when attaching new rules', async () => {
const { user } = renderRuleEditor();
const removeExpressionsButtons = await screen.findAllByLabelText(/Remove expression/);
expect(removeExpressionsButtons).toHaveLength(2);
// Needs to wait for feature discovery API call to finish - Check if ruler enabled
expect(await screen.findByText('Data source-managed')).toBeInTheDocument();
const switchToCloudButton = screen.getByText('Data source-managed');
expect(switchToCloudButton).toBeInTheDocument();
expect(switchToCloudButton).toBeEnabled();
await user.click(switchToCloudButton);
//expressions are removed after switching to data-source managed
expect(screen.queryAllByLabelText(/Remove expression/)).toHaveLength(0);
expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toBeInTheDocument();
const dataSourceSelect = await ui.inputs.dataSource.find();
await user.click(dataSourceSelect);
await user.click(screen.getByText(MIMIR_DATASOURCE_UID));
await user.type(await ui.inputs.expr.find(), 'up == 1');
await user.type(ui.inputs.name.get(), 'my great new rule with 3m interval');
await clickSelectOption(ui.inputs.namespace.get(), NAMESPACE_2);
await clickSelectOption(ui.inputs.group.get(), GROUP_4);
await user.type(ui.inputs.annotationValue(0).get(), 'some summary');
await user.type(ui.inputs.annotationValue(1).get(), 'some description');
// TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed
await user.click(ui.buttons.addLabel.get());
// save and check what was sent to backend
const capture = captureRequests();
await user.click(ui.buttons.save.get());
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
const saveRequest = serializedRequests.find((req) => req.method === 'POST');
expect(saveRequest).toBeDefined();
expect(saveRequest?.body).toMatchObject({ interval: '3m' });
});
});
@@ -11,7 +11,7 @@ import { DashboardSearchItemType } from 'app/features/search/types';
import { AccessControlAction } from 'app/types';
import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks';
import { grafanaRulerGroup, grafanaRulerRule } from '../mocks/grafanaRulerApi';
import { grafanaRulerGroup, grafanaRulerGroup2, grafanaRulerRule } from '../mocks/grafanaRulerApi';
import { setFolderResponse } from '../mocks/server/configure';
import { captureRequests, serializeRequests } from '../mocks/server/events';
import { setupDataSources } from '../testSetup/datasources';
@@ -140,4 +140,47 @@ describe('RuleEditor grafana managed rules', () => {
const serializedRequests = await serializeRequests(requests);
expect(serializedRequests).toMatchSnapshot();
});
it('should keep existing group interval when creating new rule in existing group', async () => {
const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
const { user } = renderRuleEditor();
await user.type(await ui.inputs.name.find(), 'my great new rule');
await user.click(await screen.findByRole('button', { name: /select folder/i }));
await user.click(await screen.findByLabelText(/folder a/i));
// Select the existing group with 5m interval
const groupInput = await ui.inputs.group.find();
await user.click(await byRole('combobox').find(groupInput));
await clickSelectOption(groupInput, grafanaRulerGroup2.name);
await user.type(ui.inputs.annotationValue(1).get(), 'some description');
// Set pending period to none (0s) to avoid validation errors
const pendingPeriodInput = await ui.inputs.pendingPeriod.find();
await user.clear(pendingPeriodInput);
await user.type(pendingPeriodInput, '0s');
await user.click(ui.buttons.save.get());
expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully');
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
// Verify that the existing group's 5m interval is preserved
const saveRequest = serializedRequests.find((req) => req.method === 'POST');
expect(saveRequest).toBeDefined();
expect(saveRequest?.body).toMatchObject({
name: grafanaRulerGroup2.name,
interval: '5m', // The existing group's interval should be preserved
rules: expect.arrayContaining([
expect.objectContaining({
annotations: expect.objectContaining({
description: 'some description',
}),
for: '0s',
}),
]),
});
});
});
@@ -71,7 +71,7 @@ export function GrafanaGroupLoader({
);
}
if (!rulerResponse || !promResponse) {
if (!rulerResponse && !promResponse) {
return (
<Alert
title={t(
@@ -86,7 +86,7 @@ export function GrafanaGroupLoader({
return (
<>
{rulerResponse.rules.map((rulerRule) => {
{rulerResponse?.rules.map((rulerRule) => {
const promRule = matches.get(rulerRule);
if (!promRule) {
@@ -1,7 +1,7 @@
import { createElement, PureComponent } from 'react';
import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data';
import { readOnlyCopy } from 'app/features/plugins/extensions/utils';
import { writableProxy } from 'app/features/plugins/extensions/utils';
import { GenericDataSourcePlugin } from '../types';
@@ -34,7 +34,7 @@ export class DataSourcePluginSettings extends PureComponent<Props> {
<div>
{plugin.components.ConfigEditor &&
createElement(plugin.components.ConfigEditor, {
options: readOnlyCopy(dataSource),
options: writableProxy(dataSource, { source: 'datasource', pluginId: plugin.meta?.id }),
onOptionsChange: this.onModelChanged,
})}
</div>
@@ -1035,6 +1035,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
logOptionsStorageKey={SETTING_KEY_ROOT}
onLogOptionsChange={onLogOptionsChange}
hasUnescapedContent={hasUnescapedContent}
filterLevels={filterLevels}
/>
</div>
)}
@@ -7,6 +7,7 @@ import {
DataFrame,
EventBusSrv,
ExploreLogsPanelState,
LogLevel,
LogsMetaItem,
LogsSortOrder,
SplitOpen,
@@ -32,6 +33,7 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
logOptionsStorageKey?: string;
onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
range: TimeRange;
filterLevels?: LogLevel[];
/** Props added for Table **/
visualisationType: LogsVisualisationType;
@@ -45,7 +47,14 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
export type LogRowsComponentProps = Omit<
ControlledLogRowsProps,
'app' | 'dedupStrategy' | 'showLabels' | 'showTime' | 'logsSortOrder' | 'prettifyLogMessage' | 'wrapLogMessage'
| 'app'
| 'dedupStrategy'
| 'filterLevels'
| 'showLabels'
| 'showTime'
| 'logsSortOrder'
| 'prettifyLogMessage'
| 'wrapLogMessage'
>;
export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLogRowsProps>(
@@ -53,6 +62,7 @@ export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLog
{
deduplicatedRows,
dedupStrategy,
filterLevels,
hasUnescapedContent,
showLabels,
showTime,
@@ -72,6 +82,7 @@ export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLog
displayedFields={[]}
dedupStrategy={dedupStrategy}
enableLogDetails={false}
filterLevels={filterLevels}
fontSize="default"
hasUnescapedContent={hasUnescapedContent}
logOptionsStorageKey={logOptionsStorageKey}
@@ -46,6 +46,7 @@ export const createLogLine = (
order: LogsSortOrder.Descending,
timeZone: 'browser',
virtualization: undefined,
wrapLogMessage: true,
}
): LogListModel => {
const logs = preProcessLogs([createLogRow(overrides)], processOptions);
@@ -6,7 +6,7 @@ import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../__mocks__/logRow';
import { getStyles, LogLine, Props } from './LogLine';
import { getGridTemplateColumns, getStyles, LogLine, Props } from './LogLine';
import { LogListFontSize } from './LogList';
import { LogListContextProvider } from './LogListContext';
import { LogListSearchContext } from './LogListSearchContext';
@@ -36,7 +36,7 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
beforeEach(() => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: `log message 1` },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
);
contextProps.logs = [log];
contextProps.fontSize = fontSize;
@@ -226,7 +226,7 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
jest.spyOn(virtualization, 'getTruncationLength').mockReturnValue(5);
log = createLogLine(
{ labels: { place: 'luna' }, entry: `log message 1` },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
);
});
@@ -425,3 +425,89 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
});
});
});
describe('getGridTemplateColumns', () => {
test('Gets the template columns for the default visualization mode', () => {
expect(
getGridTemplateColumns(
[
{
field: 'timestamp',
width: 23,
},
{
field: 'level',
width: 4,
},
],
[]
)
).toBe('23px 4px 1fr');
});
test('Gets the template columns when displayed fields are used', () => {
expect(
getGridTemplateColumns(
[
{
field: 'timestamp',
width: 23,
},
{
field: 'level',
width: 4,
},
],
['field']
)
).toBe('23px 4px');
});
test('Gets the template columns when displayed fields are used', () => {
expect(
getGridTemplateColumns(
[
{
field: 'timestamp',
width: 23,
},
{
field: 'level',
width: 4,
},
{
field: 'field',
width: 4,
},
],
['field']
)
).toBe('23px 4px 4px');
});
test('Gets the template columns when displayed fields are used', () => {
expect(
getGridTemplateColumns(
[
{
field: 'timestamp',
width: 23,
},
{
field: 'level',
width: 4,
},
{
field: 'field',
width: 4,
},
{
field: LOG_LINE_BODY_FIELD_NAME,
width: 20,
},
],
['field']
)
).toBe('23px 4px 4px 20px');
});
});
@@ -192,7 +192,7 @@ const LogLineComponent = memo(
{/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`}
className={`${styles.fieldsWrapper} ${detailsShown ? styles.detailsDisplayed : ''} ${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`}
style={
collapsed && virtualization
? { maxHeight: `${virtualization.getTruncationLineCount() * virtualization.getLineHeight()}px` }
@@ -350,9 +350,10 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles
return <span className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />;
};
export function getGridTemplateColumns(dimensions: LogFieldDimension[]) {
export function getGridTemplateColumns(dimensions: LogFieldDimension[], displayedFields: string[]) {
const columns = dimensions.map((dimension) => dimension.width).join('px ');
return `${columns}px 1fr`;
const logLineWidth = displayedFields.length > 0 ? '' : ' 1fr';
return `${columns}px${logLineWidth}`;
}
export type LogLineStyles = ReturnType<typeof getStyles>;
@@ -368,6 +369,8 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
parsedField: theme.colors.text.primary,
};
const hoverColor = tinycolor(theme.colors.background.canvas).darken(4).toRgbString();
return {
logLine: css({
color: tinycolor(theme.colors.text.secondary).setAlpha(0.75).toRgbString(),
@@ -379,7 +382,7 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
lineHeight: theme.typography.body.lineHeight,
wordBreak: 'break-all',
'&:hover': {
background: theme.isDark ? `hsla(0, 0%, 0%, 0.3)` : `hsla(0, 0%, 0%, 0.1)`,
background: hoverColor,
},
'&.infinite-scroll': {
'&::before': {
@@ -440,7 +443,7 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
lineHeight: theme.typography.bodySmall.lineHeight,
}),
detailsDisplayed: css({
background: theme.isDark ? `hsla(0, 0%, 0%, 0.5)` : `hsla(0, 0%, 0%, 0.1)`,
background: hoverColor,
}),
pinnedLogLine: css({
backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(),
@@ -525,6 +528,9 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER),
whiteSpace: 'pre',
paddingBottom: theme.spacing(0.75),
'& .field': {
overflow: 'hidden',
},
}),
wrappedLogLine: css({
alignSelf: 'flex-start',
@@ -537,6 +543,11 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
marginRight: 0,
},
}),
fieldsWrapper: css({
'&:hover': {
background: hoverColor,
},
}),
collapsedLogLine: css({
overflow: 'hidden',
}),
@@ -248,7 +248,7 @@ const LogListComponent = ({
() => (wrapLogMessage ? [] : virtualization.calculateFieldDimensions(processedLogs, displayedFields)),
[displayedFields, processedLogs, virtualization, wrapLogMessage]
);
const styles = useStyles2(getStyles, dimensions, { showTime });
const styles = useStyles2(getStyles, dimensions, displayedFields, { showTime });
const widthContainer = wrapperRef.current ?? containerElement;
const {
closePopoverMenu,
@@ -283,13 +283,13 @@ const LogListComponent = ({
setProcessedLogs(
preProcessLogs(
logs,
{ getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone, virtualization },
{ getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone, virtualization, wrapLogMessage },
grammar
)
);
virtualization.resetLogLineSizes();
listRef.current?.resetAfterIndex(0);
}, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone, virtualization]);
}, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone, virtualization, wrapLogMessage]);
useEffect(() => {
listRef.current?.resetAfterIndex(0);
@@ -469,13 +469,18 @@ const LogListComponent = ({
);
};
function getStyles(theme: GrafanaTheme2, dimensions: LogFieldDimension[], { showTime }: { showTime: boolean }) {
function getStyles(
theme: GrafanaTheme2,
dimensions: LogFieldDimension[],
displayedFields: string[],
{ showTime }: { showTime: boolean }
) {
const columns = showTime ? dimensions : dimensions.filter((_, index) => index > 0);
return {
logList: css({
'& .unwrapped-log-line': {
display: 'grid',
gridTemplateColumns: getGridTemplateColumns(columns),
gridTemplateColumns: getGridTemplateColumns(columns, displayedFields),
},
}),
logListContainer: css({
@@ -79,6 +79,7 @@ describe('preProcessLogs', () => {
getFieldLinks,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true,
});
});
@@ -92,9 +93,53 @@ describe('preProcessLogs', () => {
entry: `35.191.12.195 - accounts.google.com:test@grafana.com [18/Mar/2025:08:58:38 +0000] 200 "POST /grafana/api/ds/query?ds_type=prometheus&requestId=SQR461 HTTP/1.1" 59460 "https://test.example.com/?orgId=1" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" "95.91.240.90, 34.107.247.24"`,
logLevel: LogLevel.critical,
});
const logListModel = new LogListModel(logRowModel, { escape: false, timeZone: 'browser ' });
const logListModel = new LogListModel(logRowModel, { escape: false, timeZone: 'browser ', wrapLogMessage: true });
expect(logListModel).toMatchObject(logRowModel);
});
test('Unwrapped log lines strip new lines', () => {
const logListModel = createLogLine(
{ labels: { place: `lu\nna` }, entry: `log\n message\n 1` },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: false, // unwrapped
}
);
expect(logListModel.getDisplayedFieldValue('place')).toBe('luna');
expect(logListModel.body).toBe('log message 1');
});
test('Wrapped log lines do not modify new lines', () => {
const logListModel = createLogLine(
{ labels: { place: `lu\nna` }, entry: `log\n message\n 1` },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true, // wrapped
}
);
expect(logListModel.getDisplayedFieldValue('place')).toBe(logListModel.labels['place']);
expect(logListModel.body).toBe(logListModel.raw);
});
test('Strips ansi colors for measurement', () => {
const logListModel = createLogLine(
{ entry: `log \u001B[31mmessage\u001B[0m 1` },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true,
}
);
expect(logListModel.getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME, false)).toBe(
`log \u001B[31mmessage\u001B[0m 1`
);
expect(logListModel.getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME, true)).toBe('log message 1');
});
});
test('Orders logs', () => {
@@ -176,7 +221,7 @@ describe('preProcessLogs', () => {
entry = new Array(2 * virtualization.getTruncationLength(null)).fill('e').join('');
longLog = createLogLine(
{ entry, labels: { field: 'value' } },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
);
});
@@ -1,3 +1,4 @@
import ansicolor from 'ansicolor';
import Prism, { Grammar } from 'prismjs';
import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data';
@@ -11,6 +12,7 @@ import { generateLogGrammar, generateTextMatchGrammar } from './grammar';
import { LogLineVirtualization } from './virtualization';
const TRUNCATION_DEFAULT_LENGTH = 50000;
const NEWLINES_REGEX = /(\r\n|\n|\r)/g;
export class LogListModel implements LogRowModel {
collapsed: boolean | undefined = undefined;
@@ -47,8 +49,12 @@ export class LogListModel implements LogRowModel {
private _fields: FieldDef[] | undefined = undefined;
private _getFieldLinks: GetFieldLinksFn | undefined = undefined;
private _virtualization?: LogLineVirtualization;
private _wrapLogMessage: boolean;
constructor(log: LogRowModel, { escape, getFieldLinks, grammar, timeZone, virtualization }: PreProcessLogOptions) {
constructor(
log: LogRowModel,
{ escape, getFieldLinks, grammar, timeZone, virtualization, wrapLogMessage }: PreProcessLogOptions
) {
// LogRowModel
this.datasourceType = log.datasourceType;
this.dataFrame = log.dataFrame;
@@ -82,6 +88,7 @@ export class LogListModel implements LogRowModel {
defaultWithMS: true,
});
this._virtualization = virtualization;
this._wrapLogMessage = wrapLogMessage;
let raw = log.raw;
if (escape && log.hasUnescapedContent) {
@@ -95,6 +102,9 @@ export class LogListModel implements LogRowModel {
this._body = this.collapsed
? this.raw.substring(0, this._virtualization?.getTruncationLength(null) ?? TRUNCATION_DEFAULT_LENGTH)
: this.raw;
if (!this._wrapLogMessage) {
this._body = this._body.replace(NEWLINES_REGEX, '');
}
}
return this._body;
}
@@ -123,25 +133,31 @@ export class LogListModel implements LogRowModel {
return checkLogsSampled(this);
}
getDisplayedFieldValue(fieldName: string): string {
getDisplayedFieldValue(fieldName: string, stripAnsi = false): string {
if (fieldName === LOG_LINE_BODY_FIELD_NAME) {
return this.body;
return stripAnsi ? ansicolor.strip(this.body) : this.body;
}
let fieldValue = '';
if (this.labels[fieldName] != null) {
return this.labels[fieldName];
}
const field = this.fields.find((field) => {
return field.keys[0] === fieldName;
});
fieldValue = this.labels[fieldName];
} else {
const field = this.fields.find((field) => {
return field.keys[0] === fieldName;
});
return field ? field.values.toString() : '';
fieldValue = field ? field.values.toString() : '';
}
if (!this._wrapLogMessage) {
return fieldValue.replace(NEWLINES_REGEX, '');
}
return fieldValue;
}
updateCollapsedState(displayedFields: string[], container: HTMLDivElement | null) {
const lineLength =
displayedFields.length > 0
? displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length
: this.raw.length;
? displayedFields.map((field) => this.getDisplayedFieldValue(field, true)).join('').length
: this.entry.length;
const collapsed =
lineLength >= (this._virtualization?.getTruncationLength(container) ?? TRUNCATION_DEFAULT_LENGTH)
? true
@@ -172,15 +188,18 @@ export interface PreProcessOptions {
order: LogsSortOrder;
timeZone: string;
virtualization?: LogLineVirtualization;
wrapLogMessage: boolean;
}
export const preProcessLogs = (
logs: LogRowModel[],
{ escape, getFieldLinks, order, timeZone, virtualization }: PreProcessOptions,
{ escape, getFieldLinks, order, timeZone, virtualization, wrapLogMessage }: PreProcessOptions,
grammar?: Grammar
): LogListModel[] => {
const orderedLogs = sortLogRows(logs, order);
return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, grammar, timeZone, virtualization }));
return orderedLogs.map((log) =>
preProcessLog(log, { escape, getFieldLinks, grammar, timeZone, virtualization, wrapLogMessage })
);
};
interface PreProcessLogOptions {
@@ -189,6 +208,7 @@ interface PreProcessLogOptions {
grammar?: Grammar;
timeZone: string;
virtualization?: LogLineVirtualization;
wrapLogMessage: boolean;
}
const preProcessLog = (log: LogRowModel, options: PreProcessLogOptions): LogListModel => {
return new LogListModel(log, options);
@@ -3,7 +3,7 @@ import { createTheme, LogsSortOrder } from '@grafana/data';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../__mocks__/logRow';
import { LogListModel } from './processing';
import { LogListModel, PreProcessOptions } from './processing';
import { LogLineVirtualization, getLogLineSize, DisplayOptions } from './virtualization';
describe('Virtualization', () => {
@@ -28,12 +28,16 @@ describe('Virtualization', () => {
hasSampledLogs: false,
};
const preProcessOptions: PreProcessOptions = {
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
virtualization,
wrapLogMessage: true,
};
beforeEach(() => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: `log message 1` },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
//virtualization = new LogLineVirtualization(createTheme(), 'default');
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }, preProcessOptions);
container = document.createElement('div');
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE);
LETTER_WIDTH = virtualization.measureTextWidth('e');
@@ -86,7 +90,7 @@ describe('Virtualization', () => {
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
logLevel: undefined,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(virtualization, [log], container, [], { ...defaultOptions, wrap: true }, 0);
@@ -96,7 +100,7 @@ describe('Virtualization', () => {
test('Measures a multi-line log line with level, controls, and displayed time', () => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@@ -118,7 +122,7 @@ describe('Virtualization', () => {
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
logLevel: undefined,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@@ -136,7 +140,7 @@ describe('Virtualization', () => {
test('Measures displayed fields in a log line with level, controls, and displayed time', () => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@@ -154,7 +158,7 @@ describe('Virtualization', () => {
test('Measures a multi-line log line with duplicates', () => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
log.duplicates = 1;
@@ -173,7 +177,7 @@ describe('Virtualization', () => {
test('Measures a multi-line log line with errors', () => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@@ -191,7 +195,7 @@ describe('Virtualization', () => {
test('Measures a multi-line sampled log line', () => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@@ -214,6 +218,29 @@ describe('Virtualization', () => {
});
});
describe('calculateFieldDimensions', () => {
test('Measures displayed fields including the log line body', () => {
expect(virtualization.calculateFieldDimensions([log], ['place', LOG_LINE_BODY_FIELD_NAME])).toEqual([
{
field: 'timestamp',
width: 23,
},
{
field: 'level',
width: 4,
},
{
field: 'place',
width: 4,
},
{
field: '___LOG_LINE_BODY___',
width: 13,
},
]);
});
});
describe('With small font size', () => {
const virtualization = new LogLineVirtualization(createTheme(), 'small');
@@ -232,7 +259,7 @@ describe('Virtualization', () => {
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
logLevel: undefined,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@@ -2,8 +2,6 @@ import ansicolor from 'ansicolor';
import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogListFontSize } from './LogList';
import { LogListModel } from './processing';
@@ -195,7 +193,7 @@ export class LogLineVirtualization {
levelWidth = Math.round(width);
}
for (const field of displayedFields) {
width = this.measureTextWidth(logs[i].getDisplayedFieldValue(field));
width = this.measureTextWidth(logs[i].getDisplayedFieldValue(field, true));
fieldWidths[field] = !fieldWidths[field] || width > fieldWidths[field] ? Math.round(width) : fieldWidths[field];
}
}
@@ -210,10 +208,6 @@ export class LogLineVirtualization {
},
];
for (const field in fieldWidths) {
// Skip the log line when it's a displayed field
if (field === LOG_LINE_BODY_FIELD_NAME) {
continue;
}
dimensions.push({
field,
width: fieldWidths[field],
@@ -294,7 +288,7 @@ export function getLogLineSize(
textToMeasure += logs[index].displayLevel ?? '';
}
for (const field of displayedFields) {
textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure;
textToMeasure = logs[index].getDisplayedFieldValue(field, true) + textToMeasure;
}
if (!displayedFields.length) {
textToMeasure += ansicolor.strip(logs[index].body);

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