Compare commits
7 Commits
drew08t/ca
...
folders/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688d6746c9 | ||
|
|
e8f1eb1ee8 | ||
|
|
c0f8e5688b | ||
|
|
6e7f28f5a1 | ||
|
|
9dcad9c255 | ||
|
|
f8f4fb5640 | ||
|
|
d5de92e5b2 |
@@ -128,20 +128,6 @@ 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.
|
||||
|
||||
@@ -1372,11 +1372,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/AlertLabelDropdown.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
@@ -1593,11 +1588,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
|
||||
13
packages/grafana-schema/src/common/common.gen.ts
generated
13
packages/grafana-schema/src/common/common.gen.ts
generated
@@ -105,19 +105,6 @@ 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,15 +38,6 @@ 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?: ui.PositionDimensionConfig;
|
||||
height?: ui.PositionDimensionConfig;
|
||||
left?: ui.PositionDimensionConfig;
|
||||
right?: ui.PositionDimensionConfig;
|
||||
rotation?: ui.ScalarDimensionConfig;
|
||||
top?: ui.PositionDimensionConfig;
|
||||
width?: ui.PositionDimensionConfig;
|
||||
bottom?: number;
|
||||
height?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
rotation?: number;
|
||||
top?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export enum BackgroundImageSize {
|
||||
|
||||
@@ -29,10 +29,6 @@ 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'> {}
|
||||
|
||||
@@ -37,6 +37,12 @@ export function SidebarResizer() {
|
||||
return;
|
||||
}
|
||||
|
||||
// mouse is moving with no buttons pressed
|
||||
if (!e.buttons) {
|
||||
dragStart.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = e.clientX - dragStart.current;
|
||||
dragStart.current = e.clientX;
|
||||
|
||||
|
||||
@@ -156,6 +156,9 @@ func (i *Identity) GetExtra() map[string][]string {
|
||||
if i.GetOrgRole().IsValid() {
|
||||
extra["user-instance-role"] = []string{string(i.GetOrgRole())}
|
||||
}
|
||||
if i.AccessTokenClaims != nil && i.AccessTokenClaims.Rest.ServiceIdentity != "" {
|
||||
extra[authn.ServiceIdentityKey] = []string{i.AccessTokenClaims.Rest.ServiceIdentity}
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
|
||||
140
pkg/services/authn/identity_test.go
Normal file
140
pkg/services/authn/identity_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
authnlib "github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIdentity_GetExtra(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
identity *Identity
|
||||
expected map[string][]string
|
||||
}{
|
||||
{
|
||||
name: "returns empty map when no extra fields are set",
|
||||
identity: &Identity{
|
||||
ID: "1",
|
||||
Type: types.TypeUser,
|
||||
},
|
||||
expected: map[string][]string{
|
||||
"user-instance-role": {"None"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "returns id-token when IDToken is set",
|
||||
identity: &Identity{
|
||||
ID: "1",
|
||||
Type: types.TypeUser,
|
||||
IDToken: "test-id-token",
|
||||
},
|
||||
expected: map[string][]string{
|
||||
"id-token": {"test-id-token"},
|
||||
"user-instance-role": {"None"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "returns user-instance-role when OrgRole is valid",
|
||||
identity: &Identity{
|
||||
ID: "1",
|
||||
Type: types.TypeUser,
|
||||
OrgID: 1,
|
||||
OrgRoles: map[int64]org.RoleType{1: "Admin"},
|
||||
},
|
||||
expected: map[string][]string{
|
||||
"user-instance-role": {"Admin"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "returns service-identity when AccessTokenClaims contains ServiceIdentity",
|
||||
identity: &Identity{
|
||||
ID: "1",
|
||||
Type: types.TypeAccessPolicy,
|
||||
AccessTokenClaims: &authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||
Rest: authnlib.AccessTokenClaims{
|
||||
ServiceIdentity: "secrets-manager",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string][]string{
|
||||
string(authnlib.ServiceIdentityKey): {"secrets-manager"},
|
||||
"user-instance-role": {"None"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "returns all extra fields when multiple are set",
|
||||
identity: &Identity{
|
||||
ID: "1",
|
||||
Type: types.TypeUser,
|
||||
OrgID: 1,
|
||||
IDToken: "test-id-token",
|
||||
OrgRoles: map[int64]org.RoleType{1: "Editor"},
|
||||
AccessTokenClaims: &authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||
Rest: authnlib.AccessTokenClaims{
|
||||
ServiceIdentity: "custom-service",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string][]string{
|
||||
"id-token": {"test-id-token"},
|
||||
"user-instance-role": {"Editor"},
|
||||
string(authnlib.ServiceIdentityKey): {"custom-service"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "does not include service-identity when AccessTokenClaims is nil",
|
||||
identity: &Identity{
|
||||
ID: "1",
|
||||
Type: types.TypeUser,
|
||||
AccessTokenClaims: nil,
|
||||
},
|
||||
expected: map[string][]string{
|
||||
"user-instance-role": {"None"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "does not include service-identity when ServiceIdentity is empty",
|
||||
identity: &Identity{
|
||||
ID: "1",
|
||||
Type: types.TypeUser,
|
||||
AccessTokenClaims: &authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||
Rest: authnlib.AccessTokenClaims{
|
||||
ServiceIdentity: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string][]string{
|
||||
"user-instance-role": {"None"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
extra := tt.identity.GetExtra()
|
||||
assert.Equal(t, tt.expected, extra)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentity_GetExtra_ServiceIdentityKey(t *testing.T) {
|
||||
// Test that the ServiceIdentityKey constant matches authlib's constant
|
||||
identity := &Identity{
|
||||
ID: "1",
|
||||
Type: types.TypeAccessPolicy,
|
||||
AccessTokenClaims: &authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||
Rest: authnlib.AccessTokenClaims{
|
||||
ServiceIdentity: "test-service",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
extra := identity.GetExtra()
|
||||
require.Contains(t, extra, string(authnlib.ServiceIdentityKey))
|
||||
assert.Equal(t, []string{"test-service"}, extra[string(authnlib.ServiceIdentityKey)])
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
[
|
||||
"CREATE TABLE `alert` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `version` INT64 NOT NULL, `dashboard_id` INT64 NOT NULL, `panel_id` INT64 NOT NULL, `org_id` INT64 NOT NULL, `name` STRING(255) NOT NULL, `message` STRING(MAX) NOT NULL, `state` STRING(190) NOT NULL, `settings` STRING(MAX), `frequency` INT64 NOT NULL, `handler` INT64 NOT NULL, `severity` STRING(MAX) NOT NULL, `silenced` BOOL NOT NULL, `execution_error` STRING(MAX) NOT NULL, `eval_data` STRING(MAX), `eval_date` TIMESTAMP, `new_state_date` TIMESTAMP NOT NULL, `state_changes` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `for` INT64) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_alert_dashboard_id` ON `alert` (dashboard_id)",
|
||||
"CREATE INDEX `IDX_alert_org_id_id` ON `alert` (org_id, id)",
|
||||
"CREATE INDEX `IDX_alert_state` ON `alert` (state)",
|
||||
"CREATE TABLE `alert_configuration` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `alertmanager_configuration` STRING(MAX), `configuration_version` STRING(3) NOT NULL, `created_at` INT64 NOT NULL, `default` BOOL NOT NULL DEFAULT (false), `org_id` INT64 NOT NULL DEFAULT (0), `configuration_hash` STRING(32) NOT NULL DEFAULT ('not-yet-calculated')) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_configuration_org_id` ON `alert_configuration` (org_id)",
|
||||
"CREATE TABLE `alert_configuration_history` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL DEFAULT (0), `alertmanager_configuration` STRING(MAX) NOT NULL, `configuration_hash` STRING(32) NOT NULL DEFAULT ('not-yet-calculated'), `configuration_version` STRING(3) NOT NULL, `created_at` INT64 NOT NULL, `default` BOOL NOT NULL DEFAULT (false), `last_applied` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `alert_image` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `token` STRING(190) NOT NULL, `path` STRING(190) NOT NULL, `url` STRING(2048) NOT NULL, `created_at` TIMESTAMP NOT NULL, `expires_at` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_image_token` ON `alert_image` (token)",
|
||||
"CREATE TABLE `alert_instance` (`rule_org_id` INT64 NOT NULL, `rule_uid` STRING(40) NOT NULL, `labels` STRING(MAX) NOT NULL, `labels_hash` STRING(190) NOT NULL, `current_state` STRING(190) NOT NULL, `current_state_since` INT64 NOT NULL, `last_eval_time` INT64 NOT NULL, `current_state_end` INT64 NOT NULL DEFAULT (0), `current_reason` STRING(190), `result_fingerprint` STRING(16), `resolved_at` INT64, `last_sent_at` INT64) PRIMARY KEY (rule_org_id,rule_uid,labels_hash)",
|
||||
"CREATE INDEX `IDX_alert_instance_rule_org_id_current_state` ON `alert_instance` (rule_org_id, current_state)",
|
||||
"CREATE INDEX `IDX_alert_instance_rule_org_id_rule_uid_current_state` ON `alert_instance` (rule_org_id, rule_uid, current_state)",
|
||||
"CREATE TABLE `alert_notification` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `name` STRING(190) NOT NULL, `type` STRING(255) NOT NULL, `settings` STRING(MAX) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `is_default` BOOL NOT NULL DEFAULT (false), `frequency` INT64, `send_reminder` BOOL DEFAULT (false), `disable_resolve_message` BOOL NOT NULL DEFAULT (false), `uid` STRING(40), `secure_settings` STRING(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_notification_org_id_uid` ON `alert_notification` (org_id, uid)",
|
||||
"CREATE TABLE `alert_notification_state` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `alert_id` INT64 NOT NULL, `notifier_id` INT64 NOT NULL, `state` STRING(50) NOT NULL, `version` INT64 NOT NULL, `updated_at` INT64 NOT NULL, `alert_rule_state_updated_version` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_alert_notification_state_alert_id` ON `alert_notification_state` (alert_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_notification_state_org_id_alert_id_notifier_id` ON `alert_notification_state` (org_id, alert_id, notifier_id)",
|
||||
"CREATE TABLE `alert_rule` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `title` STRING(190) NOT NULL, `condition` STRING(190) NOT NULL, `data` STRING(MAX), `updated` TIMESTAMP NOT NULL, `interval_seconds` INT64 NOT NULL DEFAULT (60), `version` INT64 NOT NULL DEFAULT (0), `uid` STRING(40) NOT NULL DEFAULT ('0'), `namespace_uid` STRING(40) NOT NULL, `rule_group` STRING(190) NOT NULL, `no_data_state` STRING(15) NOT NULL DEFAULT ('NoData'), `exec_err_state` STRING(15) NOT NULL DEFAULT ('Alerting'), `for` INT64 NOT NULL DEFAULT (0), `annotations` STRING(MAX), `labels` STRING(MAX), `dashboard_uid` STRING(40), `panel_id` INT64, `rule_group_idx` INT64 NOT NULL DEFAULT (1), `is_paused` BOOL NOT NULL DEFAULT (false), `notification_settings` STRING(MAX), `record` STRING(MAX), `metadata` STRING(MAX), `updated_by` STRING(40), `guid` STRING(36) NOT NULL DEFAULT (''), `missing_series_evals_to_resolve` INT64) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_alert_rule_org_id_dashboard_uid_panel_id` ON `alert_rule` (org_id, dashboard_uid, panel_id)",
|
||||
"CREATE INDEX `IDX_alert_rule_org_id_namespace_uid_rule_group` ON `alert_rule` (org_id, namespace_uid, rule_group)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_guid` ON `alert_rule` (guid)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_org_id_namespace_uid_title` ON `alert_rule` (org_id, namespace_uid, title)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_org_id_uid` ON `alert_rule` (org_id, uid)",
|
||||
"CREATE TABLE `alert_rule_state` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `rule_uid` STRING(40) NOT NULL, `data` BYTES(MAX) NOT NULL, `updated_at` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_state_org_id_rule_uid` ON `alert_rule_state` (org_id, rule_uid)",
|
||||
"CREATE TABLE `alert_rule_tag` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `alert_id` INT64 NOT NULL, `tag_id` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_alert_rule_tag_alert_id` ON `alert_rule_tag` (alert_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_tag_alert_id_tag_id` ON `alert_rule_tag` (alert_id, tag_id)",
|
||||
"CREATE TABLE `alert_rule_version` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `rule_org_id` INT64 NOT NULL, `rule_uid` STRING(40) NOT NULL DEFAULT ('0'), `rule_namespace_uid` STRING(40) NOT NULL, `rule_group` STRING(190) NOT NULL, `parent_version` INT64 NOT NULL, `restored_from` INT64 NOT NULL, `version` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `title` STRING(190) NOT NULL, `condition` STRING(190) NOT NULL, `data` STRING(MAX), `interval_seconds` INT64 NOT NULL, `no_data_state` STRING(15) NOT NULL DEFAULT ('NoData'), `exec_err_state` STRING(15) NOT NULL DEFAULT ('Alerting'), `for` INT64 NOT NULL DEFAULT (0), `annotations` STRING(MAX), `labels` STRING(MAX), `rule_group_idx` INT64 NOT NULL DEFAULT (1), `is_paused` BOOL NOT NULL DEFAULT (false), `notification_settings` STRING(MAX), `record` STRING(MAX), `metadata` STRING(MAX), `created_by` STRING(40), `rule_guid` STRING(36) NOT NULL DEFAULT (''), `missing_series_evals_to_resolve` INT64) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_alert_rule_version_rule_org_id_rule_namespace_uid_rule_group` ON `alert_rule_version` (rule_org_id, rule_namespace_uid, rule_group)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_version_rule_guid_version` ON `alert_rule_version` (rule_guid, version)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_version_rule_org_id_rule_uid_rule_guid_version` ON `alert_rule_version` (rule_org_id, rule_uid, rule_guid, version)",
|
||||
"CREATE TABLE `annotation` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `alert_id` INT64, `user_id` INT64, `dashboard_id` INT64, `panel_id` INT64, `category_id` INT64, `type` STRING(25) NOT NULL, `title` STRING(MAX) NOT NULL, `text` STRING(MAX) NOT NULL, `metric` STRING(255), `prev_state` STRING(40) NOT NULL, `new_state` STRING(40) NOT NULL, `data` STRING(MAX) NOT NULL, `epoch` INT64 NOT NULL, `region_id` INT64 DEFAULT (0), `tags` STRING(4096), `created` INT64 DEFAULT (0), `updated` INT64 DEFAULT (0), `epoch_end` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_annotation_alert_id` ON `annotation` (alert_id)",
|
||||
"CREATE INDEX `IDX_annotation_org_id_alert_id` ON `annotation` (org_id, alert_id)",
|
||||
"CREATE INDEX `IDX_annotation_org_id_created` ON `annotation` (org_id, created)",
|
||||
"CREATE INDEX `IDX_annotation_org_id_dashboard_id_epoch_end_epoch` ON `annotation` (org_id, dashboard_id, epoch_end, epoch)",
|
||||
"CREATE INDEX `IDX_annotation_org_id_epoch_end_epoch` ON `annotation` (org_id, epoch_end, epoch)",
|
||||
"CREATE INDEX `IDX_annotation_org_id_type` ON `annotation` (org_id, type)",
|
||||
"CREATE INDEX `IDX_annotation_org_id_updated` ON `annotation` (org_id, updated)",
|
||||
"CREATE TABLE `annotation_tag` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `annotation_id` INT64 NOT NULL, `tag_id` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_annotation_tag_annotation_id_tag_id` ON `annotation_tag` (annotation_id, tag_id)",
|
||||
"CREATE TABLE `anon_device` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `client_ip` STRING(255) NOT NULL, `created_at` TIMESTAMP NOT NULL, `device_id` STRING(127) NOT NULL, `updated_at` TIMESTAMP NOT NULL, `user_agent` STRING(255) NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_anon_device_updated_at` ON `anon_device` (updated_at)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_anon_device_device_id` ON `anon_device` (device_id)",
|
||||
"CREATE TABLE `api_key` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `name` STRING(190) NOT NULL, `key` STRING(190) NOT NULL, `role` STRING(255) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `expires` INT64, `service_account_id` INT64, `last_used_at` TIMESTAMP, `is_revoked` BOOL DEFAULT (false)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_api_key_org_id` ON `api_key` (org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_api_key_key` ON `api_key` (key)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_api_key_org_id_name` ON `api_key` (org_id, name)",
|
||||
"CREATE TABLE `autoincrement_sequences` (`name` STRING(128) NOT NULL, `next_value` INT64 NOT NULL) PRIMARY KEY (name)",
|
||||
"CREATE TABLE `builtin_role` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `role` STRING(190) NOT NULL, `role_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `org_id` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_builtin_role_org_id` ON `builtin_role` (org_id)",
|
||||
"CREATE INDEX `IDX_builtin_role_role_id` ON `builtin_role` (role_id)",
|
||||
"CREATE INDEX `IDX_builtin_role_role` ON `builtin_role` (role)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_builtin_role_org_id_role_id_role` ON `builtin_role` (org_id, role_id, role)",
|
||||
"CREATE TABLE `cache_data` (`cache_key` STRING(168) NOT NULL, `data` BYTES(MAX) NOT NULL, `expires` INT64 NOT NULL, `created_at` INT64 NOT NULL) PRIMARY KEY (cache_key)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_cache_data_cache_key` ON `cache_data` (cache_key)",
|
||||
"CREATE TABLE `cloud_migration_resource` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `uid` STRING(40) NOT NULL, `resource_type` STRING(40) NOT NULL, `resource_uid` STRING(255), `status` STRING(20) NOT NULL, `error_string` STRING(MAX), `snapshot_uid` STRING(40) NOT NULL, `name` STRING(MAX), `parent_name` STRING(MAX), `error_code` STRING(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `cloud_migration_session` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `uid` STRING(40), `auth_token` STRING(MAX), `slug` STRING(MAX) NOT NULL, `stack_id` INT64 NOT NULL, `region_slug` STRING(MAX) NOT NULL, `cluster_slug` STRING(MAX) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `org_id` INT64 NOT NULL DEFAULT (1)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_cloud_migration_session_uid` ON `cloud_migration_session` (uid)",
|
||||
"CREATE TABLE `cloud_migration_snapshot` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `uid` STRING(40), `session_uid` STRING(40), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `finished` TIMESTAMP, `upload_url` STRING(MAX), `status` STRING(MAX) NOT NULL, `local_directory` STRING(MAX), `gms_snapshot_uid` STRING(MAX), `encryption_key` STRING(MAX), `error_string` STRING(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_cloud_migration_snapshot_uid` ON `cloud_migration_snapshot` (uid)",
|
||||
"CREATE TABLE `correlation` (`uid` STRING(40) NOT NULL, `org_id` INT64 NOT NULL DEFAULT (0), `source_uid` STRING(40) NOT NULL, `target_uid` STRING(40), `label` STRING(MAX) NOT NULL, `description` STRING(MAX) NOT NULL, `config` STRING(MAX), `provisioned` BOOL NOT NULL DEFAULT (false), `type` STRING(40) NOT NULL DEFAULT ('query')) PRIMARY KEY (uid,org_id,source_uid)",
|
||||
"CREATE INDEX `IDX_correlation_org_id` ON `correlation` (org_id)",
|
||||
"CREATE INDEX `IDX_correlation_source_uid` ON `correlation` (source_uid)",
|
||||
"CREATE INDEX `IDX_correlation_uid` ON `correlation` (uid)",
|
||||
"CREATE TABLE `dashboard` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `version` INT64 NOT NULL, `slug` STRING(189) NOT NULL, `title` STRING(189) NOT NULL, `data` STRING(MAX) NOT NULL, `org_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `updated_by` INT64, `created_by` INT64, `gnet_id` INT64, `plugin_id` STRING(189), `folder_id` INT64 NOT NULL DEFAULT (0), `is_folder` BOOL NOT NULL DEFAULT (false), `has_acl` BOOL NOT NULL DEFAULT (false), `uid` STRING(40), `is_public` BOOL NOT NULL DEFAULT (false), `deleted` TIMESTAMP, `api_version` STRING(16), `folder_uid` STRING(40)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_deleted` ON `dashboard` (deleted)",
|
||||
"CREATE INDEX `IDX_dashboard_gnet_id` ON `dashboard` (gnet_id)",
|
||||
"CREATE INDEX `IDX_dashboard_is_folder` ON `dashboard` (is_folder)",
|
||||
"CREATE INDEX `IDX_dashboard_org_id_folder_id_title` ON `dashboard` (org_id, folder_id, title)",
|
||||
"CREATE INDEX `IDX_dashboard_org_id_plugin_id` ON `dashboard` (org_id, plugin_id)",
|
||||
"CREATE INDEX `IDX_dashboard_org_id` ON `dashboard` (org_id)",
|
||||
"CREATE INDEX `IDX_dashboard_title` ON `dashboard` (title)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_org_id_uid` ON `dashboard` (org_id, uid)",
|
||||
"CREATE TABLE `dashboard_acl` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `dashboard_id` INT64 NOT NULL, `user_id` INT64, `team_id` INT64, `permission` INT64 NOT NULL DEFAULT (4), `role` STRING(20), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_dashboard_id` ON `dashboard_acl` (dashboard_id)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_org_id_role` ON `dashboard_acl` (org_id, role)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_permission` ON `dashboard_acl` (permission)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_team_id` ON `dashboard_acl` (team_id)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_user_id` ON `dashboard_acl` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_acl_dashboard_id_team_id` ON `dashboard_acl` (dashboard_id, team_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_acl_dashboard_id_user_id` ON `dashboard_acl` (dashboard_id, user_id)",
|
||||
"CREATE TABLE `dashboard_provisioning` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `dashboard_id` INT64, `name` STRING(150) NOT NULL, `external_id` STRING(MAX) NOT NULL, `updated` INT64 NOT NULL DEFAULT (0), `check_sum` STRING(32)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_provisioning_dashboard_id_name` ON `dashboard_provisioning` (dashboard_id, name)",
|
||||
"CREATE INDEX `IDX_dashboard_provisioning_dashboard_id` ON `dashboard_provisioning` (dashboard_id)",
|
||||
"CREATE TABLE `dashboard_public` (`uid` STRING(40) NOT NULL, `dashboard_uid` STRING(40) NOT NULL, `org_id` INT64 NOT NULL, `time_settings` STRING(MAX), `template_variables` STRING(MAX), `access_token` STRING(32) NOT NULL, `created_by` INT64 NOT NULL, `updated_by` INT64, `created_at` TIMESTAMP NOT NULL, `updated_at` TIMESTAMP, `is_enabled` BOOL NOT NULL DEFAULT (false), `annotations_enabled` BOOL NOT NULL DEFAULT (false), `time_selection_enabled` BOOL NOT NULL DEFAULT (false), `share` STRING(64) NOT NULL DEFAULT ('public')) PRIMARY KEY (uid)",
|
||||
"CREATE INDEX `IDX_dashboard_public_config_org_id_dashboard_uid` ON `dashboard_public` (org_id, dashboard_uid)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_public_config_access_token` ON `dashboard_public` (access_token)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_public_config_uid` ON `dashboard_public` (uid)",
|
||||
"CREATE TABLE `dashboard_public_email_share` (`uid` STRING(40) NOT NULL, `public_dashboard_uid` STRING(64) NOT NULL, `recipient` STRING(255) NOT NULL, `type` STRING(64) NOT NULL DEFAULT ('email'), `created_at` TIMESTAMP NOT NULL, `updated_at` TIMESTAMP NOT NULL) PRIMARY KEY (uid)",
|
||||
"CREATE TABLE `dashboard_public_magic_link` (`uid` STRING(40) NOT NULL, `token_uuid` STRING(64) NOT NULL, `public_dashboard_uid` STRING(64) NOT NULL, `email` STRING(255) NOT NULL, `created_at` TIMESTAMP NOT NULL, `updated_at` TIMESTAMP NOT NULL) PRIMARY KEY (uid)",
|
||||
"CREATE TABLE `dashboard_public_session` (`uid` STRING(40) NOT NULL, `cookie_uuid` STRING(64) NOT NULL, `public_dashboard_uid` STRING(64) NOT NULL, `email` STRING(255) NOT NULL, `created_at` TIMESTAMP NOT NULL, `updated_at` TIMESTAMP NOT NULL, `last_seen_at` TIMESTAMP) PRIMARY KEY (uid)",
|
||||
"CREATE TABLE `dashboard_public_usage_by_day` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `public_dashboard_uid` STRING(255) NOT NULL, `day` STRING(40) NOT NULL, `views` INT64 NOT NULL, `queries` INT64 NOT NULL, `errors` INT64 NOT NULL, `load_duration` FLOAT64 NOT NULL, `cached_queries` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `dashboard_snapshot` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `name` STRING(255) NOT NULL, `key` STRING(190) NOT NULL, `delete_key` STRING(190) NOT NULL, `org_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `external` BOOL NOT NULL, `external_url` STRING(255) NOT NULL, `dashboard` STRING(MAX) NOT NULL, `expires` TIMESTAMP NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `external_delete_url` STRING(255), `dashboard_encrypted` BYTES(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_snapshot_user_id` ON `dashboard_snapshot` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_snapshot_delete_key` ON `dashboard_snapshot` (delete_key)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_snapshot_key` ON `dashboard_snapshot` (key)",
|
||||
"CREATE TABLE `dashboard_tag` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `dashboard_id` INT64 NOT NULL, `term` STRING(50) NOT NULL, `dashboard_uid` STRING(40), `org_id` INT64 DEFAULT (1)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_tag_dashboard_id` ON `dashboard_tag` (dashboard_id)",
|
||||
"CREATE TABLE `dashboard_usage_by_day` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `dashboard_id` INT64 NOT NULL, `day` STRING(40) NOT NULL, `views` INT64 NOT NULL, `queries` INT64 NOT NULL, `errors` INT64 NOT NULL, `load_duration` FLOAT64 NOT NULL, `cached_queries` INT64 NOT NULL DEFAULT (0), `dashboard_uid` STRING(40), `org_id` INT64 DEFAULT (1)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_usage_by_day_dashboard_id` ON `dashboard_usage_by_day` (dashboard_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_usage_by_day_dashboard_id_day` ON `dashboard_usage_by_day` (dashboard_id, day)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_usage_by_day_dashboard_uid_org_id_day` ON `dashboard_usage_by_day` (dashboard_uid, org_id, day)",
|
||||
"CREATE TABLE `dashboard_usage_sums` (`dashboard_id` INT64 NOT NULL, `updated` TIMESTAMP NOT NULL, `views_last_1_days` INT64 NOT NULL, `views_last_7_days` INT64 NOT NULL, `views_last_30_days` INT64 NOT NULL, `views_total` INT64 NOT NULL, `queries_last_1_days` INT64 NOT NULL, `queries_last_7_days` INT64 NOT NULL, `queries_last_30_days` INT64 NOT NULL, `queries_total` INT64 NOT NULL, `errors_last_1_days` INT64 NOT NULL DEFAULT (0), `errors_last_7_days` INT64 NOT NULL DEFAULT (0), `errors_last_30_days` INT64 NOT NULL DEFAULT (0), `errors_total` INT64 NOT NULL DEFAULT (0), `dashboard_uid` STRING(40), `org_id` INT64 DEFAULT (1)) PRIMARY KEY (dashboard_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_usage_sums_org_id_dashboard_uid` ON `dashboard_usage_sums` (org_id, dashboard_uid)",
|
||||
"CREATE TABLE `dashboard_version` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `dashboard_id` INT64 NOT NULL, `parent_version` INT64 NOT NULL, `restored_from` INT64 NOT NULL, `version` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `created_by` INT64 NOT NULL, `message` STRING(MAX) NOT NULL, `data` STRING(MAX), `api_version` STRING(16)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_version_dashboard_id` ON `dashboard_version` (dashboard_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_version_dashboard_id_version` ON `dashboard_version` (dashboard_id, version)",
|
||||
"CREATE TABLE `data_keys` (`name` STRING(100) NOT NULL, `active` BOOL NOT NULL, `scope` STRING(30) NOT NULL, `provider` STRING(50) NOT NULL, `encrypted_data` BYTES(MAX) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `label` STRING(100)) PRIMARY KEY (name)",
|
||||
"CREATE TABLE `data_source` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `version` INT64 NOT NULL, `type` STRING(255) NOT NULL, `name` STRING(190) NOT NULL, `access` STRING(255) NOT NULL, `url` STRING(255) NOT NULL, `password` STRING(255), `user` STRING(255), `database` STRING(255), `basic_auth` BOOL NOT NULL, `basic_auth_user` STRING(255), `basic_auth_password` STRING(255), `is_default` BOOL NOT NULL, `json_data` STRING(MAX), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `with_credentials` BOOL NOT NULL DEFAULT (false), `secure_json_data` STRING(MAX), `read_only` BOOL, `uid` STRING(40) NOT NULL DEFAULT ('0'), `is_prunable` BOOL DEFAULT (false), `api_version` STRING(20)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_data_source_org_id_is_default` ON `data_source` (org_id, is_default)",
|
||||
"CREATE INDEX `IDX_data_source_org_id` ON `data_source` (org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_data_source_org_id_name` ON `data_source` (org_id, name)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_data_source_org_id_uid` ON `data_source` (org_id, uid)",
|
||||
"CREATE TABLE `data_source_acl` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `data_source_id` INT64 NOT NULL, `team_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `permission` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_data_source_acl_data_source_id` ON `data_source_acl` (data_source_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_data_source_acl_data_source_id_team_id_user_id` ON `data_source_acl` (data_source_id, team_id, user_id)",
|
||||
"CREATE TABLE `data_source_cache` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `data_source_id` INT64 NOT NULL, `enabled` BOOL NOT NULL, `ttl_ms` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `use_default_ttl` BOOL NOT NULL DEFAULT (true), `data_source_uid` STRING(40) NOT NULL DEFAULT ('0'), `ttl_resources_ms` INT64 NOT NULL DEFAULT (300000)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_data_source_cache_data_source_id` ON `data_source_cache` (data_source_id)",
|
||||
"CREATE INDEX `IDX_data_source_cache_data_source_uid` ON `data_source_cache` (data_source_uid)",
|
||||
"CREATE TABLE `data_source_usage_by_day` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `data_source_id` INT64 NOT NULL, `day` STRING(40) NOT NULL, `queries` INT64 NOT NULL, `errors` INT64 NOT NULL, `load_duration_ms` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_data_source_usage_by_day_data_source_id` ON `data_source_usage_by_day` (data_source_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_data_source_usage_by_day_data_source_id_day` ON `data_source_usage_by_day` (data_source_id, day)",
|
||||
"CREATE TABLE `entity_event` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `entity_id` STRING(1024) NOT NULL, `event_type` STRING(8) NOT NULL, `created` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `file` (`path` STRING(1024) NOT NULL, `path_hash` STRING(64) NOT NULL, `parent_folder_path_hash` STRING(64) NOT NULL, `contents` BYTES(MAX), `etag` STRING(32) NOT NULL, `cache_control` STRING(128) NOT NULL, `content_disposition` STRING(128) NOT NULL, `updated` TIMESTAMP NOT NULL, `created` TIMESTAMP NOT NULL, `size` INT64 NOT NULL, `mime_type` STRING(255) NOT NULL) PRIMARY KEY (path_hash)",
|
||||
"CREATE INDEX `IDX_file_parent_folder_path_hash` ON `file` (parent_folder_path_hash)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_file_path_hash` ON `file` (path_hash)",
|
||||
"CREATE TABLE `file_meta` (`path_hash` STRING(64) NOT NULL, `key` STRING(191) NOT NULL, `value` STRING(1024) NOT NULL) PRIMARY KEY (path_hash,key)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_file_meta_path_hash_key` ON `file_meta` (path_hash, key)",
|
||||
"CREATE TABLE `folder` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `uid` STRING(40) NOT NULL, `org_id` INT64 NOT NULL, `title` STRING(189) NOT NULL, `description` STRING(255), `parent_uid` STRING(40), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_folder_org_id_uid` ON `folder` (org_id, uid)",
|
||||
"CREATE TABLE `kv_store` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `namespace` STRING(190) NOT NULL, `key` STRING(190) NOT NULL, `value` STRING(MAX) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_kv_store_org_id_namespace_key` ON `kv_store` (org_id, namespace, key)",
|
||||
"CREATE TABLE `library_element` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `folder_id` INT64 NOT NULL, `uid` STRING(40) NOT NULL, `name` STRING(150) NOT NULL, `kind` INT64 NOT NULL, `type` STRING(40) NOT NULL, `description` STRING(2048) NOT NULL, `model` STRING(MAX) NOT NULL, `created` TIMESTAMP NOT NULL, `created_by` INT64 NOT NULL, `updated` TIMESTAMP NOT NULL, `updated_by` INT64 NOT NULL, `version` INT64 NOT NULL, `folder_uid` STRING(40)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_library_element_org_id_folder_id_name_kind` ON `library_element` (org_id, folder_id, name, kind)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_library_element_org_id_folder_uid_name_kind` ON `library_element` (org_id, folder_uid, name, kind)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_library_element_org_id_uid` ON `library_element` (org_id, uid)",
|
||||
"CREATE TABLE `library_element_connection` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `element_id` INT64 NOT NULL, `kind` INT64 NOT NULL, `connection_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `created_by` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_library_element_connection_element_id_kind_connection_id` ON `library_element_connection` (element_id, kind, connection_id)",
|
||||
"CREATE TABLE `license_token` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `token` STRING(MAX) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `login_attempt` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `username` STRING(190) NOT NULL, `ip_address` STRING(30) NOT NULL, `created` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_login_attempt_username` ON `login_attempt` (username)",
|
||||
"CREATE TABLE `migration_log` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `migration_id` STRING(255) NOT NULL, `sql` STRING(MAX) NOT NULL, `success` BOOL NOT NULL, `error` STRING(MAX) NOT NULL, `timestamp` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `ngalert_configuration` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `alertmanagers` STRING(MAX), `created_at` INT64 NOT NULL, `updated_at` INT64 NOT NULL, `send_alerts_to` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_ngalert_configuration_org_id` ON `ngalert_configuration` (org_id)",
|
||||
"CREATE TABLE `org` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `version` INT64 NOT NULL, `name` STRING(190) NOT NULL, `address1` STRING(255), `address2` STRING(255), `city` STRING(255), `state` STRING(255), `zip_code` STRING(50), `country` STRING(255), `billing_email` STRING(255), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_org_name` ON `org` (name)",
|
||||
"CREATE TABLE `org_user` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `role` STRING(20) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_org_user_org_id` ON `org_user` (org_id)",
|
||||
"CREATE INDEX `IDX_org_user_user_id` ON `org_user` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_org_user_org_id_user_id` ON `org_user` (org_id, user_id)",
|
||||
"CREATE TABLE `permission` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `role_id` INT64 NOT NULL, `action` STRING(190) NOT NULL, `scope` STRING(190) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `kind` STRING(40) NOT NULL DEFAULT (''), `attribute` STRING(40) NOT NULL DEFAULT (''), `identifier` STRING(40) NOT NULL DEFAULT ('')) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_permission_identifier` ON `permission` (identifier)",
|
||||
"CREATE INDEX `IDX_permission_role_id` ON `permission` (role_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_permission_action_scope_role_id` ON `permission` (action, scope, role_id)",
|
||||
"CREATE TABLE `playlist` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `name` STRING(255) NOT NULL, `interval` STRING(255) NOT NULL, `org_id` INT64 NOT NULL, `created_at` INT64 NOT NULL DEFAULT (0), `updated_at` INT64 NOT NULL DEFAULT (0), `uid` STRING(80) NOT NULL DEFAULT ('0')) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_playlist_org_id_uid` ON `playlist` (org_id, uid)",
|
||||
"CREATE TABLE `playlist_item` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `playlist_id` INT64 NOT NULL, `type` STRING(255) NOT NULL, `value` STRING(MAX) NOT NULL, `title` STRING(MAX) NOT NULL, `order` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `plugin_setting` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL DEFAULT (1), `plugin_id` STRING(190) NOT NULL, `enabled` BOOL NOT NULL, `pinned` BOOL NOT NULL, `json_data` STRING(MAX), `secure_json_data` STRING(MAX), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `plugin_version` STRING(50)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_plugin_setting_org_id_plugin_id` ON `plugin_setting` (org_id, plugin_id)",
|
||||
"CREATE TABLE `preferences` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `version` INT64 NOT NULL, `home_dashboard_id` INT64 NOT NULL, `timezone` STRING(50) NOT NULL, `theme` STRING(20) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `team_id` INT64, `week_start` STRING(10), `json_data` STRING(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_preferences_org_id` ON `preferences` (org_id)",
|
||||
"CREATE INDEX `IDX_preferences_user_id` ON `preferences` (user_id)",
|
||||
"CREATE TABLE `provenance_type` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `record_key` STRING(190) NOT NULL, `record_type` STRING(190) NOT NULL, `provenance` STRING(190) NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_provenance_type_record_type_record_key_org_id` ON `provenance_type` (record_type, record_key, org_id)",
|
||||
"CREATE TABLE `query_history` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `uid` STRING(40) NOT NULL, `org_id` INT64 NOT NULL, `datasource_uid` STRING(40) NOT NULL, `created_by` INT64, `created_at` INT64 NOT NULL, `comment` STRING(MAX) NOT NULL, `queries` STRING(MAX) NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_query_history_org_id_created_by_datasource_uid` ON `query_history` (org_id, created_by, datasource_uid)",
|
||||
"CREATE TABLE `query_history_details` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `query_history_item_uid` STRING(40) NOT NULL, `datasource_uid` STRING(40) NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `query_history_star` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `query_uid` STRING(40) NOT NULL, `user_id` INT64, `org_id` INT64 NOT NULL DEFAULT (1)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_query_history_star_user_id_query_uid` ON `query_history_star` (user_id, query_uid)",
|
||||
"CREATE TABLE `quota` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64, `user_id` INT64, `target` STRING(190) NOT NULL, `limit` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_quota_org_id_user_id_target` ON `quota` (org_id, user_id, target)",
|
||||
"CREATE TABLE `recording_rules` (`id` STRING(128) NOT NULL, `target_ref_id` STRING(128) NOT NULL, `name` STRING(128) NOT NULL, `description` STRING(MAX) NOT NULL, `org_id` INT64 NOT NULL, `interval` INT64 NOT NULL, `range` INT64 NOT NULL, `active` BOOL NOT NULL DEFAULT (false), `count` BOOL NOT NULL DEFAULT (false), `queries` BYTES(MAX) NOT NULL, `created_at` TIMESTAMP NOT NULL, `prom_name` STRING(128)) PRIMARY KEY (id,target_ref_id)",
|
||||
"CREATE TABLE `remote_write_targets` (`id` STRING(128) NOT NULL, `data_source_uid` STRING(128) NOT NULL, `write_path` STRING(128) NOT NULL, `org_id` INT64 NOT NULL) PRIMARY KEY (id,data_source_uid,write_path)",
|
||||
"CREATE TABLE `report` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `org_id` INT64 NOT NULL, `dashboard_id` INT64 NOT NULL, `name` STRING(MAX) NOT NULL, `recipients` STRING(MAX) NOT NULL, `reply_to` STRING(MAX), `message` STRING(MAX), `schedule_frequency` STRING(32) NOT NULL, `schedule_day` STRING(32) NOT NULL, `schedule_hour` INT64 NOT NULL, `schedule_minute` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `schedule_timezone` STRING(50) NOT NULL DEFAULT ('Europe/Stockholm'), `time_from` STRING(255), `time_to` STRING(255), `pdf_landscape` BOOL, `schedule_day_of_month` STRING(32), `pdf_layout` STRING(255), `pdf_orientation` STRING(32), `dashboard_uid` STRING(40), `template_vars` STRING(MAX), `enable_dashboard_url` BOOL, `state` STRING(32), `enable_csv` BOOL, `schedule_start` INT64, `schedule_end` INT64, `schedule_interval_frequency` STRING(32), `schedule_interval_amount` INT64, `schedule_workdays_only` BOOL, `formats` STRING(190) NOT NULL DEFAULT ('[\"pdf\"]'), `scale_factor` INT64 NOT NULL DEFAULT (2), `uid` STRING(40), `pdf_show_template_variables` BOOL NOT NULL DEFAULT (false), `pdf_combine_one_file` BOOL NOT NULL DEFAULT (true), `subject` STRING(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_report_dashboard_id` ON `report` (dashboard_id)",
|
||||
"CREATE INDEX `IDX_report_org_id` ON `report` (org_id)",
|
||||
"CREATE INDEX `IDX_report_user_id` ON `report` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_report_org_id_uid` ON `report` (org_id, uid)",
|
||||
"CREATE TABLE `report_dashboards` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `report_id` INT64 NOT NULL, `dashboard_uid` STRING(40) NOT NULL DEFAULT (''), `report_variables` STRING(MAX), `time_to` STRING(255), `time_from` STRING(255), `created` TIMESTAMP) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_report_dashboards_report_id` ON `report_dashboards` (report_id)",
|
||||
"CREATE TABLE `report_settings` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `org_id` INT64 NOT NULL, `branding_report_logo_url` STRING(MAX), `branding_email_logo_url` STRING(MAX), `branding_email_footer_link` STRING(MAX), `branding_email_footer_text` STRING(MAX), `branding_email_footer_mode` STRING(50), `pdf_theme` STRING(40) NOT NULL DEFAULT ('light'), `embedded_image_theme` STRING(40) NOT NULL DEFAULT ('dark')) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `role` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `name` STRING(190) NOT NULL, `description` STRING(MAX), `version` INT64 NOT NULL, `org_id` INT64 NOT NULL, `uid` STRING(40) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `display_name` STRING(190), `group_name` STRING(190), `hidden` BOOL NOT NULL DEFAULT (false)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_role_org_id` ON `role` (org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_role_org_id_name` ON `role` (org_id, name)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_role_uid` ON `role` (uid)",
|
||||
"CREATE TABLE `secrets` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `namespace` STRING(255) NOT NULL, `type` STRING(255) NOT NULL, `value` STRING(MAX), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `seed_assignment` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `builtin_role` STRING(190) NOT NULL, `role_name` STRING(190), `action` STRING(190), `scope` STRING(190), `origin` STRING(190)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_seed_assignment_builtin_role_action_scope` ON `seed_assignment` (builtin_role, action, scope)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_seed_assignment_builtin_role_role_name` ON `seed_assignment` (builtin_role, role_name)",
|
||||
"CREATE TABLE `server_lock` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `operation_uid` STRING(100) NOT NULL, `version` INT64 NOT NULL, `last_execution` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_server_lock_operation_uid` ON `server_lock` (operation_uid)",
|
||||
"CREATE TABLE `session` (`key` STRING(16) NOT NULL, `data` BYTES(MAX) NOT NULL, `expiry` INT64 NOT NULL) PRIMARY KEY (key)",
|
||||
"CREATE TABLE `setting` (`section` STRING(100) NOT NULL, `key` STRING(100) NOT NULL, `value` STRING(MAX) NOT NULL, `encrypted_value` STRING(MAX)) PRIMARY KEY (section,key)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_setting_section_key` ON `setting` (section, key)",
|
||||
"CREATE TABLE `short_url` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `uid` STRING(40) NOT NULL, `path` STRING(MAX) NOT NULL, `created_by` INT64, `created_at` INT64 NOT NULL, `last_seen_at` INT64) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_short_url_org_id_uid` ON `short_url` (org_id, uid)",
|
||||
"CREATE TABLE `signing_key` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `key_id` STRING(255) NOT NULL, `private_key` STRING(MAX) NOT NULL, `added_at` TIMESTAMP NOT NULL, `expires_at` TIMESTAMP, `alg` STRING(255) NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_signing_key_key_id` ON `signing_key` (key_id)",
|
||||
"CREATE TABLE `sso_setting` (`id` STRING(40) NOT NULL, `provider` STRING(255) NOT NULL, `settings` STRING(MAX) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `is_deleted` BOOL NOT NULL DEFAULT (false)) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `star` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `dashboard_id` INT64 NOT NULL, `dashboard_uid` STRING(40), `org_id` INT64 DEFAULT (1), `updated` TIMESTAMP) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_star_user_id_dashboard_id` ON `star` (user_id, dashboard_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_star_user_id_dashboard_uid_org_id` ON `star` (user_id, dashboard_uid, org_id)",
|
||||
"CREATE TABLE `tag` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `key` STRING(100) NOT NULL, `value` STRING(100) NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_tag_key_value` ON `tag` (key, value)",
|
||||
"CREATE TABLE `team` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `name` STRING(190) NOT NULL, `org_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `uid` STRING(40), `email` STRING(190)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_team_org_id` ON `team` (org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_org_id_name` ON `team` (org_id, name)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_org_id_uid` ON `team` (org_id, uid)",
|
||||
"CREATE TABLE `team_group` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `team_id` INT64 NOT NULL, `group_id` STRING(190) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_team_group_group_id` ON `team_group` (group_id)",
|
||||
"CREATE INDEX `IDX_team_group_org_id` ON `team_group` (org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_group_org_id_team_id_group_id` ON `team_group` (org_id, team_id, group_id)",
|
||||
"CREATE TABLE `team_member` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `team_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `external` BOOL, `permission` INT64) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_team_member_org_id` ON `team_member` (org_id)",
|
||||
"CREATE INDEX `IDX_team_member_team_id` ON `team_member` (team_id)",
|
||||
"CREATE INDEX `IDX_team_member_user_id_org_id` ON `team_member` (user_id, org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_member_org_id_team_id_user_id` ON `team_member` (org_id, team_id, user_id)",
|
||||
"CREATE TABLE `team_role` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `team_id` INT64 NOT NULL, `role_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_team_role_org_id` ON `team_role` (org_id)",
|
||||
"CREATE INDEX `IDX_team_role_team_id` ON `team_role` (team_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_role_org_id_team_id_role_id` ON `team_role` (org_id, team_id, role_id)",
|
||||
"CREATE TABLE `temp_user` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `version` INT64 NOT NULL, `email` STRING(190) NOT NULL, `name` STRING(255), `role` STRING(20), `code` STRING(190) NOT NULL, `status` STRING(20) NOT NULL, `invited_by_user_id` INT64, `email_sent` BOOL NOT NULL, `email_sent_on` TIMESTAMP, `remote_addr` STRING(255), `created` INT64 NOT NULL DEFAULT (0), `updated` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_temp_user_code` ON `temp_user` (code)",
|
||||
"CREATE INDEX `IDX_temp_user_email` ON `temp_user` (email)",
|
||||
"CREATE INDEX `IDX_temp_user_org_id` ON `temp_user` (org_id)",
|
||||
"CREATE INDEX `IDX_temp_user_status` ON `temp_user` (status)",
|
||||
"CREATE TABLE `test_data` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `metric1` STRING(20), `metric2` STRING(150), `value_big_int` INT64, `value_double` FLOAT64, `value_float` FLOAT64, `value_int` INT64, `time_epoch` INT64 NOT NULL, `time_date_time` TIMESTAMP NOT NULL, `time_time_stamp` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `user` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `version` INT64 NOT NULL, `login` STRING(190) NOT NULL, `email` STRING(190) NOT NULL, `name` STRING(255), `password` STRING(255), `salt` STRING(50), `rands` STRING(50), `company` STRING(255), `org_id` INT64 NOT NULL, `is_admin` BOOL NOT NULL, `email_verified` BOOL, `theme` STRING(255), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `help_flags1` INT64 NOT NULL DEFAULT (0), `last_seen_at` TIMESTAMP, `is_disabled` BOOL NOT NULL DEFAULT (false), `is_service_account` BOOL DEFAULT (false), `uid` STRING(40), `is_provisioned` BOOL NOT NULL DEFAULT (false)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_user_login_email` ON `user` (login, email)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_email` ON `user` (email)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_login` ON `user` (login)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_uid` ON `user` (uid)",
|
||||
"CREATE TABLE `user_auth` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `auth_module` STRING(190) NOT NULL, `auth_id` STRING(190), `created` TIMESTAMP NOT NULL, `o_auth_access_token` STRING(MAX), `o_auth_refresh_token` STRING(MAX), `o_auth_token_type` STRING(MAX), `o_auth_expiry` TIMESTAMP, `o_auth_id_token` STRING(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_user_auth_auth_module_auth_id` ON `user_auth` (auth_module, auth_id)",
|
||||
"CREATE INDEX `IDX_user_auth_user_id` ON `user_auth` (user_id)",
|
||||
"CREATE TABLE `user_auth_token` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `auth_token` STRING(100) NOT NULL, `prev_auth_token` STRING(100) NOT NULL, `user_agent` STRING(255) NOT NULL, `client_ip` STRING(255) NOT NULL, `auth_token_seen` BOOL NOT NULL, `seen_at` INT64, `rotated_at` INT64 NOT NULL, `created_at` INT64 NOT NULL, `updated_at` INT64 NOT NULL, `revoked_at` INT64, `external_session_id` INT64) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_user_auth_token_revoked_at` ON `user_auth_token` (revoked_at)",
|
||||
"CREATE INDEX `IDX_user_auth_token_user_id` ON `user_auth_token` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_auth_token_auth_token` ON `user_auth_token` (auth_token)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_auth_token_prev_auth_token` ON `user_auth_token` (prev_auth_token)",
|
||||
"CREATE TABLE `user_dashboard_views` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `dashboard_id` INT64 NOT NULL, `viewed` TIMESTAMP NOT NULL, `org_id` INT64, `dashboard_uid` STRING(40)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_user_dashboard_views_dashboard_id` ON `user_dashboard_views` (dashboard_id)",
|
||||
"CREATE INDEX `IDX_user_dashboard_views_org_id_dashboard_uid` ON `user_dashboard_views` (org_id, dashboard_uid)",
|
||||
"CREATE INDEX `IDX_user_dashboard_views_user_id` ON `user_dashboard_views` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_dashboard_views_user_id_dashboard_id` ON `user_dashboard_views` (user_id, dashboard_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_dashboard_views_user_id_org_id_dashboard_uid` ON `user_dashboard_views` (user_id, org_id, dashboard_uid)",
|
||||
"CREATE TABLE `user_external_session` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_auth_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `auth_module` STRING(190) NOT NULL, `access_token` STRING(MAX), `id_token` STRING(MAX), `refresh_token` STRING(MAX), `session_id` STRING(1024), `session_id_hash` STRING(44), `name_id` STRING(1024), `name_id_hash` STRING(44), `expires_at` TIMESTAMP, `created_at` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `user_role` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `role_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `group_mapping_uid` STRING(40) DEFAULT ('')) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_user_role_org_id` ON `user_role` (org_id)",
|
||||
"CREATE INDEX `IDX_user_role_user_id` ON `user_role` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_role_org_id_user_id_role_id_group_mapping_uid` ON `user_role` (org_id, user_id, role_id, group_mapping_uid)",
|
||||
"CREATE TABLE `user_stats` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `billing_role` STRING(40) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_stats_user_id` ON `user_stats` (user_id)",
|
||||
"CREATE TABLE resource ( namespace STRING(63), resource_group STRING(190), resource STRING(190), name STRING(253), folder STRING(253), value BYTES(MAX), resource_version TIMESTAMP NOT NULL OPTIONS ( allow_commit_timestamp = true ), previous_resource_version TIMESTAMP, ) PRIMARY KEY (namespace, resource_group, resource, name)",
|
||||
"CREATE TABLE resource_history ( namespace STRING(63), resource_group STRING(190), resource STRING(190), name STRING(253), folder STRING(253), value BYTES(MAX), resource_version TIMESTAMP NOT NULL OPTIONS ( allow_commit_timestamp = true ), previous_resource_version TIMESTAMP, action INT64, ) PRIMARY KEY (namespace, resource_group, resource, name, resource_version DESC)",
|
||||
"CREATE TABLE resource_blob ( uid STRING(36) NOT NULL, resource_key STRING(MAX) NOT NULL, content_type STRING(100), value BYTES(MAX), ) PRIMARY KEY (uid)",
|
||||
"CREATE CHANGE STREAM resource_stream FOR resource"
|
||||
]
|
||||
@@ -1,761 +0,0 @@
|
||||
[
|
||||
"create migration_log table",
|
||||
"create user table",
|
||||
"add unique index user.login",
|
||||
"add unique index user.email",
|
||||
"drop index UQE_user_login - v1",
|
||||
"drop index UQE_user_email - v1",
|
||||
"Rename table user to user_v1 - v1",
|
||||
"create user table v2",
|
||||
"create index UQE_user_login - v2",
|
||||
"create index UQE_user_email - v2",
|
||||
"copy data_source v1 to v2",
|
||||
"Drop old table user_v1",
|
||||
"Add column help_flags1 to user table",
|
||||
"Update user table charset",
|
||||
"Add last_seen_at column to user",
|
||||
"Add missing user data",
|
||||
"Add is_disabled column to user",
|
||||
"Add index user.login/user.email",
|
||||
"Add is_service_account column to user",
|
||||
"Update is_service_account column to nullable",
|
||||
"Add uid column to user",
|
||||
"Update uid column values for users",
|
||||
"Add unique index user_uid",
|
||||
"Add is_provisioned column to user",
|
||||
"update login field with orgid to allow for multiple service accounts with same name across orgs",
|
||||
"update service accounts login field orgid to appear only once",
|
||||
"update login and email fields to lowercase",
|
||||
"update login and email fields to lowercase2",
|
||||
"create temp user table v1-7",
|
||||
"create index IDX_temp_user_email - v1-7",
|
||||
"create index IDX_temp_user_org_id - v1-7",
|
||||
"create index IDX_temp_user_code - v1-7",
|
||||
"create index IDX_temp_user_status - v1-7",
|
||||
"Update temp_user table charset",
|
||||
"drop index IDX_temp_user_email - v1",
|
||||
"drop index IDX_temp_user_org_id - v1",
|
||||
"drop index IDX_temp_user_code - v1",
|
||||
"drop index IDX_temp_user_status - v1",
|
||||
"Rename table temp_user to temp_user_tmp_qwerty - v1",
|
||||
"create temp_user v2",
|
||||
"create index IDX_temp_user_email - v2",
|
||||
"create index IDX_temp_user_org_id - v2",
|
||||
"create index IDX_temp_user_code - v2",
|
||||
"create index IDX_temp_user_status - v2",
|
||||
"copy temp_user v1 to v2",
|
||||
"drop temp_user_tmp_qwerty",
|
||||
"Set created for temp users that will otherwise prematurely expire",
|
||||
"create star table",
|
||||
"add unique index star.user_id_dashboard_id",
|
||||
"Add column dashboard_uid in star",
|
||||
"Add column org_id in star",
|
||||
"Add column updated in star",
|
||||
"add index in star table on dashboard_uid, org_id and user_id columns",
|
||||
"create org table v1",
|
||||
"create index UQE_org_name - v1",
|
||||
"create org_user table v1",
|
||||
"create index IDX_org_user_org_id - v1",
|
||||
"create index UQE_org_user_org_id_user_id - v1",
|
||||
"create index IDX_org_user_user_id - v1",
|
||||
"Update org table charset",
|
||||
"Update org_user table charset",
|
||||
"Migrate all Read Only Viewers to Viewers",
|
||||
"create dashboard table",
|
||||
"add index dashboard.account_id",
|
||||
"add unique index dashboard_account_id_slug",
|
||||
"create dashboard_tag table",
|
||||
"add unique index dashboard_tag.dasboard_id_term",
|
||||
"drop index UQE_dashboard_tag_dashboard_id_term - v1",
|
||||
"Rename table dashboard to dashboard_v1 - v1",
|
||||
"create dashboard v2",
|
||||
"create index IDX_dashboard_org_id - v2",
|
||||
"create index UQE_dashboard_org_id_slug - v2",
|
||||
"copy dashboard v1 to v2",
|
||||
"drop table dashboard_v1",
|
||||
"alter dashboard.data to mediumtext v1",
|
||||
"Add column updated_by in dashboard - v2",
|
||||
"Add column created_by in dashboard - v2",
|
||||
"Add column gnetId in dashboard",
|
||||
"Add index for gnetId in dashboard",
|
||||
"Add column plugin_id in dashboard",
|
||||
"Add index for plugin_id in dashboard",
|
||||
"Add index for dashboard_id in dashboard_tag",
|
||||
"Update dashboard table charset",
|
||||
"Update dashboard_tag table charset",
|
||||
"Add column folder_id in dashboard",
|
||||
"Add column isFolder in dashboard",
|
||||
"Add column has_acl in dashboard",
|
||||
"Add column uid in dashboard",
|
||||
"Update uid column values in dashboard",
|
||||
"Add unique index dashboard_org_id_uid",
|
||||
"Remove unique index org_id_slug",
|
||||
"Update dashboard title length",
|
||||
"Add unique index for dashboard_org_id_title_folder_id",
|
||||
"create dashboard_provisioning",
|
||||
"Rename table dashboard_provisioning to dashboard_provisioning_tmp_qwerty - v1",
|
||||
"create dashboard_provisioning v2",
|
||||
"create index IDX_dashboard_provisioning_dashboard_id - v2",
|
||||
"create index IDX_dashboard_provisioning_dashboard_id_name - v2",
|
||||
"copy dashboard_provisioning v1 to v2",
|
||||
"drop dashboard_provisioning_tmp_qwerty",
|
||||
"Add check_sum column",
|
||||
"Add index for dashboard_title",
|
||||
"delete tags for deleted dashboards",
|
||||
"delete stars for deleted dashboards",
|
||||
"Add index for dashboard_is_folder",
|
||||
"Add isPublic for dashboard",
|
||||
"Add deleted for dashboard",
|
||||
"Add index for deleted",
|
||||
"Add column dashboard_uid in dashboard_tag",
|
||||
"Add column org_id in dashboard_tag",
|
||||
"Add missing dashboard_uid and org_id to dashboard_tag",
|
||||
"Add apiVersion for dashboard",
|
||||
"Add missing dashboard_uid and org_id to star",
|
||||
"create data_source table",
|
||||
"add index data_source.account_id",
|
||||
"add unique index data_source.account_id_name",
|
||||
"drop index IDX_data_source_account_id - v1",
|
||||
"drop index UQE_data_source_account_id_name - v1",
|
||||
"Rename table data_source to data_source_v1 - v1",
|
||||
"create data_source table v2",
|
||||
"create index IDX_data_source_org_id - v2",
|
||||
"create index UQE_data_source_org_id_name - v2",
|
||||
"Drop old table data_source_v1 #2",
|
||||
"Add column with_credentials",
|
||||
"Add secure json data column",
|
||||
"Update data_source table charset",
|
||||
"Update initial version to 1",
|
||||
"Add read_only data column",
|
||||
"Migrate logging ds to loki ds",
|
||||
"Update json_data with nulls",
|
||||
"Add uid column",
|
||||
"Update uid value",
|
||||
"Add unique index datasource_org_id_uid",
|
||||
"add unique index datasource_org_id_is_default",
|
||||
"Add is_prunable column",
|
||||
"Add api_version column",
|
||||
"create api_key table",
|
||||
"add index api_key.account_id",
|
||||
"add index api_key.key",
|
||||
"add index api_key.account_id_name",
|
||||
"drop index IDX_api_key_account_id - v1",
|
||||
"drop index UQE_api_key_key - v1",
|
||||
"drop index UQE_api_key_account_id_name - v1",
|
||||
"Rename table api_key to api_key_v1 - v1",
|
||||
"create api_key table v2",
|
||||
"create index IDX_api_key_org_id - v2",
|
||||
"create index UQE_api_key_key - v2",
|
||||
"create index UQE_api_key_org_id_name - v2",
|
||||
"copy api_key v1 to v2",
|
||||
"Drop old table api_key_v1",
|
||||
"Update api_key table charset",
|
||||
"Add expires to api_key table",
|
||||
"Add service account foreign key",
|
||||
"set service account foreign key to nil if 0",
|
||||
"Add last_used_at to api_key table",
|
||||
"Add is_revoked column to api_key table",
|
||||
"create dashboard_snapshot table v4",
|
||||
"drop table dashboard_snapshot_v4 #1",
|
||||
"create dashboard_snapshot table v5 #2",
|
||||
"create index UQE_dashboard_snapshot_key - v5",
|
||||
"create index UQE_dashboard_snapshot_delete_key - v5",
|
||||
"create index IDX_dashboard_snapshot_user_id - v5",
|
||||
"alter dashboard_snapshot to mediumtext v2",
|
||||
"Update dashboard_snapshot table charset",
|
||||
"Add column external_delete_url to dashboard_snapshots table",
|
||||
"Add encrypted dashboard json column",
|
||||
"Change dashboard_encrypted column to MEDIUMBLOB",
|
||||
"create quota table v1",
|
||||
"create index UQE_quota_org_id_user_id_target - v1",
|
||||
"Update quota table charset",
|
||||
"create plugin_setting table",
|
||||
"create index UQE_plugin_setting_org_id_plugin_id - v1",
|
||||
"Add column plugin_version to plugin_settings",
|
||||
"Update plugin_setting table charset",
|
||||
"update NULL org_id to 1",
|
||||
"make org_id NOT NULL and DEFAULT VALUE 1",
|
||||
"create session table",
|
||||
"Drop old table playlist table",
|
||||
"Drop old table playlist_item table",
|
||||
"create playlist table v2",
|
||||
"create playlist item table v2",
|
||||
"Update playlist table charset",
|
||||
"Update playlist_item table charset",
|
||||
"Add playlist column created_at",
|
||||
"Add playlist column updated_at",
|
||||
"drop preferences table v2",
|
||||
"drop preferences table v3",
|
||||
"create preferences table v3",
|
||||
"Update preferences table charset",
|
||||
"Add column team_id in preferences",
|
||||
"Update team_id column values in preferences",
|
||||
"Add column week_start in preferences",
|
||||
"Add column preferences.json_data",
|
||||
"alter preferences.json_data to mediumtext v1",
|
||||
"Add preferences index org_id",
|
||||
"Add preferences index user_id",
|
||||
"create alert table v1",
|
||||
"add index alert org_id \u0026 id ",
|
||||
"add index alert state",
|
||||
"add index alert dashboard_id",
|
||||
"Create alert_rule_tag table v1",
|
||||
"Add unique index alert_rule_tag.alert_id_tag_id",
|
||||
"drop index UQE_alert_rule_tag_alert_id_tag_id - v1",
|
||||
"Rename table alert_rule_tag to alert_rule_tag_v1 - v1",
|
||||
"Create alert_rule_tag table v2",
|
||||
"create index UQE_alert_rule_tag_alert_id_tag_id - Add unique index alert_rule_tag.alert_id_tag_id V2",
|
||||
"copy alert_rule_tag v1 to v2",
|
||||
"drop table alert_rule_tag_v1",
|
||||
"create alert_notification table v1",
|
||||
"Add column is_default",
|
||||
"Add column frequency",
|
||||
"Add column send_reminder",
|
||||
"Add column disable_resolve_message",
|
||||
"add index alert_notification org_id \u0026 name",
|
||||
"Update alert table charset",
|
||||
"Update alert_notification table charset",
|
||||
"create notification_journal table v1",
|
||||
"add index notification_journal org_id \u0026 alert_id \u0026 notifier_id",
|
||||
"drop alert_notification_journal",
|
||||
"create alert_notification_state table v1",
|
||||
"add index alert_notification_state org_id \u0026 alert_id \u0026 notifier_id",
|
||||
"Add for to alert table",
|
||||
"Add column uid in alert_notification",
|
||||
"Update uid column values in alert_notification",
|
||||
"Add unique index alert_notification_org_id_uid",
|
||||
"Remove unique index org_id_name",
|
||||
"Add column secure_settings in alert_notification",
|
||||
"alter alert.settings to mediumtext",
|
||||
"Add non-unique index alert_notification_state_alert_id",
|
||||
"Add non-unique index alert_rule_tag_alert_id",
|
||||
"Drop old annotation table v4",
|
||||
"create annotation table v5",
|
||||
"add index annotation 0 v3",
|
||||
"add index annotation 1 v3",
|
||||
"add index annotation 2 v3",
|
||||
"add index annotation 3 v3",
|
||||
"add index annotation 4 v3",
|
||||
"Update annotation table charset",
|
||||
"Add column region_id to annotation table",
|
||||
"Drop category_id index",
|
||||
"Add column tags to annotation table",
|
||||
"Create annotation_tag table v2",
|
||||
"Add unique index annotation_tag.annotation_id_tag_id",
|
||||
"drop index UQE_annotation_tag_annotation_id_tag_id - v2",
|
||||
"Rename table annotation_tag to annotation_tag_v2 - v2",
|
||||
"Create annotation_tag table v3",
|
||||
"create index UQE_annotation_tag_annotation_id_tag_id - Add unique index annotation_tag.annotation_id_tag_id V3",
|
||||
"copy annotation_tag v2 to v3",
|
||||
"drop table annotation_tag_v2",
|
||||
"Update alert annotations and set TEXT to empty",
|
||||
"Add created time to annotation table",
|
||||
"Add updated time to annotation table",
|
||||
"Add index for created in annotation table",
|
||||
"Add index for updated in annotation table",
|
||||
"Convert existing annotations from seconds to milliseconds",
|
||||
"Add epoch_end column",
|
||||
"Add index for epoch_end",
|
||||
"Make epoch_end the same as epoch",
|
||||
"Move region to single row",
|
||||
"Remove index org_id_epoch from annotation table",
|
||||
"Remove index org_id_dashboard_id_panel_id_epoch from annotation table",
|
||||
"Add index for org_id_dashboard_id_epoch_end_epoch on annotation table",
|
||||
"Add index for org_id_epoch_end_epoch on annotation table",
|
||||
"Remove index org_id_epoch_epoch_end from annotation table",
|
||||
"Add index for alert_id on annotation table",
|
||||
"Increase tags column to length 4096",
|
||||
"Increase prev_state column to length 40 not null",
|
||||
"Increase new_state column to length 40 not null",
|
||||
"create test_data table",
|
||||
"create dashboard_version table v1",
|
||||
"add index dashboard_version.dashboard_id",
|
||||
"add unique index dashboard_version.dashboard_id and dashboard_version.version",
|
||||
"Set dashboard version to 1 where 0",
|
||||
"save existing dashboard data in dashboard_version table v1",
|
||||
"alter dashboard_version.data to mediumtext v1",
|
||||
"Add apiVersion for dashboard_version",
|
||||
"create team table",
|
||||
"add index team.org_id",
|
||||
"add unique index team_org_id_name",
|
||||
"Add column uid in team",
|
||||
"Update uid column values in team",
|
||||
"Add unique index team_org_id_uid",
|
||||
"create team member table",
|
||||
"add index team_member.org_id",
|
||||
"add unique index team_member_org_id_team_id_user_id",
|
||||
"add index team_member.team_id",
|
||||
"Add column email to team table",
|
||||
"Add column external to team_member table",
|
||||
"Add column permission to team_member table",
|
||||
"add unique index team_member_user_id_org_id",
|
||||
"create dashboard acl table",
|
||||
"add index dashboard_acl_dashboard_id",
|
||||
"add unique index dashboard_acl_dashboard_id_user_id",
|
||||
"add unique index dashboard_acl_dashboard_id_team_id",
|
||||
"add index dashboard_acl_user_id",
|
||||
"add index dashboard_acl_team_id",
|
||||
"add index dashboard_acl_org_id_role",
|
||||
"add index dashboard_permission",
|
||||
"save default acl rules in dashboard_acl table",
|
||||
"delete acl rules for deleted dashboards and folders",
|
||||
"create tag table",
|
||||
"add index tag.key_value",
|
||||
"create login attempt table",
|
||||
"add index login_attempt.username",
|
||||
"drop index IDX_login_attempt_username - v1",
|
||||
"Rename table login_attempt to login_attempt_tmp_qwerty - v1",
|
||||
"create login_attempt v2",
|
||||
"create index IDX_login_attempt_username - v2",
|
||||
"copy login_attempt v1 to v2",
|
||||
"drop login_attempt_tmp_qwerty",
|
||||
"create user auth table",
|
||||
"create index IDX_user_auth_auth_module_auth_id - v1",
|
||||
"alter user_auth.auth_id to length 190",
|
||||
"Add OAuth access token to user_auth",
|
||||
"Add OAuth refresh token to user_auth",
|
||||
"Add OAuth token type to user_auth",
|
||||
"Add OAuth expiry to user_auth",
|
||||
"Add index to user_id column in user_auth",
|
||||
"Add OAuth ID token to user_auth",
|
||||
"create server_lock table",
|
||||
"add index server_lock.operation_uid",
|
||||
"create user auth token table",
|
||||
"add unique index user_auth_token.auth_token",
|
||||
"add unique index user_auth_token.prev_auth_token",
|
||||
"add index user_auth_token.user_id",
|
||||
"Add revoked_at to the user auth token",
|
||||
"add index user_auth_token.revoked_at",
|
||||
"add external_session_id to user_auth_token",
|
||||
"create cache_data table",
|
||||
"add unique index cache_data.cache_key",
|
||||
"create short_url table v1",
|
||||
"add index short_url.org_id-uid",
|
||||
"alter table short_url alter column created_by type to bigint",
|
||||
"delete alert_definition table",
|
||||
"recreate alert_definition table",
|
||||
"add index in alert_definition on org_id and title columns",
|
||||
"add index in alert_definition on org_id and uid columns",
|
||||
"alter alert_definition table data column to mediumtext in mysql",
|
||||
"drop index in alert_definition on org_id and title columns",
|
||||
"drop index in alert_definition on org_id and uid columns",
|
||||
"add unique index in alert_definition on org_id and title columns",
|
||||
"add unique index in alert_definition on org_id and uid columns",
|
||||
"Add column paused in alert_definition",
|
||||
"drop alert_definition table",
|
||||
"delete alert_definition_version table",
|
||||
"recreate alert_definition_version table",
|
||||
"add index in alert_definition_version table on alert_definition_id and version columns",
|
||||
"add index in alert_definition_version table on alert_definition_uid and version columns",
|
||||
"alter alert_definition_version table data column to mediumtext in mysql",
|
||||
"drop alert_definition_version table",
|
||||
"create alert_instance table",
|
||||
"add index in alert_instance table on def_org_id, def_uid and current_state columns",
|
||||
"add index in alert_instance table on def_org_id, current_state columns",
|
||||
"add column current_state_end to alert_instance",
|
||||
"remove index def_org_id, def_uid, current_state on alert_instance",
|
||||
"remove index def_org_id, current_state on alert_instance",
|
||||
"rename def_org_id to rule_org_id in alert_instance",
|
||||
"rename def_uid to rule_uid in alert_instance",
|
||||
"add index rule_org_id, rule_uid, current_state on alert_instance",
|
||||
"add index rule_org_id, current_state on alert_instance",
|
||||
"add current_reason column related to current_state",
|
||||
"add result_fingerprint column to alert_instance",
|
||||
"create alert_rule table",
|
||||
"add index in alert_rule on org_id and title columns",
|
||||
"add index in alert_rule on org_id and uid columns",
|
||||
"add index in alert_rule on org_id, namespace_uid, group_uid columns",
|
||||
"alter alert_rule table data column to mediumtext in mysql",
|
||||
"add column for to alert_rule",
|
||||
"add column annotations to alert_rule",
|
||||
"add column labels to alert_rule",
|
||||
"remove unique index from alert_rule on org_id, title columns",
|
||||
"add index in alert_rule on org_id, namespase_uid and title columns",
|
||||
"add dashboard_uid column to alert_rule",
|
||||
"add panel_id column to alert_rule",
|
||||
"add index in alert_rule on org_id, dashboard_uid and panel_id columns",
|
||||
"add rule_group_idx column to alert_rule",
|
||||
"add is_paused column to alert_rule table",
|
||||
"fix is_paused column for alert_rule table",
|
||||
"create alert_rule_version table",
|
||||
"add index in alert_rule_version table on rule_org_id, rule_uid and version columns",
|
||||
"add index in alert_rule_version table on rule_org_id, rule_namespace_uid and rule_group columns",
|
||||
"alter alert_rule_version table data column to mediumtext in mysql",
|
||||
"add column for to alert_rule_version",
|
||||
"add column annotations to alert_rule_version",
|
||||
"add column labels to alert_rule_version",
|
||||
"add rule_group_idx column to alert_rule_version",
|
||||
"add is_paused column to alert_rule_versions table",
|
||||
"fix is_paused column for alert_rule_version table",
|
||||
"create_alert_configuration_table",
|
||||
"Add column default in alert_configuration",
|
||||
"alert alert_configuration alertmanager_configuration column from TEXT to MEDIUMTEXT if mysql",
|
||||
"add column org_id in alert_configuration",
|
||||
"add index in alert_configuration table on org_id column",
|
||||
"add configuration_hash column to alert_configuration",
|
||||
"create_ngalert_configuration_table",
|
||||
"add index in ngalert_configuration on org_id column",
|
||||
"add column send_alerts_to in ngalert_configuration",
|
||||
"create provenance_type table",
|
||||
"add index to uniquify (record_key, record_type, org_id) columns",
|
||||
"create alert_image table",
|
||||
"add unique index on token to alert_image table",
|
||||
"support longer URLs in alert_image table",
|
||||
"create_alert_configuration_history_table",
|
||||
"drop non-unique orgID index on alert_configuration",
|
||||
"drop unique orgID index on alert_configuration if exists",
|
||||
"extract alertmanager configuration history to separate table",
|
||||
"add unique index on orgID to alert_configuration",
|
||||
"add last_applied column to alert_configuration_history",
|
||||
"create library_element table v1",
|
||||
"add index library_element org_id-folder_id-name-kind",
|
||||
"create library_element_connection table v1",
|
||||
"add index library_element_connection element_id-kind-connection_id",
|
||||
"add unique index library_element org_id_uid",
|
||||
"increase max description length to 2048",
|
||||
"alter library_element model to mediumtext",
|
||||
"add library_element folder uid",
|
||||
"populate library_element folder_uid",
|
||||
"add index library_element org_id-folder_uid-name-kind",
|
||||
"clone move dashboard alerts to unified alerting",
|
||||
"create data_keys table",
|
||||
"create secrets table",
|
||||
"rename data_keys name column to id",
|
||||
"add name column into data_keys",
|
||||
"copy data_keys id column values into name",
|
||||
"rename data_keys name column to label",
|
||||
"rename data_keys id column back to name",
|
||||
"create kv_store table v1",
|
||||
"add index kv_store.org_id-namespace-key",
|
||||
"update dashboard_uid and panel_id from existing annotations",
|
||||
"create permission table",
|
||||
"add unique index permission.role_id",
|
||||
"add unique index role_id_action_scope",
|
||||
"create role table",
|
||||
"add column display_name",
|
||||
"add column group_name",
|
||||
"add index role.org_id",
|
||||
"add unique index role_org_id_name",
|
||||
"add index role_org_id_uid",
|
||||
"create team role table",
|
||||
"add index team_role.org_id",
|
||||
"add unique index team_role_org_id_team_id_role_id",
|
||||
"add index team_role.team_id",
|
||||
"create user role table",
|
||||
"add index user_role.org_id",
|
||||
"add unique index user_role_org_id_user_id_role_id",
|
||||
"add index user_role.user_id",
|
||||
"create builtin role table",
|
||||
"add index builtin_role.role_id",
|
||||
"add index builtin_role.name",
|
||||
"Add column org_id to builtin_role table",
|
||||
"add index builtin_role.org_id",
|
||||
"add unique index builtin_role_org_id_role_id_role",
|
||||
"Remove unique index role_org_id_uid",
|
||||
"add unique index role.uid",
|
||||
"create seed assignment table",
|
||||
"add unique index builtin_role_role_name",
|
||||
"add column hidden to role table",
|
||||
"permission kind migration",
|
||||
"permission attribute migration",
|
||||
"permission identifier migration",
|
||||
"add permission identifier index",
|
||||
"add permission action scope role_id index",
|
||||
"remove permission role_id action scope index",
|
||||
"add group mapping UID column to user_role table",
|
||||
"add user_role org ID, user ID, role ID, group mapping UID index",
|
||||
"remove user_role org ID, user ID, role ID index",
|
||||
"create query_history table v1",
|
||||
"add index query_history.org_id-created_by-datasource_uid",
|
||||
"alter table query_history alter column created_by type to bigint",
|
||||
"create query_history_details table v1",
|
||||
"rbac disabled migrator",
|
||||
"teams permissions migration",
|
||||
"dashboard permissions",
|
||||
"dashboard permissions uid scopes",
|
||||
"drop managed folder create actions",
|
||||
"alerting notification permissions",
|
||||
"create query_history_star table v1",
|
||||
"add index query_history.user_id-query_uid",
|
||||
"add column org_id in query_history_star",
|
||||
"alter table query_history_star_mig column user_id type to bigint",
|
||||
"create correlation table v1",
|
||||
"add index correlations.uid",
|
||||
"add index correlations.source_uid",
|
||||
"add correlation config column",
|
||||
"drop index IDX_correlation_uid - v1",
|
||||
"drop index IDX_correlation_source_uid - v1",
|
||||
"Rename table correlation to correlation_tmp_qwerty - v1",
|
||||
"create correlation v2",
|
||||
"create index IDX_correlation_uid - v2",
|
||||
"create index IDX_correlation_source_uid - v2",
|
||||
"create index IDX_correlation_org_id - v2",
|
||||
"copy correlation v1 to v2",
|
||||
"drop correlation_tmp_qwerty",
|
||||
"add provisioning column",
|
||||
"add type column",
|
||||
"create entity_events table",
|
||||
"create dashboard public config v1",
|
||||
"drop index UQE_dashboard_public_config_uid - v1",
|
||||
"drop index IDX_dashboard_public_config_org_id_dashboard_uid - v1",
|
||||
"Drop old dashboard public config table",
|
||||
"recreate dashboard public config v1",
|
||||
"create index UQE_dashboard_public_config_uid - v1",
|
||||
"create index IDX_dashboard_public_config_org_id_dashboard_uid - v1",
|
||||
"drop index UQE_dashboard_public_config_uid - v2",
|
||||
"drop index IDX_dashboard_public_config_org_id_dashboard_uid - v2",
|
||||
"Drop public config table",
|
||||
"Recreate dashboard public config v2",
|
||||
"create index UQE_dashboard_public_config_uid - v2",
|
||||
"create index IDX_dashboard_public_config_org_id_dashboard_uid - v2",
|
||||
"create index UQE_dashboard_public_config_access_token - v2",
|
||||
"Rename table dashboard_public_config to dashboard_public - v2",
|
||||
"add annotations_enabled column",
|
||||
"add time_selection_enabled column",
|
||||
"delete orphaned public dashboards",
|
||||
"add share column",
|
||||
"backfill empty share column fields with default of public",
|
||||
"create file table",
|
||||
"file table idx: path natural pk",
|
||||
"file table idx: parent_folder_path_hash fast folder retrieval",
|
||||
"create file_meta table",
|
||||
"file table idx: path key",
|
||||
"set path collation in file table",
|
||||
"migrate contents column to mediumblob for MySQL",
|
||||
"managed permissions migration",
|
||||
"managed folder permissions alert actions migration",
|
||||
"RBAC action name migrator",
|
||||
"Add UID column to playlist",
|
||||
"Update uid column values in playlist",
|
||||
"Add index for uid in playlist",
|
||||
"update group index for alert rules",
|
||||
"managed folder permissions alert actions repeated migration",
|
||||
"admin only folder/dashboard permission",
|
||||
"add action column to seed_assignment",
|
||||
"add scope column to seed_assignment",
|
||||
"remove unique index builtin_role_role_name before nullable update",
|
||||
"update seed_assignment role_name column to nullable",
|
||||
"add unique index builtin_role_name back",
|
||||
"add unique index builtin_role_action_scope",
|
||||
"add primary key to seed_assigment",
|
||||
"add origin column to seed_assignment",
|
||||
"add origin to plugin seed_assignment",
|
||||
"prevent seeding OnCall access",
|
||||
"managed folder permissions alert actions repeated fixed migration",
|
||||
"managed folder permissions library panel actions migration",
|
||||
"migrate external alertmanagers to datsourcse",
|
||||
"create folder table",
|
||||
"Add index for parent_uid",
|
||||
"Add unique index for folder.uid and folder.org_id",
|
||||
"Update folder title length",
|
||||
"Add unique index for folder.title and folder.parent_uid",
|
||||
"Remove unique index for folder.title and folder.parent_uid",
|
||||
"Add unique index for title, parent_uid, and org_id",
|
||||
"Sync dashboard and folder table",
|
||||
"Remove ghost folders from the folder table",
|
||||
"Remove unique index UQE_folder_uid_org_id",
|
||||
"Add unique index UQE_folder_org_id_uid",
|
||||
"Remove unique index UQE_folder_title_parent_uid_org_id",
|
||||
"Add unique index UQE_folder_org_id_parent_uid_title",
|
||||
"Remove index IDX_folder_parent_uid_org_id",
|
||||
"Remove unique index UQE_folder_org_id_parent_uid_title",
|
||||
"create anon_device table",
|
||||
"add unique index anon_device.device_id",
|
||||
"add index anon_device.updated_at",
|
||||
"create signing_key table",
|
||||
"add unique index signing_key.key_id",
|
||||
"set legacy alert migration status in kvstore",
|
||||
"migrate record of created folders during legacy migration to kvstore",
|
||||
"Add folder_uid for dashboard",
|
||||
"Populate dashboard folder_uid column",
|
||||
"Add unique index for dashboard_org_id_folder_uid_title",
|
||||
"Delete unique index for dashboard_org_id_folder_id_title",
|
||||
"Delete unique index for dashboard_org_id_folder_uid_title",
|
||||
"Add unique index for dashboard_org_id_folder_uid_title_is_folder",
|
||||
"Restore index for dashboard_org_id_folder_id_title",
|
||||
"Remove unique index for dashboard_org_id_folder_uid_title_is_folder",
|
||||
"create sso_setting table",
|
||||
"copy kvstore migration status to each org",
|
||||
"add back entry for orgid=0 migrated status",
|
||||
"managed dashboard permissions annotation actions migration",
|
||||
"create cloud_migration table v1",
|
||||
"create cloud_migration_run table v1",
|
||||
"add stack_id column",
|
||||
"add region_slug column",
|
||||
"add cluster_slug column",
|
||||
"add migration uid column",
|
||||
"Update uid column values for migration",
|
||||
"Add unique index migration_uid",
|
||||
"add migration run uid column",
|
||||
"Update uid column values for migration run",
|
||||
"Add unique index migration_run_uid",
|
||||
"Rename table cloud_migration to cloud_migration_session_tmp_qwerty - v1",
|
||||
"create cloud_migration_session v2",
|
||||
"create index UQE_cloud_migration_session_uid - v2",
|
||||
"copy cloud_migration_session v1 to v2",
|
||||
"drop cloud_migration_session_tmp_qwerty",
|
||||
"Rename table cloud_migration_run to cloud_migration_snapshot_tmp_qwerty - v1",
|
||||
"create cloud_migration_snapshot v2",
|
||||
"create index UQE_cloud_migration_snapshot_uid - v2",
|
||||
"copy cloud_migration_snapshot v1 to v2",
|
||||
"drop cloud_migration_snapshot_tmp_qwerty",
|
||||
"add snapshot upload_url column",
|
||||
"add snapshot status column",
|
||||
"add snapshot local_directory column",
|
||||
"add snapshot gms_snapshot_uid column",
|
||||
"add snapshot encryption_key column",
|
||||
"add snapshot error_string column",
|
||||
"create cloud_migration_resource table v1",
|
||||
"delete cloud_migration_snapshot.result column",
|
||||
"add cloud_migration_resource.name column",
|
||||
"add cloud_migration_resource.parent_name column",
|
||||
"add cloud_migration_session.org_id column",
|
||||
"add cloud_migration_resource.error_code column",
|
||||
"increase resource_uid column length",
|
||||
"alter kv_store.value to longtext",
|
||||
"add notification_settings column to alert_rule table",
|
||||
"add notification_settings column to alert_rule_version table",
|
||||
"removing scope from alert.instances:read action migration",
|
||||
"managed folder permissions alerting silences actions migration",
|
||||
"add record column to alert_rule table",
|
||||
"add record column to alert_rule_version table",
|
||||
"add resolved_at column to alert_instance table",
|
||||
"add last_sent_at column to alert_instance table",
|
||||
"Enable traceQL streaming for all Tempo datasources",
|
||||
"Add scope to alert.notifications.receivers:read and alert.notifications.receivers.secrets:read",
|
||||
"add metadata column to alert_rule table",
|
||||
"add metadata column to alert_rule_version table",
|
||||
"delete orphaned service account permissions",
|
||||
"adding action set permissions",
|
||||
"create user_external_session table",
|
||||
"increase name_id column length to 1024",
|
||||
"increase session_id column length to 1024",
|
||||
"remove scope from alert.notifications.receivers:create",
|
||||
"add created_by column to alert_rule_version table",
|
||||
"add updated_by column to alert_rule table",
|
||||
"add alert_rule_state table",
|
||||
"add index to alert_rule_state on org_id and rule_uid columns",
|
||||
"add guid column to alert_rule table",
|
||||
"add rule_guid column to alert_rule_version table",
|
||||
"drop index in alert_rule_version table on rule_org_id, rule_uid and version columns",
|
||||
"populate rule guid in alert rule table",
|
||||
"add index in alert_rule_version table on rule_org_id, rule_uid, rule_guid and version columns",
|
||||
"add index in alert_rule_version table on rule_guid and version columns",
|
||||
"add index in alert_rule table on guid columns",
|
||||
"add missing_series_evals_to_resolve column to alert_rule",
|
||||
"add missing_series_evals_to_resolve column to alert_rule_version",
|
||||
"create data_source_usage_by_day table",
|
||||
"create data_source_usage_by_day(data_source_id) index",
|
||||
"create data_source_usage_by_day(data_source_id, day) unique index",
|
||||
"create dashboard_usage_by_day table",
|
||||
"create dashboard_usage_sums table",
|
||||
"create dashboard_usage_by_day(dashboard_id) index",
|
||||
"create dashboard_usage_by_day(dashboard_id, day) index",
|
||||
"add column errors_last_1_days to dashboard_usage_sums",
|
||||
"add column errors_last_7_days to dashboard_usage_sums",
|
||||
"add column errors_last_30_days to dashboard_usage_sums",
|
||||
"add column errors_total to dashboard_usage_sums",
|
||||
"create dashboard_public_usage_by_day table",
|
||||
"add column cached_queries to dashboard_usage_by_day table",
|
||||
"add column cached_queries to dashboard_public_usage_by_day table",
|
||||
"add column dashboard_uid to dashboard_usage_sums",
|
||||
"add column org_id to dashboard_usage_sums",
|
||||
"add column dashboard_uid to dashboard_usage_by_day",
|
||||
"add column org_id to dashboard_usage_by_day",
|
||||
"create dashboard_usage_by_day(dashboard_uid, org_id, day) unique index",
|
||||
"Add missing dashboard_uid and org_id to dashboard_usage_by_day and dashboard_usage_sums",
|
||||
"Add dashboard_usage_sums(org_id, dashboard_uid) index",
|
||||
"create user_dashboard_views table",
|
||||
"add index user_dashboard_views.user_id",
|
||||
"add index user_dashboard_views.dashboard_id",
|
||||
"add unique index user_dashboard_views_user_id_dashboard_id",
|
||||
"add org_id column to user_dashboard_views",
|
||||
"add dashboard_uid column to user_dashboard_views",
|
||||
"add unique index user_dashboard_views_org_id_dashboard_uid",
|
||||
"add unique index user_dashboard_views_org_id_user_id_dashboard_uid",
|
||||
"populate user_dashboard_views.dashboard_uid and user_dashboard_views.org_id from dashboard table",
|
||||
"create user_stats table",
|
||||
"add unique index user_stats(user_id)",
|
||||
"create data_source_cache table",
|
||||
"add index data_source_cache.data_source_id",
|
||||
"add use_default_ttl column",
|
||||
"add data_source_cache.data_source_uid column",
|
||||
"remove abandoned data_source_cache records",
|
||||
"update data_source_cache.data_source_uid value",
|
||||
"add index data_source_cache.data_source_uid",
|
||||
"add data_source_cache.ttl_resources_ms column",
|
||||
"update data_source_cache.ttl_resources_ms to have the same value as ttl_ms",
|
||||
"create data_source_acl table",
|
||||
"add index data_source_acl.data_source_id",
|
||||
"add unique index datasource_acl.unique",
|
||||
"create license_token table",
|
||||
"drop recorded_queries table v14",
|
||||
"drop recording_rules table v14",
|
||||
"create recording_rules table v14",
|
||||
"create remote_write_targets table v1",
|
||||
"Add prom_name to recording_rules table",
|
||||
"ensure remote_write_targets table",
|
||||
"create report config table v1",
|
||||
"Add index report.user_id",
|
||||
"add index to dashboard_id",
|
||||
"add index to org_id",
|
||||
"Add timezone to the report",
|
||||
"Add time_from to the report",
|
||||
"Add time_to to the report",
|
||||
"Add PDF landscape option to the report",
|
||||
"Add monthly day scheduling option to the report",
|
||||
"Add PDF layout option to the report",
|
||||
"Add PDF orientation option to the report",
|
||||
"Update report pdf_orientation from pdf_landscape",
|
||||
"create report settings table",
|
||||
"Add dashboard_uid field to the report",
|
||||
"Add template_vars field to the report",
|
||||
"Add option to include dashboard url in the report",
|
||||
"Add state field to the report",
|
||||
"Add option to add CSV files to the report",
|
||||
"Add scheduling start date",
|
||||
"Add missing schedule_start date for old reports",
|
||||
"Add scheduling end date",
|
||||
"Add schedulinng custom interval frequency",
|
||||
"Add scheduling custom interval amount",
|
||||
"Add workdays only flag to report",
|
||||
"create report dashboards table",
|
||||
"Add index report_dashboards.report_id",
|
||||
"Migrate report fields into report_dashboards",
|
||||
"Add formats option to the report",
|
||||
"Migrate reports with csv enabled",
|
||||
"Migrate ancient reports",
|
||||
"Add created column in report_dashboards",
|
||||
"Add scale_factor to the report",
|
||||
"Alter scale_factor from TINYINT to SMALLINT",
|
||||
"Add uid column to report",
|
||||
"Add unique index reports_org_id_uid",
|
||||
"Add pdf show template variable values to the report",
|
||||
"Add pdf combine in one file",
|
||||
"Add pdf theme to report settings table",
|
||||
"Add email subject to the report",
|
||||
"Populate email subject with report name",
|
||||
"Add embedded image theme to report settings table",
|
||||
"create team group table",
|
||||
"add index team_group.org_id",
|
||||
"add unique index team_group.org_id_team_id_group_id",
|
||||
"add index team_group.group_id",
|
||||
"create settings table",
|
||||
"add unique index settings.section_key",
|
||||
"add setting.encrypted_value",
|
||||
"migrate role names",
|
||||
"rename orgs roles",
|
||||
"remove duplicated org role",
|
||||
"migrate alerting role names",
|
||||
"data source permissions",
|
||||
"data source uid permissions",
|
||||
"rename permissions:delegate scope",
|
||||
"remove invalid managed permissions",
|
||||
"builtin role migration",
|
||||
"seed permissions migration",
|
||||
"managed permissions migration enterprise",
|
||||
"create table dashboard_public_email_share",
|
||||
"create table dashboard_public_magic_link",
|
||||
"create table dashboard_public_session",
|
||||
"add last_seen_at column"
|
||||
]
|
||||
@@ -66,8 +66,6 @@ func NewSimulationEngine() (*SimulationEngine, error) {
|
||||
newFlightSimInfo,
|
||||
newSinewaveInfo,
|
||||
newTankSimInfo,
|
||||
newNBodySimInfo,
|
||||
newGrot3dSimInfo,
|
||||
}
|
||||
|
||||
for _, init := range initializers {
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
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.
|
Before Width: | Height: | Size: 3.2 MiB |
File diff suppressed because one or more lines are too long
@@ -1,429 +0,0 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ import { useEffect, useMemo } from 'react';
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config, getAppEvents } from '@grafana/runtime';
|
||||
import { DisplayList, iamAPIv0alpha1, useLazyGetDisplayMappingQuery } from 'app/api/clients/iam/v0alpha1';
|
||||
import {
|
||||
API_GROUP as IAM_API_GROUP,
|
||||
API_VERSION as IAM_API_VERSION,
|
||||
DisplayList,
|
||||
iamAPIv0alpha1,
|
||||
useLazyGetDisplayMappingQuery,
|
||||
} from 'app/api/clients/iam/v0alpha1';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import {
|
||||
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
|
||||
@@ -56,6 +62,8 @@ import {
|
||||
ReplaceFolderApiArg,
|
||||
useGetAffectedItemsQuery,
|
||||
FolderInfo,
|
||||
ObjectMeta,
|
||||
OwnerReference,
|
||||
} from './index';
|
||||
|
||||
function getFolderUrl(uid: string, title: string): string {
|
||||
@@ -66,6 +74,10 @@ function getFolderUrl(uid: string, title: string): string {
|
||||
return `${config.appSubUrl}/dashboards/f/${uid}/${slug}`;
|
||||
}
|
||||
|
||||
type CombinedFolder = FolderDTO & {
|
||||
ownerReferences?: OwnerReference[];
|
||||
};
|
||||
|
||||
const combineFolderResponses = (
|
||||
folder: Folder,
|
||||
legacyFolder: FolderDTO,
|
||||
@@ -75,7 +87,7 @@ const combineFolderResponses = (
|
||||
const updatedBy = folder.metadata.annotations?.[AnnoKeyUpdatedBy];
|
||||
const createdBy = folder.metadata.annotations?.[AnnoKeyCreatedBy];
|
||||
|
||||
const newData: FolderDTO = {
|
||||
const newData: CombinedFolder = {
|
||||
canAdmin: legacyFolder.canAdmin,
|
||||
canDelete: legacyFolder.canDelete,
|
||||
canEdit: legacyFolder.canEdit,
|
||||
@@ -84,6 +96,7 @@ const combineFolderResponses = (
|
||||
createdBy: (createdBy && userDisplay?.display[userDisplay?.keys.indexOf(createdBy)]?.displayName) || 'Anonymous',
|
||||
updatedBy: (updatedBy && userDisplay?.display[userDisplay?.keys.indexOf(updatedBy)]?.displayName) || 'Anonymous',
|
||||
...appPlatformFolderToLegacyFolder(folder),
|
||||
ownerReferences: folder.metadata.ownerReferences || [],
|
||||
};
|
||||
|
||||
if (parents.length) {
|
||||
@@ -101,7 +114,7 @@ const combineFolderResponses = (
|
||||
return newData;
|
||||
};
|
||||
|
||||
export async function getFolderByUidFacade(uid: string): Promise<FolderDTO> {
|
||||
export async function getFolderByUidFacade(uid: string) {
|
||||
const isVirtualFolder = uid && [GENERAL_FOLDER_UID, config.sharedWithMeFolderUID].includes(uid);
|
||||
const shouldUseAppPlatformAPI = Boolean(config.featureToggles.foldersAppPlatformAPI);
|
||||
|
||||
@@ -216,7 +229,7 @@ export function useGetFolderQueryFacade(uid?: string) {
|
||||
|
||||
// Stitch together the responses to create a single FolderDTO object so on the outside this behaves as the legacy
|
||||
// api client.
|
||||
let newData: FolderDTO | undefined = undefined;
|
||||
let newData: CombinedFolder | undefined = undefined;
|
||||
if (
|
||||
resultFolder.data &&
|
||||
resultParents.data &&
|
||||
@@ -359,14 +372,36 @@ export function useCreateFolder() {
|
||||
return legacyHook;
|
||||
}
|
||||
|
||||
const createFolderAppPlatform = async (folder: NewFolder) => {
|
||||
const payload: CreateFolderApiArg = {
|
||||
const createFolderAppPlatform = async (payload: NewFolder & { createAsTeamFolder?: boolean; teamUid?: string }) => {
|
||||
const { createAsTeamFolder, teamUid, ...folder } = payload;
|
||||
const slugifiedTitle = kbn.slugifyForUrl(folder.title);
|
||||
|
||||
const metadataName = `team-${slugifiedTitle}`;
|
||||
const partialMetadata: ObjectMeta =
|
||||
createAsTeamFolder && teamUid
|
||||
? {
|
||||
name: metadataName,
|
||||
ownerReferences: [
|
||||
{
|
||||
apiVersion: `${IAM_API_GROUP}/${IAM_API_VERSION}`,
|
||||
kind: 'Team',
|
||||
name: folder.title,
|
||||
uid: teamUid,
|
||||
controller: true,
|
||||
blockOwnerDeletion: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
: { generateName: 'f' };
|
||||
|
||||
const apiPayload: CreateFolderApiArg = {
|
||||
folder: {
|
||||
spec: {
|
||||
title: folder.title,
|
||||
description: 'Testing a description',
|
||||
},
|
||||
metadata: {
|
||||
generateName: 'f',
|
||||
...partialMetadata,
|
||||
annotations: {
|
||||
...(folder.parentUid && { [AnnoKeyFolder]: folder.parentUid }),
|
||||
},
|
||||
@@ -375,7 +410,7 @@ export function useCreateFolder() {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await createFolder(payload);
|
||||
const result = await createFolder(apiPayload);
|
||||
refresh({ childrenOf: folder.parentUid });
|
||||
deletedDashboardsCache.clear();
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable @grafana/i18n/no-untranslated-strings */
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Box, Button, Combobox, ComboboxOption, Divider, Stack, Text } from '@grafana/ui';
|
||||
import { OwnerReference } from 'app/api/clients/folder/v1beta1';
|
||||
import { useListTeamQuery, API_GROUP, API_VERSION } from 'app/api/clients/iam/v0alpha1';
|
||||
import { useDispatch } from 'app/types/store';
|
||||
|
||||
import { TeamOwnerReference } from './OwnerReference';
|
||||
import { SupportedResource, useAddOwnerReference, useGetOwnerReferences } from './hooks';
|
||||
|
||||
const TeamSelector = ({ onChange }: { onChange: (ownerRef: OwnerReference) => void }) => {
|
||||
const { data: teams } = useListTeamQuery({});
|
||||
const teamsOptions = teams?.items.map((team) => ({
|
||||
label: team.spec.title,
|
||||
value: team.metadata.name!,
|
||||
}));
|
||||
return (
|
||||
<Combobox
|
||||
options={teamsOptions}
|
||||
onChange={(team: ComboboxOption<string>) => {
|
||||
onChange({
|
||||
apiVersion: `${API_GROUP}/${API_VERSION}`,
|
||||
kind: 'Team',
|
||||
name: team.label,
|
||||
uid: team.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ManageOwnerReferences = ({
|
||||
resource,
|
||||
resourceId,
|
||||
}: {
|
||||
resource: SupportedResource;
|
||||
resourceId: string;
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [addingNewReference, setAddingNewReference] = useState(false);
|
||||
const [pendingReference, setPendingReference] = useState<OwnerReference | null>(null);
|
||||
const ownerReferences = useGetOwnerReferences({ resource, resourceId });
|
||||
const [trigger, result] = useAddOwnerReference({ resource, resourceId });
|
||||
|
||||
const addOwnerReference = (ownerReference: OwnerReference) => {
|
||||
trigger(ownerReference);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Text variant="h3">Owned by:</Text>
|
||||
<Box>
|
||||
{ownerReferences
|
||||
.filter((ownerReference) => ownerReference.kind === 'Team')
|
||||
.map((ownerReference) => (
|
||||
<>
|
||||
<TeamOwnerReference key={ownerReference.uid} ownerReference={ownerReference} />
|
||||
<Divider />
|
||||
</>
|
||||
))}
|
||||
</Box>
|
||||
<Box>
|
||||
{addingNewReference && (
|
||||
<Box paddingBottom={2}>
|
||||
<Text variant="h3">Add new owner reference:</Text>
|
||||
<TeamSelector
|
||||
onChange={(ownerReference) => {
|
||||
setPendingReference(ownerReference);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
addOwnerReference(pendingReference);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Divider />
|
||||
</Box>
|
||||
)}
|
||||
<Button onClick={() => setAddingNewReference(true)}>Add new owner reference</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { OwnerReference } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||
import { useGetTeamMembersQuery } from '@grafana/api-clients/rtkq/iam/v0alpha1';
|
||||
import { Stack, Text, Avatar, Link, Tooltip } from '@grafana/ui';
|
||||
|
||||
export const getGravatarUrl = (text: string) => {
|
||||
// todo
|
||||
return `avatar/bd38b9ecaf6169ca02b848f60a44cb95`;
|
||||
};
|
||||
|
||||
export const TeamOwnerReference = ({ ownerReference }: { ownerReference: OwnerReference }) => {
|
||||
const { data: teamMembers } = useGetTeamMembersQuery({ name: ownerReference.uid });
|
||||
|
||||
const avatarURL = getGravatarUrl(ownerReference.name);
|
||||
|
||||
const membersTooltip = (
|
||||
<>
|
||||
<Stack gap={1} direction="column">
|
||||
<Text>Team members:</Text>
|
||||
{teamMembers?.items?.map((member) => (
|
||||
<div key={member.identity.name}>
|
||||
<Avatar src={member.avatarURL} /> {member.displayName}
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Link href={`/org/teams/edit/${ownerReference.uid}/members`} key={ownerReference.uid}>
|
||||
<Tooltip content={membersTooltip}>
|
||||
<Stack gap={1} alignItems="center">
|
||||
<Avatar src={avatarURL} alt={ownerReference.name} /> {ownerReference.name}
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
55
public/app/core/components/OwnerReferences/hooks.ts
Normal file
55
public/app/core/components/OwnerReferences/hooks.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { useReplaceFolderMutation } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||
import { folderAPIv1beta1, OwnerReference } from 'app/api/clients/folder/v1beta1';
|
||||
import { useDispatch } from 'app/types/store';
|
||||
|
||||
const getReferencesEndpointMap = {
|
||||
Folder: (resourceId: string) => folderAPIv1beta1.endpoints.getFolder.initiate({ name: resourceId }),
|
||||
} as const;
|
||||
|
||||
export type SupportedResource = keyof typeof getReferencesEndpointMap;
|
||||
|
||||
export const useGetOwnerReferences = ({
|
||||
resource,
|
||||
resourceId,
|
||||
}: {
|
||||
resource: SupportedResource;
|
||||
resourceId: string;
|
||||
}) => {
|
||||
const [ownerReferences, setOwnerReferences] = useState<OwnerReference[]>([]);
|
||||
const dispatch = useDispatch();
|
||||
const endpointAction = getReferencesEndpointMap[resource];
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(endpointAction(resourceId)).then(({ data }) => {
|
||||
if (data?.metadata?.ownerReferences) {
|
||||
setOwnerReferences(data.metadata.ownerReferences);
|
||||
}
|
||||
});
|
||||
}, [dispatch, endpointAction, resourceId]);
|
||||
|
||||
return ownerReferences;
|
||||
};
|
||||
|
||||
export const useAddOwnerReference = ({ resource, resourceId }: { resource: SupportedResource; resourceId: string }) => {
|
||||
const [replaceFolder, result] = useReplaceFolderMutation();
|
||||
return [
|
||||
(ownerReference: OwnerReference) =>
|
||||
replaceFolder({
|
||||
name: resourceId,
|
||||
|
||||
folder: {
|
||||
status: {},
|
||||
metadata: {
|
||||
name: resourceId,
|
||||
ownerReferences: [ownerReference],
|
||||
},
|
||||
spec: {
|
||||
title: resourceId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
result,
|
||||
] as const;
|
||||
};
|
||||
@@ -5,10 +5,12 @@ import { SelectableValue } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Combobox, ComboboxOption, Field, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export type AsyncOptionsLoader = (inputValue: string) => Promise<Array<ComboboxOption<string>>>;
|
||||
|
||||
export interface AlertLabelDropdownProps {
|
||||
onChange: (newValue: SelectableValue<string>) => void;
|
||||
onOpenMenu?: () => void;
|
||||
options: ComboboxOption[];
|
||||
options: ComboboxOption[] | AsyncOptionsLoader;
|
||||
defaultValue?: SelectableValue;
|
||||
type: 'key' | 'value';
|
||||
isLoading?: boolean;
|
||||
@@ -38,7 +40,7 @@ const AlertLabelDropdown: FC<AlertLabelDropdownProps> = forwardRef<HTMLDivElemen
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<Field disabled={false} data-testid={`alertlabel-${type}-picker`} className={styles.resetMargin}>
|
||||
<Field noMargin disabled={false} data-testid={`alertlabel-${type}-picker`} className={styles.resetMargin}>
|
||||
<Combobox<string>
|
||||
placeholder={t('alerting.alert-label-dropdown.placeholder-select', 'Choose {{type}}', { type })}
|
||||
width={25}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { OpenAssistantProps, createAssistantContextItem, useAssistant } from '@grafana/assistant';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { Menu } from '@grafana/ui';
|
||||
import { GrafanaAlertingRule, GrafanaRecordingRule, GrafanaRule } from 'app/types/unified-alerting';
|
||||
|
||||
@@ -98,8 +98,16 @@ function buildAnalyzeRulePrompt(rule: GrafanaRule): string {
|
||||
function buildAnalyzeAlertingRulePrompt(rule: GrafanaAlertingRule): string {
|
||||
const state = rule.state || 'firing';
|
||||
const timeInfo = rule.activeAt ? ` starting at ${new Date(rule.activeAt).toISOString()}` : '';
|
||||
const alertsNavigationPrompt = config.featureToggles.alertingTriage
|
||||
? '\n- Include navigation to follow up on the alerts page'
|
||||
: '';
|
||||
|
||||
let prompt = `Analyze the ${state} alert "${rule.name}"${timeInfo}.`;
|
||||
let prompt = `
|
||||
Analyze the ${state} alert "${rule.name} (uid: ${rule.uid})"${timeInfo}.
|
||||
- Get the rule definition, read the queries and run them to understand the rule
|
||||
- Get the rule state and instances to understand its current state
|
||||
- Read the rule conditions and understand how it works. Then suggest query and conditions improvements if applicable${alertsNavigationPrompt}
|
||||
`;
|
||||
|
||||
const description = rule.annotations?.description || rule.annotations?.summary || '';
|
||||
if (description) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { FC, useCallback, useMemo, useState } from 'react';
|
||||
import { FC, useCallback, useMemo } from 'react';
|
||||
import { Controller, FormProvider, useFieldArray, useForm, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { AlertLabels } from '@grafana/alerting/unstable';
|
||||
@@ -13,7 +13,7 @@ import { SupportedPlugin } from '../../../types/pluginBridges';
|
||||
import { KBObjectArray, RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { isPrivateLabelKey } from '../../../utils/labels';
|
||||
import { isRecordingRuleByType } from '../../../utils/rules';
|
||||
import AlertLabelDropdown from '../../AlertLabelDropdown';
|
||||
import AlertLabelDropdown, { AsyncOptionsLoader } from '../../AlertLabelDropdown';
|
||||
import { NeedHelpInfo } from '../NeedHelpInfo';
|
||||
import { useGetLabelsFromDataSourceName } from '../useAlertRuleSuggestions';
|
||||
|
||||
@@ -117,8 +117,7 @@ export function useCombinedLabels(
|
||||
dataSourceName: string,
|
||||
labelsPluginInstalled: boolean,
|
||||
loadingLabelsPlugin: boolean,
|
||||
labelsInSubform: Array<{ key: string; value: string }>,
|
||||
selectedKey: string
|
||||
labelsInSubform: Array<{ key: string; value: string }>
|
||||
) {
|
||||
// ------- Get labels keys and their values from existing alerts
|
||||
const { labels: labelsByKeyFromExisingAlerts, isLoading } = useGetLabelsFromDataSourceName(dataSourceName);
|
||||
@@ -126,18 +125,19 @@ export function useCombinedLabels(
|
||||
const { loading: isLoadingLabels, labelsOpsKeys = [] } = useGetOpsLabelsKeys(
|
||||
!labelsPluginInstalled || loadingLabelsPlugin
|
||||
);
|
||||
//------ Convert the labelsOpsKeys to the same format as the labelsByKeyFromExisingAlerts
|
||||
const labelsByKeyOps = useMemo(() => {
|
||||
return labelsOpsKeys.reduce((acc: Record<string, Set<string>>, label) => {
|
||||
acc[label.name] = new Set();
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Lazy query for fetching label values on demand
|
||||
const [fetchLabelValues] = labelsApi.endpoints.getLabelValues.useLazyQuery();
|
||||
|
||||
//------ Convert the labelsOpsKeys to a Set for quick lookup
|
||||
const opsLabelKeysSet = useMemo(() => {
|
||||
return new Set(labelsOpsKeys.map((label) => label.name));
|
||||
}, [labelsOpsKeys]);
|
||||
|
||||
//------- Convert the keys from the ops labels to options for the dropdown
|
||||
const keysFromGopsLabels = useMemo(() => {
|
||||
return mapLabelsToOptions(Object.keys(labelsByKeyOps).filter(isKeyAllowed), labelsInSubform);
|
||||
}, [labelsByKeyOps, labelsInSubform]);
|
||||
return mapLabelsToOptions(Array.from(opsLabelKeysSet).filter(isKeyAllowed), labelsInSubform);
|
||||
}, [opsLabelKeysSet, labelsInSubform]);
|
||||
|
||||
//------- Convert the keys from the existing alerts to options for the dropdown
|
||||
const keysFromExistingAlerts = useMemo(() => {
|
||||
@@ -158,70 +158,47 @@ export function useCombinedLabels(
|
||||
},
|
||||
];
|
||||
|
||||
const selectedKeyIsFromAlerts = labelsByKeyFromExisingAlerts.has(selectedKey);
|
||||
const selectedKeyIsFromOps = labelsByKeyOps[selectedKey] !== undefined && labelsByKeyOps[selectedKey]?.size > 0;
|
||||
const selectedKeyDoesNotExist = !selectedKeyIsFromAlerts && !selectedKeyIsFromOps;
|
||||
// Create an async options loader for a specific key
|
||||
// This is called by Combobox when the dropdown menu opens
|
||||
const createAsyncValuesLoader = useCallback(
|
||||
(key: string): AsyncOptionsLoader => {
|
||||
return async (_inputValue: string): Promise<Array<ComboboxOption<string>>> => {
|
||||
if (!isKeyAllowed(key) || !key) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const valuesAlreadyFetched = !selectedKeyIsFromAlerts && labelsByKeyOps[selectedKey]?.size > 0;
|
||||
// Collect values from existing alerts first
|
||||
const valuesFromAlerts = labelsByKeyFromExisingAlerts.get(key);
|
||||
const existingValues = valuesFromAlerts ? Array.from(valuesFromAlerts) : [];
|
||||
|
||||
// Only fetch the values for the selected key if it is from ops and the values are not already fetched (the selected key is not in the labelsByKeyOps object)
|
||||
const {
|
||||
currentData: valuesData,
|
||||
isLoading: isLoadingValues = false,
|
||||
error,
|
||||
} = labelsApi.endpoints.getLabelValues.useQuery(
|
||||
{ key: selectedKey },
|
||||
{
|
||||
skip:
|
||||
!labelsPluginInstalled ||
|
||||
!selectedKey ||
|
||||
selectedKeyIsFromAlerts ||
|
||||
valuesAlreadyFetched ||
|
||||
selectedKeyDoesNotExist,
|
||||
}
|
||||
);
|
||||
// Collect values from ops labels (if plugin is installed)
|
||||
let opsValues: string[] = [];
|
||||
if (labelsPluginInstalled && opsLabelKeysSet.has(key)) {
|
||||
try {
|
||||
// RTK Query handles caching automatically
|
||||
const result = await fetchLabelValues({ key }, true).unwrap();
|
||||
if (result?.values?.length) {
|
||||
opsValues = result.values.map((value) => value.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch label values for key:', key, error);
|
||||
}
|
||||
}
|
||||
|
||||
// these are the values for the selected key in case it is from ops
|
||||
const valuesFromSelectedGopsKey = useMemo(() => {
|
||||
// if it is from alerts, we need to fetch the values from the existing alerts
|
||||
if (selectedKeyIsFromAlerts) {
|
||||
return [];
|
||||
}
|
||||
// in case of a label from ops, we need to fetch the values from the plugin
|
||||
// fetch values from ops only if there is no value for the key
|
||||
const valuesForSelectedKey = labelsByKeyOps[selectedKey];
|
||||
const valuesAlreadyFetched = valuesForSelectedKey?.size > 0;
|
||||
if (valuesAlreadyFetched) {
|
||||
return mapLabelsToOptions(valuesForSelectedKey);
|
||||
}
|
||||
if (!isLoadingValues && valuesData?.values?.length && !error) {
|
||||
const values = valuesData?.values.map((value) => value.name);
|
||||
labelsByKeyOps[selectedKey] = new Set(values);
|
||||
return mapLabelsToOptions(values);
|
||||
}
|
||||
return [];
|
||||
}, [selectedKeyIsFromAlerts, labelsByKeyOps, selectedKey, isLoadingValues, valuesData, error]);
|
||||
// Combine: existing values first, then unique ops values (Set preserves first occurrence)
|
||||
const combinedValues = [...new Set([...existingValues, ...opsValues])];
|
||||
|
||||
const getValuesForLabel = useCallback(
|
||||
(key: string) => {
|
||||
if (!isKeyAllowed(key)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// values from existing alerts will take precedence over values from ops
|
||||
if (selectedKeyIsFromAlerts || !labelsPluginInstalled) {
|
||||
return mapLabelsToOptions(labelsByKeyFromExisingAlerts.get(key));
|
||||
}
|
||||
return valuesFromSelectedGopsKey;
|
||||
return mapLabelsToOptions(combinedValues);
|
||||
};
|
||||
},
|
||||
[labelsByKeyFromExisingAlerts, labelsPluginInstalled, valuesFromSelectedGopsKey, selectedKeyIsFromAlerts]
|
||||
[labelsByKeyFromExisingAlerts, labelsPluginInstalled, opsLabelKeysSet, fetchLabelValues]
|
||||
);
|
||||
|
||||
return {
|
||||
loading: isLoading || isLoadingLabels,
|
||||
keysFromExistingAlerts,
|
||||
groupedOptions,
|
||||
getValuesForLabel,
|
||||
createAsyncValuesLoader,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,30 +225,30 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP
|
||||
append({ key: '', value: '' });
|
||||
}, [append]);
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState('');
|
||||
// check if the labels plugin is installed
|
||||
const { installed: labelsPluginInstalled = false, loading: loadingLabelsPlugin } = usePluginBridge(
|
||||
SupportedPlugin.Labels
|
||||
);
|
||||
|
||||
const { loading, keysFromExistingAlerts, groupedOptions, getValuesForLabel } = useCombinedLabels(
|
||||
const { loading, keysFromExistingAlerts, groupedOptions, createAsyncValuesLoader } = useCombinedLabels(
|
||||
dataSourceName,
|
||||
labelsPluginInstalled,
|
||||
loadingLabelsPlugin,
|
||||
labelsInSubform,
|
||||
selectedKey
|
||||
labelsInSubform
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2} alignItems="flex-start">
|
||||
{fields.map((field, index) => {
|
||||
// Get the values for this specific row's key directly without memoization
|
||||
// Create an async loader for this specific row's key
|
||||
// This will be called by Combobox when the dropdown opens
|
||||
const currentKey = labelsInSubform[index]?.key || '';
|
||||
const valuesForCurrentKey = getValuesForLabel(currentKey);
|
||||
const asyncValuesLoader = createAsyncValuesLoader(currentKey);
|
||||
|
||||
return (
|
||||
<div key={field.id} className={cx(styles.flexRow, styles.centerAlignRow)} id="hola">
|
||||
<div key={field.id} className={cx(styles.flexRow, styles.centerAlignRow)}>
|
||||
<Field
|
||||
noMargin
|
||||
className={styles.labelInput}
|
||||
invalid={Boolean(errors.labelsInSubform?.[index]?.key?.message)}
|
||||
error={errors.labelsInSubform?.[index]?.key?.message}
|
||||
@@ -295,7 +272,6 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP
|
||||
onChange={(newValue: SelectableValue) => {
|
||||
if (newValue) {
|
||||
onChange(newValue.value || newValue.label || '');
|
||||
setSelectedKey(newValue.value);
|
||||
}
|
||||
}}
|
||||
type="key"
|
||||
@@ -306,6 +282,7 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP
|
||||
</Field>
|
||||
<InlineLabel className={styles.equalSign}>=</InlineLabel>
|
||||
<Field
|
||||
noMargin
|
||||
className={styles.labelInput}
|
||||
invalid={Boolean(errors.labelsInSubform?.[index]?.value?.message)}
|
||||
error={errors.labelsInSubform?.[index]?.value?.message}
|
||||
@@ -320,16 +297,13 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP
|
||||
<AlertLabelDropdown
|
||||
{...rest}
|
||||
defaultValue={value ? { label: value, value: value } : undefined}
|
||||
options={valuesForCurrentKey}
|
||||
options={asyncValuesLoader}
|
||||
isLoading={loading}
|
||||
onChange={(newValue: SelectableValue) => {
|
||||
if (newValue) {
|
||||
onChange(newValue.value || newValue.label || '');
|
||||
}
|
||||
}}
|
||||
onOpenMenu={() => {
|
||||
setSelectedKey(labelsInSubform[index].key);
|
||||
}}
|
||||
type="value"
|
||||
/>
|
||||
);
|
||||
@@ -368,6 +342,7 @@ export const LabelsWithoutSuggestions: FC = () => {
|
||||
<div key={field.id}>
|
||||
<div className={cx(styles.flexRow, styles.centerAlignRow)} data-testid="alertlabel-input-wrapper">
|
||||
<Field
|
||||
noMargin
|
||||
className={styles.labelInput}
|
||||
invalid={!!errors.labels?.[index]?.key?.message}
|
||||
error={errors.labels?.[index]?.key?.message}
|
||||
@@ -386,6 +361,7 @@ export const LabelsWithoutSuggestions: FC = () => {
|
||||
</Field>
|
||||
<InlineLabel className={styles.equalSign}>=</InlineLabel>
|
||||
<Field
|
||||
noMargin
|
||||
className={styles.labelInput}
|
||||
invalid={!!errors.labels?.[index]?.value?.message}
|
||||
error={errors.labels?.[index]?.value?.message}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import * as React from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { render, screen, waitFor, within } from 'test/test-utils';
|
||||
|
||||
import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
|
||||
|
||||
import { mockAlertRuleApi, setupMswServer } from '../../../mockApi';
|
||||
import { getGrafanaRule } from '../../../mocks';
|
||||
import {
|
||||
defaultLabelValues,
|
||||
getLabelValuesHandler,
|
||||
getMockOpsLabels,
|
||||
} from '../../../mocks/server/handlers/plugins/grafana-labels-app';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||
|
||||
import { LabelsWithSuggestions } from './LabelsField';
|
||||
|
||||
// Existing labels in the form (simulating editing an existing alert rule with ops labels)
|
||||
const existingOpsLabels = getMockOpsLabels();
|
||||
|
||||
const SubFormProviderWrapper = ({
|
||||
children,
|
||||
labels,
|
||||
}: React.PropsWithChildren<{ labels: Array<{ key: string; value: string }> }>) => {
|
||||
const methods = useForm({ defaultValues: { labelsInSubform: labels } });
|
||||
return <FormProvider {...methods}>{children}</FormProvider>;
|
||||
};
|
||||
|
||||
const grafanaRule = getGrafanaRule(undefined, {
|
||||
uid: 'test-rule-uid',
|
||||
title: 'test-alert',
|
||||
namespace_uid: 'folderUID1',
|
||||
data: [
|
||||
{
|
||||
refId: 'A',
|
||||
datasourceUid: 'uid1',
|
||||
queryType: 'alerting',
|
||||
relativeTimeRange: { from: 1000, to: 2000 },
|
||||
model: {
|
||||
refId: 'A',
|
||||
expression: 'vector(1)',
|
||||
queryType: 'alerting',
|
||||
datasource: { uid: 'uid1', type: 'prometheus' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Use the standard MSW server setup which includes all plugin handlers
|
||||
const server = setupMswServer();
|
||||
|
||||
describe('LabelsField with ops labels', () => {
|
||||
beforeEach(() => {
|
||||
// Mock the ruler rules API
|
||||
mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, {
|
||||
[grafanaRule.namespace.name]: [{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
clearPluginSettingsCache();
|
||||
});
|
||||
|
||||
async function renderLabelsWithOpsLabels(labels = existingOpsLabels) {
|
||||
const view = render(
|
||||
<SubFormProviderWrapper labels={labels}>
|
||||
<LabelsWithSuggestions dataSourceName="grafana" />
|
||||
</SubFormProviderWrapper>
|
||||
);
|
||||
|
||||
// Wait for the dropdowns to be rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(labels.length);
|
||||
});
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
it('should display existing ops labels correctly', async () => {
|
||||
await renderLabelsWithOpsLabels();
|
||||
|
||||
// Verify the keys are displayed
|
||||
expect(screen.getByTestId('labelsInSubform-key-0').querySelector('input')).toHaveValue(existingOpsLabels[0].key);
|
||||
expect(screen.getByTestId('labelsInSubform-key-1').querySelector('input')).toHaveValue(existingOpsLabels[1].key);
|
||||
|
||||
// Verify the values are displayed
|
||||
expect(screen.getByTestId('labelsInSubform-value-0').querySelector('input')).toHaveValue(
|
||||
existingOpsLabels[0].value
|
||||
);
|
||||
expect(screen.getByTestId('labelsInSubform-value-1').querySelector('input')).toHaveValue(
|
||||
existingOpsLabels[1].value
|
||||
);
|
||||
});
|
||||
|
||||
it('should render value dropdowns for each label', async () => {
|
||||
await renderLabelsWithOpsLabels();
|
||||
|
||||
// Verify we have value pickers for each label
|
||||
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should allow deleting a label', async () => {
|
||||
const { user } = await renderLabelsWithOpsLabels();
|
||||
|
||||
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getByTestId('delete-label-1'));
|
||||
|
||||
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(1);
|
||||
expect(screen.getByTestId('labelsInSubform-key-0').querySelector('input')).toHaveValue(existingOpsLabels[0].key);
|
||||
});
|
||||
|
||||
it('should allow adding a new label', async () => {
|
||||
const { user } = await renderLabelsWithOpsLabels();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Add more')).toBeVisible());
|
||||
await user.click(screen.getByText('Add more'));
|
||||
|
||||
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(3);
|
||||
expect(screen.getByTestId('labelsInSubform-key-2').querySelector('input')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should allow typing custom values in dropdowns', async () => {
|
||||
const { user } = await renderLabelsWithOpsLabels();
|
||||
|
||||
// Add a new label
|
||||
await waitFor(() => expect(screen.getByText('Add more')).toBeVisible());
|
||||
await user.click(screen.getByText('Add more'));
|
||||
|
||||
// Type a custom key and value
|
||||
const newKeyInput = screen.getByTestId('labelsInSubform-key-2').querySelector('input');
|
||||
const newValueInput = screen.getByTestId('labelsInSubform-value-2').querySelector('input');
|
||||
|
||||
await user.type(newKeyInput!, 'customKey{enter}');
|
||||
await user.type(newValueInput!, 'customValue{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('labelsInSubform-key-2').querySelector('input')).toHaveValue('customKey');
|
||||
});
|
||||
expect(screen.getByTestId('labelsInSubform-value-2').querySelector('input')).toHaveValue('customValue');
|
||||
});
|
||||
|
||||
// When editing an existing alert with labels, the value dropdown should open and be interactive
|
||||
it('should allow opening and interacting with existing label value dropdown', async () => {
|
||||
const { user } = await renderLabelsWithOpsLabels();
|
||||
|
||||
// Click on the first label's value dropdown (sentMail) to open it
|
||||
const firstValueDropdown = within(screen.getByTestId('labelsInSubform-value-0'));
|
||||
const combobox = firstValueDropdown.getByRole('combobox');
|
||||
|
||||
// Verify initial value is set
|
||||
expect(combobox).toHaveValue(existingOpsLabels[0].value);
|
||||
|
||||
// Open the dropdown
|
||||
await user.click(combobox);
|
||||
|
||||
// Verify dropdown is open (not showing "No options found" state)
|
||||
expect(combobox).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Close and reopen to verify it remains interactive
|
||||
await user.keyboard('{Escape}');
|
||||
expect(combobox).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await user.click(combobox);
|
||||
expect(combobox).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
// Test that value dropdowns can be opened and interacted with for different label keys
|
||||
// Note: Dropdown content cannot be verified via text due to Combobox virtualization in JSDOM
|
||||
it('should allow opening value dropdowns for different label keys', async () => {
|
||||
const { user } = await renderLabelsWithOpsLabels();
|
||||
|
||||
// Open the first label's value dropdown (sentMail)
|
||||
const firstValueDropdown = within(screen.getByTestId('labelsInSubform-value-0'));
|
||||
const firstCombobox = firstValueDropdown.getByRole('combobox');
|
||||
await user.click(firstCombobox);
|
||||
|
||||
// Verify dropdown is open
|
||||
expect(firstCombobox).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Close and open second dropdown
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
// Open the second label's value dropdown (stage)
|
||||
const secondValueDropdown = within(screen.getByTestId('labelsInSubform-value-1'));
|
||||
const secondCombobox = secondValueDropdown.getByRole('combobox');
|
||||
await user.click(secondCombobox);
|
||||
|
||||
// Verify second dropdown is open
|
||||
expect(secondCombobox).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
// Test that after deleting and re-adding a label, the value dropdown can be opened
|
||||
it('should allow opening value dropdown after deleting and re-adding a label', async () => {
|
||||
const { user } = await renderLabelsWithOpsLabels();
|
||||
|
||||
// Delete the second label (stage)
|
||||
await user.click(screen.getByTestId('delete-label-1'));
|
||||
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(1);
|
||||
|
||||
// Add a new label
|
||||
await waitFor(() => expect(screen.getByText('Add more')).toBeVisible());
|
||||
await user.click(screen.getByText('Add more'));
|
||||
|
||||
// Set the new label key to 'team'
|
||||
const newKeyDropdown = within(screen.getByTestId('labelsInSubform-key-1'));
|
||||
await user.type(newKeyDropdown.getByRole('combobox'), 'team{enter}');
|
||||
|
||||
// Verify the key was set
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('labelsInSubform-key-1').querySelector('input')).toHaveValue('team');
|
||||
});
|
||||
|
||||
// Open the new label's value dropdown
|
||||
const newValueDropdown = within(screen.getByTestId('labelsInSubform-value-1'));
|
||||
const combobox = newValueDropdown.getByRole('combobox');
|
||||
await user.click(combobox);
|
||||
|
||||
// Verify dropdown is open
|
||||
expect(combobox).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
// Test that opening the value dropdown requests values for the CORRECT label key
|
||||
// This verifies the async loader is called with the right key
|
||||
it('should request correct label values when opening value dropdown', async () => {
|
||||
const requestedKeys: string[] = [];
|
||||
|
||||
// Add a spy handler that tracks which keys are requested
|
||||
server.use(getLabelValuesHandler(defaultLabelValues, (key) => requestedKeys.push(key)));
|
||||
|
||||
const { user } = await renderLabelsWithOpsLabels();
|
||||
|
||||
// Open the first label's value dropdown (sentMail)
|
||||
const firstValueDropdown = within(screen.getByTestId('labelsInSubform-value-0'));
|
||||
await user.click(firstValueDropdown.getByRole('combobox'));
|
||||
|
||||
// Wait for the API call to be made
|
||||
await waitFor(() => {
|
||||
expect(requestedKeys).toContain('sentMail');
|
||||
});
|
||||
|
||||
// Close dropdown
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
// Clear the tracked keys
|
||||
requestedKeys.length = 0;
|
||||
|
||||
// Open the second label's value dropdown (stage)
|
||||
const secondValueDropdown = within(screen.getByTestId('labelsInSubform-value-1'));
|
||||
await user.click(secondValueDropdown.getByRole('combobox'));
|
||||
|
||||
// Wait for the API call - should request 'stage', NOT 'sentMail'
|
||||
await waitFor(() => {
|
||||
expect(requestedKeys).toContain('stage');
|
||||
});
|
||||
|
||||
// Verify we didn't request the wrong key (the bug from escalation #19378)
|
||||
expect(requestedKeys).not.toContain('sentMail');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Unit tests for the createAsyncValuesLoader function in useCombinedLabels hook.
|
||||
*
|
||||
* These tests verify that:
|
||||
* 1. Values from existing alerts are shown first
|
||||
* 2. Values from ops labels are shown after existing values
|
||||
* 3. Duplicate values between existing and ops are excluded from ops
|
||||
* 4. The order is: existing values first, then unique ops values
|
||||
*/
|
||||
|
||||
describe('createAsyncValuesLoader logic', () => {
|
||||
// Simulate the data structures used in useCombinedLabels
|
||||
const labelsByKeyFromExistingAlerts = new Map<string, Set<string>>([
|
||||
['severity', new Set(['warning', 'error', 'critical'])],
|
||||
['team', new Set(['frontend', 'backend', 'platform'])],
|
||||
['environment', new Set(['production', 'staging'])],
|
||||
]);
|
||||
|
||||
// Simulate ops labels (from grafana-labels-app plugin)
|
||||
const opsLabelValues: Record<string, string[]> = {
|
||||
severity: ['info', 'warning', 'critical', 'fatal'], // 'warning' and 'critical' overlap with existing
|
||||
team: ['frontend', 'sre', 'devops'], // 'frontend' overlaps with existing
|
||||
environment: ['production', 'staging', 'development', 'testing'], // 'production' and 'staging' overlap
|
||||
cluster: ['us-east-1', 'us-west-2', 'eu-central-1'], // ops-only key
|
||||
};
|
||||
|
||||
const opsLabelKeys = new Set(['severity', 'team', 'environment', 'cluster']);
|
||||
|
||||
const mapLabelsToOptions = (items: string[]) => {
|
||||
return items.map((item) => ({ label: item, value: item }));
|
||||
};
|
||||
|
||||
// This simulates the current implementation of createAsyncValuesLoader
|
||||
const getValuesForLabel = (key: string, labelsPluginInstalled: boolean): Array<{ label: string; value: string }> => {
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Collect values from existing alerts first
|
||||
const valuesFromAlerts = labelsByKeyFromExistingAlerts.get(key);
|
||||
const existingValues = valuesFromAlerts ? Array.from(valuesFromAlerts) : [];
|
||||
|
||||
// Collect values from ops labels (if plugin is installed)
|
||||
let opsValues: string[] = [];
|
||||
if (labelsPluginInstalled && opsLabelKeys.has(key)) {
|
||||
opsValues = opsLabelValues[key] || [];
|
||||
}
|
||||
|
||||
// Combine: existing values first, then unique ops values (Set preserves first occurrence)
|
||||
const combinedValues = [...new Set([...existingValues, ...opsValues])];
|
||||
|
||||
return mapLabelsToOptions(combinedValues);
|
||||
};
|
||||
|
||||
describe('when labels plugin is installed', () => {
|
||||
it('should combine existing and ops values with existing first', () => {
|
||||
const values = getValuesForLabel('severity', true);
|
||||
|
||||
// Existing: warning, error, critical
|
||||
// Ops: info, warning, critical, fatal (warning and critical are duplicates)
|
||||
// Expected: warning, error, critical, info, fatal
|
||||
expect(values).toHaveLength(5);
|
||||
expect(values.map((v) => v.value)).toEqual(['warning', 'error', 'critical', 'info', 'fatal']);
|
||||
});
|
||||
|
||||
it('should exclude duplicate ops values that exist in existing alerts', () => {
|
||||
const values = getValuesForLabel('environment', true);
|
||||
|
||||
// Existing: production, staging
|
||||
// Ops: production, staging, development, testing (production and staging are duplicates)
|
||||
// Expected: production, staging, development, testing
|
||||
expect(values).toHaveLength(4);
|
||||
expect(values.map((v) => v.value)).toEqual(['production', 'staging', 'development', 'testing']);
|
||||
});
|
||||
|
||||
it('should return only ops values for ops-only keys', () => {
|
||||
const values = getValuesForLabel('cluster', true);
|
||||
|
||||
// No existing alerts for 'cluster', only ops values
|
||||
expect(values).toHaveLength(3);
|
||||
expect(values.map((v) => v.value)).toEqual(['us-east-1', 'us-west-2', 'eu-central-1']);
|
||||
});
|
||||
|
||||
it('should return only existing values for keys not in ops', () => {
|
||||
// Add a key that exists in alerts but not in ops
|
||||
labelsByKeyFromExistingAlerts.set('custom', new Set(['value1', 'value2']));
|
||||
|
||||
const values = getValuesForLabel('custom', true);
|
||||
|
||||
expect(values).toHaveLength(2);
|
||||
expect(values.map((v) => v.value)).toEqual(['value1', 'value2']);
|
||||
|
||||
// Cleanup
|
||||
labelsByKeyFromExistingAlerts.delete('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when labels plugin is NOT installed', () => {
|
||||
it('should return only existing alert values', () => {
|
||||
const values = getValuesForLabel('severity', false);
|
||||
|
||||
// Only existing values, no ops values
|
||||
expect(values).toHaveLength(3);
|
||||
expect(values.map((v) => v.value)).toEqual(['warning', 'error', 'critical']);
|
||||
});
|
||||
|
||||
it('should return empty array for ops-only keys', () => {
|
||||
const values = getValuesForLabel('cluster', false);
|
||||
|
||||
// 'cluster' only exists in ops, not in existing alerts
|
||||
expect(values).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty array for empty key', () => {
|
||||
const values = getValuesForLabel('', true);
|
||||
expect(values).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown keys', () => {
|
||||
const values = getValuesForLabel('unknown-key', true);
|
||||
expect(values).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should preserve order: existing values first, then unique ops values', () => {
|
||||
const values = getValuesForLabel('team', true);
|
||||
|
||||
// Existing: frontend, backend, platform
|
||||
// Ops: frontend, sre, devops (frontend is duplicate)
|
||||
// Expected order: frontend, backend, platform, sre, devops
|
||||
const valueStrings = values.map((v) => v.value);
|
||||
|
||||
// Check that existing values come before ops values
|
||||
expect(valueStrings.indexOf('frontend')).toBeLessThan(valueStrings.indexOf('sre'));
|
||||
expect(valueStrings.indexOf('backend')).toBeLessThan(valueStrings.indexOf('sre'));
|
||||
expect(valueStrings.indexOf('platform')).toBeLessThan(valueStrings.indexOf('devops'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Re-exports all plugin proxy handlers
|
||||
*/
|
||||
import labelsHandlers from './grafana-labels-app';
|
||||
import onCallHandlers from './grafana-oncall';
|
||||
|
||||
/**
|
||||
* Array of all plugin handlers that are required across Alerting tests
|
||||
*/
|
||||
const allPluginProxyHandlers = [...onCallHandlers];
|
||||
const allPluginProxyHandlers = [...onCallHandlers, ...labelsHandlers];
|
||||
|
||||
export default allPluginProxyHandlers;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { LabelItem, LabelKeyAndValues } from 'app/features/alerting/unified/api/labelsApi';
|
||||
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
|
||||
|
||||
const BASE_URL = `/api/plugins/${SupportedPlugin.Labels}/resources`;
|
||||
|
||||
// Default mock data for ops labels
|
||||
export const defaultLabelKeys: LabelItem[] = [
|
||||
{ id: '1', name: 'sentMail', prescribed: false },
|
||||
{ id: '2', name: 'stage', prescribed: false },
|
||||
{ id: '3', name: 'team', prescribed: false },
|
||||
];
|
||||
|
||||
export const defaultLabelValues: Record<string, LabelItem[]> = {
|
||||
sentMail: [
|
||||
{ id: '1', name: 'true', prescribed: false },
|
||||
{ id: '2', name: 'false', prescribed: false },
|
||||
],
|
||||
stage: [
|
||||
{ id: '1', name: 'production', prescribed: false },
|
||||
{ id: '2', name: 'staging', prescribed: false },
|
||||
{ id: '3', name: 'development', prescribed: false },
|
||||
],
|
||||
team: [
|
||||
{ id: '1', name: 'frontend', prescribed: false },
|
||||
{ id: '2', name: 'backend', prescribed: false },
|
||||
{ id: '3', name: 'platform', prescribed: false },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to generate mock ops labels in the form format (key-value pairs).
|
||||
* @param keys - Array of label key names to include (defaults to first two: sentMail, stage)
|
||||
* @param labelValues - Optional custom label values map
|
||||
* @returns Array of { key, value } objects for use in form tests
|
||||
*/
|
||||
export function getMockOpsLabels(
|
||||
keys: string[] = [defaultLabelKeys[0].name, defaultLabelKeys[1].name],
|
||||
labelValues: Record<string, LabelItem[]> = defaultLabelValues
|
||||
): Array<{ key: string; value: string }> {
|
||||
return keys.map((key) => ({
|
||||
key,
|
||||
value: labelValues[key]?.[0]?.name ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for GET /api/plugins/grafana-labels-app/resources/v1/labels/keys
|
||||
* Returns all available label keys
|
||||
*/
|
||||
export const getLabelsKeysHandler = (labelKeys: LabelItem[] = defaultLabelKeys) =>
|
||||
http.get(`${BASE_URL}/v1/labels/keys`, () => {
|
||||
return HttpResponse.json(labelKeys);
|
||||
});
|
||||
|
||||
/**
|
||||
* Handler for GET /api/plugins/grafana-labels-app/resources/v1/labels/name/:key
|
||||
* Returns values for a specific label key.
|
||||
* @param labelValues - Custom label values map (defaults to defaultLabelValues)
|
||||
* @param onKeyRequested - Optional callback to spy on which keys are requested (useful for testing)
|
||||
*/
|
||||
export const getLabelValuesHandler = (
|
||||
labelValues: Record<string, LabelItem[]> = defaultLabelValues,
|
||||
onKeyRequested?: (key: string) => void
|
||||
) =>
|
||||
http.get<{ key: string }>(`${BASE_URL}/v1/labels/name/:key`, ({ params }) => {
|
||||
const key = params.key;
|
||||
onKeyRequested?.(key);
|
||||
const values = labelValues[key] || [];
|
||||
const response: LabelKeyAndValues = {
|
||||
labelKey: { id: '1', name: key, prescribed: false },
|
||||
values,
|
||||
};
|
||||
return HttpResponse.json(response);
|
||||
});
|
||||
|
||||
const handlers = [getLabelsKeysHandler(), getLabelValuesHandler()];
|
||||
export default handlers;
|
||||
@@ -82,7 +82,7 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
const { searchQuery, updateFilters, setSearchQuery } = useRulesFilter();
|
||||
const { filterState, searchQuery, updateFilters, setSearchQuery } = useRulesFilter();
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const { pluginsFilterEnabled } = usePluginsFilterStatus();
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP
|
||||
};
|
||||
|
||||
const handleAdvancedFilters: SubmitHandler<AdvancedFilters> = (values) => {
|
||||
updateFilters(formAdvancedFiltersToRuleFilter(values));
|
||||
updateFilters(formAdvancedFiltersToRuleFilter(values, filterState.freeFormWords));
|
||||
trackFilterButtonApplyClick(values, pluginsFilterEnabled);
|
||||
setIsPopupOpen(false);
|
||||
};
|
||||
|
||||
@@ -5,9 +5,12 @@ import { RulesFilter } from '../../search/rulesSearchParser';
|
||||
|
||||
import { AdvancedFilters } from './types';
|
||||
|
||||
export function formAdvancedFiltersToRuleFilter(values: AdvancedFilters): RulesFilter {
|
||||
export function formAdvancedFiltersToRuleFilter(
|
||||
values: AdvancedFilters,
|
||||
existingFreeFormWords: string[] = []
|
||||
): RulesFilter {
|
||||
return {
|
||||
freeFormWords: [],
|
||||
freeFormWords: existingFreeFormWords,
|
||||
...values,
|
||||
namespace: values.namespace || undefined,
|
||||
groupName: values.groupName || undefined,
|
||||
|
||||
@@ -134,6 +134,29 @@ export const pluginMeta = {
|
||||
module: 'public/plugins/grafana-asserts-app/module.js',
|
||||
baseUrl: 'public/plugins/grafana-asserts-app',
|
||||
} satisfies PluginMeta,
|
||||
[SupportedPlugin.Labels]: {
|
||||
id: SupportedPlugin.Labels,
|
||||
name: 'Labels',
|
||||
type: PluginType.app,
|
||||
enabled: true,
|
||||
info: {
|
||||
author: {
|
||||
name: 'Grafana Labs',
|
||||
url: '',
|
||||
},
|
||||
description: 'Labels management for alerting',
|
||||
links: [],
|
||||
logos: {
|
||||
small: 'public/plugins/grafana-labels-app/img/logo.svg',
|
||||
large: 'public/plugins/grafana-labels-app/img/logo.svg',
|
||||
},
|
||||
screenshots: [],
|
||||
version: 'local-dev',
|
||||
updated: '2024-04-09',
|
||||
},
|
||||
module: 'public/plugins/grafana-labels-app/module.js',
|
||||
baseUrl: 'public/plugins/grafana-labels-app',
|
||||
} satisfies PluginMeta,
|
||||
};
|
||||
|
||||
export const plugins: PluginMeta[] = [
|
||||
@@ -141,6 +164,7 @@ export const plugins: PluginMeta[] = [
|
||||
pluginMeta[SupportedPlugin.Incident],
|
||||
pluginMeta[SupportedPlugin.OnCall],
|
||||
pluginMeta['grafana-asserts-app'],
|
||||
pluginMeta[SupportedPlugin.Labels],
|
||||
];
|
||||
|
||||
export function pluginMetaToPluginConfig(pluginMeta: PluginMeta): AppPluginConfig {
|
||||
|
||||
@@ -6,8 +6,9 @@ import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui';
|
||||
import { LinkButton, FilterInput, useStyles2, Text, Stack, Box, Divider } from '@grafana/ui';
|
||||
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import { TeamOwnerReference } from 'app/core/components/OwnerReferences/OwnerReference';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { getConfig } from 'app/core/config';
|
||||
import { useDispatch } from 'app/types/store';
|
||||
@@ -146,6 +147,19 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
||||
);
|
||||
};
|
||||
|
||||
const ownerReferences = folderDTO && 'ownerReferences' in folderDTO && (
|
||||
<Box>
|
||||
{folderDTO.ownerReferences
|
||||
?.filter((ref) => ref.kind === 'Team')
|
||||
.map((ref) => (
|
||||
<Stack key={ref.uid} direction="row">
|
||||
<Text>Owned by team:</Text>
|
||||
<TeamOwnerReference ownerReference={ref} />
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Page
|
||||
navId="dashboards/browse"
|
||||
@@ -153,7 +167,8 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
||||
onEditTitle={showEditTitle ? onEditTitle : undefined}
|
||||
renderTitle={renderTitle}
|
||||
actions={
|
||||
<>
|
||||
<Stack alignItems="center">
|
||||
{ownerReferences}
|
||||
{config.featureToggles.restoreDashboards && hasAdminRights && (
|
||||
<LinkButton
|
||||
variant="secondary"
|
||||
@@ -173,7 +188,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
||||
isReadOnlyRepo={isReadOnlyRepo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Page.Contents className={styles.pageContents}>
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function listFolders(
|
||||
});
|
||||
}
|
||||
|
||||
return folders.map((item) => ({
|
||||
const result = folders.map((item) => ({
|
||||
kind: 'folder',
|
||||
uid: item.uid,
|
||||
title: item.title,
|
||||
@@ -40,6 +40,20 @@ export async function listFolders(
|
||||
// URLs from the backend come with subUrlPrefix already included, so match that behaviour here
|
||||
url: isSharedWithMe(item.uid) ? undefined : getFolderURL(item.uid),
|
||||
}));
|
||||
|
||||
if (!parentUID) {
|
||||
// result.unshift({
|
||||
// kind: 'folder',
|
||||
// uid: 'teamfolders',
|
||||
// title: 'Team folders',
|
||||
// parentTitle,
|
||||
// parentUID,
|
||||
// managedBy: undefined,
|
||||
// url: undefined,
|
||||
// });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listDashboards(parentUID?: string, page = 1, pageSize = PAGE_SIZE): Promise<DashboardViewItem[]> {
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function CheckboxCell({
|
||||
}
|
||||
}
|
||||
|
||||
if (isSharedWithMe(item.uid)) {
|
||||
if (isSharedWithMe(item.uid) || item.uid === 'teamfolders') {
|
||||
return <CheckboxSpacer />;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { Avatar, useStyles2 } from '@grafana/ui';
|
||||
import { TeamOwnerReference } from 'app/core/components/OwnerReferences/OwnerReference';
|
||||
import { DashboardViewItem } from 'app/features/search/types';
|
||||
|
||||
import {
|
||||
@@ -102,8 +103,27 @@ export function DashboardsTree({
|
||||
Header: t('browse-dashboards.dashboards-tree.tags-column', 'Tags'),
|
||||
Cell: (props: DashboardsTreeCellProps) => <TagsCell {...props} onTagClick={onTagClick} />,
|
||||
};
|
||||
const ownerReferencesColumn: DashboardsTreeColumn = {
|
||||
id: 'ownerReferences',
|
||||
width: 2,
|
||||
Header: 'Owner',
|
||||
Cell: ({ row: { original: data } }) => {
|
||||
const ownerReferences = data.item.ownerReferences;
|
||||
if (!ownerReferences) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ownerReferences.map((ownerReference) => {
|
||||
return <TeamOwnerReference ownerReference={ownerReference} key={ownerReference.uid} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
const canSelect = canSelectItems(permissions);
|
||||
const columns = [canSelect && checkboxColumn, nameColumn, tagsColumns].filter(isTruthy);
|
||||
const columns = [canSelect && checkboxColumn, nameColumn, ownerReferencesColumn, tagsColumns].filter(isTruthy);
|
||||
|
||||
return columns;
|
||||
}, [onFolderClick, onTagClick, permissions]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/ui';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { Permissions } from 'app/core/components/AccessControl/Permissions';
|
||||
import { ManageOwnerReferences } from 'app/core/components/OwnerReferences/ManageOwnerReferences';
|
||||
import { RepoType } from 'app/features/provisioning/Wizard/types';
|
||||
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
|
||||
import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm';
|
||||
@@ -30,6 +31,7 @@ interface Props {
|
||||
export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
|
||||
const [showManageOwnersDrawer, setShowManageOwnersDrawer] = useState(false);
|
||||
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
|
||||
const [showMoveProvisionedFolderDrawer, setShowMoveProvisionedFolderDrawer] = useState(false);
|
||||
const [moveFolder] = useMoveFolderMutationFacade();
|
||||
@@ -126,14 +128,18 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
||||
};
|
||||
|
||||
const managePermissionsLabel = t('browse-dashboards.folder-actions-button.manage-permissions', 'Manage permissions');
|
||||
const manageOwnersLabel = t('browse-dashboards.folder-actions-button.manage-folder-owners', 'Manage folder owners');
|
||||
const moveLabel = t('browse-dashboards.folder-actions-button.move', 'Move this folder');
|
||||
const deleteLabel = t('browse-dashboards.folder-actions-button.delete', 'Delete this folder');
|
||||
|
||||
const showManageOwners = canViewPermissions && !isProvisionedFolder;
|
||||
|
||||
const menu = (
|
||||
<Menu>
|
||||
{canViewPermissions && !isProvisionedFolder && (
|
||||
<MenuItem onClick={() => setShowPermissionsDrawer(true)} label={managePermissionsLabel} />
|
||||
)}
|
||||
{showManageOwners && <MenuItem onClick={() => setShowManageOwnersDrawer(true)} label={manageOwnersLabel} />}
|
||||
{canMoveFolder && !isReadOnlyRepo && (
|
||||
<MenuItem
|
||||
onClick={isProvisionedFolder ? handleShowMoveProvisionedFolderDrawer : showMoveModal}
|
||||
@@ -180,6 +186,16 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
||||
<Permissions resource="folders" resourceId={folder.uid} canSetPermissions={canSetPermissions} />
|
||||
</Drawer>
|
||||
)}
|
||||
{showManageOwnersDrawer && (
|
||||
<Drawer
|
||||
title={t('browse-dashboards.action.manage-permissions-button', 'Manage owners')}
|
||||
subtitle={folder.title}
|
||||
onClose={() => setShowManageOwnersDrawer(false)}
|
||||
size="md"
|
||||
>
|
||||
<ManageOwnerReferences resource="Folder" resourceId={folder.uid} />
|
||||
</Drawer>
|
||||
)}
|
||||
{showDeleteProvisionedFolderDrawer && (
|
||||
<Drawer
|
||||
title={
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
|
||||
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||
import { createAsyncThunk } from 'app/types/store';
|
||||
@@ -53,6 +54,10 @@ export const refreshParents = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
const hackGetOwnerRefs = async () => {
|
||||
return getBackendSrv().get('/apis/folder.grafana.app/v1beta1/namespaces/default/folders');
|
||||
};
|
||||
|
||||
export const refetchChildren = createAsyncThunk(
|
||||
'browseDashboards/refetchChildren',
|
||||
async ({ parentUID, pageSize }: RefetchChildrenArgs): Promise<RefetchChildrenResult> => {
|
||||
@@ -66,6 +71,7 @@ export const refetchChildren = createAsyncThunk(
|
||||
let fetchKind: DashboardViewItemKind | undefined = 'folder';
|
||||
|
||||
let children = await listFolders(uid, undefined, page, pageSize);
|
||||
|
||||
let lastPageOfKind = children.length < pageSize;
|
||||
|
||||
// If we've loaded all folders, load the first page of dashboards.
|
||||
@@ -136,6 +142,16 @@ export const fetchNextChildrenPage = createAsyncThunk(
|
||||
? await listFolders(uid, undefined, page, pageSize)
|
||||
: await listDashboards(uid, page, pageSize);
|
||||
|
||||
const foldersWithOwnerRefs = await hackGetOwnerRefs();
|
||||
|
||||
children.forEach((child) => {
|
||||
const ownerRefs = foldersWithOwnerRefs.items.find((folder) => folder.metadata.name === child.uid)?.metadata
|
||||
.ownerReferences;
|
||||
if (ownerRefs) {
|
||||
child.ownerReferences = ownerRefs;
|
||||
}
|
||||
});
|
||||
|
||||
let lastPageOfKind = children.length < pageSize;
|
||||
|
||||
// If we've loaded all folders, load the first page of dashboards.
|
||||
|
||||
@@ -183,7 +183,7 @@ export function createFlatTree(
|
||||
|
||||
const items = [thisItem, ...mappedChildren];
|
||||
|
||||
if (isSharedWithMe(thisItem.item.uid)) {
|
||||
if (isSharedWithMe(thisItem.item.uid) || thisItem.item.uid === 'teamfolders') {
|
||||
items.push({
|
||||
item: {
|
||||
kind: 'ui',
|
||||
|
||||
@@ -81,12 +81,6 @@ 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
|
||||
*
|
||||
@@ -95,7 +89,7 @@ export interface DefaultElementSize {
|
||||
// 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?: DefaultElementSize;
|
||||
defaultSize?: Placement;
|
||||
|
||||
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 { ScalarDimensionMode, PositionDimensionMode, TextDimensionMode } from '@grafana/schema';
|
||||
import { 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 ?? { 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 },
|
||||
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,
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 +94,11 @@ export const cloudItem: CanvasElementItem = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
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 },
|
||||
width: options?.placement?.width ?? 110,
|
||||
height: options?.placement?.height ?? 70,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { ScalarDimensionConfig } 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 ?? { 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 },
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 26,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { ScalarDimensionConfig } 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 ?? { 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 },
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 26,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -102,11 +101,11 @@ export const ellipseItem: CanvasElementItem<CanvasElementConfig, CanvasElementDa
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
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 },
|
||||
width: options?.placement?.width ?? 160,
|
||||
height: options?.placement?.height ?? 138,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -4,13 +4,7 @@ import { CSSProperties } from 'react';
|
||||
|
||||
import { LinkModel } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import {
|
||||
ColorDimensionConfig,
|
||||
ResourceDimensionConfig,
|
||||
ResourceDimensionMode,
|
||||
ScalarDimensionMode,
|
||||
PositionDimensionMode,
|
||||
} from '@grafana/schema';
|
||||
import { ColorDimensionConfig, ResourceDimensionConfig, ResourceDimensionMode } 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';
|
||||
@@ -82,11 +76,11 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
|
||||
},
|
||||
},
|
||||
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, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
|
||||
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,
|
||||
},
|
||||
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, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { TextDimensionMode } 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 ?? { 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 },
|
||||
top: options?.placement?.top ?? 100,
|
||||
left: options?.placement?.left ?? 100,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 +94,11 @@ export const parallelogramItem: CanvasElementItem = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
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 },
|
||||
width: options?.placement?.width ?? 250,
|
||||
height: options?.placement?.height ?? 150,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -2,12 +2,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import {
|
||||
ColorDimensionConfig,
|
||||
ScalarDimensionConfig,
|
||||
ScalarDimensionMode,
|
||||
PositionDimensionMode,
|
||||
} from '@grafana/schema';
|
||||
import { ColorDimensionConfig, ScalarDimensionConfig } from '@grafana/schema';
|
||||
import config from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
@@ -81,11 +76,11 @@ export const serverItem: CanvasElementItem<ServerConfig, ServerData> = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
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 },
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 100,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
config: {
|
||||
type: ServerType.Single,
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
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,7 +6,6 @@ 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';
|
||||
@@ -146,11 +145,11 @@ export const textItem: CanvasElementItem<TextConfig, TextData> = {
|
||||
size: 16,
|
||||
},
|
||||
placement: {
|
||||
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 },
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 100,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -96,11 +95,11 @@ export const triangleItem: CanvasElementItem = {
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
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 },
|
||||
width: options?.placement?.width ?? 160,
|
||||
height: options?.placement?.height ?? 138,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
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, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
|
||||
import { ScalarDimensionConfig } 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 ?? { 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 },
|
||||
width: options?.placement?.width ?? 100,
|
||||
height: options?.placement?.height ?? 155,
|
||||
top: options?.placement?.top,
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
@@ -12,7 +12,6 @@ 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';
|
||||
@@ -34,7 +33,6 @@ export const defaultElementItems = [
|
||||
triangleItem,
|
||||
cloudItem,
|
||||
parallelogramItem,
|
||||
svgItem,
|
||||
];
|
||||
|
||||
export const advancedElementItems = [buttonItem, windTurbineItem, droneTopItem, droneFrontItem, droneSideItem];
|
||||
|
||||
@@ -14,12 +14,7 @@ import {
|
||||
ActionType,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import {
|
||||
PositionDimensionConfig,
|
||||
PositionDimensionMode,
|
||||
ScalarDimensionMode,
|
||||
TooltipDisplayMode,
|
||||
} from '@grafana/schema';
|
||||
import { TooltipDisplayMode } from '@grafana/schema';
|
||||
import { ConfirmModal, VariablesInputModal } from '@grafana/ui';
|
||||
import { LayerElement } from 'app/core/components/Layers/types';
|
||||
import { config } from 'app/core/config';
|
||||
@@ -79,40 +74,6 @@ 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();
|
||||
@@ -132,13 +93,7 @@ export class ElementState implements LayerElement {
|
||||
vertical: VerticalConstraint.Top,
|
||||
horizontal: HorizontalConstraint.Left,
|
||||
};
|
||||
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.placement = options.placement ?? { width: 100, height: 100, top: 0, left: 0, rotation: 0 };
|
||||
options.background = options.background ?? { color: { fixed: 'transparent' } };
|
||||
options.border = options.border ?? { color: { fixed: 'dark-green' } };
|
||||
|
||||
@@ -166,18 +121,6 @@ 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) {
|
||||
@@ -191,6 +134,7 @@ export class ElementState implements LayerElement {
|
||||
|
||||
const { constraint } = this.options;
|
||||
const { vertical, horizontal } = constraint ?? {};
|
||||
const placement: Placement = this.options.placement ?? {};
|
||||
|
||||
const editingEnabled = this.getScene()?.isEditingEnabled;
|
||||
|
||||
@@ -201,64 +145,95 @@ export class ElementState implements LayerElement {
|
||||
// Minimum element size is 10x10
|
||||
minWidth: '10px',
|
||||
minHeight: '10px',
|
||||
rotate: `${this.cachedRotation}deg`,
|
||||
rotate: `${placement.rotation ?? 0}deg`,
|
||||
};
|
||||
|
||||
const translate = ['0px', '0px'];
|
||||
|
||||
switch (vertical) {
|
||||
case VerticalConstraint.Top:
|
||||
style.top = `${this.cachedTop}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.height = placement.height ?? 100;
|
||||
style.top = `${placement.top}px`;
|
||||
style.height = `${placement.height}px`;
|
||||
delete placement.bottom;
|
||||
break;
|
||||
case VerticalConstraint.Bottom:
|
||||
style.bottom = `${this.cachedBottom ?? 0}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
placement.bottom = placement.bottom ?? 0;
|
||||
placement.height = placement.height ?? 100;
|
||||
style.bottom = `${placement.bottom}px`;
|
||||
style.height = `${placement.height}px`;
|
||||
delete placement.top;
|
||||
break;
|
||||
case VerticalConstraint.TopBottom:
|
||||
style.top = `${this.cachedTop}px`;
|
||||
style.bottom = `${this.cachedBottom ?? 0}px`;
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.bottom = placement.bottom ?? 0;
|
||||
style.top = `${placement.top}px`;
|
||||
style.bottom = `${placement.bottom}px`;
|
||||
delete placement.height;
|
||||
style.height = '';
|
||||
break;
|
||||
case VerticalConstraint.Center:
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.height = placement.height ?? 100;
|
||||
translate[1] = '-50%';
|
||||
style.top = `calc(50% - ${this.cachedTop}px)`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
style.top = `calc(50% - ${placement.top}px)`;
|
||||
style.height = `${placement.height}px`;
|
||||
delete placement.bottom;
|
||||
break;
|
||||
case VerticalConstraint.Scale:
|
||||
style.top = `${this.cachedTop}%`;
|
||||
style.bottom = `${this.cachedBottom ?? 0}%`;
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.bottom = placement.bottom ?? 0;
|
||||
style.top = `${placement.top}%`;
|
||||
style.bottom = `${placement.bottom}%`;
|
||||
delete placement.height;
|
||||
style.height = '';
|
||||
break;
|
||||
}
|
||||
|
||||
switch (horizontal) {
|
||||
case HorizontalConstraint.Left:
|
||||
style.left = `${this.cachedLeft}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.width = placement.width ?? 100;
|
||||
style.left = `${placement.left}px`;
|
||||
style.width = `${placement.width}px`;
|
||||
delete placement.right;
|
||||
break;
|
||||
case HorizontalConstraint.Right:
|
||||
style.right = `${this.cachedRight ?? 0}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
placement.right = placement.right ?? 0;
|
||||
placement.width = placement.width ?? 100;
|
||||
style.right = `${placement.right}px`;
|
||||
style.width = `${placement.width}px`;
|
||||
delete placement.left;
|
||||
break;
|
||||
case HorizontalConstraint.LeftRight:
|
||||
style.left = `${this.cachedLeft}px`;
|
||||
style.right = `${this.cachedRight ?? 0}px`;
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.right = placement.right ?? 0;
|
||||
style.left = `${placement.left}px`;
|
||||
style.right = `${placement.right}px`;
|
||||
delete placement.width;
|
||||
style.width = '';
|
||||
break;
|
||||
case HorizontalConstraint.Center:
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.width = placement.width ?? 100;
|
||||
translate[0] = '-50%';
|
||||
style.left = `calc(50% - ${this.cachedLeft}px)`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
style.left = `calc(50% - ${placement.left}px)`;
|
||||
style.width = `${placement.width}px`;
|
||||
delete placement.right;
|
||||
break;
|
||||
case HorizontalConstraint.Scale:
|
||||
style.left = `${this.cachedLeft}%`;
|
||||
style.right = `${this.cachedRight ?? 0}%`;
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.right = placement.right ?? 0;
|
||||
style.left = `${placement.left}%`;
|
||||
style.right = `${placement.right}%`;
|
||||
delete placement.width;
|
||||
style.width = '';
|
||||
break;
|
||||
}
|
||||
|
||||
style.transform = `translate(${translate[0]}, ${translate[1]})`;
|
||||
this.options.placement = placement;
|
||||
this.sizeStyle = style;
|
||||
|
||||
if (this.div) {
|
||||
@@ -292,6 +267,7 @@ export class ElementState implements LayerElement {
|
||||
|
||||
const { constraint } = this.options;
|
||||
const { vertical, horizontal } = constraint ?? {};
|
||||
const placement: Placement = this.options.placement ?? {};
|
||||
|
||||
const editingEnabled = scene?.isEditingEnabled;
|
||||
|
||||
@@ -299,6 +275,7 @@ export class ElementState implements LayerElement {
|
||||
cursor: editingEnabled ? 'grab' : 'auto',
|
||||
pointerEvents: disablePointerEvents ? 'none' : 'auto',
|
||||
position: 'absolute',
|
||||
// Minimum element size is 10x10
|
||||
minWidth: '10px',
|
||||
minHeight: '10px',
|
||||
};
|
||||
@@ -308,50 +285,81 @@ export class ElementState implements LayerElement {
|
||||
|
||||
switch (vertical) {
|
||||
case VerticalConstraint.Top:
|
||||
transformY = `${this.cachedTop}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
placement.top = placement.top ?? 0;
|
||||
placement.height = placement.height ?? 100;
|
||||
transformY = `${placement.top ?? 0}px`;
|
||||
style.height = `${placement.height}px`;
|
||||
delete placement.bottom;
|
||||
break;
|
||||
case VerticalConstraint.Bottom:
|
||||
transformY = `${sceneHeight! - (this.cachedBottom ?? 0) - this.cachedHeight}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
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;
|
||||
break;
|
||||
case VerticalConstraint.TopBottom:
|
||||
transformY = `${this.cachedTop}px`;
|
||||
style.height = `${sceneHeight! - this.cachedTop - (this.cachedBottom ?? 0)}px`;
|
||||
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;
|
||||
break;
|
||||
case VerticalConstraint.Center:
|
||||
transformY = `${sceneHeight! / 2 - this.cachedTop - this.cachedHeight / 2}px`;
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
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;
|
||||
break;
|
||||
case VerticalConstraint.Scale:
|
||||
transformY = `${this.cachedTop * (sceneHeight! / 100)}px`;
|
||||
style.height = `${sceneHeight! - this.cachedTop * (sceneHeight! / 100) - (this.cachedBottom ?? 0) * (sceneHeight! / 100)}px`;
|
||||
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;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (horizontal) {
|
||||
case HorizontalConstraint.Left:
|
||||
transformX = `${this.cachedLeft}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
placement.left = placement.left ?? 0;
|
||||
placement.width = placement.width ?? 100;
|
||||
transformX = `${placement.left ?? 0}px`;
|
||||
style.width = `${placement.width}px`;
|
||||
delete placement.right;
|
||||
break;
|
||||
case HorizontalConstraint.Right:
|
||||
transformX = `${sceneWidth! - (this.cachedRight ?? 0) - this.cachedWidth}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
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;
|
||||
break;
|
||||
case HorizontalConstraint.LeftRight:
|
||||
transformX = `${this.cachedLeft}px`;
|
||||
style.width = `${sceneWidth! - this.cachedLeft - (this.cachedRight ?? 0)}px`;
|
||||
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;
|
||||
break;
|
||||
case HorizontalConstraint.Center:
|
||||
transformX = `${sceneWidth! / 2 - this.cachedLeft - this.cachedWidth / 2}px`;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
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;
|
||||
break;
|
||||
case HorizontalConstraint.Scale:
|
||||
transformX = `${this.cachedLeft * (sceneWidth! / 100)}px`;
|
||||
style.width = `${sceneWidth! - this.cachedLeft * (sceneWidth! / 100) - (this.cachedRight ?? 0) * (sceneWidth! / 100)}px`;
|
||||
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;
|
||||
break;
|
||||
}
|
||||
style.transform = `translate(${transformX}, ${transformY}) rotate(${this.cachedRotation}deg)`;
|
||||
this.options.placement = placement;
|
||||
style.transform = `translate(${transformX}, ${transformY}) rotate(${placement.rotation ?? 0}deg)`;
|
||||
this.sizeStyle = style;
|
||||
|
||||
if (this.div) {
|
||||
@@ -407,8 +415,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.cachedRotation && this.options.placement?.width && this.options.placement?.height) {
|
||||
const rotationDegrees = this.cachedRotation;
|
||||
if (this.options.placement?.rotation && this.options.placement?.width && this.options.placement?.height) {
|
||||
const rotationDegrees = this.options.placement.rotation;
|
||||
const rotationRadians = (Math.PI / 180) * rotationDegrees;
|
||||
let rotationOffset = rotationRadians;
|
||||
|
||||
@@ -430,8 +438,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.cachedWidth, this.cachedHeight);
|
||||
rotationLeftOffset = calculateDelta(this.cachedHeight, this.cachedWidth);
|
||||
rotationTopOffset = calculateDelta(this.options.placement.width, this.options.placement.height);
|
||||
rotationLeftOffset = calculateDelta(this.options.placement.height, this.options.placement.width);
|
||||
}
|
||||
|
||||
const relativeTop =
|
||||
@@ -455,103 +463,67 @@ export class ElementState implements LayerElement {
|
||||
transformScale
|
||||
: 0;
|
||||
|
||||
// Don't update placement if any position is field-driven
|
||||
if (this.hasFieldDrivenPosition()) {
|
||||
this.applyLayoutStylesToDiv();
|
||||
this.revId++;
|
||||
return;
|
||||
}
|
||||
const placement: Placement = {};
|
||||
|
||||
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 = fixedPosition(relativeTop);
|
||||
placement.height = fixedPosition(height);
|
||||
this.cachedTop = relativeTop;
|
||||
this.cachedHeight = height;
|
||||
placement.top = relativeTop;
|
||||
placement.height = height;
|
||||
break;
|
||||
case VerticalConstraint.Bottom:
|
||||
placement.bottom = fixedPosition(relativeBottom);
|
||||
placement.height = fixedPosition(height);
|
||||
this.cachedBottom = relativeBottom;
|
||||
this.cachedHeight = height;
|
||||
placement.bottom = relativeBottom;
|
||||
placement.height = height;
|
||||
break;
|
||||
case VerticalConstraint.TopBottom:
|
||||
placement.top = fixedPosition(relativeTop);
|
||||
placement.bottom = fixedPosition(relativeBottom);
|
||||
this.cachedTop = relativeTop;
|
||||
this.cachedBottom = relativeBottom;
|
||||
placement.top = relativeTop;
|
||||
placement.bottom = relativeBottom;
|
||||
break;
|
||||
case VerticalConstraint.Center:
|
||||
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;
|
||||
const elementCenter = elementContainer ? relativeTop + height / 2 : 0;
|
||||
const parentCenter = parentContainer ? parentContainer.height / 2 : 0;
|
||||
const distanceFromCenter = parentCenter - elementCenter;
|
||||
placement.top = distanceFromCenter;
|
||||
placement.height = height;
|
||||
break;
|
||||
case VerticalConstraint.Scale:
|
||||
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;
|
||||
placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (horizontal) {
|
||||
case HorizontalConstraint.Left:
|
||||
placement.left = fixedPosition(relativeLeft);
|
||||
placement.width = fixedPosition(width);
|
||||
this.cachedLeft = relativeLeft;
|
||||
this.cachedWidth = width;
|
||||
placement.left = relativeLeft;
|
||||
placement.width = width;
|
||||
break;
|
||||
case HorizontalConstraint.Right:
|
||||
placement.right = fixedPosition(relativeRight);
|
||||
placement.width = fixedPosition(width);
|
||||
this.cachedRight = relativeRight;
|
||||
this.cachedWidth = width;
|
||||
placement.right = relativeRight;
|
||||
placement.width = width;
|
||||
break;
|
||||
case HorizontalConstraint.LeftRight:
|
||||
placement.left = fixedPosition(relativeLeft);
|
||||
placement.right = fixedPosition(relativeRight);
|
||||
this.cachedLeft = relativeLeft;
|
||||
this.cachedRight = relativeRight;
|
||||
placement.left = relativeLeft;
|
||||
placement.right = relativeRight;
|
||||
break;
|
||||
case HorizontalConstraint.Center:
|
||||
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;
|
||||
const elementCenter = elementContainer ? relativeLeft + width / 2 : 0;
|
||||
const parentCenter = parentContainer ? parentContainer.width / 2 : 0;
|
||||
const distanceFromCenter = parentCenter - elementCenter;
|
||||
placement.left = distanceFromCenter;
|
||||
placement.width = width;
|
||||
break;
|
||||
case HorizontalConstraint.Scale:
|
||||
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;
|
||||
placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
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;
|
||||
@@ -582,109 +554,71 @@ export class ElementState implements LayerElement {
|
||||
const relativeLeft = Math.round(elementRect.left);
|
||||
const relativeRight = Math.round(scene.width - elementRect.left - elementRect.width);
|
||||
|
||||
// Don't update placement if any position is field-driven
|
||||
if (this.hasFieldDrivenPosition()) {
|
||||
this.applyLayoutStylesToDiv();
|
||||
this.revId++;
|
||||
return;
|
||||
}
|
||||
const placement: Placement = {};
|
||||
|
||||
const width = elementRect.width;
|
||||
const height = elementRect.height;
|
||||
|
||||
// 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;
|
||||
// INFO: calculate it anyway to be able to use it for pan&zoom
|
||||
placement.top = relativeTop;
|
||||
placement.left = relativeLeft;
|
||||
|
||||
switch (vertical) {
|
||||
case VerticalConstraint.Top:
|
||||
placement.top = fixedPosition(relativeTop);
|
||||
placement.height = fixedPosition(height);
|
||||
this.cachedTop = relativeTop;
|
||||
this.cachedHeight = height;
|
||||
placement.top = relativeTop;
|
||||
placement.height = height;
|
||||
break;
|
||||
case VerticalConstraint.Bottom:
|
||||
placement.bottom = fixedPosition(relativeBottom);
|
||||
placement.height = fixedPosition(height);
|
||||
this.cachedBottom = relativeBottom;
|
||||
this.cachedHeight = height;
|
||||
placement.bottom = relativeBottom;
|
||||
placement.height = height;
|
||||
break;
|
||||
case VerticalConstraint.TopBottom:
|
||||
placement.top = fixedPosition(relativeTop);
|
||||
placement.bottom = fixedPosition(relativeBottom);
|
||||
this.cachedTop = relativeTop;
|
||||
this.cachedBottom = relativeBottom;
|
||||
placement.top = relativeTop;
|
||||
placement.bottom = relativeBottom;
|
||||
break;
|
||||
case VerticalConstraint.Center:
|
||||
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;
|
||||
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;
|
||||
break;
|
||||
case VerticalConstraint.Scale:
|
||||
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;
|
||||
placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (horizontal) {
|
||||
case HorizontalConstraint.Left:
|
||||
placement.left = fixedPosition(relativeLeft);
|
||||
placement.width = fixedPosition(width);
|
||||
this.cachedLeft = relativeLeft;
|
||||
this.cachedWidth = width;
|
||||
placement.left = relativeLeft;
|
||||
placement.width = width;
|
||||
break;
|
||||
case HorizontalConstraint.Right:
|
||||
placement.right = fixedPosition(relativeRight);
|
||||
placement.width = fixedPosition(width);
|
||||
this.cachedRight = relativeRight;
|
||||
this.cachedWidth = width;
|
||||
placement.right = relativeRight;
|
||||
placement.width = width;
|
||||
break;
|
||||
case HorizontalConstraint.LeftRight:
|
||||
placement.left = fixedPosition(relativeLeft);
|
||||
placement.right = fixedPosition(relativeRight);
|
||||
this.cachedLeft = relativeLeft;
|
||||
this.cachedRight = relativeRight;
|
||||
placement.left = relativeLeft;
|
||||
placement.right = relativeRight;
|
||||
break;
|
||||
case HorizontalConstraint.Center:
|
||||
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;
|
||||
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;
|
||||
break;
|
||||
case HorizontalConstraint.Scale:
|
||||
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;
|
||||
placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
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;
|
||||
@@ -696,47 +630,11 @@ export class ElementState implements LayerElement {
|
||||
}
|
||||
|
||||
updateData(ctx: DimensionContext) {
|
||||
const previousData = this.data;
|
||||
|
||||
if (this.item.prepareData) {
|
||||
this.data = this.item.prepareData(ctx, this.options);
|
||||
|
||||
// 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++;
|
||||
}
|
||||
this.revId++; // rerender
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -895,11 +793,6 @@ 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) {
|
||||
@@ -920,31 +813,18 @@ export class ElementState implements LayerElement {
|
||||
applyRotate = (event: OnRotate) => {
|
||||
const rotationDelta = event.delta;
|
||||
const placement = this.options.placement!;
|
||||
const placementRotation = this.cachedRotation;
|
||||
const placementRotation = placement.rotation ?? 0;
|
||||
|
||||
const calculatedRotation = placementRotation + rotationDelta;
|
||||
|
||||
// Ensure rotation is between 0 and 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;
|
||||
placement.rotation = calculatedRotation - Math.floor(calculatedRotation / 360) * 360;
|
||||
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;
|
||||
@@ -954,8 +834,8 @@ export class ElementState implements LayerElement {
|
||||
let dirTB = event.direction[1];
|
||||
|
||||
// Handle case when element is rotated
|
||||
if (this.cachedRotation) {
|
||||
const rotation = this.cachedRotation;
|
||||
if (placement.rotation) {
|
||||
const rotation = placement.rotation ?? 0;
|
||||
const rotationInRadians = (rotation * Math.PI) / 180;
|
||||
const originalDirLR = dirLR;
|
||||
const originalDirTB = dirTB;
|
||||
@@ -965,37 +845,31 @@ export class ElementState implements LayerElement {
|
||||
}
|
||||
|
||||
if (dirLR === 1) {
|
||||
this.setPositionFixed(placement.width, event.width);
|
||||
this.cachedWidth = event.width;
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
placement.width = event.width;
|
||||
style.width = `${placement.width}px`;
|
||||
} else if (dirLR === -1) {
|
||||
this.cachedLeft -= deltaX;
|
||||
this.setPositionFixed(placement.left, this.cachedLeft);
|
||||
this.cachedWidth = event.width;
|
||||
this.setPositionFixed(placement.width, this.cachedWidth);
|
||||
placement.left! -= deltaX;
|
||||
placement.width = event.width;
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
style.transform = `translate(${this.cachedLeft}px, ${this.cachedTop}px) rotate(${this.cachedRotation}deg)`;
|
||||
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
|
||||
} else {
|
||||
style.left = `${this.cachedLeft}px`;
|
||||
style.left = `${placement.left}px`;
|
||||
}
|
||||
style.width = `${this.cachedWidth}px`;
|
||||
style.width = `${placement.width}px`;
|
||||
}
|
||||
|
||||
if (dirTB === -1) {
|
||||
this.cachedTop -= deltaY;
|
||||
this.setPositionFixed(placement.top, this.cachedTop);
|
||||
this.cachedHeight = event.height;
|
||||
this.setPositionFixed(placement.height, this.cachedHeight);
|
||||
placement.top! -= deltaY;
|
||||
placement.height = event.height;
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
style.transform = `translate(${this.cachedLeft}px, ${this.cachedTop}px) rotate(${this.cachedRotation}deg)`;
|
||||
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
|
||||
} else {
|
||||
style.top = `${this.cachedTop}px`;
|
||||
style.top = `${placement.top}px`;
|
||||
}
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
style.height = `${placement.height}px`;
|
||||
} else if (dirTB === 1) {
|
||||
this.cachedHeight = event.height;
|
||||
this.setPositionFixed(placement.height, this.cachedHeight);
|
||||
style.height = `${this.cachedHeight}px`;
|
||||
placement.height = event.height;
|
||||
style.height = `${placement.height}px`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1006,8 +880,7 @@ export class ElementState implements LayerElement {
|
||||
!scene?.isEditingEnabled && (!scene?.tooltipPayload?.isOpen || scene?.tooltipPayload?.element === this);
|
||||
if (shouldHandleTooltip) {
|
||||
this.handleTooltip(event);
|
||||
} else if (!isSelected && !this.hasFieldDrivenPosition()) {
|
||||
// Don't show connection anchors for field-driven elements
|
||||
} else if (!isSelected) {
|
||||
scene?.connections.handleMouseEnter(event);
|
||||
}
|
||||
|
||||
@@ -1217,25 +1090,6 @@ 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();
|
||||
@@ -1258,7 +1112,7 @@ export class ElementState implements LayerElement {
|
||||
key={`${this.UID}/${this.revId}`}
|
||||
config={this.options.config}
|
||||
data={this.data}
|
||||
isSelected={isSelected || this.isFieldDrivenSelected}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
</div>
|
||||
{this.showActionConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import {
|
||||
ColorDimensionConfig,
|
||||
PositionDimensionConfig,
|
||||
ResourceDimensionConfig,
|
||||
ScalarDimensionConfig,
|
||||
ScaleDimensionConfig,
|
||||
@@ -22,7 +21,6 @@ import { config } from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import {
|
||||
getColorDimensionFromData,
|
||||
getPositionDimensionFromData,
|
||||
getResourceDimensionFromData,
|
||||
getScalarDimensionFromData,
|
||||
getScaleDimensionFromData,
|
||||
@@ -111,22 +109,6 @@ 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,
|
||||
@@ -229,7 +211,6 @@ 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),
|
||||
@@ -286,8 +267,6 @@ 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);
|
||||
@@ -345,9 +324,6 @@ 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,7 +69,6 @@ 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[] = [];
|
||||
|
||||
@@ -78,10 +77,7 @@ const generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[]
|
||||
const currentElement = stack.shift();
|
||||
|
||||
if (currentElement && currentElement.div) {
|
||||
// Skip elements with field-driven positions - they can't be moved
|
||||
if (!currentElement.hasFieldDrivenPosition()) {
|
||||
targetElements.push(currentElement.div);
|
||||
}
|
||||
targetElements.push(currentElement.div);
|
||||
}
|
||||
|
||||
const nestedElements = currentElement instanceof FrameState ? currentElement.elements : [];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PanelData } from '@grafana/data';
|
||||
import {
|
||||
ColorDimensionConfig,
|
||||
PositionDimensionConfig,
|
||||
ResourceDimensionConfig,
|
||||
ScalarDimensionConfig,
|
||||
ScaleDimensionConfig,
|
||||
@@ -19,8 +18,6 @@ export interface DimensionContext {
|
||||
|
||||
getScalar(scalar: ScalarDimensionConfig): DimensionSupplier<number>;
|
||||
|
||||
getPosition(position: PositionDimensionConfig): DimensionSupplier<number>;
|
||||
|
||||
getText(text: TextDimensionConfig): DimensionSupplier<string>;
|
||||
|
||||
getResource(resource: ResourceDimensionConfig): DimensionSupplier<string>;
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useCallback, useId, useMemo } from 'react';
|
||||
|
||||
import { FieldType, SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
|
||||
import { InlineField, InlineFieldRow, RadioButtonGroup, Select } from '@grafana/ui';
|
||||
import { useFieldDisplayNames, useSelectOptions } from '@grafana/ui/internal';
|
||||
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
|
||||
|
||||
import { PositionDimensionOptions } from '../types';
|
||||
|
||||
type Props = StandardEditorProps<PositionDimensionConfig, PositionDimensionOptions>;
|
||||
|
||||
export const PositionDimensionEditor = ({ value, context, onChange }: Props) => {
|
||||
const positionOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t('dimensions.position-dimension-editor.label-fixed', 'Fixed'),
|
||||
value: PositionDimensionMode.Fixed,
|
||||
description: t('dimensions.position-dimension-editor.description-fixed', 'Fixed value'),
|
||||
},
|
||||
{
|
||||
label: t('dimensions.position-dimension-editor.label-field', 'Field'),
|
||||
value: PositionDimensionMode.Field,
|
||||
description: t('dimensions.position-dimension-editor.description-field', 'Use field value'),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const fixedValueOption: SelectableValue<string> = useMemo(
|
||||
() => ({
|
||||
label: t('dimensions.position-dimension-editor.fixed-value-option.label', 'Fixed value'),
|
||||
value: '_____fixed_____',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const labelWidth = 9;
|
||||
const fieldName = value?.field;
|
||||
const names = useFieldDisplayNames(context.data);
|
||||
// Filter to only show number fields for position values
|
||||
const selectOptions = useSelectOptions(names, fieldName, fixedValueOption, FieldType.number);
|
||||
|
||||
const onModeChange = useCallback(
|
||||
(mode: PositionDimensionMode) => {
|
||||
onChange({
|
||||
...value,
|
||||
mode,
|
||||
});
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
const onFieldChange = useCallback(
|
||||
(selection: SelectableValue<string>) => {
|
||||
const field = selection.value;
|
||||
if (field && field !== fixedValueOption.value) {
|
||||
onChange({
|
||||
...value,
|
||||
field,
|
||||
});
|
||||
} else {
|
||||
onChange({
|
||||
...value,
|
||||
field: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, value, fixedValueOption.value]
|
||||
);
|
||||
|
||||
const onFixedChange = useCallback(
|
||||
(fixed?: number) => {
|
||||
onChange({
|
||||
...value,
|
||||
fixed: fixed ?? 0,
|
||||
});
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
const fieldInputId = useId();
|
||||
const valueInputId = useId();
|
||||
|
||||
const mode = value?.mode ?? PositionDimensionMode.Fixed;
|
||||
const selectedOption =
|
||||
mode === PositionDimensionMode.Field ? selectOptions.find((v) => v.value === fieldName) : fixedValueOption;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineFieldRow>
|
||||
<InlineField
|
||||
label={t('dimensions.position-dimension-editor.label-source', 'Source')}
|
||||
labelWidth={labelWidth}
|
||||
grow={true}
|
||||
>
|
||||
<RadioButtonGroup value={mode} options={positionOptions} onChange={onModeChange} fullWidth />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
{mode === PositionDimensionMode.Field && (
|
||||
<InlineFieldRow>
|
||||
<InlineField
|
||||
label={t('dimensions.position-dimension-editor.label-field', 'Field')}
|
||||
labelWidth={labelWidth}
|
||||
grow={true}
|
||||
>
|
||||
<Select
|
||||
inputId={fieldInputId}
|
||||
value={selectedOption}
|
||||
options={selectOptions}
|
||||
onChange={onFieldChange}
|
||||
noOptionsMessage={t('dimensions.position-dimension-editor.no-fields', 'No number fields found')}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
{mode === PositionDimensionMode.Fixed && (
|
||||
<InlineFieldRow>
|
||||
<InlineField
|
||||
label={t('dimensions.position-dimension-editor.label-value', 'Value')}
|
||||
labelWidth={labelWidth}
|
||||
grow={true}
|
||||
>
|
||||
<NumberInput id={valueInputId} value={value?.fixed ?? 0} onChange={onFixedChange} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { DataFrame, Field } from '@grafana/data';
|
||||
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
|
||||
|
||||
import { DimensionSupplier } from './types';
|
||||
import { findField, getLastNotNullFieldValue } from './utils';
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Position dimension - simple fixed or field value
|
||||
//---------------------------------------------------------
|
||||
|
||||
export function getPositionDimension(
|
||||
frame: DataFrame | undefined,
|
||||
config: PositionDimensionConfig
|
||||
): DimensionSupplier<number> {
|
||||
return getPositionDimensionForField(findField(frame, config?.field), config);
|
||||
}
|
||||
|
||||
export function getPositionDimensionForField(
|
||||
field: Field | undefined,
|
||||
config: PositionDimensionConfig
|
||||
): DimensionSupplier<number> {
|
||||
const v = config.fixed ?? 0;
|
||||
const mode = config.mode ?? PositionDimensionMode.Fixed;
|
||||
|
||||
if (mode === PositionDimensionMode.Fixed) {
|
||||
return {
|
||||
isAssumed: !config.fixed,
|
||||
fixed: v,
|
||||
value: () => v,
|
||||
get: () => v,
|
||||
};
|
||||
}
|
||||
|
||||
// Field mode
|
||||
if (!field) {
|
||||
return {
|
||||
isAssumed: true,
|
||||
fixed: v,
|
||||
value: () => v,
|
||||
get: () => v,
|
||||
};
|
||||
}
|
||||
|
||||
const get = (i: number) => {
|
||||
const val = field.values[i];
|
||||
if (val === null || typeof val !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
return {
|
||||
field,
|
||||
get,
|
||||
value: () => {
|
||||
const val = getLastNotNullFieldValue(field);
|
||||
return typeof val === 'number' ? val : 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -46,10 +46,6 @@ export interface TextDimensionOptions {
|
||||
// anything?
|
||||
}
|
||||
|
||||
export interface PositionDimensionOptions {
|
||||
// anything?
|
||||
}
|
||||
|
||||
export const defaultTextConfig: TextDimensionConfig = Object.freeze({
|
||||
fixed: '',
|
||||
mode: TextDimensionMode.Field,
|
||||
|
||||
@@ -6,14 +6,12 @@ import {
|
||||
TextDimensionConfig,
|
||||
ColorDimensionConfig,
|
||||
ScalarDimensionConfig,
|
||||
PositionDimensionConfig,
|
||||
DirectionDimensionConfig,
|
||||
ConnectionDirection,
|
||||
} from '@grafana/schema';
|
||||
|
||||
import { getColorDimension } from './color';
|
||||
import { getDirectionDimension } from './direction';
|
||||
import { getPositionDimension } from './position';
|
||||
import { getResourceDimension } from './resource';
|
||||
import { getScalarDimension } from './scalar';
|
||||
import { getScaledDimension } from './scale';
|
||||
@@ -80,21 +78,6 @@ export function getScalarDimensionFromData(
|
||||
return getScalarDimension(undefined, cfg);
|
||||
}
|
||||
|
||||
export function getPositionDimensionFromData(
|
||||
data: PanelData | undefined,
|
||||
cfg: PositionDimensionConfig
|
||||
): DimensionSupplier<number> {
|
||||
if (data?.series && cfg.field) {
|
||||
for (const frame of data.series) {
|
||||
const d = getPositionDimension(frame, cfg);
|
||||
if (!d.isAssumed || data.series.length === 1) {
|
||||
return d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return getPositionDimension(undefined, cfg);
|
||||
}
|
||||
|
||||
export function getResourceDimensionFromData(
|
||||
data: PanelData | undefined,
|
||||
cfg: ResourceDimensionConfig
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { FormEvent } from 'react';
|
||||
import { FormEvent, useMemo } from 'react';
|
||||
|
||||
import { useListTeamQuery } from '@grafana/api-clients/rtkq/iam/v0alpha1';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, Checkbox, Stack, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||
import { Button, Checkbox, Stack, RadioButtonGroup, useStyles2, Combobox } from '@grafana/ui';
|
||||
import { SortPicker } from 'app/core/components/Select/SortPicker';
|
||||
import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter';
|
||||
|
||||
@@ -76,10 +77,25 @@ export const ActionRow = ({
|
||||
? [SearchLayout.Folders]
|
||||
: [];
|
||||
|
||||
const teams = useListTeamQuery({});
|
||||
|
||||
const teamOptions = useMemo(() => {
|
||||
return teams.data?.items.map((team) => ({
|
||||
label: team.spec.title,
|
||||
value: team.metadata.name || '',
|
||||
}));
|
||||
}, [teams.data?.items]);
|
||||
return (
|
||||
<Stack justifyContent="space-between" alignItems="center">
|
||||
<Stack gap={2} alignItems="center">
|
||||
<TagFilter isClearable={false} tags={state.tag} tagOptions={getTagOptions} onChange={onTagFilterChange} />
|
||||
<Combobox
|
||||
prefixIcon="user-arrows"
|
||||
onChange={() => {}}
|
||||
placeholder="Filter by owner"
|
||||
options={teamOptions || []}
|
||||
isClearable={false}
|
||||
/>
|
||||
{config.featureToggles.panelTitleSearch && (
|
||||
<Checkbox
|
||||
data-testid="include-panels"
|
||||
@@ -99,6 +115,13 @@ export const ActionRow = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* <div className={styles.checkboxWrapper}>
|
||||
<Checkbox
|
||||
label={t('search.actions.owned-by-me', 'My team folders')}
|
||||
onChange={onStarredFilterChange}
|
||||
value={state.teamFolders}
|
||||
/>
|
||||
</div> */}
|
||||
{state.datasource && (
|
||||
<Button icon="times" variant="secondary" onClick={() => onDatasourceChange(undefined)}>
|
||||
<Trans i18nKey="search.actions.remove-datasource-filter">
|
||||
|
||||
@@ -416,7 +416,7 @@ export function toDashboardResults(rsp: SearchAPIResponse, sort: string): DataFr
|
||||
|
||||
async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
|
||||
// TODO: use proper pagination for search.
|
||||
const uri = `${searchURI}?type=folders&limit=100000`;
|
||||
const uri = `${searchURI}?type=folder&limit=100000`;
|
||||
const rsp = getBackendSrv()
|
||||
.get<SearchAPIResponse>(uri)
|
||||
.then((rsp) => {
|
||||
|
||||
@@ -60,8 +60,11 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
|
||||
}
|
||||
|
||||
export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
|
||||
if (item && isSharedWithMe(item.uid)) {
|
||||
if (item.uid === 'teamfolders') {
|
||||
return 'users-alt';
|
||||
}
|
||||
if (item && isSharedWithMe(item.uid)) {
|
||||
return 'share-alt';
|
||||
} else {
|
||||
return getIconForKind(item.kind, isOpen);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Action } from 'redux';
|
||||
|
||||
import { OwnerReference } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||
import { WithAccessControlMetadata } from '@grafana/data';
|
||||
|
||||
import { ManagerKind } from '../apiserver/types';
|
||||
@@ -83,6 +84,7 @@ export interface DashboardViewItem {
|
||||
sortMeta?: number | string; // value sorted by
|
||||
sortMetaName?: string; // name of the value being sorted e.g. 'Views'
|
||||
managedBy?: ManagerKind;
|
||||
ownerReferences?: OwnerReference[];
|
||||
}
|
||||
|
||||
export interface SearchAction extends Action {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { UserEvent } from '@testing-library/user-event';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
import { render, screen, waitFor } from 'test/test-utils';
|
||||
|
||||
import { setBackendSrv } from '@grafana/runtime';
|
||||
import { config, setBackendSrv } from '@grafana/runtime';
|
||||
import { setupMockServer } from '@grafana/test-utils/server';
|
||||
import { MOCK_TEAMS } from '@grafana/test-utils/unstable';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
@@ -27,9 +27,15 @@ const setup = async () => {
|
||||
return view;
|
||||
};
|
||||
|
||||
const attemptCreateTeam = async (user: UserEvent, teamName?: string, teamEmail?: string) => {
|
||||
const attemptCreateTeam = async (
|
||||
user: UserEvent,
|
||||
teamName?: string,
|
||||
teamEmail?: string,
|
||||
createTeamFolder?: boolean
|
||||
) => {
|
||||
teamName && (await user.type(screen.getByRole('textbox', { name: /name/i }), teamName));
|
||||
teamEmail && (await user.type(screen.getByLabelText(/email/i), teamEmail));
|
||||
createTeamFolder && (await user.click(screen.getByLabelText(/auto-create a team folder/i)));
|
||||
await user.click(screen.getByRole('button', { name: /create/i }));
|
||||
};
|
||||
|
||||
@@ -72,4 +78,22 @@ describe('Create team', () => {
|
||||
|
||||
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('team folders enabled', () => {
|
||||
const originalFeatureToggles = config.featureToggles;
|
||||
beforeEach(() => {
|
||||
config.featureToggles = { ...originalFeatureToggles, teamFolders: true };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.featureToggles = originalFeatureToggles;
|
||||
});
|
||||
|
||||
it('renders team folder checkbox', async () => {
|
||||
const { user } = await setup();
|
||||
await attemptCreateTeam(user, MOCK_TEAMS[0].spec.title, undefined, true);
|
||||
|
||||
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useForm } from 'react-hook-form';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Button, Field, Input, FieldSet, Stack } from '@grafana/ui';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { Button, Field, Input, FieldSet, Stack, Checkbox, Alert } from '@grafana/ui';
|
||||
import { extractErrorMessage } from 'app/api/utils';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||
@@ -16,34 +16,42 @@ import { TeamDTO } from 'app/types/teams';
|
||||
|
||||
import { useCreateTeam } from './hooks';
|
||||
|
||||
const pageNav: NavModelItem = {
|
||||
icon: 'users-alt',
|
||||
id: 'team-new',
|
||||
text: 'New team',
|
||||
subTitle: 'Create a new team. Teams let you grant permissions to a group of users.',
|
||||
};
|
||||
type NewTeamForm = TeamDTO & { createTeamFolder?: boolean };
|
||||
|
||||
const CreateTeam = (): JSX.Element => {
|
||||
export const CreateTeam = (): JSX.Element => {
|
||||
const pageNav: NavModelItem = {
|
||||
icon: 'users-alt',
|
||||
id: 'team-new',
|
||||
text: t('teams.create-team.page-title', 'New team'),
|
||||
subTitle: t(
|
||||
'teams.create-team.page-subtitle',
|
||||
'Create a new team. Teams let you grant permissions to a group of users.'
|
||||
),
|
||||
};
|
||||
|
||||
const teamFoldersEnabled = config.featureToggles.teamFolders;
|
||||
const showRolesPicker = contextSrv.licensedAccessControlEnabled();
|
||||
const currentOrgId = contextSrv.user.orgId;
|
||||
|
||||
const notifyApp = useAppNotification();
|
||||
const [createTeamTrigger] = useCreateTeam();
|
||||
const [createTeamTrigger, createResponse] = useCreateTeam();
|
||||
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
|
||||
const [{ roleOptions }] = useRoleOptions(currentOrgId);
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<TeamDTO>();
|
||||
} = useForm<NewTeamForm>();
|
||||
|
||||
const createTeam = async (formModel: TeamDTO) => {
|
||||
const createTeam = async (formModel: NewTeamForm) => {
|
||||
try {
|
||||
const { data, error } = await createTeamTrigger(
|
||||
{
|
||||
email: formModel.email || '',
|
||||
name: formModel.name,
|
||||
},
|
||||
pendingRoles
|
||||
pendingRoles,
|
||||
formModel.createTeamFolder
|
||||
);
|
||||
|
||||
const errorMessage = error ? extractErrorMessage(error) : undefined;
|
||||
@@ -73,11 +81,11 @@ const CreateTeam = (): JSX.Element => {
|
||||
label={t('teams.create-team.label-name', 'Name')}
|
||||
required
|
||||
invalid={!!errors.name}
|
||||
error="Team name is required"
|
||||
error={t('teams.create-team.error-name-required', 'Team name is required')}
|
||||
>
|
||||
<Input {...register('name', { required: true })} id="team-name" />
|
||||
</Field>
|
||||
{contextSrv.licensedAccessControlEnabled() && (
|
||||
{showRolesPicker && (
|
||||
<Field noMargin label={t('teams.create-team.label-role', 'Role')}>
|
||||
<TeamRolePicker
|
||||
teamId={0}
|
||||
@@ -106,8 +114,37 @@ const CreateTeam = (): JSX.Element => {
|
||||
placeholder="email@test.com"
|
||||
/>
|
||||
</Field>
|
||||
{teamFoldersEnabled && (
|
||||
<Field
|
||||
noMargin
|
||||
label={t('teams.create-team.team-folder', 'Team folder')}
|
||||
description={t(
|
||||
'teams.create-team.description-team-folder',
|
||||
'This creates a folder associated with the team, where users can add resources like dashboards and schedules with the right permissions.'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
{...register('createTeamFolder')}
|
||||
id="team-folder"
|
||||
label={t(
|
||||
'teams.create-team.team-folder-label-autocreate-a-team-folder',
|
||||
'Auto-create a team folder'
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
{Boolean(createResponse.error) && (
|
||||
<Alert title={t('teams.create-team.error-title', 'Error creating team')} severity="error">
|
||||
<Trans i18nKey="teams.create-team.error-message">
|
||||
We were unable to create your new team. Please try again later or contact support.
|
||||
</Trans>
|
||||
<br />
|
||||
<br />
|
||||
<div>{extractErrorMessage(createResponse.error)}</div>
|
||||
</Alert>
|
||||
)}
|
||||
<Button type="submit" variant="primary">
|
||||
<Trans i18nKey="teams.create-team.create">Create</Trans>
|
||||
</Button>
|
||||
|
||||
23
public/app/features/teams/OwnedResources.tsx
Normal file
23
public/app/features/teams/OwnedResources.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useListFolderQuery } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||
import { Stack, Text, Link, Icon } from '@grafana/ui';
|
||||
import { Team } from 'app/types/teams';
|
||||
|
||||
export const OwnedResources = ({ team }: { team: Team }) => {
|
||||
const { data } = useListFolderQuery({});
|
||||
const ownedFolders = data?.items.filter((folder) =>
|
||||
folder.metadata.ownerReferences?.some((ref) => ref.uid === team.uid)
|
||||
);
|
||||
return (
|
||||
<Stack gap={1} direction="column">
|
||||
<Text variant="h3">Owned folders:</Text>
|
||||
{ownedFolders &&
|
||||
ownedFolders.map((folder) => (
|
||||
<div key={folder.metadata.uid}>
|
||||
<Link href={`/dashboards/f/${folder.metadata.name}`}>
|
||||
<Icon name="folder" /> <Text>{folder.spec.title}</Text>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
import { StoreState, useSelector } from 'app/types/store';
|
||||
|
||||
import { OwnedResources } from './OwnedResources';
|
||||
import TeamGroupSync, { TeamSyncUpgradeContent } from './TeamGroupSync';
|
||||
import TeamPermissions from './TeamPermissions';
|
||||
import TeamSettings from './TeamSettings';
|
||||
@@ -26,9 +27,10 @@ enum PageTypes {
|
||||
Members = 'members',
|
||||
Settings = 'settings',
|
||||
GroupSync = 'groupsync',
|
||||
Resources = 'resources',
|
||||
}
|
||||
|
||||
const PAGES = ['members', 'settings', 'groupsync'];
|
||||
const PAGES = ['members', 'settings', 'groupsync', 'resources'];
|
||||
|
||||
const pageNavSelector = createSelector(
|
||||
[
|
||||
@@ -59,24 +61,30 @@ const TeamPages = memo(() => {
|
||||
const renderPage = () => {
|
||||
const currentPage = PAGES.includes(pageName) ? pageName : PAGES[0];
|
||||
|
||||
const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, team!);
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, team);
|
||||
const canReadTeamPermissions = contextSrv.hasPermissionInMetadata(
|
||||
AccessControlAction.ActionTeamsPermissionsRead,
|
||||
team!
|
||||
team
|
||||
);
|
||||
const canWriteTeamPermissions = contextSrv.hasPermissionInMetadata(
|
||||
AccessControlAction.ActionTeamsPermissionsWrite,
|
||||
team!
|
||||
team
|
||||
);
|
||||
|
||||
switch (currentPage) {
|
||||
case PageTypes.Members:
|
||||
if (canReadTeamPermissions) {
|
||||
return <TeamPermissions team={team!} />;
|
||||
return <TeamPermissions team={team} />;
|
||||
}
|
||||
return null;
|
||||
case PageTypes.Settings:
|
||||
return canReadTeam && <TeamSettings team={team!} />;
|
||||
return canReadTeam && <TeamSettings team={team} />;
|
||||
case PageTypes.Resources:
|
||||
return canReadTeam && <OwnedResources team={team} />;
|
||||
case PageTypes.GroupSync:
|
||||
if (isSyncEnabled.current) {
|
||||
if (canReadTeamPermissions) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Field, FieldSet, Input, Stack } from '@grafana/ui';
|
||||
import { Button, Divider, Field, FieldSet, Input, Stack } from '@grafana/ui';
|
||||
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||
import { useRoleOptions } from 'app/core/components/RolePicker/hooks';
|
||||
import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
|
||||
@@ -97,6 +97,7 @@ const TeamSettings = ({ team }: Props) => {
|
||||
<Trans i18nKey="teams.team-settings.save">Save team details</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
<Divider />
|
||||
<SharedPreferences resourceUri={`teams/${team.id}`} disabled={!canWriteTeamSettings} preferenceType="team" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import {
|
||||
useSearchTeamsQuery as useLegacySearchTeamsQuery,
|
||||
useCreateTeamMutation,
|
||||
@@ -127,14 +128,16 @@ export const useDeleteTeam = () => {
|
||||
export const useCreateTeam = () => {
|
||||
const [createTeam, response] = useCreateTeamMutation();
|
||||
const [setTeamRoles] = useSetTeamRolesMutation();
|
||||
const [createFolder] = useCreateFolder();
|
||||
|
||||
const trigger = async (team: CreateTeamCommand, pendingRoles?: Role[]) => {
|
||||
const trigger = async (team: CreateTeamCommand, pendingRoles?: Role[], createTeamFolder?: boolean) => {
|
||||
const mutationResult = await createTeam({
|
||||
createTeamCommand: team,
|
||||
});
|
||||
|
||||
const { data } = mutationResult;
|
||||
|
||||
// Add any pending roles to the team
|
||||
if (data && data.teamId && pendingRoles && pendingRoles.length) {
|
||||
await contextSrv.fetchUserPermissions();
|
||||
if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles()) {
|
||||
@@ -147,6 +150,14 @@ export const useCreateTeam = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data.teamId && createTeamFolder) {
|
||||
await createFolder({
|
||||
title: team.name,
|
||||
createAsTeamFolder: true,
|
||||
teamUid: data.uid,
|
||||
});
|
||||
}
|
||||
|
||||
return mutationResult;
|
||||
};
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@ export function buildNavModel(team: Team): NavModelItem {
|
||||
url: `org/teams/edit/${team.uid}/members`,
|
||||
});
|
||||
}
|
||||
navModel.children!.push({
|
||||
active: false,
|
||||
icon: 'folder',
|
||||
id: `team-resources-${team.uid}`,
|
||||
text: 'Resources',
|
||||
url: `org/teams/edit/${team.uid}/resources`,
|
||||
});
|
||||
|
||||
const teamGroupSync: NavModelItem = {
|
||||
active: false,
|
||||
|
||||
@@ -27,8 +27,7 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
const simQuery = query.sim ?? ({} as SimulationQuery);
|
||||
const simKey = simQuery.key ?? {};
|
||||
// keep track of updated config state to pass down to form
|
||||
// Initialize from saved query config if it exists
|
||||
const [cfgValue, setCfgValue] = useState<Config>(simQuery.config ?? {});
|
||||
const [cfgValue, setCfgValue] = useState<Config>({});
|
||||
|
||||
// This only changes once
|
||||
const info = useAsync(async () => {
|
||||
@@ -51,19 +50,6 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
}, [info.value, simKey?.type]);
|
||||
|
||||
let config = useAsync(async () => {
|
||||
// If we have a saved config in the query, use that and update server
|
||||
if (simQuery.config && Object.keys(simQuery.config).length > 0) {
|
||||
let path = simKey.type + '/' + simKey.tick + 'hz';
|
||||
if (simKey.uid) {
|
||||
path += '/' + simKey.uid;
|
||||
}
|
||||
// Update server with saved config
|
||||
ds.postResource<SimInfo>('sim/' + path, simQuery.config).then((res) => {
|
||||
setCfgValue(res.config);
|
||||
});
|
||||
return simQuery.config;
|
||||
}
|
||||
// Otherwise fetch default config from server
|
||||
let path = simKey.type + '/' + simKey.tick + 'hz';
|
||||
if (simKey.uid) {
|
||||
path += '/' + simKey.uid;
|
||||
@@ -71,7 +57,7 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
let config = (await ds.getResource('sim/' + path))?.config;
|
||||
setCfgValue(config.value);
|
||||
return config;
|
||||
}, [simKey.type, simKey.tick, simKey.uid, simQuery.config]);
|
||||
}, [simKey.type, simKey.tick, simKey.uid]);
|
||||
|
||||
const onUpdateKey = (key: typeof simQuery.key) => {
|
||||
onChange({ ...query, sim: { ...simQuery, key } });
|
||||
@@ -104,9 +90,6 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
if (simKey.uid) {
|
||||
path += '/' + simKey.uid;
|
||||
}
|
||||
// Save config to query JSON so it persists in dashboard
|
||||
onChange({ ...query, sim: { ...simQuery, config } });
|
||||
// Also update server state
|
||||
ds.postResource<SimInfo>('sim/' + path, config).then((res) => {
|
||||
setCfgValue(res.config);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ const renderInput = (field: FieldSchema, onChange: SchemaFormProps['onChange'],
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={config?.[field.name]}
|
||||
defaultValue={config?.[field.name]}
|
||||
onChange={(e: FormEvent<HTMLInputElement>) => {
|
||||
const newValue = e.currentTarget.valueAsNumber;
|
||||
onChange({ ...config, [field.name]: newValue });
|
||||
@@ -76,7 +76,7 @@ export const SimulationSchemaForm = ({ config, schema, onChange }: SchemaFormPro
|
||||
onChange={() => setJsonView(!jsonView)}
|
||||
/>
|
||||
{jsonView ? (
|
||||
<TextArea value={JSON.stringify(config, null, 2)} rows={7} onChange={onUpdateTextArea} />
|
||||
<TextArea defaultValue={JSON.stringify(config, null, 2)} rows={7} onChange={onUpdateTextArea} />
|
||||
) : (
|
||||
<>
|
||||
{schema.fields.map((field) => (
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useObservable } from 'react-use';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { SelectableValue, StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data';
|
||||
import { SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { PositionDimensionConfig, ScalarDimensionConfig, ScalarDimensionMode } from '@grafana/schema';
|
||||
import { Field, Icon, InlineField, InlineFieldRow, Select, Stack } from '@grafana/ui';
|
||||
import { PositionDimensionEditor } from 'app/features/dimensions/editors/PositionDimensionEditor';
|
||||
import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor';
|
||||
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
|
||||
|
||||
import { HorizontalConstraint, Options, Placement, VerticalConstraint } from '../../panelcfg.gen';
|
||||
|
||||
@@ -14,7 +12,7 @@ import { ConstraintSelectionBox } from './ConstraintSelectionBox';
|
||||
import { QuickPositioning } from './QuickPositioning';
|
||||
import { CanvasEditorOptions } from './elementEditor';
|
||||
|
||||
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height'];
|
||||
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height', 'rotation'];
|
||||
|
||||
type Props = StandardEditorProps<unknown, CanvasEditorOptions, Options>;
|
||||
|
||||
@@ -63,9 +61,8 @@ export function PlacementEditor({ item }: Props) {
|
||||
const { options } = element;
|
||||
const { placement, constraint: layout } = options;
|
||||
|
||||
// Initialize rotation if not set
|
||||
if (placement && !placement.rotation) {
|
||||
placement.rotation = { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped };
|
||||
if (placement) {
|
||||
placement.rotation = placement?.rotation ?? 0;
|
||||
}
|
||||
|
||||
const reselectElementAfterChange = () => {
|
||||
@@ -98,34 +95,20 @@ export function PlacementEditor({ item }: Props) {
|
||||
reselectElementAfterChange();
|
||||
};
|
||||
|
||||
const onPositionChange = (value: PositionDimensionConfig | undefined, key: keyof Placement) => {
|
||||
if (value && key !== 'rotation') {
|
||||
element.options.placement![key] = value as any;
|
||||
element.updateData(settings.scene.context);
|
||||
element.applyLayoutStylesToDiv();
|
||||
settings.scene.clearCurrentSelection(true);
|
||||
reselectElementAfterChange();
|
||||
}
|
||||
};
|
||||
|
||||
const onRotationChange = (value?: ScalarDimensionConfig) => {
|
||||
if (value) {
|
||||
element.options.placement!.rotation = value;
|
||||
element.updateData(settings.scene.context);
|
||||
element.applyLayoutStylesToDiv();
|
||||
settings.scene.clearCurrentSelection(true);
|
||||
reselectElementAfterChange();
|
||||
}
|
||||
const onPositionChange = (value: number | undefined, placement: keyof Placement) => {
|
||||
element.options.placement![placement] = value ?? element.options.placement![placement];
|
||||
element.applyLayoutStylesToDiv();
|
||||
settings.scene.clearCurrentSelection(true);
|
||||
reselectElementAfterChange();
|
||||
};
|
||||
|
||||
const constraint = element.tempConstraint ?? layout ?? {};
|
||||
const editorContext = { ...settings.scene.context, data: settings.scene.context.getPanelData()?.series ?? [] };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<QuickPositioning onPositionChange={onPositionChange} settings={settings} element={element} />
|
||||
<br />
|
||||
<Field label={t('canvas.placement-editor.label-constraints', 'Constraints')} noMargin>
|
||||
<Field label={t('canvas.placement-editor.label-constraints', 'Constraints')}>
|
||||
<Stack direction="row">
|
||||
<ConstraintSelectionBox
|
||||
onVerticalConstraintChange={onVerticalConstraintChange}
|
||||
@@ -151,7 +134,7 @@ export function PlacementEditor({ item }: Props) {
|
||||
|
||||
<br />
|
||||
|
||||
<Field label={t('canvas.placement-editor.label-position', 'Position')} noMargin>
|
||||
<Field label={t('canvas.placement-editor.label-position', 'Position')}>
|
||||
<>
|
||||
{places.map((p) => {
|
||||
const v = placement![p];
|
||||
@@ -159,40 +142,18 @@ export function PlacementEditor({ item }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to set explicit min/max for rotation as logic only can handle 0-360
|
||||
const min = p === 'rotation' ? 0 : undefined;
|
||||
const max = p === 'rotation' ? 360 : undefined;
|
||||
|
||||
return (
|
||||
<InlineFieldRow key={p}>
|
||||
<InlineField label={p} labelWidth={8} grow={true}>
|
||||
<PositionDimensionEditor
|
||||
value={v as PositionDimensionConfig}
|
||||
context={editorContext}
|
||||
onChange={(val) => onPositionChange(val, p)}
|
||||
item={{} as any}
|
||||
/>
|
||||
<NumberInput min={min} max={max} value={v} onChange={(v) => onPositionChange(v, p)} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
})}
|
||||
{placement?.rotation && (
|
||||
<InlineFieldRow>
|
||||
<InlineField label={t('canvas.placement-editor.label-rotation', 'rotation')} labelWidth={8} grow={true}>
|
||||
<ScalarDimensionEditor
|
||||
value={placement.rotation}
|
||||
context={editorContext}
|
||||
onChange={onRotationChange}
|
||||
item={
|
||||
{
|
||||
id: 'rotation',
|
||||
name: 'Rotation',
|
||||
settings: {
|
||||
min: 0,
|
||||
max: 360,
|
||||
},
|
||||
} as StandardEditorsRegistryItem<ScalarDimensionConfig>
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
</>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
|
||||
import { IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { QuickPlacement } from 'app/features/canvas/types';
|
||||
@@ -12,7 +11,7 @@ import { HorizontalConstraint, VerticalConstraint, Placement } from '../../panel
|
||||
import { CanvasEditorOptions } from './elementEditor';
|
||||
|
||||
type Props = {
|
||||
onPositionChange: (value: PositionDimensionConfig | undefined, placement: keyof Placement) => void;
|
||||
onPositionChange: (value: number | undefined, placement: keyof Placement) => void;
|
||||
element: ElementState;
|
||||
settings: CanvasEditorOptions;
|
||||
};
|
||||
@@ -20,17 +19,6 @@ type Props = {
|
||||
export const QuickPositioning = ({ onPositionChange, element, settings }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// Helper to get numeric value from PositionDimensionConfig
|
||||
const getPositionValue = (config: PositionDimensionConfig | undefined): number => {
|
||||
return config?.fixed ?? 0;
|
||||
};
|
||||
|
||||
// Helper to create a fixed PositionDimensionConfig
|
||||
const fixedPosition = (value: number): PositionDimensionConfig => ({
|
||||
fixed: value,
|
||||
mode: PositionDimensionMode.Fixed,
|
||||
});
|
||||
|
||||
const onQuickPositioningChange = (position: QuickPlacement) => {
|
||||
const defaultConstraint = { vertical: VerticalConstraint.Top, horizontal: HorizontalConstraint.Left };
|
||||
const originalConstraint = { ...element.options.constraint };
|
||||
@@ -38,27 +26,24 @@ export const QuickPositioning = ({ onPositionChange, element, settings }: Props)
|
||||
element.options.constraint = defaultConstraint;
|
||||
element.setPlacementFromConstraint();
|
||||
|
||||
const height = getPositionValue(element.options.placement?.height);
|
||||
const width = getPositionValue(element.options.placement?.width);
|
||||
|
||||
switch (position) {
|
||||
case QuickPlacement.Top:
|
||||
onPositionChange(fixedPosition(0), 'top');
|
||||
onPositionChange(0, 'top');
|
||||
break;
|
||||
case QuickPlacement.Bottom:
|
||||
onPositionChange(fixedPosition(getRightBottomPosition(height, 'bottom')), 'top');
|
||||
onPositionChange(getRightBottomPosition(element.options.placement?.height ?? 0, 'bottom'), 'top');
|
||||
break;
|
||||
case QuickPlacement.VerticalCenter:
|
||||
onPositionChange(fixedPosition(getCenterPosition(height, 'v')), 'top');
|
||||
onPositionChange(getCenterPosition(element.options.placement?.height ?? 0, 'v'), 'top');
|
||||
break;
|
||||
case QuickPlacement.Left:
|
||||
onPositionChange(fixedPosition(0), 'left');
|
||||
onPositionChange(0, 'left');
|
||||
break;
|
||||
case QuickPlacement.Right:
|
||||
onPositionChange(fixedPosition(getRightBottomPosition(width, 'right')), 'left');
|
||||
onPositionChange(getRightBottomPosition(element.options.placement?.width ?? 0, 'right'), 'left');
|
||||
break;
|
||||
case QuickPlacement.HorizontalCenter:
|
||||
onPositionChange(fixedPosition(getCenterPosition(width, 'h')), 'left');
|
||||
onPositionChange(getCenterPosition(element.options.placement?.width ?? 0, 'h'), 'left');
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { PanelModel, OneClickMode } from '@grafana/data';
|
||||
import { PositionDimensionMode, ScalarDimensionMode } from '@grafana/schema';
|
||||
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
// Helper to migrate a position value from number to PositionDimensionConfig
|
||||
const migratePositionValue = (value: number | undefined) => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
fixed: value,
|
||||
mode: PositionDimensionMode.Fixed,
|
||||
};
|
||||
};
|
||||
|
||||
export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
|
||||
const pluginVersion = panel?.pluginVersion ?? '';
|
||||
|
||||
@@ -111,52 +99,5 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
|
||||
}
|
||||
}
|
||||
|
||||
// migrate placement values from numbers to dimension configs
|
||||
if (parseFloat(pluginVersion) <= 12.4) {
|
||||
const root = panel.options?.root;
|
||||
if (root?.elements) {
|
||||
for (const element of root.elements) {
|
||||
if (element.placement) {
|
||||
// Migrate rotation from number to ScalarDimensionConfig
|
||||
if (typeof element.placement.rotation === 'number') {
|
||||
element.placement.rotation = {
|
||||
fixed: element.placement.rotation,
|
||||
min: 0,
|
||||
max: 360,
|
||||
mode: ScalarDimensionMode.Clamped,
|
||||
};
|
||||
} else if (!element.placement.rotation) {
|
||||
element.placement.rotation = {
|
||||
fixed: 0,
|
||||
min: 0,
|
||||
max: 360,
|
||||
mode: ScalarDimensionMode.Clamped,
|
||||
};
|
||||
}
|
||||
|
||||
// Migrate position values from numbers to PositionDimensionConfig
|
||||
if (typeof element.placement.top === 'number') {
|
||||
element.placement.top = migratePositionValue(element.placement.top);
|
||||
}
|
||||
if (typeof element.placement.left === 'number') {
|
||||
element.placement.left = migratePositionValue(element.placement.left);
|
||||
}
|
||||
if (typeof element.placement.width === 'number') {
|
||||
element.placement.width = migratePositionValue(element.placement.width);
|
||||
}
|
||||
if (typeof element.placement.height === 'number') {
|
||||
element.placement.height = migratePositionValue(element.placement.height);
|
||||
}
|
||||
if (typeof element.placement.right === 'number') {
|
||||
element.placement.right = migratePositionValue(element.placement.right);
|
||||
}
|
||||
if (typeof element.placement.bottom === 'number') {
|
||||
element.placement.bottom = migratePositionValue(element.placement.bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return panel.options;
|
||||
};
|
||||
|
||||
@@ -35,15 +35,15 @@ composableKinds: PanelCfg: {
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
Placement: {
|
||||
top?: ui.PositionDimensionConfig
|
||||
left?: ui.PositionDimensionConfig
|
||||
right?: ui.PositionDimensionConfig
|
||||
bottom?: ui.PositionDimensionConfig
|
||||
top?: float64
|
||||
left?: float64
|
||||
right?: float64
|
||||
bottom?: float64
|
||||
|
||||
width?: ui.PositionDimensionConfig
|
||||
height?: ui.PositionDimensionConfig
|
||||
width?: float64
|
||||
height?: float64
|
||||
|
||||
rotation?: ui.ScalarDimensionConfig
|
||||
rotation?: float64
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
BackgroundImageSize: "original" | "contain" | "cover" | "fill" | "tile" @cuetsy(kind="enum", memberNames="Original|Contain|Cover|Fill|Tile")
|
||||
|
||||
14
public/app/plugins/panel/canvas/panelcfg.gen.ts
generated
14
public/app/plugins/panel/canvas/panelcfg.gen.ts
generated
@@ -32,13 +32,13 @@ export interface Constraint {
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
bottom?: ui.PositionDimensionConfig;
|
||||
height?: ui.PositionDimensionConfig;
|
||||
left?: ui.PositionDimensionConfig;
|
||||
right?: ui.PositionDimensionConfig;
|
||||
rotation?: ui.ScalarDimensionConfig;
|
||||
top?: ui.PositionDimensionConfig;
|
||||
width?: ui.PositionDimensionConfig;
|
||||
bottom?: number;
|
||||
height?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
rotation?: number;
|
||||
top?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export enum BackgroundImageSize {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isNumber, isString } from 'lodash';
|
||||
|
||||
import { DataFrame, Field, AppEvents, getFieldDisplayName, PluginState, SelectableValue } from '@grafana/data';
|
||||
import { ConnectionDirection, PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
|
||||
import { ConnectionDirection } from '@grafana/schema';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { hasAlphaPanels, config } from 'app/core/config';
|
||||
import { CanvasConnection, CanvasElementItem, CanvasElementOptions } from 'app/features/canvas/element';
|
||||
@@ -15,9 +15,6 @@ import { AnchorPoint, ConnectionState, LineStyle, StrokeDasharray } from './type
|
||||
|
||||
export function doSelect(scene: Scene, element: ElementState | FrameState) {
|
||||
try {
|
||||
// Clear any previous field-driven selection
|
||||
scene.clearFieldDrivenSelection?.();
|
||||
|
||||
let selection: SelectionParams = { targets: [] };
|
||||
if (element instanceof FrameState) {
|
||||
const targetElements: HTMLDivElement[] = [];
|
||||
@@ -25,14 +22,6 @@ export function doSelect(scene: Scene, element: ElementState | FrameState) {
|
||||
selection.targets = targetElements;
|
||||
selection.frame = element;
|
||||
scene.select(selection);
|
||||
} else if (element.hasFieldDrivenPosition()) {
|
||||
// Field-driven elements can't be selected via Selecto, show custom selection
|
||||
scene.currentLayer = element.parent;
|
||||
scene.setFieldDrivenSelection(element);
|
||||
// Clear Selecto selection and broadcast this element as selected
|
||||
scene.selecto?.setSelectedTargets([]);
|
||||
scene.moveable!.target = [];
|
||||
scene.selection.next([element]);
|
||||
} else {
|
||||
scene.currentLayer = element.parent;
|
||||
selection.targets = [element?.div!];
|
||||
@@ -92,30 +81,12 @@ export function onAddItem(sel: SelectableValue<string>, rootLayer: FrameState |
|
||||
name: '',
|
||||
};
|
||||
|
||||
// Helper to create a fixed PositionDimensionConfig
|
||||
const fixedPosition = (value: number): PositionDimensionConfig => ({
|
||||
fixed: value,
|
||||
mode: PositionDimensionMode.Fixed,
|
||||
});
|
||||
|
||||
if (anchorPoint) {
|
||||
newElementOptions.placement = {
|
||||
...newElementOptions.placement,
|
||||
top: fixedPosition(anchorPoint.y),
|
||||
left: fixedPosition(anchorPoint.x),
|
||||
};
|
||||
newElementOptions.placement = { ...newElementOptions.placement, top: anchorPoint.y, left: anchorPoint.x };
|
||||
}
|
||||
|
||||
if (newItem.defaultSize) {
|
||||
// defaultSize uses simple numbers, convert to PositionDimensionConfig
|
||||
const sizeConfig: Partial<typeof newElementOptions.placement> = {};
|
||||
if (newItem.defaultSize.width !== undefined) {
|
||||
sizeConfig.width = fixedPosition(newItem.defaultSize.width);
|
||||
}
|
||||
if (newItem.defaultSize.height !== undefined) {
|
||||
sizeConfig.height = fixedPosition(newItem.defaultSize.height);
|
||||
}
|
||||
newElementOptions.placement = { ...newElementOptions.placement, ...sizeConfig };
|
||||
newElementOptions.placement = { ...newElementOptions.placement, ...newItem.defaultSize };
|
||||
}
|
||||
|
||||
if (rootLayer) {
|
||||
|
||||
Reference in New Issue
Block a user