Compare commits

..

26 Commits

Author SHA1 Message Date
Drew Slobodnjak 8b982190f1 Compress mesh file 2025-12-08 17:22:40 -08:00
Drew Slobodnjak 953f08ed55 Flip y for 3d rendering 2025-12-08 14:56:16 -08:00
Drew Slobodnjak 8ed233e867 Double number of colors for 3d demo 2025-12-07 23:15:17 -08:00
Drew Slobodnjak e326306485 Change default ambient lighting 2025-12-07 16:45:32 -08:00
Drew Slobodnjak 632f47e367 Add lighting 2025-12-07 16:37:46 -08:00
Drew Slobodnjak 3d7358c4ce Add 3d grot simulation 2025-12-07 16:08:33 -08:00
Drew Slobodnjak fc8893bc53 Add an option to svg element for field driven 2025-12-06 23:38:25 -08:00
Drew Slobodnjak 2723a719aa Add 2 boss particles 2025-12-06 16:39:59 -08:00
Drew Slobodnjak 52d4c928e2 Limit rotation change rate 2025-12-05 13:45:06 -08:00
Drew Slobodnjak efa577e186 Add a rotation output to nbody sim 2025-12-05 13:21:46 -08:00
Drew Slobodnjak 2627df30d6 Prevent class name collisions 2025-12-05 12:41:07 -08:00
Drew Slobodnjak bf2870c8c8 Add generic svg element 2025-12-05 12:18:37 -08:00
Drew Slobodnjak c53d239aae Adjust radius to be a bit smaller 2025-12-05 11:56:49 -08:00
Drew Slobodnjak cdde735f41 Add attraction between points 2025-12-04 12:13:43 -08:00
Drew Slobodnjak 7be8de044c Fix flicker 2025-12-04 11:07:45 -08:00
Drew Slobodnjak 34305670c5 Add n body simulation 2025-12-04 10:20:24 -08:00
Drew Slobodnjak 308bc56d0f Canvas: Field driven layout 2025-12-02 23:33:19 -08:00
Paul Marbach e36ea78771 Suggestions: Deprecate the old API and put external suggestions behind a flag (#114127)
* Suggestions: Deprecate previous API, enable external plugin suggestions behind flag

* fix types for deprecated builder

* restore some support for cloud-onboarding

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

* refactor to not hardcode on 'core:'

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

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

* Address review comments
2025-12-02 00:14:54 +01:00
Todd Treece 1060dd538a CI: Run lint on self-hosted ubuntu-x64-small (#114674) 2025-12-01 22:27:14 +00:00
Todd Treece be8076dee8 CI: Run lint on ubuntu-latest-8-cores (#114673) 2025-12-01 21:40:46 +00:00
Ashley Harrison 7f1ac6188a PanelChrome: Wrapping div needs height: 100% as well (#114655)
wrapping div needs height: 100% as well
2025-12-01 17:39:15 +00:00
Rafael Bortolon Paulovic 31eaf1e898 chore: add log and metric before unified migration enforcement (#114598) 2025-12-01 17:56:59 +01:00
Sergej-Vlasov 780a64e771 DashboardControls: Adjust dashboard controls UI shift (#114639)
adjust controls layout
2025-12-01 16:08:26 +00:00
Dave Thompson 156a6f1375 fix(operator): unify service center capitalization (#113720)
Change "Service Center" to "Service center" in navigation menu to follow
sentence case capitalization style consistently across the application.

Fixes grafana/slo#3818

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-01 17:20:29 +02:00
Isabel Matwawana 5fd4fb5fb8 Docs: Add missing layout options and rework Grid view section (#113007)
Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>
Co-authored-by: Joey <90795735+joey-grafana@users.noreply.github.com>
2025-12-01 09:19:00 -05:00
187 changed files with 3920 additions and 4105 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
lint-go:
needs: detect-changes
if: needs.detect-changes.outputs.changed == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-x64-large-io
steps:
- uses: actions/checkout@v5
with:
+9 -23
View File
@@ -1,34 +1,20 @@
package kinds
import (
"github.com/grafana/grafana/apps/alerting/historian/kinds/v0alpha1"
)
manifest: {
appName: "alerting-historian"
groupOverride: "historian.alerting.grafana.app"
versions: {
"v0alpha1": v0alpha1
"v0alpha1": {
kinds: [dummyv0alpha1]
routes: v0alpha1.routes
}
}
}
v0alpha1: {
kinds: [dummyv0alpha1]
routes: {
namespaced: {
// This endpoint is an exact copy of the existing /history endpoint,
// with the exception that error responses will be Kubernetes-style,
// not Grafana-style. It will be replaced in the future with a better
// more schema-friendly API.
"/alertstate/history": {
"GET": {
response: {
body: [string]: _
}
responseMetadata: typeMeta: false
}
}
}
}
}
dummyv0alpha1: {
kind: "Dummy"
schema: {
@@ -37,4 +23,4 @@ dummyv0alpha1: {
dummyField: int
}
}
}
}
@@ -0,0 +1,9 @@
package v0alpha1
#Matcher: {
type: "=" | "!=" | "=~" | "!~" @cuetsy(kind="enum",memberNames="Equal|NotEqual|EqualRegex|NotEqualRegex")
label: string
value: string
}
#Matchers: [...#Matcher]
@@ -0,0 +1,65 @@
package v0alpha1
import (
"time"
)
#NotificationStatus: "firing" | "resolved" @cog(kind="enum",memberNames="Firing|Resolved")
#NotificationOutcome: "success" | "error" @cog(kind="enum",memberNames="Success|Error")
#NotificationQuery: {
// From is the starting timestamp for the query.
from?: time.Time
// To is the starting timestamp for the query.
to?: time.Time
// Limit is the maximum number of entries to return.
limit?: int64
// Receiver optionally filters the entries by receiver title (contact point).
receiver?: string
// Status optionally filters the entries to only either firing or resolved.
status?: #NotificationStatus
// Outcome optionally filters the entries to only either successful or failed attempts.
outcome?: #NotificationOutcome
// RuleUID optionally filters the entries to a specific alert rule.
ruleUID?: string
// GroupLabels optionally filters the entries by matching group labels.
groupLabels?: #Matchers
}
#NotificationQueryResult: {
entries: [...#NotificationEntry]
}
#NotificationEntry: {
// Timestamp is the time at which the notification attempt completed.
timestamp: time.Time
// Receiver is the receiver (contact point) title.
receiver: string
// Status indicates if the notification contains one or more firing alerts.
status: #NotificationStatus
// Outcome indicaes if the notificaion attempt was successful or if it failed.
outcome: #NotificationOutcome
// GroupLabels are the labels uniquely identifying the alert group within a route.
groupLabels: [string]: string
// Alerts are the alerts grouped into the notification.
alerts: [...#NotificationEntryAlert]
// Retry indicates if the attempt was a retried attempt.
retry: bool
// Error is the message returned by the contact point if delivery failed.
error?: string
// Duration is the length of time the notification attempt took in nanoseconds.
duration: int
// PipelineTime is the time at which the flush began.
pipelineTime: time.Time
// GroupKey uniquely idenifies the dispatcher alert group.
groupKey: string
}
#NotificationEntryAlert: {
status: string
labels: [string]: string
annotations: [string]: string
startsAt: time.Time
endsAt: time.Time
}
@@ -0,0 +1,29 @@
package v0alpha1
routes: {
namespaced: {
// This endpoint is an exact copy of the existing /history endpoint,
// with the exception that error responses will be Kubernetes-style,
// not Grafana-style. It will be replaced in the future with a better
// more schema-friendly API.
"/alertstate/history": {
"GET": {
response: {
body: [string]: _
}
responseMetadata: typeMeta: false
}
}
// Query notification history.
"/notification/query": {
"POST": {
request: {
body: #NotificationQuery
}
response: #NotificationQueryResult
responseMetadata: typeMeta: false
}
}
}
}
@@ -0,0 +1,67 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
time "time"
)
type CreateNotificationqueryRequestNotificationStatus string
const (
CreateNotificationqueryRequestNotificationStatusFiring CreateNotificationqueryRequestNotificationStatus = "firing"
CreateNotificationqueryRequestNotificationStatusResolved CreateNotificationqueryRequestNotificationStatus = "resolved"
)
type CreateNotificationqueryRequestNotificationOutcome string
const (
CreateNotificationqueryRequestNotificationOutcomeSuccess CreateNotificationqueryRequestNotificationOutcome = "success"
CreateNotificationqueryRequestNotificationOutcomeError CreateNotificationqueryRequestNotificationOutcome = "error"
)
type CreateNotificationqueryRequestMatchers []CreateNotificationqueryRequestMatcher
type CreateNotificationqueryRequestMatcher struct {
Type CreateNotificationqueryRequestMatcherType `json:"type"`
Label string `json:"label"`
Value string `json:"value"`
}
// NewCreateNotificationqueryRequestMatcher creates a new CreateNotificationqueryRequestMatcher object.
func NewCreateNotificationqueryRequestMatcher() *CreateNotificationqueryRequestMatcher {
return &CreateNotificationqueryRequestMatcher{}
}
type CreateNotificationqueryRequestBody struct {
// From is the starting timestamp for the query.
From *time.Time `json:"from,omitempty"`
// To is the starting timestamp for the query.
To *time.Time `json:"to,omitempty"`
// Limit is the maximum number of entries to return.
Limit *int64 `json:"limit,omitempty"`
// Receiver optionally filters the entries by receiver title (contact point).
Receiver *string `json:"receiver,omitempty"`
// Status optionally filters the entries to only either firing or resolved.
Status *CreateNotificationqueryRequestNotificationStatus `json:"status,omitempty"`
// Outcome optionally filters the entries to only either successful or failed attempts.
Outcome *CreateNotificationqueryRequestNotificationOutcome `json:"outcome,omitempty"`
// RuleUID optionally filters the entries to a specific alert rule.
RuleUID *string `json:"ruleUID,omitempty"`
// GroupLabels optionally filters the entries by matching group labels.
GroupLabels *CreateNotificationqueryRequestMatchers `json:"groupLabels,omitempty"`
}
// NewCreateNotificationqueryRequestBody creates a new CreateNotificationqueryRequestBody object.
func NewCreateNotificationqueryRequestBody() *CreateNotificationqueryRequestBody {
return &CreateNotificationqueryRequestBody{}
}
type CreateNotificationqueryRequestMatcherType string
const (
CreateNotificationqueryRequestMatcherTypeEqual CreateNotificationqueryRequestMatcherType = "="
CreateNotificationqueryRequestMatcherTypeNotEqual CreateNotificationqueryRequestMatcherType = "!="
CreateNotificationqueryRequestMatcherTypeEqualRegex CreateNotificationqueryRequestMatcherType = "=~"
CreateNotificationqueryRequestMatcherTypeNotEqualRegex CreateNotificationqueryRequestMatcherType = "!~"
)
@@ -0,0 +1,86 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
time "time"
)
// +k8s:openapi-gen=true
type NotificationEntry struct {
// Timestamp is the time at which the notification attempt completed.
Timestamp time.Time `json:"timestamp"`
// Receiver is the receiver (contact point) title.
Receiver string `json:"receiver"`
// Status indicates if the notification contains one or more firing alerts.
Status NotificationStatus `json:"status"`
// Outcome indicaes if the notificaion attempt was successful or if it failed.
Outcome NotificationOutcome `json:"outcome"`
// GroupLabels are the labels uniquely identifying the alert group within a route.
GroupLabels map[string]string `json:"groupLabels"`
// Alerts are the alerts grouped into the notification.
Alerts []NotificationEntryAlert `json:"alerts"`
// Retry indicates if the attempt was a retried attempt.
Retry bool `json:"retry"`
// Error is the message returned by the contact point if delivery failed.
Error *string `json:"error,omitempty"`
// Duration is the length of time the notification attempt took in nanoseconds.
Duration int64 `json:"duration"`
// PipelineTime is the time at which the flush began.
PipelineTime time.Time `json:"pipelineTime"`
// GroupKey uniquely idenifies the dispatcher alert group.
GroupKey string `json:"groupKey"`
}
// NewNotificationEntry creates a new NotificationEntry object.
func NewNotificationEntry() *NotificationEntry {
return &NotificationEntry{
GroupLabels: map[string]string{},
Alerts: []NotificationEntryAlert{},
}
}
// +k8s:openapi-gen=true
type NotificationStatus string
const (
NotificationStatusFiring NotificationStatus = "firing"
NotificationStatusResolved NotificationStatus = "resolved"
)
// +k8s:openapi-gen=true
type NotificationOutcome string
const (
NotificationOutcomeSuccess NotificationOutcome = "success"
NotificationOutcomeError NotificationOutcome = "error"
)
// +k8s:openapi-gen=true
type NotificationEntryAlert struct {
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
}
// NewNotificationEntryAlert creates a new NotificationEntryAlert object.
func NewNotificationEntryAlert() *NotificationEntryAlert {
return &NotificationEntryAlert{
Labels: map[string]string{},
Annotations: map[string]string{},
}
}
// +k8s:openapi-gen=true
type CreateNotificationquery struct {
Entries []NotificationEntry `json:"entries"`
}
// NewCreateNotificationquery creates a new CreateNotificationquery object.
func NewCreateNotificationquery() *CreateNotificationquery {
return &CreateNotificationquery{
Entries: []NotificationEntry{},
}
}
+318 -3
View File
@@ -92,9 +92,321 @@ var appManifestData = app.ManifestData{
},
},
},
"/notification/query": {
Post: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "createNotificationquery",
RequestBody: &spec3.RequestBody{
RequestBodyProps: spec3.RequestBodyProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"from": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "From is the starting timestamp for the query.",
},
},
"groupLabels": {
SchemaProps: spec.SchemaProps{
Description: "GroupLabels optionally filters the entries by matching group labels.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryMatchers"),
},
},
"limit": {
SchemaProps: spec.SchemaProps{
Type: []string{"integer"},
Description: "Limit is the maximum number of entries to return.",
},
},
"outcome": {
SchemaProps: spec.SchemaProps{
Description: "Outcome optionally filters the entries to only either successful or failed attempts.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationOutcome"),
},
},
"receiver": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Receiver optionally filters the entries by receiver title (contact point).",
},
},
"ruleUID": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "RuleUID optionally filters the entries to a specific alert rule.",
},
},
"status": {
SchemaProps: spec.SchemaProps{
Description: "Status optionally filters the entries to only either firing or resolved.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationStatus"),
},
},
"to": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "To is the starting timestamp for the query.",
},
},
},
}},
}},
},
}},
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
Default: &spec3.Response{
ResponseProps: spec3.ResponseProps{
Description: "Default OK response",
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"entries": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
},
},
},
Required: []string{
"entries",
},
}},
}},
},
},
},
}},
},
},
},
},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{},
Schemas: map[string]spec.Schema{
"createNotificationqueryMatcher": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"label": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
"type": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Enum: []interface{}{
"=",
"!=",
"=~",
"!~",
},
},
},
"value": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
Required: []string{
"type",
"label",
"value",
},
},
},
"createNotificationqueryMatchers": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
},
},
"createNotificationqueryNotificationEntry": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"alerts": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Description: "Alerts are the alerts grouped into the notification.",
},
},
"duration": {
SchemaProps: spec.SchemaProps{
Type: []string{"integer"},
Description: "Duration is the length of time the notification attempt took in nanoseconds.",
},
},
"error": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Error is the message returned by the contact point if delivery failed.",
},
},
"groupKey": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "GroupKey uniquely idenifies the dispatcher alert group.",
},
},
"groupLabels": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Description: "GroupLabels are the labels uniquely identifying the alert group within a route.",
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
"outcome": {
SchemaProps: spec.SchemaProps{
Description: "Outcome indicaes if the notificaion attempt was successful or if it failed.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationOutcome"),
},
},
"pipelineTime": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "PipelineTime is the time at which the flush began.",
},
},
"receiver": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Receiver is the receiver (contact point) title.",
},
},
"retry": {
SchemaProps: spec.SchemaProps{
Type: []string{"boolean"},
Description: "Retry indicates if the attempt was a retried attempt.",
},
},
"status": {
SchemaProps: spec.SchemaProps{
Description: "Status indicates if the notification contains one or more firing alerts.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationStatus"),
},
},
"timestamp": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "Timestamp is the time at which the notification attempt completed.",
},
},
},
Required: []string{
"timestamp",
"receiver",
"status",
"outcome",
"groupLabels",
"alerts",
"retry",
"duration",
"pipelineTime",
"groupKey",
},
},
},
"createNotificationqueryNotificationEntryAlert": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"annotations": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
"endsAt": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
},
},
"labels": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
"startsAt": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
},
},
"status": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
Required: []string{
"status",
"labels",
"annotations",
"startsAt",
"endsAt",
},
},
},
"createNotificationqueryNotificationOutcome": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Enum: []interface{}{
"success",
"error",
},
},
},
"createNotificationqueryNotificationStatus": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Enum: []interface{}{
"firing",
"resolved",
},
},
},
},
},
},
},
@@ -120,7 +432,8 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist
}
var customRouteToGoResponseType = map[string]any{
"v0alpha1||<namespace>/alertstate/history|GET": v0alpha1.GetAlertstatehistory{},
"v0alpha1||<namespace>/alertstate/history|GET": v0alpha1.GetAlertstatehistory{},
"v0alpha1||<namespace>/notification/query|POST": v0alpha1.CreateNotificationquery{},
}
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
@@ -145,7 +458,9 @@ func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goTyp
return goType, exists
}
var customRouteToGoRequestBodyType = map[string]any{}
var customRouteToGoRequestBodyType = map[string]any{
"v0alpha1||<namespace>/notification/query|POST": v0alpha1.CreateNotificationqueryRequestBody{},
}
func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) {
if len(path) > 0 && path[0] == '/' {
+20
View File
@@ -1,8 +1,13 @@
package app
import (
"context"
"net/http"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/simple"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1"
"github.com/grafana/grafana/apps/alerting/historian/pkg/app/config"
@@ -21,6 +26,11 @@ func New(cfg app.Config) (app.App, error) {
Path: "/alertstate/history",
Method: "GET",
}: runtimeConfig.GetAlertStateHistoryHandler,
{
Namespaced: true,
Path: "/notification/query",
Method: "POST",
}: UnimplementedHandler,
},
},
// TODO: Remove when SDK is fixed.
@@ -43,3 +53,13 @@ func New(cfg app.Config) (app.App, error) {
return a, nil
}
func UnimplementedHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusUnprocessableEntity,
Message: "unimplemented",
},
}
}
+1 -1
View File
@@ -7,4 +7,4 @@ generate: install-app-sdk update-app-sdk
--gogenpath=./pkg/apis \
--grouping=group \
--genoperatorstate=false \
--defencoding=none
--defencoding=none
@@ -1,35 +0,0 @@
package preferences
datasourcestacksV1alpha1: {
kind: "DataSourceStack"
pluralName: "DataSourceStacks"
scope: "Namespaced"
schema: {
spec: {
template: TemplateSpec
modes: [...ModeSpec]
}
}
}
TemplateSpec: {
[string]: DataSourceStackTemplateItem
}
DataSourceStackTemplateItem: {
group: string // type
name: string // variable name / display name
}
ModeSpec: {
name: string
uid: string
definition: Mode
}
Mode: [string]: ModeItem
ModeItem: {
dataSourceRef: string // grafana data source uid
}
+3 -4
View File
@@ -6,13 +6,12 @@ manifest: {
versions: {
"v1alpha1": {
codegen: {
ts: {enabled: true}
ts: {enabled: false}
go: {enabled: true}
}
kinds: [
starsV1alpha1,
datasourcestacksV1alpha1
]
},
}
}
}
}
@@ -1,80 +0,0 @@
package v1alpha1
import (
"context"
"github.com/grafana/grafana-app-sdk/resource"
)
type DataSourceStackClient struct {
client *resource.TypedClient[*DataSourceStack, *DataSourceStackList]
}
func NewDataSourceStackClient(client resource.Client) *DataSourceStackClient {
return &DataSourceStackClient{
client: resource.NewTypedClient[*DataSourceStack, *DataSourceStackList](client, DataSourceStackKind()),
}
}
func NewDataSourceStackClientFromGenerator(generator resource.ClientGenerator) (*DataSourceStackClient, error) {
c, err := generator.ClientFor(DataSourceStackKind())
if err != nil {
return nil, err
}
return NewDataSourceStackClient(c), nil
}
func (c *DataSourceStackClient) Get(ctx context.Context, identifier resource.Identifier) (*DataSourceStack, error) {
return c.client.Get(ctx, identifier)
}
func (c *DataSourceStackClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*DataSourceStackList, error) {
return c.client.List(ctx, namespace, opts)
}
func (c *DataSourceStackClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*DataSourceStackList, error) {
resp, err := c.client.List(ctx, namespace, resource.ListOptions{
ResourceVersion: opts.ResourceVersion,
Limit: opts.Limit,
LabelFilters: opts.LabelFilters,
FieldSelectors: opts.FieldSelectors,
})
if err != nil {
return nil, err
}
for resp.GetContinue() != "" {
page, err := c.client.List(ctx, namespace, resource.ListOptions{
Continue: resp.GetContinue(),
ResourceVersion: opts.ResourceVersion,
Limit: opts.Limit,
LabelFilters: opts.LabelFilters,
FieldSelectors: opts.FieldSelectors,
})
if err != nil {
return nil, err
}
resp.SetContinue(page.GetContinue())
resp.SetResourceVersion(page.GetResourceVersion())
resp.SetItems(append(resp.GetItems(), page.GetItems()...))
}
return resp, nil
}
func (c *DataSourceStackClient) Create(ctx context.Context, obj *DataSourceStack, opts resource.CreateOptions) (*DataSourceStack, error) {
// Make sure apiVersion and kind are set
obj.APIVersion = GroupVersion.Identifier()
obj.Kind = DataSourceStackKind().Kind()
return c.client.Create(ctx, obj, opts)
}
func (c *DataSourceStackClient) Update(ctx context.Context, obj *DataSourceStack, opts resource.UpdateOptions) (*DataSourceStack, error) {
return c.client.Update(ctx, obj, opts)
}
func (c *DataSourceStackClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*DataSourceStack, error) {
return c.client.Patch(ctx, identifier, req, opts)
}
func (c *DataSourceStackClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error {
return c.client.Delete(ctx, identifier, opts)
}
@@ -1,28 +0,0 @@
//
// Code generated by grafana-app-sdk. DO NOT EDIT.
//
package v1alpha1
import (
"encoding/json"
"io"
"github.com/grafana/grafana-app-sdk/resource"
)
// DataSourceStackJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding
type DataSourceStackJSONCodec struct{}
// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into`
func (*DataSourceStackJSONCodec) Read(reader io.Reader, into resource.Object) error {
return json.NewDecoder(reader).Decode(into)
}
// Write writes JSON-encoded bytes into `writer` marshaled from `from`
func (*DataSourceStackJSONCodec) Write(writer io.Writer, from resource.Object) error {
return json.NewEncoder(writer).Encode(from)
}
// Interface compliance checks
var _ resource.Codec = &DataSourceStackJSONCodec{}
@@ -1,31 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v1alpha1
import (
time "time"
)
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
type DataSourceStackMetadata struct {
UpdateTimestamp time.Time `json:"updateTimestamp"`
CreatedBy string `json:"createdBy"`
Uid string `json:"uid"`
CreationTimestamp time.Time `json:"creationTimestamp"`
DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"`
Finalizers []string `json:"finalizers"`
ResourceVersion string `json:"resourceVersion"`
Generation int64 `json:"generation"`
UpdatedBy string `json:"updatedBy"`
Labels map[string]string `json:"labels"`
}
// NewDataSourceStackMetadata creates a new DataSourceStackMetadata object.
func NewDataSourceStackMetadata() *DataSourceStackMetadata {
return &DataSourceStackMetadata{
Finalizers: []string{},
Labels: map[string]string{},
}
}
@@ -1,293 +0,0 @@
//
// Code generated by grafana-app-sdk. DO NOT EDIT.
//
package v1alpha1
import (
"fmt"
"github.com/grafana/grafana-app-sdk/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"time"
)
// +k8s:openapi-gen=true
type DataSourceStack struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ObjectMeta `json:"metadata" yaml:"metadata"`
// Spec is the spec of the DataSourceStack
Spec DataSourceStackSpec `json:"spec" yaml:"spec"`
}
func (o *DataSourceStack) GetSpec() any {
return o.Spec
}
func (o *DataSourceStack) SetSpec(spec any) error {
cast, ok := spec.(DataSourceStackSpec)
if !ok {
return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec)
}
o.Spec = cast
return nil
}
func (o *DataSourceStack) GetSubresources() map[string]any {
return map[string]any{}
}
func (o *DataSourceStack) GetSubresource(name string) (any, bool) {
switch name {
default:
return nil, false
}
}
func (o *DataSourceStack) SetSubresource(name string, value any) error {
switch name {
default:
return fmt.Errorf("subresource '%s' does not exist", name)
}
}
func (o *DataSourceStack) GetStaticMetadata() resource.StaticMetadata {
gvk := o.GroupVersionKind()
return resource.StaticMetadata{
Name: o.ObjectMeta.Name,
Namespace: o.ObjectMeta.Namespace,
Group: gvk.Group,
Version: gvk.Version,
Kind: gvk.Kind,
}
}
func (o *DataSourceStack) SetStaticMetadata(metadata resource.StaticMetadata) {
o.Name = metadata.Name
o.Namespace = metadata.Namespace
o.SetGroupVersionKind(schema.GroupVersionKind{
Group: metadata.Group,
Version: metadata.Version,
Kind: metadata.Kind,
})
}
func (o *DataSourceStack) GetCommonMetadata() resource.CommonMetadata {
dt := o.DeletionTimestamp
var deletionTimestamp *time.Time
if dt != nil {
deletionTimestamp = &dt.Time
}
// Legacy ExtraFields support
extraFields := make(map[string]any)
if o.Annotations != nil {
extraFields["annotations"] = o.Annotations
}
if o.ManagedFields != nil {
extraFields["managedFields"] = o.ManagedFields
}
if o.OwnerReferences != nil {
extraFields["ownerReferences"] = o.OwnerReferences
}
return resource.CommonMetadata{
UID: string(o.UID),
ResourceVersion: o.ResourceVersion,
Generation: o.Generation,
Labels: o.Labels,
CreationTimestamp: o.CreationTimestamp.Time,
DeletionTimestamp: deletionTimestamp,
Finalizers: o.Finalizers,
UpdateTimestamp: o.GetUpdateTimestamp(),
CreatedBy: o.GetCreatedBy(),
UpdatedBy: o.GetUpdatedBy(),
ExtraFields: extraFields,
}
}
func (o *DataSourceStack) SetCommonMetadata(metadata resource.CommonMetadata) {
o.UID = types.UID(metadata.UID)
o.ResourceVersion = metadata.ResourceVersion
o.Generation = metadata.Generation
o.Labels = metadata.Labels
o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp)
if metadata.DeletionTimestamp != nil {
dt := metav1.NewTime(*metadata.DeletionTimestamp)
o.DeletionTimestamp = &dt
} else {
o.DeletionTimestamp = nil
}
o.Finalizers = metadata.Finalizers
if o.Annotations == nil {
o.Annotations = make(map[string]string)
}
if !metadata.UpdateTimestamp.IsZero() {
o.SetUpdateTimestamp(metadata.UpdateTimestamp)
}
if metadata.CreatedBy != "" {
o.SetCreatedBy(metadata.CreatedBy)
}
if metadata.UpdatedBy != "" {
o.SetUpdatedBy(metadata.UpdatedBy)
}
// Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields
if metadata.ExtraFields != nil {
if annotations, ok := metadata.ExtraFields["annotations"]; ok {
if cast, ok := annotations.(map[string]string); ok {
o.Annotations = cast
}
}
if managedFields, ok := metadata.ExtraFields["managedFields"]; ok {
if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok {
o.ManagedFields = cast
}
}
if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok {
if cast, ok := ownerReferences.([]metav1.OwnerReference); ok {
o.OwnerReferences = cast
}
}
}
}
func (o *DataSourceStack) GetCreatedBy() string {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
return o.ObjectMeta.Annotations["grafana.com/createdBy"]
}
func (o *DataSourceStack) SetCreatedBy(createdBy string) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy
}
func (o *DataSourceStack) GetUpdateTimestamp() time.Time {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"])
return parsed
}
func (o *DataSourceStack) SetUpdateTimestamp(updateTimestamp time.Time) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339)
}
func (o *DataSourceStack) GetUpdatedBy() string {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
return o.ObjectMeta.Annotations["grafana.com/updatedBy"]
}
func (o *DataSourceStack) SetUpdatedBy(updatedBy string) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy
}
func (o *DataSourceStack) Copy() resource.Object {
return resource.CopyObject(o)
}
func (o *DataSourceStack) DeepCopyObject() runtime.Object {
return o.Copy()
}
func (o *DataSourceStack) DeepCopy() *DataSourceStack {
cpy := &DataSourceStack{}
o.DeepCopyInto(cpy)
return cpy
}
func (o *DataSourceStack) DeepCopyInto(dst *DataSourceStack) {
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
dst.TypeMeta.Kind = o.TypeMeta.Kind
o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta)
o.Spec.DeepCopyInto(&dst.Spec)
}
// Interface compliance compile-time check
var _ resource.Object = &DataSourceStack{}
// +k8s:openapi-gen=true
type DataSourceStackList struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ListMeta `json:"metadata" yaml:"metadata"`
Items []DataSourceStack `json:"items" yaml:"items"`
}
func (o *DataSourceStackList) DeepCopyObject() runtime.Object {
return o.Copy()
}
func (o *DataSourceStackList) Copy() resource.ListObject {
cpy := &DataSourceStackList{
TypeMeta: o.TypeMeta,
Items: make([]DataSourceStack, len(o.Items)),
}
o.ListMeta.DeepCopyInto(&cpy.ListMeta)
for i := 0; i < len(o.Items); i++ {
if item, ok := o.Items[i].Copy().(*DataSourceStack); ok {
cpy.Items[i] = *item
}
}
return cpy
}
func (o *DataSourceStackList) GetItems() []resource.Object {
items := make([]resource.Object, len(o.Items))
for i := 0; i < len(o.Items); i++ {
items[i] = &o.Items[i]
}
return items
}
func (o *DataSourceStackList) SetItems(items []resource.Object) {
o.Items = make([]DataSourceStack, len(items))
for i := 0; i < len(items); i++ {
o.Items[i] = *items[i].(*DataSourceStack)
}
}
func (o *DataSourceStackList) DeepCopy() *DataSourceStackList {
cpy := &DataSourceStackList{}
o.DeepCopyInto(cpy)
return cpy
}
func (o *DataSourceStackList) DeepCopyInto(dst *DataSourceStackList) {
resource.CopyObjectInto(dst, o)
}
// Interface compliance compile-time check
var _ resource.ListObject = &DataSourceStackList{}
// Copy methods for all subresource types
// DeepCopy creates a full deep copy of Spec
func (s *DataSourceStackSpec) DeepCopy() *DataSourceStackSpec {
cpy := &DataSourceStackSpec{}
s.DeepCopyInto(cpy)
return cpy
}
// DeepCopyInto deep copies Spec into another Spec object
func (s *DataSourceStackSpec) DeepCopyInto(dst *DataSourceStackSpec) {
resource.CopyObjectInto(dst, s)
}
@@ -1,34 +0,0 @@
//
// Code generated by grafana-app-sdk. DO NOT EDIT.
//
package v1alpha1
import (
"github.com/grafana/grafana-app-sdk/resource"
)
// schema is unexported to prevent accidental overwrites
var (
schemaDataSourceStack = resource.NewSimpleSchema("collections.grafana.app", "v1alpha1", &DataSourceStack{}, &DataSourceStackList{}, resource.WithKind("DataSourceStack"),
resource.WithPlural("datasourcestacks"), resource.WithScope(resource.NamespacedScope))
kindDataSourceStack = resource.Kind{
Schema: schemaDataSourceStack,
Codecs: map[resource.KindEncoding]resource.Codec{
resource.KindEncodingJSON: &DataSourceStackJSONCodec{},
},
}
)
// Kind returns a resource.Kind for this Schema with a JSON codec
func DataSourceStackKind() resource.Kind {
return kindDataSourceStack
}
// Schema returns a resource.SimpleSchema representation of DataSourceStack
func DataSourceStackSchema() *resource.SimpleSchema {
return schemaDataSourceStack
}
// Interface compliance checks
var _ resource.Schema = kindDataSourceStack
@@ -1,58 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v1alpha1
// +k8s:openapi-gen=true
type DataSourceStackTemplateSpec map[string]DataSourceStackDataSourceStackTemplateItem
// +k8s:openapi-gen=true
type DataSourceStackDataSourceStackTemplateItem struct {
// type
Group string `json:"group"`
// variable name / display name
Name string `json:"name"`
}
// NewDataSourceStackDataSourceStackTemplateItem creates a new DataSourceStackDataSourceStackTemplateItem object.
func NewDataSourceStackDataSourceStackTemplateItem() *DataSourceStackDataSourceStackTemplateItem {
return &DataSourceStackDataSourceStackTemplateItem{}
}
// +k8s:openapi-gen=true
type DataSourceStackModeSpec struct {
Name string `json:"name"`
Uid string `json:"uid"`
Definition DataSourceStackMode `json:"definition"`
}
// NewDataSourceStackModeSpec creates a new DataSourceStackModeSpec object.
func NewDataSourceStackModeSpec() *DataSourceStackModeSpec {
return &DataSourceStackModeSpec{}
}
// +k8s:openapi-gen=true
type DataSourceStackMode map[string]DataSourceStackModeItem
// +k8s:openapi-gen=true
type DataSourceStackModeItem struct {
// grafana data source uid
DataSourceRef string `json:"dataSourceRef"`
}
// NewDataSourceStackModeItem creates a new DataSourceStackModeItem object.
func NewDataSourceStackModeItem() *DataSourceStackModeItem {
return &DataSourceStackModeItem{}
}
// +k8s:openapi-gen=true
type DataSourceStackSpec struct {
Template DataSourceStackTemplateSpec `json:"template"`
Modes []DataSourceStackModeSpec `json:"modes"`
}
// NewDataSourceStackSpec creates a new DataSourceStackSpec object.
func NewDataSourceStackSpec() *DataSourceStackSpec {
return &DataSourceStackSpec{
Modes: []DataSourceStackModeSpec{},
}
}
@@ -32,19 +32,6 @@ var StarsResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
},
)
var DatasourceStacksResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
"datasourcestacks", "datasourcestack", "DataSourceStack",
func() runtime.Object { return &DataSourceStack{} },
func() runtime.Object { return &DataSourceStackList{} },
utils.TableColumns{
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Created At", Type: "date"},
},
// TODO: Reader?
},
)
var (
SchemeBuilder runtime.SchemeBuilder
localSchemeBuilder = &SchemeBuilder
@@ -61,8 +48,6 @@ func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(schemeGroupVersion,
&Stars{},
&StarsList{},
&DataSourceStack{},
&DataSourceStackList{},
)
metav1.AddToGroupVersion(scheme, schemeGroupVersion)
return nil
@@ -14,241 +14,10 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack": schema_pkg_apis_collections_v1alpha1_DataSourceStack(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackList": schema_pkg_apis_collections_v1alpha1_DataSourceStackList(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackModeItem(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec": schema_pkg_apis_collections_v1alpha1_DataSourceStackModeSpec(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec": schema_pkg_apis_collections_v1alpha1_DataSourceStackSpec(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref),
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStack(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
},
},
"spec": {
SchemaProps: spec.SchemaProps{
Description: "Spec is the spec of the DataSourceStack",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec"),
},
},
},
Required: []string{"metadata", "spec"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"group": {
SchemaProps: spec.SchemaProps{
Description: "type",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"name": {
SchemaProps: spec.SchemaProps{
Description: "variable name / display name",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"group", "name"},
},
},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack"),
},
},
},
},
},
},
Required: []string{"metadata", "items"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackModeItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"dataSourceRef": {
SchemaProps: spec.SchemaProps{
Description: "grafana data source uid",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"dataSourceRef"},
},
},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackModeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"name": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"uid": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"definition": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem"),
},
},
},
},
},
},
Required: []string{"name", "uid", "definition"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem"},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"template": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem"),
},
},
},
},
},
"modes": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec"),
},
},
},
},
},
},
Required: []string{"template", "modes"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem", "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec"},
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref),
}
}
@@ -1,4 +1,2 @@
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackSpec,Modes
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsSpec,Resource
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackList,ListMeta
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsList,ListMeta
+7 -19
View File
@@ -10,22 +10,19 @@ import (
"fmt"
"strings"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/resource"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
v1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/resource"
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
)
var (
rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`)
versionSchemaStarsv1alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1)
rawSchemaDataSourceStackv1alpha1 = []byte(`{"DataSourceStack":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"DataSourceStackTemplateItem":{"additionalProperties":false,"properties":{"group":{"description":"type","type":"string"},"name":{"description":"variable name / display name","type":"string"}},"required":["group","name"],"type":"object"},"Mode":{"additionalProperties":{"$ref":"#/components/schemas/ModeItem"},"type":"object"},"ModeItem":{"additionalProperties":false,"properties":{"dataSourceRef":{"description":"grafana data source uid","type":"string"}},"required":["dataSourceRef"],"type":"object"},"ModeSpec":{"additionalProperties":false,"properties":{"definition":{"$ref":"#/components/schemas/Mode"},"name":{"type":"string"},"uid":{"type":"string"}},"required":["name","uid","definition"],"type":"object"},"TemplateSpec":{"additionalProperties":{"$ref":"#/components/schemas/DataSourceStackTemplateItem"},"type":"object"},"spec":{"additionalProperties":false,"properties":{"modes":{"items":{"$ref":"#/components/schemas/ModeSpec"},"type":"array"},"template":{"$ref":"#/components/schemas/TemplateSpec"}},"required":["template","modes"],"type":"object"}}`)
versionSchemaDataSourceStackv1alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaDataSourceStackv1alpha1, &versionSchemaDataSourceStackv1alpha1)
rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`)
versionSchemaStarsv1alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1)
)
var appManifestData = app.ManifestData{
@@ -52,14 +49,6 @@ var appManifestData = app.ManifestData{
},
Schema: &versionSchemaStarsv1alpha1,
},
{
Kind: "DataSourceStack",
Plural: "DataSourceStacks",
Scope: "Namespaced",
Conversion: false,
Schema: &versionSchemaDataSourceStackv1alpha1,
},
},
Routes: app.ManifestVersionRoutes{
Namespaced: map[string]spec3.PathProps{},
@@ -79,8 +68,7 @@ func RemoteManifest() app.Manifest {
}
var kindVersionToGoType = map[string]resource.Kind{
"Stars/v1alpha1": v1alpha1.StarsKind(),
"DataSourceStack/v1alpha1": v1alpha1.DataSourceStackKind(),
"Stars/v1alpha1": v1alpha1.StarsKind(),
}
// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists.
@@ -1,47 +0,0 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface DataSourceStack {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
}
@@ -1,30 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
export interface Metadata {
updateTimestamp: string;
createdBy: string;
uid: string;
creationTimestamp: string;
deletionTimestamp?: string;
finalizers: string[];
resourceVersion: string;
generation: number;
updatedBy: string;
labels: Record<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});
@@ -1,53 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export type TemplateSpec = Record<string, DataSourceStackTemplateItem>;
export const defaultTemplateSpec = (): TemplateSpec => ({});
export interface DataSourceStackTemplateItem {
// type
group: string;
// variable name / display name
name: string;
}
export const defaultDataSourceStackTemplateItem = (): DataSourceStackTemplateItem => ({
group: "",
name: "",
});
export interface ModeSpec {
name: string;
uid: string;
definition: Mode;
}
export const defaultModeSpec = (): ModeSpec => ({
name: "",
uid: "",
definition: defaultMode(),
});
export type Mode = Record<string, ModeItem>;
export const defaultMode = (): Mode => ({});
export interface ModeItem {
// grafana data source uid
dataSourceRef: string;
}
export const defaultModeItem = (): ModeItem => ({
dataSourceRef: "",
});
export interface Spec {
template: TemplateSpec;
modes: ModeSpec[];
}
export const defaultSpec = (): Spec => ({
template: defaultTemplateSpec(),
modes: [],
});
@@ -1,47 +0,0 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface Stars {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
}
@@ -1,30 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
export interface Metadata {
updateTimestamp: string;
createdBy: string;
uid: string;
creationTimestamp: string;
deletionTimestamp?: string;
finalizers: string[];
resourceVersion: string;
generation: number;
updatedBy: string;
labels: Record<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});
@@ -1,24 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface Resource {
group: string;
kind: string;
// The set of resources
// +listType=set
names: string[];
}
export const defaultResource = (): Resource => ({
group: "",
kind: "",
names: [],
});
export interface Spec {
resource: Resource[];
}
export const defaultSpec = (): Spec => ({
resource: [],
});
@@ -911,7 +911,6 @@ CustomVariableSpec: {
skipUrlSync: bool | *false
description?: string
allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
}
// Custom variable kind
@@ -915,7 +915,6 @@ CustomVariableSpec: {
skipUrlSync: bool | *false
description?: string
allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
}
// Custom variable kind
@@ -1675,19 +1675,18 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind {
// Custom variable specification
// +k8s:openapi-gen=true
type DashboardCustomVariableSpec struct {
Name string `json:"name"`
Query string `json:"query"`
Current DashboardVariableOption `json:"current"`
Options []DashboardVariableOption `json:"options"`
Multi bool `json:"multi"`
IncludeAll bool `json:"includeAll"`
AllValue *string `json:"allValue,omitempty"`
Label *string `json:"label,omitempty"`
Hide DashboardVariableHide `json:"hide"`
SkipUrlSync bool `json:"skipUrlSync"`
Description *string `json:"description,omitempty"`
AllowCustomValue bool `json:"allowCustomValue"`
ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"`
Name string `json:"name"`
Query string `json:"query"`
Current DashboardVariableOption `json:"current"`
Options []DashboardVariableOption `json:"options"`
Multi bool `json:"multi"`
IncludeAll bool `json:"includeAll"`
AllValue *string `json:"allValue,omitempty"`
Label *string `json:"label,omitempty"`
Hide DashboardVariableHide `json:"hide"`
SkipUrlSync bool `json:"skipUrlSync"`
Description *string `json:"description,omitempty"`
AllowCustomValue bool `json:"allowCustomValue"`
}
// NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object.
@@ -2102,14 +2101,6 @@ const (
DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted"
)
// +k8s:openapi-gen=true
type DashboardCustomVariableSpecValuesFormat string
const (
DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv"
DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json"
)
// +k8s:openapi-gen=true
type DashboardPanelKindOrLibraryPanelKind struct {
PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"`
@@ -1510,12 +1510,6 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.Re
Format: "",
},
},
"valuesFormat": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"},
},
+1
View File
@@ -53,6 +53,7 @@ pluginMetaV0Alpha1: {
skipDataQuery?: bool
state?: "alpha" | "beta"
streaming?: bool
suggestions?: bool
tracing?: bool
iam?: #IAM
// +listType=atomic
@@ -40,6 +40,7 @@ type PluginMetaJSONData struct {
SkipDataQuery *bool `json:"skipDataQuery,omitempty"`
State *PluginMetaJSONDataState `json:"state,omitempty"`
Streaming *bool `json:"streaming,omitempty"`
Suggestions *bool `json:"suggestions,omitempty"`
Tracing *bool `json:"tracing,omitempty"`
Iam *PluginMetaIAM `json:"iam,omitempty"`
// +listType=atomic
File diff suppressed because one or more lines are too long
-2
View File
@@ -181,8 +181,6 @@ import (
//go:generate mockery --name InterfaceName --structname MockImplementationName --inpackage --filename my_implementation_mock.go
```
The current `go:generate` command format used in this repository is only compatible with mockery v2.
## Globals
As a general rule of thumb, avoid using global variables, since they make the code difficult to maintain and reason
@@ -341,6 +341,10 @@
"type": "boolean",
"description": "Initialize plugin on startup. By default, the plugin initializes on first use, but when preload is set to true the plugin loads when the Grafana web app loads the first time. Only applicable to app plugins. When setting to `true`, implement [frontend code splitting](https://grafana.com/developers/plugin-tools/get-started/best-practices#app-plugins) to minimise performance implications."
},
"suggestions": {
"type": "boolean",
"description": "For panel plugins. If set to true, the plugin's suggestions supplier will be invoked and any suggestions returned will be included in the Suggestions pane in the Panel Editor."
},
"queryOptions": {
"type": "object",
"description": "For data source plugins. There is a query options section in the plugin's query editor and these options can be turned on if needed.",
@@ -128,6 +128,20 @@ The server element lets you easily represent a single server, a stack of servers
{{< figure src="/media/docs/grafana/canvas-server-element-9-4-0.png" max-width="650px" alt="Canvas server element" >}}
#### SVG
The SVG element lets you add custom SVG graphics to the canvas. You can enter raw SVG markup in the content field, and the element will render it with proper sanitization to prevent XSS attacks. This element is useful for creating custom icons, logos, or complex graphics that aren't available in the standard shape elements.
SVG element features:
- **Sanitized content**: All SVG content is automatically sanitized for security
- **Data binding**: SVG content can be bound to field data using template variables
- **Scalable**: SVG graphics scale cleanly at any size
The SVG element supports the following configuration options:
- **SVG Content**: Enter raw SVG markup. Content will be sanitized automatically.
#### Button
The button element lets you add a basic button to the canvas. Button elements support triggering basic, unauthenticated API calls. [API settings](#button-api-options) are found in the button element editor. You can also pass template variables in the API editor.
@@ -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&mdash;or _nodes_&mdash;for each element you want to visualize, connected by lines&mdash;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&mdash;or _nodes_&mdash;for each element you want to visualize, connected by lines&mdash;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.
![Node graph exploration](/media/docs/grafana/panels-visualizations/node-graph-exploration-8.0-2.png 'Node graph exploration')
### 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.
![Node graph grid](/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid-v11.3.png 'Node graph grid')
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.
![Node graph legend](/media/docs/grafana/panels-visualizations/screenshot-node-graph-legend-v11.3.png 'Node graph legend')
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.
![Node graph grid to default](/media/docs/grafana/panels-visualizations/screenshot-node-graph-view-v11.3.png 'Node graph grid to default')
## Configuration options
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
@@ -155,7 +161,24 @@ Click on the node and select "Show in Graph layout" option to switch back to gra
Use the following options to refine your node graph visualization.
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
#### Zoom mode
Choose how the node graph should handle zoom and scroll events:
- **Cooperative** - Allows you to scroll the visualization normally.
- **Greedy** - Reacts to all zoom gestures.
#### Layout algorithm
Choose how the visualization layout is generated:
- **Layered** - Default. Creates a predictable and orderly layout, especially useful for service graphs.
- **Force** - Uses a physics-based force layout algorithm that's useful with a large number of nodes (500+).
- **Grid** - Arranges nodes into a grid format to provide a better overview of the most interesting nodes in the graph. This layout shows nodes in a grid without edges and can be sorted by the stats shown inside the node or by the ones represented by the a colored border of the nodes.
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid.png" max-width="650px" alt="Node graph in grid layout" >}}
For more information about using the graph in grid layout, refer to [Switch layouts](#switch-layouts).
### Nodes options
@@ -239,6 +262,6 @@ Optional fields:
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behavior depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/iconography-icon--icons-overview)) are allowed. |
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/iconography-icon--icons-overview) are allowed. |
| nodeRadius | number | Radius value in pixels. Used to manage node size. |
| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |
+2 -2
View File
@@ -296,8 +296,8 @@
"@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^6.48.0",
"@grafana/scenes-react": "^6.48.0",
"@grafana/scenes": "6.47.1",
"@grafana/scenes-react": "6.47.1",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
+1 -3
View File
@@ -715,11 +715,9 @@ export {
export {
type VisualizationSuggestion,
type VisualizationSuggestionsSupplier,
type VisualizationSuggestionsSupplierFn,
type PanelPluginVisualizationSuggestion,
type VisualizationSuggestionsBuilder,
VisualizationSuggestionScore,
VisualizationSuggestionsBuilder,
VisualizationSuggestionsListAppender,
} from './types/suggestions';
export {
type MatcherConfig,
@@ -1,14 +1,18 @@
import { createDataFrame } from '../dataframe/processDataFrame';
import { identityOverrideProcessor } from '../field/overrides/processors';
import {
StandardEditorsRegistryItem,
standardEditorsRegistry,
standardFieldConfigEditorRegistry,
} from '../field/standardFieldConfigEditorRegistry';
import { FieldType } from '../types/dataFrame';
import { FieldConfigProperty, FieldConfigPropertyItem } from '../types/fieldOverrides';
import { PanelMigrationModel } from '../types/panel';
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '../types/suggestions';
import { PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
import { PanelPlugin } from './PanelPlugin';
import { getPanelDataSummary } from './suggestions/getPanelDataSummary';
describe('PanelPlugin', () => {
describe('declarative options', () => {
@@ -483,4 +487,107 @@ describe('PanelPlugin', () => {
});
});
});
describe('suggestions', () => {
it('should register a suggestions supplier', () => {
const panel = new PanelPlugin(() => <div>Panel</div>);
panel.meta = panel.meta || {};
panel.meta.id = 'test-panel';
panel.meta.name = 'Test Panel';
panel.setSuggestionsSupplier((ds) => {
if (!ds.hasFieldType(FieldType.number)) {
return;
}
return [
{
name: 'Number Panel',
score: VisualizationSuggestionScore.Good,
},
];
});
const suggestions = panel.getSuggestions(
getPanelDataSummary([createDataFrame({ fields: [{ type: FieldType.number, name: 'Value' }] })])
);
expect(suggestions).toHaveLength(1);
expect(suggestions![0].pluginId).toBe(panel.meta.id);
expect(suggestions![0].name).toBe('Number Panel');
expect(
panel.getSuggestions(
getPanelDataSummary([createDataFrame({ fields: [{ type: FieldType.string, name: 'Value' }] })])
)
).toBeUndefined();
});
it('should not throw for the old syntax, but also should not register suggestions', () => {
jest.spyOn(console, 'warn').mockImplementation();
class DeprecatedSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder): void {
const appender = builder.getListAppender({
name: 'Deprecated Suggestion',
pluginId: 'deprecated-plugin',
options: {},
});
if (builder.dataSummary.hasNumberField) {
appender.append({});
}
}
}
const panel = new PanelPlugin(() => <div>Panel</div>);
expect(() => {
panel.setSuggestionsSupplier(new DeprecatedSuggestionsSupplier());
}).not.toThrow();
expect(console.warn).toHaveBeenCalled();
expect(
panel.getSuggestions(
getPanelDataSummary([
createDataFrame({
fields: [{ type: FieldType.number, name: 'Value', values: [1, 2, 3, 4, 5] }],
}),
])
)
).toBeUndefined();
});
it('should support the deprecated pattern of getSuggestionsSupplier with builder', () => {
jest.spyOn(console, 'warn').mockImplementation();
const panel = new PanelPlugin(() => <div>Panel</div>).setSuggestionsSupplier((ds) => {
if (!ds.hasFieldType(FieldType.number)) {
return;
}
return [
{
name: 'Number Panel',
score: VisualizationSuggestionScore.Good,
},
];
});
const oldSupplier = panel.getSuggestionsSupplier();
const builder1 = new VisualizationSuggestionsBuilder([
createDataFrame({ fields: [{ type: FieldType.number, name: 'Value' }] }),
]);
oldSupplier.getSuggestionsForData(builder1);
const suggestions1 = builder1.getList();
expect(suggestions1).toHaveLength(1);
expect(suggestions1![0].pluginId).toBe(panel.meta.id);
expect(suggestions1![0].name).toBe('Number Panel');
const builder2 = new VisualizationSuggestionsBuilder([
createDataFrame({ fields: [{ type: FieldType.string, name: 'Value' }] }),
]);
oldSupplier.getSuggestionsForData(builder2);
const suggestions2 = builder2.getList();
expect(suggestions2).toHaveLength(0);
});
});
});
+72 -36
View File
@@ -1,4 +1,4 @@
import { set } from 'lodash';
import { defaultsDeep, set } from 'lodash';
import { ComponentClass, ComponentType } from 'react';
import { FieldConfigOptionsRegistry } from '../field/FieldConfigOptionsRegistry';
@@ -14,11 +14,19 @@ import {
PanelPluginDataSupport,
} from '../types/panel';
import { GrafanaPlugin } from '../types/plugin';
import { VisualizationSuggestionsSupplierFn, VisualizationSuggestionsSupplier } from '../types/suggestions';
import {
getSuggestionHash,
PanelPluginVisualizationSuggestion,
VisualizationSuggestion,
VisualizationSuggestionsSupplierDeprecated,
VisualizationSuggestionsSupplier,
VisualizationSuggestionsBuilder,
} from '../types/suggestions';
import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
import { deprecationWarning } from '../utils/deprecationWarning';
import { createFieldConfigRegistry } from './registryFactories';
import { PanelDataSummary } from './suggestions/getPanelDataSummary';
/** @beta */
export type StandardOptionConfig = {
@@ -109,7 +117,7 @@ export class PanelPlugin<
};
private optionsSupplier?: PanelOptionsSupplier<TOptions>;
private suggestionsSupplier?: VisualizationSuggestionsSupplier;
private suggestionsSupplier?: VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>;
panel: ComponentType<PanelProps<TOptions>> | null;
editor?: ComponentClass<PanelEditorProps<TOptions>>;
@@ -363,56 +371,84 @@ export class PanelPlugin<
}
/**
* @deprecated use VisualizationSuggestionsSupplierFn
* @deprecated use VisualizationSuggestionsSupplier
*/
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier): this;
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierDeprecated): this;
/**
* @alpha
* sets function that can return visualization examples and suggestions.
*/
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>): this;
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>): this;
setSuggestionsSupplier(
supplier: VisualizationSuggestionsSupplier | VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>
supplier:
| VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>
| VisualizationSuggestionsSupplierDeprecated
): this {
this.suggestionsSupplier =
typeof supplier === 'function'
? {
getSuggestionsForData: (builder) => {
const appender = builder.getListAppender<TOptions, TFieldConfigOptions>({
pluginId: this.meta.id,
name: this.meta.name,
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
});
const result = supplier(builder.dataSummary);
if (Array.isArray(result)) {
appender.appendAll(result);
}
},
}
: supplier;
if (typeof supplier !== 'function') {
deprecationWarning(
'PanelPlugin',
'plugin.setSuggestionsSupplier(new Supplier())',
'plugin.setSuggestionsSupplier(dataSummary => [...])'
);
return this;
}
this.suggestionsSupplier = supplier;
return this;
}
/**
* Returns the suggestions supplier
* @alpha
* get suggestions based on the PanelDataSummary
*/
getSuggestionsSupplier(): VisualizationSuggestionsSupplier | undefined {
return this.suggestionsSupplier;
getSuggestions(
panelDataSummary: PanelDataSummary
): Array<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>> | void {
const withDefaults = (
suggestion: VisualizationSuggestion<TOptions, TFieldConfigOptions>
): Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'> =>
defaultsDeep(suggestion, {
pluginId: this.meta.id,
name: this.meta.name,
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
} satisfies Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'>);
return this.suggestionsSupplier?.(panelDataSummary)?.map(
(s): PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions> => {
const suggestionWithDefaults = withDefaults(s);
return Object.assign(suggestionWithDefaults, { hash: getSuggestionHash(suggestionWithDefaults) });
}
);
}
/**
* @alpha
* returns whether the plugin has configured suggestions
* @deprecated use getSuggestions
* we have to keep this method intact to support cloud-onboarding plugin.
*/
hasSuggestions(): boolean {
return this.suggestionsSupplier !== undefined;
getSuggestionsSupplier() {
const withDefaults = (
suggestion: VisualizationSuggestion<TOptions, TFieldConfigOptions>
): Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'> =>
defaultsDeep(suggestion, {
pluginId: this.meta.id,
name: this.meta.name,
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
} satisfies Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'>);
return {
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => {
deprecationWarning('PanelPlugin', 'getSuggestionsSupplier()', 'getSuggestions(panelDataSummary)');
this.suggestionsSupplier?.(builder.dataSummary)?.forEach((s) => {
builder.getListAppender(withDefaults(s)).append(s);
});
},
};
}
hasPluginId(pluginId: string) {
+5
View File
@@ -1143,6 +1143,11 @@ export interface FeatureToggles {
*/
newVizSuggestions?: boolean;
/**
* Enable all plugins to supply visualization suggestions (including 3rd party plugins)
* @default false
*/
externalVizSuggestions?: boolean;
/**
* Restrict PanelChrome contents with overflow: hidden;
* @default true
*/
+2
View File
@@ -20,6 +20,8 @@ export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, forma
export interface PanelPluginMeta extends PluginMeta {
/** Indicates that panel does not issue queries */
skipDataQuery?: boolean;
/** Indicates that the panel implements suggestions */
suggestions?: boolean;
/** Indicates that panel should not be available in visualisation picker */
hideFromList?: boolean;
/** Sort order */
+33 -55
View File
@@ -2,11 +2,10 @@ import { defaultsDeep } from 'lodash';
import { DataTransformerConfig } from '@grafana/schema';
import { PanelDataSummary, getPanelDataSummary } from '../panel/suggestions/getPanelDataSummary';
import { getPanelDataSummary, PanelDataSummary } from '../panel/suggestions/getPanelDataSummary';
import { PanelModel } from './dashboard';
import { DataFrame } from './dataFrame';
import { FieldConfigSource } from './fieldOverrides';
import { PanelData } from './panel';
/**
* @internal
@@ -108,35 +107,6 @@ export enum VisualizationSuggestionScore {
OK = 50,
}
/**
* @internal
* TODO this will move into the grafana app code once suppliers are migrated.
*/
export class VisualizationSuggestionsBuilder {
/** Summary stats for current data */
dataSummary: PanelDataSummary;
private list: PanelPluginVisualizationSuggestion[] = [];
constructor(
/** Current data */
public data?: PanelData,
/** Current panel & options */
public panel?: PanelModel
) {
this.dataSummary = getPanelDataSummary(data?.series);
}
getListAppender<TOptions extends unknown, TFieldConfig extends {} = {}>(
defaults: Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>, 'hash'>
) {
return new VisualizationSuggestionsListAppender<TOptions, TFieldConfig>(this.list, defaults);
}
getList() {
return this.list;
}
}
/**
* @alpha
* TODO: this name is temporary; it will become just "VisualizationSuggestionsSupplier" when the other interface is deleted.
@@ -147,40 +117,48 @@ export class VisualizationSuggestionsBuilder {
* - returns an array of VisualizationSuggestions
* - boolean return equates to "show a single suggestion card for this panel plugin with the default options" (true = show, false or void = hide)
*/
export type VisualizationSuggestionsSupplierFn<TOptions extends unknown, TFieldConfig extends {} = {}> = (
export type VisualizationSuggestionsSupplier<TOptions extends unknown, TFieldConfig extends {} = {}> = (
panelDataSummary: PanelDataSummary
) => Array<VisualizationSuggestion<TOptions, TFieldConfig>> | void;
/**
* @deprecated use VisualizationSuggestionsSupplierFn instead.
* DEPRECATED - the below exports need to remain in the code base to help make the transition for the Polystat plugin, which implements
* suggestions using the old API. These should be removed for Grafana 13.
*/
export type VisualizationSuggestionsSupplier = {
/**
* Adds suitable suggestions for the current data
*/
/**
* @deprecated use VisualizationSuggestionsSupplier
*/
export interface VisualizationSuggestionsSupplierDeprecated {
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void;
};
}
/**
* @internal
* TODO this will move into the grafana app code once suppliers are migrated.
* @deprecated use VisualizationSuggestionsSupplier
*/
export class VisualizationSuggestionsListAppender<TOptions extends unknown, TFieldConfig extends {} = {}> {
constructor(
private list: VisualizationSuggestion[],
private defaults: Partial<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>> = {}
) {}
export class VisualizationSuggestionsBuilder {
public dataSummary: PanelDataSummary;
public list: PanelPluginVisualizationSuggestion[] = [];
append(suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) {
this.appendAll([suggestion]);
constructor(dataFrames: DataFrame[]) {
this.dataSummary = getPanelDataSummary(dataFrames);
}
appendAll(suggestions: Array<VisualizationSuggestion<TOptions, TFieldConfig>>) {
this.list.push(
...suggestions.map((s): PanelPluginVisualizationSuggestion<TOptions, TFieldConfig> => {
const suggestionWithDefaults = defaultsDeep(s, this.defaults);
return Object.assign(suggestionWithDefaults, { hash: getSuggestionHash(suggestionWithDefaults) });
})
);
getList(): PanelPluginVisualizationSuggestion[] {
return this.list;
}
getListAppender(suggestionDefaults: Omit<PanelPluginVisualizationSuggestion, 'hash'>) {
const withDefaults = (suggestion: VisualizationSuggestion): PanelPluginVisualizationSuggestion => {
const s = defaultsDeep({}, suggestion, suggestionDefaults);
return {
...s,
hash: getSuggestionHash(s),
};
};
return {
append: (suggestion: VisualizationSuggestion) => {
this.list.push(withDefaults(suggestion));
},
};
}
}
@@ -101,7 +101,6 @@ export interface IntervalVariableModel extends VariableWithOptions {
export interface CustomVariableModel extends VariableWithMultiSupport {
type: 'custom';
valuesFormat?: 'csv' | 'json';
}
export interface DataSourceVariableModel extends VariableWithMultiSupport {
+13
View File
@@ -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)
@@ -34,13 +34,13 @@ export interface Constraint {
}
export interface Placement {
bottom?: number;
height?: number;
left?: number;
right?: number;
rotation?: number;
top?: number;
width?: number;
bottom?: ui.PositionDimensionConfig;
height?: ui.PositionDimensionConfig;
left?: ui.PositionDimensionConfig;
right?: ui.PositionDimensionConfig;
rotation?: ui.ScalarDimensionConfig;
top?: ui.PositionDimensionConfig;
width?: ui.PositionDimensionConfig;
}
export enum BackgroundImageSize {
@@ -316,7 +316,6 @@ export const handyTestingSchema: Spec = {
query: 'option1, option2',
skipUrlSync: false,
allowCustomValue: true,
valuesFormat: 'csv',
},
},
{
@@ -1335,7 +1335,6 @@ export interface CustomVariableSpec {
skipUrlSync: boolean;
description?: string;
allowCustomValue: boolean;
valuesFormat?: "csv" | "json";
}
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
@@ -29,6 +29,10 @@ export interface ScalarDimensionConfig extends BaseDimensionConfig<number>, Omit
export interface TextDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.TextDimensionConfig, 'fixed'> {}
export interface PositionDimensionConfig
extends BaseDimensionConfig<number>,
Omit<raw.PositionDimensionConfig, 'fixed'> {}
export interface ColorDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.ColorDimensionConfig, 'fixed'> {}
export interface ColorDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.ColorDimensionConfig, 'fixed'> {}
@@ -518,6 +518,7 @@ const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
height: '100%',
position: 'relative',
}),
panel: css({
-4
View File
@@ -150,10 +150,6 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/connections/datasources/edit/*", authorize(datasources.EditPageAccess), hs.Index)
r.Get("/connections", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/add-new-connection", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks/new", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks/edit/*", authorize(datasources.ConfigurationPageAccess), hs.Index)
// Plugin details pages
r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
+1
View File
@@ -164,6 +164,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery,
Suggestions: panel.Suggestions,
HideFromList: panel.HideFromList,
ReleaseState: string(panel.State),
Signature: string(panel.Signature),
+11
View File
@@ -222,6 +222,10 @@ var (
// MStatTotalRepositories is a metric total amount of repositories
MStatTotalRepositories prometheus.Gauge
// MUnifiedStorageMigrationStatus indicates the migration status for unified storage in this instance.
// Possible values: 0 (default/undefined), 1 (migration disabled), 2 (migration would run).
MUnifiedStorageMigrationStatus prometheus.Gauge
)
const (
@@ -691,6 +695,12 @@ func init() {
Help: "total amount of repositories",
Namespace: ExporterName,
})
MUnifiedStorageMigrationStatus = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "unified_storage_migration_status",
Help: "indicates whether this instance would run unified storage migrations (0=undefined, 1=migration disabled, 2=would run)",
Namespace: ExporterName,
})
}
// SetBuildInformation sets the build information for this binary
@@ -829,5 +839,6 @@ func initMetricVars(reg prometheus.Registerer) {
MStatTotalRepositories,
MFolderIDsAPICount,
MFolderIDsServiceCount,
MUnifiedStorageMigrationStatus,
)
}
+1
View File
@@ -319,6 +319,7 @@ type PanelDTO struct {
HideFromList bool `json:"hideFromList"`
Sort int `json:"sort"`
SkipDataQuery bool `json:"skipDataQuery"`
Suggestions bool `json:"suggestions,omitempty"`
ReleaseState string `json:"state"`
BaseURL string `json:"baseUrl"`
Signature string `json:"signature"`
+1
View File
@@ -105,6 +105,7 @@ type JSONData struct {
// Panel settings
SkipDataQuery bool `json:"skipDataQuery"`
Suggestions bool `json:"suggestions,omitempty"`
// App settings
AutoEnabled bool `json:"autoEnabled"`
@@ -1,88 +0,0 @@
package collections
import (
"context"
"fmt"
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/datasources/service/client"
"k8s.io/apiserver/pkg/admission"
)
var _ builder.APIGroupValidation = (*DatasourceStacksValidator)(nil)
type DatasourceStacksValidator struct {
dsClient client.DataSourceConnectionClient
}
func GetDatasourceStacksValidator(dsClient client.DataSourceConnectionClient) builder.APIGroupValidation {
return &DatasourceStacksValidator{dsClient: dsClient}
}
func (v *DatasourceStacksValidator) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
obj := a.GetObject()
operation := a.GetOperation()
if operation == admission.Connect {
return fmt.Errorf("Connect operation is not allowed (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
if operation != admission.Create && operation != admission.Update {
return nil
}
cast, ok := obj.(*collections.DataSourceStack)
if !ok {
return fmt.Errorf("object is not of type *collections.DataSourceStack (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
// get the keys from the template
template := cast.Spec.Template
templateNames := map[string]bool{}
for _, item := range template {
// template items cannot be empty
if item.Group == "" || item.Name == "" {
return fmt.Errorf("template items cannot be empty (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
// template names must be unique
if _, exists := templateNames[item.Name]; exists {
return fmt.Errorf("template item names must be unique. name '%s' already exists (%s %s)", item.Name, a.GetName(), a.GetKind().GroupVersion().String())
}
templateNames[item.Name] = true
}
// for each mode, check that the keys are in the template
modes := cast.Spec.Modes
for _, mode := range modes {
for key, item := range mode.Definition {
// if a key is not in the template, return an error
if _, ok := template[key]; !ok {
return fmt.Errorf("key '%s' is not in the DataSourceStack template (%s %s)", key, a.GetName(), a.GetKind().GroupVersion().String())
}
exists, err := v.checkDatasourceExists(ctx, item.DataSourceRef)
if err != nil || !exists {
return fmt.Errorf("datasource '%s' in group '%s' does not exist (%s %s): %w", item.DataSourceRef, template[key].Group, a.GetName(), a.GetKind().GroupVersion().String(), err)
}
}
}
return nil
}
func (v *DatasourceStacksValidator) checkDatasourceExists(ctx context.Context, name string) (bool, error) {
dsConn, err := v.dsClient.GetByUID(ctx, name)
if err != nil {
return false, err
}
if dsConn == nil {
return false, nil
}
return true, nil
}
@@ -1,212 +0,0 @@
package collections_test
import (
"context"
"testing"
collectionsv1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
queryv0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/collections"
datasourcesclient "github.com/grafana/grafana/pkg/services/datasources/service/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
)
func TestDataSourceValidator_Validate(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
operation admission.Operation
object runtime.Object
needMockDSClient bool // only set to true if you expect to make a call to the datasource client
dsClientReturnValue *queryv0alpha1.DataSourceConnection
dsClientReturnError error
expectError bool
errorMsg string
}{
{
name: "should return no error for invalid kind",
operation: admission.Delete,
object: &collectionsv1alpha1.Stars{},
expectError: false,
},
{
name: "should return error for Connect operation",
operation: admission.Connect,
object: &collectionsv1alpha1.DataSourceStack{},
expectError: true,
},
{
name: "template items cannot be empty",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{},
},
},
},
expectError: true,
errorMsg: "template items cannot be empty (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "template item name keys must be unique",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
"key2": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
},
},
expectError: true,
errorMsg: "template item names must be unique. name 'foo' already exists (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "mode keys must exist in the template",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"notintemplate": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "foo",
},
},
},
},
},
},
expectError: true,
errorMsg: "key 'notintemplate' is not in the DataSourceStack template (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "error if data source does not exist",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"key1": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "ref",
},
},
},
},
},
},
needMockDSClient: true,
dsClientReturnValue: nil, // no result - this is the default anyway
expectError: true,
errorMsg: "datasource 'ref' in group 'foo.grafana' does not exist (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "valid request",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"key1": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "ref",
},
},
},
},
},
},
needMockDSClient: true,
dsClientReturnValue: &queryv0alpha1.DataSourceConnection{}, // returning any non-nil value will pass validation
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
attrs := &FakeAdmissionAttributes{
Operation: tt.operation,
Object: tt.object,
Name: "test-datasourcestack",
Kind: schema.GroupVersionKind{Group: "collections.grafana.app", Version: "v1alpha1", Kind: "DataSourceStack"},
}
var client *datasourcesclient.MockDataSourceConnectionClient
if tt.needMockDSClient {
client = datasourcesclient.NewMockDataSourceConnectionClient(t)
client.On("GetByUID", mock.Anything, mock.Anything).Return(tt.dsClientReturnValue, tt.dsClientReturnError)
}
validator := collections.GetDatasourceStacksValidator(client)
err := validator.Validate(ctx, attrs, nil)
if tt.expectError {
assert.Error(t, err)
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
type FakeAdmissionAttributes struct {
admission.Attributes
Operation admission.Operation
Object runtime.Object
Name string
Kind schema.GroupVersionKind
}
func (m *FakeAdmissionAttributes) GetOperation() admission.Operation {
return m.Operation
}
func (m *FakeAdmissionAttributes) GetObject() runtime.Object {
return m.Object
}
func (m *FakeAdmissionAttributes) GetName() string {
return m.Name
}
func (m *FakeAdmissionAttributes) GetKind() schema.GroupVersionKind {
return m.Kind
}
+11 -56
View File
@@ -1,13 +1,11 @@
package collections
import (
"context"
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -16,16 +14,13 @@ import (
"k8s.io/kube-openapi/pkg/validation/spec"
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/registry/apis/collections/legacy"
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
datasourcesClient "github.com/grafana/grafana/pkg/services/datasources/service/client"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/user"
@@ -34,15 +29,13 @@ import (
)
var (
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupMutation = (*APIBuilder)(nil)
_ builder.APIGroupValidation = (*APIBuilder)(nil)
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupMutation = (*APIBuilder)(nil)
)
type APIBuilder struct {
authorizer authorizer.Authorizer
legacyStars *legacy.DashboardStarsStorage
datasourceStacksValidator builder.APIGroupValidation
authorizer authorizer.Authorizer
legacyStars *legacy.DashboardStarsStorage
}
func RegisterAPIService(
@@ -52,8 +45,6 @@ func RegisterAPIService(
stars star.Service,
users user.Service,
apiregistration builder.APIRegistrar,
dsConnClientFactory datasourcesClient.DataSourceConnectionClientFactory,
restConfigProvider apiserver.RestConfigProvider,
) *APIBuilder {
// Requires development settings and clearly experimental
//nolint:staticcheck // not yet migrated to OpenFeature
@@ -61,15 +52,11 @@ func RegisterAPIService(
return nil
}
dsConnClient := dsConnClientFactory(restConfigProvider)
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
builder := &APIBuilder{
datasourceStacksValidator: GetDatasourceStacksValidator(dsConnClient),
authorizer: &utils.AuthorizeFromName{
Resource: map[string][]utils.ResourceOwner{
"stars": {utils.UserResourceOwner},
"datasources": {utils.UserResourceOwner},
"stars": {utils.UserResourceOwner},
},
},
}
@@ -107,60 +94,28 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
storage := map[string]rest.Storage{}
// Configure Stars Dual writer
starsResource := collections.StarsResourceInfo
resource := collections.StarsResourceInfo
var stars grafanarest.Storage
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, starsResource, opts.OptsGetter)
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
if err != nil {
return err
}
stars = &starStorage{Storage: stars} // wrap List so we only return one value
if b.legacyStars != nil && opts.DualWriteBuilder != nil {
stars, err = opts.DualWriteBuilder(starsResource.GroupResource(), b.legacyStars, stars)
stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars)
if err != nil {
return err
}
}
storage[starsResource.StoragePath()] = stars
storage[starsResource.StoragePath("update")] = &starsREST{store: stars}
// no need for dual writer for a kind that does not exist in the legacy database
resourceInfo := collections.DatasourceStacksResourceInfo
datasourcesStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, resourceInfo, opts.OptsGetter)
storage[resourceInfo.StoragePath()] = datasourcesStorage
storage[resource.StoragePath()] = stars
storage[resource.StoragePath("update")] = &starsREST{store: stars}
apiGroupInfo.VersionedResourcesStorageMap[collections.APIVersion] = storage
return nil
}
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
if a.GetKind().Group == collections.DatasourceStacksResourceInfo.GroupResource().Group {
return b.datasourceStacksValidator.Validate(ctx, a, o)
}
return nil
}
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
return authorizer.AuthorizerFunc(
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
if attr.GetResource() == "stars" {
return b.authorizer.Authorize(ctx, attr)
}
// datasources auth branch starts
if !attr.IsResourceRequest() {
return authorizer.DecisionNoOpinion, "", nil
}
// require a user
_, err = identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "valid user is required", err
}
// TODO make the auth more restrictive
return authorizer.DecisionAllow, "", nil
})
return b.authorizer
}
func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
+1 -1
View File
@@ -85,7 +85,7 @@ func RegisterAPIService(
accessControl,
//nolint:staticcheck // not yet migrated to OpenFeature
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
true,
false,
)
if err != nil {
return nil, err
+11 -44
View File
@@ -15,7 +15,6 @@ import (
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/datasources"
"k8s.io/apimachinery/pkg/fields"
)
var (
@@ -29,11 +28,11 @@ var (
// Get all datasource connections -- this will be backed by search or duplicated resource in unified storage
type DataSourceConnectionProvider interface {
// Get gets a specific datasource (that the user in context can see)
// The name is the legacy datasource UID.
// The name is {group}:{name}, see /pkg/apis/query/v0alpha1/connection.go#L34
GetConnection(ctx context.Context, namespace string, name string) (*queryV0.DataSourceConnection, error)
// List lists all data sources the user in context can see. Optional field selectors can filter the results.
ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error)
// List lists all data sources the user in context can see
ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error)
}
type connectionAccess struct {
@@ -75,11 +74,7 @@ func (s *connectionAccess) Get(ctx context.Context, name string, options *metav1
}
func (s *connectionAccess) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
var fs fields.Selector
if options != nil && options.FieldSelector != nil {
fs = options.FieldSelector
}
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx), fs)
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx))
}
type connectionsProvider struct {
@@ -108,47 +103,19 @@ func (q *connectionsProvider) GetConnection(ctx context.Context, namespace strin
return q.asConnection(ds, namespace)
}
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error) {
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error) {
ns, err := authlib.ParseNamespace(namespace)
if err != nil {
return nil, err
}
var dss []*datasources.DataSource
// if fieldSelector is not nil, find any uids in the metadata.name field and
// use them in the query
if fieldSelector != nil && !fieldSelector.Empty() {
uids := []string{}
for _, req := range fieldSelector.Requirements() {
if req.Field == "metadata.name" {
uids = append(uids, req.Value)
}
}
// We don't have a way to fetch a subset of datasources by UID in the legacy
// datasource service, so fetch them one by one.
if len(uids) > 0 {
for _, uid := range uids {
ds, err := q.dsService.GetDataSource(ctx, &datasources.GetDataSourceQuery{
UID: uid,
OrgID: ns.OrgID,
})
if err != nil {
return nil, err
}
dss = append(dss, ds)
}
}
} else {
dss, err = q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{
OrgID: ns.OrgID,
DataSourceLimit: 10000,
})
if err != nil {
return nil, err
}
dss, err := q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{
OrgID: ns.OrgID,
DataSourceLimit: 10000,
})
if err != nil {
return nil, err
}
result := &queryV0.DataSourceConnectionList{
Items: []queryV0.DataSourceConnection{},
}
-2
View File
@@ -88,7 +88,6 @@ import (
"github.com/grafana/grafana/pkg/services/datasourceproxy"
"github.com/grafana/grafana/pkg/services/datasources"
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service"
datasourcesclient "github.com/grafana/grafana/pkg/services/datasources/service/client"
"github.com/grafana/grafana/pkg/services/dsquerierclient"
"github.com/grafana/grafana/pkg/services/encryption"
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
@@ -477,7 +476,6 @@ var wireBasicSet = wire.NewSet(
appregistry.WireSet,
// Dashboard Kubernetes helpers
dashboardclient.ProvideK8sClientWithFallback,
datasourcesclient.ProvideDataSourceConnectionClientFactory,
)
var wireSet = wire.NewSet(
+3 -6
View File
File diff suppressed because one or more lines are too long
@@ -1,147 +0,0 @@
package client
import (
"context"
"errors"
"net/http"
datasourcev0alpha1 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
queryv0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver"
"k8s.io/client-go/kubernetes"
)
// DataSourceConnectionClient can get information about data source connections.
//
//go:generate mockery --name DataSourceConnectionClient --structname MockDataSourceConnectionClient --inpackage --filename=client_mock.go --with-expecter
type DataSourceConnectionClient interface {
GetByUID(ctx context.Context, uid string) (*queryv0alpha1.DataSourceConnection, error)
}
func ProvideDataSourceConnectionClientFactory(
restConfigProvider apiserver.RestConfigProvider,
) DataSourceConnectionClientFactory {
return func(configProvider apiserver.RestConfigProvider) DataSourceConnectionClient {
return &dataSourceConnectionClient{
configProvider: configProvider,
}
}
}
type DataSourceConnectionClientFactory func(configProvider apiserver.RestConfigProvider) DataSourceConnectionClient
type dataSourceConnectionClient struct {
configProvider apiserver.RestConfigProvider
}
func (dc *dataSourceConnectionClient) Get(ctx context.Context, group, version, name string) (*queryv0alpha1.DataSourceConnection, error) {
cfg, err := dc.configProvider.GetRestConfig(ctx)
if err != nil {
return nil, err
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
if version == "" {
version = "v0alpha1"
}
result := client.RESTClient().Get().
Prefix("apis", group, version).
Namespace("default"). // TODO do something about namespace
Resource("datasources").
Name(name).
Do(ctx)
if err = result.Error(); err != nil {
return nil, err
}
var statusCode int
result = result.StatusCode(&statusCode)
if statusCode == http.StatusNotFound {
return nil, errors.New("not found")
}
fullDS := datasourcev0alpha1.DataSource{}
err = result.Into(&fullDS)
if err != nil {
return nil, err
}
dsConnection := &queryv0alpha1.DataSourceConnection{
Title: fullDS.Spec.Title(),
Datasource: queryv0alpha1.DataSourceConnectionRef{
Group: fullDS.GroupVersionKind().Group,
Name: fullDS.ObjectMeta.Name,
Version: fullDS.GroupVersionKind().Version,
},
}
return dsConnection, nil
}
func (dc *dataSourceConnectionClient) GetByUID(ctx context.Context, uid string) (*queryv0alpha1.DataSourceConnection, error) {
cfg, err := dc.configProvider.GetRestConfig(ctx)
if err != nil {
return nil, err
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
// use the list endpoint with a fieldSelector so that can get multiple results
// in the case of a non-unique "uid". This should not be possible when we are
// backed by the legacy database, but wont be guaranteed when we are using
// uniStore as the names will not be guaranteed unique across apiGroups. We
// error below if more than one result is returned.
result := client.RESTClient().Get().
Prefix("apis", "query.grafana.app", "v0alpha1").
Namespace("default"). // TODO do something about namespace
Resource("connections").
Param("fieldSelector", "metadata.name="+uid).
Do(ctx)
if err = result.Error(); err != nil {
return nil, err
}
var statusCode int
result = result.StatusCode(&statusCode)
if statusCode == http.StatusNotFound {
return nil, errors.New("not found")
}
dsList := datasourcev0alpha1.DataSourceList{}
err = result.Into(&dsList)
if err != nil {
return nil, err
}
if len(dsList.Items) == 0 {
return nil, errors.New("not found")
}
if len(dsList.Items) > 1 {
return nil, errors.New("multiple connections found")
}
fullDS := dsList.Items[0]
dsConnection := &queryv0alpha1.DataSourceConnection{
Title: fullDS.Spec.Title(),
Datasource: queryv0alpha1.DataSourceConnectionRef{
Group: fullDS.GroupVersionKind().Group,
Name: fullDS.ObjectMeta.Name,
Version: fullDS.GroupVersionKind().Version,
},
}
return dsConnection, nil
}
@@ -1,96 +0,0 @@
// Code generated by mockery v2.53.3. DO NOT EDIT.
package client
import (
context "context"
v0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockDataSourceConnectionClient is an autogenerated mock type for the DataSourceConnectionClient type
type MockDataSourceConnectionClient struct {
mock.Mock
}
type MockDataSourceConnectionClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockDataSourceConnectionClient) EXPECT() *MockDataSourceConnectionClient_Expecter {
return &MockDataSourceConnectionClient_Expecter{mock: &_m.Mock}
}
// GetByUID provides a mock function with given fields: ctx, uid
func (_m *MockDataSourceConnectionClient) GetByUID(ctx context.Context, uid string) (*v0alpha1.DataSourceConnection, error) {
ret := _m.Called(ctx, uid)
if len(ret) == 0 {
panic("no return value specified for GetByUID")
}
var r0 *v0alpha1.DataSourceConnection
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.DataSourceConnection, error)); ok {
return rf(ctx, uid)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.DataSourceConnection); ok {
r0 = rf(ctx, uid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v0alpha1.DataSourceConnection)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, uid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockDataSourceConnectionClient_GetByUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByUID'
type MockDataSourceConnectionClient_GetByUID_Call struct {
*mock.Call
}
// GetByUID is a helper method to define mock.On call
// - ctx context.Context
// - uid string
func (_e *MockDataSourceConnectionClient_Expecter) GetByUID(ctx interface{}, uid interface{}) *MockDataSourceConnectionClient_GetByUID_Call {
return &MockDataSourceConnectionClient_GetByUID_Call{Call: _e.mock.On("GetByUID", ctx, uid)}
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) Run(run func(ctx context.Context, uid string)) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) Return(_a0 *v0alpha1.DataSourceConnection, _a1 error) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.DataSourceConnection, error)) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Return(run)
return _c
}
// NewMockDataSourceConnectionClient creates a new instance of MockDataSourceConnectionClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockDataSourceConnectionClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockDataSourceConnectionClient {
mock := &MockDataSourceConnectionClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
+8
View File
@@ -1884,6 +1884,14 @@ var (
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "externalVizSuggestions",
Description: "Enable all plugins to supply visualization suggestions (including 3rd party plugins)",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "preventPanelChromeOverflow",
Description: "Restrict PanelChrome contents with overflow: hidden;",
+1
View File
@@ -256,6 +256,7 @@ cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false
pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false
newGauge,experimental,@grafana/dataviz-squad,false,false,true
newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
256 pluginInstallAPISync experimental @grafana/plugins-platform-backend false false false
257 newGauge experimental @grafana/dataviz-squad false false true
258 newVizSuggestions preview @grafana/dataviz-squad false false true
259 externalVizSuggestions experimental @grafana/dataviz-squad false false true
260 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
261 jaegerEnableGrpcEndpoint experimental @grafana/oss-big-tent false false false
262 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false
+14
View File
@@ -1383,6 +1383,20 @@
"codeowner": "@grafana/identity-access-team"
}
},
{
"metadata": {
"name": "externalVizSuggestions",
"resourceVersion": "1763498528748",
"creationTimestamp": "2025-11-18T20:42:08Z"
},
"spec": {
"description": "Enable all plugins to supply visualization suggestions (including 3rd party plugins)",
"stage": "experimental",
"codeowner": "@grafana/dataviz-squad",
"frontend": true,
"expression": "false"
}
},
{
"metadata": {
"name": "extraThemes",
+1 -1
View File
@@ -221,7 +221,7 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode
// Add Service Center as a standalone nav item under Alerts & IRM
if alertsSection := treeRoot.FindById(navtree.NavIDAlertsAndIncidents); alertsSection != nil {
serviceLink := &navtree.NavLink{
Text: "Service Center",
Text: "Service center",
Id: "standalone-plugin-page-slo-services",
Url: s.cfg.AppSubURL + "/a/grafana-slo-app/services",
SortWeight: 1,
@@ -574,15 +574,6 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n
Url: baseUrl + "/datasources",
Children: []*navtree.NavLink{},
})
// Stacks
children = append(children, &navtree.NavLink{
Id: "connections-stacks",
Text: "Stacks",
SubTitle: "Manage data source stacks for different environments",
Url: baseUrl + "/stacks",
Children: []*navtree.NavLink{},
})
}
if len(children) > 0 {
+9 -5
View File
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/util/osutil"
)
// nolint:unused
var migratedUnifiedResources = []string{
//"playlists.playlist.grafana.app",
"folders.folder.grafana.app",
@@ -58,14 +59,16 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
// Set indexer config for unified storage
section := cfg.Raw.Section("unified_storage")
// TODO: Re-enable once migrations are ready and disabled on cloud
//cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
cfg.DisableDataMigrations = true
cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
if !cfg.DisableDataMigrations && cfg.getUnifiedStorageType() == "unified" {
cfg.enforceMigrationToUnifiedConfigs()
// Helper log to find instances running migrations in the future
cfg.Logger.Info("Unified migration configs not yet enforced")
//cfg.enforceMigrationToUnifiedConfigs() // TODO: uncomment when ready for release
} else {
cfg.EnableSearch = section.Key("enable_search").MustBool(false)
// Helper log to find instances disabling migration
cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.getUnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations)
}
cfg.EnableSearch = section.Key("enable_search").MustBool(false)
cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0)
cfg.IndexPath = section.Key("index_path").String()
cfg.IndexWorkers = section.Key("index_workers").MustInt(10)
@@ -102,6 +105,7 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("")
}
// nolint:unused
// enforceMigrationToUnifiedConfigs enforces configurations required to run migrated resources in mode 5
// All migrated resources in MigratedUnifiedResources are set to mode 5 and unified search is enabled
func (cfg *Cfg) enforceMigrationToUnifiedConfigs() {
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/migrations/contract"
@@ -55,8 +56,12 @@ func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error {
// skip migrations if disabled in config
if p.cfg.DisableDataMigrations {
metrics.MUnifiedStorageMigrationStatus.Set(1)
logger.Info("Data migrations are disabled, skipping")
return nil
} else {
metrics.MUnifiedStorageMigrationStatus.Set(2)
logger.Info("Data migrations not yet enforced, skipping")
}
// TODO: Re-enable once migrations are ready
@@ -66,6 +66,8 @@ func NewSimulationEngine() (*SimulationEngine, error) {
newFlightSimInfo,
newSinewaveInfo,
newTankSimInfo,
newNBodySimInfo,
newGrot3dSimInfo,
}
for _, init := range initializers {
@@ -0,0 +1,560 @@
package sims
import (
_ "embed"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"math"
"time"
"bytes"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
//go:embed grot_mesh.json
var grotMeshData []byte
//go:embed grot_base_color.png
var grotBaseColor []byte
type grot3dSim struct {
key simulationKey
cfg grot3dConfig
state grot3dState
vertices []point3d
uvs [][]float64
indices []int
texture image.Image
}
var (
_ Simulation = (*grot3dSim)(nil)
)
type grot3dConfig struct {
RotationSpeedX float64 `json:"rotationSpeedX"` // Rotation speed around X axis (degrees/second)
RotationSpeedY float64 `json:"rotationSpeedY"` // Rotation speed around Y axis (degrees/second)
RotationSpeedZ float64 `json:"rotationSpeedZ"` // Rotation speed around Z axis (degrees/second)
MinAngleX float64 `json:"minAngleX"` // Minimum rotation angle for X axis (degrees)
MaxAngleX float64 `json:"maxAngleX"` // Maximum rotation angle for X axis (degrees)
MinAngleY float64 `json:"minAngleY"` // Minimum rotation angle for Y axis (degrees)
MaxAngleY float64 `json:"maxAngleY"` // Maximum rotation angle for Y axis (degrees)
MinAngleZ float64 `json:"minAngleZ"` // Minimum rotation angle for Z axis (degrees)
MaxAngleZ float64 `json:"maxAngleZ"` // Maximum rotation angle for Z axis (degrees)
LightX float64 `json:"lightX"` // Light direction X component
LightY float64 `json:"lightY"` // Light direction Y component
LightZ float64 `json:"lightZ"` // Light direction Z component
AmbientLight float64 `json:"ambientLight"` // Ambient light level (0-1)
ViewWidth float64 `json:"viewWidth"` // SVG viewBox width
ViewHeight float64 `json:"viewHeight"` // SVG viewBox height
Perspective float64 `json:"perspective"` // Perspective distance (larger = less perspective)
Scale float64 `json:"scale"` // Overall scale multiplier
}
type grot3dState struct {
lastTime time.Time
angleX float64 // Current rotation around X axis (radians)
angleY float64 // Current rotation around Y axis (radians)
angleZ float64 // Current rotation around Z axis (radians)
directionX float64 // Direction multiplier for X rotation (+1 or -1)
directionY float64 // Direction multiplier for Y rotation (+1 or -1)
directionZ float64 // Direction multiplier for Z rotation (+1 or -1)
}
type point3d struct {
x, y, z float64
}
type point2d struct {
x, y float64
}
type meshData struct {
Vertices [][]float64 `json:"vertices"`
Uvs [][]float64 `json:"uvs"`
Indices []int `json:"indices"`
}
type triangleWithDepth struct {
v0, v1, v2 point2d
depth float64
visible bool
idx0, idx1, idx2 int
normal point3d
}
func (s *grot3dSim) GetState() simulationState {
return simulationState{
Key: s.key,
Config: s.cfg,
}
}
func (s *grot3dSim) SetConfig(vals map[string]any) error {
return updateConfigObjectFromJSON(&s.cfg, vals)
}
func (s *grot3dSim) initialize() error {
s.state.lastTime = time.Time{}
s.state.angleX = 0
s.state.angleY = 0
s.state.angleZ = 0
s.state.directionX = 1
s.state.directionY = 1
s.state.directionZ = 1
// Load mesh data if not already loaded
if len(s.vertices) == 0 {
var mesh meshData
if err := json.Unmarshal(grotMeshData, &mesh); err != nil {
return fmt.Errorf("failed to load grot holiday mesh data: %w", err)
}
// Convert to point3d
s.vertices = make([]point3d, len(mesh.Vertices))
for i, v := range mesh.Vertices {
if len(v) != 3 {
return fmt.Errorf("invalid vertex data at index %d", i)
}
s.vertices[i] = point3d{x: v[0], y: v[1], z: v[2]}
}
s.uvs = mesh.Uvs
if len(s.uvs) != len(s.vertices) {
return fmt.Errorf("UV count mismatch: %d vs %d", len(s.uvs), len(s.vertices))
}
s.indices = mesh.Indices
}
// Load texture
img, err := png.Decode(bytes.NewReader(grotBaseColor))
if err != nil {
return fmt.Errorf("failed to decode texture: %w", err)
}
s.texture = img
return nil
}
func (s *grot3dSim) NewFrame(size int) *data.Frame {
frame := data.NewFrame("")
// Time field
frame.Fields = append(frame.Fields, data.NewField("time", nil, make([]time.Time, size)))
// SVG content field (string)
frame.Fields = append(frame.Fields, data.NewField("svg_content", nil, make([]string, size)))
// Also add rotation angles for reference/debugging
frame.Fields = append(frame.Fields, data.NewField("angle_x", nil, make([]float64, size)))
frame.Fields = append(frame.Fields, data.NewField("angle_y", nil, make([]float64, size)))
frame.Fields = append(frame.Fields, data.NewField("angle_z", nil, make([]float64, size)))
return frame
}
func (s *grot3dSim) GetValues(t time.Time) map[string]any {
// Initialize if this is the first call
if s.state.lastTime.IsZero() {
s.state.lastTime = t
}
// Calculate elapsed time and update rotation
if t.After(s.state.lastTime) {
dt := t.Sub(s.state.lastTime).Seconds()
s.updateRotation(dt)
s.state.lastTime = t
} else if t.Before(s.state.lastTime) {
// Can't go backwards - reinitialize
s.initialize()
s.state.lastTime = t
}
// Generate the SVG content for the current rotation
svgContent := s.generateSVG()
return map[string]any{
"time": t,
"svg_content": svgContent,
"angle_x": s.state.angleX * 180 / math.Pi, // Convert to degrees for display
"angle_y": s.state.angleY * 180 / math.Pi,
"angle_z": s.state.angleZ * 180 / math.Pi,
}
}
func (s *grot3dSim) updateRotation(dt float64) {
// Update X rotation
if s.cfg.MinAngleX == 0 && s.cfg.MaxAngleX == 0 {
// No limits - continuous rotation
s.state.angleX += s.cfg.RotationSpeedX * dt * math.Pi / 180
s.state.angleX = math.Mod(s.state.angleX, 2*math.Pi)
} else {
// Bouncing rotation with limits
minAngleX := s.cfg.MinAngleX * math.Pi / 180
maxAngleX := s.cfg.MaxAngleX * math.Pi / 180
s.state.angleX += s.cfg.RotationSpeedX * dt * math.Pi / 180 * s.state.directionX
if s.state.angleX >= maxAngleX {
s.state.angleX = maxAngleX
s.state.directionX = -1
} else if s.state.angleX <= minAngleX {
s.state.angleX = minAngleX
s.state.directionX = 1
}
}
// Update Y rotation
if s.cfg.MinAngleY == 0 && s.cfg.MaxAngleY == 0 {
// No limits - continuous rotation
s.state.angleY += s.cfg.RotationSpeedY * dt * math.Pi / 180
s.state.angleY = math.Mod(s.state.angleY, 2*math.Pi)
} else {
// Bouncing rotation with limits
minAngleY := s.cfg.MinAngleY * math.Pi / 180
maxAngleY := s.cfg.MaxAngleY * math.Pi / 180
s.state.angleY += s.cfg.RotationSpeedY * dt * math.Pi / 180 * s.state.directionY
if s.state.angleY >= maxAngleY {
s.state.angleY = maxAngleY
s.state.directionY = -1
} else if s.state.angleY <= minAngleY {
s.state.angleY = minAngleY
s.state.directionY = 1
}
}
// Update Z rotation
if s.cfg.MinAngleZ == 0 && s.cfg.MaxAngleZ == 0 {
// No limits - continuous rotation
s.state.angleZ += s.cfg.RotationSpeedZ * dt * math.Pi / 180
s.state.angleZ = math.Mod(s.state.angleZ, 2*math.Pi)
} else {
// Bouncing rotation with limits
minAngleZ := s.cfg.MinAngleZ * math.Pi / 180
maxAngleZ := s.cfg.MaxAngleZ * math.Pi / 180
s.state.angleZ += s.cfg.RotationSpeedZ * dt * math.Pi / 180 * s.state.directionZ
if s.state.angleZ >= maxAngleZ {
s.state.angleZ = maxAngleZ
s.state.directionZ = -1
} else if s.state.angleZ <= minAngleZ {
s.state.angleZ = minAngleZ
s.state.directionZ = 1
}
}
}
// rotatePoint3D applies 3D rotation around X, Y, and Z axes
func (s *grot3dSim) rotatePoint3D(p point3d) point3d {
// Rotate around X axis
cosX, sinX := math.Cos(s.state.angleX), math.Sin(s.state.angleX)
y := p.y*cosX - p.z*sinX
z := p.y*sinX + p.z*cosX
p.y, p.z = y, z
// Rotate around Y axis
cosY, sinY := math.Cos(s.state.angleY), math.Sin(s.state.angleY)
x := p.x*cosY + p.z*sinY
z = -p.x*sinY + p.z*cosY
p.x, p.z = x, z
// Rotate around Z axis
cosZ, sinZ := math.Cos(s.state.angleZ), math.Sin(s.state.angleZ)
x = p.x*cosZ - p.y*sinZ
y = p.x*sinZ + p.y*cosZ
p.x, p.y = x, y
return p
}
// project3DTo2D converts 3D point to 2D using perspective projection
func (s *grot3dSim) project3DTo2D(p point3d) point2d {
// Apply scale
scaledP := point3d{
x: p.x * s.cfg.Scale,
y: p.y * s.cfg.Scale,
z: p.z * s.cfg.Scale,
}
// Apply perspective projection
scale := s.cfg.Perspective / (s.cfg.Perspective + scaledP.z)
return point2d{
x: scaledP.x*scale + s.cfg.ViewWidth/2,
y: -scaledP.y*scale + s.cfg.ViewHeight/2, // Flip Y vertically (negative Y goes up)
}
}
func (s *grot3dSim) generateSVG() string {
// Rotate all vertices
rotatedVertices := make([]point3d, len(s.vertices))
for i, v := range s.vertices {
rotatedVertices[i] = s.rotatePoint3D(v)
}
// Project to 2D
projectedVertices := make([]point2d, len(rotatedVertices))
for i, v := range rotatedVertices {
projectedVertices[i] = s.project3DTo2D(v)
}
// Process triangles for depth sorting and backface culling
triangles := make([]triangleWithDepth, 0, len(s.indices)/3)
// Calculate near plane for clipping
nearPlane := -s.cfg.Perspective * 0.9 / s.cfg.Scale
for i := 0; i < len(s.indices); i += 3 {
idx0 := s.indices[i]
idx1 := s.indices[i+1]
idx2 := s.indices[i+2]
v0 := rotatedVertices[idx0]
v1 := rotatedVertices[idx1]
v2 := rotatedVertices[idx2]
// Near-plane clipping: skip triangles too close to camera
if v0.z < nearPlane || v1.z < nearPlane || v2.z < nearPlane {
continue
}
// Calculate triangle center depth for sorting
centerZ := (v0.z + v1.z + v2.z) / 3
// Calculate face normal for backface culling
// Two edges of the triangle
edge1 := point3d{v1.x - v0.x, v1.y - v0.y, v1.z - v0.z}
edge2 := point3d{v2.x - v0.x, v2.y - v0.y, v2.z - v0.z}
// Cross product gives normal
normal := point3d{
x: edge1.y*edge2.z - edge1.z*edge2.y,
y: edge1.z*edge2.x - edge1.x*edge2.z,
z: edge1.x*edge2.y - edge1.y*edge2.x,
}
// Normalize the normal vector
normalMag := math.Sqrt(normal.x*normal.x + normal.y*normal.y + normal.z*normal.z)
if normalMag > 0 {
normal.x /= normalMag
normal.y /= normalMag
normal.z /= normalMag
}
// View vector (camera is looking along -Z axis)
viewVector := point3d{0, 0, -1}
// Dot product of normal and view vector (now both are unit vectors)
dotProduct := normal.x*viewVector.x + normal.y*viewVector.y + normal.z*viewVector.z
// Only render triangles facing the camera (backface culling)
// Use small tolerance to catch edge-on triangles (dot product is now -1 to 1)
visible := dotProduct < 0.2
triangles = append(triangles, triangleWithDepth{
v0: projectedVertices[idx0],
v1: projectedVertices[idx1],
v2: projectedVertices[idx2],
depth: centerZ,
visible: visible,
idx0: idx0,
idx1: idx1,
idx2: idx2,
normal: normal,
})
}
// Sort triangles by depth (painter's algorithm - draw furthest first)
for i := 0; i < len(triangles); i++ {
for j := i + 1; j < len(triangles); j++ {
if triangles[i].depth > triangles[j].depth {
triangles[i], triangles[j] = triangles[j], triangles[i]
}
}
}
// Build SVG string
svg := fmt.Sprintf("<svg viewBox='0 0 %.0f %.0f' xmlns='http://www.w3.org/2000/svg' stroke='none'>",
s.cfg.ViewWidth, s.cfg.ViewHeight)
// Calculate colors for all visible triangles and group by color
type triangleWithColor struct {
tri triangleWithDepth
color string
opacity string
}
coloredTriangles := make([]triangleWithColor, 0, len(triangles))
bounds := s.texture.Bounds()
for _, tri := range triangles {
if !tri.visible {
continue
}
// Calculate lighting intensity
// Normalize light direction
lightDir := point3d{x: s.cfg.LightX, y: s.cfg.LightY, z: s.cfg.LightZ}
lightMag := math.Sqrt(lightDir.x*lightDir.x + lightDir.y*lightDir.y + lightDir.z*lightDir.z)
if lightMag > 0 {
lightDir.x /= lightMag
lightDir.y /= lightMag
lightDir.z /= lightMag
}
// Diffuse lighting (Lambert) - dot product of normal and light direction
diffuse := math.Max(0, -(tri.normal.x*lightDir.x + tri.normal.y*lightDir.y + tri.normal.z*lightDir.z))
// Combine ambient and diffuse
intensity := s.cfg.AmbientLight + (1.0-s.cfg.AmbientLight)*diffuse
intensity = math.Max(0, math.Min(1, intensity))
// Get centroid UV
uv0 := s.uvs[tri.idx0]
uv1 := s.uvs[tri.idx1]
uv2 := s.uvs[tri.idx2]
centU := (uv0[0] + uv1[0] + uv2[0]) / 3
centV := (uv0[1] + uv1[1] + uv2[1]) / 3
// Clamp UVs to 0-1
centU = math.Max(0, math.Min(1, centU))
centV = math.Max(0, math.Min(1, centV))
// Sample texture - no V flip
x := int(centU * float64(bounds.Dx()-1))
y := int(centV * float64(bounds.Dy()-1))
c := s.texture.At(x, y).(color.RGBA)
// Apply depth intensity to the sampled color
r := int(float64(c.R) * intensity)
g := int(float64(c.G) * intensity)
b := int(float64(c.B) * intensity)
// Quantize colors to reduce palette (round to nearest 8)
r = (r / 8) * 8
g = (g / 8) * 8
b = (b / 8) * 8
colorStr := fmt.Sprintf("#%02x%02x%02x", r, g, b)
opacityStr := ""
if c.A < 255 {
opacityStr = fmt.Sprintf("%.2f", float64(c.A)/255)
}
coloredTriangles = append(coloredTriangles, triangleWithColor{
tri: tri,
color: colorStr,
opacity: opacityStr,
})
}
// Group triangles by color and render
i := 0
for i < len(coloredTriangles) {
currentColor := coloredTriangles[i].color
currentOpacity := coloredTriangles[i].opacity
// Build path data for all triangles with the same color
pathData := ""
for i < len(coloredTriangles) &&
coloredTriangles[i].color == currentColor &&
coloredTriangles[i].opacity == currentOpacity {
tri := coloredTriangles[i].tri
pathData += fmt.Sprintf(
"M%.2f,%.2fL%.2f,%.2fL%.2f,%.2fZ",
tri.v0.x, tri.v0.y,
tri.v1.x, tri.v1.y,
tri.v2.x, tri.v2.y,
)
i++
}
// Output single path with all triangles
if currentOpacity != "" {
svg += fmt.Sprintf("<path fill='%s' opacity='%s' d='%s'/>", currentColor, currentOpacity, pathData)
} else {
svg += fmt.Sprintf("<path fill='%s' d='%s'/>", currentColor, pathData)
}
}
svg += "</svg>"
return svg
}
func (s *grot3dSim) Close() error {
return nil
}
func newGrot3dSimInfo() simulationInfo {
return simulationInfo{
Type: "grot3d",
Name: "Rotating 3D Grot",
Description: "Renders a rotating 3D grot model using SVG triangles",
OnlyForward: false,
ConfigFields: data.NewFrame("config",
data.NewField("rotationSpeedX", nil, []float64{0}),
data.NewField("rotationSpeedY", nil, []float64{5}),
data.NewField("rotationSpeedZ", nil, []float64{30}),
data.NewField("minAngleX", nil, []float64{-45}),
data.NewField("maxAngleX", nil, []float64{45}),
data.NewField("minAngleY", nil, []float64{-45}),
data.NewField("maxAngleY", nil, []float64{45}),
data.NewField("minAngleZ", nil, []float64{0}),
data.NewField("maxAngleZ", nil, []float64{0}),
data.NewField("lightX", nil, []float64{-1}),
data.NewField("lightY", nil, []float64{-1}),
data.NewField("lightZ", nil, []float64{1}),
data.NewField("ambientLight", nil, []float64{0.3}),
data.NewField("viewWidth", nil, []float64{800}),
data.NewField("viewHeight", nil, []float64{800}),
data.NewField("perspective", nil, []float64{1000}),
data.NewField("scale", nil, []float64{5.0}),
),
create: func(state simulationState) (Simulation, error) {
sim := &grot3dSim{
key: state.Key,
cfg: grot3dConfig{
RotationSpeedX: 0,
RotationSpeedY: 5,
RotationSpeedZ: 30,
MinAngleX: -45,
MaxAngleX: 45,
MinAngleY: -45,
MaxAngleY: 45,
MinAngleZ: 0,
MaxAngleZ: 0,
LightX: -1,
LightY: -1,
LightZ: -1,
AmbientLight: 0.5,
ViewWidth: 800,
ViewHeight: 800,
Perspective: 1000,
Scale: 5.0,
},
}
if state.Config != nil {
vals, ok := state.Config.(map[string]any)
if ok {
err := sim.SetConfig(vals)
if err != nil {
return nil, err
}
}
}
if err := sim.initialize(); err != nil {
return nil, err
}
return sim, nil
},
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,429 @@
package sims
import (
"fmt"
"math"
"math/rand"
"time"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
type nbodySim struct {
key simulationKey
cfg nbodyConfig
state nbodyState
random *rand.Rand
}
var (
_ Simulation = (*nbodySim)(nil)
)
type nbodyConfig struct {
N int `json:"n"` // number of bodies
Width float64 `json:"width"` // boundary width in pixels
Height float64 `json:"height"` // boundary height in pixels
Seed int64 `json:"seed"` // random seed for reproducibility
}
type circle struct {
x float64 // x position
y float64 // y position
vx float64 // x velocity
vy float64 // y velocity
radius float64 // radius
mass float64 // mass (proportional to radius^2 for simplicity)
rotation float64 // current rotation angle in degrees (0-360)
}
type nbodyState struct {
lastTime time.Time
circles []circle
}
func (s *nbodySim) GetState() simulationState {
return simulationState{
Key: s.key,
Config: s.cfg,
}
}
func (s *nbodySim) SetConfig(vals map[string]any) error {
oldCfg := s.cfg
err := updateConfigObjectFromJSON(&s.cfg, vals)
if err != nil {
return err
}
// If configuration changed, reinitialize the simulation
if oldCfg.N != s.cfg.N || oldCfg.Width != s.cfg.Width || oldCfg.Height != s.cfg.Height || oldCfg.Seed != s.cfg.Seed {
s.initialize()
}
return nil
}
func (s *nbodySim) initialize() {
s.random = rand.New(rand.NewSource(s.cfg.Seed))
s.state.circles = make([]circle, s.cfg.N)
s.state.lastTime = time.Time{}
const maxRadius = 30.0
const bossRadius = maxRadius * 2.0 // Boss is twice the max radius (60 pixels)
// Generate random circles (first one is the boss, rest are normal)
for i := 0; i < s.cfg.N; i++ {
var radius float64
// First circle is always the "boss" with double radius
if i == 0 || i == 1 {
radius = bossRadius
} else {
// Random radius between 5 and 30 pixels for normal circles
radius = 5.0 + s.random.Float64()*25.0
}
// Random position ensuring the circle is within bounds
x := radius + s.random.Float64()*(s.cfg.Width-2*radius)
y := radius + s.random.Float64()*(s.cfg.Height-2*radius)
// Random velocity between -250 and 250 pixels/second
vx := (s.random.Float64()*2.0 - 1.0) * 250.0
vy := (s.random.Float64()*2.0 - 1.0) * 250.0
// Mass proportional to area (radius squared)
mass := radius * radius
// Initial rotation based on initial velocity
rotation := math.Atan2(vy, vx) * 180.0 / math.Pi
if rotation < 0 {
rotation += 360.0
}
s.state.circles[i] = circle{
x: x,
y: y,
vx: vx,
vy: vy,
radius: radius,
mass: mass,
rotation: rotation,
}
}
}
func (s *nbodySim) NewFrame(size int) *data.Frame {
frame := data.NewFrame("")
// Time field - create with length=size for pre-allocated storage
frame.Fields = append(frame.Fields, data.NewField("time", nil, make([]time.Time, size)))
// For each circle, add position, bounding box, size, velocity, and rotation fields with pre-allocated storage
for i := 0; i < s.cfg.N; i++ {
frame.Fields = append(frame.Fields,
data.NewField(fmt.Sprintf("circle_%d_x", i), nil, make([]float64, size)),
)
frame.Fields = append(frame.Fields,
data.NewField(fmt.Sprintf("circle_%d_y", i), nil, make([]float64, size)),
)
frame.Fields = append(frame.Fields,
data.NewField(fmt.Sprintf("circle_%d_left", i), nil, make([]float64, size)),
)
frame.Fields = append(frame.Fields,
data.NewField(fmt.Sprintf("circle_%d_top", i), nil, make([]float64, size)),
)
frame.Fields = append(frame.Fields,
data.NewField(fmt.Sprintf("circle_%d_diameter", i), nil, make([]float64, size)),
)
frame.Fields = append(frame.Fields,
data.NewField(fmt.Sprintf("circle_%d_velocity", i), nil, make([]float64, size)),
)
frame.Fields = append(frame.Fields,
data.NewField(fmt.Sprintf("circle_%d_rotation", i), nil, make([]float64, size)),
)
}
return frame
}
func (s *nbodySim) GetValues(t time.Time) map[string]any {
// Initialize if this is the first call
if s.state.lastTime.IsZero() {
s.state.lastTime = t
if len(s.state.circles) == 0 {
s.initialize()
}
}
// Calculate elapsed time in seconds
if t.After(s.state.lastTime) {
dt := t.Sub(s.state.lastTime).Seconds()
s.simulate(dt)
s.state.lastTime = t
} else if t.Before(s.state.lastTime) {
// Can't go backwards - reinitialize
s.initialize()
s.state.lastTime = t
}
// Build result map
result := map[string]any{
"time": t,
}
for i := 0; i < len(s.state.circles); i++ {
c := s.state.circles[i]
// Calculate velocity magnitude: sqrt(vx^2 + vy^2)
velocity := math.Sqrt(c.vx*c.vx + c.vy*c.vy)
// Center position
result[fmt.Sprintf("circle_%d_x", i)] = c.x
result[fmt.Sprintf("circle_%d_y", i)] = c.y
// Top-left corner of bounding box (for easier canvas positioning)
result[fmt.Sprintf("circle_%d_left", i)] = c.x - c.radius
result[fmt.Sprintf("circle_%d_top", i)] = c.y - c.radius
// Size, velocity, and rotation (smoothed rotation from simulate)
result[fmt.Sprintf("circle_%d_diameter", i)] = c.radius * 2.0
result[fmt.Sprintf("circle_%d_velocity", i)] = velocity
result[fmt.Sprintf("circle_%d_rotation", i)] = c.rotation
}
return result
}
func (s *nbodySim) simulate(dt float64) {
// Don't simulate too large time steps
if dt > 1.0 {
dt = 1.0
}
// Use smaller sub-steps for more accurate collision detection
steps := int(math.Ceil(dt * 60)) // 60 sub-steps per second
if steps < 1 {
steps = 1
}
subDt := dt / float64(steps)
for step := 0; step < steps; step++ {
// Calculate and apply gravitational forces between all pairs
// G scaled for pixel world: smaller masses, pixel distances
const G = 5000.0 // Gravitational constant scaled for our pixel world
for i := 0; i < len(s.state.circles); i++ {
for j := i + 1; j < len(s.state.circles); j++ {
c1 := &s.state.circles[i]
c2 := &s.state.circles[j]
// Calculate distance between centers
dx := c2.x - c1.x
dy := c2.y - c1.y
distSq := dx*dx + dy*dy
// Avoid division by zero and extremely strong forces at close range
const minDist = 10.0 // Minimum distance to prevent extreme forces
if distSq < minDist*minDist {
distSq = minDist * minDist
}
dist := math.Sqrt(distSq)
// Calculate gravitational force magnitude: F = G * m1 * m2 / r^2
force := G * c1.mass * c2.mass / distSq
// Calculate force components (normalized direction * force)
fx := (dx / dist) * force
fy := (dy / dist) * force
// Apply acceleration to both particles (F = ma -> a = F/m)
// c1 is attracted to c2 (positive direction)
c1.vx += (fx / c1.mass) * subDt
c1.vy += (fy / c1.mass) * subDt
// c2 is attracted to c1 (negative direction, by Newton's 3rd law)
c2.vx -= (fx / c2.mass) * subDt
c2.vy -= (fy / c2.mass) * subDt
}
}
// Update positions
for i := range s.state.circles {
s.state.circles[i].x += s.state.circles[i].vx * subDt
s.state.circles[i].y += s.state.circles[i].vy * subDt
}
// Handle wall collisions
for i := range s.state.circles {
c := &s.state.circles[i]
// Left/right walls (perfectly elastic - no energy loss)
if c.x-c.radius < 0 {
c.x = c.radius
c.vx = math.Abs(c.vx)
} else if c.x+c.radius > s.cfg.Width {
c.x = s.cfg.Width - c.radius
c.vx = -math.Abs(c.vx)
}
// Top/bottom walls (perfectly elastic - no energy loss)
if c.y-c.radius < 0 {
c.y = c.radius
c.vy = math.Abs(c.vy)
} else if c.y+c.radius > s.cfg.Height {
c.y = s.cfg.Height - c.radius
c.vy = -math.Abs(c.vy)
}
}
// Handle circle-to-circle collisions
for i := 0; i < len(s.state.circles); i++ {
for j := i + 1; j < len(s.state.circles); j++ {
c1 := &s.state.circles[i]
c2 := &s.state.circles[j]
// Calculate distance between centers
dx := c2.x - c1.x
dy := c2.y - c1.y
distSq := dx*dx + dy*dy
minDist := c1.radius + c2.radius
// Check for collision
if distSq < minDist*minDist && distSq > 0 {
dist := math.Sqrt(distSq)
// Normalize collision vector
nx := dx / dist
ny := dy / dist
// Separate the circles so they don't overlap
overlap := minDist - dist
c1.x -= nx * overlap * 0.5
c1.y -= ny * overlap * 0.5
c2.x += nx * overlap * 0.5
c2.y += ny * overlap * 0.5
// Calculate relative velocity
dvx := c2.vx - c1.vx
dvy := c2.vy - c1.vy
// Calculate relative velocity in collision normal direction
dvn := dvx*nx + dvy*ny
// Do not resolve if velocities are separating
if dvn > 0 {
continue
}
// Calculate impulse scalar (perfectly elastic collision)
restitution := 1.0 // coefficient of restitution (1.0 = perfectly elastic, no energy loss)
impulse := (1 + restitution) * dvn / (1/c1.mass + 1/c2.mass)
// Apply impulse
c1.vx += impulse * nx / c1.mass
c1.vy += impulse * ny / c1.mass
c2.vx -= impulse * nx / c2.mass
c2.vy -= impulse * ny / c2.mass
}
}
}
// Update rotations smoothly based on velocity direction
// Maximum rotation change per sub-step (in degrees)
// At 60 sub-steps/sec, 1.5 degrees/step = 90 degrees/second max
const maxRotationChange = 5
for i := range s.state.circles {
c := &s.state.circles[i]
// Calculate target rotation from velocity vector
targetRotation := math.Atan2(c.vy, c.vx) * 180.0 / math.Pi
if targetRotation < 0 {
targetRotation += 360.0
}
// Calculate the shortest angular difference (handles wrap-around)
diff := targetRotation - c.rotation
if diff > 180.0 {
diff -= 360.0
} else if diff < -180.0 {
diff += 360.0
}
// Clamp the rotation change
if diff > maxRotationChange {
diff = maxRotationChange
} else if diff < -maxRotationChange {
diff = -maxRotationChange
}
// Apply the clamped rotation change
c.rotation += diff
// Keep rotation in 0-360 range
if c.rotation >= 360.0 {
c.rotation -= 360.0
} else if c.rotation < 0 {
c.rotation += 360.0
}
}
}
}
func (s *nbodySim) Close() error {
return nil
}
func newNBodySimInfo() simulationInfo {
defaultCfg := nbodyConfig{
N: 10,
Width: 800,
Height: 600,
Seed: 12345,
}
// Create config frame that describes the available configuration fields
df := data.NewFrame("")
df.Fields = append(df.Fields, data.NewField("n", nil, []int64{int64(defaultCfg.N)}))
df.Fields = append(df.Fields, data.NewField("width", nil, []float64{defaultCfg.Width}).SetConfig(&data.FieldConfig{
Unit: "px",
}))
df.Fields = append(df.Fields, data.NewField("height", nil, []float64{defaultCfg.Height}).SetConfig(&data.FieldConfig{
Unit: "px",
}))
df.Fields = append(df.Fields, data.NewField("seed", nil, []int64{defaultCfg.Seed}))
return simulationInfo{
Type: "nbody",
Name: "N-Body",
Description: "N-body collision simulation with circles bouncing in a bounded space",
ConfigFields: df,
OnlyForward: false,
create: func(cfg simulationState) (Simulation, error) {
s := &nbodySim{
key: cfg.Key,
cfg: defaultCfg,
}
err := updateConfigObjectFromJSON(&s.cfg, cfg.Config)
if err != nil {
return nil, err
}
// Validate configuration
if s.cfg.N <= 0 {
return nil, fmt.Errorf("n must be positive")
}
if s.cfg.Width <= 0 || s.cfg.Height <= 0 {
return nil, fmt.Errorf("width and height must be positive")
}
if s.cfg.N > 100 {
return nil, fmt.Errorf("n is too large (max 100)")
}
s.initialize()
return s, nil
},
}
}
@@ -0,0 +1,238 @@
package sims
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/stretchr/testify/require"
)
func TestNBodyQuery(t *testing.T) {
s, err := NewSimulationEngine()
require.NoError(t, err)
t.Run("simple nbody simulation", func(t *testing.T) {
sq := &simulationQuery{}
sq.Key = simulationKey{
Type: "nbody",
TickHZ: 10,
}
sq.Config = map[string]any{
"n": 5,
"width": 400.0,
"height": 300.0,
"seed": 42,
}
sb, err := json.Marshal(map[string]any{
"sim": sq,
})
require.NoError(t, err)
start := time.Date(2020, time.January, 10, 23, 0, 0, 0, time.UTC)
qr := &backend.QueryDataRequest{
Queries: []backend.DataQuery{
{
RefID: "A",
TimeRange: backend.TimeRange{
From: start,
To: start.Add(time.Second * 2),
},
Interval: 100 * time.Millisecond,
MaxDataPoints: 20,
JSON: sb,
},
},
}
rsp, err := s.QueryData(context.Background(), qr)
require.NoError(t, err)
require.NotNil(t, rsp)
// Verify we got a response
dr, ok := rsp.Responses["A"]
require.True(t, ok)
require.NoError(t, dr.Error)
require.Len(t, dr.Frames, 1)
frame := dr.Frames[0]
// Should have time + (x, y, left, top, diameter, velocity) for each of 5 circles = 31 fields
require.Equal(t, 31, len(frame.Fields))
// Check field names
require.Equal(t, "time", frame.Fields[0].Name)
require.Equal(t, "circle_0_x", frame.Fields[1].Name)
require.Equal(t, "circle_0_y", frame.Fields[2].Name)
require.Equal(t, "circle_0_left", frame.Fields[3].Name)
require.Equal(t, "circle_0_top", frame.Fields[4].Name)
require.Equal(t, "circle_0_diameter", frame.Fields[5].Name)
require.Equal(t, "circle_0_velocity", frame.Fields[6].Name)
// Verify we have data points
require.Greater(t, frame.Fields[0].Len(), 0)
})
t.Run("nbody with different configurations", func(t *testing.T) {
testCases := []struct {
name string
n int
width float64
height float64
seed int64
}{
{"small", 3, 200, 200, 1},
{"medium", 10, 800, 600, 2},
{"large", 20, 1000, 800, 3},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sq := &simulationQuery{}
sq.Key = simulationKey{
Type: "nbody",
TickHZ: 10,
}
sq.Config = map[string]any{
"n": tc.n,
"width": tc.width,
"height": tc.height,
"seed": tc.seed,
}
sb, err := json.Marshal(map[string]any{
"sim": sq,
})
require.NoError(t, err)
start := time.Now()
qr := &backend.QueryDataRequest{
Queries: []backend.DataQuery{
{
RefID: "A",
TimeRange: backend.TimeRange{
From: start,
To: start.Add(time.Second),
},
Interval: 100 * time.Millisecond,
MaxDataPoints: 10,
JSON: sb,
},
},
}
rsp, err := s.QueryData(context.Background(), qr)
require.NoError(t, err)
require.NotNil(t, rsp)
dr, ok := rsp.Responses["A"]
require.True(t, ok)
require.NoError(t, dr.Error)
require.Len(t, dr.Frames, 1)
frame := dr.Frames[0]
// Should have time + (x, y, left, top, diameter, velocity) for each of N circles = 1 + 6*N fields
expectedFields := 1 + 6*tc.n
require.Equal(t, expectedFields, len(frame.Fields))
})
}
})
t.Run("nbody validates configuration", func(t *testing.T) {
testCases := []struct {
name string
config map[string]any
shouldError bool
}{
{"valid", map[string]any{"n": 5, "width": 400.0, "height": 300.0, "seed": 42}, false},
{"zero n", map[string]any{"n": 0, "width": 400.0, "height": 300.0, "seed": 42}, true},
{"negative n", map[string]any{"n": -5, "width": 400.0, "height": 300.0, "seed": 42}, true},
{"zero width", map[string]any{"n": 5, "width": 0.0, "height": 300.0, "seed": 42}, true},
{"negative height", map[string]any{"n": 5, "width": 400.0, "height": -300.0, "seed": 42}, true},
{"too many bodies", map[string]any{"n": 150, "width": 400.0, "height": 300.0, "seed": 42}, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sq := &simulationQuery{}
sq.Key = simulationKey{
Type: "nbody",
TickHZ: 10,
}
sq.Config = tc.config
sb, err := json.Marshal(map[string]any{
"sim": sq,
})
require.NoError(t, err)
start := time.Now()
qr := &backend.QueryDataRequest{
Queries: []backend.DataQuery{
{
RefID: "A",
TimeRange: backend.TimeRange{
From: start,
To: start.Add(time.Second),
},
Interval: 100 * time.Millisecond,
MaxDataPoints: 10,
JSON: sb,
},
},
}
rsp, err := s.QueryData(context.Background(), qr)
if tc.shouldError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.NotNil(t, rsp)
}
})
}
})
}
func TestNBodyCollisions(t *testing.T) {
// Test that circles actually collide and bounce
info := newNBodySimInfo()
cfg := simulationState{
Key: simulationKey{
Type: "nbody",
TickHZ: 10,
},
Config: map[string]any{
"n": 2,
"width": 200.0,
"height": 200.0,
"seed": 12345,
},
}
sim, err := info.create(cfg)
require.NoError(t, err)
require.NotNil(t, sim)
// Get initial values
t0 := time.Now()
v0 := sim.GetValues(t0)
// Simulate for 2 seconds
t1 := t0.Add(2 * time.Second)
v1 := sim.GetValues(t1)
// Verify that positions have changed (circles are moving)
require.NotEqual(t, v0["circle_0_x"], v1["circle_0_x"])
require.NotEqual(t, v0["circle_0_y"], v1["circle_0_y"])
// Verify diameters remain constant
require.Equal(t, v0["circle_0_diameter"], v1["circle_0_diameter"])
require.Equal(t, v0["circle_1_diameter"], v1["circle_1_diameter"])
sim.Close()
}
@@ -182,8 +182,6 @@ export function getNavTitle(navId: string | undefined) {
return t('nav.connections.title', 'Connections');
case 'connections-add-new-connection':
return t('nav.add-new-connections.title', 'Add new connection');
case 'connections-stacks':
return t('nav.stacks.title', 'Stacks');
case 'standalone-plugin-page-/connections/collector':
return t('nav.collector.title', 'Collector');
case 'connections-datasources':
+7 -1
View File
@@ -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 ?? [],
}),
+12 -6
View File
@@ -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,
+260
View File
@@ -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 -5
View File
@@ -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 ?? [],
}),
+2
View File
@@ -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];
+341 -195
View File
@@ -14,7 +14,12 @@ import {
ActionType,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { TooltipDisplayMode } from '@grafana/schema';
import {
PositionDimensionConfig,
PositionDimensionMode,
ScalarDimensionMode,
TooltipDisplayMode,
} from '@grafana/schema';
import { ConfirmModal, VariablesInputModal } from '@grafana/ui';
import { LayerElement } from 'app/core/components/Layers/types';
import { config } from 'app/core/config';
@@ -74,6 +79,40 @@ export class ElementState implements LayerElement {
showActionVarsModal = false;
actionVars: ActionVariableInput = {};
// Cached values resolved from dimension context
private cachedRotation = 0;
private cachedTop = 0;
private cachedLeft = 0;
private cachedWidth = 100;
private cachedHeight = 100;
private cachedRight?: number;
private cachedBottom?: number;
/** Check if a position property is field-driven (not fixed) */
isPositionFieldDriven(prop: 'top' | 'left' | 'width' | 'height' | 'right' | 'bottom'): boolean {
const pos = this.options.placement?.[prop];
return pos?.mode === PositionDimensionMode.Field && !!pos?.field;
}
/** Check if rotation is field-driven (has a field binding) */
isRotationFieldDriven(): boolean {
const rot = this.options.placement?.rotation;
return !!rot?.field;
}
/** Check if ANY position/size property is field-driven - if so, element can't be moved in editor */
hasFieldDrivenPosition(): boolean {
return (
this.isPositionFieldDriven('top') ||
this.isPositionFieldDriven('left') ||
this.isPositionFieldDriven('width') ||
this.isPositionFieldDriven('height') ||
this.isPositionFieldDriven('right') ||
this.isPositionFieldDriven('bottom') ||
this.isRotationFieldDriven()
);
}
setActionVars = (vars: ActionVariableInput) => {
this.actionVars = vars;
this.forceUpdate();
@@ -93,7 +132,13 @@ export class ElementState implements LayerElement {
vertical: VerticalConstraint.Top,
horizontal: HorizontalConstraint.Left,
};
options.placement = options.placement ?? { width: 100, height: 100, top: 0, left: 0, rotation: 0 };
options.placement = options.placement ?? {
width: { fixed: 100, mode: PositionDimensionMode.Fixed },
height: { fixed: 100, mode: PositionDimensionMode.Fixed },
top: { fixed: 0, mode: PositionDimensionMode.Fixed },
left: { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
};
options.background = options.background ?? { color: { fixed: 'transparent' } };
options.border = options.border ?? { color: { fixed: 'dark-green' } };
@@ -121,6 +166,18 @@ export class ElementState implements LayerElement {
return this.options.name;
}
/** Get the current rotation value (resolved from dimension context) */
getRotation(): number {
return this.cachedRotation;
}
/** Set the fixed value of a PositionDimensionConfig */
private setPositionFixed(pos: PositionDimensionConfig | undefined, value: number): void {
if (pos) {
pos.fixed = value;
}
}
/** Use the configured options to update CSS style properties directly on the wrapper div **/
applyLayoutStylesToDiv(disablePointerEvents?: boolean) {
if (config.featureToggles.canvasPanelPanZoom) {
@@ -134,7 +191,6 @@ export class ElementState implements LayerElement {
const { constraint } = this.options;
const { vertical, horizontal } = constraint ?? {};
const placement: Placement = this.options.placement ?? {};
const editingEnabled = this.getScene()?.isEditingEnabled;
@@ -145,95 +201,64 @@ export class ElementState implements LayerElement {
// Minimum element size is 10x10
minWidth: '10px',
minHeight: '10px',
rotate: `${placement.rotation ?? 0}deg`,
rotate: `${this.cachedRotation}deg`,
};
const translate = ['0px', '0px'];
switch (vertical) {
case VerticalConstraint.Top:
placement.top = placement.top ?? 0;
placement.height = placement.height ?? 100;
style.top = `${placement.top}px`;
style.height = `${placement.height}px`;
delete placement.bottom;
style.top = `${this.cachedTop}px`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.Bottom:
placement.bottom = placement.bottom ?? 0;
placement.height = placement.height ?? 100;
style.bottom = `${placement.bottom}px`;
style.height = `${placement.height}px`;
delete placement.top;
style.bottom = `${this.cachedBottom ?? 0}px`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.TopBottom:
placement.top = placement.top ?? 0;
placement.bottom = placement.bottom ?? 0;
style.top = `${placement.top}px`;
style.bottom = `${placement.bottom}px`;
delete placement.height;
style.top = `${this.cachedTop}px`;
style.bottom = `${this.cachedBottom ?? 0}px`;
style.height = '';
break;
case VerticalConstraint.Center:
placement.top = placement.top ?? 0;
placement.height = placement.height ?? 100;
translate[1] = '-50%';
style.top = `calc(50% - ${placement.top}px)`;
style.height = `${placement.height}px`;
delete placement.bottom;
style.top = `calc(50% - ${this.cachedTop}px)`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.Scale:
placement.top = placement.top ?? 0;
placement.bottom = placement.bottom ?? 0;
style.top = `${placement.top}%`;
style.bottom = `${placement.bottom}%`;
delete placement.height;
style.top = `${this.cachedTop}%`;
style.bottom = `${this.cachedBottom ?? 0}%`;
style.height = '';
break;
}
switch (horizontal) {
case HorizontalConstraint.Left:
placement.left = placement.left ?? 0;
placement.width = placement.width ?? 100;
style.left = `${placement.left}px`;
style.width = `${placement.width}px`;
delete placement.right;
style.left = `${this.cachedLeft}px`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.Right:
placement.right = placement.right ?? 0;
placement.width = placement.width ?? 100;
style.right = `${placement.right}px`;
style.width = `${placement.width}px`;
delete placement.left;
style.right = `${this.cachedRight ?? 0}px`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.LeftRight:
placement.left = placement.left ?? 0;
placement.right = placement.right ?? 0;
style.left = `${placement.left}px`;
style.right = `${placement.right}px`;
delete placement.width;
style.left = `${this.cachedLeft}px`;
style.right = `${this.cachedRight ?? 0}px`;
style.width = '';
break;
case HorizontalConstraint.Center:
placement.left = placement.left ?? 0;
placement.width = placement.width ?? 100;
translate[0] = '-50%';
style.left = `calc(50% - ${placement.left}px)`;
style.width = `${placement.width}px`;
delete placement.right;
style.left = `calc(50% - ${this.cachedLeft}px)`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.Scale:
placement.left = placement.left ?? 0;
placement.right = placement.right ?? 0;
style.left = `${placement.left}%`;
style.right = `${placement.right}%`;
delete placement.width;
style.left = `${this.cachedLeft}%`;
style.right = `${this.cachedRight ?? 0}%`;
style.width = '';
break;
}
style.transform = `translate(${translate[0]}, ${translate[1]})`;
this.options.placement = placement;
this.sizeStyle = style;
if (this.div) {
@@ -267,7 +292,6 @@ export class ElementState implements LayerElement {
const { constraint } = this.options;
const { vertical, horizontal } = constraint ?? {};
const placement: Placement = this.options.placement ?? {};
const editingEnabled = scene?.isEditingEnabled;
@@ -275,7 +299,6 @@ export class ElementState implements LayerElement {
cursor: editingEnabled ? 'grab' : 'auto',
pointerEvents: disablePointerEvents ? 'none' : 'auto',
position: 'absolute',
// Minimum element size is 10x10
minWidth: '10px',
minHeight: '10px',
};
@@ -285,81 +308,50 @@ export class ElementState implements LayerElement {
switch (vertical) {
case VerticalConstraint.Top:
placement.top = placement.top ?? 0;
placement.height = placement.height ?? 100;
transformY = `${placement.top ?? 0}px`;
style.height = `${placement.height}px`;
delete placement.bottom;
transformY = `${this.cachedTop}px`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.Bottom:
placement.bottom = placement.bottom ?? 0;
placement.height = placement.height ?? 100;
transformY = `${sceneHeight! - (placement.bottom ?? 0) - (placement.height ?? 100)}px`;
style.height = `${placement.height}px`;
delete placement.top;
transformY = `${sceneHeight! - (this.cachedBottom ?? 0) - this.cachedHeight}px`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.TopBottom:
placement.top = placement.top ?? 0;
placement.bottom = placement.bottom ?? 0;
transformY = `${placement.top ?? 0}px`;
style.height = `${sceneHeight! - (placement.top ?? 0) - (placement.bottom ?? 0)}px`;
delete placement.height;
transformY = `${this.cachedTop}px`;
style.height = `${sceneHeight! - this.cachedTop - (this.cachedBottom ?? 0)}px`;
break;
case VerticalConstraint.Center:
placement.top = placement.top ?? 0;
placement.height = placement.height ?? 100;
transformY = `${sceneHeight! / 2 - (placement.top ?? 0) - (placement.height ?? 0) / 2}px`;
style.height = `${placement.height}px`;
delete placement.bottom;
transformY = `${sceneHeight! / 2 - this.cachedTop - this.cachedHeight / 2}px`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.Scale:
placement.top = placement.top ?? 0;
placement.bottom = placement.bottom ?? 0;
transformY = `${(placement.top ?? 0) * (sceneHeight! / 100)}px`;
style.height = `${sceneHeight! - (placement.top ?? 0) * (sceneHeight! / 100) - (placement.bottom ?? 0) * (sceneHeight! / 100)}px`;
delete placement.height;
transformY = `${this.cachedTop * (sceneHeight! / 100)}px`;
style.height = `${sceneHeight! - this.cachedTop * (sceneHeight! / 100) - (this.cachedBottom ?? 0) * (sceneHeight! / 100)}px`;
break;
}
switch (horizontal) {
case HorizontalConstraint.Left:
placement.left = placement.left ?? 0;
placement.width = placement.width ?? 100;
transformX = `${placement.left ?? 0}px`;
style.width = `${placement.width}px`;
delete placement.right;
transformX = `${this.cachedLeft}px`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.Right:
placement.right = placement.right ?? 0;
placement.width = placement.width ?? 100;
transformX = `${sceneWidth! - (placement.right ?? 0) - (placement.width ?? 100)}px`;
style.width = `${placement.width}px`;
delete placement.left;
transformX = `${sceneWidth! - (this.cachedRight ?? 0) - this.cachedWidth}px`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.LeftRight:
placement.left = placement.left ?? 0;
placement.right = placement.right ?? 0;
transformX = `${placement.left ?? 0}px`;
style.width = `${sceneWidth! - (placement.left ?? 0) - (placement.right ?? 0)}px`;
delete placement.width;
transformX = `${this.cachedLeft}px`;
style.width = `${sceneWidth! - this.cachedLeft - (this.cachedRight ?? 0)}px`;
break;
case HorizontalConstraint.Center:
placement.left = placement.left ?? 0;
placement.width = placement.width ?? 100;
transformX = `${sceneWidth! / 2 - (placement.left ?? 0) - (placement.width ?? 0) / 2}px`;
style.width = `${placement.width}px`;
delete placement.right;
transformX = `${sceneWidth! / 2 - this.cachedLeft - this.cachedWidth / 2}px`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.Scale:
placement.left = placement.left ?? 0;
placement.right = placement.right ?? 0;
transformX = `${(placement.left ?? 0) * (sceneWidth! / 100)}px`;
style.width = `${sceneWidth! - (placement.left ?? 0) * (sceneWidth! / 100) - (placement.right ?? 0) * (sceneWidth! / 100)}px`;
delete placement.width;
transformX = `${this.cachedLeft * (sceneWidth! / 100)}px`;
style.width = `${sceneWidth! - this.cachedLeft * (sceneWidth! / 100) - (this.cachedRight ?? 0) * (sceneWidth! / 100)}px`;
break;
}
this.options.placement = placement;
style.transform = `translate(${transformX}, ${transformY}) rotate(${placement.rotation ?? 0}deg)`;
style.transform = `translate(${transformX}, ${transformY}) rotate(${this.cachedRotation}deg)`;
this.sizeStyle = style;
if (this.div) {
@@ -415,8 +407,8 @@ export class ElementState implements LayerElement {
// TODO: Fix behavior for top+bottom, left+right, center, and scale constraints
let rotationTopOffset = 0;
let rotationLeftOffset = 0;
if (this.options.placement?.rotation && this.options.placement?.width && this.options.placement?.height) {
const rotationDegrees = this.options.placement.rotation;
if (this.cachedRotation && this.options.placement?.width && this.options.placement?.height) {
const rotationDegrees = this.cachedRotation;
const rotationRadians = (Math.PI / 180) * rotationDegrees;
let rotationOffset = rotationRadians;
@@ -438,8 +430,8 @@ export class ElementState implements LayerElement {
const calculateDelta = (dimension1: number, dimension2: number) =>
(dimension1 / 2) * Math.sin(rotationOffset) + (dimension2 / 2) * (Math.cos(rotationOffset) - 1);
rotationTopOffset = calculateDelta(this.options.placement.width, this.options.placement.height);
rotationLeftOffset = calculateDelta(this.options.placement.height, this.options.placement.width);
rotationTopOffset = calculateDelta(this.cachedWidth, this.cachedHeight);
rotationLeftOffset = calculateDelta(this.cachedHeight, this.cachedWidth);
}
const relativeTop =
@@ -463,67 +455,103 @@ export class ElementState implements LayerElement {
transformScale
: 0;
const placement: Placement = {};
// Don't update placement if any position is field-driven
if (this.hasFieldDrivenPosition()) {
this.applyLayoutStylesToDiv();
this.revId++;
return;
}
const width = (elementContainer?.width ?? 100) / transformScale;
const height = (elementContainer?.height ?? 100) / transformScale;
// Helper to create a position dimension config
const fixedPosition = (value: number): PositionDimensionConfig => ({
fixed: value,
mode: PositionDimensionMode.Fixed,
});
const placement: Placement = {};
switch (vertical) {
case VerticalConstraint.Top:
placement.top = relativeTop;
placement.height = height;
placement.top = fixedPosition(relativeTop);
placement.height = fixedPosition(height);
this.cachedTop = relativeTop;
this.cachedHeight = height;
break;
case VerticalConstraint.Bottom:
placement.bottom = relativeBottom;
placement.height = height;
placement.bottom = fixedPosition(relativeBottom);
placement.height = fixedPosition(height);
this.cachedBottom = relativeBottom;
this.cachedHeight = height;
break;
case VerticalConstraint.TopBottom:
placement.top = relativeTop;
placement.bottom = relativeBottom;
placement.top = fixedPosition(relativeTop);
placement.bottom = fixedPosition(relativeBottom);
this.cachedTop = relativeTop;
this.cachedBottom = relativeBottom;
break;
case VerticalConstraint.Center:
const elementCenter = elementContainer ? relativeTop + height / 2 : 0;
const parentCenter = parentContainer ? parentContainer.height / 2 : 0;
const distanceFromCenter = parentCenter - elementCenter;
placement.top = distanceFromCenter;
placement.height = height;
const elementCenterV = elementContainer ? relativeTop + height / 2 : 0;
const parentCenterV = parentContainer ? parentContainer.height / 2 : 0;
const distanceFromCenterV = parentCenterV - elementCenterV;
placement.top = fixedPosition(distanceFromCenterV);
placement.height = fixedPosition(height);
this.cachedTop = distanceFromCenterV;
this.cachedHeight = height;
break;
case VerticalConstraint.Scale:
placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
const scaleTop = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
const scaleBottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
placement.top = fixedPosition(scaleTop);
placement.bottom = fixedPosition(scaleBottom);
this.cachedTop = scaleTop;
this.cachedBottom = scaleBottom;
break;
}
switch (horizontal) {
case HorizontalConstraint.Left:
placement.left = relativeLeft;
placement.width = width;
placement.left = fixedPosition(relativeLeft);
placement.width = fixedPosition(width);
this.cachedLeft = relativeLeft;
this.cachedWidth = width;
break;
case HorizontalConstraint.Right:
placement.right = relativeRight;
placement.width = width;
placement.right = fixedPosition(relativeRight);
placement.width = fixedPosition(width);
this.cachedRight = relativeRight;
this.cachedWidth = width;
break;
case HorizontalConstraint.LeftRight:
placement.left = relativeLeft;
placement.right = relativeRight;
placement.left = fixedPosition(relativeLeft);
placement.right = fixedPosition(relativeRight);
this.cachedLeft = relativeLeft;
this.cachedRight = relativeRight;
break;
case HorizontalConstraint.Center:
const elementCenter = elementContainer ? relativeLeft + width / 2 : 0;
const parentCenter = parentContainer ? parentContainer.width / 2 : 0;
const distanceFromCenter = parentCenter - elementCenter;
placement.left = distanceFromCenter;
placement.width = width;
const elementCenterH = elementContainer ? relativeLeft + width / 2 : 0;
const parentCenterH = parentContainer ? parentContainer.width / 2 : 0;
const distanceFromCenterH = parentCenterH - elementCenterH;
placement.left = fixedPosition(distanceFromCenterH);
placement.width = fixedPosition(width);
this.cachedLeft = distanceFromCenterH;
this.cachedWidth = width;
break;
case HorizontalConstraint.Scale:
placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
const scaleLeft = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
const scaleRight = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
placement.left = fixedPosition(scaleLeft);
placement.right = fixedPosition(scaleRight);
this.cachedLeft = scaleLeft;
this.cachedRight = scaleRight;
break;
}
// Preserve rotation
if (this.options.placement?.rotation) {
placement.rotation = this.options.placement.rotation;
placement.width = this.options.placement.width;
placement.height = this.options.placement.height;
}
this.options.placement = placement;
@@ -554,71 +582,109 @@ export class ElementState implements LayerElement {
const relativeLeft = Math.round(elementRect.left);
const relativeRight = Math.round(scene.width - elementRect.left - elementRect.width);
const placement: Placement = {};
// Don't update placement if any position is field-driven
if (this.hasFieldDrivenPosition()) {
this.applyLayoutStylesToDiv();
this.revId++;
return;
}
const width = elementRect.width;
const height = elementRect.height;
// INFO: calculate it anyway to be able to use it for pan&zoom
placement.top = relativeTop;
placement.left = relativeLeft;
// Helper to create a position dimension config
const fixedPosition = (value: number): PositionDimensionConfig => ({
fixed: value,
mode: PositionDimensionMode.Fixed,
});
const placement: Placement = {};
// INFO: calculate for pan&zoom
placement.top = fixedPosition(relativeTop);
placement.left = fixedPosition(relativeLeft);
this.cachedTop = relativeTop;
this.cachedLeft = relativeLeft;
switch (vertical) {
case VerticalConstraint.Top:
placement.top = relativeTop;
placement.height = height;
placement.top = fixedPosition(relativeTop);
placement.height = fixedPosition(height);
this.cachedTop = relativeTop;
this.cachedHeight = height;
break;
case VerticalConstraint.Bottom:
placement.bottom = relativeBottom;
placement.height = height;
placement.bottom = fixedPosition(relativeBottom);
placement.height = fixedPosition(height);
this.cachedBottom = relativeBottom;
this.cachedHeight = height;
break;
case VerticalConstraint.TopBottom:
placement.top = relativeTop;
placement.bottom = relativeBottom;
placement.top = fixedPosition(relativeTop);
placement.bottom = fixedPosition(relativeBottom);
this.cachedTop = relativeTop;
this.cachedBottom = relativeBottom;
break;
case VerticalConstraint.Center:
const elementCenter = elementContainer ? relativeTop + height / 2 : 0;
const parentCenter = scene.height / 2; // Use scene height instead of scaled viewport height
const distanceFromCenter = parentCenter - elementCenter;
placement.top = distanceFromCenter;
placement.height = height;
const elementCenterV = elementContainer ? relativeTop + height / 2 : 0;
const parentCenterV = scene.height / 2;
const distanceFromCenterV = parentCenterV - elementCenterV;
placement.top = fixedPosition(distanceFromCenterV);
placement.height = fixedPosition(height);
this.cachedTop = distanceFromCenterV;
this.cachedHeight = height;
break;
case VerticalConstraint.Scale:
placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
const scaleTop = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
const scaleBottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
placement.top = fixedPosition(scaleTop);
placement.bottom = fixedPosition(scaleBottom);
this.cachedTop = scaleTop;
this.cachedBottom = scaleBottom;
break;
}
switch (horizontal) {
case HorizontalConstraint.Left:
placement.left = relativeLeft;
placement.width = width;
placement.left = fixedPosition(relativeLeft);
placement.width = fixedPosition(width);
this.cachedLeft = relativeLeft;
this.cachedWidth = width;
break;
case HorizontalConstraint.Right:
placement.right = relativeRight;
placement.width = width;
placement.right = fixedPosition(relativeRight);
placement.width = fixedPosition(width);
this.cachedRight = relativeRight;
this.cachedWidth = width;
break;
case HorizontalConstraint.LeftRight:
placement.left = relativeLeft;
placement.right = relativeRight;
placement.left = fixedPosition(relativeLeft);
placement.right = fixedPosition(relativeRight);
this.cachedLeft = relativeLeft;
this.cachedRight = relativeRight;
break;
case HorizontalConstraint.Center:
const elementCenter = elementContainer ? relativeLeft + width / 2 : 0;
const parentCenter = scene.width / 2; // Use scene width instead of scaled viewport width
const distanceFromCenter = parentCenter - elementCenter;
placement.left = distanceFromCenter;
placement.width = width;
const elementCenterH = elementContainer ? relativeLeft + width / 2 : 0;
const parentCenterH = scene.width / 2;
const distanceFromCenterH = parentCenterH - elementCenterH;
placement.left = fixedPosition(distanceFromCenterH);
placement.width = fixedPosition(width);
this.cachedLeft = distanceFromCenterH;
this.cachedWidth = width;
break;
case HorizontalConstraint.Scale:
placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
const scaleLeft = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
const scaleRight = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
placement.left = fixedPosition(scaleLeft);
placement.right = fixedPosition(scaleRight);
this.cachedLeft = scaleLeft;
this.cachedRight = scaleRight;
break;
}
// Preserve rotation
if (this.options.placement?.rotation) {
placement.rotation = this.options.placement.rotation;
placement.width = this.options.placement.width;
placement.height = this.options.placement.height;
}
this.options.placement = placement;
@@ -630,11 +696,47 @@ export class ElementState implements LayerElement {
}
updateData(ctx: DimensionContext) {
const previousData = this.data;
if (this.item.prepareData) {
this.data = this.item.prepareData(ctx, this.options);
this.revId++; // rerender
// Only increment revId if data actually changed (not just position)
// This prevents flickering when only position updates
if (JSON.stringify(this.data) !== JSON.stringify(previousData)) {
this.revId++;
}
}
// Update placement values from dimension context
const placement = this.options.placement;
if (placement) {
if (placement.rotation) {
this.cachedRotation = ctx.getScalar(placement.rotation).value();
}
if (placement.top) {
this.cachedTop = ctx.getPosition(placement.top).value();
}
if (placement.left) {
this.cachedLeft = ctx.getPosition(placement.left).value();
}
if (placement.width) {
this.cachedWidth = ctx.getPosition(placement.width).value();
}
if (placement.height) {
this.cachedHeight = ctx.getPosition(placement.height).value();
}
if (placement.right) {
this.cachedRight = ctx.getPosition(placement.right).value();
}
if (placement.bottom) {
this.cachedBottom = ctx.getPosition(placement.bottom).value();
}
}
// Apply updated positions without forcing a remount
this.applyLayoutStylesToDiv();
const scene = this.getScene();
const frames = scene?.data?.series;
@@ -793,6 +895,11 @@ export class ElementState implements LayerElement {
};
applyDrag = (event: OnDrag) => {
// Don't allow dragging if any position is field-driven
if (this.hasFieldDrivenPosition()) {
return;
}
const hasHorizontalCenterConstraint = this.options.constraint?.horizontal === HorizontalConstraint.Center;
const hasVerticalCenterConstraint = this.options.constraint?.vertical === VerticalConstraint.Center;
if (hasHorizontalCenterConstraint || hasVerticalCenterConstraint) {
@@ -813,18 +920,31 @@ export class ElementState implements LayerElement {
applyRotate = (event: OnRotate) => {
const rotationDelta = event.delta;
const placement = this.options.placement!;
const placementRotation = placement.rotation ?? 0;
const placementRotation = this.cachedRotation;
const calculatedRotation = placementRotation + rotationDelta;
// Ensure rotation is between 0 and 360
placement.rotation = calculatedRotation - Math.floor(calculatedRotation / 360) * 360;
const newRotation = calculatedRotation - Math.floor(calculatedRotation / 360) * 360;
// Update the config value as fixed
if (!placement.rotation) {
placement.rotation = { fixed: newRotation, min: 0, max: 360, mode: ScalarDimensionMode.Clamped };
} else {
placement.rotation.fixed = newRotation;
}
this.cachedRotation = newRotation;
event.target.style.transform = event.transform;
};
// kinda like:
// https://github.com/grafana/grafana-edge-app/blob/main/src/panels/draw/WrapItem.tsx#L44
applyResize = (event: OnResize) => {
// Don't allow resizing if any position is field-driven
if (this.hasFieldDrivenPosition()) {
return;
}
const placement = this.options.placement!;
const style = event.target.style;
@@ -834,8 +954,8 @@ export class ElementState implements LayerElement {
let dirTB = event.direction[1];
// Handle case when element is rotated
if (placement.rotation) {
const rotation = placement.rotation ?? 0;
if (this.cachedRotation) {
const rotation = this.cachedRotation;
const rotationInRadians = (rotation * Math.PI) / 180;
const originalDirLR = dirLR;
const originalDirTB = dirTB;
@@ -845,31 +965,37 @@ export class ElementState implements LayerElement {
}
if (dirLR === 1) {
placement.width = event.width;
style.width = `${placement.width}px`;
this.setPositionFixed(placement.width, event.width);
this.cachedWidth = event.width;
style.width = `${this.cachedWidth}px`;
} else if (dirLR === -1) {
placement.left! -= deltaX;
placement.width = event.width;
this.cachedLeft -= deltaX;
this.setPositionFixed(placement.left, this.cachedLeft);
this.cachedWidth = event.width;
this.setPositionFixed(placement.width, this.cachedWidth);
if (config.featureToggles.canvasPanelPanZoom) {
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
style.transform = `translate(${this.cachedLeft}px, ${this.cachedTop}px) rotate(${this.cachedRotation}deg)`;
} else {
style.left = `${placement.left}px`;
style.left = `${this.cachedLeft}px`;
}
style.width = `${placement.width}px`;
style.width = `${this.cachedWidth}px`;
}
if (dirTB === -1) {
placement.top! -= deltaY;
placement.height = event.height;
this.cachedTop -= deltaY;
this.setPositionFixed(placement.top, this.cachedTop);
this.cachedHeight = event.height;
this.setPositionFixed(placement.height, this.cachedHeight);
if (config.featureToggles.canvasPanelPanZoom) {
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
style.transform = `translate(${this.cachedLeft}px, ${this.cachedTop}px) rotate(${this.cachedRotation}deg)`;
} else {
style.top = `${placement.top}px`;
style.top = `${this.cachedTop}px`;
}
style.height = `${placement.height}px`;
style.height = `${this.cachedHeight}px`;
} else if (dirTB === 1) {
placement.height = event.height;
style.height = `${placement.height}px`;
this.cachedHeight = event.height;
this.setPositionFixed(placement.height, this.cachedHeight);
style.height = `${this.cachedHeight}px`;
}
};
@@ -880,7 +1006,8 @@ export class ElementState implements LayerElement {
!scene?.isEditingEnabled && (!scene?.tooltipPayload?.isOpen || scene?.tooltipPayload?.element === this);
if (shouldHandleTooltip) {
this.handleTooltip(event);
} else if (!isSelected) {
} else if (!isSelected && !this.hasFieldDrivenPosition()) {
// Don't show connection anchors for field-driven elements
scene?.connections.handleMouseEnter(event);
}
@@ -1090,6 +1217,25 @@ export class ElementState implements LayerElement {
);
};
// Track if this field-driven element is selected (for showing outline)
isFieldDrivenSelected = false;
setFieldDrivenSelected(selected: boolean) {
if (this.hasFieldDrivenPosition()) {
this.isFieldDrivenSelected = selected;
// Update the outline style
if (this.div) {
if (selected) {
this.div.style.outline = '2px solid #3274d9';
this.div.style.outlineOffset = '2px';
} else {
this.div.style.outline = '';
this.div.style.outlineOffset = '';
}
}
}
}
renderElement() {
const { item, div } = this;
const scene = this.getScene();
@@ -1112,7 +1258,7 @@ export class ElementState implements LayerElement {
key={`${this.UID}/${this.revId}`}
config={this.options.config}
data={this.data}
isSelected={isSelected}
isSelected={isSelected || this.isFieldDrivenSelected}
/>
</div>
{this.showActionConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())}
@@ -9,6 +9,7 @@ import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import {
ColorDimensionConfig,
PositionDimensionConfig,
ResourceDimensionConfig,
ScalarDimensionConfig,
ScaleDimensionConfig,
@@ -21,6 +22,7 @@ import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import {
getColorDimensionFromData,
getPositionDimensionFromData,
getResourceDimensionFromData,
getScalarDimensionFromData,
getScaleDimensionFromData,
@@ -109,6 +111,22 @@ export class Scene {
targetsToSelect = new Set<HTMLDivElement>();
// Track currently selected field-driven element (these aren't in Selecto)
private fieldDrivenSelectedElement?: ElementState;
clearFieldDrivenSelection = () => {
if (this.fieldDrivenSelectedElement) {
this.fieldDrivenSelectedElement.setFieldDrivenSelected(false);
this.fieldDrivenSelectedElement = undefined;
}
};
setFieldDrivenSelection = (element: ElementState) => {
this.clearFieldDrivenSelection();
this.fieldDrivenSelectedElement = element;
element.setFieldDrivenSelected(true);
};
constructor(
options: Options,
public onSave: (cfg: CanvasFrameOptions) => void,
@@ -211,6 +229,7 @@ export class Scene {
getColor: (color: ColorDimensionConfig) => getColorDimensionFromData(this.data, color),
getScale: (scale: ScaleDimensionConfig) => getScaleDimensionFromData(this.data, scale),
getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.data, scalar),
getPosition: (pos: PositionDimensionConfig) => getPositionDimensionFromData(this.data, pos),
getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.data, text),
getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.data, res),
getDirection: (direction: DirectionDimensionConfig) => getDirectionDimensionFromData(this.data, direction),
@@ -267,6 +286,8 @@ export class Scene {
clearCurrentSelection(skipNextSelectionBroadcast = false) {
this.skipNextSelectionBroadcast = skipNextSelectionBroadcast;
// Clear field-driven selection
this.clearFieldDrivenSelection();
let event: MouseEvent = new MouseEvent('click');
if (config.featureToggles.canvasPanelPanZoom) {
this.selecto?.clickTarget(event, this.viewportDiv);
@@ -324,6 +345,9 @@ export class Scene {
select = (selection: SelectionParams) => {
if (this.selecto) {
// Clear any field-driven selection when selecting via Selecto
this.clearFieldDrivenSelection();
this.selecto.setSelectedTargets(selection.targets);
this.updateSelection(selection);
this.editModeEnabled.next(false);
@@ -69,6 +69,7 @@ const isTargetAlreadySelected = (selectedTarget: HTMLElement, scene: Scene) => {
};
// Generate HTML element divs for every canvas element to configure selecto / moveable
// Excludes elements with field-driven positions (they can't be moved in editor)
const generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[] => {
let targetElements: HTMLDivElement[] = [];
@@ -77,7 +78,10 @@ const generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[]
const currentElement = stack.shift();
if (currentElement && currentElement.div) {
targetElements.push(currentElement.div);
// Skip elements with field-driven positions - they can't be moved
if (!currentElement.hasFieldDrivenPosition()) {
targetElements.push(currentElement.div);
}
}
const nestedElements = currentElement instanceof FrameState ? currentElement.elements : [];

Some files were not shown because too many files have changed in this diff Show More