Compare commits
35 Commits
kvstore-ba
...
hackathon/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b6a434383 | ||
|
|
6d6112b627 | ||
|
|
41f9162472 | ||
|
|
7e991886e0 | ||
|
|
85925d0765 | ||
|
|
7790698aaa | ||
|
|
5499ad8023 | ||
|
|
90c4ab9d96 | ||
|
|
fd31f087ee | ||
|
|
3ee834922b | ||
|
|
2e2ce8fddd | ||
|
|
8214dbc758 | ||
|
|
98d454401c | ||
|
|
fcf1a47222 | ||
|
|
8a5b6804dd | ||
|
|
f0028f692b | ||
|
|
d71474246c | ||
|
|
9447015e54 | ||
|
|
abe10b2bb6 | ||
|
|
009716a408 | ||
|
|
e0c28cfa4c | ||
|
|
18c4f5b875 | ||
|
|
400f3a91d0 | ||
|
|
d6b04d28b6 | ||
|
|
0400d536c7 | ||
|
|
694e88b95b | ||
|
|
ad73303328 | ||
|
|
3dcd809aaf | ||
|
|
6b7fac65b1 | ||
|
|
2d17de2395 | ||
|
|
5b685373aa | ||
|
|
4d29e5bf6a | ||
|
|
7a0e64196b | ||
|
|
f1e24f528e | ||
|
|
198f4dbf93 |
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-x64-large-io
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
package kinds
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/apps/alerting/historian/kinds/v0alpha1"
|
||||
)
|
||||
|
||||
manifest: {
|
||||
appName: "alerting-historian"
|
||||
groupOverride: "historian.alerting.grafana.app"
|
||||
versions: {
|
||||
"v0alpha1": {
|
||||
kinds: [dummyv0alpha1]
|
||||
routes: v0alpha1.routes
|
||||
}
|
||||
"v0alpha1": v0alpha1
|
||||
}
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -23,4 +37,4 @@ dummyv0alpha1: {
|
||||
dummyField: int
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package v0alpha1
|
||||
|
||||
#Matcher: {
|
||||
type: "=" | "!=" | "=~" | "!~" @cuetsy(kind="enum",memberNames="Equal|NotEqual|EqualRegex|NotEqualRegex")
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
#Matchers: [...#Matcher]
|
||||
@@ -1,65 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// 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 = "!~"
|
||||
)
|
||||
@@ -1,86 +0,0 @@
|
||||
// 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,321 +92,9 @@ 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{
|
||||
"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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Schemas: map[string]spec.Schema{},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -432,8 +120,7 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist
|
||||
}
|
||||
|
||||
var customRouteToGoResponseType = map[string]any{
|
||||
"v0alpha1||<namespace>/alertstate/history|GET": v0alpha1.GetAlertstatehistory{},
|
||||
"v0alpha1||<namespace>/notification/query|POST": v0alpha1.CreateNotificationquery{},
|
||||
"v0alpha1||<namespace>/alertstate/history|GET": v0alpha1.GetAlertstatehistory{},
|
||||
}
|
||||
|
||||
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
|
||||
@@ -458,9 +145,7 @@ func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goTyp
|
||||
return goType, exists
|
||||
}
|
||||
|
||||
var customRouteToGoRequestBodyType = map[string]any{
|
||||
"v0alpha1||<namespace>/notification/query|POST": v0alpha1.CreateNotificationqueryRequestBody{},
|
||||
}
|
||||
var customRouteToGoRequestBodyType = map[string]any{}
|
||||
|
||||
func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) {
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
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"
|
||||
@@ -26,11 +21,6 @@ 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.
|
||||
@@ -53,13 +43,3 @@ 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ generate: install-app-sdk update-app-sdk
|
||||
--gogenpath=./pkg/apis \
|
||||
--grouping=group \
|
||||
--genoperatorstate=false \
|
||||
--defencoding=none
|
||||
--defencoding=none
|
||||
|
||||
35
apps/collections/kinds/datasourcestacks.cue
Normal file
35
apps/collections/kinds/datasourcestacks.cue
Normal file
@@ -0,0 +1,35 @@
|
||||
package preferences
|
||||
|
||||
datasourcestacksV1alpha1: {
|
||||
kind: "DataSourceStack"
|
||||
pluralName: "DataSourceStacks"
|
||||
scope: "Namespaced"
|
||||
schema: {
|
||||
spec: {
|
||||
template: TemplateSpec
|
||||
modes: [...ModeSpec]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TemplateSpec: {
|
||||
[string]: DataSourceStackTemplateItem
|
||||
}
|
||||
|
||||
DataSourceStackTemplateItem: {
|
||||
group: string // type
|
||||
name: string // variable name / display name
|
||||
}
|
||||
|
||||
ModeSpec: {
|
||||
name: string
|
||||
uid: string
|
||||
definition: Mode
|
||||
}
|
||||
|
||||
Mode: [string]: ModeItem
|
||||
|
||||
ModeItem: {
|
||||
dataSourceRef: string // grafana data source uid
|
||||
}
|
||||
@@ -6,12 +6,13 @@ manifest: {
|
||||
versions: {
|
||||
"v1alpha1": {
|
||||
codegen: {
|
||||
ts: {enabled: false}
|
||||
ts: {enabled: true}
|
||||
go: {enabled: true}
|
||||
}
|
||||
kinds: [
|
||||
starsV1alpha1,
|
||||
datasourcestacksV1alpha1
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_client_gen.go
generated
Normal file
80
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_client_gen.go
generated
Normal file
@@ -0,0 +1,80 @@
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
)
|
||||
|
||||
type DataSourceStackClient struct {
|
||||
client *resource.TypedClient[*DataSourceStack, *DataSourceStackList]
|
||||
}
|
||||
|
||||
func NewDataSourceStackClient(client resource.Client) *DataSourceStackClient {
|
||||
return &DataSourceStackClient{
|
||||
client: resource.NewTypedClient[*DataSourceStack, *DataSourceStackList](client, DataSourceStackKind()),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDataSourceStackClientFromGenerator(generator resource.ClientGenerator) (*DataSourceStackClient, error) {
|
||||
c, err := generator.ClientFor(DataSourceStackKind())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewDataSourceStackClient(c), nil
|
||||
}
|
||||
|
||||
func (c *DataSourceStackClient) Get(ctx context.Context, identifier resource.Identifier) (*DataSourceStack, error) {
|
||||
return c.client.Get(ctx, identifier)
|
||||
}
|
||||
|
||||
func (c *DataSourceStackClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*DataSourceStackList, error) {
|
||||
return c.client.List(ctx, namespace, opts)
|
||||
}
|
||||
|
||||
func (c *DataSourceStackClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*DataSourceStackList, error) {
|
||||
resp, err := c.client.List(ctx, namespace, resource.ListOptions{
|
||||
ResourceVersion: opts.ResourceVersion,
|
||||
Limit: opts.Limit,
|
||||
LabelFilters: opts.LabelFilters,
|
||||
FieldSelectors: opts.FieldSelectors,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for resp.GetContinue() != "" {
|
||||
page, err := c.client.List(ctx, namespace, resource.ListOptions{
|
||||
Continue: resp.GetContinue(),
|
||||
ResourceVersion: opts.ResourceVersion,
|
||||
Limit: opts.Limit,
|
||||
LabelFilters: opts.LabelFilters,
|
||||
FieldSelectors: opts.FieldSelectors,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.SetContinue(page.GetContinue())
|
||||
resp.SetResourceVersion(page.GetResourceVersion())
|
||||
resp.SetItems(append(resp.GetItems(), page.GetItems()...))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *DataSourceStackClient) Create(ctx context.Context, obj *DataSourceStack, opts resource.CreateOptions) (*DataSourceStack, error) {
|
||||
// Make sure apiVersion and kind are set
|
||||
obj.APIVersion = GroupVersion.Identifier()
|
||||
obj.Kind = DataSourceStackKind().Kind()
|
||||
return c.client.Create(ctx, obj, opts)
|
||||
}
|
||||
|
||||
func (c *DataSourceStackClient) Update(ctx context.Context, obj *DataSourceStack, opts resource.UpdateOptions) (*DataSourceStack, error) {
|
||||
return c.client.Update(ctx, obj, opts)
|
||||
}
|
||||
|
||||
func (c *DataSourceStackClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*DataSourceStack, error) {
|
||||
return c.client.Patch(ctx, identifier, req, opts)
|
||||
}
|
||||
|
||||
func (c *DataSourceStackClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error {
|
||||
return c.client.Delete(ctx, identifier, opts)
|
||||
}
|
||||
28
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_codec_gen.go
generated
Normal file
28
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_codec_gen.go
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// Code generated by grafana-app-sdk. DO NOT EDIT.
|
||||
//
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
)
|
||||
|
||||
// DataSourceStackJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding
|
||||
type DataSourceStackJSONCodec struct{}
|
||||
|
||||
// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into`
|
||||
func (*DataSourceStackJSONCodec) Read(reader io.Reader, into resource.Object) error {
|
||||
return json.NewDecoder(reader).Decode(into)
|
||||
}
|
||||
|
||||
// Write writes JSON-encoded bytes into `writer` marshaled from `from`
|
||||
func (*DataSourceStackJSONCodec) Write(writer io.Writer, from resource.Object) error {
|
||||
return json.NewEncoder(writer).Encode(from)
|
||||
}
|
||||
|
||||
// Interface compliance checks
|
||||
var _ resource.Codec = &DataSourceStackJSONCodec{}
|
||||
31
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_metadata_gen.go
generated
Normal file
31
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_metadata_gen.go
generated
Normal file
@@ -0,0 +1,31 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
time "time"
|
||||
)
|
||||
|
||||
// metadata contains embedded CommonMetadata and can be extended with custom string fields
|
||||
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
|
||||
// without external reference as using the CommonMetadata reference breaks thema codegen.
|
||||
type DataSourceStackMetadata struct {
|
||||
UpdateTimestamp time.Time `json:"updateTimestamp"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
Uid string `json:"uid"`
|
||||
CreationTimestamp time.Time `json:"creationTimestamp"`
|
||||
DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"`
|
||||
Finalizers []string `json:"finalizers"`
|
||||
ResourceVersion string `json:"resourceVersion"`
|
||||
Generation int64 `json:"generation"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
}
|
||||
|
||||
// NewDataSourceStackMetadata creates a new DataSourceStackMetadata object.
|
||||
func NewDataSourceStackMetadata() *DataSourceStackMetadata {
|
||||
return &DataSourceStackMetadata{
|
||||
Finalizers: []string{},
|
||||
Labels: map[string]string{},
|
||||
}
|
||||
}
|
||||
293
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_object_gen.go
generated
Normal file
293
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_object_gen.go
generated
Normal file
@@ -0,0 +1,293 @@
|
||||
//
|
||||
// Code generated by grafana-app-sdk. DO NOT EDIT.
|
||||
//
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"time"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DataSourceStack struct {
|
||||
metav1.TypeMeta `json:",inline" yaml:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata" yaml:"metadata"`
|
||||
|
||||
// Spec is the spec of the DataSourceStack
|
||||
Spec DataSourceStackSpec `json:"spec" yaml:"spec"`
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) GetSpec() any {
|
||||
return o.Spec
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) SetSpec(spec any) error {
|
||||
cast, ok := spec.(DataSourceStackSpec)
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec)
|
||||
}
|
||||
o.Spec = cast
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) GetSubresources() map[string]any {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) GetSubresource(name string) (any, bool) {
|
||||
switch name {
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) SetSubresource(name string, value any) error {
|
||||
switch name {
|
||||
default:
|
||||
return fmt.Errorf("subresource '%s' does not exist", name)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) GetStaticMetadata() resource.StaticMetadata {
|
||||
gvk := o.GroupVersionKind()
|
||||
return resource.StaticMetadata{
|
||||
Name: o.ObjectMeta.Name,
|
||||
Namespace: o.ObjectMeta.Namespace,
|
||||
Group: gvk.Group,
|
||||
Version: gvk.Version,
|
||||
Kind: gvk.Kind,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) SetStaticMetadata(metadata resource.StaticMetadata) {
|
||||
o.Name = metadata.Name
|
||||
o.Namespace = metadata.Namespace
|
||||
o.SetGroupVersionKind(schema.GroupVersionKind{
|
||||
Group: metadata.Group,
|
||||
Version: metadata.Version,
|
||||
Kind: metadata.Kind,
|
||||
})
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) GetCommonMetadata() resource.CommonMetadata {
|
||||
dt := o.DeletionTimestamp
|
||||
var deletionTimestamp *time.Time
|
||||
if dt != nil {
|
||||
deletionTimestamp = &dt.Time
|
||||
}
|
||||
// Legacy ExtraFields support
|
||||
extraFields := make(map[string]any)
|
||||
if o.Annotations != nil {
|
||||
extraFields["annotations"] = o.Annotations
|
||||
}
|
||||
if o.ManagedFields != nil {
|
||||
extraFields["managedFields"] = o.ManagedFields
|
||||
}
|
||||
if o.OwnerReferences != nil {
|
||||
extraFields["ownerReferences"] = o.OwnerReferences
|
||||
}
|
||||
return resource.CommonMetadata{
|
||||
UID: string(o.UID),
|
||||
ResourceVersion: o.ResourceVersion,
|
||||
Generation: o.Generation,
|
||||
Labels: o.Labels,
|
||||
CreationTimestamp: o.CreationTimestamp.Time,
|
||||
DeletionTimestamp: deletionTimestamp,
|
||||
Finalizers: o.Finalizers,
|
||||
UpdateTimestamp: o.GetUpdateTimestamp(),
|
||||
CreatedBy: o.GetCreatedBy(),
|
||||
UpdatedBy: o.GetUpdatedBy(),
|
||||
ExtraFields: extraFields,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) SetCommonMetadata(metadata resource.CommonMetadata) {
|
||||
o.UID = types.UID(metadata.UID)
|
||||
o.ResourceVersion = metadata.ResourceVersion
|
||||
o.Generation = metadata.Generation
|
||||
o.Labels = metadata.Labels
|
||||
o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp)
|
||||
if metadata.DeletionTimestamp != nil {
|
||||
dt := metav1.NewTime(*metadata.DeletionTimestamp)
|
||||
o.DeletionTimestamp = &dt
|
||||
} else {
|
||||
o.DeletionTimestamp = nil
|
||||
}
|
||||
o.Finalizers = metadata.Finalizers
|
||||
if o.Annotations == nil {
|
||||
o.Annotations = make(map[string]string)
|
||||
}
|
||||
if !metadata.UpdateTimestamp.IsZero() {
|
||||
o.SetUpdateTimestamp(metadata.UpdateTimestamp)
|
||||
}
|
||||
if metadata.CreatedBy != "" {
|
||||
o.SetCreatedBy(metadata.CreatedBy)
|
||||
}
|
||||
if metadata.UpdatedBy != "" {
|
||||
o.SetUpdatedBy(metadata.UpdatedBy)
|
||||
}
|
||||
// Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields
|
||||
if metadata.ExtraFields != nil {
|
||||
if annotations, ok := metadata.ExtraFields["annotations"]; ok {
|
||||
if cast, ok := annotations.(map[string]string); ok {
|
||||
o.Annotations = cast
|
||||
}
|
||||
}
|
||||
if managedFields, ok := metadata.ExtraFields["managedFields"]; ok {
|
||||
if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok {
|
||||
o.ManagedFields = cast
|
||||
}
|
||||
}
|
||||
if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok {
|
||||
if cast, ok := ownerReferences.([]metav1.OwnerReference); ok {
|
||||
o.OwnerReferences = cast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) GetCreatedBy() string {
|
||||
if o.ObjectMeta.Annotations == nil {
|
||||
o.ObjectMeta.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
return o.ObjectMeta.Annotations["grafana.com/createdBy"]
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) SetCreatedBy(createdBy string) {
|
||||
if o.ObjectMeta.Annotations == nil {
|
||||
o.ObjectMeta.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) GetUpdateTimestamp() time.Time {
|
||||
if o.ObjectMeta.Annotations == nil {
|
||||
o.ObjectMeta.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"])
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) SetUpdateTimestamp(updateTimestamp time.Time) {
|
||||
if o.ObjectMeta.Annotations == nil {
|
||||
o.ObjectMeta.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) GetUpdatedBy() string {
|
||||
if o.ObjectMeta.Annotations == nil {
|
||||
o.ObjectMeta.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
return o.ObjectMeta.Annotations["grafana.com/updatedBy"]
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) SetUpdatedBy(updatedBy string) {
|
||||
if o.ObjectMeta.Annotations == nil {
|
||||
o.ObjectMeta.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) Copy() resource.Object {
|
||||
return resource.CopyObject(o)
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) DeepCopyObject() runtime.Object {
|
||||
return o.Copy()
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) DeepCopy() *DataSourceStack {
|
||||
cpy := &DataSourceStack{}
|
||||
o.DeepCopyInto(cpy)
|
||||
return cpy
|
||||
}
|
||||
|
||||
func (o *DataSourceStack) DeepCopyInto(dst *DataSourceStack) {
|
||||
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
|
||||
dst.TypeMeta.Kind = o.TypeMeta.Kind
|
||||
o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta)
|
||||
o.Spec.DeepCopyInto(&dst.Spec)
|
||||
}
|
||||
|
||||
// Interface compliance compile-time check
|
||||
var _ resource.Object = &DataSourceStack{}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DataSourceStackList struct {
|
||||
metav1.TypeMeta `json:",inline" yaml:",inline"`
|
||||
metav1.ListMeta `json:"metadata" yaml:"metadata"`
|
||||
Items []DataSourceStack `json:"items" yaml:"items"`
|
||||
}
|
||||
|
||||
func (o *DataSourceStackList) DeepCopyObject() runtime.Object {
|
||||
return o.Copy()
|
||||
}
|
||||
|
||||
func (o *DataSourceStackList) Copy() resource.ListObject {
|
||||
cpy := &DataSourceStackList{
|
||||
TypeMeta: o.TypeMeta,
|
||||
Items: make([]DataSourceStack, len(o.Items)),
|
||||
}
|
||||
o.ListMeta.DeepCopyInto(&cpy.ListMeta)
|
||||
for i := 0; i < len(o.Items); i++ {
|
||||
if item, ok := o.Items[i].Copy().(*DataSourceStack); ok {
|
||||
cpy.Items[i] = *item
|
||||
}
|
||||
}
|
||||
return cpy
|
||||
}
|
||||
|
||||
func (o *DataSourceStackList) GetItems() []resource.Object {
|
||||
items := make([]resource.Object, len(o.Items))
|
||||
for i := 0; i < len(o.Items); i++ {
|
||||
items[i] = &o.Items[i]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (o *DataSourceStackList) SetItems(items []resource.Object) {
|
||||
o.Items = make([]DataSourceStack, len(items))
|
||||
for i := 0; i < len(items); i++ {
|
||||
o.Items[i] = *items[i].(*DataSourceStack)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *DataSourceStackList) DeepCopy() *DataSourceStackList {
|
||||
cpy := &DataSourceStackList{}
|
||||
o.DeepCopyInto(cpy)
|
||||
return cpy
|
||||
}
|
||||
|
||||
func (o *DataSourceStackList) DeepCopyInto(dst *DataSourceStackList) {
|
||||
resource.CopyObjectInto(dst, o)
|
||||
}
|
||||
|
||||
// Interface compliance compile-time check
|
||||
var _ resource.ListObject = &DataSourceStackList{}
|
||||
|
||||
// Copy methods for all subresource types
|
||||
|
||||
// DeepCopy creates a full deep copy of Spec
|
||||
func (s *DataSourceStackSpec) DeepCopy() *DataSourceStackSpec {
|
||||
cpy := &DataSourceStackSpec{}
|
||||
s.DeepCopyInto(cpy)
|
||||
return cpy
|
||||
}
|
||||
|
||||
// DeepCopyInto deep copies Spec into another Spec object
|
||||
func (s *DataSourceStackSpec) DeepCopyInto(dst *DataSourceStackSpec) {
|
||||
resource.CopyObjectInto(dst, s)
|
||||
}
|
||||
34
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_schema_gen.go
generated
Normal file
34
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_schema_gen.go
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// Code generated by grafana-app-sdk. DO NOT EDIT.
|
||||
//
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
)
|
||||
|
||||
// schema is unexported to prevent accidental overwrites
|
||||
var (
|
||||
schemaDataSourceStack = resource.NewSimpleSchema("collections.grafana.app", "v1alpha1", &DataSourceStack{}, &DataSourceStackList{}, resource.WithKind("DataSourceStack"),
|
||||
resource.WithPlural("datasourcestacks"), resource.WithScope(resource.NamespacedScope))
|
||||
kindDataSourceStack = resource.Kind{
|
||||
Schema: schemaDataSourceStack,
|
||||
Codecs: map[resource.KindEncoding]resource.Codec{
|
||||
resource.KindEncodingJSON: &DataSourceStackJSONCodec{},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Kind returns a resource.Kind for this Schema with a JSON codec
|
||||
func DataSourceStackKind() resource.Kind {
|
||||
return kindDataSourceStack
|
||||
}
|
||||
|
||||
// Schema returns a resource.SimpleSchema representation of DataSourceStack
|
||||
func DataSourceStackSchema() *resource.SimpleSchema {
|
||||
return schemaDataSourceStack
|
||||
}
|
||||
|
||||
// Interface compliance checks
|
||||
var _ resource.Schema = kindDataSourceStack
|
||||
58
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_spec_gen.go
generated
Normal file
58
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_spec_gen.go
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v1alpha1
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DataSourceStackTemplateSpec map[string]DataSourceStackDataSourceStackTemplateItem
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DataSourceStackDataSourceStackTemplateItem struct {
|
||||
// type
|
||||
Group string `json:"group"`
|
||||
// variable name / display name
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// NewDataSourceStackDataSourceStackTemplateItem creates a new DataSourceStackDataSourceStackTemplateItem object.
|
||||
func NewDataSourceStackDataSourceStackTemplateItem() *DataSourceStackDataSourceStackTemplateItem {
|
||||
return &DataSourceStackDataSourceStackTemplateItem{}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DataSourceStackModeSpec struct {
|
||||
Name string `json:"name"`
|
||||
Uid string `json:"uid"`
|
||||
Definition DataSourceStackMode `json:"definition"`
|
||||
}
|
||||
|
||||
// NewDataSourceStackModeSpec creates a new DataSourceStackModeSpec object.
|
||||
func NewDataSourceStackModeSpec() *DataSourceStackModeSpec {
|
||||
return &DataSourceStackModeSpec{}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DataSourceStackMode map[string]DataSourceStackModeItem
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DataSourceStackModeItem struct {
|
||||
// grafana data source uid
|
||||
DataSourceRef string `json:"dataSourceRef"`
|
||||
}
|
||||
|
||||
// NewDataSourceStackModeItem creates a new DataSourceStackModeItem object.
|
||||
func NewDataSourceStackModeItem() *DataSourceStackModeItem {
|
||||
return &DataSourceStackModeItem{}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DataSourceStackSpec struct {
|
||||
Template DataSourceStackTemplateSpec `json:"template"`
|
||||
Modes []DataSourceStackModeSpec `json:"modes"`
|
||||
}
|
||||
|
||||
// NewDataSourceStackSpec creates a new DataSourceStackSpec object.
|
||||
func NewDataSourceStackSpec() *DataSourceStackSpec {
|
||||
return &DataSourceStackSpec{
|
||||
Modes: []DataSourceStackModeSpec{},
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,19 @@ var StarsResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
|
||||
},
|
||||
)
|
||||
|
||||
var DatasourceStacksResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
|
||||
"datasourcestacks", "datasourcestack", "DataSourceStack",
|
||||
func() runtime.Object { return &DataSourceStack{} },
|
||||
func() runtime.Object { return &DataSourceStackList{} },
|
||||
utils.TableColumns{
|
||||
Definition: []metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
{Name: "Created At", Type: "date"},
|
||||
},
|
||||
// TODO: Reader?
|
||||
},
|
||||
)
|
||||
|
||||
var (
|
||||
SchemeBuilder runtime.SchemeBuilder
|
||||
localSchemeBuilder = &SchemeBuilder
|
||||
@@ -48,6 +61,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
|
||||
scheme.AddKnownTypes(schemeGroupVersion,
|
||||
&Stars{},
|
||||
&StarsList{},
|
||||
&DataSourceStack{},
|
||||
&DataSourceStackList{},
|
||||
)
|
||||
metav1.AddToGroupVersion(scheme, schemeGroupVersion)
|
||||
return nil
|
||||
|
||||
@@ -14,10 +14,241 @@ import (
|
||||
|
||||
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
|
||||
return map[string]common.OpenAPIDefinition{
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack": schema_pkg_apis_collections_v1alpha1_DataSourceStack(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackList": schema_pkg_apis_collections_v1alpha1_DataSourceStackList(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackModeItem(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec": schema_pkg_apis_collections_v1alpha1_DataSourceStackModeSpec(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec": schema_pkg_apis_collections_v1alpha1_DataSourceStackSpec(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref),
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStack(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Spec is the spec of the DataSourceStack",
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"metadata", "spec"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"group": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "type",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "variable name / display name",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"group", "name"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStackList(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"metadata", "items"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStackModeItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"dataSourceRef": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "grafana data source uid",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"dataSourceRef"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStackModeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"name": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"uid": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"definition": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Allows: true,
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "uid", "definition"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStackSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"template": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Allows: true,
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"modes": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"template", "modes"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem", "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackSpec,Modes
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsSpec,Resource
|
||||
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackList,ListMeta
|
||||
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsList,ListMeta
|
||||
|
||||
26
apps/collections/pkg/apis/collections_manifest.go
generated
26
apps/collections/pkg/apis/collections_manifest.go
generated
@@ -10,19 +10,22 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
v1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
)
|
||||
|
||||
var (
|
||||
rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`)
|
||||
versionSchemaStarsv1alpha1 app.VersionSchema
|
||||
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1)
|
||||
rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`)
|
||||
versionSchemaStarsv1alpha1 app.VersionSchema
|
||||
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1)
|
||||
rawSchemaDataSourceStackv1alpha1 = []byte(`{"DataSourceStack":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"DataSourceStackTemplateItem":{"additionalProperties":false,"properties":{"group":{"description":"type","type":"string"},"name":{"description":"variable name / display name","type":"string"}},"required":["group","name"],"type":"object"},"Mode":{"additionalProperties":{"$ref":"#/components/schemas/ModeItem"},"type":"object"},"ModeItem":{"additionalProperties":false,"properties":{"dataSourceRef":{"description":"grafana data source uid","type":"string"}},"required":["dataSourceRef"],"type":"object"},"ModeSpec":{"additionalProperties":false,"properties":{"definition":{"$ref":"#/components/schemas/Mode"},"name":{"type":"string"},"uid":{"type":"string"}},"required":["name","uid","definition"],"type":"object"},"TemplateSpec":{"additionalProperties":{"$ref":"#/components/schemas/DataSourceStackTemplateItem"},"type":"object"},"spec":{"additionalProperties":false,"properties":{"modes":{"items":{"$ref":"#/components/schemas/ModeSpec"},"type":"array"},"template":{"$ref":"#/components/schemas/TemplateSpec"}},"required":["template","modes"],"type":"object"}}`)
|
||||
versionSchemaDataSourceStackv1alpha1 app.VersionSchema
|
||||
_ = json.Unmarshal(rawSchemaDataSourceStackv1alpha1, &versionSchemaDataSourceStackv1alpha1)
|
||||
)
|
||||
|
||||
var appManifestData = app.ManifestData{
|
||||
@@ -49,6 +52,14 @@ var appManifestData = app.ManifestData{
|
||||
},
|
||||
Schema: &versionSchemaStarsv1alpha1,
|
||||
},
|
||||
|
||||
{
|
||||
Kind: "DataSourceStack",
|
||||
Plural: "DataSourceStacks",
|
||||
Scope: "Namespaced",
|
||||
Conversion: false,
|
||||
Schema: &versionSchemaDataSourceStackv1alpha1,
|
||||
},
|
||||
},
|
||||
Routes: app.ManifestVersionRoutes{
|
||||
Namespaced: map[string]spec3.PathProps{},
|
||||
@@ -68,7 +79,8 @@ func RemoteManifest() app.Manifest {
|
||||
}
|
||||
|
||||
var kindVersionToGoType = map[string]resource.Kind{
|
||||
"Stars/v1alpha1": v1alpha1.StarsKind(),
|
||||
"Stars/v1alpha1": v1alpha1.StarsKind(),
|
||||
"DataSourceStack/v1alpha1": v1alpha1.DataSourceStackKind(),
|
||||
}
|
||||
|
||||
// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists.
|
||||
|
||||
47
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/datasourcestack_object_gen.ts
generated
Normal file
47
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/datasourcestack_object_gen.ts
generated
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* This file was generated by grafana-app-sdk. DO NOT EDIT.
|
||||
*/
|
||||
import { Spec } from './types.spec.gen';
|
||||
|
||||
export interface Metadata {
|
||||
name: string;
|
||||
namespace: string;
|
||||
generateName?: string;
|
||||
selfLink?: string;
|
||||
uid?: string;
|
||||
resourceVersion?: string;
|
||||
generation?: number;
|
||||
creationTimestamp?: string;
|
||||
deletionTimestamp?: string;
|
||||
deletionGracePeriodSeconds?: number;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
ownerReferences?: OwnerReference[];
|
||||
finalizers?: string[];
|
||||
managedFields?: ManagedFieldsEntry[];
|
||||
}
|
||||
|
||||
export interface OwnerReference {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
controller?: boolean;
|
||||
blockOwnerDeletion?: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedFieldsEntry {
|
||||
manager?: string;
|
||||
operation?: string;
|
||||
apiVersion?: string;
|
||||
time?: string;
|
||||
fieldsType?: string;
|
||||
subresource?: string;
|
||||
}
|
||||
|
||||
export interface DataSourceStack {
|
||||
kind: string;
|
||||
apiVersion: string;
|
||||
metadata: Metadata;
|
||||
spec: Spec;
|
||||
}
|
||||
30
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/types.metadata.gen.ts
generated
Normal file
30
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/types.metadata.gen.ts
generated
Normal file
@@ -0,0 +1,30 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
// metadata contains embedded CommonMetadata and can be extended with custom string fields
|
||||
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
|
||||
// without external reference as using the CommonMetadata reference breaks thema codegen.
|
||||
export interface Metadata {
|
||||
updateTimestamp: string;
|
||||
createdBy: string;
|
||||
uid: string;
|
||||
creationTimestamp: string;
|
||||
deletionTimestamp?: string;
|
||||
finalizers: string[];
|
||||
resourceVersion: string;
|
||||
generation: number;
|
||||
updatedBy: string;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export const defaultMetadata = (): Metadata => ({
|
||||
updateTimestamp: "",
|
||||
createdBy: "",
|
||||
uid: "",
|
||||
creationTimestamp: "",
|
||||
finalizers: [],
|
||||
resourceVersion: "",
|
||||
generation: 0,
|
||||
updatedBy: "",
|
||||
labels: {},
|
||||
});
|
||||
|
||||
53
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/types.spec.gen.ts
generated
Normal file
53
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/types.spec.gen.ts
generated
Normal file
@@ -0,0 +1,53 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
export type TemplateSpec = Record<string, DataSourceStackTemplateItem>;
|
||||
|
||||
export const defaultTemplateSpec = (): TemplateSpec => ({});
|
||||
|
||||
export interface DataSourceStackTemplateItem {
|
||||
// type
|
||||
group: string;
|
||||
// variable name / display name
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const defaultDataSourceStackTemplateItem = (): DataSourceStackTemplateItem => ({
|
||||
group: "",
|
||||
name: "",
|
||||
});
|
||||
|
||||
export interface ModeSpec {
|
||||
name: string;
|
||||
uid: string;
|
||||
definition: Mode;
|
||||
}
|
||||
|
||||
export const defaultModeSpec = (): ModeSpec => ({
|
||||
name: "",
|
||||
uid: "",
|
||||
definition: defaultMode(),
|
||||
});
|
||||
|
||||
export type Mode = Record<string, ModeItem>;
|
||||
|
||||
export const defaultMode = (): Mode => ({});
|
||||
|
||||
export interface ModeItem {
|
||||
// grafana data source uid
|
||||
dataSourceRef: string;
|
||||
}
|
||||
|
||||
export const defaultModeItem = (): ModeItem => ({
|
||||
dataSourceRef: "",
|
||||
});
|
||||
|
||||
export interface Spec {
|
||||
template: TemplateSpec;
|
||||
modes: ModeSpec[];
|
||||
}
|
||||
|
||||
export const defaultSpec = (): Spec => ({
|
||||
template: defaultTemplateSpec(),
|
||||
modes: [],
|
||||
});
|
||||
|
||||
47
apps/collections/plugin/src/generated/stars/v1alpha1/stars_object_gen.ts
generated
Normal file
47
apps/collections/plugin/src/generated/stars/v1alpha1/stars_object_gen.ts
generated
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* This file was generated by grafana-app-sdk. DO NOT EDIT.
|
||||
*/
|
||||
import { Spec } from './types.spec.gen';
|
||||
|
||||
export interface Metadata {
|
||||
name: string;
|
||||
namespace: string;
|
||||
generateName?: string;
|
||||
selfLink?: string;
|
||||
uid?: string;
|
||||
resourceVersion?: string;
|
||||
generation?: number;
|
||||
creationTimestamp?: string;
|
||||
deletionTimestamp?: string;
|
||||
deletionGracePeriodSeconds?: number;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
ownerReferences?: OwnerReference[];
|
||||
finalizers?: string[];
|
||||
managedFields?: ManagedFieldsEntry[];
|
||||
}
|
||||
|
||||
export interface OwnerReference {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
controller?: boolean;
|
||||
blockOwnerDeletion?: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedFieldsEntry {
|
||||
manager?: string;
|
||||
operation?: string;
|
||||
apiVersion?: string;
|
||||
time?: string;
|
||||
fieldsType?: string;
|
||||
subresource?: string;
|
||||
}
|
||||
|
||||
export interface Stars {
|
||||
kind: string;
|
||||
apiVersion: string;
|
||||
metadata: Metadata;
|
||||
spec: Spec;
|
||||
}
|
||||
30
apps/collections/plugin/src/generated/stars/v1alpha1/types.metadata.gen.ts
generated
Normal file
30
apps/collections/plugin/src/generated/stars/v1alpha1/types.metadata.gen.ts
generated
Normal file
@@ -0,0 +1,30 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
// metadata contains embedded CommonMetadata and can be extended with custom string fields
|
||||
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
|
||||
// without external reference as using the CommonMetadata reference breaks thema codegen.
|
||||
export interface Metadata {
|
||||
updateTimestamp: string;
|
||||
createdBy: string;
|
||||
uid: string;
|
||||
creationTimestamp: string;
|
||||
deletionTimestamp?: string;
|
||||
finalizers: string[];
|
||||
resourceVersion: string;
|
||||
generation: number;
|
||||
updatedBy: string;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export const defaultMetadata = (): Metadata => ({
|
||||
updateTimestamp: "",
|
||||
createdBy: "",
|
||||
uid: "",
|
||||
creationTimestamp: "",
|
||||
finalizers: [],
|
||||
resourceVersion: "",
|
||||
generation: 0,
|
||||
updatedBy: "",
|
||||
labels: {},
|
||||
});
|
||||
|
||||
24
apps/collections/plugin/src/generated/stars/v1alpha1/types.spec.gen.ts
generated
Normal file
24
apps/collections/plugin/src/generated/stars/v1alpha1/types.spec.gen.ts
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
export interface Resource {
|
||||
group: string;
|
||||
kind: string;
|
||||
// The set of resources
|
||||
// +listType=set
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export const defaultResource = (): Resource => ({
|
||||
group: "",
|
||||
kind: "",
|
||||
names: [],
|
||||
});
|
||||
|
||||
export interface Spec {
|
||||
resource: Resource[];
|
||||
}
|
||||
|
||||
export const defaultSpec = (): Spec => ({
|
||||
resource: [],
|
||||
});
|
||||
|
||||
@@ -911,6 +911,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
@@ -915,6 +915,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
@@ -1675,18 +1675,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind {
|
||||
// Custom variable specification
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpec struct {
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"`
|
||||
}
|
||||
|
||||
// NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object.
|
||||
@@ -2101,6 +2102,14 @@ const (
|
||||
DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpecValuesFormat string
|
||||
|
||||
const (
|
||||
DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv"
|
||||
DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardPanelKindOrLibraryPanelKind struct {
|
||||
PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"`
|
||||
|
||||
@@ -1510,6 +1510,12 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.Re
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"valuesFormat": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"},
|
||||
},
|
||||
|
||||
@@ -53,7 +53,6 @@ pluginMetaV0Alpha1: {
|
||||
skipDataQuery?: bool
|
||||
state?: "alpha" | "beta"
|
||||
streaming?: bool
|
||||
suggestions?: bool
|
||||
tracing?: bool
|
||||
iam?: #IAM
|
||||
// +listType=atomic
|
||||
|
||||
@@ -40,7 +40,6 @@ 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
@@ -181,6 +181,8 @@ import (
|
||||
//go:generate mockery --name InterfaceName --structname MockImplementationName --inpackage --filename my_implementation_mock.go
|
||||
```
|
||||
|
||||
The current `go:generate` command format used in this repository is only compatible with mockery v2.
|
||||
|
||||
## Globals
|
||||
|
||||
As a general rule of thumb, avoid using global variables, since they make the code difficult to maintain and reason
|
||||
|
||||
@@ -341,10 +341,6 @@
|
||||
"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_. By default, the visualization uses a [layered layout](#layout-algorithm) 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_. The visualization uses a directed force layout 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,32 +123,26 @@ 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>" >}}
|
||||
@@ -161,24 +155,7 @@ The number of nodes shown at a given time is limited to maintain a reasonable vi
|
||||
|
||||
Use the following options to refine your node graph visualization.
|
||||
|
||||
#### 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).
|
||||
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
|
||||
|
||||
### Nodes options
|
||||
|
||||
@@ -262,6 +239,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` |
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"@grafana/plugin-ui": "^0.11.1",
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/scenes": "6.47.1",
|
||||
"@grafana/scenes-react": "6.47.1",
|
||||
"@grafana/scenes": "^6.48.0",
|
||||
"@grafana/scenes-react": "^6.48.0",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -715,9 +715,11 @@ export {
|
||||
export {
|
||||
type VisualizationSuggestion,
|
||||
type VisualizationSuggestionsSupplier,
|
||||
type VisualizationSuggestionsSupplierFn,
|
||||
type PanelPluginVisualizationSuggestion,
|
||||
type VisualizationSuggestionsBuilder,
|
||||
VisualizationSuggestionScore,
|
||||
VisualizationSuggestionsBuilder,
|
||||
VisualizationSuggestionsListAppender,
|
||||
} from './types/suggestions';
|
||||
export {
|
||||
type MatcherConfig,
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
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', () => {
|
||||
@@ -487,107 +483,4 @@ 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 { defaultsDeep, set } from 'lodash';
|
||||
import { set } from 'lodash';
|
||||
import { ComponentClass, ComponentType } from 'react';
|
||||
|
||||
import { FieldConfigOptionsRegistry } from '../field/FieldConfigOptionsRegistry';
|
||||
@@ -14,19 +14,11 @@ import {
|
||||
PanelPluginDataSupport,
|
||||
} from '../types/panel';
|
||||
import { GrafanaPlugin } from '../types/plugin';
|
||||
import {
|
||||
getSuggestionHash,
|
||||
PanelPluginVisualizationSuggestion,
|
||||
VisualizationSuggestion,
|
||||
VisualizationSuggestionsSupplierDeprecated,
|
||||
VisualizationSuggestionsSupplier,
|
||||
VisualizationSuggestionsBuilder,
|
||||
} from '../types/suggestions';
|
||||
import { VisualizationSuggestionsSupplierFn, VisualizationSuggestionsSupplier } 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 = {
|
||||
@@ -117,7 +109,7 @@ export class PanelPlugin<
|
||||
};
|
||||
|
||||
private optionsSupplier?: PanelOptionsSupplier<TOptions>;
|
||||
private suggestionsSupplier?: VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>;
|
||||
private suggestionsSupplier?: VisualizationSuggestionsSupplier;
|
||||
|
||||
panel: ComponentType<PanelProps<TOptions>> | null;
|
||||
editor?: ComponentClass<PanelEditorProps<TOptions>>;
|
||||
@@ -371,84 +363,56 @@ export class PanelPlugin<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use VisualizationSuggestionsSupplier
|
||||
* @deprecated use VisualizationSuggestionsSupplierFn
|
||||
*/
|
||||
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierDeprecated): this;
|
||||
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier): this;
|
||||
/**
|
||||
* @alpha
|
||||
* sets function that can return visualization examples and suggestions.
|
||||
*/
|
||||
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>): this;
|
||||
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>): this;
|
||||
setSuggestionsSupplier(
|
||||
supplier:
|
||||
| VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>
|
||||
| VisualizationSuggestionsSupplierDeprecated
|
||||
supplier: VisualizationSuggestionsSupplier | VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>
|
||||
): this {
|
||||
if (typeof supplier !== 'function') {
|
||||
deprecationWarning(
|
||||
'PanelPlugin',
|
||||
'plugin.setSuggestionsSupplier(new Supplier())',
|
||||
'plugin.setSuggestionsSupplier(dataSummary => [...])'
|
||||
);
|
||||
return this;
|
||||
}
|
||||
this.suggestionsSupplier = supplier;
|
||||
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;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the suggestions supplier
|
||||
* @alpha
|
||||
* get suggestions based on the PanelDataSummary
|
||||
*/
|
||||
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) });
|
||||
}
|
||||
);
|
||||
getSuggestionsSupplier(): VisualizationSuggestionsSupplier | undefined {
|
||||
return this.suggestionsSupplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use getSuggestions
|
||||
* we have to keep this method intact to support cloud-onboarding plugin.
|
||||
* @alpha
|
||||
* returns whether the plugin has configured suggestions
|
||||
*/
|
||||
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);
|
||||
});
|
||||
},
|
||||
};
|
||||
hasSuggestions(): boolean {
|
||||
return this.suggestionsSupplier !== undefined;
|
||||
}
|
||||
|
||||
hasPluginId(pluginId: string) {
|
||||
|
||||
@@ -1143,11 +1143,6 @@ 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,8 +20,6 @@ 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,10 +2,11 @@ import { defaultsDeep } from 'lodash';
|
||||
|
||||
import { DataTransformerConfig } from '@grafana/schema';
|
||||
|
||||
import { getPanelDataSummary, PanelDataSummary } from '../panel/suggestions/getPanelDataSummary';
|
||||
import { PanelDataSummary, getPanelDataSummary } from '../panel/suggestions/getPanelDataSummary';
|
||||
|
||||
import { DataFrame } from './dataFrame';
|
||||
import { PanelModel } from './dashboard';
|
||||
import { FieldConfigSource } from './fieldOverrides';
|
||||
import { PanelData } from './panel';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -107,6 +108,35 @@ 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.
|
||||
@@ -117,48 +147,40 @@ export enum VisualizationSuggestionScore {
|
||||
* - 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 VisualizationSuggestionsSupplier<TOptions extends unknown, TFieldConfig extends {} = {}> = (
|
||||
export type VisualizationSuggestionsSupplierFn<TOptions extends unknown, TFieldConfig extends {} = {}> = (
|
||||
panelDataSummary: PanelDataSummary
|
||||
) => Array<VisualizationSuggestion<TOptions, TFieldConfig>> | void;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @deprecated use VisualizationSuggestionsSupplierFn instead.
|
||||
*/
|
||||
/**
|
||||
* @deprecated use VisualizationSuggestionsSupplier
|
||||
*/
|
||||
export interface VisualizationSuggestionsSupplierDeprecated {
|
||||
export type VisualizationSuggestionsSupplier = {
|
||||
/**
|
||||
* Adds suitable suggestions for the current data
|
||||
*/
|
||||
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use VisualizationSuggestionsSupplier
|
||||
* @internal
|
||||
* TODO this will move into the grafana app code once suppliers are migrated.
|
||||
*/
|
||||
export class VisualizationSuggestionsBuilder {
|
||||
public dataSummary: PanelDataSummary;
|
||||
public list: PanelPluginVisualizationSuggestion[] = [];
|
||||
export class VisualizationSuggestionsListAppender<TOptions extends unknown, TFieldConfig extends {} = {}> {
|
||||
constructor(
|
||||
private list: VisualizationSuggestion[],
|
||||
private defaults: Partial<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>> = {}
|
||||
) {}
|
||||
|
||||
constructor(dataFrames: DataFrame[]) {
|
||||
this.dataSummary = getPanelDataSummary(dataFrames);
|
||||
append(suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) {
|
||||
this.appendAll([suggestion]);
|
||||
}
|
||||
|
||||
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));
|
||||
},
|
||||
};
|
||||
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) });
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export interface IntervalVariableModel extends VariableWithOptions {
|
||||
|
||||
export interface CustomVariableModel extends VariableWithMultiSupport {
|
||||
type: 'custom';
|
||||
valuesFormat?: 'csv' | 'json';
|
||||
}
|
||||
|
||||
export interface DataSourceVariableModel extends VariableWithMultiSupport {
|
||||
|
||||
@@ -316,6 +316,7 @@ export const handyTestingSchema: Spec = {
|
||||
query: 'option1, option2',
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1335,6 +1335,7 @@ export interface CustomVariableSpec {
|
||||
skipUrlSync: boolean;
|
||||
description?: string;
|
||||
allowCustomValue: boolean;
|
||||
valuesFormat?: "csv" | "json";
|
||||
}
|
||||
|
||||
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
|
||||
|
||||
@@ -518,7 +518,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
|
||||
return {
|
||||
container: css({
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}),
|
||||
panel: css({
|
||||
|
||||
@@ -150,6 +150,10 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Get("/connections/datasources/edit/*", authorize(datasources.EditPageAccess), hs.Index)
|
||||
r.Get("/connections", authorize(datasources.ConfigurationPageAccess), hs.Index)
|
||||
r.Get("/connections/add-new-connection", authorize(datasources.ConfigurationPageAccess), hs.Index)
|
||||
r.Get("/connections/stacks", authorize(datasources.ConfigurationPageAccess), hs.Index)
|
||||
r.Get("/connections/stacks/new", authorize(datasources.ConfigurationPageAccess), hs.Index)
|
||||
r.Get("/connections/stacks/edit/*", authorize(datasources.ConfigurationPageAccess), hs.Index)
|
||||
|
||||
// Plugin details pages
|
||||
r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
|
||||
r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
|
||||
|
||||
@@ -164,7 +164,6 @@ 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,10 +222,6 @@ 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 (
|
||||
@@ -695,12 +691,6 @@ 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
|
||||
@@ -839,6 +829,5 @@ func initMetricVars(reg prometheus.Registerer) {
|
||||
MStatTotalRepositories,
|
||||
MFolderIDsAPICount,
|
||||
MFolderIDsServiceCount,
|
||||
MUnifiedStorageMigrationStatus,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -319,7 +319,6 @@ 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,7 +105,6 @@ type JSONData struct {
|
||||
|
||||
// Panel settings
|
||||
SkipDataQuery bool `json:"skipDataQuery"`
|
||||
Suggestions bool `json:"suggestions,omitempty"`
|
||||
|
||||
// App settings
|
||||
AutoEnabled bool `json:"autoEnabled"`
|
||||
|
||||
88
pkg/registry/apis/collections/datasources_validator.go
Normal file
88
pkg/registry/apis/collections/datasources_validator.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/datasources/service/client"
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupValidation = (*DatasourceStacksValidator)(nil)
|
||||
|
||||
type DatasourceStacksValidator struct {
|
||||
dsClient client.DataSourceConnectionClient
|
||||
}
|
||||
|
||||
func GetDatasourceStacksValidator(dsClient client.DataSourceConnectionClient) builder.APIGroupValidation {
|
||||
return &DatasourceStacksValidator{dsClient: dsClient}
|
||||
}
|
||||
|
||||
func (v *DatasourceStacksValidator) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
|
||||
obj := a.GetObject()
|
||||
operation := a.GetOperation()
|
||||
|
||||
if operation == admission.Connect {
|
||||
return fmt.Errorf("Connect operation is not allowed (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
|
||||
}
|
||||
|
||||
if operation != admission.Create && operation != admission.Update {
|
||||
return nil
|
||||
}
|
||||
|
||||
cast, ok := obj.(*collections.DataSourceStack)
|
||||
if !ok {
|
||||
return fmt.Errorf("object is not of type *collections.DataSourceStack (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
|
||||
}
|
||||
|
||||
// get the keys from the template
|
||||
template := cast.Spec.Template
|
||||
|
||||
templateNames := map[string]bool{}
|
||||
for _, item := range template {
|
||||
// template items cannot be empty
|
||||
if item.Group == "" || item.Name == "" {
|
||||
return fmt.Errorf("template items cannot be empty (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
|
||||
}
|
||||
// template names must be unique
|
||||
if _, exists := templateNames[item.Name]; exists {
|
||||
return fmt.Errorf("template item names must be unique. name '%s' already exists (%s %s)", item.Name, a.GetName(), a.GetKind().GroupVersion().String())
|
||||
}
|
||||
templateNames[item.Name] = true
|
||||
}
|
||||
|
||||
// for each mode, check that the keys are in the template
|
||||
modes := cast.Spec.Modes
|
||||
|
||||
for _, mode := range modes {
|
||||
for key, item := range mode.Definition {
|
||||
// if a key is not in the template, return an error
|
||||
if _, ok := template[key]; !ok {
|
||||
return fmt.Errorf("key '%s' is not in the DataSourceStack template (%s %s)", key, a.GetName(), a.GetKind().GroupVersion().String())
|
||||
}
|
||||
|
||||
exists, err := v.checkDatasourceExists(ctx, item.DataSourceRef)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("datasource '%s' in group '%s' does not exist (%s %s): %w", item.DataSourceRef, template[key].Group, a.GetName(), a.GetKind().GroupVersion().String(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *DatasourceStacksValidator) checkDatasourceExists(ctx context.Context, name string) (bool, error) {
|
||||
dsConn, err := v.dsClient.GetByUID(ctx, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if dsConn == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
|
||||
}
|
||||
212
pkg/registry/apis/collections/datasources_validator_test.go
Normal file
212
pkg/registry/apis/collections/datasources_validator_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package collections_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
collectionsv1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
queryv0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/collections"
|
||||
datasourcesclient "github.com/grafana/grafana/pkg/services/datasources/service/client"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
)
|
||||
|
||||
func TestDataSourceValidator_Validate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation admission.Operation
|
||||
object runtime.Object
|
||||
needMockDSClient bool // only set to true if you expect to make a call to the datasource client
|
||||
dsClientReturnValue *queryv0alpha1.DataSourceConnection
|
||||
dsClientReturnError error
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "should return no error for invalid kind",
|
||||
operation: admission.Delete,
|
||||
object: &collectionsv1alpha1.Stars{},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "should return error for Connect operation",
|
||||
operation: admission.Connect,
|
||||
object: &collectionsv1alpha1.DataSourceStack{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "template items cannot be empty",
|
||||
operation: admission.Create,
|
||||
object: &collectionsv1alpha1.DataSourceStack{
|
||||
Spec: collectionsv1alpha1.DataSourceStackSpec{
|
||||
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
|
||||
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "template items cannot be empty (test-datasourcestack collections.grafana.app/v1alpha1)",
|
||||
},
|
||||
{
|
||||
name: "template item name keys must be unique",
|
||||
operation: admission.Create,
|
||||
object: &collectionsv1alpha1.DataSourceStack{
|
||||
Spec: collectionsv1alpha1.DataSourceStackSpec{
|
||||
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
|
||||
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
|
||||
Name: "foo",
|
||||
Group: "foo.grafana",
|
||||
},
|
||||
"key2": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
|
||||
Name: "foo",
|
||||
Group: "foo.grafana",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "template item names must be unique. name 'foo' already exists (test-datasourcestack collections.grafana.app/v1alpha1)",
|
||||
},
|
||||
{
|
||||
name: "mode keys must exist in the template",
|
||||
operation: admission.Create,
|
||||
object: &collectionsv1alpha1.DataSourceStack{
|
||||
Spec: collectionsv1alpha1.DataSourceStackSpec{
|
||||
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
|
||||
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
|
||||
Name: "foo",
|
||||
Group: "foo.grafana",
|
||||
},
|
||||
},
|
||||
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
|
||||
{
|
||||
Name: "prod",
|
||||
Definition: collectionsv1alpha1.DataSourceStackMode{
|
||||
"notintemplate": collectionsv1alpha1.DataSourceStackModeItem{
|
||||
DataSourceRef: "foo",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "key 'notintemplate' is not in the DataSourceStack template (test-datasourcestack collections.grafana.app/v1alpha1)",
|
||||
},
|
||||
{
|
||||
name: "error if data source does not exist",
|
||||
operation: admission.Create,
|
||||
object: &collectionsv1alpha1.DataSourceStack{
|
||||
Spec: collectionsv1alpha1.DataSourceStackSpec{
|
||||
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
|
||||
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
|
||||
Name: "foo",
|
||||
Group: "foo.grafana",
|
||||
},
|
||||
},
|
||||
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
|
||||
{
|
||||
Name: "prod",
|
||||
Definition: collectionsv1alpha1.DataSourceStackMode{
|
||||
"key1": collectionsv1alpha1.DataSourceStackModeItem{
|
||||
DataSourceRef: "ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
needMockDSClient: true,
|
||||
dsClientReturnValue: nil, // no result - this is the default anyway
|
||||
expectError: true,
|
||||
errorMsg: "datasource 'ref' in group 'foo.grafana' does not exist (test-datasourcestack collections.grafana.app/v1alpha1)",
|
||||
},
|
||||
{
|
||||
name: "valid request",
|
||||
operation: admission.Create,
|
||||
object: &collectionsv1alpha1.DataSourceStack{
|
||||
Spec: collectionsv1alpha1.DataSourceStackSpec{
|
||||
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
|
||||
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
|
||||
Name: "foo",
|
||||
Group: "foo.grafana",
|
||||
},
|
||||
},
|
||||
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
|
||||
{
|
||||
Name: "prod",
|
||||
Definition: collectionsv1alpha1.DataSourceStackMode{
|
||||
"key1": collectionsv1alpha1.DataSourceStackModeItem{
|
||||
DataSourceRef: "ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
needMockDSClient: true,
|
||||
dsClientReturnValue: &queryv0alpha1.DataSourceConnection{}, // returning any non-nil value will pass validation
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
attrs := &FakeAdmissionAttributes{
|
||||
Operation: tt.operation,
|
||||
Object: tt.object,
|
||||
Name: "test-datasourcestack",
|
||||
Kind: schema.GroupVersionKind{Group: "collections.grafana.app", Version: "v1alpha1", Kind: "DataSourceStack"},
|
||||
}
|
||||
|
||||
var client *datasourcesclient.MockDataSourceConnectionClient
|
||||
if tt.needMockDSClient {
|
||||
client = datasourcesclient.NewMockDataSourceConnectionClient(t)
|
||||
client.On("GetByUID", mock.Anything, mock.Anything).Return(tt.dsClientReturnValue, tt.dsClientReturnError)
|
||||
}
|
||||
|
||||
validator := collections.GetDatasourceStacksValidator(client)
|
||||
err := validator.Validate(ctx, attrs, nil)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
if tt.errorMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type FakeAdmissionAttributes struct {
|
||||
admission.Attributes
|
||||
Operation admission.Operation
|
||||
Object runtime.Object
|
||||
Name string
|
||||
Kind schema.GroupVersionKind
|
||||
}
|
||||
|
||||
func (m *FakeAdmissionAttributes) GetOperation() admission.Operation {
|
||||
return m.Operation
|
||||
}
|
||||
|
||||
func (m *FakeAdmissionAttributes) GetObject() runtime.Object {
|
||||
return m.Object
|
||||
}
|
||||
|
||||
func (m *FakeAdmissionAttributes) GetName() string {
|
||||
return m.Name
|
||||
}
|
||||
|
||||
func (m *FakeAdmissionAttributes) GetKind() schema.GroupVersionKind {
|
||||
return m.Kind
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
@@ -14,13 +16,16 @@ import (
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/collections/legacy"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
datasourcesClient "github.com/grafana/grafana/pkg/services/datasources/service/client"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/star"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@@ -29,13 +34,15 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupMutation = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupMutation = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupValidation = (*APIBuilder)(nil)
|
||||
)
|
||||
|
||||
type APIBuilder struct {
|
||||
authorizer authorizer.Authorizer
|
||||
legacyStars *legacy.DashboardStarsStorage
|
||||
authorizer authorizer.Authorizer
|
||||
legacyStars *legacy.DashboardStarsStorage
|
||||
datasourceStacksValidator builder.APIGroupValidation
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
@@ -45,6 +52,8 @@ func RegisterAPIService(
|
||||
stars star.Service,
|
||||
users user.Service,
|
||||
apiregistration builder.APIRegistrar,
|
||||
dsConnClientFactory datasourcesClient.DataSourceConnectionClientFactory,
|
||||
restConfigProvider apiserver.RestConfigProvider,
|
||||
) *APIBuilder {
|
||||
// Requires development settings and clearly experimental
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
@@ -52,11 +61,15 @@ func RegisterAPIService(
|
||||
return nil
|
||||
}
|
||||
|
||||
dsConnClient := dsConnClientFactory(restConfigProvider)
|
||||
|
||||
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
|
||||
builder := &APIBuilder{
|
||||
datasourceStacksValidator: GetDatasourceStacksValidator(dsConnClient),
|
||||
authorizer: &utils.AuthorizeFromName{
|
||||
Resource: map[string][]utils.ResourceOwner{
|
||||
"stars": {utils.UserResourceOwner},
|
||||
"stars": {utils.UserResourceOwner},
|
||||
"datasources": {utils.UserResourceOwner},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -94,28 +107,60 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
// Configure Stars Dual writer
|
||||
resource := collections.StarsResourceInfo
|
||||
starsResource := collections.StarsResourceInfo
|
||||
var stars grafanarest.Storage
|
||||
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
|
||||
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, starsResource, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stars = &starStorage{Storage: stars} // wrap List so we only return one value
|
||||
if b.legacyStars != nil && opts.DualWriteBuilder != nil {
|
||||
stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars)
|
||||
stars, err = opts.DualWriteBuilder(starsResource.GroupResource(), b.legacyStars, stars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
storage[resource.StoragePath()] = stars
|
||||
storage[resource.StoragePath("update")] = &starsREST{store: stars}
|
||||
storage[starsResource.StoragePath()] = stars
|
||||
storage[starsResource.StoragePath("update")] = &starsREST{store: stars}
|
||||
|
||||
// no need for dual writer for a kind that does not exist in the legacy database
|
||||
resourceInfo := collections.DatasourceStacksResourceInfo
|
||||
datasourcesStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, resourceInfo, opts.OptsGetter)
|
||||
storage[resourceInfo.StoragePath()] = datasourcesStorage
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[collections.APIVersion] = storage
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
|
||||
if a.GetKind().Group == collections.DatasourceStacksResourceInfo.GroupResource().Group {
|
||||
return b.datasourceStacksValidator.Validate(ctx, a, o)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
return b.authorizer
|
||||
|
||||
return authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
if attr.GetResource() == "stars" {
|
||||
return b.authorizer.Authorize(ctx, attr)
|
||||
}
|
||||
|
||||
// datasources auth branch starts
|
||||
if !attr.IsResourceRequest() {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
// require a user
|
||||
_, err = identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
// TODO make the auth more restrictive
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
|
||||
@@ -85,7 +85,7 @@ func RegisterAPIService(
|
||||
accessControl,
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
|
||||
false,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,11 +29,11 @@ var (
|
||||
// Get all datasource connections -- this will be backed by search or duplicated resource in unified storage
|
||||
type DataSourceConnectionProvider interface {
|
||||
// Get gets a specific datasource (that the user in context can see)
|
||||
// The name is {group}:{name}, see /pkg/apis/query/v0alpha1/connection.go#L34
|
||||
// The name is the legacy datasource UID.
|
||||
GetConnection(ctx context.Context, namespace string, name string) (*queryV0.DataSourceConnection, error)
|
||||
|
||||
// List lists all data sources the user in context can see
|
||||
ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error)
|
||||
// List lists all data sources the user in context can see. Optional field selectors can filter the results.
|
||||
ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error)
|
||||
}
|
||||
|
||||
type connectionAccess struct {
|
||||
@@ -74,7 +75,11 @@ func (s *connectionAccess) Get(ctx context.Context, name string, options *metav1
|
||||
}
|
||||
|
||||
func (s *connectionAccess) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx))
|
||||
var fs fields.Selector
|
||||
if options != nil && options.FieldSelector != nil {
|
||||
fs = options.FieldSelector
|
||||
}
|
||||
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx), fs)
|
||||
}
|
||||
|
||||
type connectionsProvider struct {
|
||||
@@ -103,19 +108,47 @@ func (q *connectionsProvider) GetConnection(ctx context.Context, namespace strin
|
||||
return q.asConnection(ds, namespace)
|
||||
}
|
||||
|
||||
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error) {
|
||||
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error) {
|
||||
ns, err := authlib.ParseNamespace(namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dss, err := q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{
|
||||
OrgID: ns.OrgID,
|
||||
DataSourceLimit: 10000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var dss []*datasources.DataSource
|
||||
// if fieldSelector is not nil, find any uids in the metadata.name field and
|
||||
// use them in the query
|
||||
if fieldSelector != nil && !fieldSelector.Empty() {
|
||||
uids := []string{}
|
||||
for _, req := range fieldSelector.Requirements() {
|
||||
if req.Field == "metadata.name" {
|
||||
uids = append(uids, req.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// We don't have a way to fetch a subset of datasources by UID in the legacy
|
||||
// datasource service, so fetch them one by one.
|
||||
if len(uids) > 0 {
|
||||
for _, uid := range uids {
|
||||
ds, err := q.dsService.GetDataSource(ctx, &datasources.GetDataSourceQuery{
|
||||
UID: uid,
|
||||
OrgID: ns.OrgID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dss = append(dss, ds)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dss, err = q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{
|
||||
OrgID: ns.OrgID,
|
||||
DataSourceLimit: 10000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result := &queryV0.DataSourceConnectionList{
|
||||
Items: []queryV0.DataSourceConnection{},
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/datasourceproxy"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service"
|
||||
datasourcesclient "github.com/grafana/grafana/pkg/services/datasources/service/client"
|
||||
"github.com/grafana/grafana/pkg/services/dsquerierclient"
|
||||
"github.com/grafana/grafana/pkg/services/encryption"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
@@ -476,6 +477,7 @@ var wireBasicSet = wire.NewSet(
|
||||
appregistry.WireSet,
|
||||
// Dashboard Kubernetes helpers
|
||||
dashboardclient.ProvideK8sClientWithFallback,
|
||||
datasourcesclient.ProvideDataSourceConnectionClientFactory,
|
||||
)
|
||||
|
||||
var wireSet = wire.NewSet(
|
||||
|
||||
9
pkg/server/wire_gen.go
generated
9
pkg/server/wire_gen.go
generated
File diff suppressed because one or more lines are too long
147
pkg/services/datasources/service/client/client.go
Normal file
147
pkg/services/datasources/service/client/client.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
datasourcev0alpha1 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
queryv0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// DataSourceConnectionClient can get information about data source connections.
|
||||
//
|
||||
//go:generate mockery --name DataSourceConnectionClient --structname MockDataSourceConnectionClient --inpackage --filename=client_mock.go --with-expecter
|
||||
type DataSourceConnectionClient interface {
|
||||
GetByUID(ctx context.Context, uid string) (*queryv0alpha1.DataSourceConnection, error)
|
||||
}
|
||||
|
||||
func ProvideDataSourceConnectionClientFactory(
|
||||
restConfigProvider apiserver.RestConfigProvider,
|
||||
) DataSourceConnectionClientFactory {
|
||||
return func(configProvider apiserver.RestConfigProvider) DataSourceConnectionClient {
|
||||
return &dataSourceConnectionClient{
|
||||
configProvider: configProvider,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DataSourceConnectionClientFactory func(configProvider apiserver.RestConfigProvider) DataSourceConnectionClient
|
||||
|
||||
type dataSourceConnectionClient struct {
|
||||
configProvider apiserver.RestConfigProvider
|
||||
}
|
||||
|
||||
func (dc *dataSourceConnectionClient) Get(ctx context.Context, group, version, name string) (*queryv0alpha1.DataSourceConnection, error) {
|
||||
cfg, err := dc.configProvider.GetRestConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := kubernetes.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
version = "v0alpha1"
|
||||
}
|
||||
|
||||
result := client.RESTClient().Get().
|
||||
Prefix("apis", group, version).
|
||||
Namespace("default"). // TODO do something about namespace
|
||||
Resource("datasources").
|
||||
Name(name).
|
||||
Do(ctx)
|
||||
|
||||
if err = result.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var statusCode int
|
||||
|
||||
result = result.StatusCode(&statusCode)
|
||||
if statusCode == http.StatusNotFound {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
|
||||
fullDS := datasourcev0alpha1.DataSource{}
|
||||
err = result.Into(&fullDS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dsConnection := &queryv0alpha1.DataSourceConnection{
|
||||
Title: fullDS.Spec.Title(),
|
||||
Datasource: queryv0alpha1.DataSourceConnectionRef{
|
||||
Group: fullDS.GroupVersionKind().Group,
|
||||
Name: fullDS.ObjectMeta.Name,
|
||||
Version: fullDS.GroupVersionKind().Version,
|
||||
},
|
||||
}
|
||||
|
||||
return dsConnection, nil
|
||||
}
|
||||
|
||||
func (dc *dataSourceConnectionClient) GetByUID(ctx context.Context, uid string) (*queryv0alpha1.DataSourceConnection, error) {
|
||||
cfg, err := dc.configProvider.GetRestConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := kubernetes.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// use the list endpoint with a fieldSelector so that can get multiple results
|
||||
// in the case of a non-unique "uid". This should not be possible when we are
|
||||
// backed by the legacy database, but wont be guaranteed when we are using
|
||||
// uniStore as the names will not be guaranteed unique across apiGroups. We
|
||||
// error below if more than one result is returned.
|
||||
result := client.RESTClient().Get().
|
||||
Prefix("apis", "query.grafana.app", "v0alpha1").
|
||||
Namespace("default"). // TODO do something about namespace
|
||||
Resource("connections").
|
||||
Param("fieldSelector", "metadata.name="+uid).
|
||||
Do(ctx)
|
||||
|
||||
if err = result.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var statusCode int
|
||||
|
||||
result = result.StatusCode(&statusCode)
|
||||
if statusCode == http.StatusNotFound {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
|
||||
dsList := datasourcev0alpha1.DataSourceList{}
|
||||
err = result.Into(&dsList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(dsList.Items) == 0 {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
|
||||
if len(dsList.Items) > 1 {
|
||||
return nil, errors.New("multiple connections found")
|
||||
}
|
||||
|
||||
fullDS := dsList.Items[0]
|
||||
dsConnection := &queryv0alpha1.DataSourceConnection{
|
||||
Title: fullDS.Spec.Title(),
|
||||
Datasource: queryv0alpha1.DataSourceConnectionRef{
|
||||
Group: fullDS.GroupVersionKind().Group,
|
||||
Name: fullDS.ObjectMeta.Name,
|
||||
Version: fullDS.GroupVersionKind().Version,
|
||||
},
|
||||
}
|
||||
|
||||
return dsConnection, nil
|
||||
}
|
||||
96
pkg/services/datasources/service/client/client_mock.go
Normal file
96
pkg/services/datasources/service/client/client_mock.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Code generated by mockery v2.53.3. DO NOT EDIT.
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockDataSourceConnectionClient is an autogenerated mock type for the DataSourceConnectionClient type
|
||||
type MockDataSourceConnectionClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockDataSourceConnectionClient_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockDataSourceConnectionClient) EXPECT() *MockDataSourceConnectionClient_Expecter {
|
||||
return &MockDataSourceConnectionClient_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// GetByUID provides a mock function with given fields: ctx, uid
|
||||
func (_m *MockDataSourceConnectionClient) GetByUID(ctx context.Context, uid string) (*v0alpha1.DataSourceConnection, error) {
|
||||
ret := _m.Called(ctx, uid)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetByUID")
|
||||
}
|
||||
|
||||
var r0 *v0alpha1.DataSourceConnection
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.DataSourceConnection, error)); ok {
|
||||
return rf(ctx, uid)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.DataSourceConnection); ok {
|
||||
r0 = rf(ctx, uid)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*v0alpha1.DataSourceConnection)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
r1 = rf(ctx, uid)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockDataSourceConnectionClient_GetByUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByUID'
|
||||
type MockDataSourceConnectionClient_GetByUID_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetByUID is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - uid string
|
||||
func (_e *MockDataSourceConnectionClient_Expecter) GetByUID(ctx interface{}, uid interface{}) *MockDataSourceConnectionClient_GetByUID_Call {
|
||||
return &MockDataSourceConnectionClient_GetByUID_Call{Call: _e.mock.On("GetByUID", ctx, uid)}
|
||||
}
|
||||
|
||||
func (_c *MockDataSourceConnectionClient_GetByUID_Call) Run(run func(ctx context.Context, uid string)) *MockDataSourceConnectionClient_GetByUID_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockDataSourceConnectionClient_GetByUID_Call) Return(_a0 *v0alpha1.DataSourceConnection, _a1 error) *MockDataSourceConnectionClient_GetByUID_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockDataSourceConnectionClient_GetByUID_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.DataSourceConnection, error)) *MockDataSourceConnectionClient_GetByUID_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockDataSourceConnectionClient creates a new instance of MockDataSourceConnectionClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockDataSourceConnectionClient(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockDataSourceConnectionClient {
|
||||
mock := &MockDataSourceConnectionClient{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -1884,14 +1884,6 @@ 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,7 +256,6 @@ 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,20 +1383,6 @@
|
||||
"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,
|
||||
|
||||
@@ -574,6 +574,15 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n
|
||||
Url: baseUrl + "/datasources",
|
||||
Children: []*navtree.NavLink{},
|
||||
})
|
||||
|
||||
// Stacks
|
||||
children = append(children, &navtree.NavLink{
|
||||
Id: "connections-stacks",
|
||||
Text: "Stacks",
|
||||
SubTitle: "Manage data source stacks for different environments",
|
||||
Url: baseUrl + "/stacks",
|
||||
Children: []*navtree.NavLink{},
|
||||
})
|
||||
}
|
||||
|
||||
if len(children) > 0 {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/util/osutil"
|
||||
)
|
||||
|
||||
// nolint:unused
|
||||
var migratedUnifiedResources = []string{
|
||||
//"playlists.playlist.grafana.app",
|
||||
"folders.folder.grafana.app",
|
||||
@@ -59,16 +58,14 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
|
||||
|
||||
// Set indexer config for unified storage
|
||||
section := cfg.Raw.Section("unified_storage")
|
||||
cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
|
||||
// TODO: Re-enable once migrations are ready and disabled on cloud
|
||||
//cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
|
||||
cfg.DisableDataMigrations = true
|
||||
if !cfg.DisableDataMigrations && cfg.getUnifiedStorageType() == "unified" {
|
||||
// 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
|
||||
cfg.enforceMigrationToUnifiedConfigs()
|
||||
} else {
|
||||
// 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.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)
|
||||
@@ -105,7 +102,6 @@ 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,7 +8,6 @@ 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"
|
||||
@@ -56,12 +55,8 @@ 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,33 +35,6 @@ 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]
|
||||
@@ -87,17 +60,6 @@ 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{}
|
||||
@@ -398,69 +360,3 @@ 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,7 +28,6 @@ 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
|
||||
@@ -70,7 +69,6 @@ func RunKVTest(t *testing.T, newKV NewKVFunc, opts *KVTestOptions) {
|
||||
{TestKVUnixTimestamp, runTestKVUnixTimestamp},
|
||||
{TestKVBatchGet, runTestKVBatchGet},
|
||||
{TestKVBatchDelete, runTestKVBatchDelete},
|
||||
{TestKVBatch, runTestKVBatch},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -803,259 +801,3 @@ 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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,6 +182,8 @@ export function getNavTitle(navId: string | undefined) {
|
||||
return t('nav.connections.title', 'Connections');
|
||||
case 'connections-add-new-connection':
|
||||
return t('nav.add-new-connections.title', 'Add new connection');
|
||||
case 'connections-stacks':
|
||||
return t('nav.stacks.title', 'Stacks');
|
||||
case 'standalone-plugin-page-/connections/collector':
|
||||
return t('nav.collector.title', 'Collector');
|
||||
case 'connections-datasources':
|
||||
|
||||
@@ -10,10 +10,13 @@ import { CacheFeatureHighlightPage } from './pages/CacheFeatureHighlightPage';
|
||||
import ConnectionsHomePage from './pages/ConnectionsHomePage';
|
||||
import { DataSourceDashboardsPage } from './pages/DataSourceDashboardsPage';
|
||||
import { DataSourceDetailsPage } from './pages/DataSourceDetailsPage';
|
||||
import { DataSourceStacksPage } from './pages/DataSourceStacksPage';
|
||||
import { DataSourcesListPage } from './pages/DataSourcesListPage';
|
||||
import { EditDataSourcePage } from './pages/EditDataSourcePage';
|
||||
import { EditStackPage } from './pages/EditStackPage';
|
||||
import { InsightsFeatureHighlightPage } from './pages/InsightsFeatureHighlightPage';
|
||||
import { NewDataSourcePage } from './pages/NewDataSourcePage';
|
||||
import { NewStackPage } from './pages/NewStackPage';
|
||||
import { PermissionsFeatureHighlightPage } from './pages/PermissionsFeatureHighlightPage';
|
||||
|
||||
function RedirectToAddNewConnection() {
|
||||
@@ -41,6 +44,9 @@ export default function Connections() {
|
||||
{/* The route paths need to be relative to the parent path (ROUTES.Base), so we need to remove that part */}
|
||||
<Route caseSensitive path={ROUTES.DataSources.replace(ROUTES.Base, '')} element={<DataSourcesListPage />} />
|
||||
<Route caseSensitive path={ROUTES.DataSourcesNew.replace(ROUTES.Base, '')} element={<NewDataSourcePage />} />
|
||||
<Route caseSensitive path={ROUTES.Stacks.replace(ROUTES.Base, '')} element={<DataSourceStacksPage />} />
|
||||
<Route caseSensitive path={ROUTES.StacksNew.replace(ROUTES.Base, '')} element={<NewStackPage />} />
|
||||
<Route caseSensitive path={ROUTES.StacksEdit.replace(ROUTES.Base, '')} element={<EditStackPage />} />
|
||||
<Route
|
||||
caseSensitive
|
||||
path={ROUTES.DataSourcesDetails.replace(ROUTES.Base, '')}
|
||||
|
||||
@@ -75,5 +75,11 @@ export function getOssCardData(): CardData[] {
|
||||
url: '/connections/datasources',
|
||||
icon: 'database',
|
||||
},
|
||||
{
|
||||
text: 'Stacks',
|
||||
subTitle: 'Manage your data source stacks',
|
||||
url: '/connections/stacks',
|
||||
icon: 'layers',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ export const ROUTES = {
|
||||
DataSourcesNew: `/${ROUTE_BASE_ID}/datasources/new`,
|
||||
DataSourcesEdit: `/${ROUTE_BASE_ID}/datasources/edit/:uid`,
|
||||
DataSourcesDashboards: `/${ROUTE_BASE_ID}/datasources/edit/:uid/dashboards`,
|
||||
// Stacks
|
||||
Stacks: `/${ROUTE_BASE_ID}/stacks`,
|
||||
StacksNew: `/${ROUTE_BASE_ID}/stacks/new`,
|
||||
StacksEdit: `/${ROUTE_BASE_ID}/stacks/edit/:uid`,
|
||||
|
||||
// Add new connection
|
||||
AddNewConnection: `/${ROUTE_BASE_ID}/add-new-connection`,
|
||||
|
||||
252
public/app/features/connections/pages/DataSourceStacksPage.tsx
Normal file
252
public/app/features/connections/pages/DataSourceStacksPage.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import {
|
||||
Card,
|
||||
EmptyState,
|
||||
FilterInput,
|
||||
IconButton,
|
||||
LinkButton,
|
||||
Spinner,
|
||||
Stack,
|
||||
TagList,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||
import { Resource, ResourceList, GroupVersionResource } from 'app/features/apiserver/types';
|
||||
|
||||
// Define the DataSourceStack spec type based on the backend Go types
|
||||
export interface DataSourceStackTemplateItem {
|
||||
group: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DataSourceStackModeItem {
|
||||
dataSourceRef: string;
|
||||
}
|
||||
|
||||
export interface DataSourceStackModeSpec {
|
||||
name: string;
|
||||
uid: string;
|
||||
definition: Record<string, DataSourceStackModeItem>;
|
||||
}
|
||||
|
||||
export interface DataSourceStackSpec {
|
||||
template: Record<string, DataSourceStackTemplateItem>;
|
||||
modes: DataSourceStackModeSpec[];
|
||||
}
|
||||
|
||||
// GroupVersionResource for datasourcestacks
|
||||
const datasourceStacksGVR: GroupVersionResource = {
|
||||
group: 'collections.grafana.app',
|
||||
version: 'v1alpha1',
|
||||
resource: 'datasourcestacks',
|
||||
};
|
||||
|
||||
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
|
||||
|
||||
export function DataSourceStacksPage() {
|
||||
const [stacks, setStacks] = useState<Array<Resource<DataSourceStackSpec>>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const fetchStacks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response: ResourceList<DataSourceStackSpec> = await datasourceStacksClient.list();
|
||||
setStacks(response.items);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch datasource stacks:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch datasource stacks');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStacks();
|
||||
}, [fetchStacks]);
|
||||
|
||||
const onDeleteStack = (stackName: string) => async () => {
|
||||
await datasourceStacksClient.delete(stackName, false);
|
||||
fetchStacks();
|
||||
};
|
||||
|
||||
// Filter stacks based on search query
|
||||
const filteredStacks = useMemo(() => {
|
||||
if (!searchQuery) {
|
||||
return stacks;
|
||||
}
|
||||
const query = searchQuery.toLowerCase();
|
||||
return stacks.filter((stack) => {
|
||||
const nameMatch = stack.metadata.name?.toLowerCase().includes(query);
|
||||
const templateMatch = Object.values(stack.spec.template).some(
|
||||
(template) => template.name.toLowerCase().includes(query) || template.group.toLowerCase().includes(query)
|
||||
);
|
||||
return nameMatch || templateMatch;
|
||||
});
|
||||
}, [stacks, searchQuery]);
|
||||
|
||||
const actions =
|
||||
stacks.length > 0 ? (
|
||||
<LinkButton variant="primary" icon="plus" href="/connections/stacks/new">
|
||||
<Trans i18nKey="connections.stacks-list-view.add-stack">Add stack</Trans>
|
||||
</LinkButton>
|
||||
) : undefined;
|
||||
|
||||
const pageNav = {
|
||||
text: t('connections.stacks-list-view.title', 'Data source stacks'),
|
||||
subTitle: t(
|
||||
'connections.stacks-list-view.subtitle',
|
||||
'Manage your data source stacks to group environments like dev, staging, and production'
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<Page navId="connections-stacks" pageNav={pageNav} actions={actions}>
|
||||
<Page.Contents>
|
||||
<DataSourceStacksListContent
|
||||
stacks={filteredStacks}
|
||||
loading={loading}
|
||||
error={error}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
onDeleteStack={onDeleteStack}
|
||||
styles={styles}
|
||||
/>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataSourceStacksListContentProps {
|
||||
stacks: Array<Resource<DataSourceStackSpec>>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
styles: ReturnType<typeof getStyles>;
|
||||
onDeleteStack: (stackName: string) => () => Promise<void>;
|
||||
}
|
||||
|
||||
function DataSourceStacksListContent({
|
||||
stacks,
|
||||
loading,
|
||||
error,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
styles,
|
||||
onDeleteStack,
|
||||
}: DataSourceStacksListContentProps) {
|
||||
if (loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
variant="not-found"
|
||||
message={t('connections.stacks-list-view.error', 'Failed to load data source stacks')}
|
||||
>
|
||||
<div>{error}</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
if (stacks.length === 0 && !searchQuery) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={t(
|
||||
'connections.stacks-list-view.empty.no-rules-created',
|
||||
"You haven't created any data source stacks yet"
|
||||
)}
|
||||
variant="call-to-action"
|
||||
>
|
||||
<div>
|
||||
<Trans i18nKey="connections.stacks-list-view.empty.description">
|
||||
Use data source stacks to group environments like dev, stg, and prod. Reference the stack in your query, and
|
||||
Grafana automatically selects the right data source for that environment.
|
||||
</Trans>
|
||||
</div>
|
||||
|
||||
<LinkButton variant="primary" icon="plus" size="lg" href="/connections/stacks/new">
|
||||
<Trans i18nKey="connections.stacks-list-view.empty.new-stack">New stack</Trans>
|
||||
</LinkButton>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
<div className={styles.searchContainer}>
|
||||
<FilterInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={t('connections.stacks-list-view.search-placeholder', 'Search by name or type')}
|
||||
/>
|
||||
</div>
|
||||
{stacks.length === 0 && searchQuery ? (
|
||||
<EmptyState
|
||||
variant="not-found"
|
||||
message={t('connections.stacks-list-view.no-results', 'No data source stacks found')}
|
||||
/>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{stacks.map((stack) => (
|
||||
<li key={stack.metadata.name}>
|
||||
<Card noMargin href={`/connections/stacks/edit/${stack.metadata.name}`}>
|
||||
<Card.Heading>{stack.metadata.name}</Card.Heading>
|
||||
<Card.Tags>
|
||||
<Stack direction="row" gap={2} alignItems="center">
|
||||
<TagList tags={getDatasourceList(stack.spec)} />
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
variant="destructive"
|
||||
aria-label={t('connections.stacks-list-view.delete-stack', 'Delete stack')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDeleteStack(stack.metadata.name)();
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card.Tags>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
searchContainer: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
maxWidth: '500px',
|
||||
}),
|
||||
list: css({
|
||||
listStyle: 'none',
|
||||
display: 'grid',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
||||
const getDatasourceList = (stack: DataSourceStackSpec): string[] => {
|
||||
return Array.from(
|
||||
// remove duplicates
|
||||
new Set(
|
||||
Object.values(stack.template).map((template) => {
|
||||
const match = template.group.match(/^grafana-(.+)-datasource$/);
|
||||
if (match && match[1]) {
|
||||
return match[1].charAt(0).toUpperCase() + match[1].slice(1);
|
||||
}
|
||||
return template.name.charAt(0).toUpperCase() + template.name.slice(1);
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
106
public/app/features/connections/pages/EditStackPage.tsx
Normal file
106
public/app/features/connections/pages/EditStackPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { EmptyState, Spinner } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||
import { Resource, GroupVersionResource } from 'app/features/apiserver/types';
|
||||
import {
|
||||
StackForm,
|
||||
transformStackSpecToFormValues,
|
||||
} from 'app/features/datasources/components/new-stack-form/StackForm';
|
||||
import { StackFormValues } from 'app/features/datasources/components/new-stack-form/types';
|
||||
|
||||
import { DataSourceStackSpec } from './DataSourceStacksPage';
|
||||
|
||||
const datasourceStacksGVR: GroupVersionResource = {
|
||||
group: 'collections.grafana.app',
|
||||
version: 'v1alpha1',
|
||||
resource: 'datasourcestacks',
|
||||
};
|
||||
|
||||
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
|
||||
|
||||
export function EditStackPage() {
|
||||
const { uid } = useParams<{ uid: string }>();
|
||||
const [stack, setStack] = useState<Resource<DataSourceStackSpec> | null>(null);
|
||||
const [formValues, setFormValues] = useState<StackFormValues | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStack = async () => {
|
||||
if (!uid) {
|
||||
setError('No stack UID provided');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await datasourceStacksClient.get(uid);
|
||||
setStack(response);
|
||||
|
||||
const values = transformStackSpecToFormValues(response.metadata.name || '', response.spec);
|
||||
setFormValues(values);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch datasource stack:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch datasource stack');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStack();
|
||||
}, [uid]);
|
||||
|
||||
const pageNav = {
|
||||
text: stack?.metadata.name
|
||||
? t('connections.edit-stack-page.title-with-name', 'Edit {{name}}', { name: stack.metadata.name })
|
||||
: t('connections.edit-stack-page.title', 'Edit Data Source Stack'),
|
||||
subTitle: t('connections.edit-stack-page.subtitle', 'Modify your data source stack configuration'),
|
||||
};
|
||||
|
||||
return (
|
||||
<Page navId="connections-stacks" pageNav={pageNav}>
|
||||
<Page.Contents>
|
||||
<EditStackContent loading={loading} error={error} formValues={formValues} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditStackContentProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
formValues: StackFormValues | null;
|
||||
}
|
||||
|
||||
function EditStackContent({ loading, error, formValues }: EditStackContentProps) {
|
||||
if (loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
variant="not-found"
|
||||
message={t('connections.edit-stack-page.error', 'Failed to load data source stack')}
|
||||
>
|
||||
<div>{error}</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
if (!formValues) {
|
||||
return (
|
||||
<EmptyState
|
||||
variant="not-found"
|
||||
message={t('connections.edit-stack-page.not-found', 'Data source stack not found')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <StackForm existing={formValues} />;
|
||||
}
|
||||
19
public/app/features/connections/pages/NewStackPage.tsx
Normal file
19
public/app/features/connections/pages/NewStackPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { StackForm } from 'app/features/datasources/components/new-stack-form/StackForm';
|
||||
|
||||
export function NewStackPage() {
|
||||
return (
|
||||
<Page
|
||||
navId="connections-stacks"
|
||||
pageNav={{
|
||||
text: 'New Data Source Stack',
|
||||
subTitle: 'Add a new data source stack',
|
||||
active: true,
|
||||
}}
|
||||
>
|
||||
<Page.Contents>
|
||||
<StackForm />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ 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,
|
||||
@@ -120,7 +121,8 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
||||
dataObject.subscribeToState(async () => {
|
||||
const { data } = dataObject.state;
|
||||
if (hasData(data) && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) {
|
||||
const suggestions = await getAllSuggestions(data);
|
||||
const panelModel = new PanelModelCompatibilityWrapper(panel);
|
||||
const suggestions = await getAllSuggestions(data, panelModel);
|
||||
|
||||
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.fixedControls}>
|
||||
<div className={styles.timeControls}>
|
||||
<timePicker.Component model={timePicker} />
|
||||
<refreshPicker.Component model={refreshPicker} />
|
||||
</div>
|
||||
@@ -181,11 +181,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
|
||||
<DashboardControlsButton dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
{config.featureToggles.dashboardNewLayouts && (
|
||||
<div className={styles.fixedControls}>
|
||||
<DashboardControlActions dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
{config.featureToggles.dashboardNewLayouts && <DashboardControlActions dashboard={dashboard} />}
|
||||
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} dashboard={dashboard} />}
|
||||
</div>
|
||||
{!hideVariableControls && (
|
||||
@@ -278,12 +274,12 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
float: 'right',
|
||||
alignItems: 'flex-start',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
}),
|
||||
fixedControls: css({
|
||||
timeControls: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: theme.spacing(1),
|
||||
|
||||
@@ -849,6 +849,7 @@ describe('DashboardSceneSerializer', () => {
|
||||
query: 'app1',
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -294,6 +294,7 @@ exports[`Given a scene with custom quick ranges should save quick ranges to save
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -679,6 +680,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
||||
"options": [],
|
||||
"query": "A,B,C,D,E,F,E,G,H,I,J,K,L",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -697,6 +699,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
||||
"options": [],
|
||||
"query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1019,6 +1022,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -1378,6 +1382,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
|
||||
@@ -197,6 +197,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
"options": [],
|
||||
"query": "option1, option2",
|
||||
"skipUrlSync": false,
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -374,6 +374,7 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
"options": [],
|
||||
"query": "test,test1,test2",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
}
|
||||
`);
|
||||
});
|
||||
@@ -1148,6 +1149,7 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
"options": [],
|
||||
"query": "test,test1,test2",
|
||||
"skipUrlSync": false,
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -110,6 +110,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
|
||||
allValue: variable.state.allValue,
|
||||
includeAll: variable.state.includeAll,
|
||||
allowCustomValue: variable.state.allowCustomValue,
|
||||
valuesFormat: variable.state.valuesFormat,
|
||||
});
|
||||
} else if (sceneUtils.isDataSourceVariable(variable)) {
|
||||
variables.push({
|
||||
@@ -392,6 +393,7 @@ export function sceneVariablesSetToSchemaV2Variables(
|
||||
allValue: variable.state.allValue,
|
||||
includeAll: variable.state.includeAll ?? false,
|
||||
allowCustomValue: variable.state.allowCustomValue ?? true,
|
||||
valuesFormat: variable.state.valuesFormat,
|
||||
},
|
||||
};
|
||||
variables.push(customVariable);
|
||||
|
||||
@@ -335,12 +335,12 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (variable.kind === defaultCustomVariableKind().kind) {
|
||||
return new CustomVariable({
|
||||
...commonProperties,
|
||||
value: variable.spec.current?.value ?? '',
|
||||
text: variable.spec.current?.text ?? '',
|
||||
|
||||
query: variable.spec.query,
|
||||
isMulti: variable.spec.multi,
|
||||
allValue: variable.spec.allValue || undefined,
|
||||
@@ -348,6 +348,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
|
||||
defaultToAll: Boolean(variable.spec.includeAll),
|
||||
skipUrlSync: variable.spec.skipUrlSync,
|
||||
hide: transformVariableHideToEnumV1(variable.spec.hide),
|
||||
valuesFormat: variable.spec.valuesFormat || 'csv',
|
||||
});
|
||||
} else if (variable.kind === defaultQueryVariableKind().kind) {
|
||||
return new QueryVariable({
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { SceneVariable } from '@grafana/scenes';
|
||||
import { VariableHide, defaultVariableModel } from '@grafana/schema';
|
||||
import { Button, LoadingPlaceholder, ConfirmModal, ModalsController, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { Button, ConfirmModal, LoadingPlaceholder, ModalsController, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { VariableHideSelect } from 'app/features/dashboard-scene/settings/variables/components/VariableHideSelect';
|
||||
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
|
||||
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField';
|
||||
@@ -68,6 +68,9 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
|
||||
const onHideChange = (hide: VariableHide) => variable.setState({ hide });
|
||||
|
||||
const isHasVariableOptions = hasVariableOptions(variable);
|
||||
const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : [];
|
||||
const hasJsonValuesFormat = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json';
|
||||
const hasMultiProps = hasJsonValuesFormat || optionsForSelect.every((o) => Boolean(o.properties));
|
||||
|
||||
const onDeleteVariable = (hideModal: () => void) => () => {
|
||||
reportInteraction('Delete variable');
|
||||
@@ -123,7 +126,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
|
||||
|
||||
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
|
||||
|
||||
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
|
||||
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<Stack gap={2}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
@@ -130,4 +131,71 @@ describe('CustomVariableForm', () => {
|
||||
expect(onMultiChange).not.toHaveBeenCalled();
|
||||
expect(onIncludeAllChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('JSON values format', () => {
|
||||
test('should render the form fields correctly', async () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<CustomVariableForm
|
||||
query="query"
|
||||
valuesFormat="json"
|
||||
multi={true}
|
||||
allowCustomValue={true}
|
||||
includeAll={true}
|
||||
allValue="custom value"
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Object values in a JSON array'));
|
||||
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
);
|
||||
const allowCustomValueCheckbox = queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
);
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
);
|
||||
const allValueInput = queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
);
|
||||
|
||||
expect(multiCheckbox).toBeInTheDocument();
|
||||
expect(multiCheckbox).toBeChecked();
|
||||
expect(includeAllCheckbox).toBeInTheDocument();
|
||||
expect(includeAllCheckbox).toBeChecked();
|
||||
|
||||
expect(allowCustomValueCheckbox).not.toBeInTheDocument();
|
||||
expect(allValueInput).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display validation error', async () => {
|
||||
const validationError = new Error('Ooops! Validation error.');
|
||||
|
||||
const { findByText } = render(
|
||||
<CustomVariableForm
|
||||
query="query"
|
||||
valuesFormat="json"
|
||||
queryValidationError={validationError}
|
||||
multi={false}
|
||||
includeAll={false}
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Object values in a JSON array'));
|
||||
|
||||
const errorEl = await findByText(validationError.message);
|
||||
expect(errorEl).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
import { CustomVariableModel } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { SelectionOptionsForm } from './SelectionOptionsForm';
|
||||
import { VariableLegend } from './VariableLegend';
|
||||
@@ -9,10 +11,12 @@ import { VariableTextAreaField } from './VariableTextAreaField';
|
||||
|
||||
interface CustomVariableFormProps {
|
||||
query: string;
|
||||
valuesFormat?: CustomVariableModel['valuesFormat'];
|
||||
multi: boolean;
|
||||
allValue?: string | null;
|
||||
includeAll: boolean;
|
||||
allowCustomValue?: boolean;
|
||||
queryValidationError?: Error;
|
||||
onQueryChange: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
@@ -20,19 +24,23 @@ interface CustomVariableFormProps {
|
||||
onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
|
||||
}
|
||||
|
||||
export function CustomVariableForm({
|
||||
query,
|
||||
valuesFormat,
|
||||
multi,
|
||||
allValue,
|
||||
includeAll,
|
||||
allowCustomValue,
|
||||
queryValidationError,
|
||||
onQueryChange,
|
||||
onMultiChange,
|
||||
onIncludeAllChange,
|
||||
onAllValueChange,
|
||||
onAllowCustomValueChange,
|
||||
onValuesFormatChange,
|
||||
}: CustomVariableFormProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -40,16 +48,27 @@ export function CustomVariableForm({
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
|
||||
</VariableLegend>
|
||||
|
||||
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
|
||||
|
||||
<VariableTextAreaField
|
||||
name={t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma')}
|
||||
// we don't use a controlled component so we make sure the textarea content is cleared when changing format by providing a key
|
||||
key={valuesFormat}
|
||||
name=""
|
||||
placeholder={
|
||||
valuesFormat === 'json'
|
||||
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
|
||||
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'1, 10, mykey : myvalue, myvalue, escaped\,value'
|
||||
}
|
||||
defaultValue={query}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="1, 10, mykey : myvalue, myvalue, escaped\,value"
|
||||
onBlur={onQueryChange}
|
||||
required
|
||||
width={52}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
|
||||
/>
|
||||
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
|
||||
|
||||
<VariableLegend>
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.selection-options">Selection options</Trans>
|
||||
</VariableLegend>
|
||||
@@ -58,6 +77,8 @@ export function CustomVariableForm({
|
||||
includeAll={includeAll}
|
||||
allValue={allValue}
|
||||
allowCustomValue={allowCustomValue}
|
||||
disableAllowCustomValue={valuesFormat === 'json'}
|
||||
disableCustomAllValue={valuesFormat === 'json'}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
@@ -66,3 +87,48 @@ export function CustomVariableForm({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValuesFormatSelectorProps {
|
||||
valuesFormat?: CustomVariableModel['valuesFormat'];
|
||||
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
|
||||
}
|
||||
|
||||
export function ValuesFormatSelector({ valuesFormat, onValuesFormatChange }: ValuesFormatSelectorProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={1}>
|
||||
<RadioButtonGroup
|
||||
value={valuesFormat}
|
||||
onChange={onValuesFormatChange}
|
||||
options={[
|
||||
{
|
||||
value: 'csv',
|
||||
label: t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma'),
|
||||
},
|
||||
{
|
||||
value: 'json',
|
||||
label: t('dashboard-scene.custom-variable-form.name-json-values', 'Object values in a JSON array'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{valuesFormat === 'json' && (
|
||||
<Tooltip
|
||||
content={
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.json-values-tooltip">
|
||||
Provide a JSON representing an array of objects, where each object can have any number of properties.
|
||||
<br />
|
||||
Check{' '}
|
||||
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
|
||||
our docs
|
||||
</TextLink>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
}
|
||||
placement="top"
|
||||
interactive
|
||||
>
|
||||
<Icon name="info-circle" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { QueryVariable } from '@grafana/scenes';
|
||||
import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema';
|
||||
import { Field, TextLink } from '@grafana/ui';
|
||||
import { Box, Field, TextLink } from '@grafana/ui';
|
||||
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
|
||||
import { SelectionOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
@@ -15,9 +15,9 @@ import { getVariableQueryEditor } from 'app/features/variables/editor/getVariabl
|
||||
import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect';
|
||||
import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect';
|
||||
import {
|
||||
QueryVariableStaticOptions,
|
||||
StaticOptionsOrderType,
|
||||
StaticOptionsType,
|
||||
QueryVariableStaticOptions,
|
||||
} from 'app/features/variables/query/QueryVariableStaticOptions';
|
||||
|
||||
import { VariableLegend } from './VariableLegend';
|
||||
@@ -34,6 +34,7 @@ interface QueryVariableEditorFormProps {
|
||||
timeRange: TimeRange;
|
||||
regex: string | null;
|
||||
onRegExChange: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
disableRegexEdition?: boolean;
|
||||
sort: VariableSort;
|
||||
onSortChange: (option: SelectableValue<VariableSort>) => void;
|
||||
refresh: VariableRefresh;
|
||||
@@ -42,14 +43,17 @@ interface QueryVariableEditorFormProps {
|
||||
onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
allowCustomValue?: boolean;
|
||||
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
disableAllowCustomValue?: boolean;
|
||||
includeAll: boolean;
|
||||
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
allValue: string;
|
||||
onAllValueChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
disableCustomAllValue?: boolean;
|
||||
staticOptions?: StaticOptionsType;
|
||||
staticOptionsOrder?: StaticOptionsOrderType;
|
||||
onStaticOptionsChange?: (staticOptions: StaticOptionsType) => void;
|
||||
onStaticOptionsOrderChange?: (staticOptionsOrder: StaticOptionsOrderType) => void;
|
||||
disableStaticOptions?: boolean;
|
||||
}
|
||||
|
||||
export function QueryVariableEditorForm({
|
||||
@@ -61,6 +65,7 @@ export function QueryVariableEditorForm({
|
||||
timeRange,
|
||||
regex,
|
||||
onRegExChange,
|
||||
disableRegexEdition,
|
||||
sort,
|
||||
onSortChange,
|
||||
refresh,
|
||||
@@ -69,14 +74,17 @@ export function QueryVariableEditorForm({
|
||||
onMultiChange,
|
||||
allowCustomValue,
|
||||
onAllowCustomValueChange,
|
||||
disableAllowCustomValue,
|
||||
includeAll,
|
||||
onIncludeAllChange,
|
||||
allValue,
|
||||
onAllValueChange,
|
||||
disableCustomAllValue,
|
||||
staticOptions,
|
||||
staticOptionsOrder,
|
||||
onStaticOptionsChange,
|
||||
onStaticOptionsOrderChange,
|
||||
disableStaticOptions,
|
||||
}: QueryVariableEditorFormProps) {
|
||||
const { value: dsConfig } = useAsync(async () => {
|
||||
const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
|
||||
@@ -116,48 +124,53 @@ export function QueryVariableEditorForm({
|
||||
<Field
|
||||
label={t('dashboard-scene.query-variable-editor-form.label-data-source', 'Data source')}
|
||||
htmlFor="data-source-picker"
|
||||
noMargin
|
||||
>
|
||||
<DataSourcePicker current={datasourceRef} onChange={datasourceChangeHandler} variables={true} width={30} />
|
||||
</Field>
|
||||
|
||||
{datasource && VariableQueryEditor && (
|
||||
<QueryEditor
|
||||
onQueryChange={onQueryChange}
|
||||
onLegacyQueryChange={onLegacyQueryChange}
|
||||
datasource={datasource}
|
||||
query={query}
|
||||
VariableQueryEditor={VariableQueryEditor}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
<Box marginBottom={2}>
|
||||
<QueryEditor
|
||||
onQueryChange={onQueryChange}
|
||||
onLegacyQueryChange={onLegacyQueryChange}
|
||||
datasource={datasource}
|
||||
query={query}
|
||||
VariableQueryEditor={VariableQueryEditor}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<VariableTextAreaField
|
||||
defaultValue={regex ?? ''}
|
||||
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
|
||||
description={
|
||||
<div>
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
|
||||
Optional, if you want to extract part of a series name or metric node segment.
|
||||
</Trans>
|
||||
<br />
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
|
||||
Named capture groups can be used to separate the display text and value (
|
||||
<TextLink
|
||||
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
|
||||
external
|
||||
>
|
||||
see examples
|
||||
</TextLink>
|
||||
).
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
|
||||
onBlur={onRegExChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
|
||||
width={52}
|
||||
/>
|
||||
{!disableRegexEdition && (
|
||||
<VariableTextAreaField
|
||||
defaultValue={regex ?? ''}
|
||||
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
|
||||
description={
|
||||
<div>
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
|
||||
Optional, if you want to extract part of a series name or metric node segment.
|
||||
</Trans>
|
||||
<br />
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
|
||||
Named capture groups can be used to separate the display text and value (
|
||||
<TextLink
|
||||
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
|
||||
external
|
||||
>
|
||||
see examples
|
||||
</TextLink>
|
||||
).
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
|
||||
onBlur={onRegExChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
|
||||
width={52}
|
||||
/>
|
||||
)}
|
||||
|
||||
<QueryVariableSortSelect
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
|
||||
@@ -171,7 +184,7 @@ export function QueryVariableEditorForm({
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
{onStaticOptionsChange && onStaticOptionsOrderChange && (
|
||||
{!disableStaticOptions && onStaticOptionsChange && onStaticOptionsOrderChange && (
|
||||
<QueryVariableStaticOptions
|
||||
staticOptions={staticOptions}
|
||||
staticOptionsOrder={staticOptionsOrder}
|
||||
@@ -187,6 +200,8 @@ export function QueryVariableEditorForm({
|
||||
multi={!!isMulti}
|
||||
includeAll={!!includeAll}
|
||||
allowCustomValue={allowCustomValue}
|
||||
disableAllowCustomValue={disableAllowCustomValue}
|
||||
disableCustomAllValue={disableCustomAllValue}
|
||||
allValue={allValue}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
|
||||
@@ -10,7 +10,9 @@ interface SelectionOptionsFormProps {
|
||||
multi: boolean;
|
||||
includeAll: boolean;
|
||||
allowCustomValue?: boolean;
|
||||
disableAllowCustomValue?: boolean;
|
||||
allValue?: string | null;
|
||||
disableCustomAllValue?: boolean;
|
||||
onMultiChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onAllowCustomValueChange?: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onIncludeAllChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -20,8 +22,10 @@ interface SelectionOptionsFormProps {
|
||||
export function SelectionOptionsForm({
|
||||
multi,
|
||||
allowCustomValue,
|
||||
disableAllowCustomValue,
|
||||
includeAll,
|
||||
allValue,
|
||||
disableCustomAllValue,
|
||||
onMultiChange,
|
||||
onAllowCustomValueChange,
|
||||
onIncludeAllChange,
|
||||
@@ -39,18 +43,19 @@ export function SelectionOptionsForm({
|
||||
onChange={onMultiChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch}
|
||||
/>
|
||||
{onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
|
||||
<VariableCheckboxField
|
||||
value={allowCustomValue ?? true}
|
||||
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
|
||||
description={t(
|
||||
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
|
||||
'Enables users to add custom values to the list'
|
||||
)}
|
||||
onChange={onAllowCustomValueChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
|
||||
/>
|
||||
)}
|
||||
{!disableAllowCustomValue &&
|
||||
onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
|
||||
<VariableCheckboxField
|
||||
value={allowCustomValue ?? true}
|
||||
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
|
||||
description={t(
|
||||
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
|
||||
'Enables users to add custom values to the list'
|
||||
)}
|
||||
onChange={onAllowCustomValueChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
|
||||
/>
|
||||
)}
|
||||
<VariableCheckboxField
|
||||
value={includeAll}
|
||||
name={t('dashboard-scene.selection-options-form.name-include-all-option', 'Include All option')}
|
||||
@@ -61,7 +66,7 @@ export function SelectionOptionsForm({
|
||||
onChange={onIncludeAllChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch}
|
||||
/>
|
||||
{includeAll && (
|
||||
{!disableCustomAllValue && includeAll && (
|
||||
<VariableTextField
|
||||
defaultValue={allValue ?? ''}
|
||||
onBlur={onAllValueChange}
|
||||
|
||||
@@ -5,13 +5,50 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { VariableValueOption } from '@grafana/scenes';
|
||||
import { Button, InlineFieldRow, InlineLabel, useStyles2, Text } from '@grafana/ui';
|
||||
import { Button, InlineFieldRow, InlineLabel, InteractiveTable, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface VariableValuesPreviewProps {
|
||||
export interface Props {
|
||||
options: VariableValueOption[];
|
||||
hasMultiProps?: boolean;
|
||||
}
|
||||
|
||||
export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) => {
|
||||
export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => {
|
||||
if (!options.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasMultiProps) {
|
||||
return <VariableValuesWithPropsPreview options={options} />;
|
||||
}
|
||||
|
||||
return <VariableValuesWithoutPropsPreview options={options} />;
|
||||
};
|
||||
VariableValuesPreview.displayName = 'VariableValuesPreview';
|
||||
|
||||
function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const data = options.map((o) => ({ label: String(o.label), value: String(o.value), ...o.properties }));
|
||||
// the first item in data may be the "All" option, which does not have any extra properties, so we try the 2nd item to determine the column names
|
||||
const columns = Object.keys(data[1] || data[0]).map((id) => ({ id, header: id, sortType: 'alphanumeric' as const }));
|
||||
|
||||
return (
|
||||
<div className={styles.previewContainer} style={{ gap: '8px' }}>
|
||||
<Text variant="bodySmall" weight="medium">
|
||||
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
|
||||
</Text>
|
||||
<InteractiveTable
|
||||
className={styles.table}
|
||||
columns={columns}
|
||||
data={data}
|
||||
getRowId={(r) => String(r.value)}
|
||||
pageSize={8}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [previewLimit, setPreviewLimit] = useState(20);
|
||||
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);
|
||||
const showMoreOptions = useCallback(
|
||||
@@ -21,15 +58,10 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
|
||||
},
|
||||
[previewLimit, setPreviewLimit]
|
||||
);
|
||||
const styles = useStyles2(getStyles);
|
||||
useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]);
|
||||
|
||||
if (!previewOptions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', marginTop: '16px' }}>
|
||||
<div className={styles.previewContainer}>
|
||||
<Text variant="bodySmall" weight="medium">
|
||||
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
|
||||
</Text>
|
||||
@@ -51,12 +83,12 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
VariableValuesPreview.displayName = 'VariableValuesPreview';
|
||||
}
|
||||
VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview';
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
previewContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: theme.spacing(2),
|
||||
@@ -71,5 +103,10 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '50vw',
|
||||
}),
|
||||
table: css({
|
||||
td: css({
|
||||
padding: theme.spacing(0.5, 1),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,117 +5,225 @@ import { CustomVariable } from '@grafana/scenes';
|
||||
|
||||
import { CustomVariableEditor } from './CustomVariableEditor';
|
||||
|
||||
function setup(options: Partial<ConstructorParameters<typeof CustomVariable>[0]> = {}) {
|
||||
return {
|
||||
variable: new CustomVariable({
|
||||
name: 'customVar',
|
||||
...options,
|
||||
}),
|
||||
onRunQuery: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderEditor(ui: React.ReactNode) {
|
||||
const renderResult = render(ui);
|
||||
|
||||
const elements = {
|
||||
formatButton: (label: string) => renderResult.queryByLabelText(label) as HTMLElement,
|
||||
queryInput: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
|
||||
) as HTMLTextAreaElement,
|
||||
multiValueCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
) as HTMLInputElement,
|
||||
allowCustomValueCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
) as HTMLInputElement,
|
||||
includeAllCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
) as HTMLInputElement,
|
||||
customAllValueInput: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement,
|
||||
};
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
elements,
|
||||
actions: {
|
||||
updateValuesInput(newQuery: string) {
|
||||
fireEvent.change(elements.queryInput(), { target: { value: newQuery } });
|
||||
fireEvent.blur(elements.queryInput());
|
||||
},
|
||||
changeValuesFormat(newFormat: 'csv' | 'json') {
|
||||
const targetLabel = newFormat === 'json' ? 'Object values in a JSON array' : 'Values separated by comma';
|
||||
|
||||
const formatButton = elements.formatButton(targetLabel);
|
||||
if (formatButton === null) {
|
||||
throw new Error(`Unable to fire a "click" event - button with label "${targetLabel}" not found in DOM`);
|
||||
}
|
||||
|
||||
fireEvent.click(formatButton);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CustomVariableEditor', () => {
|
||||
it('should render the CustomVariableForm with correct initial values', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allValue: 'test',
|
||||
describe('CSV values format', () => {
|
||||
it('should render CustomVariableForm with the correct initial values', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allowCustomValue: true,
|
||||
allValue: 'all',
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.queryInput().value).toBe('test, test2');
|
||||
expect(elements.multiValueCheckbox().checked).toBe(true);
|
||||
expect(elements.allowCustomValueCheckbox().checked).toBe(true);
|
||||
expect(elements.includeAllCheckbox().checked).toBe(true);
|
||||
expect(elements.customAllValueInput().value).toBe('all');
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
it('should update the variable state when some input values change ("Multi-value", "Allow custom values" & "Include All option")', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: false,
|
||||
allowCustomValue: false,
|
||||
includeAll: false,
|
||||
});
|
||||
|
||||
const queryInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
|
||||
) as HTMLInputElement;
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement;
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
) as HTMLInputElement;
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
) as HTMLInputElement;
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(queryInput.value).toBe('test, test2');
|
||||
expect(allValueInput.value).toBe('test');
|
||||
expect(multiCheckbox.checked).toBe(true);
|
||||
expect(includeAllCheckbox.checked).toBe(true);
|
||||
expect(elements.multiValueCheckbox().checked).toBe(false);
|
||||
expect(elements.allowCustomValueCheckbox().checked).toBe(false);
|
||||
expect(elements.includeAllCheckbox().checked).toBe(false);
|
||||
// include-all-custom input appears after include-all checkbox is checked only
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(elements.multiValueCheckbox());
|
||||
fireEvent.click(elements.allowCustomValueCheckbox());
|
||||
fireEvent.click(elements.includeAllCheckbox());
|
||||
|
||||
expect(variable.state.isMulti).toBe(true);
|
||||
expect(variable.state.allowCustomValue).toBe(true);
|
||||
expect(variable.state.includeAll).toBe(true);
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('when the values textarea loses focus after its value has changed', () => {
|
||||
it('should update the query in the variable state and call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ query: 'test, test2', value: 'test' });
|
||||
|
||||
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
actions.updateValuesInput('test3, test4');
|
||||
|
||||
expect(variable.state.query).toBe('test3, test4');
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the "Custom all value" input loses focus after its value has changed', () => {
|
||||
it('should update the variable state', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
fireEvent.change(elements.customAllValueInput(), { target: { value: 'new custom all' } });
|
||||
fireEvent.blur(elements.customAllValueInput());
|
||||
|
||||
expect(variable.state.allValue).toBe('new custom all');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the variable state when input values change', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
describe('JSON values format', () => {
|
||||
const initialJsonQuery = `[
|
||||
{"value":1,"text":"Development","aws":"dev","azure":"development"},
|
||||
{"value":2,"text":"Production","aws":"prod","azure":"production"}
|
||||
]`;
|
||||
|
||||
it('should render CustomVariableForm with the correct initial values', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
valuesFormat: 'json',
|
||||
query: initialJsonQuery,
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.queryInput().value).toBe(initialJsonQuery);
|
||||
expect(elements.multiValueCheckbox().checked).toBe(true);
|
||||
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
|
||||
expect(elements.includeAllCheckbox().checked).toBe(true);
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
describe('when the values textarea loses focus after its value has changed', () => {
|
||||
describe('if the value is valid JSON', () => {
|
||||
it('should update the query in the variable state and call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
|
||||
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
);
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
);
|
||||
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const allowCustomValueCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
);
|
||||
actions.updateValuesInput('[]');
|
||||
|
||||
// It include-all-custom input appears after include-all checkbox is checked only
|
||||
expect(() =>
|
||||
getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput)
|
||||
).toThrow('Unable to find an element');
|
||||
expect(variable.state.query).toBe('[]');
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(allowCustomValueCheckbox);
|
||||
describe('if the value is NOT valid JSON', () => {
|
||||
it('should display a validation error message and neither update the query in the variable state nor call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
|
||||
|
||||
fireEvent.click(multiCheckbox);
|
||||
const { actions, getByRole } = renderEditor(
|
||||
<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />
|
||||
);
|
||||
|
||||
fireEvent.click(includeAllCheckbox);
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
);
|
||||
actions.updateValuesInput('[x]');
|
||||
|
||||
expect(variable.state.isMulti).toBe(true);
|
||||
expect(variable.state.includeAll).toBe(true);
|
||||
expect(variable.state.allowCustomValue).toBe(false);
|
||||
expect(allValueInput).toBeInTheDocument();
|
||||
expect(getByRole('alert')).toHaveTextContent(`Unexpected token 'x', "[x]" is not valid JSON`);
|
||||
expect(variable.state.query).toBe(initialJsonQuery);
|
||||
expect(onRunQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call update query and re-run query when input loses focus', async () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
describe('when switching values format', () => {
|
||||
it('should switch the visibility of the proper form inputs ("Allow custom values" and "Custom all value")', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
valuesFormat: 'csv',
|
||||
query: '',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allowCustomValue: true,
|
||||
allValue: '',
|
||||
});
|
||||
|
||||
const { elements, actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
|
||||
actions.changeValuesFormat('json');
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
|
||||
actions.changeValuesFormat('csv');
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput);
|
||||
fireEvent.change(queryInput, { target: { value: 'test3, test4' } });
|
||||
fireEvent.blur(queryInput);
|
||||
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
expect(variable.state.query).toBe('test3, test4');
|
||||
});
|
||||
|
||||
it('should update the variable state when all-custom-value input loses focus', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(allValueInput, { target: { value: 'new custom all' } });
|
||||
fireEvent.blur(allValueInput);
|
||||
|
||||
expect(variable.state.allValue).toBe('new custom all');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FormEvent, useCallback } from 'react';
|
||||
import { isObject } from 'lodash';
|
||||
import { FormEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { CustomVariableModel, shallowCompare } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { CustomVariable, SceneVariable } from '@grafana/scenes';
|
||||
|
||||
@@ -14,7 +16,26 @@ interface CustomVariableEditorProps {
|
||||
}
|
||||
|
||||
export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) {
|
||||
const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
|
||||
const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
|
||||
const [queryValidationError, setQueryValidationError] = useState<Error>();
|
||||
|
||||
const [prevQuery, setPrevQuery] = useState('');
|
||||
const onValuesFormatChange = useCallback(
|
||||
(format: CustomVariableModel['valuesFormat']) => {
|
||||
variable.setState({ query: prevQuery });
|
||||
variable.setState({ value: isMulti ? [] : undefined });
|
||||
variable.setState({ valuesFormat: format });
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: undefined });
|
||||
onRunQuery();
|
||||
|
||||
setQueryValidationError(undefined);
|
||||
if (query !== prevQuery) {
|
||||
setPrevQuery(query);
|
||||
}
|
||||
},
|
||||
[isMulti, onRunQuery, prevQuery, query, variable]
|
||||
);
|
||||
|
||||
const onMultiChange = useCallback(
|
||||
(event: FormEvent<HTMLInputElement>) => {
|
||||
@@ -32,10 +53,20 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
(event: FormEvent<HTMLTextAreaElement>) => {
|
||||
setPrevQuery('');
|
||||
|
||||
if (valuesFormat === 'json') {
|
||||
const validationError = validateJsonQuery(event.currentTarget.value);
|
||||
setQueryValidationError(validationError);
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
variable.setState({ query: event.currentTarget.value });
|
||||
onRunQuery();
|
||||
},
|
||||
[variable, onRunQuery]
|
||||
[valuesFormat, variable, onRunQuery]
|
||||
);
|
||||
|
||||
const onAllValueChange = useCallback(
|
||||
@@ -55,15 +86,18 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
|
||||
return (
|
||||
<CustomVariableForm
|
||||
query={query ?? ''}
|
||||
valuesFormat={valuesFormat ?? 'csv'}
|
||||
multi={!!isMulti}
|
||||
allValue={allValue ?? ''}
|
||||
includeAll={!!includeAll}
|
||||
allowCustomValue={allowCustomValue}
|
||||
queryValidationError={queryValidationError}
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onQueryChange={onQueryChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
onValuesFormatChange={onValuesFormatChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -81,3 +115,47 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export const validateJsonQuery = (rawQuery: string): Error | undefined => {
|
||||
const query = rawQuery.trim();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = JSON.parse(query);
|
||||
|
||||
if (!Array.isArray(options)) {
|
||||
throw new Error('Enter a valid JSON array of objects');
|
||||
}
|
||||
|
||||
if (!options.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let errorIndex = options.findIndex((item) => !isObject(item));
|
||||
if (errorIndex !== -1) {
|
||||
throw new Error(`All items must be objects. The item at index ${errorIndex} is not an object.`);
|
||||
}
|
||||
|
||||
const keys = Object.keys(options[0]);
|
||||
if (!keys.includes('value')) {
|
||||
throw new Error('Each object in the array must include at least a "value" property');
|
||||
}
|
||||
if (keys.includes('')) {
|
||||
throw new Error('Object property names cannot be empty strings');
|
||||
}
|
||||
|
||||
errorIndex = options.findIndex((o) => !shallowCompare(keys, Object.keys(o)));
|
||||
if (errorIndex !== -1) {
|
||||
throw new Error(
|
||||
`All objects must have the same set of properties. The object at index ${errorIndex} does not match the expected properties`
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return error as Error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { FormEvent, useCallback, useState } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { CustomVariableModel } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { CustomVariable } from '@grafana/scenes';
|
||||
import { Button, Modal, Stack } from '@grafana/ui';
|
||||
import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui';
|
||||
|
||||
import { VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
|
||||
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
|
||||
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
|
||||
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
|
||||
|
||||
import { ValuesBuilder } from './ValuesBuilder';
|
||||
import { ValuesPreview } from './ValuesPreview';
|
||||
import { validateJsonQuery } from './CustomVariableEditor';
|
||||
|
||||
interface ModalEditorProps {
|
||||
variable: CustomVariable;
|
||||
@@ -18,9 +19,49 @@ interface ModalEditorProps {
|
||||
}
|
||||
|
||||
export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
|
||||
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
|
||||
const { query, valuesFormat, isMulti } = variable.useState();
|
||||
const [prevQuery, setPrevQuery] = useState('');
|
||||
const [queryValidationError, setQueryValidationError] = useState<Error>();
|
||||
|
||||
const handleOnAdd = useCallback(() => formRef.current?.addItem(), []);
|
||||
const onValuesFormatChange = useCallback(
|
||||
async (format: CustomVariableModel['valuesFormat']) => {
|
||||
variable.setState({ query: prevQuery });
|
||||
variable.setState({ value: isMulti ? [] : undefined });
|
||||
variable.setState({ valuesFormat: format });
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: undefined });
|
||||
|
||||
await lastValueFrom(variable.validateAndUpdate());
|
||||
|
||||
setQueryValidationError(undefined);
|
||||
if (query !== prevQuery) {
|
||||
setPrevQuery(query);
|
||||
}
|
||||
},
|
||||
[isMulti, prevQuery, query, variable]
|
||||
);
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
async (event: FormEvent<HTMLTextAreaElement>) => {
|
||||
setPrevQuery('');
|
||||
|
||||
if (valuesFormat === 'json') {
|
||||
const validationError = validateJsonQuery(event.currentTarget.value);
|
||||
setQueryValidationError(validationError);
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
variable.setState({ query: event.currentTarget.value });
|
||||
await lastValueFrom(variable.validateAndUpdate());
|
||||
},
|
||||
[valuesFormat, variable]
|
||||
);
|
||||
|
||||
const optionsForSelect = variable.getOptionsForSelect(false);
|
||||
const hasJsonValuesFormat = variable.state.valuesFormat === 'json';
|
||||
const hasMultiProps = hasJsonValuesFormat || optionsForSelect.every((o) => Boolean(o.properties));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -29,10 +70,31 @@ export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
|
||||
onDismiss={onClose}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
<ValuesBuilder variable={variable} ref={formRef} />
|
||||
<ValuesPreview variable={variable} />
|
||||
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
|
||||
<div>
|
||||
<TextArea
|
||||
id={valuesFormat}
|
||||
key={valuesFormat}
|
||||
rows={4}
|
||||
defaultValue={query}
|
||||
onBlur={onQueryChange}
|
||||
placeholder={
|
||||
valuesFormat === 'json'
|
||||
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
|
||||
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'1, 10, mykey : myvalue, myvalue, escaped\,value'
|
||||
}
|
||||
required
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
|
||||
/>
|
||||
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
|
||||
</div>
|
||||
<div>
|
||||
<VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />
|
||||
</div>
|
||||
</Stack>
|
||||
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={handleOnAdd} />}>
|
||||
<Modal.ButtonRow>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from '@grafana/i18n';
|
||||
import { CustomVariable, SceneVariable } from '@grafana/scenes';
|
||||
|
||||
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
@@ -12,7 +11,6 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
|
||||
|
||||
return [
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
|
||||
id: 'custom-variable-values',
|
||||
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useState, FormEvent, useMemo, useEffect } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { SelectableValue, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data';
|
||||
@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes';
|
||||
import { VariableRefresh, VariableSort } from '@grafana/schema';
|
||||
import { Box, Button, Field, Modal, TextLink } from '@grafana/ui';
|
||||
import { Box, Button, Field, Modal, Switch, TextLink } from '@grafana/ui';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
@@ -44,6 +44,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
|
||||
allowCustomValue,
|
||||
staticOptions,
|
||||
staticOptionsOrder,
|
||||
options,
|
||||
} = variable.useState();
|
||||
const { value: timeRange } = sceneGraph.getTimeRange(variable).useState();
|
||||
|
||||
@@ -93,6 +94,17 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
|
||||
variable.setState({ staticOptionsOrder });
|
||||
};
|
||||
|
||||
const hasMultiProps = useMemo(() => options.every((o) => Boolean(o.properties)), [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMultiProps) {
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: '' });
|
||||
variable.setState({ regex: '' });
|
||||
variable.setState({ staticOptions: [] });
|
||||
}
|
||||
}, [hasMultiProps, variable]);
|
||||
|
||||
return (
|
||||
<QueryVariableEditorForm
|
||||
datasource={datasource ?? undefined}
|
||||
@@ -103,6 +115,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
|
||||
timeRange={timeRange}
|
||||
regex={regex}
|
||||
onRegExChange={onRegExChange}
|
||||
disableRegexEdition={hasMultiProps}
|
||||
sort={sort}
|
||||
onSortChange={onSortChange}
|
||||
refresh={refresh}
|
||||
@@ -113,12 +126,15 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
allValue={allValue ?? ''}
|
||||
onAllValueChange={onAllValueChange}
|
||||
disableCustomAllValue={hasMultiProps}
|
||||
allowCustomValue={allowCustomValue}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
disableAllowCustomValue={hasMultiProps}
|
||||
staticOptions={staticOptions}
|
||||
staticOptionsOrder={staticOptionsOrder}
|
||||
onStaticOptionsChange={onStaticOptionsChange}
|
||||
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
|
||||
disableStaticOptions={hasMultiProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -250,6 +266,19 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||
|
||||
const isHasVariableOptions = hasVariableOptions(variable);
|
||||
|
||||
// TODO: remove me after finished testing - each DS can/should implement their own UI
|
||||
const [returnsMultiProps, setReturnsMultiProps] = useState(false);
|
||||
const onChangeReturnsMultipleProps = (e: FormEvent<HTMLInputElement>) => {
|
||||
setReturnsMultiProps(e.currentTarget.checked);
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: '' });
|
||||
variable.setState({ regex: '' });
|
||||
onStaticOptionsChange?.([]);
|
||||
};
|
||||
|
||||
const optionsForSelect = variable.getOptionsForSelect(false);
|
||||
const hasMultiProps = returnsMultiProps || optionsForSelect.every((o) => Boolean(o.properties));
|
||||
|
||||
return (
|
||||
<div data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.editor}>
|
||||
<Field
|
||||
@@ -261,43 +290,64 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||
</Field>
|
||||
|
||||
{selectedDatasource && VariableQueryEditor && (
|
||||
<QueryEditor
|
||||
onQueryChange={onQueryChange}
|
||||
onLegacyQueryChange={onQueryChange}
|
||||
datasource={selectedDatasource}
|
||||
query={query}
|
||||
VariableQueryEditor={VariableQueryEditor}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
<Box marginBottom={2}>
|
||||
<QueryEditor
|
||||
onQueryChange={onQueryChange}
|
||||
onLegacyQueryChange={onQueryChange}
|
||||
datasource={selectedDatasource}
|
||||
query={query}
|
||||
VariableQueryEditor={VariableQueryEditor}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
{/* TODO: remove me after finished testing - each DS can/should implement their own UI */}
|
||||
<Field
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
label="Enable access to all the fields of the query results"
|
||||
description={
|
||||
<Trans i18nKey="">
|
||||
Check{' '}
|
||||
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
|
||||
our docs
|
||||
</TextLink>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
}
|
||||
noMargin
|
||||
>
|
||||
<Switch onChange={onChangeReturnsMultipleProps} />
|
||||
</Field>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<VariableTextAreaField
|
||||
defaultValue={regex ?? ''}
|
||||
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
|
||||
description={
|
||||
<div>
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
|
||||
Optional, if you want to extract part of a series name or metric node segment.
|
||||
</Trans>
|
||||
<br />
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
|
||||
Named capture groups can be used to separate the display text and value (
|
||||
<TextLink
|
||||
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
|
||||
external
|
||||
>
|
||||
see examples
|
||||
</TextLink>
|
||||
).
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
|
||||
onBlur={onRegExChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
|
||||
width={52}
|
||||
/>
|
||||
{!returnsMultiProps && (
|
||||
<VariableTextAreaField
|
||||
defaultValue={regex ?? ''}
|
||||
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
|
||||
description={
|
||||
<div>
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
|
||||
Optional, if you want to extract part of a series name or metric node segment.
|
||||
</Trans>
|
||||
<br />
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
|
||||
Named capture groups can be used to separate the display text and value (
|
||||
<TextLink
|
||||
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
|
||||
external
|
||||
>
|
||||
see examples
|
||||
</TextLink>
|
||||
).
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
|
||||
onBlur={onRegExChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
|
||||
width={52}
|
||||
/>
|
||||
)}
|
||||
|
||||
<QueryVariableSortSelect
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
|
||||
@@ -311,7 +361,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
{onStaticOptionsChange && onStaticOptionsOrderChange && (
|
||||
{!returnsMultiProps && onStaticOptionsChange && onStaticOptionsOrderChange && (
|
||||
<QueryVariableStaticOptions
|
||||
staticOptions={staticOptions}
|
||||
staticOptionsOrder={staticOptionsOrder}
|
||||
@@ -320,7 +370,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
|
||||
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,11 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
|
||||
'A wildcard regex or other value to represent All'
|
||||
),
|
||||
useShowIf: () => {
|
||||
return variable.useState().includeAll ?? false;
|
||||
const state = variable.useState();
|
||||
const hasMultiProps =
|
||||
('valuesFormat' in state && state.valuesFormat === 'json') ||
|
||||
state.options.every((o) => Boolean(o.properties));
|
||||
return hasMultiProps ? false : (state.includeAll ?? false);
|
||||
},
|
||||
render: (descriptor) => <CustomAllValueInput id={descriptor.props.id} variable={variable} />,
|
||||
})
|
||||
@@ -58,6 +62,13 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
|
||||
'dashboard.edit-pane.variable.selection-options.allow-custom-values-description',
|
||||
'Enables users to enter values'
|
||||
),
|
||||
useShowIf: () => {
|
||||
const state = variable.useState();
|
||||
const hasMultiProps =
|
||||
('valuesFormat' in state && state.valuesFormat === 'json') ||
|
||||
state.options.every((o) => Boolean(o.properties));
|
||||
return !hasMultiProps;
|
||||
},
|
||||
render: (descriptor) => <AllowCustomSwitch id={descriptor.props.id} variable={variable} />,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ export function getLocalVariableValueSet(
|
||||
name: variable.state.name,
|
||||
value,
|
||||
text,
|
||||
properties: variable.state.options.find((o) => o.value === value)?.properties,
|
||||
isMulti: variable.state.isMulti,
|
||||
includeAll: variable.state.includeAll,
|
||||
}),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user