Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b982190f1 | |||
| 953f08ed55 | |||
| 8ed233e867 | |||
| e326306485 | |||
| 632f47e367 | |||
| 3d7358c4ce | |||
| fc8893bc53 | |||
| 2723a719aa | |||
| 52d4c928e2 | |||
| efa577e186 | |||
| 2627df30d6 | |||
| bf2870c8c8 | |||
| c53d239aae | |||
| cdde735f41 | |||
| 7be8de044c | |||
| 34305670c5 | |||
| 308bc56d0f | |||
| e36ea78771 | |||
| b332a108f3 | |||
| 1060dd538a | |||
| be8076dee8 | |||
| 7f1ac6188a | |||
| 31eaf1e898 | |||
| 780a64e771 | |||
| 156a6f1375 | |||
| 5fd4fb5fb8 |
@@ -57,7 +57,7 @@ jobs:
|
||||
lint-go:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-x64-large-io
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
||||
@@ -1,34 +1,20 @@
|
||||
package kinds
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/apps/alerting/historian/kinds/v0alpha1"
|
||||
)
|
||||
|
||||
manifest: {
|
||||
appName: "alerting-historian"
|
||||
groupOverride: "historian.alerting.grafana.app"
|
||||
versions: {
|
||||
"v0alpha1": v0alpha1
|
||||
"v0alpha1": {
|
||||
kinds: [dummyv0alpha1]
|
||||
routes: v0alpha1.routes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
v0alpha1: {
|
||||
kinds: [dummyv0alpha1]
|
||||
|
||||
routes: {
|
||||
namespaced: {
|
||||
// This endpoint is an exact copy of the existing /history endpoint,
|
||||
// with the exception that error responses will be Kubernetes-style,
|
||||
// not Grafana-style. It will be replaced in the future with a better
|
||||
// more schema-friendly API.
|
||||
"/alertstate/history": {
|
||||
"GET": {
|
||||
response: {
|
||||
body: [string]: _
|
||||
}
|
||||
responseMetadata: typeMeta: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dummyv0alpha1: {
|
||||
kind: "Dummy"
|
||||
schema: {
|
||||
@@ -37,4 +23,4 @@ dummyv0alpha1: {
|
||||
dummyField: int
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package v0alpha1
|
||||
|
||||
#Matcher: {
|
||||
type: "=" | "!=" | "=~" | "!~" @cuetsy(kind="enum",memberNames="Equal|NotEqual|EqualRegex|NotEqualRegex")
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
#Matchers: [...#Matcher]
|
||||
@@ -0,0 +1,65 @@
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
#NotificationStatus: "firing" | "resolved" @cog(kind="enum",memberNames="Firing|Resolved")
|
||||
|
||||
#NotificationOutcome: "success" | "error" @cog(kind="enum",memberNames="Success|Error")
|
||||
|
||||
#NotificationQuery: {
|
||||
// From is the starting timestamp for the query.
|
||||
from?: time.Time
|
||||
// To is the starting timestamp for the query.
|
||||
to?: time.Time
|
||||
// Limit is the maximum number of entries to return.
|
||||
limit?: int64
|
||||
// Receiver optionally filters the entries by receiver title (contact point).
|
||||
receiver?: string
|
||||
// Status optionally filters the entries to only either firing or resolved.
|
||||
status?: #NotificationStatus
|
||||
// Outcome optionally filters the entries to only either successful or failed attempts.
|
||||
outcome?: #NotificationOutcome
|
||||
// RuleUID optionally filters the entries to a specific alert rule.
|
||||
ruleUID?: string
|
||||
// GroupLabels optionally filters the entries by matching group labels.
|
||||
groupLabels?: #Matchers
|
||||
}
|
||||
|
||||
#NotificationQueryResult: {
|
||||
entries: [...#NotificationEntry]
|
||||
}
|
||||
|
||||
#NotificationEntry: {
|
||||
// Timestamp is the time at which the notification attempt completed.
|
||||
timestamp: time.Time
|
||||
// Receiver is the receiver (contact point) title.
|
||||
receiver: string
|
||||
// Status indicates if the notification contains one or more firing alerts.
|
||||
status: #NotificationStatus
|
||||
// Outcome indicaes if the notificaion attempt was successful or if it failed.
|
||||
outcome: #NotificationOutcome
|
||||
// GroupLabels are the labels uniquely identifying the alert group within a route.
|
||||
groupLabels: [string]: string
|
||||
// Alerts are the alerts grouped into the notification.
|
||||
alerts: [...#NotificationEntryAlert]
|
||||
// Retry indicates if the attempt was a retried attempt.
|
||||
retry: bool
|
||||
// Error is the message returned by the contact point if delivery failed.
|
||||
error?: string
|
||||
// Duration is the length of time the notification attempt took in nanoseconds.
|
||||
duration: int
|
||||
// PipelineTime is the time at which the flush began.
|
||||
pipelineTime: time.Time
|
||||
// GroupKey uniquely idenifies the dispatcher alert group.
|
||||
groupKey: string
|
||||
}
|
||||
|
||||
#NotificationEntryAlert: {
|
||||
status: string
|
||||
labels: [string]: string
|
||||
annotations: [string]: string
|
||||
startsAt: time.Time
|
||||
endsAt: time.Time
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package v0alpha1
|
||||
|
||||
routes: {
|
||||
namespaced: {
|
||||
// This endpoint is an exact copy of the existing /history endpoint,
|
||||
// with the exception that error responses will be Kubernetes-style,
|
||||
// not Grafana-style. It will be replaced in the future with a better
|
||||
// more schema-friendly API.
|
||||
"/alertstate/history": {
|
||||
"GET": {
|
||||
response: {
|
||||
body: [string]: _
|
||||
}
|
||||
responseMetadata: typeMeta: false
|
||||
}
|
||||
}
|
||||
|
||||
// Query notification history.
|
||||
"/notification/query": {
|
||||
"POST": {
|
||||
request: {
|
||||
body: #NotificationQuery
|
||||
}
|
||||
response: #NotificationQueryResult
|
||||
responseMetadata: typeMeta: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
time "time"
|
||||
)
|
||||
|
||||
type CreateNotificationqueryRequestNotificationStatus string
|
||||
|
||||
const (
|
||||
CreateNotificationqueryRequestNotificationStatusFiring CreateNotificationqueryRequestNotificationStatus = "firing"
|
||||
CreateNotificationqueryRequestNotificationStatusResolved CreateNotificationqueryRequestNotificationStatus = "resolved"
|
||||
)
|
||||
|
||||
type CreateNotificationqueryRequestNotificationOutcome string
|
||||
|
||||
const (
|
||||
CreateNotificationqueryRequestNotificationOutcomeSuccess CreateNotificationqueryRequestNotificationOutcome = "success"
|
||||
CreateNotificationqueryRequestNotificationOutcomeError CreateNotificationqueryRequestNotificationOutcome = "error"
|
||||
)
|
||||
|
||||
type CreateNotificationqueryRequestMatchers []CreateNotificationqueryRequestMatcher
|
||||
|
||||
type CreateNotificationqueryRequestMatcher struct {
|
||||
Type CreateNotificationqueryRequestMatcherType `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// NewCreateNotificationqueryRequestMatcher creates a new CreateNotificationqueryRequestMatcher object.
|
||||
func NewCreateNotificationqueryRequestMatcher() *CreateNotificationqueryRequestMatcher {
|
||||
return &CreateNotificationqueryRequestMatcher{}
|
||||
}
|
||||
|
||||
type CreateNotificationqueryRequestBody struct {
|
||||
// From is the starting timestamp for the query.
|
||||
From *time.Time `json:"from,omitempty"`
|
||||
// To is the starting timestamp for the query.
|
||||
To *time.Time `json:"to,omitempty"`
|
||||
// Limit is the maximum number of entries to return.
|
||||
Limit *int64 `json:"limit,omitempty"`
|
||||
// Receiver optionally filters the entries by receiver title (contact point).
|
||||
Receiver *string `json:"receiver,omitempty"`
|
||||
// Status optionally filters the entries to only either firing or resolved.
|
||||
Status *CreateNotificationqueryRequestNotificationStatus `json:"status,omitempty"`
|
||||
// Outcome optionally filters the entries to only either successful or failed attempts.
|
||||
Outcome *CreateNotificationqueryRequestNotificationOutcome `json:"outcome,omitempty"`
|
||||
// RuleUID optionally filters the entries to a specific alert rule.
|
||||
RuleUID *string `json:"ruleUID,omitempty"`
|
||||
// GroupLabels optionally filters the entries by matching group labels.
|
||||
GroupLabels *CreateNotificationqueryRequestMatchers `json:"groupLabels,omitempty"`
|
||||
}
|
||||
|
||||
// NewCreateNotificationqueryRequestBody creates a new CreateNotificationqueryRequestBody object.
|
||||
func NewCreateNotificationqueryRequestBody() *CreateNotificationqueryRequestBody {
|
||||
return &CreateNotificationqueryRequestBody{}
|
||||
}
|
||||
|
||||
type CreateNotificationqueryRequestMatcherType string
|
||||
|
||||
const (
|
||||
CreateNotificationqueryRequestMatcherTypeEqual CreateNotificationqueryRequestMatcherType = "="
|
||||
CreateNotificationqueryRequestMatcherTypeNotEqual CreateNotificationqueryRequestMatcherType = "!="
|
||||
CreateNotificationqueryRequestMatcherTypeEqualRegex CreateNotificationqueryRequestMatcherType = "=~"
|
||||
CreateNotificationqueryRequestMatcherTypeNotEqualRegex CreateNotificationqueryRequestMatcherType = "!~"
|
||||
)
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
time "time"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type NotificationEntry struct {
|
||||
// Timestamp is the time at which the notification attempt completed.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
// Receiver is the receiver (contact point) title.
|
||||
Receiver string `json:"receiver"`
|
||||
// Status indicates if the notification contains one or more firing alerts.
|
||||
Status NotificationStatus `json:"status"`
|
||||
// Outcome indicaes if the notificaion attempt was successful or if it failed.
|
||||
Outcome NotificationOutcome `json:"outcome"`
|
||||
// GroupLabels are the labels uniquely identifying the alert group within a route.
|
||||
GroupLabels map[string]string `json:"groupLabels"`
|
||||
// Alerts are the alerts grouped into the notification.
|
||||
Alerts []NotificationEntryAlert `json:"alerts"`
|
||||
// Retry indicates if the attempt was a retried attempt.
|
||||
Retry bool `json:"retry"`
|
||||
// Error is the message returned by the contact point if delivery failed.
|
||||
Error *string `json:"error,omitempty"`
|
||||
// Duration is the length of time the notification attempt took in nanoseconds.
|
||||
Duration int64 `json:"duration"`
|
||||
// PipelineTime is the time at which the flush began.
|
||||
PipelineTime time.Time `json:"pipelineTime"`
|
||||
// GroupKey uniquely idenifies the dispatcher alert group.
|
||||
GroupKey string `json:"groupKey"`
|
||||
}
|
||||
|
||||
// NewNotificationEntry creates a new NotificationEntry object.
|
||||
func NewNotificationEntry() *NotificationEntry {
|
||||
return &NotificationEntry{
|
||||
GroupLabels: map[string]string{},
|
||||
Alerts: []NotificationEntryAlert{},
|
||||
}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type NotificationStatus string
|
||||
|
||||
const (
|
||||
NotificationStatusFiring NotificationStatus = "firing"
|
||||
NotificationStatusResolved NotificationStatus = "resolved"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type NotificationOutcome string
|
||||
|
||||
const (
|
||||
NotificationOutcomeSuccess NotificationOutcome = "success"
|
||||
NotificationOutcomeError NotificationOutcome = "error"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type NotificationEntryAlert struct {
|
||||
Status string `json:"status"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
}
|
||||
|
||||
// NewNotificationEntryAlert creates a new NotificationEntryAlert object.
|
||||
func NewNotificationEntryAlert() *NotificationEntryAlert {
|
||||
return &NotificationEntryAlert{
|
||||
Labels: map[string]string{},
|
||||
Annotations: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type CreateNotificationquery struct {
|
||||
Entries []NotificationEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// NewCreateNotificationquery creates a new CreateNotificationquery object.
|
||||
func NewCreateNotificationquery() *CreateNotificationquery {
|
||||
return &CreateNotificationquery{
|
||||
Entries: []NotificationEntry{},
|
||||
}
|
||||
}
|
||||
+318
-3
@@ -92,9 +92,321 @@ var appManifestData = app.ManifestData{
|
||||
},
|
||||
},
|
||||
},
|
||||
"/notification/query": {
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
|
||||
OperationId: "createNotificationquery",
|
||||
|
||||
RequestBody: &spec3.RequestBody{
|
||||
RequestBodyProps: spec3.RequestBodyProps{
|
||||
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"from": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "date-time",
|
||||
Description: "From is the starting timestamp for the query.",
|
||||
},
|
||||
},
|
||||
"groupLabels": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
|
||||
Description: "GroupLabels optionally filters the entries by matching group labels.",
|
||||
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryMatchers"),
|
||||
},
|
||||
},
|
||||
"limit": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"integer"},
|
||||
Description: "Limit is the maximum number of entries to return.",
|
||||
},
|
||||
},
|
||||
"outcome": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
|
||||
Description: "Outcome optionally filters the entries to only either successful or failed attempts.",
|
||||
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationOutcome"),
|
||||
},
|
||||
},
|
||||
"receiver": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Description: "Receiver optionally filters the entries by receiver title (contact point).",
|
||||
},
|
||||
},
|
||||
"ruleUID": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Description: "RuleUID optionally filters the entries to a specific alert rule.",
|
||||
},
|
||||
},
|
||||
"status": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
|
||||
Description: "Status optionally filters the entries to only either firing or resolved.",
|
||||
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationStatus"),
|
||||
},
|
||||
},
|
||||
"to": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "date-time",
|
||||
Description: "To is the starting timestamp for the query.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
Default: &spec3.Response{
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Description: "Default OK response",
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"entries": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"entries",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Cluster: map[string]spec3.PathProps{},
|
||||
Schemas: map[string]spec.Schema{},
|
||||
Schemas: map[string]spec.Schema{
|
||||
"createNotificationqueryMatcher": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"label": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"type": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Enum: []interface{}{
|
||||
"=",
|
||||
"!=",
|
||||
"=~",
|
||||
"!~",
|
||||
},
|
||||
},
|
||||
},
|
||||
"value": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"type",
|
||||
"label",
|
||||
"value",
|
||||
},
|
||||
},
|
||||
},
|
||||
"createNotificationqueryMatchers": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
},
|
||||
},
|
||||
"createNotificationqueryNotificationEntry": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"alerts": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Description: "Alerts are the alerts grouped into the notification.",
|
||||
},
|
||||
},
|
||||
"duration": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"integer"},
|
||||
Description: "Duration is the length of time the notification attempt took in nanoseconds.",
|
||||
},
|
||||
},
|
||||
"error": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Description: "Error is the message returned by the contact point if delivery failed.",
|
||||
},
|
||||
},
|
||||
"groupKey": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Description: "GroupKey uniquely idenifies the dispatcher alert group.",
|
||||
},
|
||||
},
|
||||
"groupLabels": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Description: "GroupLabels are the labels uniquely identifying the alert group within a route.",
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"outcome": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
|
||||
Description: "Outcome indicaes if the notificaion attempt was successful or if it failed.",
|
||||
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationOutcome"),
|
||||
},
|
||||
},
|
||||
"pipelineTime": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "date-time",
|
||||
Description: "PipelineTime is the time at which the flush began.",
|
||||
},
|
||||
},
|
||||
"receiver": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Description: "Receiver is the receiver (contact point) title.",
|
||||
},
|
||||
},
|
||||
"retry": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"boolean"},
|
||||
Description: "Retry indicates if the attempt was a retried attempt.",
|
||||
},
|
||||
},
|
||||
"status": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
|
||||
Description: "Status indicates if the notification contains one or more firing alerts.",
|
||||
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationStatus"),
|
||||
},
|
||||
},
|
||||
"timestamp": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "date-time",
|
||||
Description: "Timestamp is the time at which the notification attempt completed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"timestamp",
|
||||
"receiver",
|
||||
"status",
|
||||
"outcome",
|
||||
"groupLabels",
|
||||
"alerts",
|
||||
"retry",
|
||||
"duration",
|
||||
"pipelineTime",
|
||||
"groupKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
"createNotificationqueryNotificationEntryAlert": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"annotations": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"endsAt": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "date-time",
|
||||
},
|
||||
},
|
||||
"labels": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"startsAt": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "date-time",
|
||||
},
|
||||
},
|
||||
"status": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"status",
|
||||
"labels",
|
||||
"annotations",
|
||||
"startsAt",
|
||||
"endsAt",
|
||||
},
|
||||
},
|
||||
},
|
||||
"createNotificationqueryNotificationOutcome": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Enum: []interface{}{
|
||||
"success",
|
||||
"error",
|
||||
},
|
||||
},
|
||||
},
|
||||
"createNotificationqueryNotificationStatus": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Enum: []interface{}{
|
||||
"firing",
|
||||
"resolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -120,7 +432,8 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist
|
||||
}
|
||||
|
||||
var customRouteToGoResponseType = map[string]any{
|
||||
"v0alpha1||<namespace>/alertstate/history|GET": v0alpha1.GetAlertstatehistory{},
|
||||
"v0alpha1||<namespace>/alertstate/history|GET": v0alpha1.GetAlertstatehistory{},
|
||||
"v0alpha1||<namespace>/notification/query|POST": v0alpha1.CreateNotificationquery{},
|
||||
}
|
||||
|
||||
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
|
||||
@@ -145,7 +458,9 @@ func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goTyp
|
||||
return goType, exists
|
||||
}
|
||||
|
||||
var customRouteToGoRequestBodyType = map[string]any{}
|
||||
var customRouteToGoRequestBodyType = map[string]any{
|
||||
"v0alpha1||<namespace>/notification/query|POST": v0alpha1.CreateNotificationqueryRequestBody{},
|
||||
}
|
||||
|
||||
func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) {
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/simple"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/grafana/grafana/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/alerting/historian/pkg/app/config"
|
||||
@@ -21,6 +26,11 @@ func New(cfg app.Config) (app.App, error) {
|
||||
Path: "/alertstate/history",
|
||||
Method: "GET",
|
||||
}: runtimeConfig.GetAlertStateHistoryHandler,
|
||||
{
|
||||
Namespaced: true,
|
||||
Path: "/notification/query",
|
||||
Method: "POST",
|
||||
}: UnimplementedHandler,
|
||||
},
|
||||
},
|
||||
// TODO: Remove when SDK is fixed.
|
||||
@@ -43,3 +53,13 @@ func New(cfg app.Config) (app.App, error) {
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func UnimplementedHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
|
||||
return &apierrors.StatusError{
|
||||
ErrStatus: metav1.Status{
|
||||
Status: metav1.StatusFailure,
|
||||
Code: http.StatusUnprocessableEntity,
|
||||
Message: "unimplemented",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ generate: install-app-sdk update-app-sdk
|
||||
--gogenpath=./pkg/apis \
|
||||
--grouping=group \
|
||||
--genoperatorstate=false \
|
||||
--defencoding=none
|
||||
--defencoding=none
|
||||
@@ -1,35 +0,0 @@
|
||||
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,13 +6,12 @@ manifest: {
|
||||
versions: {
|
||||
"v1alpha1": {
|
||||
codegen: {
|
||||
ts: {enabled: true}
|
||||
ts: {enabled: false}
|
||||
go: {enabled: true}
|
||||
}
|
||||
kinds: [
|
||||
starsV1alpha1,
|
||||
datasourcestacksV1alpha1
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//
|
||||
// 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
@@ -1,31 +0,0 @@
|
||||
// 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
@@ -1,293 +0,0 @@
|
||||
//
|
||||
// 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
@@ -1,34 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
@@ -1,58 +0,0 @@
|
||||
// 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,19 +32,6 @@ 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
|
||||
@@ -61,8 +48,6 @@ func addKnownTypes(scheme *runtime.Scheme) error {
|
||||
scheme.AddKnownTypes(schemeGroupVersion,
|
||||
&Stars{},
|
||||
&StarsList{},
|
||||
&DataSourceStack{},
|
||||
&DataSourceStackList{},
|
||||
)
|
||||
metav1.AddToGroupVersion(scheme, schemeGroupVersion)
|
||||
return nil
|
||||
|
||||
@@ -14,241 +14,10 @@ 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.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"},
|
||||
"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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -1,4 +1,2 @@
|
||||
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
|
||||
|
||||
+7
-19
@@ -10,22 +10,19 @@ 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"
|
||||
|
||||
v1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
"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)
|
||||
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)
|
||||
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)
|
||||
)
|
||||
|
||||
var appManifestData = app.ManifestData{
|
||||
@@ -52,14 +49,6 @@ var appManifestData = app.ManifestData{
|
||||
},
|
||||
Schema: &versionSchemaStarsv1alpha1,
|
||||
},
|
||||
|
||||
{
|
||||
Kind: "DataSourceStack",
|
||||
Plural: "DataSourceStacks",
|
||||
Scope: "Namespaced",
|
||||
Conversion: false,
|
||||
Schema: &versionSchemaDataSourceStackv1alpha1,
|
||||
},
|
||||
},
|
||||
Routes: app.ManifestVersionRoutes{
|
||||
Namespaced: map[string]spec3.PathProps{},
|
||||
@@ -79,8 +68,7 @@ func RemoteManifest() app.Manifest {
|
||||
}
|
||||
|
||||
var kindVersionToGoType = map[string]resource.Kind{
|
||||
"Stars/v1alpha1": v1alpha1.StarsKind(),
|
||||
"DataSourceStack/v1alpha1": v1alpha1.DataSourceStackKind(),
|
||||
"Stars/v1alpha1": v1alpha1.StarsKind(),
|
||||
}
|
||||
|
||||
// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists.
|
||||
|
||||
Generated
-47
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* 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
@@ -1,30 +0,0 @@
|
||||
// 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
@@ -1,53 +0,0 @@
|
||||
// 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: [],
|
||||
});
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// 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: {},
|
||||
});
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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,7 +911,6 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
@@ -915,7 +915,6 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
+12
-21
@@ -1675,19 +1675,18 @@ 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"`
|
||||
ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object.
|
||||
@@ -2102,14 +2101,6 @@ 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,12 +1510,6 @@ 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,6 +53,7 @@ pluginMetaV0Alpha1: {
|
||||
skipDataQuery?: bool
|
||||
state?: "alpha" | "beta"
|
||||
streaming?: bool
|
||||
suggestions?: bool
|
||||
tracing?: bool
|
||||
iam?: #IAM
|
||||
// +listType=atomic
|
||||
|
||||
@@ -40,6 +40,7 @@ type PluginMetaJSONData struct {
|
||||
SkipDataQuery *bool `json:"skipDataQuery,omitempty"`
|
||||
State *PluginMetaJSONDataState `json:"state,omitempty"`
|
||||
Streaming *bool `json:"streaming,omitempty"`
|
||||
Suggestions *bool `json:"suggestions,omitempty"`
|
||||
Tracing *bool `json:"tracing,omitempty"`
|
||||
Iam *PluginMetaIAM `json:"iam,omitempty"`
|
||||
// +listType=atomic
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -181,8 +181,6 @@ 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,6 +341,10 @@
|
||||
"type": "boolean",
|
||||
"description": "Initialize plugin on startup. By default, the plugin initializes on first use, but when preload is set to true the plugin loads when the Grafana web app loads the first time. Only applicable to app plugins. When setting to `true`, implement [frontend code splitting](https://grafana.com/developers/plugin-tools/get-started/best-practices#app-plugins) to minimise performance implications."
|
||||
},
|
||||
"suggestions": {
|
||||
"type": "boolean",
|
||||
"description": "For panel plugins. If set to true, the plugin's suggestions supplier will be invoked and any suggestions returned will be included in the Suggestions pane in the Panel Editor."
|
||||
},
|
||||
"queryOptions": {
|
||||
"type": "object",
|
||||
"description": "For data source plugins. There is a query options section in the plugin's query editor and these options can be turned on if needed.",
|
||||
|
||||
@@ -128,6 +128,20 @@ The server element lets you easily represent a single server, a stack of servers
|
||||
|
||||
{{< figure src="/media/docs/grafana/canvas-server-element-9-4-0.png" max-width="650px" alt="Canvas server element" >}}
|
||||
|
||||
#### SVG
|
||||
|
||||
The SVG element lets you add custom SVG graphics to the canvas. You can enter raw SVG markup in the content field, and the element will render it with proper sanitization to prevent XSS attacks. This element is useful for creating custom icons, logos, or complex graphics that aren't available in the standard shape elements.
|
||||
|
||||
SVG element features:
|
||||
|
||||
- **Sanitized content**: All SVG content is automatically sanitized for security
|
||||
- **Data binding**: SVG content can be bound to field data using template variables
|
||||
- **Scalable**: SVG graphics scale cleanly at any size
|
||||
|
||||
The SVG element supports the following configuration options:
|
||||
|
||||
- **SVG Content**: Enter raw SVG markup. Content will be sanitized automatically.
|
||||
|
||||
#### Button
|
||||
|
||||
The button element lets you add a basic button to the canvas. Button elements support triggering basic, unauthenticated API calls. [API settings](#button-api-options) are found in the button element editor. You can also pass template variables in the API editor.
|
||||
|
||||
+40
-17
@@ -22,7 +22,7 @@ weight: 100
|
||||
|
||||
# Node graph
|
||||
|
||||
Node graphs are useful when you need to visualize elements that are related to each other. This is done by displaying circles—or _nodes_—for each element you want to visualize, connected by lines—or _edges_. The visualization uses a directed force layout that positions the nodes into a network of connected circles.
|
||||
Node graphs are useful when you need to visualize elements that are related to each other. This is done by displaying circles—or _nodes_—for each element you want to visualize, connected by lines—or _edges_. By default, the visualization uses a [layered layout](#layout-algorithm) that positions the nodes into a network of connected circles.
|
||||
|
||||
Node graphs display useful information about each node, as well as the relationships between them, allowing you to visualize complex infrastructure maps, hierarchies, or execution diagrams.
|
||||
|
||||
@@ -123,26 +123,32 @@ You can pan the view by clicking outside any node or edge and dragging your mous
|
||||
|
||||
Use the buttons in the lower right corner to zoom in or out. You can also use the mouse wheel or touchpad scroll, together with either Ctrl or Cmd key to do so.
|
||||
|
||||
### Switch layouts
|
||||
|
||||
Switch quickly between displaying the visualization in graph or grid [layout](#layout-algorithm).
|
||||
|
||||
Click a node and select either **Show in Grid layout** or **Show in Graph layout**, depending on the current layout of the visualization:
|
||||
|
||||
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid-menu.png" max-width="750px" alt="Node graph in grid layout with node menu open" >}}
|
||||
|
||||
In grid layout, you can sort nodes by clicking on the stats inside the legend.
|
||||
The marker next to the stat name shows which stat is currently used for sorting and the sorting direction:
|
||||
|
||||
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-legend-sort.png" max-width="550px" alt="Node graph legend sorting" >}}
|
||||
|
||||
Switching between grid and other layouts this way only changes the layout temporarily.
|
||||
The visualization maintains the layout algorithm selected in the panel editor, and reverts to it when the dashboard refreshes.
|
||||
|
||||
For more information about layouts, refer to [Layout algorithm](#layout-algorithm).
|
||||
|
||||
<!-- if you have the panel in grid layout and switch it to graph, is it switching to layered? -->
|
||||
|
||||
### Hidden nodes
|
||||
|
||||
The number of nodes shown at a given time is limited to maintain a reasonable visualization performance. Nodes that are not currently visible are hidden behind clickable markers that show an approximate number of hidden nodes that are connected by a particular edge. You can click on the marker to expand the graph around that node.
|
||||
|
||||

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

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

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

|
||||
|
||||
## Configuration options
|
||||
|
||||
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
@@ -155,7 +161,24 @@ Click on the node and select "Show in Graph layout" option to switch back to gra
|
||||
|
||||
Use the following options to refine your node graph visualization.
|
||||
|
||||
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
|
||||
#### Zoom mode
|
||||
|
||||
Choose how the node graph should handle zoom and scroll events:
|
||||
|
||||
- **Cooperative** - Allows you to scroll the visualization normally.
|
||||
- **Greedy** - Reacts to all zoom gestures.
|
||||
|
||||
#### Layout algorithm
|
||||
|
||||
Choose how the visualization layout is generated:
|
||||
|
||||
- **Layered** - Default. Creates a predictable and orderly layout, especially useful for service graphs.
|
||||
- **Force** - Uses a physics-based force layout algorithm that's useful with a large number of nodes (500+).
|
||||
- **Grid** - Arranges nodes into a grid format to provide a better overview of the most interesting nodes in the graph. This layout shows nodes in a grid without edges and can be sorted by the stats shown inside the node or by the ones represented by the a colored border of the nodes.
|
||||
|
||||
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid.png" max-width="650px" alt="Node graph in grid layout" >}}
|
||||
|
||||
For more information about using the graph in grid layout, refer to [Switch layouts](#switch-layouts).
|
||||
|
||||
### Nodes options
|
||||
|
||||
@@ -239,6 +262,6 @@ Optional fields:
|
||||
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
|
||||
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
|
||||
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behavior depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
|
||||
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/iconography-icon--icons-overview)) are allowed. |
|
||||
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/iconography-icon--icons-overview) are allowed. |
|
||||
| nodeRadius | number | Radius value in pixels. Used to manage node size. |
|
||||
| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |
|
||||
|
||||
+2
-2
@@ -296,8 +296,8 @@
|
||||
"@grafana/plugin-ui": "^0.11.1",
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/scenes": "^6.48.0",
|
||||
"@grafana/scenes-react": "^6.48.0",
|
||||
"@grafana/scenes": "6.47.1",
|
||||
"@grafana/scenes-react": "6.47.1",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -715,11 +715,9 @@ export {
|
||||
export {
|
||||
type VisualizationSuggestion,
|
||||
type VisualizationSuggestionsSupplier,
|
||||
type VisualizationSuggestionsSupplierFn,
|
||||
type PanelPluginVisualizationSuggestion,
|
||||
type VisualizationSuggestionsBuilder,
|
||||
VisualizationSuggestionScore,
|
||||
VisualizationSuggestionsBuilder,
|
||||
VisualizationSuggestionsListAppender,
|
||||
} from './types/suggestions';
|
||||
export {
|
||||
type MatcherConfig,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { createDataFrame } from '../dataframe/processDataFrame';
|
||||
import { identityOverrideProcessor } from '../field/overrides/processors';
|
||||
import {
|
||||
StandardEditorsRegistryItem,
|
||||
standardEditorsRegistry,
|
||||
standardFieldConfigEditorRegistry,
|
||||
} from '../field/standardFieldConfigEditorRegistry';
|
||||
import { FieldType } from '../types/dataFrame';
|
||||
import { FieldConfigProperty, FieldConfigPropertyItem } from '../types/fieldOverrides';
|
||||
import { PanelMigrationModel } from '../types/panel';
|
||||
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '../types/suggestions';
|
||||
import { PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
|
||||
|
||||
import { PanelPlugin } from './PanelPlugin';
|
||||
import { getPanelDataSummary } from './suggestions/getPanelDataSummary';
|
||||
|
||||
describe('PanelPlugin', () => {
|
||||
describe('declarative options', () => {
|
||||
@@ -483,4 +487,107 @@ describe('PanelPlugin', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestions', () => {
|
||||
it('should register a suggestions supplier', () => {
|
||||
const panel = new PanelPlugin(() => <div>Panel</div>);
|
||||
panel.meta = panel.meta || {};
|
||||
panel.meta.id = 'test-panel';
|
||||
panel.meta.name = 'Test Panel';
|
||||
|
||||
panel.setSuggestionsSupplier((ds) => {
|
||||
if (!ds.hasFieldType(FieldType.number)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'Number Panel',
|
||||
score: VisualizationSuggestionScore.Good,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const suggestions = panel.getSuggestions(
|
||||
getPanelDataSummary([createDataFrame({ fields: [{ type: FieldType.number, name: 'Value' }] })])
|
||||
);
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions![0].pluginId).toBe(panel.meta.id);
|
||||
expect(suggestions![0].name).toBe('Number Panel');
|
||||
|
||||
expect(
|
||||
panel.getSuggestions(
|
||||
getPanelDataSummary([createDataFrame({ fields: [{ type: FieldType.string, name: 'Value' }] })])
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not throw for the old syntax, but also should not register suggestions', () => {
|
||||
jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
class DeprecatedSuggestionsSupplier {
|
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder): void {
|
||||
const appender = builder.getListAppender({
|
||||
name: 'Deprecated Suggestion',
|
||||
pluginId: 'deprecated-plugin',
|
||||
options: {},
|
||||
});
|
||||
|
||||
if (builder.dataSummary.hasNumberField) {
|
||||
appender.append({});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const panel = new PanelPlugin(() => <div>Panel</div>);
|
||||
|
||||
expect(() => {
|
||||
panel.setSuggestionsSupplier(new DeprecatedSuggestionsSupplier());
|
||||
}).not.toThrow();
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
expect(
|
||||
panel.getSuggestions(
|
||||
getPanelDataSummary([
|
||||
createDataFrame({
|
||||
fields: [{ type: FieldType.number, name: 'Value', values: [1, 2, 3, 4, 5] }],
|
||||
}),
|
||||
])
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should support the deprecated pattern of getSuggestionsSupplier with builder', () => {
|
||||
jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
const panel = new PanelPlugin(() => <div>Panel</div>).setSuggestionsSupplier((ds) => {
|
||||
if (!ds.hasFieldType(FieldType.number)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'Number Panel',
|
||||
score: VisualizationSuggestionScore.Good,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const oldSupplier = panel.getSuggestionsSupplier();
|
||||
const builder1 = new VisualizationSuggestionsBuilder([
|
||||
createDataFrame({ fields: [{ type: FieldType.number, name: 'Value' }] }),
|
||||
]);
|
||||
oldSupplier.getSuggestionsForData(builder1);
|
||||
const suggestions1 = builder1.getList();
|
||||
expect(suggestions1).toHaveLength(1);
|
||||
expect(suggestions1![0].pluginId).toBe(panel.meta.id);
|
||||
expect(suggestions1![0].name).toBe('Number Panel');
|
||||
|
||||
const builder2 = new VisualizationSuggestionsBuilder([
|
||||
createDataFrame({ fields: [{ type: FieldType.string, name: 'Value' }] }),
|
||||
]);
|
||||
oldSupplier.getSuggestionsForData(builder2);
|
||||
const suggestions2 = builder2.getList();
|
||||
expect(suggestions2).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { set } from 'lodash';
|
||||
import { defaultsDeep, set } from 'lodash';
|
||||
import { ComponentClass, ComponentType } from 'react';
|
||||
|
||||
import { FieldConfigOptionsRegistry } from '../field/FieldConfigOptionsRegistry';
|
||||
@@ -14,11 +14,19 @@ import {
|
||||
PanelPluginDataSupport,
|
||||
} from '../types/panel';
|
||||
import { GrafanaPlugin } from '../types/plugin';
|
||||
import { VisualizationSuggestionsSupplierFn, VisualizationSuggestionsSupplier } from '../types/suggestions';
|
||||
import {
|
||||
getSuggestionHash,
|
||||
PanelPluginVisualizationSuggestion,
|
||||
VisualizationSuggestion,
|
||||
VisualizationSuggestionsSupplierDeprecated,
|
||||
VisualizationSuggestionsSupplier,
|
||||
VisualizationSuggestionsBuilder,
|
||||
} from '../types/suggestions';
|
||||
import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
|
||||
import { deprecationWarning } from '../utils/deprecationWarning';
|
||||
|
||||
import { createFieldConfigRegistry } from './registryFactories';
|
||||
import { PanelDataSummary } from './suggestions/getPanelDataSummary';
|
||||
|
||||
/** @beta */
|
||||
export type StandardOptionConfig = {
|
||||
@@ -109,7 +117,7 @@ export class PanelPlugin<
|
||||
};
|
||||
|
||||
private optionsSupplier?: PanelOptionsSupplier<TOptions>;
|
||||
private suggestionsSupplier?: VisualizationSuggestionsSupplier;
|
||||
private suggestionsSupplier?: VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>;
|
||||
|
||||
panel: ComponentType<PanelProps<TOptions>> | null;
|
||||
editor?: ComponentClass<PanelEditorProps<TOptions>>;
|
||||
@@ -363,56 +371,84 @@ export class PanelPlugin<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use VisualizationSuggestionsSupplierFn
|
||||
* @deprecated use VisualizationSuggestionsSupplier
|
||||
*/
|
||||
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier): this;
|
||||
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierDeprecated): this;
|
||||
/**
|
||||
* @alpha
|
||||
* sets function that can return visualization examples and suggestions.
|
||||
*/
|
||||
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>): this;
|
||||
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>): this;
|
||||
setSuggestionsSupplier(
|
||||
supplier: VisualizationSuggestionsSupplier | VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>
|
||||
supplier:
|
||||
| VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>
|
||||
| VisualizationSuggestionsSupplierDeprecated
|
||||
): this {
|
||||
this.suggestionsSupplier =
|
||||
typeof supplier === 'function'
|
||||
? {
|
||||
getSuggestionsForData: (builder) => {
|
||||
const appender = builder.getListAppender<TOptions, TFieldConfigOptions>({
|
||||
pluginId: this.meta.id,
|
||||
name: this.meta.name,
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = supplier(builder.dataSummary);
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
appender.appendAll(result);
|
||||
}
|
||||
},
|
||||
}
|
||||
: supplier;
|
||||
if (typeof supplier !== 'function') {
|
||||
deprecationWarning(
|
||||
'PanelPlugin',
|
||||
'plugin.setSuggestionsSupplier(new Supplier())',
|
||||
'plugin.setSuggestionsSupplier(dataSummary => [...])'
|
||||
);
|
||||
return this;
|
||||
}
|
||||
this.suggestionsSupplier = supplier;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the suggestions supplier
|
||||
* @alpha
|
||||
* get suggestions based on the PanelDataSummary
|
||||
*/
|
||||
getSuggestionsSupplier(): VisualizationSuggestionsSupplier | undefined {
|
||||
return this.suggestionsSupplier;
|
||||
getSuggestions(
|
||||
panelDataSummary: PanelDataSummary
|
||||
): Array<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>> | void {
|
||||
const withDefaults = (
|
||||
suggestion: VisualizationSuggestion<TOptions, TFieldConfigOptions>
|
||||
): Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'> =>
|
||||
defaultsDeep(suggestion, {
|
||||
pluginId: this.meta.id,
|
||||
name: this.meta.name,
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
} satisfies Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'>);
|
||||
return this.suggestionsSupplier?.(panelDataSummary)?.map(
|
||||
(s): PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions> => {
|
||||
const suggestionWithDefaults = withDefaults(s);
|
||||
return Object.assign(suggestionWithDefaults, { hash: getSuggestionHash(suggestionWithDefaults) });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* returns whether the plugin has configured suggestions
|
||||
* @deprecated use getSuggestions
|
||||
* we have to keep this method intact to support cloud-onboarding plugin.
|
||||
*/
|
||||
hasSuggestions(): boolean {
|
||||
return this.suggestionsSupplier !== undefined;
|
||||
getSuggestionsSupplier() {
|
||||
const withDefaults = (
|
||||
suggestion: VisualizationSuggestion<TOptions, TFieldConfigOptions>
|
||||
): Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'> =>
|
||||
defaultsDeep(suggestion, {
|
||||
pluginId: this.meta.id,
|
||||
name: this.meta.name,
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
} satisfies Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'>);
|
||||
|
||||
return {
|
||||
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => {
|
||||
deprecationWarning('PanelPlugin', 'getSuggestionsSupplier()', 'getSuggestions(panelDataSummary)');
|
||||
this.suggestionsSupplier?.(builder.dataSummary)?.forEach((s) => {
|
||||
builder.getListAppender(withDefaults(s)).append(s);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
hasPluginId(pluginId: string) {
|
||||
|
||||
@@ -1143,6 +1143,11 @@ export interface FeatureToggles {
|
||||
*/
|
||||
newVizSuggestions?: boolean;
|
||||
/**
|
||||
* Enable all plugins to supply visualization suggestions (including 3rd party plugins)
|
||||
* @default false
|
||||
*/
|
||||
externalVizSuggestions?: boolean;
|
||||
/**
|
||||
* Restrict PanelChrome contents with overflow: hidden;
|
||||
* @default true
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,8 @@ export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, forma
|
||||
export interface PanelPluginMeta extends PluginMeta {
|
||||
/** Indicates that panel does not issue queries */
|
||||
skipDataQuery?: boolean;
|
||||
/** Indicates that the panel implements suggestions */
|
||||
suggestions?: boolean;
|
||||
/** Indicates that panel should not be available in visualisation picker */
|
||||
hideFromList?: boolean;
|
||||
/** Sort order */
|
||||
|
||||
@@ -2,11 +2,10 @@ import { defaultsDeep } from 'lodash';
|
||||
|
||||
import { DataTransformerConfig } from '@grafana/schema';
|
||||
|
||||
import { PanelDataSummary, getPanelDataSummary } from '../panel/suggestions/getPanelDataSummary';
|
||||
import { getPanelDataSummary, PanelDataSummary } from '../panel/suggestions/getPanelDataSummary';
|
||||
|
||||
import { PanelModel } from './dashboard';
|
||||
import { DataFrame } from './dataFrame';
|
||||
import { FieldConfigSource } from './fieldOverrides';
|
||||
import { PanelData } from './panel';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -108,35 +107,6 @@ export enum VisualizationSuggestionScore {
|
||||
OK = 50,
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* TODO this will move into the grafana app code once suppliers are migrated.
|
||||
*/
|
||||
export class VisualizationSuggestionsBuilder {
|
||||
/** Summary stats for current data */
|
||||
dataSummary: PanelDataSummary;
|
||||
private list: PanelPluginVisualizationSuggestion[] = [];
|
||||
|
||||
constructor(
|
||||
/** Current data */
|
||||
public data?: PanelData,
|
||||
/** Current panel & options */
|
||||
public panel?: PanelModel
|
||||
) {
|
||||
this.dataSummary = getPanelDataSummary(data?.series);
|
||||
}
|
||||
|
||||
getListAppender<TOptions extends unknown, TFieldConfig extends {} = {}>(
|
||||
defaults: Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>, 'hash'>
|
||||
) {
|
||||
return new VisualizationSuggestionsListAppender<TOptions, TFieldConfig>(this.list, defaults);
|
||||
}
|
||||
|
||||
getList() {
|
||||
return this.list;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* TODO: this name is temporary; it will become just "VisualizationSuggestionsSupplier" when the other interface is deleted.
|
||||
@@ -147,40 +117,48 @@ export class VisualizationSuggestionsBuilder {
|
||||
* - returns an array of VisualizationSuggestions
|
||||
* - boolean return equates to "show a single suggestion card for this panel plugin with the default options" (true = show, false or void = hide)
|
||||
*/
|
||||
export type VisualizationSuggestionsSupplierFn<TOptions extends unknown, TFieldConfig extends {} = {}> = (
|
||||
export type VisualizationSuggestionsSupplier<TOptions extends unknown, TFieldConfig extends {} = {}> = (
|
||||
panelDataSummary: PanelDataSummary
|
||||
) => Array<VisualizationSuggestion<TOptions, TFieldConfig>> | void;
|
||||
|
||||
/**
|
||||
* @deprecated use VisualizationSuggestionsSupplierFn instead.
|
||||
* DEPRECATED - the below exports need to remain in the code base to help make the transition for the Polystat plugin, which implements
|
||||
* suggestions using the old API. These should be removed for Grafana 13.
|
||||
*/
|
||||
export type VisualizationSuggestionsSupplier = {
|
||||
/**
|
||||
* Adds suitable suggestions for the current data
|
||||
*/
|
||||
/**
|
||||
* @deprecated use VisualizationSuggestionsSupplier
|
||||
*/
|
||||
export interface VisualizationSuggestionsSupplierDeprecated {
|
||||
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* TODO this will move into the grafana app code once suppliers are migrated.
|
||||
* @deprecated use VisualizationSuggestionsSupplier
|
||||
*/
|
||||
export class VisualizationSuggestionsListAppender<TOptions extends unknown, TFieldConfig extends {} = {}> {
|
||||
constructor(
|
||||
private list: VisualizationSuggestion[],
|
||||
private defaults: Partial<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>> = {}
|
||||
) {}
|
||||
export class VisualizationSuggestionsBuilder {
|
||||
public dataSummary: PanelDataSummary;
|
||||
public list: PanelPluginVisualizationSuggestion[] = [];
|
||||
|
||||
append(suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) {
|
||||
this.appendAll([suggestion]);
|
||||
constructor(dataFrames: DataFrame[]) {
|
||||
this.dataSummary = getPanelDataSummary(dataFrames);
|
||||
}
|
||||
|
||||
appendAll(suggestions: Array<VisualizationSuggestion<TOptions, TFieldConfig>>) {
|
||||
this.list.push(
|
||||
...suggestions.map((s): PanelPluginVisualizationSuggestion<TOptions, TFieldConfig> => {
|
||||
const suggestionWithDefaults = defaultsDeep(s, this.defaults);
|
||||
return Object.assign(suggestionWithDefaults, { hash: getSuggestionHash(suggestionWithDefaults) });
|
||||
})
|
||||
);
|
||||
getList(): PanelPluginVisualizationSuggestion[] {
|
||||
return this.list;
|
||||
}
|
||||
|
||||
getListAppender(suggestionDefaults: Omit<PanelPluginVisualizationSuggestion, 'hash'>) {
|
||||
const withDefaults = (suggestion: VisualizationSuggestion): PanelPluginVisualizationSuggestion => {
|
||||
const s = defaultsDeep({}, suggestion, suggestionDefaults);
|
||||
return {
|
||||
...s,
|
||||
hash: getSuggestionHash(s),
|
||||
};
|
||||
};
|
||||
return {
|
||||
append: (suggestion: VisualizationSuggestion) => {
|
||||
this.list.push(withDefaults(suggestion));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,6 @@ export interface IntervalVariableModel extends VariableWithOptions {
|
||||
|
||||
export interface CustomVariableModel extends VariableWithMultiSupport {
|
||||
type: 'custom';
|
||||
valuesFormat?: 'csv' | 'json';
|
||||
}
|
||||
|
||||
export interface DataSourceVariableModel extends VariableWithMultiSupport {
|
||||
|
||||
+13
@@ -105,6 +105,19 @@ export interface TextDimensionConfig extends BaseDimensionConfig {
|
||||
mode: TextDimensionMode;
|
||||
}
|
||||
|
||||
export enum PositionDimensionMode {
|
||||
Field = 'field',
|
||||
Fixed = 'fixed',
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple position/coordinate dimension - just fixed value or field value, no scaling/clamping
|
||||
*/
|
||||
export interface PositionDimensionConfig extends BaseDimensionConfig {
|
||||
fixed?: number;
|
||||
mode: PositionDimensionMode;
|
||||
}
|
||||
|
||||
export enum ResourceDimensionMode {
|
||||
Field = 'field',
|
||||
Fixed = 'fixed',
|
||||
|
||||
@@ -38,6 +38,15 @@ TextDimensionConfig: {
|
||||
fixed?: string
|
||||
}@cuetsy(kind="interface")
|
||||
|
||||
PositionDimensionMode: "fixed" | "field" @cuetsy(kind="enum")
|
||||
|
||||
// Simple position/coordinate dimension - just fixed value or field value, no scaling/clamping
|
||||
PositionDimensionConfig: {
|
||||
BaseDimensionConfig
|
||||
mode: PositionDimensionMode
|
||||
fixed?: number
|
||||
}@cuetsy(kind="interface")
|
||||
|
||||
ResourceDimensionMode: "fixed" | "field" | "mapping" @cuetsy(kind="enum")
|
||||
|
||||
// Links to a resource (image/svg path)
|
||||
|
||||
+7
-7
@@ -34,13 +34,13 @@ export interface Constraint {
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
bottom?: number;
|
||||
height?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
rotation?: number;
|
||||
top?: number;
|
||||
width?: number;
|
||||
bottom?: ui.PositionDimensionConfig;
|
||||
height?: ui.PositionDimensionConfig;
|
||||
left?: ui.PositionDimensionConfig;
|
||||
right?: ui.PositionDimensionConfig;
|
||||
rotation?: ui.ScalarDimensionConfig;
|
||||
top?: ui.PositionDimensionConfig;
|
||||
width?: ui.PositionDimensionConfig;
|
||||
}
|
||||
|
||||
export enum BackgroundImageSize {
|
||||
|
||||
@@ -316,7 +316,6 @@ export const handyTestingSchema: Spec = {
|
||||
query: 'option1, option2',
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1335,7 +1335,6 @@ export interface CustomVariableSpec {
|
||||
skipUrlSync: boolean;
|
||||
description?: string;
|
||||
allowCustomValue: boolean;
|
||||
valuesFormat?: "csv" | "json";
|
||||
}
|
||||
|
||||
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
|
||||
|
||||
@@ -29,6 +29,10 @@ export interface ScalarDimensionConfig extends BaseDimensionConfig<number>, Omit
|
||||
|
||||
export interface TextDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.TextDimensionConfig, 'fixed'> {}
|
||||
|
||||
export interface PositionDimensionConfig
|
||||
extends BaseDimensionConfig<number>,
|
||||
Omit<raw.PositionDimensionConfig, 'fixed'> {}
|
||||
|
||||
export interface ColorDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.ColorDimensionConfig, 'fixed'> {}
|
||||
|
||||
export interface ColorDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.ColorDimensionConfig, 'fixed'> {}
|
||||
|
||||
@@ -518,6 +518,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
|
||||
return {
|
||||
container: css({
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}),
|
||||
panel: css({
|
||||
|
||||
@@ -150,10 +150,6 @@ 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,6 +164,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
|
||||
BaseURL: panel.BaseURL,
|
||||
SkipDataQuery: panel.SkipDataQuery,
|
||||
Suggestions: panel.Suggestions,
|
||||
HideFromList: panel.HideFromList,
|
||||
ReleaseState: string(panel.State),
|
||||
Signature: string(panel.Signature),
|
||||
|
||||
@@ -222,6 +222,10 @@ var (
|
||||
|
||||
// MStatTotalRepositories is a metric total amount of repositories
|
||||
MStatTotalRepositories prometheus.Gauge
|
||||
|
||||
// MUnifiedStorageMigrationStatus indicates the migration status for unified storage in this instance.
|
||||
// Possible values: 0 (default/undefined), 1 (migration disabled), 2 (migration would run).
|
||||
MUnifiedStorageMigrationStatus prometheus.Gauge
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -691,6 +695,12 @@ func init() {
|
||||
Help: "total amount of repositories",
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
|
||||
MUnifiedStorageMigrationStatus = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "unified_storage_migration_status",
|
||||
Help: "indicates whether this instance would run unified storage migrations (0=undefined, 1=migration disabled, 2=would run)",
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
}
|
||||
|
||||
// SetBuildInformation sets the build information for this binary
|
||||
@@ -829,5 +839,6 @@ func initMetricVars(reg prometheus.Registerer) {
|
||||
MStatTotalRepositories,
|
||||
MFolderIDsAPICount,
|
||||
MFolderIDsServiceCount,
|
||||
MUnifiedStorageMigrationStatus,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -319,6 +319,7 @@ type PanelDTO struct {
|
||||
HideFromList bool `json:"hideFromList"`
|
||||
Sort int `json:"sort"`
|
||||
SkipDataQuery bool `json:"skipDataQuery"`
|
||||
Suggestions bool `json:"suggestions,omitempty"`
|
||||
ReleaseState string `json:"state"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Signature string `json:"signature"`
|
||||
|
||||
@@ -105,6 +105,7 @@ type JSONData struct {
|
||||
|
||||
// Panel settings
|
||||
SkipDataQuery bool `json:"skipDataQuery"`
|
||||
Suggestions bool `json:"suggestions,omitempty"`
|
||||
|
||||
// App settings
|
||||
AutoEnabled bool `json:"autoEnabled"`
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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
|
||||
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
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,13 +1,11 @@
|
||||
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"
|
||||
@@ -16,16 +14,13 @@ 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"
|
||||
@@ -34,15 +29,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupMutation = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupValidation = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupMutation = (*APIBuilder)(nil)
|
||||
)
|
||||
|
||||
type APIBuilder struct {
|
||||
authorizer authorizer.Authorizer
|
||||
legacyStars *legacy.DashboardStarsStorage
|
||||
datasourceStacksValidator builder.APIGroupValidation
|
||||
authorizer authorizer.Authorizer
|
||||
legacyStars *legacy.DashboardStarsStorage
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
@@ -52,8 +45,6 @@ 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
|
||||
@@ -61,15 +52,11 @@ 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},
|
||||
"datasources": {utils.UserResourceOwner},
|
||||
"stars": {utils.UserResourceOwner},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -107,60 +94,28 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
// Configure Stars Dual writer
|
||||
starsResource := collections.StarsResourceInfo
|
||||
resource := collections.StarsResourceInfo
|
||||
var stars grafanarest.Storage
|
||||
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, starsResource, opts.OptsGetter)
|
||||
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, 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(starsResource.GroupResource(), b.legacyStars, stars)
|
||||
stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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
|
||||
storage[resource.StoragePath()] = stars
|
||||
storage[resource.StoragePath("update")] = &starsREST{store: stars}
|
||||
|
||||
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 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
|
||||
|
||||
})
|
||||
return b.authorizer
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
|
||||
@@ -85,7 +85,7 @@ func RegisterAPIService(
|
||||
accessControl,
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
|
||||
true,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,7 +15,6 @@ 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 (
|
||||
@@ -29,11 +28,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 the legacy datasource UID.
|
||||
// The name is {group}:{name}, see /pkg/apis/query/v0alpha1/connection.go#L34
|
||||
GetConnection(ctx context.Context, namespace string, name string) (*queryV0.DataSourceConnection, 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)
|
||||
// List lists all data sources the user in context can see
|
||||
ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error)
|
||||
}
|
||||
|
||||
type connectionAccess struct {
|
||||
@@ -75,11 +74,7 @@ func (s *connectionAccess) Get(ctx context.Context, name string, options *metav1
|
||||
}
|
||||
|
||||
func (s *connectionAccess) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
var fs fields.Selector
|
||||
if options != nil && options.FieldSelector != nil {
|
||||
fs = options.FieldSelector
|
||||
}
|
||||
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx), fs)
|
||||
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx))
|
||||
}
|
||||
|
||||
type connectionsProvider struct {
|
||||
@@ -108,47 +103,19 @@ func (q *connectionsProvider) GetConnection(ctx context.Context, namespace strin
|
||||
return q.asConnection(ds, namespace)
|
||||
}
|
||||
|
||||
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error) {
|
||||
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error) {
|
||||
ns, err := authlib.ParseNamespace(namespace)
|
||||
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
|
||||
}
|
||||
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,7 +88,6 @@ 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"
|
||||
@@ -477,7 +476,6 @@ var wireBasicSet = wire.NewSet(
|
||||
appregistry.WireSet,
|
||||
// Dashboard Kubernetes helpers
|
||||
dashboardclient.ProvideK8sClientWithFallback,
|
||||
datasourcesclient.ProvideDataSourceConnectionClientFactory,
|
||||
)
|
||||
|
||||
var wireSet = wire.NewSet(
|
||||
|
||||
Generated
+3
-6
File diff suppressed because one or more lines are too long
@@ -1,147 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// 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,6 +1884,14 @@ var (
|
||||
Owner: grafanaDatavizSquad,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "externalVizSuggestions",
|
||||
Description: "Enable all plugins to supply visualization suggestions (including 3rd party plugins)",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDatavizSquad,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "preventPanelChromeOverflow",
|
||||
Description: "Restrict PanelChrome contents with overflow: hidden;",
|
||||
|
||||
Generated
+1
@@ -256,6 +256,7 @@ cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
newGauge,experimental,@grafana/dataviz-squad,false,false,true
|
||||
newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
|
||||
externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true
|
||||
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
|
||||
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
|
||||
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
|
||||
|
+14
@@ -1383,6 +1383,20 @@
|
||||
"codeowner": "@grafana/identity-access-team"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "externalVizSuggestions",
|
||||
"resourceVersion": "1763498528748",
|
||||
"creationTimestamp": "2025-11-18T20:42:08Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable all plugins to supply visualization suggestions (including 3rd party plugins)",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dataviz-squad",
|
||||
"frontend": true,
|
||||
"expression": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "extraThemes",
|
||||
|
||||
@@ -221,7 +221,7 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode
|
||||
// Add Service Center as a standalone nav item under Alerts & IRM
|
||||
if alertsSection := treeRoot.FindById(navtree.NavIDAlertsAndIncidents); alertsSection != nil {
|
||||
serviceLink := &navtree.NavLink{
|
||||
Text: "Service Center",
|
||||
Text: "Service center",
|
||||
Id: "standalone-plugin-page-slo-services",
|
||||
Url: s.cfg.AppSubURL + "/a/grafana-slo-app/services",
|
||||
SortWeight: 1,
|
||||
|
||||
@@ -574,15 +574,6 @@ 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,6 +8,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/util/osutil"
|
||||
)
|
||||
|
||||
// nolint:unused
|
||||
var migratedUnifiedResources = []string{
|
||||
//"playlists.playlist.grafana.app",
|
||||
"folders.folder.grafana.app",
|
||||
@@ -58,14 +59,16 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
|
||||
|
||||
// Set indexer config for unified storage
|
||||
section := cfg.Raw.Section("unified_storage")
|
||||
// TODO: Re-enable once migrations are ready and disabled on cloud
|
||||
//cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
|
||||
cfg.DisableDataMigrations = true
|
||||
cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
|
||||
if !cfg.DisableDataMigrations && cfg.getUnifiedStorageType() == "unified" {
|
||||
cfg.enforceMigrationToUnifiedConfigs()
|
||||
// Helper log to find instances running migrations in the future
|
||||
cfg.Logger.Info("Unified migration configs not yet enforced")
|
||||
//cfg.enforceMigrationToUnifiedConfigs() // TODO: uncomment when ready for release
|
||||
} else {
|
||||
cfg.EnableSearch = section.Key("enable_search").MustBool(false)
|
||||
// Helper log to find instances disabling migration
|
||||
cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.getUnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations)
|
||||
}
|
||||
cfg.EnableSearch = section.Key("enable_search").MustBool(false)
|
||||
cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0)
|
||||
cfg.IndexPath = section.Key("index_path").String()
|
||||
cfg.IndexWorkers = section.Key("index_workers").MustInt(10)
|
||||
@@ -102,6 +105,7 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
|
||||
cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("")
|
||||
}
|
||||
|
||||
// nolint:unused
|
||||
// enforceMigrationToUnifiedConfigs enforces configurations required to run migrated resources in mode 5
|
||||
// All migrated resources in MigratedUnifiedResources are set to mode 5 and unified search is enabled
|
||||
func (cfg *Cfg) enforceMigrationToUnifiedConfigs() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/migrations/contract"
|
||||
@@ -55,8 +56,12 @@ func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error {
|
||||
|
||||
// skip migrations if disabled in config
|
||||
if p.cfg.DisableDataMigrations {
|
||||
metrics.MUnifiedStorageMigrationStatus.Set(1)
|
||||
logger.Info("Data migrations are disabled, skipping")
|
||||
return nil
|
||||
} else {
|
||||
metrics.MUnifiedStorageMigrationStatus.Set(2)
|
||||
logger.Info("Data migrations not yet enforced, skipping")
|
||||
}
|
||||
|
||||
// TODO: Re-enable once migrations are ready
|
||||
|
||||
@@ -66,6 +66,8 @@ func NewSimulationEngine() (*SimulationEngine, error) {
|
||||
newFlightSimInfo,
|
||||
newSinewaveInfo,
|
||||
newTankSimInfo,
|
||||
newNBodySimInfo,
|
||||
newGrot3dSimInfo,
|
||||
}
|
||||
|
||||
for _, init := range initializers {
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"bytes"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
//go:embed grot_mesh.json
|
||||
var grotMeshData []byte
|
||||
|
||||
//go:embed grot_base_color.png
|
||||
var grotBaseColor []byte
|
||||
|
||||
type grot3dSim struct {
|
||||
key simulationKey
|
||||
cfg grot3dConfig
|
||||
state grot3dState
|
||||
vertices []point3d
|
||||
uvs [][]float64
|
||||
indices []int
|
||||
texture image.Image
|
||||
}
|
||||
|
||||
var (
|
||||
_ Simulation = (*grot3dSim)(nil)
|
||||
)
|
||||
|
||||
type grot3dConfig struct {
|
||||
RotationSpeedX float64 `json:"rotationSpeedX"` // Rotation speed around X axis (degrees/second)
|
||||
RotationSpeedY float64 `json:"rotationSpeedY"` // Rotation speed around Y axis (degrees/second)
|
||||
RotationSpeedZ float64 `json:"rotationSpeedZ"` // Rotation speed around Z axis (degrees/second)
|
||||
MinAngleX float64 `json:"minAngleX"` // Minimum rotation angle for X axis (degrees)
|
||||
MaxAngleX float64 `json:"maxAngleX"` // Maximum rotation angle for X axis (degrees)
|
||||
MinAngleY float64 `json:"minAngleY"` // Minimum rotation angle for Y axis (degrees)
|
||||
MaxAngleY float64 `json:"maxAngleY"` // Maximum rotation angle for Y axis (degrees)
|
||||
MinAngleZ float64 `json:"minAngleZ"` // Minimum rotation angle for Z axis (degrees)
|
||||
MaxAngleZ float64 `json:"maxAngleZ"` // Maximum rotation angle for Z axis (degrees)
|
||||
LightX float64 `json:"lightX"` // Light direction X component
|
||||
LightY float64 `json:"lightY"` // Light direction Y component
|
||||
LightZ float64 `json:"lightZ"` // Light direction Z component
|
||||
AmbientLight float64 `json:"ambientLight"` // Ambient light level (0-1)
|
||||
ViewWidth float64 `json:"viewWidth"` // SVG viewBox width
|
||||
ViewHeight float64 `json:"viewHeight"` // SVG viewBox height
|
||||
Perspective float64 `json:"perspective"` // Perspective distance (larger = less perspective)
|
||||
Scale float64 `json:"scale"` // Overall scale multiplier
|
||||
}
|
||||
|
||||
type grot3dState struct {
|
||||
lastTime time.Time
|
||||
angleX float64 // Current rotation around X axis (radians)
|
||||
angleY float64 // Current rotation around Y axis (radians)
|
||||
angleZ float64 // Current rotation around Z axis (radians)
|
||||
directionX float64 // Direction multiplier for X rotation (+1 or -1)
|
||||
directionY float64 // Direction multiplier for Y rotation (+1 or -1)
|
||||
directionZ float64 // Direction multiplier for Z rotation (+1 or -1)
|
||||
}
|
||||
|
||||
type point3d struct {
|
||||
x, y, z float64
|
||||
}
|
||||
|
||||
type point2d struct {
|
||||
x, y float64
|
||||
}
|
||||
|
||||
type meshData struct {
|
||||
Vertices [][]float64 `json:"vertices"`
|
||||
Uvs [][]float64 `json:"uvs"`
|
||||
Indices []int `json:"indices"`
|
||||
}
|
||||
|
||||
type triangleWithDepth struct {
|
||||
v0, v1, v2 point2d
|
||||
depth float64
|
||||
visible bool
|
||||
idx0, idx1, idx2 int
|
||||
normal point3d
|
||||
}
|
||||
|
||||
func (s *grot3dSim) GetState() simulationState {
|
||||
return simulationState{
|
||||
Key: s.key,
|
||||
Config: s.cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grot3dSim) SetConfig(vals map[string]any) error {
|
||||
return updateConfigObjectFromJSON(&s.cfg, vals)
|
||||
}
|
||||
|
||||
func (s *grot3dSim) initialize() error {
|
||||
s.state.lastTime = time.Time{}
|
||||
s.state.angleX = 0
|
||||
s.state.angleY = 0
|
||||
s.state.angleZ = 0
|
||||
s.state.directionX = 1
|
||||
s.state.directionY = 1
|
||||
s.state.directionZ = 1
|
||||
|
||||
// Load mesh data if not already loaded
|
||||
if len(s.vertices) == 0 {
|
||||
var mesh meshData
|
||||
if err := json.Unmarshal(grotMeshData, &mesh); err != nil {
|
||||
return fmt.Errorf("failed to load grot holiday mesh data: %w", err)
|
||||
}
|
||||
|
||||
// Convert to point3d
|
||||
s.vertices = make([]point3d, len(mesh.Vertices))
|
||||
for i, v := range mesh.Vertices {
|
||||
if len(v) != 3 {
|
||||
return fmt.Errorf("invalid vertex data at index %d", i)
|
||||
}
|
||||
s.vertices[i] = point3d{x: v[0], y: v[1], z: v[2]}
|
||||
}
|
||||
|
||||
s.uvs = mesh.Uvs
|
||||
|
||||
if len(s.uvs) != len(s.vertices) {
|
||||
return fmt.Errorf("UV count mismatch: %d vs %d", len(s.uvs), len(s.vertices))
|
||||
}
|
||||
|
||||
s.indices = mesh.Indices
|
||||
}
|
||||
|
||||
// Load texture
|
||||
img, err := png.Decode(bytes.NewReader(grotBaseColor))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode texture: %w", err)
|
||||
}
|
||||
s.texture = img
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *grot3dSim) NewFrame(size int) *data.Frame {
|
||||
frame := data.NewFrame("")
|
||||
|
||||
// Time field
|
||||
frame.Fields = append(frame.Fields, data.NewField("time", nil, make([]time.Time, size)))
|
||||
|
||||
// SVG content field (string)
|
||||
frame.Fields = append(frame.Fields, data.NewField("svg_content", nil, make([]string, size)))
|
||||
|
||||
// Also add rotation angles for reference/debugging
|
||||
frame.Fields = append(frame.Fields, data.NewField("angle_x", nil, make([]float64, size)))
|
||||
frame.Fields = append(frame.Fields, data.NewField("angle_y", nil, make([]float64, size)))
|
||||
frame.Fields = append(frame.Fields, data.NewField("angle_z", nil, make([]float64, size)))
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
func (s *grot3dSim) GetValues(t time.Time) map[string]any {
|
||||
// Initialize if this is the first call
|
||||
if s.state.lastTime.IsZero() {
|
||||
s.state.lastTime = t
|
||||
}
|
||||
|
||||
// Calculate elapsed time and update rotation
|
||||
if t.After(s.state.lastTime) {
|
||||
dt := t.Sub(s.state.lastTime).Seconds()
|
||||
s.updateRotation(dt)
|
||||
s.state.lastTime = t
|
||||
} else if t.Before(s.state.lastTime) {
|
||||
// Can't go backwards - reinitialize
|
||||
s.initialize()
|
||||
s.state.lastTime = t
|
||||
}
|
||||
|
||||
// Generate the SVG content for the current rotation
|
||||
svgContent := s.generateSVG()
|
||||
|
||||
return map[string]any{
|
||||
"time": t,
|
||||
"svg_content": svgContent,
|
||||
"angle_x": s.state.angleX * 180 / math.Pi, // Convert to degrees for display
|
||||
"angle_y": s.state.angleY * 180 / math.Pi,
|
||||
"angle_z": s.state.angleZ * 180 / math.Pi,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grot3dSim) updateRotation(dt float64) {
|
||||
// Update X rotation
|
||||
if s.cfg.MinAngleX == 0 && s.cfg.MaxAngleX == 0 {
|
||||
// No limits - continuous rotation
|
||||
s.state.angleX += s.cfg.RotationSpeedX * dt * math.Pi / 180
|
||||
s.state.angleX = math.Mod(s.state.angleX, 2*math.Pi)
|
||||
} else {
|
||||
// Bouncing rotation with limits
|
||||
minAngleX := s.cfg.MinAngleX * math.Pi / 180
|
||||
maxAngleX := s.cfg.MaxAngleX * math.Pi / 180
|
||||
s.state.angleX += s.cfg.RotationSpeedX * dt * math.Pi / 180 * s.state.directionX
|
||||
if s.state.angleX >= maxAngleX {
|
||||
s.state.angleX = maxAngleX
|
||||
s.state.directionX = -1
|
||||
} else if s.state.angleX <= minAngleX {
|
||||
s.state.angleX = minAngleX
|
||||
s.state.directionX = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Update Y rotation
|
||||
if s.cfg.MinAngleY == 0 && s.cfg.MaxAngleY == 0 {
|
||||
// No limits - continuous rotation
|
||||
s.state.angleY += s.cfg.RotationSpeedY * dt * math.Pi / 180
|
||||
s.state.angleY = math.Mod(s.state.angleY, 2*math.Pi)
|
||||
} else {
|
||||
// Bouncing rotation with limits
|
||||
minAngleY := s.cfg.MinAngleY * math.Pi / 180
|
||||
maxAngleY := s.cfg.MaxAngleY * math.Pi / 180
|
||||
s.state.angleY += s.cfg.RotationSpeedY * dt * math.Pi / 180 * s.state.directionY
|
||||
if s.state.angleY >= maxAngleY {
|
||||
s.state.angleY = maxAngleY
|
||||
s.state.directionY = -1
|
||||
} else if s.state.angleY <= minAngleY {
|
||||
s.state.angleY = minAngleY
|
||||
s.state.directionY = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Update Z rotation
|
||||
if s.cfg.MinAngleZ == 0 && s.cfg.MaxAngleZ == 0 {
|
||||
// No limits - continuous rotation
|
||||
s.state.angleZ += s.cfg.RotationSpeedZ * dt * math.Pi / 180
|
||||
s.state.angleZ = math.Mod(s.state.angleZ, 2*math.Pi)
|
||||
} else {
|
||||
// Bouncing rotation with limits
|
||||
minAngleZ := s.cfg.MinAngleZ * math.Pi / 180
|
||||
maxAngleZ := s.cfg.MaxAngleZ * math.Pi / 180
|
||||
s.state.angleZ += s.cfg.RotationSpeedZ * dt * math.Pi / 180 * s.state.directionZ
|
||||
if s.state.angleZ >= maxAngleZ {
|
||||
s.state.angleZ = maxAngleZ
|
||||
s.state.directionZ = -1
|
||||
} else if s.state.angleZ <= minAngleZ {
|
||||
s.state.angleZ = minAngleZ
|
||||
s.state.directionZ = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rotatePoint3D applies 3D rotation around X, Y, and Z axes
|
||||
func (s *grot3dSim) rotatePoint3D(p point3d) point3d {
|
||||
// Rotate around X axis
|
||||
cosX, sinX := math.Cos(s.state.angleX), math.Sin(s.state.angleX)
|
||||
y := p.y*cosX - p.z*sinX
|
||||
z := p.y*sinX + p.z*cosX
|
||||
p.y, p.z = y, z
|
||||
|
||||
// Rotate around Y axis
|
||||
cosY, sinY := math.Cos(s.state.angleY), math.Sin(s.state.angleY)
|
||||
x := p.x*cosY + p.z*sinY
|
||||
z = -p.x*sinY + p.z*cosY
|
||||
p.x, p.z = x, z
|
||||
|
||||
// Rotate around Z axis
|
||||
cosZ, sinZ := math.Cos(s.state.angleZ), math.Sin(s.state.angleZ)
|
||||
x = p.x*cosZ - p.y*sinZ
|
||||
y = p.x*sinZ + p.y*cosZ
|
||||
p.x, p.y = x, y
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// project3DTo2D converts 3D point to 2D using perspective projection
|
||||
func (s *grot3dSim) project3DTo2D(p point3d) point2d {
|
||||
// Apply scale
|
||||
scaledP := point3d{
|
||||
x: p.x * s.cfg.Scale,
|
||||
y: p.y * s.cfg.Scale,
|
||||
z: p.z * s.cfg.Scale,
|
||||
}
|
||||
|
||||
// Apply perspective projection
|
||||
scale := s.cfg.Perspective / (s.cfg.Perspective + scaledP.z)
|
||||
|
||||
return point2d{
|
||||
x: scaledP.x*scale + s.cfg.ViewWidth/2,
|
||||
y: -scaledP.y*scale + s.cfg.ViewHeight/2, // Flip Y vertically (negative Y goes up)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grot3dSim) generateSVG() string {
|
||||
// Rotate all vertices
|
||||
rotatedVertices := make([]point3d, len(s.vertices))
|
||||
for i, v := range s.vertices {
|
||||
rotatedVertices[i] = s.rotatePoint3D(v)
|
||||
}
|
||||
|
||||
// Project to 2D
|
||||
projectedVertices := make([]point2d, len(rotatedVertices))
|
||||
for i, v := range rotatedVertices {
|
||||
projectedVertices[i] = s.project3DTo2D(v)
|
||||
}
|
||||
|
||||
// Process triangles for depth sorting and backface culling
|
||||
triangles := make([]triangleWithDepth, 0, len(s.indices)/3)
|
||||
|
||||
// Calculate near plane for clipping
|
||||
nearPlane := -s.cfg.Perspective * 0.9 / s.cfg.Scale
|
||||
|
||||
for i := 0; i < len(s.indices); i += 3 {
|
||||
idx0 := s.indices[i]
|
||||
idx1 := s.indices[i+1]
|
||||
idx2 := s.indices[i+2]
|
||||
|
||||
v0 := rotatedVertices[idx0]
|
||||
v1 := rotatedVertices[idx1]
|
||||
v2 := rotatedVertices[idx2]
|
||||
|
||||
// Near-plane clipping: skip triangles too close to camera
|
||||
if v0.z < nearPlane || v1.z < nearPlane || v2.z < nearPlane {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate triangle center depth for sorting
|
||||
centerZ := (v0.z + v1.z + v2.z) / 3
|
||||
|
||||
// Calculate face normal for backface culling
|
||||
// Two edges of the triangle
|
||||
edge1 := point3d{v1.x - v0.x, v1.y - v0.y, v1.z - v0.z}
|
||||
edge2 := point3d{v2.x - v0.x, v2.y - v0.y, v2.z - v0.z}
|
||||
|
||||
// Cross product gives normal
|
||||
normal := point3d{
|
||||
x: edge1.y*edge2.z - edge1.z*edge2.y,
|
||||
y: edge1.z*edge2.x - edge1.x*edge2.z,
|
||||
z: edge1.x*edge2.y - edge1.y*edge2.x,
|
||||
}
|
||||
|
||||
// Normalize the normal vector
|
||||
normalMag := math.Sqrt(normal.x*normal.x + normal.y*normal.y + normal.z*normal.z)
|
||||
if normalMag > 0 {
|
||||
normal.x /= normalMag
|
||||
normal.y /= normalMag
|
||||
normal.z /= normalMag
|
||||
}
|
||||
|
||||
// View vector (camera is looking along -Z axis)
|
||||
viewVector := point3d{0, 0, -1}
|
||||
|
||||
// Dot product of normal and view vector (now both are unit vectors)
|
||||
dotProduct := normal.x*viewVector.x + normal.y*viewVector.y + normal.z*viewVector.z
|
||||
|
||||
// Only render triangles facing the camera (backface culling)
|
||||
// Use small tolerance to catch edge-on triangles (dot product is now -1 to 1)
|
||||
visible := dotProduct < 0.2
|
||||
|
||||
triangles = append(triangles, triangleWithDepth{
|
||||
v0: projectedVertices[idx0],
|
||||
v1: projectedVertices[idx1],
|
||||
v2: projectedVertices[idx2],
|
||||
depth: centerZ,
|
||||
visible: visible,
|
||||
idx0: idx0,
|
||||
idx1: idx1,
|
||||
idx2: idx2,
|
||||
normal: normal,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort triangles by depth (painter's algorithm - draw furthest first)
|
||||
for i := 0; i < len(triangles); i++ {
|
||||
for j := i + 1; j < len(triangles); j++ {
|
||||
if triangles[i].depth > triangles[j].depth {
|
||||
triangles[i], triangles[j] = triangles[j], triangles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build SVG string
|
||||
svg := fmt.Sprintf("<svg viewBox='0 0 %.0f %.0f' xmlns='http://www.w3.org/2000/svg' stroke='none'>",
|
||||
s.cfg.ViewWidth, s.cfg.ViewHeight)
|
||||
|
||||
// Calculate colors for all visible triangles and group by color
|
||||
type triangleWithColor struct {
|
||||
tri triangleWithDepth
|
||||
color string
|
||||
opacity string
|
||||
}
|
||||
|
||||
coloredTriangles := make([]triangleWithColor, 0, len(triangles))
|
||||
bounds := s.texture.Bounds()
|
||||
|
||||
for _, tri := range triangles {
|
||||
if !tri.visible {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate lighting intensity
|
||||
// Normalize light direction
|
||||
lightDir := point3d{x: s.cfg.LightX, y: s.cfg.LightY, z: s.cfg.LightZ}
|
||||
lightMag := math.Sqrt(lightDir.x*lightDir.x + lightDir.y*lightDir.y + lightDir.z*lightDir.z)
|
||||
if lightMag > 0 {
|
||||
lightDir.x /= lightMag
|
||||
lightDir.y /= lightMag
|
||||
lightDir.z /= lightMag
|
||||
}
|
||||
|
||||
// Diffuse lighting (Lambert) - dot product of normal and light direction
|
||||
diffuse := math.Max(0, -(tri.normal.x*lightDir.x + tri.normal.y*lightDir.y + tri.normal.z*lightDir.z))
|
||||
|
||||
// Combine ambient and diffuse
|
||||
intensity := s.cfg.AmbientLight + (1.0-s.cfg.AmbientLight)*diffuse
|
||||
intensity = math.Max(0, math.Min(1, intensity))
|
||||
|
||||
// Get centroid UV
|
||||
uv0 := s.uvs[tri.idx0]
|
||||
uv1 := s.uvs[tri.idx1]
|
||||
uv2 := s.uvs[tri.idx2]
|
||||
|
||||
centU := (uv0[0] + uv1[0] + uv2[0]) / 3
|
||||
centV := (uv0[1] + uv1[1] + uv2[1]) / 3
|
||||
|
||||
// Clamp UVs to 0-1
|
||||
centU = math.Max(0, math.Min(1, centU))
|
||||
centV = math.Max(0, math.Min(1, centV))
|
||||
|
||||
// Sample texture - no V flip
|
||||
x := int(centU * float64(bounds.Dx()-1))
|
||||
y := int(centV * float64(bounds.Dy()-1))
|
||||
|
||||
c := s.texture.At(x, y).(color.RGBA)
|
||||
|
||||
// Apply depth intensity to the sampled color
|
||||
r := int(float64(c.R) * intensity)
|
||||
g := int(float64(c.G) * intensity)
|
||||
b := int(float64(c.B) * intensity)
|
||||
|
||||
// Quantize colors to reduce palette (round to nearest 8)
|
||||
r = (r / 8) * 8
|
||||
g = (g / 8) * 8
|
||||
b = (b / 8) * 8
|
||||
|
||||
colorStr := fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
|
||||
opacityStr := ""
|
||||
if c.A < 255 {
|
||||
opacityStr = fmt.Sprintf("%.2f", float64(c.A)/255)
|
||||
}
|
||||
|
||||
coloredTriangles = append(coloredTriangles, triangleWithColor{
|
||||
tri: tri,
|
||||
color: colorStr,
|
||||
opacity: opacityStr,
|
||||
})
|
||||
}
|
||||
|
||||
// Group triangles by color and render
|
||||
i := 0
|
||||
for i < len(coloredTriangles) {
|
||||
currentColor := coloredTriangles[i].color
|
||||
currentOpacity := coloredTriangles[i].opacity
|
||||
|
||||
// Build path data for all triangles with the same color
|
||||
pathData := ""
|
||||
for i < len(coloredTriangles) &&
|
||||
coloredTriangles[i].color == currentColor &&
|
||||
coloredTriangles[i].opacity == currentOpacity {
|
||||
|
||||
tri := coloredTriangles[i].tri
|
||||
pathData += fmt.Sprintf(
|
||||
"M%.2f,%.2fL%.2f,%.2fL%.2f,%.2fZ",
|
||||
tri.v0.x, tri.v0.y,
|
||||
tri.v1.x, tri.v1.y,
|
||||
tri.v2.x, tri.v2.y,
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
// Output single path with all triangles
|
||||
if currentOpacity != "" {
|
||||
svg += fmt.Sprintf("<path fill='%s' opacity='%s' d='%s'/>", currentColor, currentOpacity, pathData)
|
||||
} else {
|
||||
svg += fmt.Sprintf("<path fill='%s' d='%s'/>", currentColor, pathData)
|
||||
}
|
||||
}
|
||||
|
||||
svg += "</svg>"
|
||||
return svg
|
||||
}
|
||||
|
||||
func (s *grot3dSim) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newGrot3dSimInfo() simulationInfo {
|
||||
return simulationInfo{
|
||||
Type: "grot3d",
|
||||
Name: "Rotating 3D Grot",
|
||||
Description: "Renders a rotating 3D grot model using SVG triangles",
|
||||
OnlyForward: false,
|
||||
ConfigFields: data.NewFrame("config",
|
||||
data.NewField("rotationSpeedX", nil, []float64{0}),
|
||||
data.NewField("rotationSpeedY", nil, []float64{5}),
|
||||
data.NewField("rotationSpeedZ", nil, []float64{30}),
|
||||
data.NewField("minAngleX", nil, []float64{-45}),
|
||||
data.NewField("maxAngleX", nil, []float64{45}),
|
||||
data.NewField("minAngleY", nil, []float64{-45}),
|
||||
data.NewField("maxAngleY", nil, []float64{45}),
|
||||
data.NewField("minAngleZ", nil, []float64{0}),
|
||||
data.NewField("maxAngleZ", nil, []float64{0}),
|
||||
data.NewField("lightX", nil, []float64{-1}),
|
||||
data.NewField("lightY", nil, []float64{-1}),
|
||||
data.NewField("lightZ", nil, []float64{1}),
|
||||
data.NewField("ambientLight", nil, []float64{0.3}),
|
||||
data.NewField("viewWidth", nil, []float64{800}),
|
||||
data.NewField("viewHeight", nil, []float64{800}),
|
||||
data.NewField("perspective", nil, []float64{1000}),
|
||||
data.NewField("scale", nil, []float64{5.0}),
|
||||
),
|
||||
create: func(state simulationState) (Simulation, error) {
|
||||
sim := &grot3dSim{
|
||||
key: state.Key,
|
||||
cfg: grot3dConfig{
|
||||
RotationSpeedX: 0,
|
||||
RotationSpeedY: 5,
|
||||
RotationSpeedZ: 30,
|
||||
MinAngleX: -45,
|
||||
MaxAngleX: 45,
|
||||
MinAngleY: -45,
|
||||
MaxAngleY: 45,
|
||||
MinAngleZ: 0,
|
||||
MaxAngleZ: 0,
|
||||
LightX: -1,
|
||||
LightY: -1,
|
||||
LightZ: -1,
|
||||
AmbientLight: 0.5,
|
||||
ViewWidth: 800,
|
||||
ViewHeight: 800,
|
||||
Perspective: 1000,
|
||||
Scale: 5.0,
|
||||
},
|
||||
}
|
||||
|
||||
if state.Config != nil {
|
||||
vals, ok := state.Config.(map[string]any)
|
||||
if ok {
|
||||
err := sim.SetConfig(vals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := sim.initialize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sim, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,429 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
type nbodySim struct {
|
||||
key simulationKey
|
||||
cfg nbodyConfig
|
||||
state nbodyState
|
||||
random *rand.Rand
|
||||
}
|
||||
|
||||
var (
|
||||
_ Simulation = (*nbodySim)(nil)
|
||||
)
|
||||
|
||||
type nbodyConfig struct {
|
||||
N int `json:"n"` // number of bodies
|
||||
Width float64 `json:"width"` // boundary width in pixels
|
||||
Height float64 `json:"height"` // boundary height in pixels
|
||||
Seed int64 `json:"seed"` // random seed for reproducibility
|
||||
}
|
||||
|
||||
type circle struct {
|
||||
x float64 // x position
|
||||
y float64 // y position
|
||||
vx float64 // x velocity
|
||||
vy float64 // y velocity
|
||||
radius float64 // radius
|
||||
mass float64 // mass (proportional to radius^2 for simplicity)
|
||||
rotation float64 // current rotation angle in degrees (0-360)
|
||||
}
|
||||
|
||||
type nbodyState struct {
|
||||
lastTime time.Time
|
||||
circles []circle
|
||||
}
|
||||
|
||||
func (s *nbodySim) GetState() simulationState {
|
||||
return simulationState{
|
||||
Key: s.key,
|
||||
Config: s.cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nbodySim) SetConfig(vals map[string]any) error {
|
||||
oldCfg := s.cfg
|
||||
err := updateConfigObjectFromJSON(&s.cfg, vals)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If configuration changed, reinitialize the simulation
|
||||
if oldCfg.N != s.cfg.N || oldCfg.Width != s.cfg.Width || oldCfg.Height != s.cfg.Height || oldCfg.Seed != s.cfg.Seed {
|
||||
s.initialize()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *nbodySim) initialize() {
|
||||
s.random = rand.New(rand.NewSource(s.cfg.Seed))
|
||||
s.state.circles = make([]circle, s.cfg.N)
|
||||
s.state.lastTime = time.Time{}
|
||||
|
||||
const maxRadius = 30.0
|
||||
const bossRadius = maxRadius * 2.0 // Boss is twice the max radius (60 pixels)
|
||||
|
||||
// Generate random circles (first one is the boss, rest are normal)
|
||||
for i := 0; i < s.cfg.N; i++ {
|
||||
var radius float64
|
||||
|
||||
// First circle is always the "boss" with double radius
|
||||
if i == 0 || i == 1 {
|
||||
radius = bossRadius
|
||||
} else {
|
||||
// Random radius between 5 and 30 pixels for normal circles
|
||||
radius = 5.0 + s.random.Float64()*25.0
|
||||
}
|
||||
|
||||
// Random position ensuring the circle is within bounds
|
||||
x := radius + s.random.Float64()*(s.cfg.Width-2*radius)
|
||||
y := radius + s.random.Float64()*(s.cfg.Height-2*radius)
|
||||
|
||||
// Random velocity between -250 and 250 pixels/second
|
||||
vx := (s.random.Float64()*2.0 - 1.0) * 250.0
|
||||
vy := (s.random.Float64()*2.0 - 1.0) * 250.0
|
||||
|
||||
// Mass proportional to area (radius squared)
|
||||
mass := radius * radius
|
||||
|
||||
// Initial rotation based on initial velocity
|
||||
rotation := math.Atan2(vy, vx) * 180.0 / math.Pi
|
||||
if rotation < 0 {
|
||||
rotation += 360.0
|
||||
}
|
||||
|
||||
s.state.circles[i] = circle{
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
radius: radius,
|
||||
mass: mass,
|
||||
rotation: rotation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nbodySim) NewFrame(size int) *data.Frame {
|
||||
frame := data.NewFrame("")
|
||||
|
||||
// Time field - create with length=size for pre-allocated storage
|
||||
frame.Fields = append(frame.Fields, data.NewField("time", nil, make([]time.Time, size)))
|
||||
|
||||
// For each circle, add position, bounding box, size, velocity, and rotation fields with pre-allocated storage
|
||||
for i := 0; i < s.cfg.N; i++ {
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_x", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_y", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_left", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_top", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_diameter", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_velocity", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_rotation", i), nil, make([]float64, size)),
|
||||
)
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
func (s *nbodySim) GetValues(t time.Time) map[string]any {
|
||||
// Initialize if this is the first call
|
||||
if s.state.lastTime.IsZero() {
|
||||
s.state.lastTime = t
|
||||
if len(s.state.circles) == 0 {
|
||||
s.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate elapsed time in seconds
|
||||
if t.After(s.state.lastTime) {
|
||||
dt := t.Sub(s.state.lastTime).Seconds()
|
||||
s.simulate(dt)
|
||||
s.state.lastTime = t
|
||||
} else if t.Before(s.state.lastTime) {
|
||||
// Can't go backwards - reinitialize
|
||||
s.initialize()
|
||||
s.state.lastTime = t
|
||||
}
|
||||
|
||||
// Build result map
|
||||
result := map[string]any{
|
||||
"time": t,
|
||||
}
|
||||
|
||||
for i := 0; i < len(s.state.circles); i++ {
|
||||
c := s.state.circles[i]
|
||||
// Calculate velocity magnitude: sqrt(vx^2 + vy^2)
|
||||
velocity := math.Sqrt(c.vx*c.vx + c.vy*c.vy)
|
||||
|
||||
// Center position
|
||||
result[fmt.Sprintf("circle_%d_x", i)] = c.x
|
||||
result[fmt.Sprintf("circle_%d_y", i)] = c.y
|
||||
|
||||
// Top-left corner of bounding box (for easier canvas positioning)
|
||||
result[fmt.Sprintf("circle_%d_left", i)] = c.x - c.radius
|
||||
result[fmt.Sprintf("circle_%d_top", i)] = c.y - c.radius
|
||||
|
||||
// Size, velocity, and rotation (smoothed rotation from simulate)
|
||||
result[fmt.Sprintf("circle_%d_diameter", i)] = c.radius * 2.0
|
||||
result[fmt.Sprintf("circle_%d_velocity", i)] = velocity
|
||||
result[fmt.Sprintf("circle_%d_rotation", i)] = c.rotation
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *nbodySim) simulate(dt float64) {
|
||||
// Don't simulate too large time steps
|
||||
if dt > 1.0 {
|
||||
dt = 1.0
|
||||
}
|
||||
|
||||
// Use smaller sub-steps for more accurate collision detection
|
||||
steps := int(math.Ceil(dt * 60)) // 60 sub-steps per second
|
||||
if steps < 1 {
|
||||
steps = 1
|
||||
}
|
||||
subDt := dt / float64(steps)
|
||||
|
||||
for step := 0; step < steps; step++ {
|
||||
// Calculate and apply gravitational forces between all pairs
|
||||
// G scaled for pixel world: smaller masses, pixel distances
|
||||
const G = 5000.0 // Gravitational constant scaled for our pixel world
|
||||
|
||||
for i := 0; i < len(s.state.circles); i++ {
|
||||
for j := i + 1; j < len(s.state.circles); j++ {
|
||||
c1 := &s.state.circles[i]
|
||||
c2 := &s.state.circles[j]
|
||||
|
||||
// Calculate distance between centers
|
||||
dx := c2.x - c1.x
|
||||
dy := c2.y - c1.y
|
||||
distSq := dx*dx + dy*dy
|
||||
|
||||
// Avoid division by zero and extremely strong forces at close range
|
||||
const minDist = 10.0 // Minimum distance to prevent extreme forces
|
||||
if distSq < minDist*minDist {
|
||||
distSq = minDist * minDist
|
||||
}
|
||||
|
||||
dist := math.Sqrt(distSq)
|
||||
|
||||
// Calculate gravitational force magnitude: F = G * m1 * m2 / r^2
|
||||
force := G * c1.mass * c2.mass / distSq
|
||||
|
||||
// Calculate force components (normalized direction * force)
|
||||
fx := (dx / dist) * force
|
||||
fy := (dy / dist) * force
|
||||
|
||||
// Apply acceleration to both particles (F = ma -> a = F/m)
|
||||
// c1 is attracted to c2 (positive direction)
|
||||
c1.vx += (fx / c1.mass) * subDt
|
||||
c1.vy += (fy / c1.mass) * subDt
|
||||
|
||||
// c2 is attracted to c1 (negative direction, by Newton's 3rd law)
|
||||
c2.vx -= (fx / c2.mass) * subDt
|
||||
c2.vy -= (fy / c2.mass) * subDt
|
||||
}
|
||||
}
|
||||
|
||||
// Update positions
|
||||
for i := range s.state.circles {
|
||||
s.state.circles[i].x += s.state.circles[i].vx * subDt
|
||||
s.state.circles[i].y += s.state.circles[i].vy * subDt
|
||||
}
|
||||
|
||||
// Handle wall collisions
|
||||
for i := range s.state.circles {
|
||||
c := &s.state.circles[i]
|
||||
|
||||
// Left/right walls (perfectly elastic - no energy loss)
|
||||
if c.x-c.radius < 0 {
|
||||
c.x = c.radius
|
||||
c.vx = math.Abs(c.vx)
|
||||
} else if c.x+c.radius > s.cfg.Width {
|
||||
c.x = s.cfg.Width - c.radius
|
||||
c.vx = -math.Abs(c.vx)
|
||||
}
|
||||
|
||||
// Top/bottom walls (perfectly elastic - no energy loss)
|
||||
if c.y-c.radius < 0 {
|
||||
c.y = c.radius
|
||||
c.vy = math.Abs(c.vy)
|
||||
} else if c.y+c.radius > s.cfg.Height {
|
||||
c.y = s.cfg.Height - c.radius
|
||||
c.vy = -math.Abs(c.vy)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle circle-to-circle collisions
|
||||
for i := 0; i < len(s.state.circles); i++ {
|
||||
for j := i + 1; j < len(s.state.circles); j++ {
|
||||
c1 := &s.state.circles[i]
|
||||
c2 := &s.state.circles[j]
|
||||
|
||||
// Calculate distance between centers
|
||||
dx := c2.x - c1.x
|
||||
dy := c2.y - c1.y
|
||||
distSq := dx*dx + dy*dy
|
||||
minDist := c1.radius + c2.radius
|
||||
|
||||
// Check for collision
|
||||
if distSq < minDist*minDist && distSq > 0 {
|
||||
dist := math.Sqrt(distSq)
|
||||
|
||||
// Normalize collision vector
|
||||
nx := dx / dist
|
||||
ny := dy / dist
|
||||
|
||||
// Separate the circles so they don't overlap
|
||||
overlap := minDist - dist
|
||||
c1.x -= nx * overlap * 0.5
|
||||
c1.y -= ny * overlap * 0.5
|
||||
c2.x += nx * overlap * 0.5
|
||||
c2.y += ny * overlap * 0.5
|
||||
|
||||
// Calculate relative velocity
|
||||
dvx := c2.vx - c1.vx
|
||||
dvy := c2.vy - c1.vy
|
||||
|
||||
// Calculate relative velocity in collision normal direction
|
||||
dvn := dvx*nx + dvy*ny
|
||||
|
||||
// Do not resolve if velocities are separating
|
||||
if dvn > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate impulse scalar (perfectly elastic collision)
|
||||
restitution := 1.0 // coefficient of restitution (1.0 = perfectly elastic, no energy loss)
|
||||
impulse := (1 + restitution) * dvn / (1/c1.mass + 1/c2.mass)
|
||||
|
||||
// Apply impulse
|
||||
c1.vx += impulse * nx / c1.mass
|
||||
c1.vy += impulse * ny / c1.mass
|
||||
c2.vx -= impulse * nx / c2.mass
|
||||
c2.vy -= impulse * ny / c2.mass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update rotations smoothly based on velocity direction
|
||||
// Maximum rotation change per sub-step (in degrees)
|
||||
// At 60 sub-steps/sec, 1.5 degrees/step = 90 degrees/second max
|
||||
const maxRotationChange = 5
|
||||
|
||||
for i := range s.state.circles {
|
||||
c := &s.state.circles[i]
|
||||
|
||||
// Calculate target rotation from velocity vector
|
||||
targetRotation := math.Atan2(c.vy, c.vx) * 180.0 / math.Pi
|
||||
if targetRotation < 0 {
|
||||
targetRotation += 360.0
|
||||
}
|
||||
|
||||
// Calculate the shortest angular difference (handles wrap-around)
|
||||
diff := targetRotation - c.rotation
|
||||
if diff > 180.0 {
|
||||
diff -= 360.0
|
||||
} else if diff < -180.0 {
|
||||
diff += 360.0
|
||||
}
|
||||
|
||||
// Clamp the rotation change
|
||||
if diff > maxRotationChange {
|
||||
diff = maxRotationChange
|
||||
} else if diff < -maxRotationChange {
|
||||
diff = -maxRotationChange
|
||||
}
|
||||
|
||||
// Apply the clamped rotation change
|
||||
c.rotation += diff
|
||||
|
||||
// Keep rotation in 0-360 range
|
||||
if c.rotation >= 360.0 {
|
||||
c.rotation -= 360.0
|
||||
} else if c.rotation < 0 {
|
||||
c.rotation += 360.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nbodySim) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newNBodySimInfo() simulationInfo {
|
||||
defaultCfg := nbodyConfig{
|
||||
N: 10,
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Seed: 12345,
|
||||
}
|
||||
|
||||
// Create config frame that describes the available configuration fields
|
||||
df := data.NewFrame("")
|
||||
df.Fields = append(df.Fields, data.NewField("n", nil, []int64{int64(defaultCfg.N)}))
|
||||
df.Fields = append(df.Fields, data.NewField("width", nil, []float64{defaultCfg.Width}).SetConfig(&data.FieldConfig{
|
||||
Unit: "px",
|
||||
}))
|
||||
df.Fields = append(df.Fields, data.NewField("height", nil, []float64{defaultCfg.Height}).SetConfig(&data.FieldConfig{
|
||||
Unit: "px",
|
||||
}))
|
||||
df.Fields = append(df.Fields, data.NewField("seed", nil, []int64{defaultCfg.Seed}))
|
||||
|
||||
return simulationInfo{
|
||||
Type: "nbody",
|
||||
Name: "N-Body",
|
||||
Description: "N-body collision simulation with circles bouncing in a bounded space",
|
||||
ConfigFields: df,
|
||||
OnlyForward: false,
|
||||
create: func(cfg simulationState) (Simulation, error) {
|
||||
s := &nbodySim{
|
||||
key: cfg.Key,
|
||||
cfg: defaultCfg,
|
||||
}
|
||||
err := updateConfigObjectFromJSON(&s.cfg, cfg.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if s.cfg.N <= 0 {
|
||||
return nil, fmt.Errorf("n must be positive")
|
||||
}
|
||||
if s.cfg.Width <= 0 || s.cfg.Height <= 0 {
|
||||
return nil, fmt.Errorf("width and height must be positive")
|
||||
}
|
||||
if s.cfg.N > 100 {
|
||||
return nil, fmt.Errorf("n is too large (max 100)")
|
||||
}
|
||||
|
||||
s.initialize()
|
||||
return s, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNBodyQuery(t *testing.T) {
|
||||
s, err := NewSimulationEngine()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("simple nbody simulation", func(t *testing.T) {
|
||||
sq := &simulationQuery{}
|
||||
sq.Key = simulationKey{
|
||||
Type: "nbody",
|
||||
TickHZ: 10,
|
||||
}
|
||||
sq.Config = map[string]any{
|
||||
"n": 5,
|
||||
"width": 400.0,
|
||||
"height": 300.0,
|
||||
"seed": 42,
|
||||
}
|
||||
|
||||
sb, err := json.Marshal(map[string]any{
|
||||
"sim": sq,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Date(2020, time.January, 10, 23, 0, 0, 0, time.UTC)
|
||||
qr := &backend.QueryDataRequest{
|
||||
Queries: []backend.DataQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
TimeRange: backend.TimeRange{
|
||||
From: start,
|
||||
To: start.Add(time.Second * 2),
|
||||
},
|
||||
Interval: 100 * time.Millisecond,
|
||||
MaxDataPoints: 20,
|
||||
JSON: sb,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rsp, err := s.QueryData(context.Background(), qr)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rsp)
|
||||
|
||||
// Verify we got a response
|
||||
dr, ok := rsp.Responses["A"]
|
||||
require.True(t, ok)
|
||||
require.NoError(t, dr.Error)
|
||||
require.Len(t, dr.Frames, 1)
|
||||
|
||||
frame := dr.Frames[0]
|
||||
// Should have time + (x, y, left, top, diameter, velocity) for each of 5 circles = 31 fields
|
||||
require.Equal(t, 31, len(frame.Fields))
|
||||
|
||||
// Check field names
|
||||
require.Equal(t, "time", frame.Fields[0].Name)
|
||||
require.Equal(t, "circle_0_x", frame.Fields[1].Name)
|
||||
require.Equal(t, "circle_0_y", frame.Fields[2].Name)
|
||||
require.Equal(t, "circle_0_left", frame.Fields[3].Name)
|
||||
require.Equal(t, "circle_0_top", frame.Fields[4].Name)
|
||||
require.Equal(t, "circle_0_diameter", frame.Fields[5].Name)
|
||||
require.Equal(t, "circle_0_velocity", frame.Fields[6].Name)
|
||||
|
||||
// Verify we have data points
|
||||
require.Greater(t, frame.Fields[0].Len(), 0)
|
||||
})
|
||||
|
||||
t.Run("nbody with different configurations", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
n int
|
||||
width float64
|
||||
height float64
|
||||
seed int64
|
||||
}{
|
||||
{"small", 3, 200, 200, 1},
|
||||
{"medium", 10, 800, 600, 2},
|
||||
{"large", 20, 1000, 800, 3},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sq := &simulationQuery{}
|
||||
sq.Key = simulationKey{
|
||||
Type: "nbody",
|
||||
TickHZ: 10,
|
||||
}
|
||||
sq.Config = map[string]any{
|
||||
"n": tc.n,
|
||||
"width": tc.width,
|
||||
"height": tc.height,
|
||||
"seed": tc.seed,
|
||||
}
|
||||
|
||||
sb, err := json.Marshal(map[string]any{
|
||||
"sim": sq,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
qr := &backend.QueryDataRequest{
|
||||
Queries: []backend.DataQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
TimeRange: backend.TimeRange{
|
||||
From: start,
|
||||
To: start.Add(time.Second),
|
||||
},
|
||||
Interval: 100 * time.Millisecond,
|
||||
MaxDataPoints: 10,
|
||||
JSON: sb,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rsp, err := s.QueryData(context.Background(), qr)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rsp)
|
||||
|
||||
dr, ok := rsp.Responses["A"]
|
||||
require.True(t, ok)
|
||||
require.NoError(t, dr.Error)
|
||||
require.Len(t, dr.Frames, 1)
|
||||
|
||||
frame := dr.Frames[0]
|
||||
// Should have time + (x, y, left, top, diameter, velocity) for each of N circles = 1 + 6*N fields
|
||||
expectedFields := 1 + 6*tc.n
|
||||
require.Equal(t, expectedFields, len(frame.Fields))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nbody validates configuration", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
config map[string]any
|
||||
shouldError bool
|
||||
}{
|
||||
{"valid", map[string]any{"n": 5, "width": 400.0, "height": 300.0, "seed": 42}, false},
|
||||
{"zero n", map[string]any{"n": 0, "width": 400.0, "height": 300.0, "seed": 42}, true},
|
||||
{"negative n", map[string]any{"n": -5, "width": 400.0, "height": 300.0, "seed": 42}, true},
|
||||
{"zero width", map[string]any{"n": 5, "width": 0.0, "height": 300.0, "seed": 42}, true},
|
||||
{"negative height", map[string]any{"n": 5, "width": 400.0, "height": -300.0, "seed": 42}, true},
|
||||
{"too many bodies", map[string]any{"n": 150, "width": 400.0, "height": 300.0, "seed": 42}, true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sq := &simulationQuery{}
|
||||
sq.Key = simulationKey{
|
||||
Type: "nbody",
|
||||
TickHZ: 10,
|
||||
}
|
||||
sq.Config = tc.config
|
||||
|
||||
sb, err := json.Marshal(map[string]any{
|
||||
"sim": sq,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
qr := &backend.QueryDataRequest{
|
||||
Queries: []backend.DataQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
TimeRange: backend.TimeRange{
|
||||
From: start,
|
||||
To: start.Add(time.Second),
|
||||
},
|
||||
Interval: 100 * time.Millisecond,
|
||||
MaxDataPoints: 10,
|
||||
JSON: sb,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rsp, err := s.QueryData(context.Background(), qr)
|
||||
|
||||
if tc.shouldError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rsp)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNBodyCollisions(t *testing.T) {
|
||||
// Test that circles actually collide and bounce
|
||||
info := newNBodySimInfo()
|
||||
cfg := simulationState{
|
||||
Key: simulationKey{
|
||||
Type: "nbody",
|
||||
TickHZ: 10,
|
||||
},
|
||||
Config: map[string]any{
|
||||
"n": 2,
|
||||
"width": 200.0,
|
||||
"height": 200.0,
|
||||
"seed": 12345,
|
||||
},
|
||||
}
|
||||
|
||||
sim, err := info.create(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sim)
|
||||
|
||||
// Get initial values
|
||||
t0 := time.Now()
|
||||
v0 := sim.GetValues(t0)
|
||||
|
||||
// Simulate for 2 seconds
|
||||
t1 := t0.Add(2 * time.Second)
|
||||
v1 := sim.GetValues(t1)
|
||||
|
||||
// Verify that positions have changed (circles are moving)
|
||||
require.NotEqual(t, v0["circle_0_x"], v1["circle_0_x"])
|
||||
require.NotEqual(t, v0["circle_0_y"], v1["circle_0_y"])
|
||||
|
||||
// Verify diameters remain constant
|
||||
require.Equal(t, v0["circle_0_diameter"], v1["circle_0_diameter"])
|
||||
require.Equal(t, v0["circle_1_diameter"], v1["circle_1_diameter"])
|
||||
|
||||
sim.Close()
|
||||
}
|
||||
|
||||
@@ -182,8 +182,6 @@ 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':
|
||||
|
||||
@@ -81,6 +81,12 @@ export interface CanvasElementProps<TConfig = unknown, TData = unknown> {
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
/** Simple numeric size for element defaults - not persisted, just for initial sizing */
|
||||
export interface DefaultElementSize {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas item builder
|
||||
*
|
||||
@@ -89,7 +95,7 @@ export interface CanvasElementProps<TConfig = unknown, TData = unknown> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface CanvasElementItem<TConfig = any, TData = any> extends RegistryItem {
|
||||
/** The default width/height to use when adding */
|
||||
defaultSize?: Placement;
|
||||
defaultSize?: DefaultElementSize;
|
||||
|
||||
prepareData?: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<TConfig>) => TData;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2, PluginState } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { TextDimensionMode } from '@grafana/schema';
|
||||
import { ScalarDimensionMode, PositionDimensionMode, TextDimensionMode } from '@grafana/schema';
|
||||
import { Button, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
@@ -122,11 +122,11 @@ export const buttonItem: CanvasElementItem<ButtonConfig, ButtonData> = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 32,
|
||||
height: options?.placement?.height ?? 78,
|
||||
top: options?.placement?.top ?? 100,
|
||||
left: options?.placement?.left ?? 100,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 32, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 78, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { config } from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
@@ -94,11 +95,11 @@ export const cloudItem: CanvasElementItem = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 110,
|
||||
height: options?.placement?.height ?? 70,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 110, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 70, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScalarDimensionConfig } from '@grafana/schema';
|
||||
import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor';
|
||||
@@ -89,11 +89,11 @@ export const droneFrontItem: CanvasElementItem = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 26,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 26, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScalarDimensionConfig } from '@grafana/schema';
|
||||
import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor';
|
||||
@@ -88,11 +88,11 @@ export const droneSideItem: CanvasElementItem = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 26,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 26, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { config } from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
@@ -101,11 +102,11 @@ export const ellipseItem: CanvasElementItem<CanvasElementConfig, CanvasElementDa
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 160,
|
||||
height: options?.placement?.height ?? 138,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 160, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 138, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,13 @@ import { CSSProperties } from 'react';
|
||||
|
||||
import { LinkModel } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ColorDimensionConfig, ResourceDimensionConfig, ResourceDimensionMode } from '@grafana/schema';
|
||||
import {
|
||||
ColorDimensionConfig,
|
||||
ResourceDimensionConfig,
|
||||
ResourceDimensionMode,
|
||||
ScalarDimensionMode,
|
||||
PositionDimensionMode,
|
||||
} from '@grafana/schema';
|
||||
import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
@@ -76,11 +82,11 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 100,
|
||||
top: options?.placement?.top ?? 100,
|
||||
left: options?.placement?.left ?? 100,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { of } from 'rxjs';
|
||||
|
||||
import { DataFrame, FieldNamePickerConfigSettings, GrafanaTheme2, StandardEditorsRegistryItem } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { TextDimensionMode } from '@grafana/schema';
|
||||
import { TextDimensionMode, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { FieldNamePicker, frameHasName, getFrameFieldsDisplayNames } from '@grafana/ui/internal';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
@@ -171,9 +171,9 @@ export const metricValueItem: CanvasElementItem<TextConfig, TextData> = {
|
||||
placement: {
|
||||
width: options?.placement?.width,
|
||||
height: options?.placement?.height,
|
||||
top: options?.placement?.top ?? 100,
|
||||
left: options?.placement?.left ?? 100,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { config } from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
@@ -94,11 +95,11 @@ export const parallelogramItem: CanvasElementItem = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 250,
|
||||
height: options?.placement?.height ?? 150,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 250, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 150, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,12 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ColorDimensionConfig, ScalarDimensionConfig } from '@grafana/schema';
|
||||
import {
|
||||
ColorDimensionConfig,
|
||||
ScalarDimensionConfig,
|
||||
ScalarDimensionMode,
|
||||
PositionDimensionMode,
|
||||
} from '@grafana/schema';
|
||||
import config from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
@@ -76,11 +81,11 @@ export const serverItem: CanvasElementItem<ServerConfig, ServerData> = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 100,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
config: {
|
||||
type: ServerType.Single,
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { FieldNamePickerConfigSettings, GrafanaTheme2, StandardEditorsRegistryItem, textUtil } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { PositionDimensionMode, ScalarDimensionMode, TextDimensionConfig, TextDimensionMode } from '@grafana/schema';
|
||||
import { CodeEditor, InlineField, InlineFieldRow, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||
import { FieldNamePicker } from '@grafana/ui/internal';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
|
||||
import { CanvasElementItem, CanvasElementOptions, CanvasElementProps } from '../element';
|
||||
|
||||
// eslint-disable-next-line
|
||||
const dummyFieldSettings: StandardEditorsRegistryItem<string, FieldNamePickerConfigSettings> = {
|
||||
settings: {},
|
||||
} as StandardEditorsRegistryItem<string, FieldNamePickerConfigSettings>;
|
||||
|
||||
// Simple hash function to generate unique scope IDs
|
||||
function hashString(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
// Scope CSS classes to avoid conflicts between multiple SVG elements
|
||||
function scopeSvgClasses(content: string, scopeId: string): string {
|
||||
// Replace class definitions in style blocks (.classname)
|
||||
let scoped = content.replace(/\.([a-zA-Z_-][\w-]*)/g, (match, className) => {
|
||||
return `.${className}-${scopeId}`;
|
||||
});
|
||||
|
||||
// Replace class attributes (class="name1 name2")
|
||||
scoped = scoped.replace(/class="([^"]+)"/g, (match, classNames) => {
|
||||
const scopedNames = classNames
|
||||
.split(/\s+/)
|
||||
.map((name: string) => (name ? `${name}-${scopeId}` : ''))
|
||||
.join(' ');
|
||||
return `class="${scopedNames}"`;
|
||||
});
|
||||
|
||||
return scoped;
|
||||
}
|
||||
|
||||
export interface SvgConfig {
|
||||
content?: TextDimensionConfig;
|
||||
}
|
||||
|
||||
interface SvgData {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function SvgDisplay(props: CanvasElementProps<SvgConfig, SvgData>) {
|
||||
const { data } = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// Generate unique scope ID based on content hash
|
||||
const scopeId = useMemo(() => {
|
||||
if (!data?.content) {
|
||||
return '';
|
||||
}
|
||||
return hashString(data.content);
|
||||
}, [data?.content]);
|
||||
|
||||
if (!data?.content) {
|
||||
return (
|
||||
<div className={styles.placeholder}>{t('canvas.svg-element.placeholder', 'Double click to add SVG content')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if content already has an SVG wrapper
|
||||
const hasSvgWrapper = data.content.trim().toLowerCase().startsWith('<svg');
|
||||
|
||||
// Prepare content (wrap if needed)
|
||||
let contentToScope = data.content;
|
||||
if (!hasSvgWrapper) {
|
||||
contentToScope = `<svg width="100%" height="100%">${data.content}</svg>`;
|
||||
}
|
||||
|
||||
// Scope class names to prevent conflicts
|
||||
const scopedContent = scopeSvgClasses(contentToScope, scopeId);
|
||||
|
||||
// Sanitize the scoped content
|
||||
const sanitizedContent = textUtil.sanitizeSVGContent(scopedContent);
|
||||
|
||||
return <div className={styles.container} dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
}),
|
||||
placeholder: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
textAlign: 'center',
|
||||
padding: theme.spacing(1),
|
||||
border: `1px dashed ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
});
|
||||
|
||||
export const svgItem: CanvasElementItem<SvgConfig, SvgData> = {
|
||||
id: 'svg',
|
||||
name: 'SVG',
|
||||
description: 'Generic SVG element with sanitized content',
|
||||
|
||||
display: SvgDisplay,
|
||||
|
||||
hasEditMode: false,
|
||||
|
||||
defaultSize: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
|
||||
getNewOptions: (options) => ({
|
||||
...options,
|
||||
config: {
|
||||
content: {
|
||||
mode: TextDimensionMode.Fixed,
|
||||
fixed: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="currentColor" /></svg>',
|
||||
},
|
||||
},
|
||||
background: {
|
||||
color: {
|
||||
fixed: 'transparent',
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, mode: ScalarDimensionMode.Clamped, min: 0, max: 360 },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<SvgConfig>) => {
|
||||
const svgConfig = elementOptions.config;
|
||||
|
||||
const data: SvgData = {
|
||||
content: svgConfig?.content ? dimensionContext.getText(svgConfig.content).value() : '',
|
||||
};
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
registerOptionsUI: (builder) => {
|
||||
const category = [t('canvas.svg-element.category', 'SVG')];
|
||||
|
||||
builder.addCustomEditor({
|
||||
category,
|
||||
id: 'svgContent',
|
||||
path: 'config.content',
|
||||
name: t('canvas.svg-element.content', 'SVG Content'),
|
||||
description: t('canvas.svg-element.content-description', 'Enter SVG content or select a field.'),
|
||||
editor: ({ value, onChange, context }) => {
|
||||
const mode = value?.mode ?? TextDimensionMode.Fixed;
|
||||
const labelWidth = 9;
|
||||
|
||||
const modeOptions = [
|
||||
{
|
||||
label: t('canvas.svg-element.mode-fixed', 'Fixed'),
|
||||
value: TextDimensionMode.Fixed,
|
||||
description: t('canvas.svg-element.mode-fixed-description', 'Manually enter SVG content'),
|
||||
},
|
||||
{
|
||||
label: t('canvas.svg-element.mode-field', 'Field'),
|
||||
value: TextDimensionMode.Field,
|
||||
description: t('canvas.svg-element.mode-field-description', 'SVG content from data source field'),
|
||||
},
|
||||
];
|
||||
|
||||
const onModeChange = (newMode: TextDimensionMode) => {
|
||||
onChange({
|
||||
...value,
|
||||
mode: newMode,
|
||||
});
|
||||
};
|
||||
|
||||
const onFieldChange = (field?: string) => {
|
||||
onChange({
|
||||
...value,
|
||||
field,
|
||||
});
|
||||
};
|
||||
|
||||
const onFixedChange = (newValue: string) => {
|
||||
onChange({
|
||||
...value,
|
||||
mode: TextDimensionMode.Fixed,
|
||||
fixed: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineFieldRow>
|
||||
<InlineField label={t('canvas.svg-element.source', 'Source')} labelWidth={labelWidth} grow={true}>
|
||||
<RadioButtonGroup value={mode} options={modeOptions} onChange={onModeChange} fullWidth />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
|
||||
{mode === TextDimensionMode.Field && (
|
||||
<InlineFieldRow>
|
||||
<InlineField label={t('canvas.svg-element.field', 'Field')} labelWidth={labelWidth} grow={true}>
|
||||
<FieldNamePicker
|
||||
context={context}
|
||||
value={value?.field ?? ''}
|
||||
onChange={onFieldChange}
|
||||
item={dummyFieldSettings}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
|
||||
{mode === TextDimensionMode.Fixed && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<CodeEditor
|
||||
value={value?.fixed || ''}
|
||||
language="xml"
|
||||
height="200px"
|
||||
onBlur={onFixedChange}
|
||||
monacoOptions={{
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
folding: false,
|
||||
renderLineHighlight: 'none',
|
||||
overviewRulerBorder: false,
|
||||
hideCursorInOverviewRuler: true,
|
||||
overviewRulerLanes: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
settings: {},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { of } from 'rxjs';
|
||||
|
||||
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { Input, usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
@@ -145,11 +146,11 @@ export const textItem: CanvasElementItem<TextConfig, TextData> = {
|
||||
size: 16,
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 100,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { config } from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
@@ -95,11 +96,11 @@ export const triangleItem: CanvasElementItem = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 160,
|
||||
height: options?.placement?.height ?? 138,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 160, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 138, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScalarDimensionConfig } from '@grafana/schema';
|
||||
import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor';
|
||||
@@ -85,11 +85,11 @@ export const windTurbineItem: CanvasElementItem = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 155,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 155, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { metricValueItem } from './elements/metricValue';
|
||||
import { parallelogramItem } from './elements/parallelogram';
|
||||
import { rectangleItem } from './elements/rectangle';
|
||||
import { serverItem } from './elements/server/server';
|
||||
import { svgItem } from './elements/svg';
|
||||
import { textItem } from './elements/text';
|
||||
import { triangleItem } from './elements/triangle';
|
||||
import { windTurbineItem } from './elements/windTurbine';
|
||||
@@ -33,6 +34,7 @@ export const defaultElementItems = [
|
||||
triangleItem,
|
||||
cloudItem,
|
||||
parallelogramItem,
|
||||
svgItem,
|
||||
];
|
||||
|
||||
export const advancedElementItems = [buttonItem, windTurbineItem, droneTopItem, droneFrontItem, droneSideItem];
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
ActionType,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { TooltipDisplayMode } from '@grafana/schema';
|
||||
import {
|
||||
PositionDimensionConfig,
|
||||
PositionDimensionMode,
|
||||
ScalarDimensionMode,
|
||||
TooltipDisplayMode,
|
||||
} from '@grafana/schema';
|
||||
import { ConfirmModal, VariablesInputModal } from '@grafana/ui';
|
||||
import { LayerElement } from 'app/core/components/Layers/types';
|
||||
import { config } from 'app/core/config';
|
||||
@@ -74,6 +79,40 @@ export class ElementState implements LayerElement {
|
||||
showActionVarsModal = false;
|
||||
actionVars: ActionVariableInput = {};
|
||||
|
||||
// Cached values resolved from dimension context
|
||||
private cachedRotation = 0;
|
||||
private cachedTop = 0;
|
||||
private cachedLeft = 0;
|
||||
private cachedWidth = 100;
|
||||
private cachedHeight = 100;
|
||||
private cachedRight?: number;
|
||||
private cachedBottom?: number;
|
||||
|
||||
/** Check if a position property is field-driven (not fixed) */
|
||||
isPositionFieldDriven(prop: 'top' | 'left' | 'width' | 'height' | 'right' | 'bottom'): boolean {
|
||||
const pos = this.options.placement?.[prop];
|
||||
return pos?.mode === PositionDimensionMode.Field && !!pos?.field;
|
||||
}
|
||||
|
||||
/** Check if rotation is field-driven (has a field binding) */
|
||||
isRotationFieldDriven(): boolean {
|
||||
const rot = this.options.placement?.rotation;
|
||||
return !!rot?.field;
|
||||
}
|
||||
|
||||
/** Check if ANY position/size property is field-driven - if so, element can't be moved in editor */
|
||||
hasFieldDrivenPosition(): boolean {
|
||||
return (
|
||||
this.isPositionFieldDriven('top') ||
|
||||
this.isPositionFieldDriven('left') ||
|
||||
this.isPositionFieldDriven('width') ||
|
||||
this.isPositionFieldDriven('height') ||
|
||||
this.isPositionFieldDriven('right') ||
|
||||
this.isPositionFieldDriven('bottom') ||
|
||||
this.isRotationFieldDriven()
|
||||
);
|
||||
}
|
||||
|
||||
setActionVars = (vars: ActionVariableInput) => {
|
||||
this.actionVars = vars;
|
||||
this.forceUpdate();
|
||||
@@ -93,7 +132,13 @@ export class ElementState implements LayerElement {
|
||||
vertical: VerticalConstraint.Top,
|
||||
horizontal: HorizontalConstraint.Left,
|
||||
};
|
||||
options.placement = options.placement ?? { width: 100, height: 100, top: 0, left: 0, rotation: 0 };
|
||||
options.placement = options.placement ?? {
|
||||
width: { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
height: { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
top: { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
left: { fixed: 0, mode: PositionDimensionMode.Fixed },
|
||||
rotation: { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
};
|
||||
options.background = options.background ?? { color: { fixed: 'transparent' } };
|
||||
options.border = options.border ?? { color: { fixed: 'dark-green' } };
|
||||
|
||||
@@ -121,6 +166,18 @@ export class ElementState implements LayerElement {
|
||||
return this.options.name;
|
||||
}
|
||||
|
||||
/** Get the current rotation value (resolved from dimension context) */
|
||||
getRotation(): number {
|
||||
return this.cachedRotation;
|
||||
}
|
||||
|
||||
/** Set the fixed value of a PositionDimensionConfig */
|
||||
private setPositionFixed(pos: PositionDimensionConfig | undefined, value: number): void {
|
||||
if (pos) {
|
||||
pos.fixed = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Use the configured options to update CSS style properties directly on the wrapper div **/
|
||||
applyLayoutStylesToDiv(disablePointerEvents?: boolean) {
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
@@ -134,7 +191,6 @@ export class ElementState implements LayerElement {
|
||||
|
||||
const { constraint } = this.options;
|
||||
const { vertical, horizontal } = constraint ?? {};
|
||||
const placement: Placement = this.options.placement ?? {};
|
||||
|
||||
const editingEnabled = this.getScene()?.isEditingEnabled;
|
||||
|
||||
@@ -145,95 +201,64 @@ export class ElementState implements LayerElement {
|
||||
// Minimum element size is 10x10
|
||||
minWidth: '10px',
|
||||
minHeight: '10px',
|
||||
rotate: `${placement.rotation ?? 0}deg`,
|
||||
rotate: `${this.cachedRotation}deg`,
|
||||
};
|
||||
|
||||
const translate = ['0px', '0px'];
|
||||
|
||||
switch (vertical) {
|
||||
case VerticalConstraint.Top:
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.height = placement.height ?? 100;
|
||||
style.top = `${placement.top}px`;
|
||||
style.height = `${placement.height}px`;
|
||||
delete placement.bottom;
|
||||
style.top = `${this.cachedTop}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
break;
|
||||
case VerticalConstraint.Bottom:
|
||||
placement.bottom = placement.bottom ?? 0;
|
||||
placement.height = placement.height ?? 100;
|
||||
style.bottom = `${placement.bottom}px`;
|
||||
style.height = `${placement.height}px`;
|
||||
delete placement.top;
|
||||
style.bottom = `${this.cachedBottom ?? 0}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
break;
|
||||
case VerticalConstraint.TopBottom:
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.bottom = placement.bottom ?? 0;
|
||||
style.top = `${placement.top}px`;
|
||||
style.bottom = `${placement.bottom}px`;
|
||||
delete placement.height;
|
||||
style.top = `${this.cachedTop}px`;
|
||||
style.bottom = `${this.cachedBottom ?? 0}px`;
|
||||
style.height = '';
|
||||
break;
|
||||
case VerticalConstraint.Center:
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.height = placement.height ?? 100;
|
||||
translate[1] = '-50%';
|
||||
style.top = `calc(50% - ${placement.top}px)`;
|
||||
style.height = `${placement.height}px`;
|
||||
delete placement.bottom;
|
||||
style.top = `calc(50% - ${this.cachedTop}px)`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
break;
|
||||
case VerticalConstraint.Scale:
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.bottom = placement.bottom ?? 0;
|
||||
style.top = `${placement.top}%`;
|
||||
style.bottom = `${placement.bottom}%`;
|
||||
delete placement.height;
|
||||
style.top = `${this.cachedTop}%`;
|
||||
style.bottom = `${this.cachedBottom ?? 0}%`;
|
||||
style.height = '';
|
||||
break;
|
||||
}
|
||||
|
||||
switch (horizontal) {
|
||||
case HorizontalConstraint.Left:
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.width = placement.width ?? 100;
|
||||
style.left = `${placement.left}px`;
|
||||
style.width = `${placement.width}px`;
|
||||
delete placement.right;
|
||||
style.left = `${this.cachedLeft}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
break;
|
||||
case HorizontalConstraint.Right:
|
||||
placement.right = placement.right ?? 0;
|
||||
placement.width = placement.width ?? 100;
|
||||
style.right = `${placement.right}px`;
|
||||
style.width = `${placement.width}px`;
|
||||
delete placement.left;
|
||||
style.right = `${this.cachedRight ?? 0}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
break;
|
||||
case HorizontalConstraint.LeftRight:
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.right = placement.right ?? 0;
|
||||
style.left = `${placement.left}px`;
|
||||
style.right = `${placement.right}px`;
|
||||
delete placement.width;
|
||||
style.left = `${this.cachedLeft}px`;
|
||||
style.right = `${this.cachedRight ?? 0}px`;
|
||||
style.width = '';
|
||||
break;
|
||||
case HorizontalConstraint.Center:
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.width = placement.width ?? 100;
|
||||
translate[0] = '-50%';
|
||||
style.left = `calc(50% - ${placement.left}px)`;
|
||||
style.width = `${placement.width}px`;
|
||||
delete placement.right;
|
||||
style.left = `calc(50% - ${this.cachedLeft}px)`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
break;
|
||||
case HorizontalConstraint.Scale:
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.right = placement.right ?? 0;
|
||||
style.left = `${placement.left}%`;
|
||||
style.right = `${placement.right}%`;
|
||||
delete placement.width;
|
||||
style.left = `${this.cachedLeft}%`;
|
||||
style.right = `${this.cachedRight ?? 0}%`;
|
||||
style.width = '';
|
||||
break;
|
||||
}
|
||||
|
||||
style.transform = `translate(${translate[0]}, ${translate[1]})`;
|
||||
this.options.placement = placement;
|
||||
this.sizeStyle = style;
|
||||
|
||||
if (this.div) {
|
||||
@@ -267,7 +292,6 @@ export class ElementState implements LayerElement {
|
||||
|
||||
const { constraint } = this.options;
|
||||
const { vertical, horizontal } = constraint ?? {};
|
||||
const placement: Placement = this.options.placement ?? {};
|
||||
|
||||
const editingEnabled = scene?.isEditingEnabled;
|
||||
|
||||
@@ -275,7 +299,6 @@ export class ElementState implements LayerElement {
|
||||
cursor: editingEnabled ? 'grab' : 'auto',
|
||||
pointerEvents: disablePointerEvents ? 'none' : 'auto',
|
||||
position: 'absolute',
|
||||
// Minimum element size is 10x10
|
||||
minWidth: '10px',
|
||||
minHeight: '10px',
|
||||
};
|
||||
@@ -285,81 +308,50 @@ export class ElementState implements LayerElement {
|
||||
|
||||
switch (vertical) {
|
||||
case VerticalConstraint.Top:
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.height = placement.height ?? 100;
|
||||
transformY = `${placement.top ?? 0}px`;
|
||||
style.height = `${placement.height}px`;
|
||||
delete placement.bottom;
|
||||
transformY = `${this.cachedTop}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
break;
|
||||
case VerticalConstraint.Bottom:
|
||||
placement.bottom = placement.bottom ?? 0;
|
||||
placement.height = placement.height ?? 100;
|
||||
transformY = `${sceneHeight! - (placement.bottom ?? 0) - (placement.height ?? 100)}px`;
|
||||
style.height = `${placement.height}px`;
|
||||
delete placement.top;
|
||||
transformY = `${sceneHeight! - (this.cachedBottom ?? 0) - this.cachedHeight}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
break;
|
||||
case VerticalConstraint.TopBottom:
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.bottom = placement.bottom ?? 0;
|
||||
transformY = `${placement.top ?? 0}px`;
|
||||
style.height = `${sceneHeight! - (placement.top ?? 0) - (placement.bottom ?? 0)}px`;
|
||||
delete placement.height;
|
||||
transformY = `${this.cachedTop}px`;
|
||||
style.height = `${sceneHeight! - this.cachedTop - (this.cachedBottom ?? 0)}px`;
|
||||
break;
|
||||
case VerticalConstraint.Center:
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.height = placement.height ?? 100;
|
||||
transformY = `${sceneHeight! / 2 - (placement.top ?? 0) - (placement.height ?? 0) / 2}px`;
|
||||
style.height = `${placement.height}px`;
|
||||
delete placement.bottom;
|
||||
transformY = `${sceneHeight! / 2 - this.cachedTop - this.cachedHeight / 2}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
break;
|
||||
case VerticalConstraint.Scale:
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.bottom = placement.bottom ?? 0;
|
||||
transformY = `${(placement.top ?? 0) * (sceneHeight! / 100)}px`;
|
||||
style.height = `${sceneHeight! - (placement.top ?? 0) * (sceneHeight! / 100) - (placement.bottom ?? 0) * (sceneHeight! / 100)}px`;
|
||||
delete placement.height;
|
||||
transformY = `${this.cachedTop * (sceneHeight! / 100)}px`;
|
||||
style.height = `${sceneHeight! - this.cachedTop * (sceneHeight! / 100) - (this.cachedBottom ?? 0) * (sceneHeight! / 100)}px`;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (horizontal) {
|
||||
case HorizontalConstraint.Left:
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.width = placement.width ?? 100;
|
||||
transformX = `${placement.left ?? 0}px`;
|
||||
style.width = `${placement.width}px`;
|
||||
delete placement.right;
|
||||
transformX = `${this.cachedLeft}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
break;
|
||||
case HorizontalConstraint.Right:
|
||||
placement.right = placement.right ?? 0;
|
||||
placement.width = placement.width ?? 100;
|
||||
transformX = `${sceneWidth! - (placement.right ?? 0) - (placement.width ?? 100)}px`;
|
||||
style.width = `${placement.width}px`;
|
||||
delete placement.left;
|
||||
transformX = `${sceneWidth! - (this.cachedRight ?? 0) - this.cachedWidth}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
break;
|
||||
case HorizontalConstraint.LeftRight:
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.right = placement.right ?? 0;
|
||||
transformX = `${placement.left ?? 0}px`;
|
||||
style.width = `${sceneWidth! - (placement.left ?? 0) - (placement.right ?? 0)}px`;
|
||||
delete placement.width;
|
||||
transformX = `${this.cachedLeft}px`;
|
||||
style.width = `${sceneWidth! - this.cachedLeft - (this.cachedRight ?? 0)}px`;
|
||||
break;
|
||||
case HorizontalConstraint.Center:
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.width = placement.width ?? 100;
|
||||
transformX = `${sceneWidth! / 2 - (placement.left ?? 0) - (placement.width ?? 0) / 2}px`;
|
||||
style.width = `${placement.width}px`;
|
||||
delete placement.right;
|
||||
transformX = `${sceneWidth! / 2 - this.cachedLeft - this.cachedWidth / 2}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
break;
|
||||
case HorizontalConstraint.Scale:
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.right = placement.right ?? 0;
|
||||
transformX = `${(placement.left ?? 0) * (sceneWidth! / 100)}px`;
|
||||
style.width = `${sceneWidth! - (placement.left ?? 0) * (sceneWidth! / 100) - (placement.right ?? 0) * (sceneWidth! / 100)}px`;
|
||||
delete placement.width;
|
||||
transformX = `${this.cachedLeft * (sceneWidth! / 100)}px`;
|
||||
style.width = `${sceneWidth! - this.cachedLeft * (sceneWidth! / 100) - (this.cachedRight ?? 0) * (sceneWidth! / 100)}px`;
|
||||
break;
|
||||
}
|
||||
this.options.placement = placement;
|
||||
style.transform = `translate(${transformX}, ${transformY}) rotate(${placement.rotation ?? 0}deg)`;
|
||||
style.transform = `translate(${transformX}, ${transformY}) rotate(${this.cachedRotation}deg)`;
|
||||
this.sizeStyle = style;
|
||||
|
||||
if (this.div) {
|
||||
@@ -415,8 +407,8 @@ export class ElementState implements LayerElement {
|
||||
// TODO: Fix behavior for top+bottom, left+right, center, and scale constraints
|
||||
let rotationTopOffset = 0;
|
||||
let rotationLeftOffset = 0;
|
||||
if (this.options.placement?.rotation && this.options.placement?.width && this.options.placement?.height) {
|
||||
const rotationDegrees = this.options.placement.rotation;
|
||||
if (this.cachedRotation && this.options.placement?.width && this.options.placement?.height) {
|
||||
const rotationDegrees = this.cachedRotation;
|
||||
const rotationRadians = (Math.PI / 180) * rotationDegrees;
|
||||
let rotationOffset = rotationRadians;
|
||||
|
||||
@@ -438,8 +430,8 @@ export class ElementState implements LayerElement {
|
||||
const calculateDelta = (dimension1: number, dimension2: number) =>
|
||||
(dimension1 / 2) * Math.sin(rotationOffset) + (dimension2 / 2) * (Math.cos(rotationOffset) - 1);
|
||||
|
||||
rotationTopOffset = calculateDelta(this.options.placement.width, this.options.placement.height);
|
||||
rotationLeftOffset = calculateDelta(this.options.placement.height, this.options.placement.width);
|
||||
rotationTopOffset = calculateDelta(this.cachedWidth, this.cachedHeight);
|
||||
rotationLeftOffset = calculateDelta(this.cachedHeight, this.cachedWidth);
|
||||
}
|
||||
|
||||
const relativeTop =
|
||||
@@ -463,67 +455,103 @@ export class ElementState implements LayerElement {
|
||||
transformScale
|
||||
: 0;
|
||||
|
||||
const placement: Placement = {};
|
||||
// Don't update placement if any position is field-driven
|
||||
if (this.hasFieldDrivenPosition()) {
|
||||
this.applyLayoutStylesToDiv();
|
||||
this.revId++;
|
||||
return;
|
||||
}
|
||||
|
||||
const width = (elementContainer?.width ?? 100) / transformScale;
|
||||
const height = (elementContainer?.height ?? 100) / transformScale;
|
||||
|
||||
// Helper to create a position dimension config
|
||||
const fixedPosition = (value: number): PositionDimensionConfig => ({
|
||||
fixed: value,
|
||||
mode: PositionDimensionMode.Fixed,
|
||||
});
|
||||
|
||||
const placement: Placement = {};
|
||||
|
||||
switch (vertical) {
|
||||
case VerticalConstraint.Top:
|
||||
placement.top = relativeTop;
|
||||
placement.height = height;
|
||||
placement.top = fixedPosition(relativeTop);
|
||||
placement.height = fixedPosition(height);
|
||||
this.cachedTop = relativeTop;
|
||||
this.cachedHeight = height;
|
||||
break;
|
||||
case VerticalConstraint.Bottom:
|
||||
placement.bottom = relativeBottom;
|
||||
placement.height = height;
|
||||
placement.bottom = fixedPosition(relativeBottom);
|
||||
placement.height = fixedPosition(height);
|
||||
this.cachedBottom = relativeBottom;
|
||||
this.cachedHeight = height;
|
||||
break;
|
||||
case VerticalConstraint.TopBottom:
|
||||
placement.top = relativeTop;
|
||||
placement.bottom = relativeBottom;
|
||||
placement.top = fixedPosition(relativeTop);
|
||||
placement.bottom = fixedPosition(relativeBottom);
|
||||
this.cachedTop = relativeTop;
|
||||
this.cachedBottom = relativeBottom;
|
||||
break;
|
||||
case VerticalConstraint.Center:
|
||||
const elementCenter = elementContainer ? relativeTop + height / 2 : 0;
|
||||
const parentCenter = parentContainer ? parentContainer.height / 2 : 0;
|
||||
const distanceFromCenter = parentCenter - elementCenter;
|
||||
placement.top = distanceFromCenter;
|
||||
placement.height = height;
|
||||
const elementCenterV = elementContainer ? relativeTop + height / 2 : 0;
|
||||
const parentCenterV = parentContainer ? parentContainer.height / 2 : 0;
|
||||
const distanceFromCenterV = parentCenterV - elementCenterV;
|
||||
placement.top = fixedPosition(distanceFromCenterV);
|
||||
placement.height = fixedPosition(height);
|
||||
this.cachedTop = distanceFromCenterV;
|
||||
this.cachedHeight = height;
|
||||
break;
|
||||
case VerticalConstraint.Scale:
|
||||
placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
const scaleTop = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
const scaleBottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
placement.top = fixedPosition(scaleTop);
|
||||
placement.bottom = fixedPosition(scaleBottom);
|
||||
this.cachedTop = scaleTop;
|
||||
this.cachedBottom = scaleBottom;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (horizontal) {
|
||||
case HorizontalConstraint.Left:
|
||||
placement.left = relativeLeft;
|
||||
placement.width = width;
|
||||
placement.left = fixedPosition(relativeLeft);
|
||||
placement.width = fixedPosition(width);
|
||||
this.cachedLeft = relativeLeft;
|
||||
this.cachedWidth = width;
|
||||
break;
|
||||
case HorizontalConstraint.Right:
|
||||
placement.right = relativeRight;
|
||||
placement.width = width;
|
||||
placement.right = fixedPosition(relativeRight);
|
||||
placement.width = fixedPosition(width);
|
||||
this.cachedRight = relativeRight;
|
||||
this.cachedWidth = width;
|
||||
break;
|
||||
case HorizontalConstraint.LeftRight:
|
||||
placement.left = relativeLeft;
|
||||
placement.right = relativeRight;
|
||||
placement.left = fixedPosition(relativeLeft);
|
||||
placement.right = fixedPosition(relativeRight);
|
||||
this.cachedLeft = relativeLeft;
|
||||
this.cachedRight = relativeRight;
|
||||
break;
|
||||
case HorizontalConstraint.Center:
|
||||
const elementCenter = elementContainer ? relativeLeft + width / 2 : 0;
|
||||
const parentCenter = parentContainer ? parentContainer.width / 2 : 0;
|
||||
const distanceFromCenter = parentCenter - elementCenter;
|
||||
placement.left = distanceFromCenter;
|
||||
placement.width = width;
|
||||
const elementCenterH = elementContainer ? relativeLeft + width / 2 : 0;
|
||||
const parentCenterH = parentContainer ? parentContainer.width / 2 : 0;
|
||||
const distanceFromCenterH = parentCenterH - elementCenterH;
|
||||
placement.left = fixedPosition(distanceFromCenterH);
|
||||
placement.width = fixedPosition(width);
|
||||
this.cachedLeft = distanceFromCenterH;
|
||||
this.cachedWidth = width;
|
||||
break;
|
||||
case HorizontalConstraint.Scale:
|
||||
placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
const scaleLeft = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
const scaleRight = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
placement.left = fixedPosition(scaleLeft);
|
||||
placement.right = fixedPosition(scaleRight);
|
||||
this.cachedLeft = scaleLeft;
|
||||
this.cachedRight = scaleRight;
|
||||
break;
|
||||
}
|
||||
|
||||
// Preserve rotation
|
||||
if (this.options.placement?.rotation) {
|
||||
placement.rotation = this.options.placement.rotation;
|
||||
placement.width = this.options.placement.width;
|
||||
placement.height = this.options.placement.height;
|
||||
}
|
||||
|
||||
this.options.placement = placement;
|
||||
@@ -554,71 +582,109 @@ export class ElementState implements LayerElement {
|
||||
const relativeLeft = Math.round(elementRect.left);
|
||||
const relativeRight = Math.round(scene.width - elementRect.left - elementRect.width);
|
||||
|
||||
const placement: Placement = {};
|
||||
// Don't update placement if any position is field-driven
|
||||
if (this.hasFieldDrivenPosition()) {
|
||||
this.applyLayoutStylesToDiv();
|
||||
this.revId++;
|
||||
return;
|
||||
}
|
||||
|
||||
const width = elementRect.width;
|
||||
const height = elementRect.height;
|
||||
|
||||
// INFO: calculate it anyway to be able to use it for pan&zoom
|
||||
placement.top = relativeTop;
|
||||
placement.left = relativeLeft;
|
||||
// Helper to create a position dimension config
|
||||
const fixedPosition = (value: number): PositionDimensionConfig => ({
|
||||
fixed: value,
|
||||
mode: PositionDimensionMode.Fixed,
|
||||
});
|
||||
|
||||
const placement: Placement = {};
|
||||
|
||||
// INFO: calculate for pan&zoom
|
||||
placement.top = fixedPosition(relativeTop);
|
||||
placement.left = fixedPosition(relativeLeft);
|
||||
this.cachedTop = relativeTop;
|
||||
this.cachedLeft = relativeLeft;
|
||||
|
||||
switch (vertical) {
|
||||
case VerticalConstraint.Top:
|
||||
placement.top = relativeTop;
|
||||
placement.height = height;
|
||||
placement.top = fixedPosition(relativeTop);
|
||||
placement.height = fixedPosition(height);
|
||||
this.cachedTop = relativeTop;
|
||||
this.cachedHeight = height;
|
||||
break;
|
||||
case VerticalConstraint.Bottom:
|
||||
placement.bottom = relativeBottom;
|
||||
placement.height = height;
|
||||
placement.bottom = fixedPosition(relativeBottom);
|
||||
placement.height = fixedPosition(height);
|
||||
this.cachedBottom = relativeBottom;
|
||||
this.cachedHeight = height;
|
||||
break;
|
||||
case VerticalConstraint.TopBottom:
|
||||
placement.top = relativeTop;
|
||||
placement.bottom = relativeBottom;
|
||||
placement.top = fixedPosition(relativeTop);
|
||||
placement.bottom = fixedPosition(relativeBottom);
|
||||
this.cachedTop = relativeTop;
|
||||
this.cachedBottom = relativeBottom;
|
||||
break;
|
||||
case VerticalConstraint.Center:
|
||||
const elementCenter = elementContainer ? relativeTop + height / 2 : 0;
|
||||
const parentCenter = scene.height / 2; // Use scene height instead of scaled viewport height
|
||||
const distanceFromCenter = parentCenter - elementCenter;
|
||||
placement.top = distanceFromCenter;
|
||||
placement.height = height;
|
||||
const elementCenterV = elementContainer ? relativeTop + height / 2 : 0;
|
||||
const parentCenterV = scene.height / 2;
|
||||
const distanceFromCenterV = parentCenterV - elementCenterV;
|
||||
placement.top = fixedPosition(distanceFromCenterV);
|
||||
placement.height = fixedPosition(height);
|
||||
this.cachedTop = distanceFromCenterV;
|
||||
this.cachedHeight = height;
|
||||
break;
|
||||
case VerticalConstraint.Scale:
|
||||
placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
const scaleTop = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
const scaleBottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
placement.top = fixedPosition(scaleTop);
|
||||
placement.bottom = fixedPosition(scaleBottom);
|
||||
this.cachedTop = scaleTop;
|
||||
this.cachedBottom = scaleBottom;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (horizontal) {
|
||||
case HorizontalConstraint.Left:
|
||||
placement.left = relativeLeft;
|
||||
placement.width = width;
|
||||
placement.left = fixedPosition(relativeLeft);
|
||||
placement.width = fixedPosition(width);
|
||||
this.cachedLeft = relativeLeft;
|
||||
this.cachedWidth = width;
|
||||
break;
|
||||
case HorizontalConstraint.Right:
|
||||
placement.right = relativeRight;
|
||||
placement.width = width;
|
||||
placement.right = fixedPosition(relativeRight);
|
||||
placement.width = fixedPosition(width);
|
||||
this.cachedRight = relativeRight;
|
||||
this.cachedWidth = width;
|
||||
break;
|
||||
case HorizontalConstraint.LeftRight:
|
||||
placement.left = relativeLeft;
|
||||
placement.right = relativeRight;
|
||||
placement.left = fixedPosition(relativeLeft);
|
||||
placement.right = fixedPosition(relativeRight);
|
||||
this.cachedLeft = relativeLeft;
|
||||
this.cachedRight = relativeRight;
|
||||
break;
|
||||
case HorizontalConstraint.Center:
|
||||
const elementCenter = elementContainer ? relativeLeft + width / 2 : 0;
|
||||
const parentCenter = scene.width / 2; // Use scene width instead of scaled viewport width
|
||||
const distanceFromCenter = parentCenter - elementCenter;
|
||||
placement.left = distanceFromCenter;
|
||||
placement.width = width;
|
||||
const elementCenterH = elementContainer ? relativeLeft + width / 2 : 0;
|
||||
const parentCenterH = scene.width / 2;
|
||||
const distanceFromCenterH = parentCenterH - elementCenterH;
|
||||
placement.left = fixedPosition(distanceFromCenterH);
|
||||
placement.width = fixedPosition(width);
|
||||
this.cachedLeft = distanceFromCenterH;
|
||||
this.cachedWidth = width;
|
||||
break;
|
||||
case HorizontalConstraint.Scale:
|
||||
placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
const scaleLeft = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
const scaleRight = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
placement.left = fixedPosition(scaleLeft);
|
||||
placement.right = fixedPosition(scaleRight);
|
||||
this.cachedLeft = scaleLeft;
|
||||
this.cachedRight = scaleRight;
|
||||
break;
|
||||
}
|
||||
|
||||
// Preserve rotation
|
||||
if (this.options.placement?.rotation) {
|
||||
placement.rotation = this.options.placement.rotation;
|
||||
placement.width = this.options.placement.width;
|
||||
placement.height = this.options.placement.height;
|
||||
}
|
||||
|
||||
this.options.placement = placement;
|
||||
@@ -630,11 +696,47 @@ export class ElementState implements LayerElement {
|
||||
}
|
||||
|
||||
updateData(ctx: DimensionContext) {
|
||||
const previousData = this.data;
|
||||
|
||||
if (this.item.prepareData) {
|
||||
this.data = this.item.prepareData(ctx, this.options);
|
||||
this.revId++; // rerender
|
||||
|
||||
// Only increment revId if data actually changed (not just position)
|
||||
// This prevents flickering when only position updates
|
||||
if (JSON.stringify(this.data) !== JSON.stringify(previousData)) {
|
||||
this.revId++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update placement values from dimension context
|
||||
const placement = this.options.placement;
|
||||
if (placement) {
|
||||
if (placement.rotation) {
|
||||
this.cachedRotation = ctx.getScalar(placement.rotation).value();
|
||||
}
|
||||
if (placement.top) {
|
||||
this.cachedTop = ctx.getPosition(placement.top).value();
|
||||
}
|
||||
if (placement.left) {
|
||||
this.cachedLeft = ctx.getPosition(placement.left).value();
|
||||
}
|
||||
if (placement.width) {
|
||||
this.cachedWidth = ctx.getPosition(placement.width).value();
|
||||
}
|
||||
if (placement.height) {
|
||||
this.cachedHeight = ctx.getPosition(placement.height).value();
|
||||
}
|
||||
if (placement.right) {
|
||||
this.cachedRight = ctx.getPosition(placement.right).value();
|
||||
}
|
||||
if (placement.bottom) {
|
||||
this.cachedBottom = ctx.getPosition(placement.bottom).value();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply updated positions without forcing a remount
|
||||
this.applyLayoutStylesToDiv();
|
||||
|
||||
const scene = this.getScene();
|
||||
const frames = scene?.data?.series;
|
||||
|
||||
@@ -793,6 +895,11 @@ export class ElementState implements LayerElement {
|
||||
};
|
||||
|
||||
applyDrag = (event: OnDrag) => {
|
||||
// Don't allow dragging if any position is field-driven
|
||||
if (this.hasFieldDrivenPosition()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasHorizontalCenterConstraint = this.options.constraint?.horizontal === HorizontalConstraint.Center;
|
||||
const hasVerticalCenterConstraint = this.options.constraint?.vertical === VerticalConstraint.Center;
|
||||
if (hasHorizontalCenterConstraint || hasVerticalCenterConstraint) {
|
||||
@@ -813,18 +920,31 @@ export class ElementState implements LayerElement {
|
||||
applyRotate = (event: OnRotate) => {
|
||||
const rotationDelta = event.delta;
|
||||
const placement = this.options.placement!;
|
||||
const placementRotation = placement.rotation ?? 0;
|
||||
const placementRotation = this.cachedRotation;
|
||||
|
||||
const calculatedRotation = placementRotation + rotationDelta;
|
||||
|
||||
// Ensure rotation is between 0 and 360
|
||||
placement.rotation = calculatedRotation - Math.floor(calculatedRotation / 360) * 360;
|
||||
const newRotation = calculatedRotation - Math.floor(calculatedRotation / 360) * 360;
|
||||
|
||||
// Update the config value as fixed
|
||||
if (!placement.rotation) {
|
||||
placement.rotation = { fixed: newRotation, min: 0, max: 360, mode: ScalarDimensionMode.Clamped };
|
||||
} else {
|
||||
placement.rotation.fixed = newRotation;
|
||||
}
|
||||
this.cachedRotation = newRotation;
|
||||
event.target.style.transform = event.transform;
|
||||
};
|
||||
|
||||
// kinda like:
|
||||
// https://github.com/grafana/grafana-edge-app/blob/main/src/panels/draw/WrapItem.tsx#L44
|
||||
applyResize = (event: OnResize) => {
|
||||
// Don't allow resizing if any position is field-driven
|
||||
if (this.hasFieldDrivenPosition()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const placement = this.options.placement!;
|
||||
|
||||
const style = event.target.style;
|
||||
@@ -834,8 +954,8 @@ export class ElementState implements LayerElement {
|
||||
let dirTB = event.direction[1];
|
||||
|
||||
// Handle case when element is rotated
|
||||
if (placement.rotation) {
|
||||
const rotation = placement.rotation ?? 0;
|
||||
if (this.cachedRotation) {
|
||||
const rotation = this.cachedRotation;
|
||||
const rotationInRadians = (rotation * Math.PI) / 180;
|
||||
const originalDirLR = dirLR;
|
||||
const originalDirTB = dirTB;
|
||||
@@ -845,31 +965,37 @@ export class ElementState implements LayerElement {
|
||||
}
|
||||
|
||||
if (dirLR === 1) {
|
||||
placement.width = event.width;
|
||||
style.width = `${placement.width}px`;
|
||||
this.setPositionFixed(placement.width, event.width);
|
||||
this.cachedWidth = event.width;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
} else if (dirLR === -1) {
|
||||
placement.left! -= deltaX;
|
||||
placement.width = event.width;
|
||||
this.cachedLeft -= deltaX;
|
||||
this.setPositionFixed(placement.left, this.cachedLeft);
|
||||
this.cachedWidth = event.width;
|
||||
this.setPositionFixed(placement.width, this.cachedWidth);
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
|
||||
style.transform = `translate(${this.cachedLeft}px, ${this.cachedTop}px) rotate(${this.cachedRotation}deg)`;
|
||||
} else {
|
||||
style.left = `${placement.left}px`;
|
||||
style.left = `${this.cachedLeft}px`;
|
||||
}
|
||||
style.width = `${placement.width}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
}
|
||||
|
||||
if (dirTB === -1) {
|
||||
placement.top! -= deltaY;
|
||||
placement.height = event.height;
|
||||
this.cachedTop -= deltaY;
|
||||
this.setPositionFixed(placement.top, this.cachedTop);
|
||||
this.cachedHeight = event.height;
|
||||
this.setPositionFixed(placement.height, this.cachedHeight);
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
|
||||
style.transform = `translate(${this.cachedLeft}px, ${this.cachedTop}px) rotate(${this.cachedRotation}deg)`;
|
||||
} else {
|
||||
style.top = `${placement.top}px`;
|
||||
style.top = `${this.cachedTop}px`;
|
||||
}
|
||||
style.height = `${placement.height}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
} else if (dirTB === 1) {
|
||||
placement.height = event.height;
|
||||
style.height = `${placement.height}px`;
|
||||
this.cachedHeight = event.height;
|
||||
this.setPositionFixed(placement.height, this.cachedHeight);
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -880,7 +1006,8 @@ export class ElementState implements LayerElement {
|
||||
!scene?.isEditingEnabled && (!scene?.tooltipPayload?.isOpen || scene?.tooltipPayload?.element === this);
|
||||
if (shouldHandleTooltip) {
|
||||
this.handleTooltip(event);
|
||||
} else if (!isSelected) {
|
||||
} else if (!isSelected && !this.hasFieldDrivenPosition()) {
|
||||
// Don't show connection anchors for field-driven elements
|
||||
scene?.connections.handleMouseEnter(event);
|
||||
}
|
||||
|
||||
@@ -1090,6 +1217,25 @@ export class ElementState implements LayerElement {
|
||||
);
|
||||
};
|
||||
|
||||
// Track if this field-driven element is selected (for showing outline)
|
||||
isFieldDrivenSelected = false;
|
||||
|
||||
setFieldDrivenSelected(selected: boolean) {
|
||||
if (this.hasFieldDrivenPosition()) {
|
||||
this.isFieldDrivenSelected = selected;
|
||||
// Update the outline style
|
||||
if (this.div) {
|
||||
if (selected) {
|
||||
this.div.style.outline = '2px solid #3274d9';
|
||||
this.div.style.outlineOffset = '2px';
|
||||
} else {
|
||||
this.div.style.outline = '';
|
||||
this.div.style.outlineOffset = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderElement() {
|
||||
const { item, div } = this;
|
||||
const scene = this.getScene();
|
||||
@@ -1112,7 +1258,7 @@ export class ElementState implements LayerElement {
|
||||
key={`${this.UID}/${this.revId}`}
|
||||
config={this.options.config}
|
||||
data={this.data}
|
||||
isSelected={isSelected}
|
||||
isSelected={isSelected || this.isFieldDrivenSelected}
|
||||
/>
|
||||
</div>
|
||||
{this.showActionConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import {
|
||||
ColorDimensionConfig,
|
||||
PositionDimensionConfig,
|
||||
ResourceDimensionConfig,
|
||||
ScalarDimensionConfig,
|
||||
ScaleDimensionConfig,
|
||||
@@ -21,6 +22,7 @@ import { config } from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import {
|
||||
getColorDimensionFromData,
|
||||
getPositionDimensionFromData,
|
||||
getResourceDimensionFromData,
|
||||
getScalarDimensionFromData,
|
||||
getScaleDimensionFromData,
|
||||
@@ -109,6 +111,22 @@ export class Scene {
|
||||
|
||||
targetsToSelect = new Set<HTMLDivElement>();
|
||||
|
||||
// Track currently selected field-driven element (these aren't in Selecto)
|
||||
private fieldDrivenSelectedElement?: ElementState;
|
||||
|
||||
clearFieldDrivenSelection = () => {
|
||||
if (this.fieldDrivenSelectedElement) {
|
||||
this.fieldDrivenSelectedElement.setFieldDrivenSelected(false);
|
||||
this.fieldDrivenSelectedElement = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
setFieldDrivenSelection = (element: ElementState) => {
|
||||
this.clearFieldDrivenSelection();
|
||||
this.fieldDrivenSelectedElement = element;
|
||||
element.setFieldDrivenSelected(true);
|
||||
};
|
||||
|
||||
constructor(
|
||||
options: Options,
|
||||
public onSave: (cfg: CanvasFrameOptions) => void,
|
||||
@@ -211,6 +229,7 @@ export class Scene {
|
||||
getColor: (color: ColorDimensionConfig) => getColorDimensionFromData(this.data, color),
|
||||
getScale: (scale: ScaleDimensionConfig) => getScaleDimensionFromData(this.data, scale),
|
||||
getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.data, scalar),
|
||||
getPosition: (pos: PositionDimensionConfig) => getPositionDimensionFromData(this.data, pos),
|
||||
getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.data, text),
|
||||
getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.data, res),
|
||||
getDirection: (direction: DirectionDimensionConfig) => getDirectionDimensionFromData(this.data, direction),
|
||||
@@ -267,6 +286,8 @@ export class Scene {
|
||||
|
||||
clearCurrentSelection(skipNextSelectionBroadcast = false) {
|
||||
this.skipNextSelectionBroadcast = skipNextSelectionBroadcast;
|
||||
// Clear field-driven selection
|
||||
this.clearFieldDrivenSelection();
|
||||
let event: MouseEvent = new MouseEvent('click');
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
this.selecto?.clickTarget(event, this.viewportDiv);
|
||||
@@ -324,6 +345,9 @@ export class Scene {
|
||||
|
||||
select = (selection: SelectionParams) => {
|
||||
if (this.selecto) {
|
||||
// Clear any field-driven selection when selecting via Selecto
|
||||
this.clearFieldDrivenSelection();
|
||||
|
||||
this.selecto.setSelectedTargets(selection.targets);
|
||||
this.updateSelection(selection);
|
||||
this.editModeEnabled.next(false);
|
||||
|
||||
@@ -69,6 +69,7 @@ const isTargetAlreadySelected = (selectedTarget: HTMLElement, scene: Scene) => {
|
||||
};
|
||||
|
||||
// Generate HTML element divs for every canvas element to configure selecto / moveable
|
||||
// Excludes elements with field-driven positions (they can't be moved in editor)
|
||||
const generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[] => {
|
||||
let targetElements: HTMLDivElement[] = [];
|
||||
|
||||
@@ -77,7 +78,10 @@ const generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[]
|
||||
const currentElement = stack.shift();
|
||||
|
||||
if (currentElement && currentElement.div) {
|
||||
targetElements.push(currentElement.div);
|
||||
// Skip elements with field-driven positions - they can't be moved
|
||||
if (!currentElement.hasFieldDrivenPosition()) {
|
||||
targetElements.push(currentElement.div);
|
||||
}
|
||||
}
|
||||
|
||||
const nestedElements = currentElement instanceof FrameState ? currentElement.elements : [];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user