Compare commits

..
Author SHA1 Message Date
Georges Chaudy 3c83c64d72 Implement batch operation support in KV storage
- Introduced a new Batch method to execute multiple operations atomically within a single transaction.
- Defined BatchOp types for various operations: put, create, update, and delete, with specific semantics for each.
- Updated the KV interface and implementation to support batch operations, ensuring rollback on failure.
- Enhanced testing suite to cover various batch scenarios, including success, failure, and edge cases, ensuring robust functionality.
2026-01-09 15:24:34 +01:00
Georges Chaudy 6e39b24b6f Enhance comparison functions in KV storage
- Updated the CompareKeyExists function to simplify its usage by removing the 'exists' parameter, making it always succeed if the key exists.
- Introduced a new CompareKeyNotExists function to check for non-existent keys.
- Modified transaction tests to utilize the new comparison functions, ensuring accurate behavior for both existing and non-existing keys.
2025-12-04 16:17:43 +01:00
Georges Chaudy c31c1d8e8d Add transaction support to KV storage
- Introduced transaction operations with compare-and-swap semantics.
- Added types and functions for comparisons (e.g., CompareExists, CompareValue) and transaction operations (e.g., TxnOpPut, TxnOpDelete).
- Implemented Txn method to execute transactions based on comparisons and success/failure operations.
- Added validation for transaction requests to enforce limits on comparisons and operations.
- Enhanced testing suite to cover various transaction scenarios, including success and failure cases, and edge conditions.

This update enhances the KV interface, allowing for more complex data manipulation while ensuring data integrity through transactional guarantees.
2025-12-03 11:33:22 +01:00
Paul Marbach e36ea78771 Suggestions: Deprecate the old API and put external suggestions behind a flag (#114127)
* Suggestions: Deprecate previous API, enable external plugin suggestions behind flag

* fix types for deprecated builder

* restore some support for cloud-onboarding

* add support for cloud-onboarding usage, add test to ensure it keeps working

* refactor to not hardcode on 'core:'

* remove unused import
2025-12-01 23:22:22 +00:00
Steve Simpson b332a108f3 Alerting: Notification history query API. (#114677)
* Alerting: Notification history query API.

First cut at defining a namespace scoped route on the historian.alerting app
to query notification history.

* Address review comments
2025-12-02 00:14:54 +01:00
Todd Treece 1060dd538a CI: Run lint on self-hosted ubuntu-x64-small (#114674) 2025-12-01 22:27:14 +00:00
Todd Treece be8076dee8 CI: Run lint on ubuntu-latest-8-cores (#114673) 2025-12-01 21:40:46 +00:00
Ashley Harrison 7f1ac6188a PanelChrome: Wrapping div needs height: 100% as well (#114655)
wrapping div needs height: 100% as well
2025-12-01 17:39:15 +00:00
Rafael Bortolon Paulovic 31eaf1e898 chore: add log and metric before unified migration enforcement (#114598) 2025-12-01 17:56:59 +01:00
71 changed files with 1539 additions and 553 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
lint-go:
needs: detect-changes
if: needs.detect-changes.outputs.changed == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-x64-large-io
steps:
- uses: actions/checkout@v5
with:
+9 -23
View File
@@ -1,34 +1,20 @@
package kinds
import (
"github.com/grafana/grafana/apps/alerting/historian/kinds/v0alpha1"
)
manifest: {
appName: "alerting-historian"
groupOverride: "historian.alerting.grafana.app"
versions: {
"v0alpha1": v0alpha1
"v0alpha1": {
kinds: [dummyv0alpha1]
routes: v0alpha1.routes
}
}
}
v0alpha1: {
kinds: [dummyv0alpha1]
routes: {
namespaced: {
// This endpoint is an exact copy of the existing /history endpoint,
// with the exception that error responses will be Kubernetes-style,
// not Grafana-style. It will be replaced in the future with a better
// more schema-friendly API.
"/alertstate/history": {
"GET": {
response: {
body: [string]: _
}
responseMetadata: typeMeta: false
}
}
}
}
}
dummyv0alpha1: {
kind: "Dummy"
schema: {
@@ -37,4 +23,4 @@ dummyv0alpha1: {
dummyField: int
}
}
}
}
@@ -0,0 +1,9 @@
package v0alpha1
#Matcher: {
type: "=" | "!=" | "=~" | "!~" @cuetsy(kind="enum",memberNames="Equal|NotEqual|EqualRegex|NotEqualRegex")
label: string
value: string
}
#Matchers: [...#Matcher]
@@ -0,0 +1,65 @@
package v0alpha1
import (
"time"
)
#NotificationStatus: "firing" | "resolved" @cog(kind="enum",memberNames="Firing|Resolved")
#NotificationOutcome: "success" | "error" @cog(kind="enum",memberNames="Success|Error")
#NotificationQuery: {
// From is the starting timestamp for the query.
from?: time.Time
// To is the starting timestamp for the query.
to?: time.Time
// Limit is the maximum number of entries to return.
limit?: int64
// Receiver optionally filters the entries by receiver title (contact point).
receiver?: string
// Status optionally filters the entries to only either firing or resolved.
status?: #NotificationStatus
// Outcome optionally filters the entries to only either successful or failed attempts.
outcome?: #NotificationOutcome
// RuleUID optionally filters the entries to a specific alert rule.
ruleUID?: string
// GroupLabels optionally filters the entries by matching group labels.
groupLabels?: #Matchers
}
#NotificationQueryResult: {
entries: [...#NotificationEntry]
}
#NotificationEntry: {
// Timestamp is the time at which the notification attempt completed.
timestamp: time.Time
// Receiver is the receiver (contact point) title.
receiver: string
// Status indicates if the notification contains one or more firing alerts.
status: #NotificationStatus
// Outcome indicaes if the notificaion attempt was successful or if it failed.
outcome: #NotificationOutcome
// GroupLabels are the labels uniquely identifying the alert group within a route.
groupLabels: [string]: string
// Alerts are the alerts grouped into the notification.
alerts: [...#NotificationEntryAlert]
// Retry indicates if the attempt was a retried attempt.
retry: bool
// Error is the message returned by the contact point if delivery failed.
error?: string
// Duration is the length of time the notification attempt took in nanoseconds.
duration: int
// PipelineTime is the time at which the flush began.
pipelineTime: time.Time
// GroupKey uniquely idenifies the dispatcher alert group.
groupKey: string
}
#NotificationEntryAlert: {
status: string
labels: [string]: string
annotations: [string]: string
startsAt: time.Time
endsAt: time.Time
}
@@ -0,0 +1,29 @@
package v0alpha1
routes: {
namespaced: {
// This endpoint is an exact copy of the existing /history endpoint,
// with the exception that error responses will be Kubernetes-style,
// not Grafana-style. It will be replaced in the future with a better
// more schema-friendly API.
"/alertstate/history": {
"GET": {
response: {
body: [string]: _
}
responseMetadata: typeMeta: false
}
}
// Query notification history.
"/notification/query": {
"POST": {
request: {
body: #NotificationQuery
}
response: #NotificationQueryResult
responseMetadata: typeMeta: false
}
}
}
}
@@ -0,0 +1,67 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
time "time"
)
type CreateNotificationqueryRequestNotificationStatus string
const (
CreateNotificationqueryRequestNotificationStatusFiring CreateNotificationqueryRequestNotificationStatus = "firing"
CreateNotificationqueryRequestNotificationStatusResolved CreateNotificationqueryRequestNotificationStatus = "resolved"
)
type CreateNotificationqueryRequestNotificationOutcome string
const (
CreateNotificationqueryRequestNotificationOutcomeSuccess CreateNotificationqueryRequestNotificationOutcome = "success"
CreateNotificationqueryRequestNotificationOutcomeError CreateNotificationqueryRequestNotificationOutcome = "error"
)
type CreateNotificationqueryRequestMatchers []CreateNotificationqueryRequestMatcher
type CreateNotificationqueryRequestMatcher struct {
Type CreateNotificationqueryRequestMatcherType `json:"type"`
Label string `json:"label"`
Value string `json:"value"`
}
// NewCreateNotificationqueryRequestMatcher creates a new CreateNotificationqueryRequestMatcher object.
func NewCreateNotificationqueryRequestMatcher() *CreateNotificationqueryRequestMatcher {
return &CreateNotificationqueryRequestMatcher{}
}
type CreateNotificationqueryRequestBody struct {
// From is the starting timestamp for the query.
From *time.Time `json:"from,omitempty"`
// To is the starting timestamp for the query.
To *time.Time `json:"to,omitempty"`
// Limit is the maximum number of entries to return.
Limit *int64 `json:"limit,omitempty"`
// Receiver optionally filters the entries by receiver title (contact point).
Receiver *string `json:"receiver,omitempty"`
// Status optionally filters the entries to only either firing or resolved.
Status *CreateNotificationqueryRequestNotificationStatus `json:"status,omitempty"`
// Outcome optionally filters the entries to only either successful or failed attempts.
Outcome *CreateNotificationqueryRequestNotificationOutcome `json:"outcome,omitempty"`
// RuleUID optionally filters the entries to a specific alert rule.
RuleUID *string `json:"ruleUID,omitempty"`
// GroupLabels optionally filters the entries by matching group labels.
GroupLabels *CreateNotificationqueryRequestMatchers `json:"groupLabels,omitempty"`
}
// NewCreateNotificationqueryRequestBody creates a new CreateNotificationqueryRequestBody object.
func NewCreateNotificationqueryRequestBody() *CreateNotificationqueryRequestBody {
return &CreateNotificationqueryRequestBody{}
}
type CreateNotificationqueryRequestMatcherType string
const (
CreateNotificationqueryRequestMatcherTypeEqual CreateNotificationqueryRequestMatcherType = "="
CreateNotificationqueryRequestMatcherTypeNotEqual CreateNotificationqueryRequestMatcherType = "!="
CreateNotificationqueryRequestMatcherTypeEqualRegex CreateNotificationqueryRequestMatcherType = "=~"
CreateNotificationqueryRequestMatcherTypeNotEqualRegex CreateNotificationqueryRequestMatcherType = "!~"
)
@@ -0,0 +1,86 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
time "time"
)
// +k8s:openapi-gen=true
type NotificationEntry struct {
// Timestamp is the time at which the notification attempt completed.
Timestamp time.Time `json:"timestamp"`
// Receiver is the receiver (contact point) title.
Receiver string `json:"receiver"`
// Status indicates if the notification contains one or more firing alerts.
Status NotificationStatus `json:"status"`
// Outcome indicaes if the notificaion attempt was successful or if it failed.
Outcome NotificationOutcome `json:"outcome"`
// GroupLabels are the labels uniquely identifying the alert group within a route.
GroupLabels map[string]string `json:"groupLabels"`
// Alerts are the alerts grouped into the notification.
Alerts []NotificationEntryAlert `json:"alerts"`
// Retry indicates if the attempt was a retried attempt.
Retry bool `json:"retry"`
// Error is the message returned by the contact point if delivery failed.
Error *string `json:"error,omitempty"`
// Duration is the length of time the notification attempt took in nanoseconds.
Duration int64 `json:"duration"`
// PipelineTime is the time at which the flush began.
PipelineTime time.Time `json:"pipelineTime"`
// GroupKey uniquely idenifies the dispatcher alert group.
GroupKey string `json:"groupKey"`
}
// NewNotificationEntry creates a new NotificationEntry object.
func NewNotificationEntry() *NotificationEntry {
return &NotificationEntry{
GroupLabels: map[string]string{},
Alerts: []NotificationEntryAlert{},
}
}
// +k8s:openapi-gen=true
type NotificationStatus string
const (
NotificationStatusFiring NotificationStatus = "firing"
NotificationStatusResolved NotificationStatus = "resolved"
)
// +k8s:openapi-gen=true
type NotificationOutcome string
const (
NotificationOutcomeSuccess NotificationOutcome = "success"
NotificationOutcomeError NotificationOutcome = "error"
)
// +k8s:openapi-gen=true
type NotificationEntryAlert struct {
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
}
// NewNotificationEntryAlert creates a new NotificationEntryAlert object.
func NewNotificationEntryAlert() *NotificationEntryAlert {
return &NotificationEntryAlert{
Labels: map[string]string{},
Annotations: map[string]string{},
}
}
// +k8s:openapi-gen=true
type CreateNotificationquery struct {
Entries []NotificationEntry `json:"entries"`
}
// NewCreateNotificationquery creates a new CreateNotificationquery object.
func NewCreateNotificationquery() *CreateNotificationquery {
return &CreateNotificationquery{
Entries: []NotificationEntry{},
}
}
+318 -3
View File
@@ -92,9 +92,321 @@ var appManifestData = app.ManifestData{
},
},
},
"/notification/query": {
Post: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "createNotificationquery",
RequestBody: &spec3.RequestBody{
RequestBodyProps: spec3.RequestBodyProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"from": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "From is the starting timestamp for the query.",
},
},
"groupLabels": {
SchemaProps: spec.SchemaProps{
Description: "GroupLabels optionally filters the entries by matching group labels.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryMatchers"),
},
},
"limit": {
SchemaProps: spec.SchemaProps{
Type: []string{"integer"},
Description: "Limit is the maximum number of entries to return.",
},
},
"outcome": {
SchemaProps: spec.SchemaProps{
Description: "Outcome optionally filters the entries to only either successful or failed attempts.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationOutcome"),
},
},
"receiver": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Receiver optionally filters the entries by receiver title (contact point).",
},
},
"ruleUID": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "RuleUID optionally filters the entries to a specific alert rule.",
},
},
"status": {
SchemaProps: spec.SchemaProps{
Description: "Status optionally filters the entries to only either firing or resolved.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationStatus"),
},
},
"to": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "To is the starting timestamp for the query.",
},
},
},
}},
}},
},
}},
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
Default: &spec3.Response{
ResponseProps: spec3.ResponseProps{
Description: "Default OK response",
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"entries": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
},
},
},
Required: []string{
"entries",
},
}},
}},
},
},
},
}},
},
},
},
},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{},
Schemas: map[string]spec.Schema{
"createNotificationqueryMatcher": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"label": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
"type": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Enum: []interface{}{
"=",
"!=",
"=~",
"!~",
},
},
},
"value": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
Required: []string{
"type",
"label",
"value",
},
},
},
"createNotificationqueryMatchers": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
},
},
"createNotificationqueryNotificationEntry": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"alerts": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Description: "Alerts are the alerts grouped into the notification.",
},
},
"duration": {
SchemaProps: spec.SchemaProps{
Type: []string{"integer"},
Description: "Duration is the length of time the notification attempt took in nanoseconds.",
},
},
"error": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Error is the message returned by the contact point if delivery failed.",
},
},
"groupKey": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "GroupKey uniquely idenifies the dispatcher alert group.",
},
},
"groupLabels": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Description: "GroupLabels are the labels uniquely identifying the alert group within a route.",
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
"outcome": {
SchemaProps: spec.SchemaProps{
Description: "Outcome indicaes if the notificaion attempt was successful or if it failed.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationOutcome"),
},
},
"pipelineTime": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "PipelineTime is the time at which the flush began.",
},
},
"receiver": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Receiver is the receiver (contact point) title.",
},
},
"retry": {
SchemaProps: spec.SchemaProps{
Type: []string{"boolean"},
Description: "Retry indicates if the attempt was a retried attempt.",
},
},
"status": {
SchemaProps: spec.SchemaProps{
Description: "Status indicates if the notification contains one or more firing alerts.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationStatus"),
},
},
"timestamp": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "Timestamp is the time at which the notification attempt completed.",
},
},
},
Required: []string{
"timestamp",
"receiver",
"status",
"outcome",
"groupLabels",
"alerts",
"retry",
"duration",
"pipelineTime",
"groupKey",
},
},
},
"createNotificationqueryNotificationEntryAlert": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"annotations": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
"endsAt": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
},
},
"labels": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
"startsAt": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
},
},
"status": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
Required: []string{
"status",
"labels",
"annotations",
"startsAt",
"endsAt",
},
},
},
"createNotificationqueryNotificationOutcome": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Enum: []interface{}{
"success",
"error",
},
},
},
"createNotificationqueryNotificationStatus": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Enum: []interface{}{
"firing",
"resolved",
},
},
},
},
},
},
},
@@ -120,7 +432,8 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist
}
var customRouteToGoResponseType = map[string]any{
"v0alpha1||<namespace>/alertstate/history|GET": v0alpha1.GetAlertstatehistory{},
"v0alpha1||<namespace>/alertstate/history|GET": v0alpha1.GetAlertstatehistory{},
"v0alpha1||<namespace>/notification/query|POST": v0alpha1.CreateNotificationquery{},
}
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
@@ -145,7 +458,9 @@ func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goTyp
return goType, exists
}
var customRouteToGoRequestBodyType = map[string]any{}
var customRouteToGoRequestBodyType = map[string]any{
"v0alpha1||<namespace>/notification/query|POST": v0alpha1.CreateNotificationqueryRequestBody{},
}
func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) {
if len(path) > 0 && path[0] == '/' {
+20
View File
@@ -1,8 +1,13 @@
package app
import (
"context"
"net/http"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/simple"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1"
"github.com/grafana/grafana/apps/alerting/historian/pkg/app/config"
@@ -21,6 +26,11 @@ func New(cfg app.Config) (app.App, error) {
Path: "/alertstate/history",
Method: "GET",
}: runtimeConfig.GetAlertStateHistoryHandler,
{
Namespaced: true,
Path: "/notification/query",
Method: "POST",
}: UnimplementedHandler,
},
},
// TODO: Remove when SDK is fixed.
@@ -43,3 +53,13 @@ func New(cfg app.Config) (app.App, error) {
return a, nil
}
func UnimplementedHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusUnprocessableEntity,
Message: "unimplemented",
},
}
}
+1
View File
@@ -53,6 +53,7 @@ pluginMetaV0Alpha1: {
skipDataQuery?: bool
state?: "alpha" | "beta"
streaming?: bool
suggestions?: bool
tracing?: bool
iam?: #IAM
// +listType=atomic
@@ -40,6 +40,7 @@ type PluginMetaJSONData struct {
SkipDataQuery *bool `json:"skipDataQuery,omitempty"`
State *PluginMetaJSONDataState `json:"state,omitempty"`
Streaming *bool `json:"streaming,omitempty"`
Suggestions *bool `json:"suggestions,omitempty"`
Tracing *bool `json:"tracing,omitempty"`
Iam *PluginMetaIAM `json:"iam,omitempty"`
// +listType=atomic
File diff suppressed because one or more lines are too long
@@ -341,6 +341,10 @@
"type": "boolean",
"description": "Initialize plugin on startup. By default, the plugin initializes on first use, but when preload is set to true the plugin loads when the Grafana web app loads the first time. Only applicable to app plugins. When setting to `true`, implement [frontend code splitting](https://grafana.com/developers/plugin-tools/get-started/best-practices#app-plugins) to minimise performance implications."
},
"suggestions": {
"type": "boolean",
"description": "For panel plugins. If set to true, the plugin's suggestions supplier will be invoked and any suggestions returned will be included in the Suggestions pane in the Panel Editor."
},
"queryOptions": {
"type": "object",
"description": "For data source plugins. There is a query options section in the plugin's query editor and these options can be turned on if needed.",
@@ -226,8 +226,8 @@ Dashboards are reloaded when the JSON files change.
#### `min_tls_version`
The TLS handshake requires a minimum TLS version. Available options are `TLS1.2` and `TLS1.3`.
If you don't specify a version, Grafana uses `TLS1.2`.
The TLS Handshake requires a minimum TLS version. The available options are TLS1.2 and TLS1.3.
If you do not specify a version, the system uses TLS1.2.
#### `http_addr`
@@ -286,9 +286,9 @@ Set to `true` for Grafana to log all HTTP requests (not just errors). These are
#### `static_root_path`
The path to the directory containing the frontend files (HTML, JS, and CSS).
Defaults to `public`, which is why you must run the Grafana binary with the
working directory set to the installation path.
The path to the directory where the frontend files (HTML, JS, and CSS
files). Defaults to `public` which is why the Grafana binary needs to be
executed with working directory set to the installation path.
#### `enable_gzip`
@@ -312,8 +312,8 @@ Optional. Password to decrypt encrypted certificates.
#### `certs_watch_interval`
Controls whether `cert_key` and `cert_file` are periodically watched for changes.
Disabled by default. When enabled, `cert_key` and `cert_file` are watched for
changes. If there is a change, Grafana loads the new certificates automatically.
Disabled, by default. When enabled, `cert_key` and `cert_file`
are watched for changes. If there is change, the new certificates are loaded automatically.
{{< admonition type="warning" >}}
After the new certificates are loaded, connections with old certificates don't work.
@@ -393,10 +393,6 @@ The database user's password (not applicable for `sqlite3`). If the password con
Use either URL or the previous fields to configure the database
Example: `type://user:password@host:port/name`
#### `high_availability`
Enable or disable high availability mode. When disabled, some functions run in-process instead of relying on the database. Default is `true`.
#### `max_idle_conn`
The maximum number of connections in the idle connection pool.
@@ -479,10 +475,6 @@ This setting applies to `sqlite` only and controls the number of times the syste
Set to `true` to add metrics and tracing for database queries. The default value is `false`.
#### `delete_auto_gen_ids`
Delete auto-generated primary keys during migrations. Useful if the database has auto-generated primary keys enabled. Default is `false`.
#### `skip_dashboard_uid_migration_on_startup`
Set to true to skip dashboard UID migrations on startup. Improves startup performance for instances with large numbers of annotations who do not plan to downgrade Grafana. The default value is `false`.
@@ -524,14 +516,6 @@ Example connection string: `addr=127.0.0.1:6379,pool_size=100,db=0,username=graf
Example connection string: `127.0.0.1:11211`
#### `prefix`
Prefix prepended to all keys in the remote cache.
#### `encryption`
Enable encryption of values stored in the remote cache.
<hr />
### `[dataproxy]`
@@ -546,10 +530,6 @@ How long the data proxy should wait before timing out. Default is 30 seconds.
This setting also applies to core backend HTTP data sources where query requests use an HTTP client with timeout set.
#### `dialTimeout`
How long the data proxy waits to establish a TCP connection before timing out. Default is `10` seconds.
#### `keep_alive_seconds`
Interval between keep-alive probes. Default is `30` seconds. For more details, refer to the [`Dialer.KeepAlive`](https://golang.org/pkg/net/#Dialer.KeepAlive) documentation.
@@ -838,22 +818,6 @@ Set to `true` to execute the CSRF check even if the login cookie is not in a req
Comma-separated list of plugins IDs to load inside the frontend sandbox.
### `[security.encryption]`
Configure encryption-related cache settings for data encryption keys used by Grafana.
#### `data_keys_cache_ttl`
Defines the time-to-live (TTL) for decrypted data encryption keys stored in memory. Default: `15m`.
#### `data_keys_cache_cleanup_interval`
Sets how often Grafana cleans up the encryption key cache, removing entries that reached the TTL. Default: `1m`.
{{< admonition type="note" >}}
Small TTL values can impact performance due to frequent decryption operations.
{{< /admonition >}}
### `[snapshots]`
#### `enabled`
@@ -1686,10 +1650,6 @@ Custom HTTP endpoint to send events captured by the Grafana Faro agent to. Defau
If `custom_endpoint` required authentication, you can set the API key here. Only relevant for Grafana JavaScript Agent provider.
#### `internal_logger_level`
Sets the internal logging level for the Grafana JavaScript agent. Allowed values are `0` (OFF), `1` (ERROR), `2` (WARN), `3` (INFO), and `4` (VERBOSE). Default is `0`.
#### `instrumentations_console_enabled`
Enables the [Console instrumentation](https://grafana.com/docs/grafana-cloud/monitor-applications/frontend-observability/instrument/console-instrumentation/) for Grafana Faro, defaults to `true`.
@@ -2010,29 +1970,7 @@ This setting has precedence over each individual rule frequency.
If a rule frequency is lower than this value, then this value is enforced.
{{< /admonition >}}
#### `state_periodic_save_interval`
If the `alertingSaveStatePeriodic` feature flag is enabled, sets the interval used to persist alerting instances to the database. Specify a duration (for example, `5m`). Default is `5m`.
#### `state_periodic_save_batch_size`
If the `alertingSaveStatePeriodic` feature flag is enabled, sets the size of the batch that is saved to the database at once. Default is `1`.
#### `state_periodic_save_jitter_enabled`
Enables jitter for periodic state saving to distribute database load over time. When enabled, batches are spread across the save interval to prevent load spikes. Default is `false`.
#### `disable_jitter`
Disables smoothing of alert evaluations across their evaluation window. When set to `true`, rules evaluate in sync. Default is `false`.
#### `notification_log_retention`
Retention period for Alertmanager notification log entries. Specify a duration (for example, `5d`). Default is `5d`.
#### `resolved_alert_retention`
Duration for which a resolved alert state transition continues to be sent to the Alertmanager. Specify a duration (for example, `15m`). Default is `15m`.
<hr>
#### `rule_version_record_limit`
@@ -2040,12 +1978,6 @@ Defines the limits for how many alert rule versions are stored in the database p
The default `0` value means there's no limit.
#### `deleted_rule_retention`
Retention period for deleted alerting rules before permanent removal. Specify a duration (for example, `30d`). Default is `30d`. Setting `0` deletes rules immediately.
<hr>
### `[unified_alerting.screenshots]`
For more information about screenshots, refer to [Images in notifications](../../alerting/configure-notifications/template-notifications/images-in-notifications/).
@@ -2085,86 +2017,6 @@ For example: `disabled_labels=grafana_folder`
<hr>
### `[unified_alerting.state_history]`
Configure state history for Unified Alerting. Previous alert rule states can be queried in panels and viewed in the UI.
#### `enabled`
Enable or disable the state history functionality. Default: `true`.
#### `backend`
Select the backend for state history. Options: `annotations`, `loki`, `prometheus`, `multiple`. Default: `annotations`.
The backends provide different storage and query characteristics:
- **annotations:** Stores alert state transitions as Grafana annotations in the local database.
- **loki:** Writes alert state history to an external Loki instance. Requires Loki connection configuration in this section.
- **prometheus:** Emits alert state as `GRAFANA_ALERTS` metrics to a Prometheus-compatible data source. Requires Prometheus target configuration in this section.
- **multiple:** Writes state history to more than one backend at the same time. Use `primary` to select which backend serves queries, and `secondaries` for additional write targets.
Backend-specific configuration requirements:
- When `backend = annotations`, no additional keys in this section are required.
- When a Loki backend is used in any capacity (for example, `backend = loki`, or `backend = multiple` with Loki as `primary` or present in `secondaries`) you must set either `loki_remote_url` or both `loki_remote_read_url` and `loki_remote_write_url`.
- When a Prometheus backend is used in any capacity (for example, `backend = prometheus`, or `backend = multiple` with Prometheus present in `secondaries`) you must set `prometheus_target_datasource_uid`.
- When `backend = multiple`, set `primary` and `secondaries`.
#### `primary`
For `multiple` backend only. Sets the primary backend used to serve queries. Options: `annotations`, `loki`.
#### `secondaries`
For `multiple` backend only. Comma-separated list of additional backends to write state history to.
#### `loki_remote_url`
For `loki` backend. URL of the external Loki instance.
#### `loki_remote_read_url`
For `loki` backend. Read URL when Loki read/write endpoints are separated.
#### `loki_remote_write_url`
For `loki` backend. Write URL when Loki read/write endpoints are separated.
#### `loki_tenant_id`
For `loki` backend. Optional tenant ID to attach to requests.
#### `loki_basic_auth_username`
For `loki` backend. Optional username for basic authentication.
#### `loki_basic_auth_password`
For `loki` backend. Optional password for basic authentication.
#### `loki_max_query_length`
For `loki` backend. Maximum query length duration. Default: `721h`.
#### `loki_max_query_size`
For `loki` backend. Maximum query size in bytes. Default: `65536`.
#### `prometheus_target_datasource_uid`
For `prometheus` backend. Target datasource UID for writing `GRAFANA_ALERTS` metrics.
#### `prometheus_metric_name`
For `prometheus` backend. Metric name for `GRAFANA_ALERTS`. Default: `GRAFANA_ALERTS`.
#### `prometheus_write_timeout`
For `prometheus` backend. Timeout for writing `GRAFANA_ALERTS` metrics. Default: `10s`.
<hr>
### `[unified_alerting.state_history.annotations]`
This section controls retention of annotations automatically created while evaluating alert rules when alerting state history backend is configured to be annotations (see setting [unified_alerting.state_history].backend)
@@ -2179,36 +2031,6 @@ Configures max number of alert annotations that Grafana stores. Default value is
<hr>
### `[unified_alerting.notification_history]`
Enable storage of Alertmanager notification logs in Loki.
#### `enabled`
Enable or disable the notification history functionality. Default: `false`.
#### `loki_remote_url`
URL of the Loki instance used to store logs.
#### `loki_tenant_id`
Optional tenant ID to attach to requests sent to Loki.
#### `loki_basic_auth_username`
Optional username for basic authentication to Loki.
#### `loki_basic_auth_password`
Optional password for basic authentication to Loki.
### `[unified_alerting.notification_history.external_labels]`
Optional extra labels to attach to outbound notification history records or log streams. Provide any number of label key-value pairs.
<hr>
### `[unified_alerting.prometheus_conversion]`
This section applies only to rules imported as Grafana-managed rules. For more information about the import process, refer to [Import data source-managed rules to Grafana-managed rules](/docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/alerting-migration/).
@@ -2219,52 +2041,6 @@ Set the query offset to imported Grafana-managed rules when `query_offset` is no
<hr>
### `[recording_rules]`
Configure recording rules.
#### `enabled`
Enable recording rules. Default: `true`.
#### `timeout`
Request timeout for recording rule writes. Default: `10s`.
#### `default_datasource_uid`
Default data source UID to write to if not specified in the rule definition.
### `[recording_rules.custom_headers]`
Optional custom headers to include in recording rule write requests.
### `[remote.alertmanager]`
Configure a remote Alertmanager to replace the internal one.
#### `url`
Root URL of the remote Alertmanager. Grafana automatically appends `/alertmanager` for certain HTTP calls.
#### `tenant`
Tenant ID used in requests. Also used as basic auth username if a password is configured.
#### `password`
Optional password for basic authentication. If not present, the tenant ID will be set in the X-Scope-OrgID header.
#### `sync_interval`
Interval for syncing with the Alertmanager. Default: `5m`.
#### `timeout`
Timeout for the HTTP client. Default: `30s`.
<hr>
### `[annotations]`
#### `cleanupjob_batchsize`
@@ -3175,8 +2951,6 @@ Move an app plugin (referenced by its id), including all its pages, to a specifi
Move an individual app plugin page (referenced by its `path` field) to a specific navigation section.
Format: `<pageUrl> = <sectionId> <sortWeight>`
<hr>
### `[public_dashboards]`
This section configures the [shared dashboards](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/dashboards/share-dashboards-panels/shared-dashboards/) feature.
@@ -3184,81 +2958,3 @@ This section configures the [shared dashboards](https://grafana.com/docs/grafana
#### `enabled`
Set this to `false` to disable the shared dashboards feature. This prevents users from creating new shared dashboards and disables existing ones.
<hr>
### `[cloud_migration]`
Configure the Grafana Cloud Migration Assistant.
#### `enabled`
Enable or disable the Cloud Migration Assistant. Default is `true`.
#### `is_target`
Enable the target-side migration UI. Default is `false`.
#### `gcom_api_token`
Token used to send requests to Grafana com. Default is empty.
#### `start_snapshot_timeout`
Timeout for requests to start a snapshot. Default is `5s`.
#### `validate_key_timeout`
Timeout for requests to validate a key. Default is `5s`.
#### `get_snapshot_status_timeout`
Timeout for requests to get snapshot status. Default is `5s`.
#### `create_upload_url_timeout`
Timeout for requests to create a presigned upload URL. Default is `5s`.
#### `report_event_timeout`
Timeout for requests to report an event. Default is `5s`.
#### `fetch_instance_timeout`
Timeout for requests to fetch an instance. Default is `5s`.
#### `create_access_policy_timeout`
Timeout for requests to create an access policy. Default is `5s`.
#### `fetch_access_policy_timeout`
Timeout for requests to fetch an access policy. Default is `5s`.
#### `delete_access_policy_timeout`
Timeout for requests to delete an access policy. Default is `5s`.
#### `domain`
Domain name used to access the cloud migration service. Default is `grafana.net`.
#### `snapshot_folder`
Folder used to store snapshot files. Default is empty (home dir).
#### `frontend_poll_interval`
Polling interval for the frontend UI while resources are migrating. Default is `2s`.
#### `alert_rules_state`
Controls how alert rules are migrated. Options are `paused` or `unchanged`. Default is `"paused"`.
{{< adomition type="note" >}}
For more information, refer to the [Prevent duplicated alert notifications](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/migration-guide/cloud-migration-assistant/#prevent-duplicated-alert-notifications) documentation.
{{< /admonition >}}
#### `resource_storage_type`
Resource snapshot storage type. Options are `db` (database) or `fs` (file system). Default is `"db"`.
+1 -3
View File
@@ -715,11 +715,9 @@ export {
export {
type VisualizationSuggestion,
type VisualizationSuggestionsSupplier,
type VisualizationSuggestionsSupplierFn,
type PanelPluginVisualizationSuggestion,
type VisualizationSuggestionsBuilder,
VisualizationSuggestionScore,
VisualizationSuggestionsBuilder,
VisualizationSuggestionsListAppender,
} from './types/suggestions';
export {
type MatcherConfig,
@@ -1,14 +1,18 @@
import { createDataFrame } from '../dataframe/processDataFrame';
import { identityOverrideProcessor } from '../field/overrides/processors';
import {
StandardEditorsRegistryItem,
standardEditorsRegistry,
standardFieldConfigEditorRegistry,
} from '../field/standardFieldConfigEditorRegistry';
import { FieldType } from '../types/dataFrame';
import { FieldConfigProperty, FieldConfigPropertyItem } from '../types/fieldOverrides';
import { PanelMigrationModel } from '../types/panel';
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '../types/suggestions';
import { PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
import { PanelPlugin } from './PanelPlugin';
import { getPanelDataSummary } from './suggestions/getPanelDataSummary';
describe('PanelPlugin', () => {
describe('declarative options', () => {
@@ -483,4 +487,107 @@ describe('PanelPlugin', () => {
});
});
});
describe('suggestions', () => {
it('should register a suggestions supplier', () => {
const panel = new PanelPlugin(() => <div>Panel</div>);
panel.meta = panel.meta || {};
panel.meta.id = 'test-panel';
panel.meta.name = 'Test Panel';
panel.setSuggestionsSupplier((ds) => {
if (!ds.hasFieldType(FieldType.number)) {
return;
}
return [
{
name: 'Number Panel',
score: VisualizationSuggestionScore.Good,
},
];
});
const suggestions = panel.getSuggestions(
getPanelDataSummary([createDataFrame({ fields: [{ type: FieldType.number, name: 'Value' }] })])
);
expect(suggestions).toHaveLength(1);
expect(suggestions![0].pluginId).toBe(panel.meta.id);
expect(suggestions![0].name).toBe('Number Panel');
expect(
panel.getSuggestions(
getPanelDataSummary([createDataFrame({ fields: [{ type: FieldType.string, name: 'Value' }] })])
)
).toBeUndefined();
});
it('should not throw for the old syntax, but also should not register suggestions', () => {
jest.spyOn(console, 'warn').mockImplementation();
class DeprecatedSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder): void {
const appender = builder.getListAppender({
name: 'Deprecated Suggestion',
pluginId: 'deprecated-plugin',
options: {},
});
if (builder.dataSummary.hasNumberField) {
appender.append({});
}
}
}
const panel = new PanelPlugin(() => <div>Panel</div>);
expect(() => {
panel.setSuggestionsSupplier(new DeprecatedSuggestionsSupplier());
}).not.toThrow();
expect(console.warn).toHaveBeenCalled();
expect(
panel.getSuggestions(
getPanelDataSummary([
createDataFrame({
fields: [{ type: FieldType.number, name: 'Value', values: [1, 2, 3, 4, 5] }],
}),
])
)
).toBeUndefined();
});
it('should support the deprecated pattern of getSuggestionsSupplier with builder', () => {
jest.spyOn(console, 'warn').mockImplementation();
const panel = new PanelPlugin(() => <div>Panel</div>).setSuggestionsSupplier((ds) => {
if (!ds.hasFieldType(FieldType.number)) {
return;
}
return [
{
name: 'Number Panel',
score: VisualizationSuggestionScore.Good,
},
];
});
const oldSupplier = panel.getSuggestionsSupplier();
const builder1 = new VisualizationSuggestionsBuilder([
createDataFrame({ fields: [{ type: FieldType.number, name: 'Value' }] }),
]);
oldSupplier.getSuggestionsForData(builder1);
const suggestions1 = builder1.getList();
expect(suggestions1).toHaveLength(1);
expect(suggestions1![0].pluginId).toBe(panel.meta.id);
expect(suggestions1![0].name).toBe('Number Panel');
const builder2 = new VisualizationSuggestionsBuilder([
createDataFrame({ fields: [{ type: FieldType.string, name: 'Value' }] }),
]);
oldSupplier.getSuggestionsForData(builder2);
const suggestions2 = builder2.getList();
expect(suggestions2).toHaveLength(0);
});
});
});
+72 -36
View File
@@ -1,4 +1,4 @@
import { set } from 'lodash';
import { defaultsDeep, set } from 'lodash';
import { ComponentClass, ComponentType } from 'react';
import { FieldConfigOptionsRegistry } from '../field/FieldConfigOptionsRegistry';
@@ -14,11 +14,19 @@ import {
PanelPluginDataSupport,
} from '../types/panel';
import { GrafanaPlugin } from '../types/plugin';
import { VisualizationSuggestionsSupplierFn, VisualizationSuggestionsSupplier } from '../types/suggestions';
import {
getSuggestionHash,
PanelPluginVisualizationSuggestion,
VisualizationSuggestion,
VisualizationSuggestionsSupplierDeprecated,
VisualizationSuggestionsSupplier,
VisualizationSuggestionsBuilder,
} from '../types/suggestions';
import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
import { deprecationWarning } from '../utils/deprecationWarning';
import { createFieldConfigRegistry } from './registryFactories';
import { PanelDataSummary } from './suggestions/getPanelDataSummary';
/** @beta */
export type StandardOptionConfig = {
@@ -109,7 +117,7 @@ export class PanelPlugin<
};
private optionsSupplier?: PanelOptionsSupplier<TOptions>;
private suggestionsSupplier?: VisualizationSuggestionsSupplier;
private suggestionsSupplier?: VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>;
panel: ComponentType<PanelProps<TOptions>> | null;
editor?: ComponentClass<PanelEditorProps<TOptions>>;
@@ -363,56 +371,84 @@ export class PanelPlugin<
}
/**
* @deprecated use VisualizationSuggestionsSupplierFn
* @deprecated use VisualizationSuggestionsSupplier
*/
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier): this;
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierDeprecated): this;
/**
* @alpha
* sets function that can return visualization examples and suggestions.
*/
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>): this;
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>): this;
setSuggestionsSupplier(
supplier: VisualizationSuggestionsSupplier | VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>
supplier:
| VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>
| VisualizationSuggestionsSupplierDeprecated
): this {
this.suggestionsSupplier =
typeof supplier === 'function'
? {
getSuggestionsForData: (builder) => {
const appender = builder.getListAppender<TOptions, TFieldConfigOptions>({
pluginId: this.meta.id,
name: this.meta.name,
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
});
const result = supplier(builder.dataSummary);
if (Array.isArray(result)) {
appender.appendAll(result);
}
},
}
: supplier;
if (typeof supplier !== 'function') {
deprecationWarning(
'PanelPlugin',
'plugin.setSuggestionsSupplier(new Supplier())',
'plugin.setSuggestionsSupplier(dataSummary => [...])'
);
return this;
}
this.suggestionsSupplier = supplier;
return this;
}
/**
* Returns the suggestions supplier
* @alpha
* get suggestions based on the PanelDataSummary
*/
getSuggestionsSupplier(): VisualizationSuggestionsSupplier | undefined {
return this.suggestionsSupplier;
getSuggestions(
panelDataSummary: PanelDataSummary
): Array<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>> | void {
const withDefaults = (
suggestion: VisualizationSuggestion<TOptions, TFieldConfigOptions>
): Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'> =>
defaultsDeep(suggestion, {
pluginId: this.meta.id,
name: this.meta.name,
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
} satisfies Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'>);
return this.suggestionsSupplier?.(panelDataSummary)?.map(
(s): PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions> => {
const suggestionWithDefaults = withDefaults(s);
return Object.assign(suggestionWithDefaults, { hash: getSuggestionHash(suggestionWithDefaults) });
}
);
}
/**
* @alpha
* returns whether the plugin has configured suggestions
* @deprecated use getSuggestions
* we have to keep this method intact to support cloud-onboarding plugin.
*/
hasSuggestions(): boolean {
return this.suggestionsSupplier !== undefined;
getSuggestionsSupplier() {
const withDefaults = (
suggestion: VisualizationSuggestion<TOptions, TFieldConfigOptions>
): Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'> =>
defaultsDeep(suggestion, {
pluginId: this.meta.id,
name: this.meta.name,
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
} satisfies Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'>);
return {
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => {
deprecationWarning('PanelPlugin', 'getSuggestionsSupplier()', 'getSuggestions(panelDataSummary)');
this.suggestionsSupplier?.(builder.dataSummary)?.forEach((s) => {
builder.getListAppender(withDefaults(s)).append(s);
});
},
};
}
hasPluginId(pluginId: string) {
+5
View File
@@ -1143,6 +1143,11 @@ export interface FeatureToggles {
*/
newVizSuggestions?: boolean;
/**
* Enable all plugins to supply visualization suggestions (including 3rd party plugins)
* @default false
*/
externalVizSuggestions?: boolean;
/**
* Restrict PanelChrome contents with overflow: hidden;
* @default true
*/
+2
View File
@@ -20,6 +20,8 @@ export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, forma
export interface PanelPluginMeta extends PluginMeta {
/** Indicates that panel does not issue queries */
skipDataQuery?: boolean;
/** Indicates that the panel implements suggestions */
suggestions?: boolean;
/** Indicates that panel should not be available in visualisation picker */
hideFromList?: boolean;
/** Sort order */
+33 -55
View File
@@ -2,11 +2,10 @@ import { defaultsDeep } from 'lodash';
import { DataTransformerConfig } from '@grafana/schema';
import { PanelDataSummary, getPanelDataSummary } from '../panel/suggestions/getPanelDataSummary';
import { getPanelDataSummary, PanelDataSummary } from '../panel/suggestions/getPanelDataSummary';
import { PanelModel } from './dashboard';
import { DataFrame } from './dataFrame';
import { FieldConfigSource } from './fieldOverrides';
import { PanelData } from './panel';
/**
* @internal
@@ -108,35 +107,6 @@ export enum VisualizationSuggestionScore {
OK = 50,
}
/**
* @internal
* TODO this will move into the grafana app code once suppliers are migrated.
*/
export class VisualizationSuggestionsBuilder {
/** Summary stats for current data */
dataSummary: PanelDataSummary;
private list: PanelPluginVisualizationSuggestion[] = [];
constructor(
/** Current data */
public data?: PanelData,
/** Current panel & options */
public panel?: PanelModel
) {
this.dataSummary = getPanelDataSummary(data?.series);
}
getListAppender<TOptions extends unknown, TFieldConfig extends {} = {}>(
defaults: Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>, 'hash'>
) {
return new VisualizationSuggestionsListAppender<TOptions, TFieldConfig>(this.list, defaults);
}
getList() {
return this.list;
}
}
/**
* @alpha
* TODO: this name is temporary; it will become just "VisualizationSuggestionsSupplier" when the other interface is deleted.
@@ -147,40 +117,48 @@ export class VisualizationSuggestionsBuilder {
* - returns an array of VisualizationSuggestions
* - boolean return equates to "show a single suggestion card for this panel plugin with the default options" (true = show, false or void = hide)
*/
export type VisualizationSuggestionsSupplierFn<TOptions extends unknown, TFieldConfig extends {} = {}> = (
export type VisualizationSuggestionsSupplier<TOptions extends unknown, TFieldConfig extends {} = {}> = (
panelDataSummary: PanelDataSummary
) => Array<VisualizationSuggestion<TOptions, TFieldConfig>> | void;
/**
* @deprecated use VisualizationSuggestionsSupplierFn instead.
* DEPRECATED - the below exports need to remain in the code base to help make the transition for the Polystat plugin, which implements
* suggestions using the old API. These should be removed for Grafana 13.
*/
export type VisualizationSuggestionsSupplier = {
/**
* Adds suitable suggestions for the current data
*/
/**
* @deprecated use VisualizationSuggestionsSupplier
*/
export interface VisualizationSuggestionsSupplierDeprecated {
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void;
};
}
/**
* @internal
* TODO this will move into the grafana app code once suppliers are migrated.
* @deprecated use VisualizationSuggestionsSupplier
*/
export class VisualizationSuggestionsListAppender<TOptions extends unknown, TFieldConfig extends {} = {}> {
constructor(
private list: VisualizationSuggestion[],
private defaults: Partial<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>> = {}
) {}
export class VisualizationSuggestionsBuilder {
public dataSummary: PanelDataSummary;
public list: PanelPluginVisualizationSuggestion[] = [];
append(suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) {
this.appendAll([suggestion]);
constructor(dataFrames: DataFrame[]) {
this.dataSummary = getPanelDataSummary(dataFrames);
}
appendAll(suggestions: Array<VisualizationSuggestion<TOptions, TFieldConfig>>) {
this.list.push(
...suggestions.map((s): PanelPluginVisualizationSuggestion<TOptions, TFieldConfig> => {
const suggestionWithDefaults = defaultsDeep(s, this.defaults);
return Object.assign(suggestionWithDefaults, { hash: getSuggestionHash(suggestionWithDefaults) });
})
);
getList(): PanelPluginVisualizationSuggestion[] {
return this.list;
}
getListAppender(suggestionDefaults: Omit<PanelPluginVisualizationSuggestion, 'hash'>) {
const withDefaults = (suggestion: VisualizationSuggestion): PanelPluginVisualizationSuggestion => {
const s = defaultsDeep({}, suggestion, suggestionDefaults);
return {
...s,
hash: getSuggestionHash(s),
};
};
return {
append: (suggestion: VisualizationSuggestion) => {
this.list.push(withDefaults(suggestion));
},
};
}
}
@@ -518,6 +518,7 @@ const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
height: '100%',
position: 'relative',
}),
panel: css({
+1
View File
@@ -164,6 +164,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery,
Suggestions: panel.Suggestions,
HideFromList: panel.HideFromList,
ReleaseState: string(panel.State),
Signature: string(panel.Signature),
+11
View File
@@ -222,6 +222,10 @@ var (
// MStatTotalRepositories is a metric total amount of repositories
MStatTotalRepositories prometheus.Gauge
// MUnifiedStorageMigrationStatus indicates the migration status for unified storage in this instance.
// Possible values: 0 (default/undefined), 1 (migration disabled), 2 (migration would run).
MUnifiedStorageMigrationStatus prometheus.Gauge
)
const (
@@ -691,6 +695,12 @@ func init() {
Help: "total amount of repositories",
Namespace: ExporterName,
})
MUnifiedStorageMigrationStatus = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "unified_storage_migration_status",
Help: "indicates whether this instance would run unified storage migrations (0=undefined, 1=migration disabled, 2=would run)",
Namespace: ExporterName,
})
}
// SetBuildInformation sets the build information for this binary
@@ -829,5 +839,6 @@ func initMetricVars(reg prometheus.Registerer) {
MStatTotalRepositories,
MFolderIDsAPICount,
MFolderIDsServiceCount,
MUnifiedStorageMigrationStatus,
)
}
+1
View File
@@ -319,6 +319,7 @@ type PanelDTO struct {
HideFromList bool `json:"hideFromList"`
Sort int `json:"sort"`
SkipDataQuery bool `json:"skipDataQuery"`
Suggestions bool `json:"suggestions,omitempty"`
ReleaseState string `json:"state"`
BaseURL string `json:"baseUrl"`
Signature string `json:"signature"`
+1
View File
@@ -105,6 +105,7 @@ type JSONData struct {
// Panel settings
SkipDataQuery bool `json:"skipDataQuery"`
Suggestions bool `json:"suggestions,omitempty"`
// App settings
AutoEnabled bool `json:"autoEnabled"`
+8
View File
@@ -1884,6 +1884,14 @@ var (
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "externalVizSuggestions",
Description: "Enable all plugins to supply visualization suggestions (including 3rd party plugins)",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "preventPanelChromeOverflow",
Description: "Restrict PanelChrome contents with overflow: hidden;",
+1
View File
@@ -256,6 +256,7 @@ cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false
pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false
newGauge,experimental,@grafana/dataviz-squad,false,false,true
newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
256 pluginInstallAPISync experimental @grafana/plugins-platform-backend false false false
257 newGauge experimental @grafana/dataviz-squad false false true
258 newVizSuggestions preview @grafana/dataviz-squad false false true
259 externalVizSuggestions experimental @grafana/dataviz-squad false false true
260 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
261 jaegerEnableGrpcEndpoint experimental @grafana/oss-big-tent false false false
262 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false
+14
View File
@@ -1383,6 +1383,20 @@
"codeowner": "@grafana/identity-access-team"
}
},
{
"metadata": {
"name": "externalVizSuggestions",
"resourceVersion": "1763498528748",
"creationTimestamp": "2025-11-18T20:42:08Z"
},
"spec": {
"description": "Enable all plugins to supply visualization suggestions (including 3rd party plugins)",
"stage": "experimental",
"codeowner": "@grafana/dataviz-squad",
"frontend": true,
"expression": "false"
}
},
{
"metadata": {
"name": "extraThemes",
+9 -5
View File
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/util/osutil"
)
// nolint:unused
var migratedUnifiedResources = []string{
//"playlists.playlist.grafana.app",
"folders.folder.grafana.app",
@@ -58,14 +59,16 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
// Set indexer config for unified storage
section := cfg.Raw.Section("unified_storage")
// TODO: Re-enable once migrations are ready and disabled on cloud
//cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
cfg.DisableDataMigrations = true
cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
if !cfg.DisableDataMigrations && cfg.getUnifiedStorageType() == "unified" {
cfg.enforceMigrationToUnifiedConfigs()
// Helper log to find instances running migrations in the future
cfg.Logger.Info("Unified migration configs not yet enforced")
//cfg.enforceMigrationToUnifiedConfigs() // TODO: uncomment when ready for release
} else {
cfg.EnableSearch = section.Key("enable_search").MustBool(false)
// Helper log to find instances disabling migration
cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.getUnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations)
}
cfg.EnableSearch = section.Key("enable_search").MustBool(false)
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)
@@ -102,6 +105,7 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("")
}
// nolint:unused
// enforceMigrationToUnifiedConfigs enforces configurations required to run migrated resources in mode 5
// All migrated resources in MigratedUnifiedResources are set to mode 5 and unified search is enabled
func (cfg *Cfg) enforceMigrationToUnifiedConfigs() {
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/migrations/contract"
@@ -55,8 +56,12 @@ func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error {
// skip migrations if disabled in config
if p.cfg.DisableDataMigrations {
metrics.MUnifiedStorageMigrationStatus.Set(1)
logger.Info("Data migrations are disabled, skipping")
return nil
} else {
metrics.MUnifiedStorageMigrationStatus.Set(2)
logger.Info("Data migrations not yet enforced, skipping")
}
// TODO: Re-enable once migrations are ready
+104
View File
@@ -35,6 +35,33 @@ type ListOptions struct {
Limit int64 // maximum number of results to return. 0 means no limit.
}
// BatchOpMode controls the semantics of each operation in a batch
type BatchOpMode int
const (
// BatchOpPut performs an upsert: create or update (never fails on key state)
BatchOpPut BatchOpMode = iota
// BatchOpCreate creates a new key, fails if the key already exists
BatchOpCreate
// BatchOpUpdate updates an existing key, fails if the key doesn't exist
BatchOpUpdate
// BatchOpDelete removes a key, idempotent (never fails on key state)
BatchOpDelete
)
// BatchOp represents a single operation in an atomic batch
type BatchOp struct {
Mode BatchOpMode
Key string
Value []byte // For Put/Create/Update operations, nil for Delete
}
// Maximum limit for batch operations
const MaxBatchOps = 20
// ErrKeyAlreadyExists is returned when BatchOpCreate is used on an existing key
var ErrKeyAlreadyExists = errors.New("key already exists")
type KV interface {
// Keys returns all the keys in the store
Keys(ctx context.Context, section string, opt ListOptions) iter.Seq2[string, error]
@@ -60,6 +87,17 @@ type KV interface {
// UnixTimestamp returns the current time in seconds since Epoch.
// This is used to ensure the server and client are not too far apart in time.
UnixTimestamp(ctx context.Context) (int64, error)
// Batch executes all operations atomically within a single transaction.
// If any operation fails, all operations are rolled back.
// Operations are executed in order; the batch stops on first failure.
//
// Operation semantics:
// - BatchOpPut: Upsert (create or update), never fails on key state
// - BatchOpCreate: Fail with ErrKeyAlreadyExists if key exists
// - BatchOpUpdate: Fail with ErrNotFound if key doesn't exist
// - BatchOpDelete: Idempotent, never fails on key state
Batch(ctx context.Context, section string, ops []BatchOp) error
}
var _ KV = &badgerKV{}
@@ -360,3 +398,69 @@ func (k *badgerKV) BatchDelete(ctx context.Context, section string, keys []strin
return txn.Commit()
}
func (k *badgerKV) Batch(ctx context.Context, section string, ops []BatchOp) error {
if k.db.IsClosed() {
return fmt.Errorf("database is closed")
}
if section == "" {
return fmt.Errorf("section is required")
}
if len(ops) > MaxBatchOps {
return fmt.Errorf("too many operations: %d > %d", len(ops), MaxBatchOps)
}
txn := k.db.NewTransaction(true)
defer txn.Discard()
for _, op := range ops {
keyWithSection := section + "/" + op.Key
switch op.Mode {
case BatchOpCreate:
// Check that key doesn't exist, then set
_, err := txn.Get([]byte(keyWithSection))
if err == nil {
return ErrKeyAlreadyExists
}
if !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err := txn.Set([]byte(keyWithSection), op.Value); err != nil {
return err
}
case BatchOpUpdate:
// Check that key exists, then set
_, err := txn.Get([]byte(keyWithSection))
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
if err := txn.Set([]byte(keyWithSection), op.Value); err != nil {
return err
}
case BatchOpPut:
// Upsert: create or update
if err := txn.Set([]byte(keyWithSection), op.Value); err != nil {
return err
}
case BatchOpDelete:
// Idempotent delete - don't error if not found
if err := txn.Delete([]byte(keyWithSection)); err != nil {
return err
}
default:
return fmt.Errorf("unknown operation mode: %d", op.Mode)
}
}
return txn.Commit()
}
+258
View File
@@ -28,6 +28,7 @@ const (
TestKVUnixTimestamp = "unix timestamp"
TestKVBatchGet = "batch get operations"
TestKVBatchDelete = "batch delete operations"
TestKVBatch = "batch operations"
)
// NewKVFunc is a function that creates a new KV instance for testing
@@ -69,6 +70,7 @@ func RunKVTest(t *testing.T, newKV NewKVFunc, opts *KVTestOptions) {
{TestKVUnixTimestamp, runTestKVUnixTimestamp},
{TestKVBatchGet, runTestKVBatchGet},
{TestKVBatchDelete, runTestKVBatchDelete},
{TestKVBatch, runTestKVBatch},
}
for _, tc := range cases {
@@ -801,3 +803,259 @@ func saveKVHelper(t *testing.T, kv resource.KV, ctx context.Context, section, ke
err = writer.Close()
require.NoError(t, err)
}
func runTestKVBatch(t *testing.T, kv resource.KV, nsPrefix string) {
ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second))
section := nsPrefix + "-batch"
t.Run("batch with empty section", func(t *testing.T) {
err := kv.Batch(ctx, "", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "section is required")
})
t.Run("batch with empty ops succeeds", func(t *testing.T) {
err := kv.Batch(ctx, section, nil)
require.NoError(t, err)
})
t.Run("batch put creates new key", func(t *testing.T) {
ops := []resource.BatchOp{
{Mode: resource.BatchOpPut, Key: "put-key", Value: []byte("put-value")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify the key was created
reader, err := kv.Get(ctx, section, "put-key")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "put-value", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch put updates existing key", func(t *testing.T) {
// First create a key
saveKVHelper(t, kv, ctx, section, "put-update-key", strings.NewReader("original-value"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpPut, Key: "put-update-key", Value: []byte("updated-value")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify the key was updated
reader, err := kv.Get(ctx, section, "put-update-key")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "updated-value", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch create succeeds for new key", func(t *testing.T) {
ops := []resource.BatchOp{
{Mode: resource.BatchOpCreate, Key: "create-new-key", Value: []byte("new-value")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify the key was created
reader, err := kv.Get(ctx, section, "create-new-key")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "new-value", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch create fails for existing key", func(t *testing.T) {
// First create a key
saveKVHelper(t, kv, ctx, section, "create-exists-key", strings.NewReader("existing-value"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpCreate, Key: "create-exists-key", Value: []byte("new-value")},
}
err := kv.Batch(ctx, section, ops)
assert.ErrorIs(t, err, resource.ErrKeyAlreadyExists)
// Verify the original value is unchanged
reader, err := kv.Get(ctx, section, "create-exists-key")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "existing-value", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch update succeeds for existing key", func(t *testing.T) {
// First create a key
saveKVHelper(t, kv, ctx, section, "update-exists-key", strings.NewReader("original-value"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpUpdate, Key: "update-exists-key", Value: []byte("updated-value")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify the key was updated
reader, err := kv.Get(ctx, section, "update-exists-key")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "updated-value", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch update fails for non-existent key", func(t *testing.T) {
ops := []resource.BatchOp{
{Mode: resource.BatchOpUpdate, Key: "update-nonexistent-key", Value: []byte("new-value")},
}
err := kv.Batch(ctx, section, ops)
assert.ErrorIs(t, err, resource.ErrNotFound)
// Verify the key was not created
_, err = kv.Get(ctx, section, "update-nonexistent-key")
assert.ErrorIs(t, err, resource.ErrNotFound)
})
t.Run("batch delete removes existing key", func(t *testing.T) {
// First create a key
saveKVHelper(t, kv, ctx, section, "delete-exists-key", strings.NewReader("to-be-deleted"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpDelete, Key: "delete-exists-key"},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify the key was deleted
_, err = kv.Get(ctx, section, "delete-exists-key")
assert.ErrorIs(t, err, resource.ErrNotFound)
})
t.Run("batch delete is idempotent for non-existent key", func(t *testing.T) {
ops := []resource.BatchOp{
{Mode: resource.BatchOpDelete, Key: "delete-nonexistent-key"},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err) // Should succeed even though key doesn't exist
})
t.Run("batch multiple operations atomic success", func(t *testing.T) {
ops := []resource.BatchOp{
{Mode: resource.BatchOpPut, Key: "multi-key1", Value: []byte("value1")},
{Mode: resource.BatchOpPut, Key: "multi-key2", Value: []byte("value2")},
{Mode: resource.BatchOpPut, Key: "multi-key3", Value: []byte("value3")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify all keys were created
for i := 1; i <= 3; i++ {
key := fmt.Sprintf("multi-key%d", i)
reader, err := kv.Get(ctx, section, key)
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, fmt.Sprintf("value%d", i), string(value))
err = reader.Close()
require.NoError(t, err)
}
})
t.Run("batch multiple operations atomic rollback on failure", func(t *testing.T) {
// First create a key that will cause the batch to fail
saveKVHelper(t, kv, ctx, section, "rollback-exists", strings.NewReader("existing"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpPut, Key: "rollback-new1", Value: []byte("value1")},
{Mode: resource.BatchOpCreate, Key: "rollback-exists", Value: []byte("should-fail")}, // This will fail
{Mode: resource.BatchOpPut, Key: "rollback-new2", Value: []byte("value2")},
}
err := kv.Batch(ctx, section, ops)
assert.ErrorIs(t, err, resource.ErrKeyAlreadyExists)
// Verify rollback: the first operation should NOT have persisted
_, err = kv.Get(ctx, section, "rollback-new1")
assert.ErrorIs(t, err, resource.ErrNotFound)
// Verify the third operation was not executed
_, err = kv.Get(ctx, section, "rollback-new2")
assert.ErrorIs(t, err, resource.ErrNotFound)
})
t.Run("batch mixed operations", func(t *testing.T) {
// Setup: create a key to update and one to delete
saveKVHelper(t, kv, ctx, section, "mixed-update", strings.NewReader("original"))
saveKVHelper(t, kv, ctx, section, "mixed-delete", strings.NewReader("to-delete"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpCreate, Key: "mixed-create", Value: []byte("created")},
{Mode: resource.BatchOpUpdate, Key: "mixed-update", Value: []byte("updated")},
{Mode: resource.BatchOpDelete, Key: "mixed-delete"},
{Mode: resource.BatchOpPut, Key: "mixed-put", Value: []byte("put")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify create
reader, err := kv.Get(ctx, section, "mixed-create")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "created", string(value))
err = reader.Close()
require.NoError(t, err)
// Verify update
reader, err = kv.Get(ctx, section, "mixed-update")
require.NoError(t, err)
value, err = io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "updated", string(value))
err = reader.Close()
require.NoError(t, err)
// Verify delete
_, err = kv.Get(ctx, section, "mixed-delete")
assert.ErrorIs(t, err, resource.ErrNotFound)
// Verify put
reader, err = kv.Get(ctx, section, "mixed-put")
require.NoError(t, err)
value, err = io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "put", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch too many operations", func(t *testing.T) {
ops := make([]resource.BatchOp, resource.MaxBatchOps+1)
for i := range ops {
ops[i] = resource.BatchOp{Mode: resource.BatchOpPut, Key: fmt.Sprintf("key-%d", i), Value: []byte("value")}
}
err := kv.Batch(ctx, section, ops)
assert.Error(t, err)
assert.Contains(t, err.Error(), "too many operations")
})
}
@@ -31,7 +31,6 @@ import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { PanelModelCompatibilityWrapper } from '../utils/PanelModelCompatibilityWrapper';
import {
activateSceneObjectAndParentTree,
getDashboardSceneFor,
@@ -121,8 +120,7 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
dataObject.subscribeToState(async () => {
const { data } = dataObject.state;
if (hasData(data) && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) {
const panelModel = new PanelModelCompatibilityWrapper(panel);
const suggestions = await getAllSuggestions(data, panelModel);
const suggestions = await getAllSuggestions(data);
if (suggestions.length > 0) {
const defaultFirstSuggestion = suggestions[0];
@@ -25,7 +25,7 @@ const MIN_COLUMN_SIZE = 260;
export function VisualizationSuggestions({ onChange, data, panel }: Props) {
const styles = useStyles2(getStyles);
const { value: suggestions } = useAsync(() => getAllSuggestions(data, panel), [data, panel]);
const { value: suggestions } = useAsync(async () => await getAllSuggestions(data), [data]);
const [suggestionHash, setSuggestionHash] = useState<string | null>(null);
const [firstCardRef, { width }] = useMeasure<HTMLDivElement>();
const [firstCardHash, setFirstCardHash] = useState<string | null>(null);
@@ -0,0 +1,19 @@
export const panelsToCheckFirst = [
'timeseries',
'barchart',
'gauge',
'stat',
'piechart',
'bargauge',
'table',
'state-timeline',
'status-history',
'logs',
'candlestick',
'flamegraph',
'traces',
'nodeGraph',
'heatmap',
'histogram',
'geomap',
];
@@ -2,11 +2,13 @@ import {
DataFrame,
FieldType,
getDefaultTimeRange,
getPanelDataSummary,
LoadingState,
PanelData,
PanelPluginMeta,
PanelPluginVisualizationSuggestion,
PluginType,
toDataFrame,
VisualizationSuggestionScore,
} from '@grafana/data';
import {
BarGaugeDisplayMode,
@@ -18,26 +20,69 @@ import {
} from '@grafana/schema';
import { config } from 'app/core/config';
import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions';
import { panelsToCheckFirst } from './consts';
import { getAllSuggestions, sortSuggestions } from './getAllSuggestions';
config.featureToggles.externalVizSuggestions = true;
let idx = 0;
for (const pluginId of panelsToCheckFirst) {
if (pluginId === 'geomap') {
continue;
}
config.panels[pluginId] = {
module: `core:plugin/${pluginId}`,
id: pluginId,
} as PanelPluginMeta;
module: `core:plugin/${pluginId}`,
sort: idx++,
name: pluginId,
type: PluginType.panel,
baseUrl: 'public/app/plugins/panel',
suggestions: true,
info: {
version: '1.0.0',
updated: '2025-01-01',
links: [],
screenshots: [],
author: {
name: 'Grafana Labs',
},
description: pluginId,
logos: { small: 'small/logo', large: 'large/logo' },
},
};
}
const SCALAR_PLUGINS = ['gauge', 'stat', 'bargauge', 'piechart', 'radialbar'];
config.panels['text'] = {
config.panels.text = {
id: 'text',
module: 'core:plugin/text',
sort: idx++,
name: 'Text',
type: PluginType.panel,
baseUrl: 'public/app/plugins/panel',
skipDataQuery: true,
suggestions: false,
info: {
description: 'pretty decent plugin',
version: '1.0.0',
updated: '2025-01-01',
links: [],
screenshots: [],
author: {
name: 'Grafana Labs',
},
description: 'Text panel',
logos: { small: 'small/logo', large: 'large/logo' },
},
} as PanelPluginMeta;
};
jest.mock('../state/util', () => {
const originalModule = jest.requireActual('../state/util');
return {
...originalModule,
getAllPanelPluginMeta: jest.fn().mockImplementation(() => [...Object.values(config.panels)]),
};
});
const SCALAR_PLUGINS = ['gauge', 'stat', 'bargauge', 'piechart', 'radialbar'];
class ScenarioContext {
data: DataFrame[] = [];
@@ -289,10 +334,8 @@ scenario('Single frame with string and number field', (ctx) => {
pluginId: 'stat',
options: expect.objectContaining({ colorMode: BigValueColorMode.Background }),
}),
expect.objectContaining({
pluginId: 'bargauge',
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }),
}),
expect.objectContaining({
pluginId: 'bargauge',
@@ -447,6 +490,70 @@ scenario('Given a preferredVisualisationType', (ctx) => {
});
});
describe('sortSuggestions', () => {
it('should sort suggestions correctly by score', () => {
const suggestions = [
{ pluginId: 'timeseries', name: 'Time series', hash: 'b', score: VisualizationSuggestionScore.OK },
{ pluginId: 'table', name: 'Table', hash: 'a', score: VisualizationSuggestionScore.OK },
{ pluginId: 'stat', name: 'Stat', hash: 'c', score: VisualizationSuggestionScore.Good },
] satisfies PanelPluginVisualizationSuggestion[];
const dataSummary = getPanelDataSummary([
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2, 3, 4, 5] },
{ name: 'ServerA', type: FieldType.number, values: [1, 10, 50, 2, 5] },
{ name: 'ServerB', type: FieldType.number, values: [1, 10, 50, 2, 5] },
],
}),
]);
sortSuggestions(suggestions, dataSummary);
expect(suggestions[0].pluginId).toBe('stat');
expect(suggestions[1].pluginId).toBe('timeseries');
expect(suggestions[2].pluginId).toBe('table');
});
it('should sort suggestions based on core module', () => {
const suggestions = [
{
pluginId: 'fake-external-panel',
name: 'Time series',
hash: 'b',
score: VisualizationSuggestionScore.Good,
},
{
pluginId: 'fake-external-panel',
name: 'Time series',
hash: 'd',
score: VisualizationSuggestionScore.Best,
},
{ pluginId: 'timeseries', name: 'Table', hash: 'a', score: VisualizationSuggestionScore.OK },
{ pluginId: 'stat', name: 'Stat', hash: 'c', score: VisualizationSuggestionScore.Good },
] satisfies PanelPluginVisualizationSuggestion[];
const dataSummary = getPanelDataSummary([
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2, 3, 4, 5] },
{ name: 'ServerA', type: FieldType.number, values: [1, 10, 50, 2, 5] },
{ name: 'ServerB', type: FieldType.number, values: [1, 10, 50, 2, 5] },
],
}),
]);
sortSuggestions(suggestions, dataSummary);
expect(suggestions[0].pluginId).toBe('stat');
expect(suggestions[1].pluginId).toBe('timeseries');
expect(suggestions[2].pluginId).toBe('fake-external-panel');
expect(suggestions[2].hash).toBe('d');
expect(suggestions[3].pluginId).toBe('fake-external-panel');
expect(suggestions[3].hash).toBe('b');
});
});
function repeatFrame(count: number, frame: DataFrame): DataFrame[] {
const frames: DataFrame[] = [];
for (let i = 0; i < count; i++) {
@@ -1,33 +1,52 @@
import {
getPanelDataSummary,
PanelData,
PanelDataSummary,
PanelPlugin,
PanelPluginVisualizationSuggestion,
VisualizationSuggestionsBuilder,
PanelModel,
VisualizationSuggestionScore,
PreferredVisualisationType,
VisualizationSuggestionScore,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
import { importPanelPlugin, isBuiltInPlugin } from 'app/features/plugins/importPanelPlugin';
export const panelsToCheckFirst = [
'timeseries',
'barchart',
'gauge',
'stat',
'piechart',
'bargauge',
'table',
'state-timeline',
'status-history',
'logs',
'candlestick',
'flamegraph',
'traces',
'nodeGraph',
'heatmap',
'histogram',
'geomap',
];
import { getAllPanelPluginMeta } from '../state/util';
import { panelsToCheckFirst } from './consts';
/**
* gather and cache the plugins which provide visualization suggestions so they can be invoked to build suggestions
*/
let _pluginCache: PanelPlugin[] | null = null;
async function getPanelsWithSuggestions(): Promise<PanelPlugin[]> {
if (!_pluginCache) {
_pluginCache = [];
// list of plugins to load is determined by the feature flag
const pluginIds: string[] = config.featureToggles.externalVizSuggestions
? getAllPanelPluginMeta()
.filter((panel) => panel.suggestions)
.map((m) => m.id)
: panelsToCheckFirst;
// import the plugins in parallel using Promise.allSettled
const settledPromises = await Promise.allSettled(pluginIds.map((id) => importPanelPlugin(id)));
for (let i = 0; i < settledPromises.length; i++) {
const settled = settledPromises[i];
if (settled.status === 'fulfilled') {
_pluginCache.push(settled.value);
}
// TODO: do we want to somehow log if there were errors loading some of the plugins?
}
}
if (_pluginCache.length === 0) {
throw new Error('No panel plugins with visualization suggestions found');
}
return _pluginCache;
}
/**
* some of the PreferredVisualisationTypes do not match the panel plugin ids, so we have to map them. d'oh.
@@ -44,24 +63,54 @@ const mapPreferredVisualisationTypeToPlugin = (type: string): PreferredVisualisa
return PLUGIN_ID_TO_PREFERRED_VIZ_TYPE[type];
};
export async function getAllSuggestions(
data?: PanelData,
panel?: PanelModel
): Promise<PanelPluginVisualizationSuggestion[]> {
const builder = new VisualizationSuggestionsBuilder(data, panel);
/**
* given a list of suggestions, sort them in place based on score and preferred visualisation type
*/
export function sortSuggestions(suggestions: PanelPluginVisualizationSuggestion[], dataSummary: PanelDataSummary) {
suggestions.sort((a, b) => {
// if one of these suggestions is from a built-in panel and the other isn't, prioritize the core panel.
const isPluginABuiltIn = isBuiltInPlugin(a.pluginId);
const isPluginBBuiltIn = isBuiltInPlugin(b.pluginId);
if (isPluginABuiltIn && !isPluginBBuiltIn) {
return -1;
}
if (isPluginBBuiltIn && !isPluginABuiltIn) {
return 1;
}
for (const pluginId of panelsToCheckFirst) {
const plugin = await importPanelPlugin(pluginId);
const supplier = plugin.getSuggestionsSupplier();
// if a preferred visualisation type matches the data, prioritize it
const mappedA = mapPreferredVisualisationTypeToPlugin(a.pluginId);
if (mappedA && dataSummary.hasPreferredVisualisationType(mappedA)) {
return -1;
}
const mappedB = mapPreferredVisualisationTypeToPlugin(a.pluginId);
if (mappedB && dataSummary.hasPreferredVisualisationType(mappedB)) {
return 1;
}
if (supplier) {
supplier.getSuggestionsForData(builder);
// compare scores directly if there are no other factors
return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK);
});
}
/**
* given PanelData, return a sorted list of Suggestions from all plugins which support it.
* @param {PanelData} data queried and transformed data for the panel
* @returns {PanelPluginVisualizationSuggestion[]} sorted list of suggestions
*/
export async function getAllSuggestions(data?: PanelData): Promise<PanelPluginVisualizationSuggestion[]> {
const dataSummary = getPanelDataSummary(data?.series);
const list: PanelPluginVisualizationSuggestion[] = [];
const plugins = await getPanelsWithSuggestions();
for (const plugin of plugins) {
const suggestions = plugin.getSuggestions(dataSummary);
if (suggestions) {
list.push(...suggestions);
}
}
const list = builder.getList();
if (builder.dataSummary.fieldCount === 0) {
if (dataSummary.fieldCount === 0) {
for (const plugin of Object.values(config.panels)) {
if (!plugin.skipDataQuery || plugin.hideFromList) {
continue;
@@ -79,15 +128,7 @@ export async function getAllSuggestions(
}
}
return list.sort((a, b) => {
const mappedA = mapPreferredVisualisationTypeToPlugin(a.pluginId);
if (mappedA && builder.dataSummary.hasPreferredVisualisationType(mappedA)) {
return -1;
}
const mappedB = mapPreferredVisualisationTypeToPlugin(a.pluginId);
if (mappedB && builder.dataSummary.hasPreferredVisualisationType(mappedB)) {
return 1;
}
return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK);
});
sortSuggestions(list, dataSummary);
return list;
}
@@ -115,4 +115,8 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul
'core:plugin/radialbar': radialBar,
};
export function isBuiltinPluginPath(path: string): path is keyof typeof builtInPlugins {
return Boolean(builtInPlugins[path]);
}
export default builtInPlugins;
@@ -1,6 +1,7 @@
import { PanelPlugin, PanelPluginMeta } from '@grafana/data';
import config from 'app/core/config';
import builtInPlugins, { isBuiltinPluginPath } from './built_in_plugins';
import { pluginImporter } from './importer/pluginImporter';
const promiseCache: Record<string, Promise<PanelPlugin>> = {};
@@ -25,6 +26,14 @@ export function importPanelPlugin(id: string): Promise<PanelPlugin> {
return promiseCache[id];
}
export function isBuiltInPlugin(id?: string): id is keyof typeof builtInPlugins {
if (!id) {
return false;
}
const meta = getPanelPluginMeta(id);
return Boolean(meta != null && isBuiltinPluginPath(meta.module));
}
export function hasPanelPlugin(id: string): boolean {
return !!getPanelPluginMeta(id);
}
@@ -2,7 +2,7 @@ import { DEFAULT_LANGUAGE } from '@grafana/i18n';
import { getResolvedLanguage } from '@grafana/i18n/internal';
import { config } from '@grafana/runtime';
import builtInPlugins from '../built_in_plugins';
import builtInPlugins, { isBuiltinPluginPath } from '../built_in_plugins';
import { registerPluginInfoInCache } from '../loader/pluginInfoCache';
import { SystemJS } from '../loader/systemjs';
import { resolveModulePath } from '../loader/utils';
@@ -35,8 +35,8 @@ export async function importPluginModule({
});
}
const builtIn = builtInPlugins[path];
if (builtIn) {
if (isBuiltinPluginPath(path)) {
const builtIn = builtInPlugins[path];
// for handling dynamic imports
if (typeof builtIn === 'function') {
return await builtIn();
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Bar chart",
"id": "barchart",
"suggestions": true,
"info": {
"description": "Categorical charts with group support",
"author": {
@@ -1,6 +1,6 @@
import { defaultsDeep } from 'lodash';
import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn, VizOrientation } from '@grafana/data';
import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplier, VizOrientation } from '@grafana/data';
import { t } from '@grafana/i18n';
import { LegendDisplayMode, StackingMode, VisibilityMode } from '@grafana/schema';
@@ -32,7 +32,7 @@ const withDefaults = (suggestion: VisualizationSuggestion<Options, FieldConfig>)
},
} satisfies VisualizationSuggestion<Options, FieldConfig>);
export const barchartSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, FieldConfig> = (dataSummary) => {
export const barchartSuggestionsSupplier: VisualizationSuggestionsSupplier<Options, FieldConfig> = (dataSummary) => {
if (dataSummary.frameCount !== 1) {
return;
}
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Bar gauge",
"id": "bargauge",
"suggestions": true,
"info": {
"description": "Horizontal and vertical gauges",
"author": {
@@ -4,7 +4,7 @@ import {
FieldColorModeId,
FieldType,
VisualizationSuggestion,
VisualizationSuggestionsSupplierFn,
VisualizationSuggestionsSupplier,
VizOrientation,
} from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -31,7 +31,7 @@ const withDefaults = (suggestion: VisualizationSuggestion<Options>): Visualizati
const BAR_LIMIT = 30;
export const barGaugeSugggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
export const barGaugeSugggestionsSupplier: VisualizationSuggestionsSupplier<Options> = (dataSummary) => {
if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
return;
}
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Candlestick",
"id": "candlestick",
"suggestions": true,
"info": {
"description": "Graphical representation of price movements of a security, derivative, or currency.",
"keywords": ["financial", "price", "currency", "k-line"],
@@ -1,10 +1,10 @@
import { FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data';
import { config } from '@grafana/runtime';
import { prepareCandlestickFields } from './fields';
import { defaultOptions, Options } from './types';
export const candlestickSuggestionSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
export const candlestickSuggestionSupplier: VisualizationSuggestionsSupplier<Options> = (dataSummary) => {
if (
!dataSummary.rawFrames ||
!dataSummary.hasData ||
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Flame Graph",
"id": "flamegraph",
"suggestions": true,
"info": {
"author": {
"name": "Grafana Labs",
+1 -1
View File
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Gauge",
"id": "gauge",
"suggestions": true,
"info": {
"description": "Standard gauge visualization",
"author": {
@@ -1,6 +1,6 @@
import { defaultsDeep } from 'lodash';
import { ThresholdsMode, FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { ThresholdsMode, FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplier } from '@grafana/data';
import { t } from '@grafana/i18n';
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
@@ -33,7 +33,7 @@ const withDefaults = (suggestion: VisualizationSuggestion<Options>): Visualizati
const GAUGE_LIMIT = 10;
export const gaugeSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
export const gaugeSuggestionsSupplier: VisualizationSuggestionsSupplier<Options> = (dataSummary) => {
if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
return;
}
+1 -1
View File
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Geomap",
"id": "geomap",
"suggestions": true,
"info": {
"description": "Geomap panel",
"author": {
@@ -1,12 +1,10 @@
import { VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data';
import { GraphFieldConfig } from '@grafana/ui';
import { getGeometryField, getDefaultLocationMatchers } from 'app/features/geo/utils/location';
import { Options } from './panelcfg.gen';
export const geomapSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
dataSummary
) => {
export const geomapSuggestionsSupplier: VisualizationSuggestionsSupplier<Options, GraphFieldConfig> = (dataSummary) => {
if (!dataSummary.hasData || !dataSummary.rawFrames) {
return;
}
+1 -1
View File
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Heatmap",
"id": "heatmap",
"suggestions": true,
"info": {
"description": "Like a histogram over time",
"author": {
@@ -3,7 +3,7 @@ import {
FieldType,
PanelDataSummary,
VisualizationSuggestionScore,
VisualizationSuggestionsSupplierFn,
VisualizationSuggestionsSupplier,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { GraphFieldConfig } from '@grafana/schema';
@@ -43,7 +43,7 @@ function determineScore(dataSummary: PanelDataSummary): VisualizationSuggestionS
return VisualizationSuggestionScore.OK;
}
export const heatmapSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
export const heatmapSuggestionsSupplier: VisualizationSuggestionsSupplier<Options, GraphFieldConfig> = (
dataSummary: PanelDataSummary
) => {
if (
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Histogram",
"id": "histogram",
"suggestions": true,
"info": {
"description": "Distribution of values presented as a bar chart.",
"keywords": ["distribution", "bar chart", "frequency", "proportional"],
+1 -1
View File
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Logs",
"id": "logs",
"suggestions": true,
"info": {
"author": {
"name": "Grafana Labs",
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Node Graph",
"id": "nodeGraph",
"suggestions": true,
"info": {
"author": {
"name": "Grafana Labs",
@@ -1,4 +1,4 @@
import { DataFrame, FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { DataFrame, FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data';
import { Options } from './panelcfg.gen';
@@ -44,7 +44,7 @@ function frameHasCorrectFields(frames: DataFrame[]): boolean {
return hasNodesFrame && hasEdgesFrame;
}
export const nodeGraphSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
export const nodeGraphSuggestionsSupplier: VisualizationSuggestionsSupplier<Options> = (dataSummary) => {
if (!dataSummary.rawFrames) {
return;
}
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Pie chart",
"id": "piechart",
"suggestions": true,
"info": {
"description": "The new core pie chart visualization",
"author": {
@@ -4,7 +4,7 @@ import {
FieldType,
VisualizationSuggestion,
VisualizationSuggestionScore,
VisualizationSuggestionsSupplierFn,
VisualizationSuggestionsSupplier,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { LegendDisplayMode } from '@grafana/schema';
@@ -29,7 +29,7 @@ const withDefaults = (suggestion: VisualizationSuggestion<Options>): Visualizati
const SLICE_MAX = 30;
const SLICE_MIN = 2;
export const piechartSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
export const piechartSuggestionsSupplier: VisualizationSuggestionsSupplier<Options> = (dataSummary) => {
if (!dataSummary.hasFieldType(FieldType.number)) {
return;
}
@@ -3,6 +3,7 @@
"name": "New Gauge",
"id": "radialbar",
"state": "alpha",
"suggestions": false,
"info": {
"description": "Standard gauge visualization",
"author": {
@@ -1,11 +1,6 @@
import { defaultsDeep } from 'lodash';
import {
FieldColorModeId,
FieldType,
VisualizationSuggestion,
VisualizationSuggestionsSupplierFn,
} from '@grafana/data';
import { FieldColorModeId, FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplier } from '@grafana/data';
import { t } from '@grafana/i18n';
import { GraphFieldConfig } from '@grafana/ui';
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
@@ -40,7 +35,7 @@ const withDefaults = (
const MAX_GAUGES = 10;
export const radialBarSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
export const radialBarSuggestionsSupplier: VisualizationSuggestionsSupplier<Options, GraphFieldConfig> = (
dataSummary
) => {
if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
+1 -1
View File
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Stat",
"id": "stat",
"suggestions": true,
"info": {
"description": "Big stat values & sparklines",
"author": {
+2 -2
View File
@@ -1,6 +1,6 @@
import { defaultsDeep } from 'lodash';
import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplier } from '@grafana/data';
import { t } from '@grafana/i18n';
import { BigValueColorMode, BigValueGraphMode } from '@grafana/schema';
@@ -24,7 +24,7 @@ const withDefaults = (s: VisualizationSuggestion<Options>): VisualizationSuggest
},
} satisfies VisualizationSuggestion<Options>);
export const statSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (ds) => {
export const statSuggestionsSupplier: VisualizationSuggestionsSupplier<Options> = (ds) => {
if (!ds.hasData) {
return;
}
@@ -2,7 +2,7 @@
"type": "panel",
"name": "State timeline",
"id": "state-timeline",
"suggestions": true,
"info": {
"description": "State changes and durations",
"author": {
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Status history",
"id": "status-history",
"suggestions": true,
"info": {
"description": "Periodic status history",
"author": {
+1 -1
View File
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Table",
"id": "table",
"suggestions": true,
"info": {
"description": "Supports many column styles",
"author": {
@@ -1,4 +1,4 @@
import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data';
import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg';
import { Options, FieldConfig } from './panelcfg.gen';
@@ -16,7 +16,7 @@ function getTableSuggestionScore(dataSummary: PanelDataSummary): VisualizationSu
return VisualizationSuggestionScore.OK;
}
export const tableSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, FieldConfig> = (dataSummary) => [
export const tableSuggestionsSupplier: VisualizationSuggestionsSupplier<Options, FieldConfig> = (dataSummary) => [
{
score: getTableSuggestionScore(dataSummary),
cardOptions: {
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Time series",
"id": "timeseries",
"suggestions": true,
"info": {
"description": "Time based line, area and bar charts",
"author": {
@@ -7,7 +7,7 @@ import {
PanelPluginVisualizationSuggestion,
VisualizationSuggestion,
VisualizationSuggestionScore,
VisualizationSuggestionsSupplierFn,
VisualizationSuggestionsSupplier,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import {
@@ -83,7 +83,7 @@ const barChart = (name: string, stacking?: StackingMode) => ({
// TODO: all "gradient color scheme" suggestions have been removed. they will be re-added as part of the "styles" feature.
export const timeseriesSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
export const timeseriesSuggestionsSupplier: VisualizationSuggestionsSupplier<Options, GraphFieldConfig> = (
dataSummary
) => {
if (
+1 -1
View File
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Traces",
"id": "traces",
"suggestions": true,
"info": {
"author": {
"name": "Grafana Labs",
+1 -1
View File
@@ -4,7 +4,7 @@
"id": "trend",
"state": "beta",
"suggestions": true,
"info": {
"description": "Like timeseries, but when x != time",
"author": {