mirror of
https://github.com/grafana/grafana.git
synced 2026-01-14 13:21:26 +00:00
Compare commits
12 Commits
hackathon/
...
kvstore-ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c83c64d72 | ||
|
|
6e39b24b6f | ||
|
|
c31c1d8e8d | ||
|
|
e36ea78771 | ||
|
|
b332a108f3 | ||
|
|
1060dd538a | ||
|
|
be8076dee8 | ||
|
|
7f1ac6188a | ||
|
|
31eaf1e898 | ||
|
|
780a64e771 | ||
|
|
156a6f1375 | ||
|
|
5fd4fb5fb8 |
2
.github/workflows/go-lint.yml
vendored
2
.github/workflows/go-lint.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/alerting/historian/kinds/v0alpha1/matcher.cue
Normal file
9
apps/alerting/historian/kinds/v0alpha1/matcher.cue
Normal file
@@ -0,0 +1,9 @@
|
||||
package v0alpha1
|
||||
|
||||
#Matcher: {
|
||||
type: "=" | "!=" | "=~" | "!~" @cuetsy(kind="enum",memberNames="Equal|NotEqual|EqualRegex|NotEqualRegex")
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
#Matchers: [...#Matcher]
|
||||
65
apps/alerting/historian/kinds/v0alpha1/notification.cue
Normal file
65
apps/alerting/historian/kinds/v0alpha1/notification.cue
Normal file
@@ -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
|
||||
}
|
||||
29
apps/alerting/historian/kinds/v0alpha1/routes.cue
Normal file
29
apps/alerting/historian/kinds/v0alpha1/routes.cue
Normal file
@@ -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{},
|
||||
}
|
||||
}
|
||||
@@ -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] == '/' {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
2
apps/plugins/pkg/apis/plugins_manifest.go
generated
2
apps/plugins/pkg/apis/plugins_manifest.go
generated
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.",
|
||||
|
||||
@@ -22,7 +22,7 @@ weight: 100
|
||||
|
||||
# Node graph
|
||||
|
||||
Node graphs are useful when you need to visualize elements that are related to each other. This is done by displaying circles—or _nodes_—for each element you want to visualize, connected by lines—or _edges_. The visualization uses a directed force layout that positions the nodes into a network of connected circles.
|
||||
Node graphs are useful when you need to visualize elements that are related to each other. This is done by displaying circles—or _nodes_—for each element you want to visualize, connected by lines—or _edges_. By default, the visualization uses a [layered layout](#layout-algorithm) that positions the nodes into a network of connected circles.
|
||||
|
||||
Node graphs display useful information about each node, as well as the relationships between them, allowing you to visualize complex infrastructure maps, hierarchies, or execution diagrams.
|
||||
|
||||
@@ -123,26 +123,32 @@ You can pan the view by clicking outside any node or edge and dragging your mous
|
||||
|
||||
Use the buttons in the lower right corner to zoom in or out. You can also use the mouse wheel or touchpad scroll, together with either Ctrl or Cmd key to do so.
|
||||
|
||||
### Switch layouts
|
||||
|
||||
Switch quickly between displaying the visualization in graph or grid [layout](#layout-algorithm).
|
||||
|
||||
Click a node and select either **Show in Grid layout** or **Show in Graph layout**, depending on the current layout of the visualization:
|
||||
|
||||
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid-menu.png" max-width="750px" alt="Node graph in grid layout with node menu open" >}}
|
||||
|
||||
In grid layout, you can sort nodes by clicking on the stats inside the legend.
|
||||
The marker next to the stat name shows which stat is currently used for sorting and the sorting direction:
|
||||
|
||||
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-legend-sort.png" max-width="550px" alt="Node graph legend sorting" >}}
|
||||
|
||||
Switching between grid and other layouts this way only changes the layout temporarily.
|
||||
The visualization maintains the layout algorithm selected in the panel editor, and reverts to it when the dashboard refreshes.
|
||||
|
||||
For more information about layouts, refer to [Layout algorithm](#layout-algorithm).
|
||||
|
||||
<!-- if you have the panel in grid layout and switch it to graph, is it switching to layered? -->
|
||||
|
||||
### Hidden nodes
|
||||
|
||||
The number of nodes shown at a given time is limited to maintain a reasonable visualization performance. Nodes that are not currently visible are hidden behind clickable markers that show an approximate number of hidden nodes that are connected by a particular edge. You can click on the marker to expand the graph around that node.
|
||||
|
||||

|
||||
|
||||
### Grid view
|
||||
|
||||
You can switch to the grid view to have a better overview of the most interesting nodes in the graph. Grid view shows nodes in a grid without edges and can be sorted by stats shown inside the node or by stats represented by the a colored border of the nodes.
|
||||
|
||||

|
||||
|
||||
To sort the nodes, click on the stats inside the legend. The marker next to the stat name shows which stat is currently used for sorting and sorting direction.
|
||||
|
||||

|
||||
|
||||
Click on the node and select "Show in Graph layout" option to switch back to graph layout and focus on the selected node, to show it in context of the full graph.
|
||||
|
||||

|
||||
|
||||
## Configuration options
|
||||
|
||||
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
@@ -155,7 +161,24 @@ Click on the node and select "Show in Graph layout" option to switch back to gra
|
||||
|
||||
Use the following options to refine your node graph visualization.
|
||||
|
||||
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
|
||||
#### Zoom mode
|
||||
|
||||
Choose how the node graph should handle zoom and scroll events:
|
||||
|
||||
- **Cooperative** - Allows you to scroll the visualization normally.
|
||||
- **Greedy** - Reacts to all zoom gestures.
|
||||
|
||||
#### Layout algorithm
|
||||
|
||||
Choose how the visualization layout is generated:
|
||||
|
||||
- **Layered** - Default. Creates a predictable and orderly layout, especially useful for service graphs.
|
||||
- **Force** - Uses a physics-based force layout algorithm that's useful with a large number of nodes (500+).
|
||||
- **Grid** - Arranges nodes into a grid format to provide a better overview of the most interesting nodes in the graph. This layout shows nodes in a grid without edges and can be sorted by the stats shown inside the node or by the ones represented by the a colored border of the nodes.
|
||||
|
||||
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid.png" max-width="650px" alt="Node graph in grid layout" >}}
|
||||
|
||||
For more information about using the graph in grid layout, refer to [Switch layouts](#switch-layouts).
|
||||
|
||||
### Nodes options
|
||||
|
||||
@@ -239,6 +262,6 @@ Optional fields:
|
||||
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
|
||||
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
|
||||
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behavior depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
|
||||
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/iconography-icon--icons-overview)) are allowed. |
|
||||
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/iconography-icon--icons-overview) are allowed. |
|
||||
| nodeRadius | number | Radius value in pixels. Used to manage node size. |
|
||||
| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -105,6 +105,7 @@ type JSONData struct {
|
||||
|
||||
// Panel settings
|
||||
SkipDataQuery bool `json:"skipDataQuery"`
|
||||
Suggestions bool `json:"suggestions,omitempty"`
|
||||
|
||||
// App settings
|
||||
AutoEnabled bool `json:"autoEnabled"`
|
||||
|
||||
@@ -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
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -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
|
||||
|
||||
|
14
pkg/services/featuremgmt/toggles_gen.json
generated
14
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -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",
|
||||
|
||||
@@ -221,7 +221,7 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode
|
||||
// Add Service Center as a standalone nav item under Alerts & IRM
|
||||
if alertsSection := treeRoot.FindById(navtree.NavIDAlertsAndIncidents); alertsSection != nil {
|
||||
serviceLink := &navtree.NavLink{
|
||||
Text: "Service Center",
|
||||
Text: "Service center",
|
||||
Id: "standalone-plugin-page-slo-services",
|
||||
Url: s.cfg.AppSubURL + "/a/grafana-slo-app/services",
|
||||
SortWeight: 1,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -171,7 +171,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
|
||||
>
|
||||
<div className={cx(styles.rightControls, editPanel && styles.rightControlsWrap)}>
|
||||
{!hideTimeControls && (
|
||||
<div className={styles.timeControls}>
|
||||
<div className={styles.fixedControls}>
|
||||
<timePicker.Component model={timePicker} />
|
||||
<refreshPicker.Component model={refreshPicker} />
|
||||
</div>
|
||||
@@ -181,7 +181,11 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
|
||||
<DashboardControlsButton dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
{config.featureToggles.dashboardNewLayouts && <DashboardControlActions dashboard={dashboard} />}
|
||||
{config.featureToggles.dashboardNewLayouts && (
|
||||
<div className={styles.fixedControls}>
|
||||
<DashboardControlActions dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} dashboard={dashboard} />}
|
||||
</div>
|
||||
{!hideVariableControls && (
|
||||
@@ -274,12 +278,12 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
float: 'right',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
flexWrap: 'wrap',
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
}),
|
||||
timeControls: css({
|
||||
fixedControls: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: theme.spacing(1),
|
||||
|
||||
@@ -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);
|
||||
|
||||
19
public/app/features/panel/suggestions/consts.ts
Normal file
19
public/app/features/panel/suggestions/consts.ts
Normal file
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Stat",
|
||||
"id": "stat",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"description": "Big stat values & sparklines",
|
||||
"author": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Traces",
|
||||
"id": "traces",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"id": "trend",
|
||||
|
||||
"state": "beta",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"description": "Like timeseries, but when x != time",
|
||||
"author": {
|
||||
|
||||
Reference in New Issue
Block a user