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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -164,6 +164,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
|
||||
BaseURL: panel.BaseURL,
|
||||
SkipDataQuery: panel.SkipDataQuery,
|
||||
Suggestions: panel.Suggestions,
|
||||
HideFromList: panel.HideFromList,
|
||||
ReleaseState: string(panel.State),
|
||||
Signature: string(panel.Signature),
|
||||
|
||||
@@ -222,6 +222,10 @@ var (
|
||||
|
||||
// MStatTotalRepositories is a metric total amount of repositories
|
||||
MStatTotalRepositories prometheus.Gauge
|
||||
|
||||
// MUnifiedStorageMigrationStatus indicates the migration status for unified storage in this instance.
|
||||
// Possible values: 0 (default/undefined), 1 (migration disabled), 2 (migration would run).
|
||||
MUnifiedStorageMigrationStatus prometheus.Gauge
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -691,6 +695,12 @@ func init() {
|
||||
Help: "total amount of repositories",
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
|
||||
MUnifiedStorageMigrationStatus = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "unified_storage_migration_status",
|
||||
Help: "indicates whether this instance would run unified storage migrations (0=undefined, 1=migration disabled, 2=would run)",
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
}
|
||||
|
||||
// SetBuildInformation sets the build information for this binary
|
||||
@@ -829,5 +839,6 @@ func initMetricVars(reg prometheus.Registerer) {
|
||||
MStatTotalRepositories,
|
||||
MFolderIDsAPICount,
|
||||
MFolderIDsServiceCount,
|
||||
MUnifiedStorageMigrationStatus,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -319,6 +319,7 @@ type PanelDTO struct {
|
||||
HideFromList bool `json:"hideFromList"`
|
||||
Sort int `json:"sort"`
|
||||
SkipDataQuery bool `json:"skipDataQuery"`
|
||||
Suggestions bool `json:"suggestions,omitempty"`
|
||||
ReleaseState string `json:"state"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Signature string `json:"signature"`
|
||||
|
||||
@@ -105,6 +105,7 @@ type JSONData struct {
|
||||
|
||||
// Panel settings
|
||||
SkipDataQuery bool `json:"skipDataQuery"`
|
||||
Suggestions bool `json:"suggestions,omitempty"`
|
||||
|
||||
// App settings
|
||||
AutoEnabled bool `json:"autoEnabled"`
|
||||
|
||||
@@ -1884,6 +1884,14 @@ var (
|
||||
Owner: grafanaDatavizSquad,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "externalVizSuggestions",
|
||||
Description: "Enable all plugins to supply visualization suggestions (including 3rd party plugins)",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDatavizSquad,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "preventPanelChromeOverflow",
|
||||
Description: "Restrict PanelChrome contents with overflow: hidden;",
|
||||
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -31,7 +31,6 @@ import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
|
||||
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
|
||||
import { PanelModelCompatibilityWrapper } from '../utils/PanelModelCompatibilityWrapper';
|
||||
import {
|
||||
activateSceneObjectAndParentTree,
|
||||
getDashboardSceneFor,
|
||||
@@ -121,8 +120,7 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
||||
dataObject.subscribeToState(async () => {
|
||||
const { data } = dataObject.state;
|
||||
if (hasData(data) && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) {
|
||||
const panelModel = new PanelModelCompatibilityWrapper(panel);
|
||||
const suggestions = await getAllSuggestions(data, panelModel);
|
||||
const suggestions = await getAllSuggestions(data);
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
const defaultFirstSuggestion = suggestions[0];
|
||||
|
||||
@@ -171,7 +171,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
|
||||
>
|
||||
<div className={cx(styles.rightControls, editPanel && styles.rightControlsWrap)}>
|
||||
{!hideTimeControls && (
|
||||
<div className={styles.timeControls}>
|
||||
<div className={styles.fixedControls}>
|
||||
<timePicker.Component model={timePicker} />
|
||||
<refreshPicker.Component model={refreshPicker} />
|
||||
</div>
|
||||
@@ -181,7 +181,11 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
|
||||
<DashboardControlsButton dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
{config.featureToggles.dashboardNewLayouts && <DashboardControlActions dashboard={dashboard} />}
|
||||
{config.featureToggles.dashboardNewLayouts && (
|
||||
<div className={styles.fixedControls}>
|
||||
<DashboardControlActions dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} dashboard={dashboard} />}
|
||||
</div>
|
||||
{!hideVariableControls && (
|
||||
@@ -274,12 +278,12 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
float: 'right',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
flexWrap: 'wrap',
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
}),
|
||||
timeControls: css({
|
||||
fixedControls: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: theme.spacing(1),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PanelData } from '@grafana/data';
|
||||
import {
|
||||
ColorDimensionConfig,
|
||||
PositionDimensionConfig,
|
||||
ResourceDimensionConfig,
|
||||
ScalarDimensionConfig,
|
||||
ScaleDimensionConfig,
|
||||
@@ -18,6 +19,8 @@ export interface DimensionContext {
|
||||
|
||||
getScalar(scalar: ScalarDimensionConfig): DimensionSupplier<number>;
|
||||
|
||||
getPosition(position: PositionDimensionConfig): DimensionSupplier<number>;
|
||||
|
||||
getText(text: TextDimensionConfig): DimensionSupplier<string>;
|
||||
|
||||
getResource(resource: ResourceDimensionConfig): DimensionSupplier<string>;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useId, useMemo } from 'react';
|
||||
|
||||
import { FieldType, SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
|
||||
import { InlineField, InlineFieldRow, RadioButtonGroup, Select } from '@grafana/ui';
|
||||
import { useFieldDisplayNames, useSelectOptions } from '@grafana/ui/internal';
|
||||
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
|
||||
|
||||
import { PositionDimensionOptions } from '../types';
|
||||
|
||||
type Props = StandardEditorProps<PositionDimensionConfig, PositionDimensionOptions>;
|
||||
|
||||
export const PositionDimensionEditor = ({ value, context, onChange }: Props) => {
|
||||
const positionOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t('dimensions.position-dimension-editor.label-fixed', 'Fixed'),
|
||||
value: PositionDimensionMode.Fixed,
|
||||
description: t('dimensions.position-dimension-editor.description-fixed', 'Fixed value'),
|
||||
},
|
||||
{
|
||||
label: t('dimensions.position-dimension-editor.label-field', 'Field'),
|
||||
value: PositionDimensionMode.Field,
|
||||
description: t('dimensions.position-dimension-editor.description-field', 'Use field value'),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const fixedValueOption: SelectableValue<string> = useMemo(
|
||||
() => ({
|
||||
label: t('dimensions.position-dimension-editor.fixed-value-option.label', 'Fixed value'),
|
||||
value: '_____fixed_____',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const labelWidth = 9;
|
||||
const fieldName = value?.field;
|
||||
const names = useFieldDisplayNames(context.data);
|
||||
// Filter to only show number fields for position values
|
||||
const selectOptions = useSelectOptions(names, fieldName, fixedValueOption, FieldType.number);
|
||||
|
||||
const onModeChange = useCallback(
|
||||
(mode: PositionDimensionMode) => {
|
||||
onChange({
|
||||
...value,
|
||||
mode,
|
||||
});
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
const onFieldChange = useCallback(
|
||||
(selection: SelectableValue<string>) => {
|
||||
const field = selection.value;
|
||||
if (field && field !== fixedValueOption.value) {
|
||||
onChange({
|
||||
...value,
|
||||
field,
|
||||
});
|
||||
} else {
|
||||
onChange({
|
||||
...value,
|
||||
field: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, value, fixedValueOption.value]
|
||||
);
|
||||
|
||||
const onFixedChange = useCallback(
|
||||
(fixed?: number) => {
|
||||
onChange({
|
||||
...value,
|
||||
fixed: fixed ?? 0,
|
||||
});
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
const fieldInputId = useId();
|
||||
const valueInputId = useId();
|
||||
|
||||
const mode = value?.mode ?? PositionDimensionMode.Fixed;
|
||||
const selectedOption =
|
||||
mode === PositionDimensionMode.Field ? selectOptions.find((v) => v.value === fieldName) : fixedValueOption;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineFieldRow>
|
||||
<InlineField
|
||||
label={t('dimensions.position-dimension-editor.label-source', 'Source')}
|
||||
labelWidth={labelWidth}
|
||||
grow={true}
|
||||
>
|
||||
<RadioButtonGroup value={mode} options={positionOptions} onChange={onModeChange} fullWidth />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
{mode === PositionDimensionMode.Field && (
|
||||
<InlineFieldRow>
|
||||
<InlineField
|
||||
label={t('dimensions.position-dimension-editor.label-field', 'Field')}
|
||||
labelWidth={labelWidth}
|
||||
grow={true}
|
||||
>
|
||||
<Select
|
||||
inputId={fieldInputId}
|
||||
value={selectedOption}
|
||||
options={selectOptions}
|
||||
onChange={onFieldChange}
|
||||
noOptionsMessage={t('dimensions.position-dimension-editor.no-fields', 'No number fields found')}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
{mode === PositionDimensionMode.Fixed && (
|
||||
<InlineFieldRow>
|
||||
<InlineField
|
||||
label={t('dimensions.position-dimension-editor.label-value', 'Value')}
|
||||
labelWidth={labelWidth}
|
||||
grow={true}
|
||||
>
|
||||
<NumberInput id={valueInputId} value={value?.fixed ?? 0} onChange={onFixedChange} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { DataFrame, Field } from '@grafana/data';
|
||||
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
|
||||
|
||||
import { DimensionSupplier } from './types';
|
||||
import { findField, getLastNotNullFieldValue } from './utils';
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Position dimension - simple fixed or field value
|
||||
//---------------------------------------------------------
|
||||
|
||||
export function getPositionDimension(
|
||||
frame: DataFrame | undefined,
|
||||
config: PositionDimensionConfig
|
||||
): DimensionSupplier<number> {
|
||||
return getPositionDimensionForField(findField(frame, config?.field), config);
|
||||
}
|
||||
|
||||
export function getPositionDimensionForField(
|
||||
field: Field | undefined,
|
||||
config: PositionDimensionConfig
|
||||
): DimensionSupplier<number> {
|
||||
const v = config.fixed ?? 0;
|
||||
const mode = config.mode ?? PositionDimensionMode.Fixed;
|
||||
|
||||
if (mode === PositionDimensionMode.Fixed) {
|
||||
return {
|
||||
isAssumed: !config.fixed,
|
||||
fixed: v,
|
||||
value: () => v,
|
||||
get: () => v,
|
||||
};
|
||||
}
|
||||
|
||||
// Field mode
|
||||
if (!field) {
|
||||
return {
|
||||
isAssumed: true,
|
||||
fixed: v,
|
||||
value: () => v,
|
||||
get: () => v,
|
||||
};
|
||||
}
|
||||
|
||||
const get = (i: number) => {
|
||||
const val = field.values[i];
|
||||
if (val === null || typeof val !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
return {
|
||||
field,
|
||||
get,
|
||||
value: () => {
|
||||
const val = getLastNotNullFieldValue(field);
|
||||
return typeof val === 'number' ? val : 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -46,6 +46,10 @@ export interface TextDimensionOptions {
|
||||
// anything?
|
||||
}
|
||||
|
||||
export interface PositionDimensionOptions {
|
||||
// anything?
|
||||
}
|
||||
|
||||
export const defaultTextConfig: TextDimensionConfig = Object.freeze({
|
||||
fixed: '',
|
||||
mode: TextDimensionMode.Field,
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
TextDimensionConfig,
|
||||
ColorDimensionConfig,
|
||||
ScalarDimensionConfig,
|
||||
PositionDimensionConfig,
|
||||
DirectionDimensionConfig,
|
||||
ConnectionDirection,
|
||||
} from '@grafana/schema';
|
||||
|
||||
import { getColorDimension } from './color';
|
||||
import { getDirectionDimension } from './direction';
|
||||
import { getPositionDimension } from './position';
|
||||
import { getResourceDimension } from './resource';
|
||||
import { getScalarDimension } from './scalar';
|
||||
import { getScaledDimension } from './scale';
|
||||
@@ -78,6 +80,21 @@ export function getScalarDimensionFromData(
|
||||
return getScalarDimension(undefined, cfg);
|
||||
}
|
||||
|
||||
export function getPositionDimensionFromData(
|
||||
data: PanelData | undefined,
|
||||
cfg: PositionDimensionConfig
|
||||
): DimensionSupplier<number> {
|
||||
if (data?.series && cfg.field) {
|
||||
for (const frame of data.series) {
|
||||
const d = getPositionDimension(frame, cfg);
|
||||
if (!d.isAssumed || data.series.length === 1) {
|
||||
return d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return getPositionDimension(undefined, cfg);
|
||||
}
|
||||
|
||||
export function getResourceDimensionFromData(
|
||||
data: PanelData | undefined,
|
||||
cfg: ResourceDimensionConfig
|
||||
|
||||
@@ -25,7 +25,7 @@ const MIN_COLUMN_SIZE = 260;
|
||||
|
||||
export function VisualizationSuggestions({ onChange, data, panel }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { value: suggestions } = useAsync(() => getAllSuggestions(data, panel), [data, panel]);
|
||||
const { value: suggestions } = useAsync(async () => await getAllSuggestions(data), [data]);
|
||||
const [suggestionHash, setSuggestionHash] = useState<string | null>(null);
|
||||
const [firstCardRef, { width }] = useMeasure<HTMLDivElement>();
|
||||
const [firstCardHash, setFirstCardHash] = useState<string | null>(null);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const panelsToCheckFirst = [
|
||||
'timeseries',
|
||||
'barchart',
|
||||
'gauge',
|
||||
'stat',
|
||||
'piechart',
|
||||
'bargauge',
|
||||
'table',
|
||||
'state-timeline',
|
||||
'status-history',
|
||||
'logs',
|
||||
'candlestick',
|
||||
'flamegraph',
|
||||
'traces',
|
||||
'nodeGraph',
|
||||
'heatmap',
|
||||
'histogram',
|
||||
'geomap',
|
||||
];
|
||||
@@ -2,11 +2,13 @@ import {
|
||||
DataFrame,
|
||||
FieldType,
|
||||
getDefaultTimeRange,
|
||||
getPanelDataSummary,
|
||||
LoadingState,
|
||||
PanelData,
|
||||
PanelPluginMeta,
|
||||
PanelPluginVisualizationSuggestion,
|
||||
PluginType,
|
||||
toDataFrame,
|
||||
VisualizationSuggestionScore,
|
||||
} from '@grafana/data';
|
||||
import {
|
||||
BarGaugeDisplayMode,
|
||||
@@ -18,26 +20,69 @@ import {
|
||||
} from '@grafana/schema';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions';
|
||||
import { panelsToCheckFirst } from './consts';
|
||||
import { getAllSuggestions, sortSuggestions } from './getAllSuggestions';
|
||||
|
||||
config.featureToggles.externalVizSuggestions = true;
|
||||
|
||||
let idx = 0;
|
||||
for (const pluginId of panelsToCheckFirst) {
|
||||
if (pluginId === 'geomap') {
|
||||
continue;
|
||||
}
|
||||
config.panels[pluginId] = {
|
||||
module: `core:plugin/${pluginId}`,
|
||||
id: pluginId,
|
||||
} as PanelPluginMeta;
|
||||
module: `core:plugin/${pluginId}`,
|
||||
sort: idx++,
|
||||
name: pluginId,
|
||||
type: PluginType.panel,
|
||||
baseUrl: 'public/app/plugins/panel',
|
||||
suggestions: true,
|
||||
info: {
|
||||
version: '1.0.0',
|
||||
updated: '2025-01-01',
|
||||
links: [],
|
||||
screenshots: [],
|
||||
author: {
|
||||
name: 'Grafana Labs',
|
||||
},
|
||||
description: pluginId,
|
||||
logos: { small: 'small/logo', large: 'large/logo' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SCALAR_PLUGINS = ['gauge', 'stat', 'bargauge', 'piechart', 'radialbar'];
|
||||
|
||||
config.panels['text'] = {
|
||||
config.panels.text = {
|
||||
id: 'text',
|
||||
module: 'core:plugin/text',
|
||||
sort: idx++,
|
||||
name: 'Text',
|
||||
type: PluginType.panel,
|
||||
baseUrl: 'public/app/plugins/panel',
|
||||
skipDataQuery: true,
|
||||
suggestions: false,
|
||||
info: {
|
||||
description: 'pretty decent plugin',
|
||||
version: '1.0.0',
|
||||
updated: '2025-01-01',
|
||||
links: [],
|
||||
screenshots: [],
|
||||
author: {
|
||||
name: 'Grafana Labs',
|
||||
},
|
||||
description: 'Text panel',
|
||||
logos: { small: 'small/logo', large: 'large/logo' },
|
||||
},
|
||||
} as PanelPluginMeta;
|
||||
};
|
||||
|
||||
jest.mock('../state/util', () => {
|
||||
const originalModule = jest.requireActual('../state/util');
|
||||
return {
|
||||
...originalModule,
|
||||
getAllPanelPluginMeta: jest.fn().mockImplementation(() => [...Object.values(config.panels)]),
|
||||
};
|
||||
});
|
||||
|
||||
const SCALAR_PLUGINS = ['gauge', 'stat', 'bargauge', 'piechart', 'radialbar'];
|
||||
|
||||
class ScenarioContext {
|
||||
data: DataFrame[] = [];
|
||||
@@ -289,10 +334,8 @@ scenario('Single frame with string and number field', (ctx) => {
|
||||
pluginId: 'stat',
|
||||
options: expect.objectContaining({ colorMode: BigValueColorMode.Background }),
|
||||
}),
|
||||
|
||||
expect.objectContaining({
|
||||
pluginId: 'bargauge',
|
||||
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pluginId: 'bargauge',
|
||||
@@ -447,6 +490,70 @@ scenario('Given a preferredVisualisationType', (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortSuggestions', () => {
|
||||
it('should sort suggestions correctly by score', () => {
|
||||
const suggestions = [
|
||||
{ pluginId: 'timeseries', name: 'Time series', hash: 'b', score: VisualizationSuggestionScore.OK },
|
||||
{ pluginId: 'table', name: 'Table', hash: 'a', score: VisualizationSuggestionScore.OK },
|
||||
{ pluginId: 'stat', name: 'Stat', hash: 'c', score: VisualizationSuggestionScore.Good },
|
||||
] satisfies PanelPluginVisualizationSuggestion[];
|
||||
|
||||
const dataSummary = getPanelDataSummary([
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [1, 2, 3, 4, 5] },
|
||||
{ name: 'ServerA', type: FieldType.number, values: [1, 10, 50, 2, 5] },
|
||||
{ name: 'ServerB', type: FieldType.number, values: [1, 10, 50, 2, 5] },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
sortSuggestions(suggestions, dataSummary);
|
||||
|
||||
expect(suggestions[0].pluginId).toBe('stat');
|
||||
expect(suggestions[1].pluginId).toBe('timeseries');
|
||||
expect(suggestions[2].pluginId).toBe('table');
|
||||
});
|
||||
|
||||
it('should sort suggestions based on core module', () => {
|
||||
const suggestions = [
|
||||
{
|
||||
pluginId: 'fake-external-panel',
|
||||
name: 'Time series',
|
||||
hash: 'b',
|
||||
score: VisualizationSuggestionScore.Good,
|
||||
},
|
||||
{
|
||||
pluginId: 'fake-external-panel',
|
||||
name: 'Time series',
|
||||
hash: 'd',
|
||||
score: VisualizationSuggestionScore.Best,
|
||||
},
|
||||
{ pluginId: 'timeseries', name: 'Table', hash: 'a', score: VisualizationSuggestionScore.OK },
|
||||
{ pluginId: 'stat', name: 'Stat', hash: 'c', score: VisualizationSuggestionScore.Good },
|
||||
] satisfies PanelPluginVisualizationSuggestion[];
|
||||
|
||||
const dataSummary = getPanelDataSummary([
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [1, 2, 3, 4, 5] },
|
||||
{ name: 'ServerA', type: FieldType.number, values: [1, 10, 50, 2, 5] },
|
||||
{ name: 'ServerB', type: FieldType.number, values: [1, 10, 50, 2, 5] },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
sortSuggestions(suggestions, dataSummary);
|
||||
|
||||
expect(suggestions[0].pluginId).toBe('stat');
|
||||
expect(suggestions[1].pluginId).toBe('timeseries');
|
||||
expect(suggestions[2].pluginId).toBe('fake-external-panel');
|
||||
expect(suggestions[2].hash).toBe('d');
|
||||
expect(suggestions[3].pluginId).toBe('fake-external-panel');
|
||||
expect(suggestions[3].hash).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
function repeatFrame(count: number, frame: DataFrame): DataFrame[] {
|
||||
const frames: DataFrame[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
|
||||
@@ -1,33 +1,52 @@
|
||||
import {
|
||||
getPanelDataSummary,
|
||||
PanelData,
|
||||
PanelDataSummary,
|
||||
PanelPlugin,
|
||||
PanelPluginVisualizationSuggestion,
|
||||
VisualizationSuggestionsBuilder,
|
||||
PanelModel,
|
||||
VisualizationSuggestionScore,
|
||||
PreferredVisualisationType,
|
||||
VisualizationSuggestionScore,
|
||||
} from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
|
||||
import { importPanelPlugin, isBuiltInPlugin } from 'app/features/plugins/importPanelPlugin';
|
||||
|
||||
export const panelsToCheckFirst = [
|
||||
'timeseries',
|
||||
'barchart',
|
||||
'gauge',
|
||||
'stat',
|
||||
'piechart',
|
||||
'bargauge',
|
||||
'table',
|
||||
'state-timeline',
|
||||
'status-history',
|
||||
'logs',
|
||||
'candlestick',
|
||||
'flamegraph',
|
||||
'traces',
|
||||
'nodeGraph',
|
||||
'heatmap',
|
||||
'histogram',
|
||||
'geomap',
|
||||
];
|
||||
import { getAllPanelPluginMeta } from '../state/util';
|
||||
|
||||
import { panelsToCheckFirst } from './consts';
|
||||
|
||||
/**
|
||||
* gather and cache the plugins which provide visualization suggestions so they can be invoked to build suggestions
|
||||
*/
|
||||
let _pluginCache: PanelPlugin[] | null = null;
|
||||
async function getPanelsWithSuggestions(): Promise<PanelPlugin[]> {
|
||||
if (!_pluginCache) {
|
||||
_pluginCache = [];
|
||||
|
||||
// list of plugins to load is determined by the feature flag
|
||||
const pluginIds: string[] = config.featureToggles.externalVizSuggestions
|
||||
? getAllPanelPluginMeta()
|
||||
.filter((panel) => panel.suggestions)
|
||||
.map((m) => m.id)
|
||||
: panelsToCheckFirst;
|
||||
|
||||
// import the plugins in parallel using Promise.allSettled
|
||||
const settledPromises = await Promise.allSettled(pluginIds.map((id) => importPanelPlugin(id)));
|
||||
for (let i = 0; i < settledPromises.length; i++) {
|
||||
const settled = settledPromises[i];
|
||||
|
||||
if (settled.status === 'fulfilled') {
|
||||
_pluginCache.push(settled.value);
|
||||
}
|
||||
// TODO: do we want to somehow log if there were errors loading some of the plugins?
|
||||
}
|
||||
}
|
||||
|
||||
if (_pluginCache.length === 0) {
|
||||
throw new Error('No panel plugins with visualization suggestions found');
|
||||
}
|
||||
|
||||
return _pluginCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* some of the PreferredVisualisationTypes do not match the panel plugin ids, so we have to map them. d'oh.
|
||||
@@ -44,24 +63,54 @@ const mapPreferredVisualisationTypeToPlugin = (type: string): PreferredVisualisa
|
||||
return PLUGIN_ID_TO_PREFERRED_VIZ_TYPE[type];
|
||||
};
|
||||
|
||||
export async function getAllSuggestions(
|
||||
data?: PanelData,
|
||||
panel?: PanelModel
|
||||
): Promise<PanelPluginVisualizationSuggestion[]> {
|
||||
const builder = new VisualizationSuggestionsBuilder(data, panel);
|
||||
/**
|
||||
* given a list of suggestions, sort them in place based on score and preferred visualisation type
|
||||
*/
|
||||
export function sortSuggestions(suggestions: PanelPluginVisualizationSuggestion[], dataSummary: PanelDataSummary) {
|
||||
suggestions.sort((a, b) => {
|
||||
// if one of these suggestions is from a built-in panel and the other isn't, prioritize the core panel.
|
||||
const isPluginABuiltIn = isBuiltInPlugin(a.pluginId);
|
||||
const isPluginBBuiltIn = isBuiltInPlugin(b.pluginId);
|
||||
if (isPluginABuiltIn && !isPluginBBuiltIn) {
|
||||
return -1;
|
||||
}
|
||||
if (isPluginBBuiltIn && !isPluginABuiltIn) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
for (const pluginId of panelsToCheckFirst) {
|
||||
const plugin = await importPanelPlugin(pluginId);
|
||||
const supplier = plugin.getSuggestionsSupplier();
|
||||
// if a preferred visualisation type matches the data, prioritize it
|
||||
const mappedA = mapPreferredVisualisationTypeToPlugin(a.pluginId);
|
||||
if (mappedA && dataSummary.hasPreferredVisualisationType(mappedA)) {
|
||||
return -1;
|
||||
}
|
||||
const mappedB = mapPreferredVisualisationTypeToPlugin(a.pluginId);
|
||||
if (mappedB && dataSummary.hasPreferredVisualisationType(mappedB)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (supplier) {
|
||||
supplier.getSuggestionsForData(builder);
|
||||
// compare scores directly if there are no other factors
|
||||
return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* given PanelData, return a sorted list of Suggestions from all plugins which support it.
|
||||
* @param {PanelData} data queried and transformed data for the panel
|
||||
* @returns {PanelPluginVisualizationSuggestion[]} sorted list of suggestions
|
||||
*/
|
||||
export async function getAllSuggestions(data?: PanelData): Promise<PanelPluginVisualizationSuggestion[]> {
|
||||
const dataSummary = getPanelDataSummary(data?.series);
|
||||
const list: PanelPluginVisualizationSuggestion[] = [];
|
||||
const plugins = await getPanelsWithSuggestions();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const suggestions = plugin.getSuggestions(dataSummary);
|
||||
if (suggestions) {
|
||||
list.push(...suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
const list = builder.getList();
|
||||
|
||||
if (builder.dataSummary.fieldCount === 0) {
|
||||
if (dataSummary.fieldCount === 0) {
|
||||
for (const plugin of Object.values(config.panels)) {
|
||||
if (!plugin.skipDataQuery || plugin.hideFromList) {
|
||||
continue;
|
||||
@@ -79,15 +128,7 @@ export async function getAllSuggestions(
|
||||
}
|
||||
}
|
||||
|
||||
return list.sort((a, b) => {
|
||||
const mappedA = mapPreferredVisualisationTypeToPlugin(a.pluginId);
|
||||
if (mappedA && builder.dataSummary.hasPreferredVisualisationType(mappedA)) {
|
||||
return -1;
|
||||
}
|
||||
const mappedB = mapPreferredVisualisationTypeToPlugin(a.pluginId);
|
||||
if (mappedB && builder.dataSummary.hasPreferredVisualisationType(mappedB)) {
|
||||
return 1;
|
||||
}
|
||||
return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK);
|
||||
});
|
||||
sortSuggestions(list, dataSummary);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -115,4 +115,8 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul
|
||||
'core:plugin/radialbar': radialBar,
|
||||
};
|
||||
|
||||
export function isBuiltinPluginPath(path: string): path is keyof typeof builtInPlugins {
|
||||
return Boolean(builtInPlugins[path]);
|
||||
}
|
||||
|
||||
export default builtInPlugins;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PanelPlugin, PanelPluginMeta } from '@grafana/data';
|
||||
import config from 'app/core/config';
|
||||
|
||||
import builtInPlugins, { isBuiltinPluginPath } from './built_in_plugins';
|
||||
import { pluginImporter } from './importer/pluginImporter';
|
||||
|
||||
const promiseCache: Record<string, Promise<PanelPlugin>> = {};
|
||||
@@ -25,6 +26,14 @@ export function importPanelPlugin(id: string): Promise<PanelPlugin> {
|
||||
return promiseCache[id];
|
||||
}
|
||||
|
||||
export function isBuiltInPlugin(id?: string): id is keyof typeof builtInPlugins {
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
const meta = getPanelPluginMeta(id);
|
||||
return Boolean(meta != null && isBuiltinPluginPath(meta.module));
|
||||
}
|
||||
|
||||
export function hasPanelPlugin(id: string): boolean {
|
||||
return !!getPanelPluginMeta(id);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DEFAULT_LANGUAGE } from '@grafana/i18n';
|
||||
import { getResolvedLanguage } from '@grafana/i18n/internal';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import builtInPlugins from '../built_in_plugins';
|
||||
import builtInPlugins, { isBuiltinPluginPath } from '../built_in_plugins';
|
||||
import { registerPluginInfoInCache } from '../loader/pluginInfoCache';
|
||||
import { SystemJS } from '../loader/systemjs';
|
||||
import { resolveModulePath } from '../loader/utils';
|
||||
@@ -35,8 +35,8 @@ export async function importPluginModule({
|
||||
});
|
||||
}
|
||||
|
||||
const builtIn = builtInPlugins[path];
|
||||
if (builtIn) {
|
||||
if (isBuiltinPluginPath(path)) {
|
||||
const builtIn = builtInPlugins[path];
|
||||
// for handling dynamic imports
|
||||
if (typeof builtIn === 'function') {
|
||||
return await builtIn();
|
||||
|
||||
+19
-2
@@ -27,7 +27,8 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
const simQuery = query.sim ?? ({} as SimulationQuery);
|
||||
const simKey = simQuery.key ?? {};
|
||||
// keep track of updated config state to pass down to form
|
||||
const [cfgValue, setCfgValue] = useState<Config>({});
|
||||
// Initialize from saved query config if it exists
|
||||
const [cfgValue, setCfgValue] = useState<Config>(simQuery.config ?? {});
|
||||
|
||||
// This only changes once
|
||||
const info = useAsync(async () => {
|
||||
@@ -50,6 +51,19 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
}, [info.value, simKey?.type]);
|
||||
|
||||
let config = useAsync(async () => {
|
||||
// If we have a saved config in the query, use that and update server
|
||||
if (simQuery.config && Object.keys(simQuery.config).length > 0) {
|
||||
let path = simKey.type + '/' + simKey.tick + 'hz';
|
||||
if (simKey.uid) {
|
||||
path += '/' + simKey.uid;
|
||||
}
|
||||
// Update server with saved config
|
||||
ds.postResource<SimInfo>('sim/' + path, simQuery.config).then((res) => {
|
||||
setCfgValue(res.config);
|
||||
});
|
||||
return simQuery.config;
|
||||
}
|
||||
// Otherwise fetch default config from server
|
||||
let path = simKey.type + '/' + simKey.tick + 'hz';
|
||||
if (simKey.uid) {
|
||||
path += '/' + simKey.uid;
|
||||
@@ -57,7 +71,7 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
let config = (await ds.getResource('sim/' + path))?.config;
|
||||
setCfgValue(config.value);
|
||||
return config;
|
||||
}, [simKey.type, simKey.tick, simKey.uid]);
|
||||
}, [simKey.type, simKey.tick, simKey.uid, simQuery.config]);
|
||||
|
||||
const onUpdateKey = (key: typeof simQuery.key) => {
|
||||
onChange({ ...query, sim: { ...simQuery, key } });
|
||||
@@ -90,6 +104,9 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
if (simKey.uid) {
|
||||
path += '/' + simKey.uid;
|
||||
}
|
||||
// Save config to query JSON so it persists in dashboard
|
||||
onChange({ ...query, sim: { ...simQuery, config } });
|
||||
// Also update server state
|
||||
ds.postResource<SimInfo>('sim/' + path, config).then((res) => {
|
||||
setCfgValue(res.config);
|
||||
});
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ const renderInput = (field: FieldSchema, onChange: SchemaFormProps['onChange'],
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={config?.[field.name]}
|
||||
value={config?.[field.name]}
|
||||
onChange={(e: FormEvent<HTMLInputElement>) => {
|
||||
const newValue = e.currentTarget.valueAsNumber;
|
||||
onChange({ ...config, [field.name]: newValue });
|
||||
@@ -76,7 +76,7 @@ export const SimulationSchemaForm = ({ config, schema, onChange }: SchemaFormPro
|
||||
onChange={() => setJsonView(!jsonView)}
|
||||
/>
|
||||
{jsonView ? (
|
||||
<TextArea defaultValue={JSON.stringify(config, null, 2)} rows={7} onChange={onUpdateTextArea} />
|
||||
<TextArea value={JSON.stringify(config, null, 2)} rows={7} onChange={onUpdateTextArea} />
|
||||
) : (
|
||||
<>
|
||||
{schema.fields.map((field) => (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Bar chart",
|
||||
"id": "barchart",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"description": "Categorical charts with group support",
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defaultsDeep } from 'lodash';
|
||||
|
||||
import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn, VizOrientation } from '@grafana/data';
|
||||
import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplier, VizOrientation } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { LegendDisplayMode, StackingMode, VisibilityMode } from '@grafana/schema';
|
||||
|
||||
@@ -32,7 +32,7 @@ const withDefaults = (suggestion: VisualizationSuggestion<Options, FieldConfig>)
|
||||
},
|
||||
} satisfies VisualizationSuggestion<Options, FieldConfig>);
|
||||
|
||||
export const barchartSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, FieldConfig> = (dataSummary) => {
|
||||
export const barchartSuggestionsSupplier: VisualizationSuggestionsSupplier<Options, FieldConfig> = (dataSummary) => {
|
||||
if (dataSummary.frameCount !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Bar gauge",
|
||||
"id": "bargauge",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"description": "Horizontal and vertical gauges",
|
||||
"author": {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
FieldColorModeId,
|
||||
FieldType,
|
||||
VisualizationSuggestion,
|
||||
VisualizationSuggestionsSupplierFn,
|
||||
VisualizationSuggestionsSupplier,
|
||||
VizOrientation,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
@@ -31,7 +31,7 @@ const withDefaults = (suggestion: VisualizationSuggestion<Options>): Visualizati
|
||||
|
||||
const BAR_LIMIT = 30;
|
||||
|
||||
export const barGaugeSugggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
|
||||
export const barGaugeSugggestionsSupplier: VisualizationSuggestionsSupplier<Options> = (dataSummary) => {
|
||||
if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Candlestick",
|
||||
"id": "candlestick",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"description": "Graphical representation of price movements of a security, derivative, or currency.",
|
||||
"keywords": ["financial", "price", "currency", "k-line"],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
|
||||
import { FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { prepareCandlestickFields } from './fields';
|
||||
import { defaultOptions, Options } from './types';
|
||||
|
||||
export const candlestickSuggestionSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
|
||||
export const candlestickSuggestionSupplier: VisualizationSuggestionsSupplier<Options> = (dataSummary) => {
|
||||
if (
|
||||
!dataSummary.rawFrames ||
|
||||
!dataSummary.hasData ||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useObservable } from 'react-use';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||
import { SelectableValue, StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { PositionDimensionConfig, ScalarDimensionConfig, ScalarDimensionMode } from '@grafana/schema';
|
||||
import { Field, Icon, InlineField, InlineFieldRow, Select, Stack } from '@grafana/ui';
|
||||
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
|
||||
import { PositionDimensionEditor } from 'app/features/dimensions/editors/PositionDimensionEditor';
|
||||
import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor';
|
||||
|
||||
import { HorizontalConstraint, Options, Placement, VerticalConstraint } from '../../panelcfg.gen';
|
||||
|
||||
@@ -12,7 +14,7 @@ import { ConstraintSelectionBox } from './ConstraintSelectionBox';
|
||||
import { QuickPositioning } from './QuickPositioning';
|
||||
import { CanvasEditorOptions } from './elementEditor';
|
||||
|
||||
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height', 'rotation'];
|
||||
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height'];
|
||||
|
||||
type Props = StandardEditorProps<unknown, CanvasEditorOptions, Options>;
|
||||
|
||||
@@ -61,8 +63,9 @@ export function PlacementEditor({ item }: Props) {
|
||||
const { options } = element;
|
||||
const { placement, constraint: layout } = options;
|
||||
|
||||
if (placement) {
|
||||
placement.rotation = placement?.rotation ?? 0;
|
||||
// Initialize rotation if not set
|
||||
if (placement && !placement.rotation) {
|
||||
placement.rotation = { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped };
|
||||
}
|
||||
|
||||
const reselectElementAfterChange = () => {
|
||||
@@ -95,20 +98,34 @@ export function PlacementEditor({ item }: Props) {
|
||||
reselectElementAfterChange();
|
||||
};
|
||||
|
||||
const onPositionChange = (value: number | undefined, placement: keyof Placement) => {
|
||||
element.options.placement![placement] = value ?? element.options.placement![placement];
|
||||
element.applyLayoutStylesToDiv();
|
||||
settings.scene.clearCurrentSelection(true);
|
||||
reselectElementAfterChange();
|
||||
const onPositionChange = (value: PositionDimensionConfig | undefined, key: keyof Placement) => {
|
||||
if (value && key !== 'rotation') {
|
||||
element.options.placement![key] = value as any;
|
||||
element.updateData(settings.scene.context);
|
||||
element.applyLayoutStylesToDiv();
|
||||
settings.scene.clearCurrentSelection(true);
|
||||
reselectElementAfterChange();
|
||||
}
|
||||
};
|
||||
|
||||
const onRotationChange = (value?: ScalarDimensionConfig) => {
|
||||
if (value) {
|
||||
element.options.placement!.rotation = value;
|
||||
element.updateData(settings.scene.context);
|
||||
element.applyLayoutStylesToDiv();
|
||||
settings.scene.clearCurrentSelection(true);
|
||||
reselectElementAfterChange();
|
||||
}
|
||||
};
|
||||
|
||||
const constraint = element.tempConstraint ?? layout ?? {};
|
||||
const editorContext = { ...settings.scene.context, data: settings.scene.context.getPanelData()?.series ?? [] };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<QuickPositioning onPositionChange={onPositionChange} settings={settings} element={element} />
|
||||
<br />
|
||||
<Field label={t('canvas.placement-editor.label-constraints', 'Constraints')}>
|
||||
<Field label={t('canvas.placement-editor.label-constraints', 'Constraints')} noMargin>
|
||||
<Stack direction="row">
|
||||
<ConstraintSelectionBox
|
||||
onVerticalConstraintChange={onVerticalConstraintChange}
|
||||
@@ -134,7 +151,7 @@ export function PlacementEditor({ item }: Props) {
|
||||
|
||||
<br />
|
||||
|
||||
<Field label={t('canvas.placement-editor.label-position', 'Position')}>
|
||||
<Field label={t('canvas.placement-editor.label-position', 'Position')} noMargin>
|
||||
<>
|
||||
{places.map((p) => {
|
||||
const v = placement![p];
|
||||
@@ -142,18 +159,40 @@ export function PlacementEditor({ item }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to set explicit min/max for rotation as logic only can handle 0-360
|
||||
const min = p === 'rotation' ? 0 : undefined;
|
||||
const max = p === 'rotation' ? 360 : undefined;
|
||||
|
||||
return (
|
||||
<InlineFieldRow key={p}>
|
||||
<InlineField label={p} labelWidth={8} grow={true}>
|
||||
<NumberInput min={min} max={max} value={v} onChange={(v) => onPositionChange(v, p)} />
|
||||
<PositionDimensionEditor
|
||||
value={v as PositionDimensionConfig}
|
||||
context={editorContext}
|
||||
onChange={(val) => onPositionChange(val, p)}
|
||||
item={{} as any}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
})}
|
||||
{placement?.rotation && (
|
||||
<InlineFieldRow>
|
||||
<InlineField label={t('canvas.placement-editor.label-rotation', 'rotation')} labelWidth={8} grow={true}>
|
||||
<ScalarDimensionEditor
|
||||
value={placement.rotation}
|
||||
context={editorContext}
|
||||
onChange={onRotationChange}
|
||||
item={
|
||||
{
|
||||
id: 'rotation',
|
||||
name: 'Rotation',
|
||||
settings: {
|
||||
min: 0,
|
||||
max: 360,
|
||||
},
|
||||
} as StandardEditorsRegistryItem<ScalarDimensionConfig>
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
</>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
|
||||
import { IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { QuickPlacement } from 'app/features/canvas/types';
|
||||
@@ -11,7 +12,7 @@ import { HorizontalConstraint, VerticalConstraint, Placement } from '../../panel
|
||||
import { CanvasEditorOptions } from './elementEditor';
|
||||
|
||||
type Props = {
|
||||
onPositionChange: (value: number | undefined, placement: keyof Placement) => void;
|
||||
onPositionChange: (value: PositionDimensionConfig | undefined, placement: keyof Placement) => void;
|
||||
element: ElementState;
|
||||
settings: CanvasEditorOptions;
|
||||
};
|
||||
@@ -19,6 +20,17 @@ type Props = {
|
||||
export const QuickPositioning = ({ onPositionChange, element, settings }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// Helper to get numeric value from PositionDimensionConfig
|
||||
const getPositionValue = (config: PositionDimensionConfig | undefined): number => {
|
||||
return config?.fixed ?? 0;
|
||||
};
|
||||
|
||||
// Helper to create a fixed PositionDimensionConfig
|
||||
const fixedPosition = (value: number): PositionDimensionConfig => ({
|
||||
fixed: value,
|
||||
mode: PositionDimensionMode.Fixed,
|
||||
});
|
||||
|
||||
const onQuickPositioningChange = (position: QuickPlacement) => {
|
||||
const defaultConstraint = { vertical: VerticalConstraint.Top, horizontal: HorizontalConstraint.Left };
|
||||
const originalConstraint = { ...element.options.constraint };
|
||||
@@ -26,24 +38,27 @@ export const QuickPositioning = ({ onPositionChange, element, settings }: Props)
|
||||
element.options.constraint = defaultConstraint;
|
||||
element.setPlacementFromConstraint();
|
||||
|
||||
const height = getPositionValue(element.options.placement?.height);
|
||||
const width = getPositionValue(element.options.placement?.width);
|
||||
|
||||
switch (position) {
|
||||
case QuickPlacement.Top:
|
||||
onPositionChange(0, 'top');
|
||||
onPositionChange(fixedPosition(0), 'top');
|
||||
break;
|
||||
case QuickPlacement.Bottom:
|
||||
onPositionChange(getRightBottomPosition(element.options.placement?.height ?? 0, 'bottom'), 'top');
|
||||
onPositionChange(fixedPosition(getRightBottomPosition(height, 'bottom')), 'top');
|
||||
break;
|
||||
case QuickPlacement.VerticalCenter:
|
||||
onPositionChange(getCenterPosition(element.options.placement?.height ?? 0, 'v'), 'top');
|
||||
onPositionChange(fixedPosition(getCenterPosition(height, 'v')), 'top');
|
||||
break;
|
||||
case QuickPlacement.Left:
|
||||
onPositionChange(0, 'left');
|
||||
onPositionChange(fixedPosition(0), 'left');
|
||||
break;
|
||||
case QuickPlacement.Right:
|
||||
onPositionChange(getRightBottomPosition(element.options.placement?.width ?? 0, 'right'), 'left');
|
||||
onPositionChange(fixedPosition(getRightBottomPosition(width, 'right')), 'left');
|
||||
break;
|
||||
case QuickPlacement.HorizontalCenter:
|
||||
onPositionChange(getCenterPosition(element.options.placement?.width ?? 0, 'h'), 'left');
|
||||
onPositionChange(fixedPosition(getCenterPosition(width, 'h')), 'left');
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { PanelModel, OneClickMode } from '@grafana/data';
|
||||
import { PositionDimensionMode, ScalarDimensionMode } from '@grafana/schema';
|
||||
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
// Helper to migrate a position value from number to PositionDimensionConfig
|
||||
const migratePositionValue = (value: number | undefined) => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
fixed: value,
|
||||
mode: PositionDimensionMode.Fixed,
|
||||
};
|
||||
};
|
||||
|
||||
export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
|
||||
const pluginVersion = panel?.pluginVersion ?? '';
|
||||
|
||||
@@ -99,5 +111,52 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
|
||||
}
|
||||
}
|
||||
|
||||
// migrate placement values from numbers to dimension configs
|
||||
if (parseFloat(pluginVersion) <= 12.4) {
|
||||
const root = panel.options?.root;
|
||||
if (root?.elements) {
|
||||
for (const element of root.elements) {
|
||||
if (element.placement) {
|
||||
// Migrate rotation from number to ScalarDimensionConfig
|
||||
if (typeof element.placement.rotation === 'number') {
|
||||
element.placement.rotation = {
|
||||
fixed: element.placement.rotation,
|
||||
min: 0,
|
||||
max: 360,
|
||||
mode: ScalarDimensionMode.Clamped,
|
||||
};
|
||||
} else if (!element.placement.rotation) {
|
||||
element.placement.rotation = {
|
||||
fixed: 0,
|
||||
min: 0,
|
||||
max: 360,
|
||||
mode: ScalarDimensionMode.Clamped,
|
||||
};
|
||||
}
|
||||
|
||||
// Migrate position values from numbers to PositionDimensionConfig
|
||||
if (typeof element.placement.top === 'number') {
|
||||
element.placement.top = migratePositionValue(element.placement.top);
|
||||
}
|
||||
if (typeof element.placement.left === 'number') {
|
||||
element.placement.left = migratePositionValue(element.placement.left);
|
||||
}
|
||||
if (typeof element.placement.width === 'number') {
|
||||
element.placement.width = migratePositionValue(element.placement.width);
|
||||
}
|
||||
if (typeof element.placement.height === 'number') {
|
||||
element.placement.height = migratePositionValue(element.placement.height);
|
||||
}
|
||||
if (typeof element.placement.right === 'number') {
|
||||
element.placement.right = migratePositionValue(element.placement.right);
|
||||
}
|
||||
if (typeof element.placement.bottom === 'number') {
|
||||
element.placement.bottom = migratePositionValue(element.placement.bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return panel.options;
|
||||
};
|
||||
|
||||
@@ -35,15 +35,15 @@ composableKinds: PanelCfg: {
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
Placement: {
|
||||
top?: float64
|
||||
left?: float64
|
||||
right?: float64
|
||||
bottom?: float64
|
||||
top?: ui.PositionDimensionConfig
|
||||
left?: ui.PositionDimensionConfig
|
||||
right?: ui.PositionDimensionConfig
|
||||
bottom?: ui.PositionDimensionConfig
|
||||
|
||||
width?: float64
|
||||
height?: float64
|
||||
width?: ui.PositionDimensionConfig
|
||||
height?: ui.PositionDimensionConfig
|
||||
|
||||
rotation?: float64
|
||||
rotation?: ui.ScalarDimensionConfig
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
BackgroundImageSize: "original" | "contain" | "cover" | "fill" | "tile" @cuetsy(kind="enum", memberNames="Original|Contain|Cover|Fill|Tile")
|
||||
|
||||
+7
-7
@@ -32,13 +32,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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isNumber, isString } from 'lodash';
|
||||
|
||||
import { DataFrame, Field, AppEvents, getFieldDisplayName, PluginState, SelectableValue } from '@grafana/data';
|
||||
import { ConnectionDirection } from '@grafana/schema';
|
||||
import { ConnectionDirection, PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { hasAlphaPanels, config } from 'app/core/config';
|
||||
import { CanvasConnection, CanvasElementItem, CanvasElementOptions } from 'app/features/canvas/element';
|
||||
@@ -15,6 +15,9 @@ import { AnchorPoint, ConnectionState, LineStyle, StrokeDasharray } from './type
|
||||
|
||||
export function doSelect(scene: Scene, element: ElementState | FrameState) {
|
||||
try {
|
||||
// Clear any previous field-driven selection
|
||||
scene.clearFieldDrivenSelection?.();
|
||||
|
||||
let selection: SelectionParams = { targets: [] };
|
||||
if (element instanceof FrameState) {
|
||||
const targetElements: HTMLDivElement[] = [];
|
||||
@@ -22,6 +25,14 @@ export function doSelect(scene: Scene, element: ElementState | FrameState) {
|
||||
selection.targets = targetElements;
|
||||
selection.frame = element;
|
||||
scene.select(selection);
|
||||
} else if (element.hasFieldDrivenPosition()) {
|
||||
// Field-driven elements can't be selected via Selecto, show custom selection
|
||||
scene.currentLayer = element.parent;
|
||||
scene.setFieldDrivenSelection(element);
|
||||
// Clear Selecto selection and broadcast this element as selected
|
||||
scene.selecto?.setSelectedTargets([]);
|
||||
scene.moveable!.target = [];
|
||||
scene.selection.next([element]);
|
||||
} else {
|
||||
scene.currentLayer = element.parent;
|
||||
selection.targets = [element?.div!];
|
||||
@@ -81,12 +92,30 @@ export function onAddItem(sel: SelectableValue<string>, rootLayer: FrameState |
|
||||
name: '',
|
||||
};
|
||||
|
||||
// Helper to create a fixed PositionDimensionConfig
|
||||
const fixedPosition = (value: number): PositionDimensionConfig => ({
|
||||
fixed: value,
|
||||
mode: PositionDimensionMode.Fixed,
|
||||
});
|
||||
|
||||
if (anchorPoint) {
|
||||
newElementOptions.placement = { ...newElementOptions.placement, top: anchorPoint.y, left: anchorPoint.x };
|
||||
newElementOptions.placement = {
|
||||
...newElementOptions.placement,
|
||||
top: fixedPosition(anchorPoint.y),
|
||||
left: fixedPosition(anchorPoint.x),
|
||||
};
|
||||
}
|
||||
|
||||
if (newItem.defaultSize) {
|
||||
newElementOptions.placement = { ...newElementOptions.placement, ...newItem.defaultSize };
|
||||
// defaultSize uses simple numbers, convert to PositionDimensionConfig
|
||||
const sizeConfig: Partial<typeof newElementOptions.placement> = {};
|
||||
if (newItem.defaultSize.width !== undefined) {
|
||||
sizeConfig.width = fixedPosition(newItem.defaultSize.width);
|
||||
}
|
||||
if (newItem.defaultSize.height !== undefined) {
|
||||
sizeConfig.height = fixedPosition(newItem.defaultSize.height);
|
||||
}
|
||||
newElementOptions.placement = { ...newElementOptions.placement, ...sizeConfig };
|
||||
}
|
||||
|
||||
if (rootLayer) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Flame Graph",
|
||||
"id": "flamegraph",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Gauge",
|
||||
"id": "gauge",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"description": "Standard gauge visualization",
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defaultsDeep } from 'lodash';
|
||||
|
||||
import { ThresholdsMode, FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn } from '@grafana/data';
|
||||
import { ThresholdsMode, FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplier } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
|
||||
|
||||
@@ -33,7 +33,7 @@ const withDefaults = (suggestion: VisualizationSuggestion<Options>): Visualizati
|
||||
|
||||
const GAUGE_LIMIT = 10;
|
||||
|
||||
export const gaugeSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
|
||||
export const gaugeSuggestionsSupplier: VisualizationSuggestionsSupplier<Options> = (dataSummary) => {
|
||||
if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Geomap",
|
||||
"id": "geomap",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"description": "Geomap panel",
|
||||
"author": {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
|
||||
import { VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data';
|
||||
import { GraphFieldConfig } from '@grafana/ui';
|
||||
import { getGeometryField, getDefaultLocationMatchers } from 'app/features/geo/utils/location';
|
||||
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
export const geomapSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
|
||||
dataSummary
|
||||
) => {
|
||||
export const geomapSuggestionsSupplier: VisualizationSuggestionsSupplier<Options, GraphFieldConfig> = (dataSummary) => {
|
||||
if (!dataSummary.hasData || !dataSummary.rawFrames) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Heatmap",
|
||||
"id": "heatmap",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"description": "Like a histogram over time",
|
||||
"author": {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
FieldType,
|
||||
PanelDataSummary,
|
||||
VisualizationSuggestionScore,
|
||||
VisualizationSuggestionsSupplierFn,
|
||||
VisualizationSuggestionsSupplier,
|
||||
} from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { GraphFieldConfig } from '@grafana/schema';
|
||||
@@ -43,7 +43,7 @@ function determineScore(dataSummary: PanelDataSummary): VisualizationSuggestionS
|
||||
return VisualizationSuggestionScore.OK;
|
||||
}
|
||||
|
||||
export const heatmapSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
|
||||
export const heatmapSuggestionsSupplier: VisualizationSuggestionsSupplier<Options, GraphFieldConfig> = (
|
||||
dataSummary: PanelDataSummary
|
||||
) => {
|
||||
if (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Histogram",
|
||||
"id": "histogram",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"description": "Distribution of values presented as a bar chart.",
|
||||
"keywords": ["distribution", "bar chart", "frequency", "proportional"],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Logs",
|
||||
"id": "logs",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Node Graph",
|
||||
"id": "nodeGraph",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DataFrame, FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
|
||||
import { DataFrame, FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data';
|
||||
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
@@ -44,7 +44,7 @@ function frameHasCorrectFields(frames: DataFrame[]): boolean {
|
||||
return hasNodesFrame && hasEdgesFrame;
|
||||
}
|
||||
|
||||
export const nodeGraphSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
|
||||
export const nodeGraphSuggestionsSupplier: VisualizationSuggestionsSupplier<Options> = (dataSummary) => {
|
||||
if (!dataSummary.rawFrames) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "panel",
|
||||
"name": "Pie chart",
|
||||
"id": "piechart",
|
||||
|
||||
"suggestions": true,
|
||||
"info": {
|
||||
"description": "The new core pie chart visualization",
|
||||
"author": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user