diff --git a/.betterer.results b/.betterer.results index 44be1e9a8f3..a08ea50cf59 100644 --- a/.betterer.results +++ b/.betterer.results @@ -538,8 +538,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"] ], "packages/grafana-ui/src/components/Combobox/Combobox.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -547,6 +546,9 @@ exports[`better eslint`] = { "packages/grafana-ui/src/components/Combobox/ValuePill.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], + "packages/grafana-ui/src/components/Combobox/useOptions.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], @@ -3486,24 +3488,6 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/scene/UnlinkModal.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], - "public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] - ], - "public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] - ], - "public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] - ], - "public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] - ], "public/app/features/dashboard-scene/scene/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 927936a27d6..024f80e0ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ + + +# 11.5.1 (2025-02-03) + +### Bug fixes + +- **CodeEditor:** Fix cursor alignment [#99090](https://github.com/grafana/grafana/pull/99090), [@ashharrison90](https://github.com/ashharrison90) +- **TransformationFilter**: Include transformation outputs in transformation filtering options: Include transformation outputs in transformation filtering options [#98323](https://github.com/grafana/grafana/pull/98323), [@Sergej-Vlasov](https://github.com/Sergej-Vlasov) + + # 11.5.0 (2025-01-28) diff --git a/apps/advisor/.gitignore b/apps/advisor/.gitignore new file mode 100644 index 00000000000..9e73bafbae8 --- /dev/null +++ b/apps/advisor/.gitignore @@ -0,0 +1 @@ +plugin/src \ No newline at end of file diff --git a/apps/advisor/kinds/check.cue b/apps/advisor/kinds/check.cue index cf1cfd58d39..fdf641abdf3 100644 --- a/apps/advisor/kinds/check.cue +++ b/apps/advisor/kinds/check.cue @@ -7,7 +7,7 @@ check: { versions: { "v0alpha1": { codegen: { - frontend: false + frontend: true backend: true } validation: { @@ -17,24 +17,27 @@ check: { ] } schema: { - spec: { + #Data: { // Generic data input that a check can receive data?: [string]: string } - status: { - report: { + #ReportError: { + // Severity of the error + severity: "high" | "low" + // Human readable reason for the error + reason: string + // Action to take to resolve the error + action: string + } + #Report: { // Number of elements analyzed count: int // List of errors - errors: [...{ - // Severity of the error - severity: "high" | "low" - // Human readable reason for the error - reason: string - // Action to take to resolve the error - action: string - }] - } + errors: [...#ReportError] + } + spec: #Data + status: { + report: #Report } } } diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go index 057ed6c4b7e..ac73ee86ec4 100644 --- a/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go +++ b/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go @@ -2,6 +2,21 @@ package v0alpha1 +// +k8s:openapi-gen=true +type CheckReportError struct { + // Severity of the error + Severity CheckReportErrorSeverity `json:"severity"` + // Human readable reason for the error + Reason string `json:"reason"` + // Action to take to resolve the error + Action string `json:"action"` +} + +// NewCheckReportError creates a new CheckReportError object. +func NewCheckReportError() *CheckReportError { + return &CheckReportError{} +} + // +k8s:openapi-gen=true type CheckstatusOperatorState struct { // lastEvaluation is the ResourceVersion last evaluated @@ -37,6 +52,14 @@ func NewCheckStatus() *CheckStatus { } } +// +k8s:openapi-gen=true +type CheckReportErrorSeverity string + +const ( + CheckReportErrorSeverityHigh CheckReportErrorSeverity = "high" + CheckReportErrorSeverityLow CheckReportErrorSeverity = "low" +) + // +k8s:openapi-gen=true type CheckStatusOperatorStateState string @@ -46,35 +69,12 @@ const ( CheckStatusOperatorStateStateFailed CheckStatusOperatorStateState = "failed" ) -// +k8s:openapi-gen=true -type CheckStatusSeverity string - -const ( - CheckStatusSeverityHigh CheckStatusSeverity = "high" - CheckStatusSeverityLow CheckStatusSeverity = "low" -) - -// +k8s:openapi-gen=true -type CheckV0alpha1StatusReportErrors struct { - // Severity of the error - Severity CheckStatusSeverity `json:"severity"` - // Human readable reason for the error - Reason string `json:"reason"` - // Action to take to resolve the error - Action string `json:"action"` -} - -// NewCheckV0alpha1StatusReportErrors creates a new CheckV0alpha1StatusReportErrors object. -func NewCheckV0alpha1StatusReportErrors() *CheckV0alpha1StatusReportErrors { - return &CheckV0alpha1StatusReportErrors{} -} - // +k8s:openapi-gen=true type CheckV0alpha1StatusReport struct { // Number of elements analyzed Count int64 `json:"count"` // List of errors - Errors []CheckV0alpha1StatusReportErrors `json:"errors"` + Errors []CheckReportError `json:"errors"` } // NewCheckV0alpha1StatusReport creates a new CheckV0alpha1StatusReport object. diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go index a78d033bb26..74ccb4861e6 100644 --- a/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go +++ b/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go @@ -12,13 +12,13 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.Check": schema_pkg_apis_advisor_v0alpha1_Check(ref), - "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckList": schema_pkg_apis_advisor_v0alpha1_CheckList(ref), - "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckSpec": schema_pkg_apis_advisor_v0alpha1_CheckSpec(ref), - "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckStatus": schema_pkg_apis_advisor_v0alpha1_CheckStatus(ref), - "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReport": schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref), - "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReportErrors": schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReportErrors(ref), - "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckstatusOperatorState": schema_pkg_apis_advisor_v0alpha1_CheckstatusOperatorState(ref), + "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.Check": schema_pkg_apis_advisor_v0alpha1_Check(ref), + "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckList": schema_pkg_apis_advisor_v0alpha1_CheckList(ref), + "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportError": schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref), + "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckSpec": schema_pkg_apis_advisor_v0alpha1_CheckSpec(ref), + "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckStatus": schema_pkg_apis_advisor_v0alpha1_CheckStatus(ref), + "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReport": schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref), + "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckstatusOperatorState": schema_pkg_apis_advisor_v0alpha1_CheckstatusOperatorState(ref), } } @@ -117,6 +117,43 @@ func schema_pkg_apis_advisor_v0alpha1_CheckList(ref common.ReferenceCallback) co } } +func schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "severity": { + SchemaProps: spec.SchemaProps{ + Description: "Severity of the error", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "reason": { + SchemaProps: spec.SchemaProps{ + Description: "Human readable reason for the error", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "action": { + SchemaProps: spec.SchemaProps{ + Description: "Action to take to resolve the error", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"severity", "reason", "action"}, + }, + }, + } +} + func schema_pkg_apis_advisor_v0alpha1_CheckSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -218,7 +255,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref common.Refer Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReportErrors"), + Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportError"), }, }, }, @@ -229,44 +266,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReportErrors"}, - } -} - -func schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReportErrors(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "severity": { - SchemaProps: spec.SchemaProps{ - Description: "Severity of the error", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "reason": { - SchemaProps: spec.SchemaProps{ - Description: "Human readable reason for the error", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "action": { - SchemaProps: spec.SchemaProps{ - Description: "Action to take to resolve the error", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"severity", "reason", "action"}, - }, - }, + "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportError"}, } } diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check.go b/apps/advisor/pkg/app/checks/datasourcecheck/check.go index 8ff4c1a1dc3..5c5d7d8bdcc 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check.go @@ -49,13 +49,13 @@ func (c *check) Run(ctx context.Context, obj *advisor.CheckSpec) (*advisor.Check return nil, err } - dsErrs := []advisor.CheckV0alpha1StatusReportErrors{} + dsErrs := []advisor.CheckReportError{} for _, ds := range dss { // Data source UID validation err := util.ValidateUID(ds.UID) if err != nil { - dsErrs = append(dsErrs, advisor.CheckV0alpha1StatusReportErrors{ - Severity: advisor.CheckStatusSeverityLow, + dsErrs = append(dsErrs, advisor.CheckReportError{ + Severity: advisor.CheckReportErrorSeverityLow, Reason: fmt.Sprintf("Invalid UID '%s' for data source %s", ds.UID, ds.Name), Action: "Check the documentation for more information.", }) @@ -81,8 +81,8 @@ func (c *check) Run(ctx context.Context, obj *advisor.CheckSpec) (*advisor.Check continue } if resp.Status != backend.HealthStatusOk { - dsErrs = append(dsErrs, advisor.CheckV0alpha1StatusReportErrors{ - Severity: advisor.CheckStatusSeverityHigh, + dsErrs = append(dsErrs, advisor.CheckReportError{ + Severity: advisor.CheckReportErrorSeverityHigh, Reason: fmt.Sprintf("Health check failed for %s", ds.Name), Action: fmt.Sprintf( "Go to the data source configuration"+ diff --git a/apps/advisor/pkg/app/checks/plugincheck/check.go b/apps/advisor/pkg/app/checks/plugincheck/check.go index 793fc935946..8a20820f450 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check.go @@ -43,7 +43,7 @@ func (c *check) Type() string { func (c *check) Run(ctx context.Context, _ *advisor.CheckSpec) (*advisor.CheckV0alpha1StatusReport, error) { ps := c.PluginStore.Plugins(ctx) - errs := []advisor.CheckV0alpha1StatusReportErrors{} + errs := []advisor.CheckReportError{} for _, p := range ps { // Skip if it's a core plugin if p.IsCorePlugin() { @@ -56,8 +56,8 @@ func (c *check) Run(ctx context.Context, _ *advisor.CheckSpec) (*advisor.CheckV0 continue } if i.Status == "deprecated" { - errs = append(errs, advisor.CheckV0alpha1StatusReportErrors{ - Severity: advisor.CheckStatusSeverityHigh, + errs = append(errs, advisor.CheckReportError{ + Severity: advisor.CheckReportErrorSeverityHigh, Reason: fmt.Sprintf("Plugin deprecated: %s", p.ID), Action: "Check the documentation for recommended steps.", }) @@ -73,8 +73,8 @@ func (c *check) Run(ctx context.Context, _ *advisor.CheckSpec) (*advisor.CheckV0 continue } if hasUpdate(p, info) { - errs = append(errs, advisor.CheckV0alpha1StatusReportErrors{ - Severity: advisor.CheckStatusSeverityLow, + errs = append(errs, advisor.CheckReportError{ + Severity: advisor.CheckReportErrorSeverityLow, Reason: fmt.Sprintf("New version available for %s", p.ID), Action: fmt.Sprintf( "Go to the plugin admin page"+ diff --git a/apps/advisor/pkg/app/checks/plugincheck/check_test.go b/apps/advisor/pkg/app/checks/plugincheck/check_test.go index 3fa7f1b20cb..41c0705e904 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check_test.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check_test.go @@ -21,12 +21,12 @@ func TestRun(t *testing.T) { pluginArchives map[string]*repo.PluginArchiveInfo pluginPreinstalled []string pluginManaged []string - expectedErrors []advisor.CheckV0alpha1StatusReportErrors + expectedErrors []advisor.CheckReportError }{ { name: "No plugins", plugins: []pluginstore.Plugin{}, - expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{}, + expectedErrors: []advisor.CheckReportError{}, }, { name: "Deprecated plugin", @@ -39,9 +39,9 @@ func TestRun(t *testing.T) { pluginArchives: map[string]*repo.PluginArchiveInfo{ "plugin1": {Version: "1.0.0"}, }, - expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{ + expectedErrors: []advisor.CheckReportError{ { - Severity: advisor.CheckStatusSeverityHigh, + Severity: advisor.CheckReportErrorSeverityHigh, Reason: "Plugin deprecated: plugin1", Action: "Check the documentation for recommended steps.", }, @@ -58,9 +58,9 @@ func TestRun(t *testing.T) { pluginArchives: map[string]*repo.PluginArchiveInfo{ "plugin2": {Version: "1.1.0"}, }, - expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{ + expectedErrors: []advisor.CheckReportError{ { - Severity: advisor.CheckStatusSeverityLow, + Severity: advisor.CheckReportErrorSeverityLow, Reason: "New version available for plugin2", Action: "Go to the plugin admin page and upgrade to the latest version.", }, @@ -77,9 +77,9 @@ func TestRun(t *testing.T) { pluginArchives: map[string]*repo.PluginArchiveInfo{ "plugin2": {Version: "beta"}, }, - expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{ + expectedErrors: []advisor.CheckReportError{ { - Severity: advisor.CheckStatusSeverityLow, + Severity: advisor.CheckReportErrorSeverityLow, Reason: "New version available for plugin2", Action: "Go to the plugin admin page and upgrade to the latest version.", }, @@ -97,7 +97,7 @@ func TestRun(t *testing.T) { "plugin3": {Version: "1.1.0"}, }, pluginPreinstalled: []string{"plugin3"}, - expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{}, + expectedErrors: []advisor.CheckReportError{}, }, { name: "Managed plugin", @@ -111,7 +111,7 @@ func TestRun(t *testing.T) { "plugin4": {Version: "1.1.0"}, }, pluginManaged: []string{"plugin4"}, - expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{}, + expectedErrors: []advisor.CheckReportError{}, }, } diff --git a/apps/advisor/plugin/README.md b/apps/advisor/plugin/README.md new file mode 100644 index 00000000000..5b9e2bc8744 --- /dev/null +++ b/apps/advisor/plugin/README.md @@ -0,0 +1,7 @@ +This folder contains the automatically generated types for the frontend that are used in the app plugin. + +To update the types: + +1. Make any necessary changes in the `kinds` directory +2. Run `make generate` +3. Copy the `plugin` directory to the frontend app: https://github.com/grafana/grafana-advisor-app/tree/main/src/plugin diff --git a/e2e/old-arch/various-suite/solo-route.spec.ts b/e2e/old-arch/various-suite/solo-route.spec.ts index cfea359cff2..9717baf41bd 100644 --- a/e2e/old-arch/various-suite/solo-route.spec.ts +++ b/e2e/old-arch/various-suite/solo-route.spec.ts @@ -5,7 +5,7 @@ describe('Solo Route', () => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); - it('Can view panels with shared queries in fullsceen', () => { + it('Can view panels with shared queries in fullscreen', () => { // open Panel Tests - Bar Gauge e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4'); @@ -25,20 +25,20 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server=B').should('exist'); + e2e.components.Panels.Panel.title('server=A').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); - it('Can view solo in repeaterd row and panel in scenes', () => { + it('Can view solo in repeated row and panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-clone-D-clone-2&__feature.dashboardSceneSolo=true' + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist'); + e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); }); diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts index cfea359cff2..9717baf41bd 100644 --- a/e2e/various-suite/solo-route.spec.ts +++ b/e2e/various-suite/solo-route.spec.ts @@ -5,7 +5,7 @@ describe('Solo Route', () => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); - it('Can view panels with shared queries in fullsceen', () => { + it('Can view panels with shared queries in fullscreen', () => { // open Panel Tests - Bar Gauge e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4'); @@ -25,20 +25,20 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server=B').should('exist'); + e2e.components.Panels.Panel.title('server=A').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); - it('Can view solo in repeaterd row and panel in scenes', () => { + it('Can view solo in repeated row and panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-clone-D-clone-2&__feature.dashboardSceneSolo=true' + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist'); + e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); }); diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx index 7787e38d00c..7ae3f1c5ec5 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx @@ -237,11 +237,11 @@ describe('Combobox', () => { const onChangeHandler = jest.fn(); render(); const input = screen.getByRole('combobox'); - await userEvent.type(input, 'custom value'); + await userEvent.type(input, 'Use custom value'); await userEvent.keyboard('{Enter}'); - expect(screen.getByDisplayValue('custom value')).toBeInTheDocument(); - expect(onChangeHandler).toHaveBeenCalledWith(expect.objectContaining({ value: 'custom value' })); + expect(screen.getByDisplayValue('Use custom value')).toBeInTheDocument(); + expect(onChangeHandler).toHaveBeenCalledWith(expect.objectContaining({ value: 'Use custom value' })); }); it('should provide custom string when all options are numbers', async () => { @@ -256,10 +256,10 @@ describe('Combobox', () => { render(); const input = screen.getByRole('combobox'); - await userEvent.type(input, 'custom value'); + await userEvent.type(input, 'Use custom value'); await userEvent.keyboard('{Enter}'); - expect(screen.getByDisplayValue('custom value')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Use custom value')).toBeInTheDocument(); expect(typeof onChangeHandler.mock.calls[0][0].value === 'string').toBeTruthy(); expect(typeof onChangeHandler.mock.calls[0][0].value === 'number').toBeFalsy(); @@ -411,9 +411,12 @@ describe('Combobox', () => { jest.advanceTimersByTime(500); // Custom value while typing }); - const customItem = screen.queryByRole('option', { name: 'Custom value: fir' }); - + const customItem = screen.getByRole('option'); + const customValue = customItem.getElementsByTagName('span')[0].textContent; + const customDescription = customItem.getElementsByTagName('span')[1].textContent; expect(customItem).toBeInTheDocument(); + expect(customValue).toBe('fir'); + expect(customDescription).toBe('Use custom value'); }); it('should display message when there is an error loading async options', async () => { diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index cf59c4910f8..4c2f6a97b63 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -135,12 +135,10 @@ export const Combobox = (props: ComboboxProps) => if (!optionMatchingInput) { const customValueOption = { - label: t('combobox.custom-value.label', 'Custom value: ') + inputValue, + label: inputValue, // Type casting needed to make this work when T is a number - value: inputValue as unknown as T, - /* TODO: Add this back when we do support descriptions and have need for it - description: t('combobox.custom-value.create', 'Create custom value'), - */ + value: inputValue as T, + description: t('combobox.custom-value.description', 'Use custom value'), }; itemsToSet = items.slice(0); diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx index fa1274b5666..bee10de6783 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx @@ -128,6 +128,40 @@ describe('MultiCombobox', () => { expect(await screen.findByText('d')).toBeInTheDocument(); }); + it('should be able to set custom value', async () => { + const options = [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + { label: 'C', value: 'c' }, + ]; + const onChange = jest.fn(); + render(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.type(input, 'D'); + await user.keyboard('{arrowdown}{enter}'); + expect(onChange).toHaveBeenCalledWith([{ label: 'D', value: 'D', description: 'Use custom value' }]); + }); + + it('should be able to add custom value to the selected options', async () => { + const options = [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + { label: 'C', value: 'c' }, + ]; + const onChange = jest.fn(); + render(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.type(input, 'D'); + await user.keyboard('{arrowdown}{enter}'); + expect(onChange).toHaveBeenCalledWith([ + { value: 'a' }, + { value: 'c' }, + { label: 'D', value: 'D', description: 'Use custom value' }, + ]); + }); + it('should remove value when clicking on the close icon of the pill', async () => { const options = [ { label: 'A', value: 'a' }, diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index a2d5063b096..849e2a26271 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -37,8 +37,19 @@ interface MultiComboboxBaseProps extends Omit = MultiComboboxBaseProps & AutoSizeConditionals; export const MultiCombobox = (props: MultiComboboxProps) => { - const { placeholder, onChange, value, width, enableAllOption, invalid, disabled, minWidth, maxWidth, isClearable } = - props; + const { + placeholder, + onChange, + value, + width, + enableAllOption, + invalid, + disabled, + minWidth, + maxWidth, + isClearable, + createCustomValue = false, + } = props; const styles = useStyles2(getComboboxStyles); const [inputValue, setInputValue] = useState(''); @@ -55,7 +66,7 @@ export const MultiCombobox = (props: MultiComboboxPro }, [inputValue]); // Handle async options and the 'All' option - const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options); + const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options, createCustomValue); const options = useMemo(() => { // Only add the 'All' option if there's more than 1 option const addAllOption = enableAllOption && baseOptions.length > 1; @@ -202,14 +213,12 @@ export const MultiCombobox = (props: MultiComboboxPro const filteredSet = new Set(realOptions.map((item) => item.value)); newSelectedItems = selectedItems.filter((item) => !filteredSet.has(item.value)); } - setSelectedItems(newSelectedItems); } else if (newSelectedItem && isOptionSelected(newSelectedItem)) { removeSelectedItem(newSelectedItem); } else if (newSelectedItem) { addSelectedItem(newSelectedItem); } - break; case useCombobox.stateChangeTypes.InputChange: setInputValue(newInputValue ?? ''); diff --git a/packages/grafana-ui/src/components/Combobox/filter.ts b/packages/grafana-ui/src/components/Combobox/filter.ts index cb7a14d86fe..a03cbec475c 100644 --- a/packages/grafana-ui/src/components/Combobox/filter.ts +++ b/packages/grafana-ui/src/components/Combobox/filter.ts @@ -20,9 +20,6 @@ export function itemToString(item?: ComboboxOption if (item == null) { return ''; } - if (item.label?.startsWith('Custom value: ')) { - return item.value.toString(); - } return item.label ?? item.value.toString(); } diff --git a/packages/grafana-ui/src/components/Combobox/useOptions.ts b/packages/grafana-ui/src/components/Combobox/useOptions.ts index 504f3584e1d..a66e60a2747 100644 --- a/packages/grafana-ui/src/components/Combobox/useOptions.ts +++ b/packages/grafana-ui/src/components/Combobox/useOptions.ts @@ -1,6 +1,8 @@ import { debounce } from 'lodash'; import { useState, useCallback, useMemo } from 'react'; +import { t } from '../../utils/i18n'; + import { itemFilter } from './filter'; import { ComboboxOption } from './types'; import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall'; @@ -20,7 +22,7 @@ const asyncNoop = () => Promise.resolve([]); * - function to call when user types (to filter, or call async fn) * - loading and error states */ -export function useOptions(rawOptions: AsyncOptions) { +export function useOptions(rawOptions: AsyncOptions, createCustomValue: boolean) { const isAsync = typeof rawOptions === 'function'; const loadOptions = useLatestAsyncCall(isAsync ? rawOptions : asyncNoop); @@ -56,6 +58,27 @@ export function useOptions(rawOptions: AsyncOptions>) => { + let currentOptions: Array> = opts; + if (createCustomValue && userTypedSearch) { + const customValueExists = opts.some((opt) => opt.value === userTypedSearch); + if (!customValueExists) { + currentOptions = [ + { + label: userTypedSearch, + value: userTypedSearch as T, + description: t('combobox.custom-value.description', 'Use custom value'), + }, + ...currentOptions, + ]; + } + } + return currentOptions; + }, + [createCustomValue, userTypedSearch] + ); + const updateOptions = useCallback( (inputValue: string) => { if (!isAsync) { @@ -71,12 +94,15 @@ export function useOptions(rawOptions: AsyncOptions { + let currentOptions = []; if (isAsync) { - return asyncOptions; + currentOptions = addCustomValue(asyncOptions); } else { - return rawOptions.filter(itemFilter(userTypedSearch)); + currentOptions = addCustomValue(rawOptions.filter(itemFilter(userTypedSearch))); } - }, [rawOptions, asyncOptions, isAsync, userTypedSearch]); + + return currentOptions; + }, [isAsync, addCustomValue, asyncOptions, rawOptions, userTypedSearch]); return { options: finalOptions, updateOptions, asyncLoading, asyncError }; } diff --git a/pkg/apis/iam/v0alpha1/types_display.go b/pkg/apis/iam/v0alpha1/types_display.go index 02c06662d09..9d3f27b4a41 100644 --- a/pkg/apis/iam/v0alpha1/types_display.go +++ b/pkg/apis/iam/v0alpha1/types_display.go @@ -33,16 +33,17 @@ type Display struct { // AvatarURL is the url where we can get the avatar for identity AvatarURL string `json:"avatarURL,omitempty"` - // InternalID is the legacy numreric id for identity, this is deprecated and should be phased out + // InternalID is the legacy numeric id for identity, + // Deprecated: use the identityRef where possible InternalID int64 `json:"internalId,omitempty"` } type IdentityRef struct { // Type of identity e.g. "user". - // For a full list see https://github.com/grafana/authlib/blob/2f8d13a83ca3e82da08b53726de1697ee5b5b4cc/claims/type.go#L15-L24 + // For a full list see https://github.com/grafana/authlib/blob/d6737a7dc8f55e9d42834adb83b5da607ceed293/types/type.go#L15 Type claims.IdentityType `json:"type"` - // Name is the unique identifier for identity, guaranteed jo be a unique value for the type within a namespace. + // Name is the unique identifier for identity, guaranteed to be a unique value for the type within a namespace. Name string `json:"name"` } diff --git a/pkg/apis/iam/v0alpha1/zz_generated.openapi.go b/pkg/apis/iam/v0alpha1/zz_generated.openapi.go index 51c53868df8..391aa5579d1 100644 --- a/pkg/apis/iam/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/iam/v0alpha1/zz_generated.openapi.go @@ -72,7 +72,7 @@ func schema_pkg_apis_iam_v0alpha1_Display(ref common.ReferenceCallback) common.O }, "internalId": { SchemaProps: spec.SchemaProps{ - Description: "InternalID is the legacy numreric id for identity, this is deprecated and should be phased out", + Description: "InternalID is the legacy numeric id for identity, Deprecated: use the identityRef where possible", Type: []string{"integer"}, Format: "int64", }, @@ -188,7 +188,7 @@ func schema_pkg_apis_iam_v0alpha1_IdentityRef(ref common.ReferenceCallback) comm Properties: map[string]spec.Schema{ "type": { SchemaProps: spec.SchemaProps{ - Description: "Type of identity e.g. \"user\". For a full list see https://github.com/grafana/authlib/blob/2f8d13a83ca3e82da08b53726de1697ee5b5b4cc/claims/type.go#L15-L24", + Description: "Type of identity e.g. \"user\". For a full list see https://github.com/grafana/authlib/blob/d6737a7dc8f55e9d42834adb83b5da607ceed293/types/type.go#L15", Default: "", Type: []string{"string"}, Format: "", @@ -196,7 +196,7 @@ func schema_pkg_apis_iam_v0alpha1_IdentityRef(ref common.ReferenceCallback) comm }, "name": { SchemaProps: spec.SchemaProps{ - Description: "Name is the unique identifier for identity, guaranteed jo be a unique value for the type within a namespace.", + Description: "Name is the unique identifier for identity, guaranteed to be a unique value for the type within a namespace.", Default: "", Type: []string{"string"}, Format: "", @@ -763,7 +763,7 @@ func schema_pkg_apis_iam_v0alpha1_TeamMember(ref common.ReferenceCallback) commo }, "internalId": { SchemaProps: spec.SchemaProps{ - Description: "InternalID is the legacy numreric id for identity, this is deprecated and should be phased out", + Description: "InternalID is the legacy numeric id for identity, Deprecated: use the identityRef where possible", Type: []string{"integer"}, Format: "int64", }, diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 42f69649a61..2789d859347 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -10,6 +10,7 @@ import ( "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" common "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/validation/spec" "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -34,6 +35,9 @@ type IdentityAccessManagementAPIBuilder struct { authorizer authorizer.Authorizer accessClient types.AccessClient + // non-k8s api route + display *user.LegacyDisplayREST + // Not set for multi-tenant deployment for now sso ssosettings.Service } @@ -52,6 +56,7 @@ func RegisterAPIService( sso: ssoService, authorizer: authorizer, accessClient: client, + display: user.NewLegacyDisplayREST(store), } apiregistration.RegisterAPI(builder) @@ -60,7 +65,8 @@ func RegisterAPIService( func NewAPIService(store legacy.LegacyIdentityStore) *IdentityAccessManagementAPIBuilder { return &IdentityAccessManagementAPIBuilder{ - store: store, + store: store, + display: user.NewLegacyDisplayREST(store), authorizer: authorizer.AuthorizerFunc( func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { user, err := identity.GetRequester(ctx) @@ -114,9 +120,6 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[ssoResource.StoragePath()] = sso.NewLegacyStore(b.sso) } - // The display endpoint -- NOTE, this uses a rewrite hack to allow requests without a name parameter - storage["display"] = user.NewLegacyDisplayREST(b.store) - apiGroupInfo.VersionedResourcesStorageMap[iamv0.VERSION] = storage return nil } @@ -125,6 +128,11 @@ func (b *IdentityAccessManagementAPIBuilder) GetOpenAPIDefinitions() common.GetO return iamv0.GetOpenAPIDefinitions } +func (b *IdentityAccessManagementAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) + return b.display.GetAPIRoutes(defs) +} + func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer { return b.authorizer } diff --git a/pkg/registry/apis/iam/user/rest_display.go b/pkg/registry/apis/iam/user/rest_display.go index 112db40233c..c82567878c7 100644 --- a/pkg/registry/apis/iam/user/rest_display.go +++ b/pkg/registry/apis/iam/user/rest_display.go @@ -1,123 +1,154 @@ package user import ( - "context" + "encoding/json" "net/http" "strconv" "strings" - errorsK8s "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apiserver/pkg/registry/rest" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" - claims "github.com/grafana/authlib/types" + authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/api/dtos" - iamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" + iam "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errhttp" ) type LegacyDisplayREST struct { store legacy.LegacyIdentityStore } -var ( - _ rest.Storage = (*LegacyDisplayREST)(nil) - _ rest.SingularNameProvider = (*LegacyDisplayREST)(nil) - _ rest.Connecter = (*LegacyDisplayREST)(nil) - _ rest.Scoper = (*LegacyDisplayREST)(nil) - _ rest.StorageMetadata = (*LegacyDisplayREST)(nil) -) - func NewLegacyDisplayREST(store legacy.LegacyIdentityStore) *LegacyDisplayREST { return &LegacyDisplayREST{store} } -func (r *LegacyDisplayREST) New() runtime.Object { - return &iamv0.DisplayList{} -} - -func (r *LegacyDisplayREST) Destroy() {} - -func (r *LegacyDisplayREST) NamespaceScoped() bool { - return true -} - -func (r *LegacyDisplayREST) GetSingularName() string { - return "display" -} - -func (r *LegacyDisplayREST) ProducesMIMETypes(verb string) []string { - return []string{"application/json"} -} - -func (r *LegacyDisplayREST) ProducesObject(verb string) any { - return &iamv0.DisplayList{} -} - -func (r *LegacyDisplayREST) ConnectMethods() []string { - return []string{http.MethodGet} -} - -func (r *LegacyDisplayREST) NewConnectOptions() (runtime.Object, bool, string) { - return nil, false, "" // true means you can use the trailing path as a variable +func (r *LegacyDisplayREST) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes { + listSchema := defs["github.com/grafana/grafana/pkg/apis/iam/v0alpha1.DisplayList"].Schema + displaySchema := defs["github.com/grafana/grafana/pkg/apis/iam/v0alpha1.Display"].Schema + identitySchema := defs["github.com/grafana/grafana/pkg/apis/iam/v0alpha1.IdentityRef"].Schema + listSchema.Properties["display"].Items.Schema = &displaySchema // not sure why this is lost + displaySchema.Properties["identity"] = identitySchema // not sure why this is lost + return &builder.APIRoutes{ + Namespace: []builder.APIRouteHandler{ + { + Path: "display", + Spec: &spec3.PathProps{ + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + OperationId: "getDisplayMapping", // This is used by RTK client generator + Tags: []string{"Display"}, + Description: "Show user display information", + Parameters: []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "namespace", + In: "path", + Required: true, + Example: "default", + Description: "workspace", + Schema: spec.StringProperty(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "key", + In: "query", + Description: "Display keys", + Required: true, + Example: "user:u000000001", + Schema: spec.ArrayProperty(spec.StringProperty()), + // Style: "form", + Explode: true, + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &listSchema, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: r.handleDisplay, + }, + }, + } } // This will always have an empty app url var fakeCfgForGravatar = &setting.Cfg{} -func (r *LegacyDisplayREST) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) { - // See: /pkg/services/apiserver/builder/helper.go#L34 - // The name is set with a rewriter hack - if name != "name" { - return nil, errorsK8s.NewNotFound(schema.GroupResource{}, name) +func (r *LegacyDisplayREST) handleDisplay(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + user, ok := authlib.AuthInfoFrom(ctx) + if !ok { + errhttp.Write(ctx, apierrors.NewUnauthorized("missing auth info"), w) + return } - ns, err := request.NamespaceInfoFrom(ctx, true) + + ns, err := authlib.ParseNamespace(user.GetNamespace()) if err != nil { - return nil, err + errhttp.Write(ctx, err, w) + return + } + keys := parseKeys(req.URL.Query()["key"]) + users, err := r.store.ListDisplay(ctx, ns, legacy.ListDisplayQuery{ + OrgID: ns.OrgID, + UIDs: keys.uids, + IDs: keys.ids, + }) + if err != nil { + errhttp.Write(ctx, err, w) + return } - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - keys := parseKeys(req.URL.Query()["key"]) - users, err := r.store.ListDisplay(ctx, ns, legacy.ListDisplayQuery{ - OrgID: ns.OrgID, - UIDs: keys.uids, - IDs: keys.ids, - }) - if err != nil { - responder.Error(err) - return + rsp := &iam.DisplayList{ + Keys: keys.keys, + InvalidKeys: keys.invalid, + Items: make([]iam.Display, 0, len(users.Users)+len(keys.disp)+1), + } + for _, user := range users.Users { + disp := iam.Display{ + Identity: iam.IdentityRef{ + Type: authlib.TypeUser, + Name: user.UID, + }, + DisplayName: user.NameOrFallback(), + InternalID: user.ID, // nolint:staticcheck } + if user.IsServiceAccount { + disp.Identity.Type = authlib.TypeServiceAccount + } + disp.AvatarURL = dtos.GetGravatarUrlWithDefault(fakeCfgForGravatar, user.Email, disp.DisplayName) + rsp.Items = append(rsp.Items, disp) + } - rsp := &iamv0.DisplayList{ - Keys: keys.keys, - InvalidKeys: keys.invalid, - Items: make([]iamv0.Display, 0, len(users.Users)+len(keys.disp)+1), - } - for _, user := range users.Users { - disp := iamv0.Display{ - Identity: iamv0.IdentityRef{ - Type: claims.TypeUser, - Name: user.UID, - }, - DisplayName: user.NameOrFallback(), - InternalID: user.ID, - } - if user.IsServiceAccount { - disp.Identity.Type = claims.TypeServiceAccount - } - disp.AvatarURL = dtos.GetGravatarUrlWithDefault(fakeCfgForGravatar, user.Email, disp.DisplayName) - rsp.Items = append(rsp.Items, disp) - } + // Append the constants here + if len(keys.disp) > 0 { + rsp.Items = append(rsp.Items, keys.disp...) + } - // Append the constants here - if len(keys.disp) > 0 { - rsp.Items = append(rsp.Items, keys.disp...) - } - responder.Object(200, rsp) - }), nil + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rsp) } type dispKeys struct { @@ -127,7 +158,7 @@ type dispKeys struct { invalid []string // For terminal keys, this is a constant - disp []iamv0.Display + disp []iam.Display } func parseKeys(req []string) dispKeys { @@ -139,7 +170,7 @@ func parseKeys(req []string) dispKeys { for _, key := range req { idx := strings.Index(key, ":") if idx > 0 { - t, err := claims.ParseType(key[0:idx]) + t, err := authlib.ParseType(key[0:idx]) if err != nil { keys.invalid = append(keys.invalid, key) continue @@ -147,18 +178,18 @@ func parseKeys(req []string) dispKeys { key = key[idx+1:] switch t { - case claims.TypeAnonymous: - keys.disp = append(keys.disp, iamv0.Display{ - Identity: iamv0.IdentityRef{ + case authlib.TypeAnonymous: + keys.disp = append(keys.disp, iam.Display{ + Identity: iam.IdentityRef{ Type: t, }, DisplayName: "Anonymous", AvatarURL: dtos.GetGravatarUrl(fakeCfgForGravatar, string(t)), }) continue - case claims.TypeAPIKey: - keys.disp = append(keys.disp, iamv0.Display{ - Identity: iamv0.IdentityRef{ + case authlib.TypeAPIKey: + keys.disp = append(keys.disp, iam.Display{ + Identity: iam.IdentityRef{ Type: t, Name: key, }, @@ -166,9 +197,9 @@ func parseKeys(req []string) dispKeys { AvatarURL: dtos.GetGravatarUrl(fakeCfgForGravatar, string(t)), }) continue - case claims.TypeProvisioning: - keys.disp = append(keys.disp, iamv0.Display{ - Identity: iamv0.IdentityRef{ + case authlib.TypeProvisioning: + keys.disp = append(keys.disp, iam.Display{ + Identity: iam.IdentityRef{ Type: t, }, DisplayName: "Provisioning", @@ -184,9 +215,9 @@ func parseKeys(req []string) dispKeys { id, err := strconv.ParseInt(key, 10, 64) if err == nil { if id == 0 { - keys.disp = append(keys.disp, iamv0.Display{ - Identity: iamv0.IdentityRef{ - Type: claims.TypeUser, + keys.disp = append(keys.disp, iam.Display{ + Identity: iam.IdentityRef{ + Type: authlib.TypeUser, Name: key, }, DisplayName: "System admin", diff --git a/pkg/registry/apis/iam/user/rest_user_team.go b/pkg/registry/apis/iam/user/rest_user_team.go index d9d0c22d2ca..79f781fa7e0 100644 --- a/pkg/registry/apis/iam/user/rest_user_team.go +++ b/pkg/registry/apis/iam/user/rest_user_team.go @@ -15,7 +15,6 @@ import ( var ( _ rest.Storage = (*LegacyUserTeamREST)(nil) - _ rest.Scoper = (*LegacyUserTeamREST)(nil) _ rest.StorageMetadata = (*LegacyUserTeamREST)(nil) _ rest.Connecter = (*LegacyUserTeamREST)(nil) ) @@ -36,11 +35,6 @@ func (s *LegacyUserTeamREST) New() runtime.Object { // Destroy implements rest.Storage. func (s *LegacyUserTeamREST) Destroy() {} -// NamespaceScoped implements rest.Scoper. -func (s *LegacyUserTeamREST) NamespaceScoped() bool { - return true -} - // ProducesMIMETypes implements rest.StorageMetadata. func (s *LegacyUserTeamREST) ProducesMIMETypes(verb string) []string { return []string{"application/json"} diff --git a/pkg/registry/apis/query/client.go b/pkg/registry/apis/query/client.go index 7f621512c09..2317afdffc9 100644 --- a/pkg/registry/apis/query/client.go +++ b/pkg/registry/apis/query/client.go @@ -4,18 +4,13 @@ import ( "context" data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/query/clientapi" ) -// The query runner interface -type DataSourceClientSupplier interface { - // Get a client for a given datasource - GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (data.QueryDataClient, error) -} - type CommonDataSourceClientSupplier struct { - Client data.QueryDataClient + Client clientapi.QueryDataClient } -func (s *CommonDataSourceClientSupplier) GetDataSourceClient(_ context.Context, _ data.DataSourceRef, _ map[string]string) (data.QueryDataClient, error) { +func (s *CommonDataSourceClientSupplier) GetDataSourceClient(_ context.Context, _ data.DataSourceRef, _ map[string]string) (clientapi.QueryDataClient, error) { return s.Client, nil } diff --git a/pkg/registry/apis/query/client/plugin.go b/pkg/registry/apis/query/client/plugin.go index ab1f85dd5c6..2ffad9fa52b 100644 --- a/pkg/registry/apis/query/client/plugin.go +++ b/pkg/registry/apis/query/client/plugin.go @@ -3,7 +3,6 @@ package client import ( "context" "fmt" - "net/http" "sync" "time" @@ -14,6 +13,7 @@ import ( query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/registry/apis/query/clientapi" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -36,11 +36,11 @@ type pluginRegistry struct { dataSourcesService datasources.DataSourceService } -var _ data.QueryDataClient = (*pluginClient)(nil) +var _ clientapi.QueryDataClient = (*pluginClient)(nil) var _ query.DataSourceApiServerRegistry = (*pluginRegistry)(nil) // NewQueryClientForPluginClient creates a client that delegates to the internal plugins.Client stack -func NewQueryClientForPluginClient(p plugins.Client, ctx *plugincontext.Provider) data.QueryDataClient { +func NewQueryClientForPluginClient(p plugins.Client, ctx *plugincontext.Provider) clientapi.QueryDataClient { return &pluginClient{ pluginClient: p, pCtxProvider: ctx, @@ -57,19 +57,19 @@ func NewDataSourceRegistryFromStore(pluginStore pluginstore.Store, } // ExecuteQueryData implements QueryHelper. -func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest) (int, *backend.QueryDataResponse, error) { +func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest) (*backend.QueryDataResponse, error) { queries, dsRef, err := data.ToDataSourceQueries(req) if err != nil { - return http.StatusBadRequest, nil, err + return nil, err } if dsRef == nil { - return http.StatusBadRequest, nil, fmt.Errorf("expected single datasource request") + return nil, fmt.Errorf("expected single datasource request") } // NOTE: this depends on uid unique across datasources settings, err := d.pCtxProvider.GetDataSourceInstanceSettings(ctx, dsRef.UID) if err != nil { - return http.StatusBadRequest, nil, err + return nil, err } qdr := &backend.QueryDataRequest{ @@ -77,14 +77,14 @@ func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest) } qdr.PluginContext, err = d.pCtxProvider.PluginContextForDataSource(ctx, settings) if err != nil { - return http.StatusBadRequest, nil, err + return nil, err } rsp, err := d.pluginClient.QueryData(ctx, qdr) if err != nil { - return http.StatusInternalServerError, rsp, err + return rsp, err } - return query.GetResponseCode(rsp), rsp, err + return rsp, err } // GetDatasourceAPI implements DataSourceRegistry. diff --git a/pkg/registry/apis/query/client/testdata.go b/pkg/registry/apis/query/client/testdata.go index 16aba9469e6..e481fde1b96 100644 --- a/pkg/registry/apis/query/client/testdata.go +++ b/pkg/registry/apis/query/client/testdata.go @@ -3,45 +3,23 @@ package client import ( "context" "fmt" - "net/http" "time" - "github.com/grafana/grafana-plugin-sdk-go/backend" - data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" - testdata "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource" ) type testdataDummy struct{} -var _ data.QueryDataClient = (*testdataDummy)(nil) var _ query.DataSourceApiServerRegistry = (*testdataDummy)(nil) -// NewTestDataClient creates a runner that only works with testdata -func NewTestDataClient() data.QueryDataClient { - return &testdataDummy{} -} - // NewTestDataRegistry returns a registry that only knows about testdata func NewTestDataRegistry() query.DataSourceApiServerRegistry { return &testdataDummy{} } -// ExecuteQueryData implements QueryHelper. -func (d *testdataDummy) QueryData(ctx context.Context, req data.QueryDataRequest) (int, *backend.QueryDataResponse, error) { - queries, _, err := data.ToDataSourceQueries(req) - if err != nil { - return http.StatusBadRequest, nil, err - } - - qdr := &backend.QueryDataRequest{Queries: queries} - rsp, err := testdata.ProvideService().QueryData(ctx, qdr) - return query.GetResponseCode(rsp), rsp, err -} - // GetDatasourceAPI implements DataSourceRegistry. func (*testdataDummy) GetDatasourceGroupVersion(pluginId string) (schema.GroupVersion, error) { if pluginId == "testdata" || pluginId == "grafana-testdata-datasource" { diff --git a/pkg/registry/apis/query/clientapi/clientapi.go b/pkg/registry/apis/query/clientapi/clientapi.go new file mode 100644 index 00000000000..5dfd91267ad --- /dev/null +++ b/pkg/registry/apis/query/clientapi/clientapi.go @@ -0,0 +1,18 @@ +package clientapi + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" +) + +type QueryDataClient interface { + QueryData(ctx context.Context, req data.QueryDataRequest) (*backend.QueryDataResponse, error) +} + +// The query runner interface +type DataSourceClientSupplier interface { + // Get a client for a given datasource + GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (QueryDataClient, error) +} diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index bdd1a8f227e..f86bf0fc75f 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -242,7 +242,7 @@ func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req d return qdr, err } - code, rsp, err := client.QueryData(ctx, *req.Request) + rsp, err := client.QueryData(ctx, *req.Request) if err == nil && rsp != nil { for _, q := range req.Request.Queries { if q.ResultAssertions != nil { @@ -259,16 +259,6 @@ func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req d } } - // Create a response object with the error when missing (happens for client errors like 404) - if rsp == nil && err != nil { - rsp = &backend.QueryDataResponse{Responses: make(backend.Responses)} - for _, q := range req.Request.Queries { - rsp.Responses[q.RefID] = backend.DataResponse{ - Status: backend.Status(code), - Error: err, - } - } - } return rsp, err } diff --git a/pkg/registry/apis/query/query_test.go b/pkg/registry/apis/query/query_test.go index 711b9da8ed5..5147b51bfb9 100644 --- a/pkg/registry/apis/query/query_test.go +++ b/pkg/registry/apis/query/query_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/query/clientapi" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/stretchr/testify/require" @@ -95,7 +96,7 @@ type mockClient struct { lastCalledWithHeaders *map[string]string } -func (m mockClient) GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (data.QueryDataClient, error) { +func (m mockClient) GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (clientapi.QueryDataClient, error) { *m.lastCalledWithHeaders = headers return nil, fmt.Errorf("mock error") diff --git a/pkg/registry/apis/query/register.go b/pkg/registry/apis/query/register.go index ddf56be78fd..4f8303126fb 100644 --- a/pkg/registry/apis/query/register.go +++ b/pkg/registry/apis/query/register.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry/apis/query/client" + "github.com/grafana/grafana/pkg/registry/apis/query/clientapi" "github.com/grafana/grafana/pkg/registry/apis/query/queryschema" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" @@ -40,14 +41,14 @@ type QueryAPIBuilder struct { tracer tracing.Tracer metrics *queryMetrics parser *queryParser - client DataSourceClientSupplier + client clientapi.DataSourceClientSupplier registry query.DataSourceApiServerRegistry converter *expr.ResultConverter queryTypes *query.QueryTypeDefinitionList } func NewQueryAPIBuilder(features featuremgmt.FeatureToggles, - client DataSourceClientSupplier, + client clientapi.DataSourceClientSupplier, registry query.DataSourceApiServerRegistry, legacy service.LegacyDataSourceLookup, registerer prometheus.Registerer, diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index 19f3752ff6f..5e76c41fac5 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -52,12 +52,6 @@ var PathRewriters = []filters.PathRewriter{ return matches[1] + "/name" // connector requires a name }, }, - { - Pattern: regexp.MustCompile(`(/apis/iam.grafana.app/v0alpha1/namespaces/.*/display$)`), - ReplaceFunc: func(matches []string) string { - return matches[1] + "/name" // connector requires a name - }, - }, { Pattern: regexp.MustCompile(`(/apis/.*/v0alpha1/namespaces/.*/queryconvert$)`), ReplaceFunc: func(matches []string) string { diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index 9c8a82a8ee1..26bd352903c 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -619,27 +619,28 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool, } } - folderSet := make(map[string]struct{}, len(scopeMap)) - - prefix := t.prefix() - itemSet := make(map[string]struct{}, len(scopeMap)) - for scope := range scopeMap { - if strings.HasPrefix(scope, "folders:uid:") { - identifier := strings.TrimPrefix(scope, "folders:uid:") - if _, ok := folderSet[identifier]; ok { - continue - } - folderSet[identifier] = struct{}{} - getChildren(folderMap, identifier, folderSet) - } else { - identifier := strings.TrimPrefix(scope, prefix) - itemSet[identifier] = struct{}{} - } + var res *authzv1.ListResponse + if strings.HasPrefix(req.Action, "folders:") { + res = buildFolderList(scopeMap, folderMap) + } else { + res = buildItemList(scopeMap, folderMap, t.prefix()) } - folderList := make([]string, 0, len(folderSet)) - for folder := range folderSet { - folderList = append(folderList, folder) + span.SetAttributes(attribute.Int("num_folders", len(res.Folders)), attribute.Int("num_items", len(res.Items))) + return res, nil +} + +func buildFolderList(scopes map[string]bool, tree map[string]FolderNode) *authzv1.ListResponse { + itemSet := make(map[string]struct{}, len(scopes)) + + for scope := range scopes { + identifier := strings.TrimPrefix(scope, "folders:uid:") + if _, ok := itemSet[identifier]; ok { + continue + } + + itemSet[identifier] = struct{}{} + getChildren(tree, identifier, itemSet) } itemList := make([]string, 0, len(itemSet)) @@ -647,8 +648,35 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool, itemList = append(itemList, item) } - span.SetAttributes(attribute.Int("num_folders", len(folderList)), attribute.Int("num_items", len(itemList))) - return &authzv1.ListResponse{Folders: folderList, Items: itemList}, nil + return &authzv1.ListResponse{Items: itemList} +} + +func buildItemList(scopes map[string]bool, tree map[string]FolderNode, prefix string) *authzv1.ListResponse { + folderSet := make(map[string]struct{}, len(scopes)) + itemSet := make(map[string]struct{}, len(scopes)) + + for scope := range scopes { + if identifier, ok := strings.CutPrefix(scope, "folders:uid:"); ok { + if _, ok := folderSet[identifier]; ok { + continue + } + folderSet[identifier] = struct{}{} + getChildren(tree, identifier, folderSet) + } else { + identifier := strings.TrimPrefix(scope, prefix) + itemSet[identifier] = struct{}{} + } + } + folderList := make([]string, 0, len(folderSet)) + for folder := range folderSet { + folderList = append(folderList, folder) + } + itemList := make([]string, 0, len(itemSet)) + for item := range itemSet { + itemList = append(itemList, item) + } + + return &authzv1.ListResponse{Folders: folderList, Items: itemList} } func getChildren(folderMap map[string]FolderNode, folderUID string, folderSet map[string]struct{}) { diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go index 586f528f863..a8ad76d61fe 100644 --- a/pkg/services/authz/rbac/service_test.go +++ b/pkg/services/authz/rbac/service_test.go @@ -452,13 +452,13 @@ func TestService_buildFolderTree(t *testing.T) { func TestService_listPermission(t *testing.T) { type testCase struct { - name string - permissions []accesscontrol.Permission - folderTree map[string]FolderNode - list ListRequest - expectedDashboards []string - expectedFolders []string - expectedAll bool + name string + permissions []accesscontrol.Permission + folderTree map[string]FolderNode + list ListRequest + expectedItems []string + expectedFolders []string + expectedAll bool } testCases := []testCase{ @@ -512,8 +512,8 @@ func TestService_listPermission(t *testing.T) { Group: "dashboard.grafana.app", Resource: "dashboards", }, - expectedDashboards: []string{"some_dashboard"}, - expectedFolders: []string{"some_folder_1", "some_folder_2"}, + expectedItems: []string{"some_dashboard"}, + expectedFolders: []string{"some_folder_1", "some_folder_2"}, }, { name: "should return folders that user has inherited access to", @@ -568,8 +568,8 @@ func TestService_listPermission(t *testing.T) { Group: "dashboard.grafana.app", Resource: "dashboards", }, - expectedDashboards: []string{"some_dashboard"}, - expectedFolders: []string{"some_folder_parent", "some_folder_child"}, + expectedItems: []string{"some_dashboard"}, + expectedFolders: []string{"some_folder_parent", "some_folder_child"}, }, { name: "should deduplicate folders that user has inherited as well as direct access to", @@ -613,6 +613,28 @@ func TestService_listPermission(t *testing.T) { Resource: "dashboards", }, }, + { + name: "should collect folder permissions into items", + permissions: []accesscontrol.Permission{ + { + Action: "folders:read", + Scope: "folders:uid:some_folder_parent", + Kind: "folders", + Attribute: "uid", + Identifier: "some_folder_parent", + }, + }, + folderTree: map[string]FolderNode{ + "some_folder_parent": {UID: "some_folder_parent", ChildrenUIDs: []string{"some_folder_child"}}, + "some_folder_child": {UID: "some_folder_child", ParentUID: strPtr("some_folder_parent")}, + }, + list: ListRequest{ + Action: "folders:read", + Group: "folder.grafana.app", + Resource: "folders", + }, + expectedItems: []string{"some_folder_parent", "some_folder_child"}, + }, } for _, tc := range testCases { @@ -626,7 +648,7 @@ func TestService_listPermission(t *testing.T) { got, err := s.listPermission(context.Background(), getScopeMap(tc.permissions), &tc.list) require.NoError(t, err) assert.Equal(t, tc.expectedAll, got.All) - assert.ElementsMatch(t, tc.expectedDashboards, got.Items) + assert.ElementsMatch(t, tc.expectedItems, got.Items) assert.ElementsMatch(t, tc.expectedFolders, got.Folders) }) } diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index 8d93c4de46b..3cf08eb01c2 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -11,14 +11,45 @@ import ( "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/provisioning/utils" ) type configReader struct { - path string - log log.Logger - orgService org.Service + path string + log log.Logger + orgExists utils.OrgExists +} + +func ReadDashboardConfig(dir string) ([]*DashboardProvisioning, error) { + var cfg []*DashboardProvisioning + + cr := &configReader{ + path: dir, + } + + files, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + for _, file := range files { + if !strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml") { + continue + } + + parsedDashboards, err := cr.parseConfigs(file) + if err != nil { + return nil, fmt.Errorf("could not parse provisioning config file: %s error: %v", file.Name(), err) + } + + for _, config := range parsedDashboards { + cfg = append(cfg, &DashboardProvisioning{ + config: *config, + }) + } + } + + return cfg, nil } func (cr *configReader) parseConfigs(file fs.DirEntry) ([]*config, error) { @@ -96,7 +127,7 @@ func (cr *configReader) readConfig(ctx context.Context) ([]*config, error) { dashboard.OrgID = 1 } - if err := utils.CheckOrgExists(ctx, cr.orgService, dashboard.OrgID); err != nil { + if err := cr.orgExists(ctx, dashboard.OrgID); err != nil { return nil, fmt.Errorf("failed to provision dashboards with %q reader: %w", dashboard.Name, err) } diff --git a/pkg/services/provisioning/dashboards/config_reader_test.go b/pkg/services/provisioning/dashboards/config_reader_test.go index 07f0ade89a1..6afe2607e68 100644 --- a/pkg/services/provisioning/dashboards/config_reader_test.go +++ b/pkg/services/provisioning/dashboards/config_reader_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" + "github.com/grafana/grafana/pkg/services/provisioning/utils" ) var ( @@ -25,10 +26,11 @@ func TestDashboardsAsConfig(t *testing.T) { logger := log.New("test-logger") // store := db.InitTestDB(t) orgFake := orgtest.NewOrgServiceFake() + orgExists := utils.NewOrgExistsChecker(orgFake) t.Run("Should fail if orgs don't exist in the database", func(t *testing.T) { orgFake.ExpectedError = org.ErrOrgNotFound - cfgProvider := configReader{path: appliedDefaults, log: logger, orgService: orgFake} + cfgProvider := configReader{path: appliedDefaults, log: logger, orgExists: orgExists} _, err := cfgProvider.readConfig(context.Background()) require.Error(t, err) assert.True(t, errors.Is(err, org.ErrOrgNotFound)) @@ -36,7 +38,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("default values should be applied", func(t *testing.T) { - cfgProvider := configReader{path: appliedDefaults, log: logger, orgService: orgFake} + cfgProvider := configReader{path: appliedDefaults, log: logger, orgExists: orgExists} cfg, err := cfgProvider.readConfig(context.Background()) require.NoError(t, err) @@ -47,7 +49,7 @@ func TestDashboardsAsConfig(t *testing.T) { t.Run("Can read config file version 1 format", func(t *testing.T) { t.Setenv("TEST_VAR", "general") - cfgProvider := configReader{path: simpleDashboardConfig, log: logger, orgService: orgFake} + cfgProvider := configReader{path: simpleDashboardConfig, log: logger, orgExists: orgExists} cfg, err := cfgProvider.readConfig(context.Background()) require.NoError(t, err) @@ -55,7 +57,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("Can read config file in version 0 format", func(t *testing.T) { - cfgProvider := configReader{path: oldVersion, log: logger, orgService: orgFake} + cfgProvider := configReader{path: oldVersion, log: logger, orgExists: orgExists} cfg, err := cfgProvider.readConfig(context.Background()) require.NoError(t, err) @@ -63,7 +65,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("Should skip invalid path", func(t *testing.T) { - cfgProvider := configReader{path: "/invalid-directory", log: logger, orgService: orgFake} + cfgProvider := configReader{path: "/invalid-directory", log: logger, orgExists: orgExists} cfg, err := cfgProvider.readConfig(context.Background()) if err != nil { t.Fatalf("readConfig return an error %v", err) @@ -73,7 +75,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("Should skip broken config files", func(t *testing.T) { - cfgProvider := configReader{path: brokenConfigs, log: logger, orgService: orgFake} + cfgProvider := configReader{path: brokenConfigs, log: logger, orgExists: orgExists} cfg, err := cfgProvider.readConfig(context.Background()) if err != nil { t.Fatalf("readConfig return an error %v", err) diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index 0a689d2e295..1d1d8fd87c7 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -42,7 +42,7 @@ func (provider *Provisioner) HasDashboardSources() bool { // New returns a new DashboardProvisioner func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service) (DashboardProvisioner, error) { logger := log.New("provisioning.dashboard") - cfgReader := &configReader{path: configDirectory, log: logger, orgService: orgService} + cfgReader := &configReader{path: configDirectory, log: logger, orgExists: utils.NewOrgExistsChecker(orgService)} configs, err := cfgReader.readConfig(ctx) if err != nil { return nil, fmt.Errorf("%v: %w", "Failed to read dashboards config", err) diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index 18e58c1ce7f..717326305ae 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -36,6 +36,13 @@ type configV0 struct { AllowUIUpdates bool `json:"allowUiUpdates" yaml:"allowUiUpdates"` } +// Access to dashboard provisioning config +// Exposes the internal config outside this package with as few changes as possible +// NOTE: these provisioning configs will eventually be replaced with: /apis/provisioning.grafana.app/ +type DashboardProvisioning struct { + config +} + type configVersion struct { APIVersion int64 `json:"apiVersion" yaml:"apiVersion"` } diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go index 8b8d73a4496..1d5a992d1f7 100644 --- a/pkg/services/provisioning/datasources/config_reader.go +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -131,7 +131,8 @@ func (cr *configReader) validateDefaultUniqueness(ctx context.Context, datasourc } func (cr *configReader) validateAccessAndOrgID(ctx context.Context, ds *upsertDataSourceFromConfig) error { - if err := utils.CheckOrgExists(ctx, cr.orgService, ds.OrgID); err != nil { + checker := utils.NewOrgExistsChecker(cr.orgService) + if err := checker(ctx, ds.OrgID); err != nil { return err } diff --git a/pkg/services/provisioning/utils/utils.go b/pkg/services/provisioning/utils/utils.go index 05e649aa586..80a7e838a64 100644 --- a/pkg/services/provisioning/utils/utils.go +++ b/pkg/services/provisioning/utils/utils.go @@ -13,14 +13,20 @@ type DashboardStore interface { GetDashboard(context.Context, *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) } -func CheckOrgExists(ctx context.Context, orgService org.Service, orgID int64) error { - query := org.GetOrgByIDQuery{ID: orgID} - _, err := orgService.GetByID(ctx, &query) - if err != nil { - if errors.Is(err, org.ErrOrgNotFound) { - return err +// Throw an error if the org does not exist +type OrgExists = func(ctx context.Context, orgID int64) error + +// Use the org service to check if an org exists +func NewOrgExistsChecker(orgService org.Service) OrgExists { + return func(ctx context.Context, orgID int64) error { + query := org.GetOrgByIDQuery{ID: orgID} + _, err := orgService.GetByID(ctx, &query) + if err != nil { + if errors.Is(err, org.ErrOrgNotFound) { + return err + } + return fmt.Errorf("failed to check whether org. with the given ID exists: %w", err) } - return fmt.Errorf("failed to check whether org. with the given ID exists: %w", err) + return nil } - return nil } diff --git a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json index a7377164309..4ad7f837fdb 100644 --- a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json @@ -35,54 +35,133 @@ } } }, - "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/display/{name}": { + "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/display": { "get": { "tags": [ - "DisplayList" + "Display" + ], + "description": "Show user display information", + "operationId": "getDisplayMapping", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "workspace", + "required": true, + "schema": { + "type": "string" + }, + "example": "default" + }, + { + "name": "key", + "in": "query", + "description": "Display keys", + "required": true, + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "user:u000000001" + } ], - "description": "connect GET requests to DisplayList", - "operationId": "getDisplayList", "responses": { "200": { - "description": "OK", "content": { - "*/*": { + "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.iam.v0alpha1.DisplayList" + "type": "object", + "required": [ + "keys", + "display" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "display": { + "description": "Matching items (the caller may need to remap from keys to results)", + "type": "array", + "items": { + "type": "object", + "required": [ + "identity", + "displayName" + ], + "properties": { + "avatarURL": { + "description": "AvatarURL is the url where we can get the avatar for identity", + "type": "string" + }, + "displayName": { + "description": "Display name for identity.", + "type": "string", + "default": "" + }, + "identity": { + "type": "object", + "required": [ + "type", + "name" + ], + "properties": { + "name": { + "description": "Name is the unique identifier for identity, guaranteed to be a unique value for the type within a namespace.", + "type": "string", + "default": "" + }, + "type": { + "description": "Type of identity e.g. \"user\". For a full list see https://github.com/grafana/authlib/blob/d6737a7dc8f55e9d42834adb83b5da607ceed293/types/type.go#L15", + "type": "string", + "default": "" + } + } + }, + "internalId": { + "description": "InternalID is the legacy numeric id for identity, Deprecated: use the identityRef where possible", + "type": "integer", + "format": "int64" + } + } + }, + "x-kubernetes-list-type": "atomic" + }, + "invalidKeys": { + "description": "Input keys that were not useable", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "set" + }, + "keys": { + "description": "Request keys used to lookup the display value", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "set" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {} + } + } } } } } - }, - "x-kubernetes-action": "connect", - "x-kubernetes-group-version-kind": { - "group": "iam.grafana.app", - "version": "v0alpha1", - "kind": "DisplayList" } - }, - "parameters": [ - { - "name": "name", - "in": "path", - "description": "name of the DisplayList", - "required": true, - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "namespace", - "in": "path", - "description": "object name and auth scope, such as for teams and projects", - "required": true, - "schema": { - "type": "string", - "uniqueItems": true - } - } - ] + } }, "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/serviceaccounts": { "get": { @@ -2432,105 +2511,6 @@ "additionalProperties": true, "x-kubernetes-preserve-unknown-fields": true }, - "com.github.grafana.grafana.pkg.apis.iam.v0alpha1.Display": { - "type": "object", - "required": [ - "identity", - "displayName" - ], - "properties": { - "avatarURL": { - "description": "AvatarURL is the url where we can get the avatar for identity", - "type": "string" - }, - "displayName": { - "description": "Display name for identity.", - "type": "string", - "default": "" - }, - "identity": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.iam.v0alpha1.IdentityRef" - } - ] - }, - "internalId": { - "description": "InternalID is the legacy numreric id for identity, this is deprecated and should be phased out", - "type": "integer", - "format": "int64" - } - } - }, - "com.github.grafana.grafana.pkg.apis.iam.v0alpha1.DisplayList": { - "type": "object", - "required": [ - "keys", - "display" - ], - "properties": { - "apiVersion": { - "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - "type": "string" - }, - "display": { - "description": "Matching items (the caller may need to remap from keys to results)", - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.iam.v0alpha1.Display" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "invalidKeys": { - "description": "Input keys that were not useable", - "type": "array", - "items": { - "type": "string", - "default": "" - }, - "x-kubernetes-list-type": "set" - }, - "keys": { - "description": "Request keys used to lookup the display value", - "type": "array", - "items": { - "type": "string", - "default": "" - }, - "x-kubernetes-list-type": "set" - }, - "kind": { - "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - "type": "string" - }, - "metadata": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" - } - ] - } - }, - "x-kubernetes-group-version-kind": [ - { - "group": "iam.grafana.app", - "kind": "DisplayList", - "version": "__internal" - }, - { - "group": "iam.grafana.app", - "kind": "DisplayList", - "version": "v0alpha1" - } - ] - }, "com.github.grafana.grafana.pkg.apis.iam.v0alpha1.IdentityRef": { "type": "object", "required": [ @@ -2539,12 +2519,12 @@ ], "properties": { "name": { - "description": "Name is the unique identifier for identity, guaranteed jo be a unique value for the type within a namespace.", + "description": "Name is the unique identifier for identity, guaranteed to be a unique value for the type within a namespace.", "type": "string", "default": "" }, "type": { - "description": "Type of identity e.g. \"user\". For a full list see https://github.com/grafana/authlib/blob/2f8d13a83ca3e82da08b53726de1697ee5b5b4cc/claims/type.go#L15-L24", + "description": "Type of identity e.g. \"user\". For a full list see https://github.com/grafana/authlib/blob/d6737a7dc8f55e9d42834adb83b5da607ceed293/types/type.go#L15", "type": "string", "default": "" } @@ -2977,7 +2957,7 @@ ] }, "internalId": { - "description": "InternalID is the legacy numreric id for identity, this is deprecated and should be phased out", + "description": "InternalID is the legacy numeric id for identity, Deprecated: use the identityRef where possible", "type": "integer", "format": "int64" }, diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index b8fdd41352d..6376687d480 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -12,6 +12,7 @@ import { } from '@grafana/scenes'; import { ElementSelectionContextItem, ElementSelectionContextState, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { isInCloneChain } from '../utils/clone'; import { getDashboardSceneFor } from '../utils/utils'; import { ElementEditPane } from './ElementEditPane'; @@ -46,6 +47,16 @@ export class DashboardEditPane extends SceneObjectBase { } private selectElement(element: ElementSelectionContextItem, multi?: boolean) { + // We should not select clones + if (isInCloneChain(element.id)) { + if (multi) { + return; + } + + this.clearSelection(); + return; + } + const obj = sceneGraph.findByKey(this, element.id); if (obj) { this.selectObject(obj, element.id, multi); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 80d4b19fa3a..386aa1e7782 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -23,6 +23,7 @@ import { createWorker } from '../saving/createDetectChangesWorker'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { historySrv } from '../settings/version-history/HistorySrv'; +import { getCloneKey } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { findVizPanelByKey, getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; @@ -33,7 +34,7 @@ import { LibraryPanelBehavior } from './LibraryPanelBehavior'; import { PanelTimeRange } from './PanelTimeRange'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; -import { RowActions } from './row-actions/RowActions'; +import { RowActions } from './layout-default/row-actions/RowActions'; jest.mock('../settings/version-history/HistorySrv'); jest.mock('../serialization/transformSaveModelToScene'); @@ -642,7 +643,7 @@ describe('DashboardScene', () => { it('Should hash the key of the cloned panels and set it as panelId', () => { const queryRunner = sceneGraph.findObject(scene, (o) => o.state.key === 'data-query-runner2')!; - const expectedPanelId = djb2Hash('panel-2-clone-1'); + const expectedPanelId = djb2Hash(getCloneKey('panel-2', 1)); expect(scene.enrichDataRequest(queryRunner).panelId).toEqual(expectedPanelId); }); }); @@ -915,7 +916,7 @@ function buildTestScene(overrides?: Partial) { new DashboardGridItem({ body: new VizPanel({ title: 'Panel B', - key: 'panel-2-clone-1', + key: getCloneKey('panel-2', 1), pluginId: 'table', $data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }), }), diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index d6e7a4eb923..2d2a85dc469 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -53,17 +53,12 @@ import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; import { historySrv } from '../settings/version-history'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; +import { isInCloneChain } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { getDashboardUrl } from '../utils/getDashboardUrl'; import { getViewPanelUrl } from '../utils/urlBuilders'; -import { - getClosestVizPanel, - getDashboardSceneFor, - getDefaultVizPanel, - getPanelIdForVizPanel, - isPanelClone, -} from '../utils/utils'; +import { getClosestVizPanel, getDashboardSceneFor, getDefaultVizPanel, getPanelIdForVizPanel } from '../utils/utils'; import { SchemaV2EditorDrawer } from '../v2schema/SchemaV2EditorDrawer'; import { AddLibraryPanelDrawer } from './AddLibraryPanelDrawer'; @@ -489,6 +484,10 @@ export class DashboardScene extends SceneObjectBase { return this._initialState; } + public getNextPanelId(): number { + return this.state.body.getMaxPanelId() + 1; + } + public addPanel(vizPanel: VizPanel): void { if (!this.state.isEditing) { this.onEnterEditMode(); @@ -615,10 +614,11 @@ export class DashboardScene extends SceneObjectBase { public switchLayout(layout: DashboardLayoutManager) { this.setState({ body: layout }); + layout.activateRepeaters?.(); } /** - * Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request + * Called by the SceneQueryRunner to provide contextual parameters (tracking) props for the request */ public enrichDataRequest(sceneObject: SceneObject): Partial { const dashboard = getDashboardSceneFor(sceneObject); @@ -632,9 +632,12 @@ export class DashboardScene extends SceneObjectBase { let panelId = 0; if (panel && panel.state.key) { - if (isPanelClone(panel.state.key)) { + if (isInCloneChain(panel.state.key)) { + // We check if any of the panel ancestors are clones because we can't use the original panel ID in this case panelId = djb2Hash(panel?.state.key); } else { + // Otherwise, it's the absolute original panel, and we can use the key directly + // getPanelIdForVizPanel extracts the panel ID from the key so we don't need to do it manually panelId = getPanelIdForVizPanel(panel); } } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts index 1decb7b9fc5..6092bac64c2 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts @@ -3,6 +3,8 @@ import { SceneQueryRunner, VizPanel } from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { KioskMode } from 'app/types'; +import { getCloneKey } from '../utils/clone'; + import { DashboardScene } from './DashboardScene'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; @@ -82,7 +84,7 @@ describe('DashboardSceneUrlSync', () => { let errorNotice = 0; appEvents.on(AppEvents.alertError, (evt) => errorNotice++); - scene.urlSync?.updateFromUrl({ viewPanel: 'panel-1-clone-1' }); + scene.urlSync?.updateFromUrl({ viewPanel: getCloneKey('panel-1', 1) }); expect(scene.state.viewPanelScene).toBeUndefined(); // Verify no error notice was shown @@ -98,7 +100,7 @@ describe('DashboardSceneUrlSync', () => { x: 0, body: new VizPanel({ title: 'Clone Panel A', - key: 'panel-1-clone-1', + key: getCloneKey('panel-1', 1), pluginId: 'table', }), }), @@ -107,7 +109,7 @@ describe('DashboardSceneUrlSync', () => { // Verify it subscribes to DashboardRepeatsProcessedEvent scene.publishEvent(new DashboardRepeatsProcessedEvent({ source: scene })); - expect(scene.state.viewPanelScene?.getUrlKey()).toBe('panel-1-clone-1'); + expect(scene.state.viewPanelScene?.getUrlKey()).toBe(getCloneKey('panel-1', 1)); }); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 960789473c1..1eb85045504 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -12,7 +12,8 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; -import { findVizPanelByKey, getLibraryPanelBehavior, isPanelClone } from '../utils/utils'; +import { containsCloneKey } from '../utils/clone'; +import { findVizPanelByKey, getLibraryPanelBehavior } from '../utils/utils'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; @@ -94,8 +95,10 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { const panel = findVizPanelByKey(this._scene, values.viewPanel); if (!panel) { - // // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet - if (isPanelClone(values.viewPanel)) { + // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet + // Here we check if the key contains the clone key so we force the repeat processing + // It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key + if (containsCloneKey(values.viewPanel)) { this._handleViewRepeatClone(values.viewPanel); return; } diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 0080b57e228..1f8a9547c83 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -30,15 +30,10 @@ import { ShowConfirmModalEvent } from 'app/types/events'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; +import { isInCloneChain } from '../utils/clone'; import { DashboardInteractions } from '../utils/interactions'; import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; -import { - getDashboardSceneFor, - getPanelIdForVizPanel, - getQueryRunnerFor, - isLibraryPanel, - isReadOnlyClone, -} from '../utils/utils'; +import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks'; @@ -59,7 +54,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) { const dashboard = getDashboardSceneFor(panel); const { isEmbedded } = dashboard.state.meta; const exploreMenuItem = await getExploreMenuItem(panel); - const isReadOnlyRepeat = isReadOnlyClone(panel); + const isReadOnlyRepeat = isInCloneChain(panel.state.key!); // For embedded dashboards we only have explore action for now if (isEmbedded) { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx index c9bf5da0031..165d00b99a2 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx @@ -3,8 +3,8 @@ import { setPluginImportUtils } from '@grafana/runtime'; import { SceneGridLayout, SceneVariableSet, TestVariable, VizPanel } from '@grafana/scenes'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; +import { isInCloneChain } from '../../utils/clone'; import { activateFullSceneTree, buildPanelRepeaterScene } from '../../utils/test-utils'; -import { isReadOnlyClone } from '../../utils/utils'; import { DashboardScene } from '../DashboardScene'; import { DashboardGridItem, DashboardGridItemState } from './DashboardGridItem'; @@ -42,8 +42,8 @@ describe('PanelRepeaterGridItem', () => { expect(panel1.state.$variables?.state.variables[0].getValueText?.()).toBe('A'); expect(panel2.state.$variables?.state.variables[0].getValue()).toBe('2'); - expect(isReadOnlyClone(panel1)).toBe(false); - expect(isReadOnlyClone(panel2)).toBe(true); + expect(isInCloneChain(panel1.state.key!)).toBe(false); + expect(isInCloneChain(panel2.state.key!)).toBe(true); }); it('Should wait for variable to load', async () => { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index b9ce621a2a2..165016a3493 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -23,6 +23,7 @@ import { import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; +import { getCloneKey } from '../../utils/clone'; import { getMultiVariableValues, getQueryRunnerFor } from '../../utils/utils'; import { DashboardLayoutItem, DashboardRepeatsProcessedEvent } from '../types'; @@ -138,7 +139,7 @@ export class DashboardGridItem }), ], }), - key: `${panelToRepeat.state.key}-clone-${index}`, + key: getCloneKey(panelToRepeat.state.key!, index), }; const clone = panelToRepeat.clone(cloneState); repeatedPanels.push(clone); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx index 39a11c6babd..e4bb910235e 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx @@ -1,6 +1,7 @@ import { SceneGridItemLike, SceneGridLayout, SceneGridRow, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import { findVizPanelByKey } from '../../utils/utils'; +import { DashboardScene } from '../DashboardScene'; import { DashboardGridItem } from './DashboardGridItem'; import { DefaultGridLayoutManager } from './DefaultGridLayoutManager'; @@ -277,5 +278,7 @@ function setup(options?: TestOptions) { const grid = new SceneGridLayout({ children: gridItems }); const manager = new DefaultGridLayoutManager({ grid: grid }); + new DashboardScene({ body: manager }); + return { manager, grid }; } diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index f6d2156b1c6..3bae6e1c50c 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -12,18 +12,21 @@ import { } from '@grafana/scenes'; import { GRID_COLUMN_COUNT } from 'app/core/constants'; +import { isClonedKey, joinCloneKeys } from '../../utils/clone'; import { forceRenderChildren, getPanelIdForVizPanel, NEW_PANEL_HEIGHT, NEW_PANEL_WIDTH, getVizPanelKeyForPanelId, + getGridItemKeyForPanelId, + getDashboardSceneFor, } from '../../utils/utils'; -import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; -import { RowActions } from '../row-actions/RowActions'; import { DashboardLayoutManager, LayoutRegistryItem } from '../types'; import { DashboardGridItem } from './DashboardGridItem'; +import { RowRepeaterBehavior } from './RowRepeaterBehavior'; +import { RowActions } from './row-actions/RowActions'; interface DefaultGridLayoutManagerState extends SceneObjectState { grid: SceneGridLayout; @@ -66,7 +69,7 @@ export class DefaultGridLayoutManager x: 0, y: 0, body: vizPanel, - key: `grid-item-${panelId}`, + key: getGridItemKeyForPanelId(panelId), }); this.state.grid.setState({ @@ -75,7 +78,7 @@ export class DefaultGridLayoutManager } /** - * Adds a new emtpy row + * Adds a new empty row */ public addNewRow(): SceneGridRow { const id = this.getNextPanelId(); @@ -127,7 +130,7 @@ export class DefaultGridLayoutManager /** * Removes a panel */ - public removePanel(panel: VizPanel) { + public removePanel(panel: VizPanel): void { const gridItem = panel.parent!; if (!(gridItem instanceof DashboardGridItem)) { @@ -231,7 +234,7 @@ export class DefaultGridLayoutManager return panels; } - public getNextPanelId(): number { + public getMaxPanelId(): number { let max = 0; for (const child of this.state.grid.state.children) { @@ -271,10 +274,14 @@ export class DefaultGridLayoutManager } } - return max + 1; + return max; } - public collapseAllRows() { + public getNextPanelId(): number { + return getDashboardSceneFor(this).getNextPanelId(); + } + + public collapseAllRows(): void { this.state.grid.state.children.forEach((child) => { if (!(child instanceof SceneGridRow)) { return; @@ -285,7 +292,7 @@ export class DefaultGridLayoutManager }); } - public expandAllRows() { + public expandAllRows(): void { this.state.grid.state.children.forEach((child) => { if (!(child instanceof SceneGridRow)) { return; @@ -296,7 +303,7 @@ export class DefaultGridLayoutManager }); } - activateRepeaters(): void { + public activateRepeaters(): void { this.state.grid.forEachChild((child) => { if (child instanceof DashboardGridItem && !child.isActive) { child.activate(); @@ -325,6 +332,76 @@ export class DefaultGridLayoutManager return DefaultGridLayoutManager.getDescriptor(); } + public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { + return this.clone({ + grid: this.state.grid.clone({ + isResizable: isSource && this.state.grid.state.isResizable, + isDraggable: isSource && this.state.grid.state.isDraggable, + children: this.state.grid.state.children.reduce<{ panelId: number; children: SceneGridItemLike[] }>( + (childrenAcc, child) => { + if (child instanceof DashboardGridItem) { + const gridItemKey = joinCloneKeys(ancestorKey, getGridItemKeyForPanelId(childrenAcc.panelId)); + + const gridItem = child.clone({ + key: gridItemKey, + body: child.state.body.clone({ + key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)), + }), + isDraggable: isSource && child.state.isDraggable, + isResizable: isSource && child.state.isResizable, + }); + + childrenAcc.children.push(gridItem); + return childrenAcc; + } + + if (child instanceof SceneGridRow) { + const rowKey = joinCloneKeys(ancestorKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)); + + const row = child.clone({ + key: rowKey, + children: child.state.children.reduce((rowAcc, rowChild) => { + if (isClonedKey(rowChild.state.key!)) { + return rowAcc; + } + + if (!(rowChild instanceof DashboardGridItem)) { + rowAcc.push(rowChild.clone()); + return rowAcc; + } + + const gridItemKey = joinCloneKeys(rowKey, getGridItemKeyForPanelId(childrenAcc.panelId)); + + const gridItem = rowChild.clone({ + key: gridItemKey, + isDraggable: isSource && rowChild.state.isDraggable, + isResizable: isSource && rowChild.state.isResizable, + body: rowChild.state.body.clone({ + key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)), + }), + }); + + rowAcc.push(gridItem); + return rowAcc; + }, []), + isDraggable: isSource && child.state.isDraggable, + isResizable: isSource && child.state.isResizable, + }); + + childrenAcc.children.push(row); + + return childrenAcc; + } + + childrenAcc.children.push(child.clone()); + return childrenAcc; + }, + { panelId: 0, children: [] } + ).children, + }), + }); + } + public static getDescriptor(): LayoutRegistryItem { return { name: 'Default grid', @@ -389,9 +466,15 @@ export class DefaultGridLayoutManager /** * Useful for preserving items positioning when switching layouts * @param gridItems + * @param isDraggable + * @param isResizable * @returns */ - public static fromGridItems(gridItems: SceneGridItemLike[]): DefaultGridLayoutManager { + public static fromGridItems( + gridItems: SceneGridItemLike[], + isDraggable?: boolean, + isResizable?: boolean + ): DefaultGridLayoutManager { const children = gridItems.reduce((acc, gridItem) => { gridItem.clearParent(); acc.push(gridItem); @@ -402,8 +485,8 @@ export class DefaultGridLayoutManager return new DefaultGridLayoutManager({ grid: new SceneGridLayout({ children, - isDraggable: true, - isResizable: true, + isDraggable, + isResizable, }), }); } diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx similarity index 88% rename from public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx rename to public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx index 116fc50781d..a16bfe7da18 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx @@ -13,13 +13,13 @@ import { } from '@grafana/scenes'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; -import { activateFullSceneTree } from '../utils/test-utils'; -import { isReadOnlyClone } from '../utils/utils'; +import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone'; +import { activateFullSceneTree } from '../../utils/test-utils'; +import { DashboardScene } from '../DashboardScene'; -import { DashboardScene } from './DashboardScene'; +import { RepeatDirection } from './DashboardGridItem'; +import { DefaultGridLayoutManager } from './DefaultGridLayoutManager'; import { RowRepeaterBehavior } from './RowRepeaterBehavior'; -import { RepeatDirection } from './layout-default/DashboardGridItem'; -import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; import { RowActions } from './row-actions/RowActions'; jest.mock('@grafana/runtime', () => ({ @@ -54,24 +54,31 @@ describe('RowRepeaterBehavior', () => { // Verify that first row still has repeat behavior const row1 = grid.state.children[1] as SceneGridRow; + expect(row1.state.key).toBe(getCloneKey('row-1', 0)); expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowRepeaterBehavior); expect(row1.state.$variables!.state.variables[0].getValue()).toBe('A1'); expect(row1.state.actions).toBeDefined(); + const gridItemRow1 = row1.state.children[0] as SceneGridItem; + expect(gridItemRow1.state.key!).toBe(joinCloneKeys(row1.state.key!, 'griditem-1')); + expect(gridItemRow1.state.body?.state.key).toBe(joinCloneKeys(gridItemRow1.state.key!, 'canvas-1')); + const row2 = grid.state.children[2] as SceneGridRow; + expect(row2.state.key).toBe(getCloneKey('row-1', 1)); + expect(row2.state.$behaviors).toEqual([]); expect(row2.state.$variables!.state.variables[0].getValueText?.()).toBe('B'); expect(row2.state.actions).toBeUndefined(); - // Should give repeated panels unique keys - const gridItem = row2.state.children[0] as SceneGridItem; - expect(gridItem.state.body?.state.key).toBe('canvas-1-clone-B1'); + const gridItemRow2 = row2.state.children[0] as SceneGridItem; + expect(gridItemRow2.state.key!).toBe(joinCloneKeys(row2.state.key!, 'griditem-1')); + expect(gridItemRow2.state.body?.state.key).toBe(joinCloneKeys(gridItemRow2.state.key!, 'canvas-1')); }); it('Repeated rows should be read only', () => { const row1 = grid.state.children[1] as SceneGridRow; const row2 = grid.state.children[2] as SceneGridRow; - expect(isReadOnlyClone(row1)).toBe(false); - expect(isReadOnlyClone(row2)).toBe(true); + expect(isInCloneChain(row1.state.key!)).toBe(false); + expect(isInCloneChain(row2.state.key!)).toBe(true); }); it('Should update all rows when a panel is added to a clone', async () => { @@ -209,6 +216,7 @@ function buildScene( const grid = new SceneGridLayout({ children: [ new SceneGridItem({ + key: 'griditem-no-row', x: 0, y: 0, width: 24, @@ -218,6 +226,7 @@ function buildScene( }), }), new SceneGridRow({ + key: 'row-1', x: 0, y: 10, width: 24, @@ -226,6 +235,7 @@ function buildScene( $behaviors: [repeatBehavior], children: [ new SceneGridItem({ + key: 'griditem-1', x: 0, y: 11, width: 24, @@ -238,12 +248,12 @@ function buildScene( ], }), new SceneGridRow({ + key: 'row-2', x: 0, y: 16, width: 24, height: 5, title: 'Row at the bottom', - children: [ new SceneGridItem({ key: 'griditem-2', diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts similarity index 62% rename from public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts rename to public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts index 990ada92748..6ac38847198 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts @@ -14,10 +14,18 @@ import { VariableValueSingle, } from '@grafana/scenes'; -import { getMultiVariableValues } from '../utils/utils'; +import { + containsCloneKey, + getLastKeyFromClone, + isClonedKeyOf, + joinCloneKeys, + getCloneKey, + isClonedKey, +} from '../../utils/clone'; +import { getMultiVariableValues } from '../../utils/utils'; +import { DashboardRepeatsProcessedEvent } from '../types'; -import { DashboardGridItem } from './layout-default/DashboardGridItem'; -import { DashboardRepeatsProcessedEvent } from './types'; +import { DashboardGridItem } from './DashboardGridItem'; interface RowRepeaterBehaviorState extends SceneObjectState { variableName: string; @@ -47,11 +55,11 @@ export class RowRepeaterBehavior extends SceneObjectBase !isClonedKey(child.state.key!)); const sub = layout.subscribeToState(() => { - const repeatedRows = layout.state.children.filter( - (child) => child instanceof SceneGridRow && child.state.key?.includes(filterKey) + const repeatedRows = layout.state.children.filter((child) => + isClonedKeyOf(child.state.key!, originalRow.state.key!) ); // go through cloned rows, search for panels that are not clones @@ -60,24 +68,29 @@ export class RowRepeaterBehavior extends SceneObjectBase !isClonedKey(child.state.key!)); + // if no differences in row children compared to original, then no new panel added to clone - if (row.state.children.length === originalRow.state.children.length) { + if (rowNonClonedPanels.length === originalRowNonClonedPanels.length) { continue; } - //if there are differences, find the new panel, move it to the original and perform re peat - const gridItem = row.state.children.find((gridItem) => !gridItem.state.key?.includes('clone')); + // if there are differences, find the new panel, move it to the original and perform repeat + const gridItem = rowNonClonedPanels.find((gridItem) => !containsCloneKey(gridItem.state.key!)); + if (gridItem) { const newGridItem = gridItem.clone(); + row.setState({ children: row.state.children.filter((item) => item !== gridItem) }); // if we are moving a panel from the origin row to a clone row, we just return - // this means we are modifying the origin row, retriggering the repeat and losing that panel + // this means we are modifying the origin row, re-triggering the repeat and losing that panel if (originalRow.state.children.find((item) => item.state.key === newGridItem.state.key)) { return; } originalRow.setState({ children: [...originalRow.state.children, newGridItem] }); + this.performRepeat(true); } } @@ -135,13 +148,14 @@ export class RowRepeaterBehavior extends SceneObjectBase 0 ? `${source.state.key}-clone-${localValue}` : source.state.key; - const itemClone = source.clone({ key: itemKey, y: itemY }); + const rowCloneKey = getCloneKey(rowToRepeat.state.key!, rowIndex); - // Make sure all the child scene objects have unique keys - // and add proper menu to the repeated panel - if (index > 0) { - ensureUniqueKeys(itemClone, localValue); - - //disallow clones to be dragged around or out of the row - if (itemClone instanceof DashboardGridItem) { - itemClone.setState({ isDraggable: false }); - } - } - - children.push(itemClone); - - if (maxYOfRows < itemY + itemClone.state.height!) { - maxYOfRows = itemY + itemClone.state.height!; - } - } - - const rowClone = this.getRowClone( - rowToRepeat, - index, - localValue, - variableTexts[index], - rowContentHeight, - children, - variable - ); - this._clonedRows.push(rowClone); - } - - updateLayout(layout, this._clonedRows, maxYOfRows, rowToRepeat); - - // Used from dashboard url sync - this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); - } - - getRowClone( - rowToRepeat: SceneGridRow, - index: number, - value: VariableValueSingle, - text: VariableValueSingle, - rowContentHeight: number, - children: SceneGridItemLike[], - variable: MultiValueVariable - ): SceneGridRow { - if (index === 0) { - rowToRepeat.setState({ - // not activated + rowClone.setState({ + key: rowCloneKey, $variables: new SceneVariableSet({ variables: [ new LocalValueVariable({ name: this.state.variableName, - value, - text: String(text), + value: variableValues[rowIndex], + text: String(variableTexts[rowIndex]), isMulti: variable.state.isMulti, includeAll: variable.state.includeAll, }), ], }), - children, + children: [], }); - return rowToRepeat; + + const children: SceneGridItemLike[] = []; + + for (const sourceItem of rowContent) { + const sourceItemY = sourceItem.state.y ?? 0; + + const cloneItemKey = joinCloneKeys(rowCloneKey, getLastKeyFromClone(sourceItem.state.key!)); + const cloneItemY = sourceItemY + (rowContentHeight + 1) * rowIndex; + + const cloneItem = sourceItem.clone({ + key: cloneItemKey, + y: cloneItemY, + isDraggable: !isSourceRow && sourceItem instanceof DashboardGridItem ? false : sourceItem.state.isDraggable, + isResizable: !isSourceRow && sourceItem instanceof DashboardGridItem ? false : sourceItem.state.isResizable, + }); + + ensureUniqueKeys(cloneItem, cloneItemKey); + + children.push(cloneItem); + + if (maxYOfRows < cloneItemY + cloneItem.state.height!) { + maxYOfRows = cloneItemY + cloneItem.state.height!; + } + } + + rowClone.setState({ children }); + + this._clonedRows.push(rowClone); } - const sourceRowY = rowToRepeat.state.y ?? 0; + updateLayout(layout, this._clonedRows, maxYOfRows, rowToRepeat.state.key!); - return rowToRepeat.clone({ - key: `${rowToRepeat.state.key}-clone-${value}`, - $variables: new SceneVariableSet({ - variables: [ - new LocalValueVariable({ - name: this.state.variableName, - value, - text: String(text), - isMulti: variable.state.isMulti, - includeAll: variable.state.includeAll, - }), - ], - }), - $behaviors: [], - children, - y: sourceRowY + rowContentHeight * index + index, - actions: undefined, - }); + // Used from dashboard url sync + this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); } public removeBehavior() { const row = this._getRow(); const layout = this._getLayout(); - const children = getLayoutChildrenFilterOutRepeatClones(this._getLayout(), this._getRow()); + const children = getLayoutChildrenFilterOutRepeatClones(layout, row.state.key!); layout.setState({ children: children }); @@ -280,9 +259,9 @@ function getRowContentHeight(panels: SceneGridItemLike[]): number { return maxY - minY; } -function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: number, rowToRepeat: SceneGridRow) { - const allChildren = getLayoutChildrenFilterOutRepeatClones(layout, rowToRepeat); - const index = allChildren.indexOf(rowToRepeat); +function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: number, rowKey: string) { + const allChildren = getLayoutChildrenFilterOutRepeatClones(layout, rowKey); + const index = allChildren.findIndex((child) => child.state.key!.includes(rowKey)); if (index === -1) { throw new Error('RowRepeaterBehavior: Parent row not found in layout children'); @@ -310,19 +289,14 @@ function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: layout.setState({ children: newChildren }); } -function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowToRepeat: SceneGridRow) { - return layout.state.children.filter((child) => { - if (child.state.key?.startsWith(`${rowToRepeat.state.key}-clone-`)) { - return false; - } - - return true; - }); +function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowKey: string) { + return layout.state.children.filter((child) => !isClonedKeyOf(child.state.key!, rowKey)); } -function ensureUniqueKeys(item: SceneGridItemLike, localValue: VariableValueSingle) { +function ensureUniqueKeys(item: SceneGridItemLike, ancestors: string) { item.forEachChild((child) => { - child.setState({ key: `${child.state.key}-clone-${localValue}` }); - ensureUniqueKeys(child, localValue); + const key = joinCloneKeys(ancestors, child.state.key!); + child.setState({ key }); + ensureUniqueKeys(child, key); }); } diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActions.tsx similarity index 72% rename from public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx rename to public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActions.tsx index 5d5f342dca2..c26258176db 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActions.tsx @@ -11,14 +11,16 @@ import { } from '@grafana/scenes'; import { Icon, TextLink, useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events'; +import { t, Trans } from 'app/core/internationalization'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { ShowConfirmModalEvent } from 'app/types/events'; -import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils'; -import { DashboardScene } from '../DashboardScene'; +import { getDashboardSceneFor, getQueryRunnerFor } from '../../../utils/utils'; +import { DashboardScene } from '../../DashboardScene'; +import { DashboardGridItem } from '../DashboardGridItem'; +import { DefaultGridLayoutManager } from '../DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; -import { DashboardGridItem } from '../layout-default/DashboardGridItem'; -import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowOptionsButton } from './RowOptionsButton'; @@ -75,9 +77,12 @@ export class RowActions extends SceneObjectBase { public onDelete = () => { appEvents.publish( new ShowConfirmModalEvent({ - title: 'Delete row', - text: 'Are you sure you want to remove this row and all its panels?', - altActionText: 'Delete row only', + title: t('dashboard.default-layout.row-actions.modal.title', 'Delete row'), + text: t( + 'dashboard.default-layout.row-actions.modal.text', + 'Are you sure you want to remove this row and all its panels?' + ), + altActionText: t('dashboard.default-layout.row-actions.modal.alt-action', 'Delete row only'), icon: 'trash-alt', onConfirm: () => this.removeRow(true), onAltAction: () => this.removeRow(), @@ -97,7 +102,11 @@ export class RowActions extends SceneObjectBase { const vizPanel = gridItem.state.body; if (vizPanel instanceof VizPanel) { const runner = getQueryRunnerFor(vizPanel); - return runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY; + return ( + runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY || + (runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME && + runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY)) + ); } return false; @@ -107,8 +116,10 @@ export class RowActions extends SceneObjectBase { return (

- Panels in this row use the {SHARED_DASHBOARD_QUERY} data source. These panels will reference the panel in - the original row, not the ones in the repeated rows. + + Panels in this row use the {{ SHARED_DASHBOARD_QUERY }} data source. These panels will reference the panel + in the original row, not the ones in the repeated rows. +

{ 'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows' } > - Learn more + Learn more
); @@ -146,7 +157,11 @@ export class RowActions extends SceneObjectBase { onUpdate={model.onUpdate} warning={model.getWarning()} /> - diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsButton.tsx similarity index 89% rename from public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx rename to public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsButton.tsx index 546c7de8387..aca11f93bbc 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsButton.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { SceneObject } from '@grafana/scenes'; import { Icon, ModalsController } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { OnRowOptionsUpdate } from './RowOptionsForm'; import { RowOptionsModal } from './RowOptionsModal'; @@ -27,7 +28,7 @@ export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: R + - ); diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsModal.tsx similarity index 79% rename from public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx rename to public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsModal.tsx index bab4052178b..0976bf2e387 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsModal.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { SceneObject } from '@grafana/scenes'; import { Modal, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { OnRowOptionsUpdate, RowOptionsForm } from './RowOptionsForm'; @@ -19,7 +20,12 @@ export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, wa const styles = useStyles2(getStyles); return ( - + child !== element) }); @@ -126,6 +130,7 @@ export class ResponsiveGridLayoutManager activateRepeaters?(): void { throw new Error('Method not implemented.'); } + public static Component = ({ model }: SceneComponentProps) => { return ; }; diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index cd95834bcad..e52fd8d46d2 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -1,17 +1,40 @@ import { css, cx } from '@emotion/css'; -import { useMemo, useRef } from 'react'; +import { ReactNode, useMemo, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneObjectState, SceneObjectBase, SceneComponentProps, sceneGraph } from '@grafana/scenes'; -import { Button, Icon, Input, RadioButtonGroup, Switch, useElementSelection, useStyles2 } from '@grafana/ui'; +import { + SceneObjectState, + SceneObjectBase, + SceneComponentProps, + sceneGraph, + VariableDependencyConfig, +} from '@grafana/scenes'; +import { + Alert, + Button, + Icon, + Input, + RadioButtonGroup, + Switch, + TextLink, + useElementSelection, + useStyles2, +} from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { getDashboardSceneFor, getDefaultVizPanel } from '../../utils/utils'; +import { isClonedKey } from '../../utils/clone'; +import { getDashboardSceneFor, getDefaultVizPanel, getQueryRunnerFor } from '../../utils/utils'; +import { DashboardScene } from '../DashboardScene'; import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; import { DashboardLayoutManager, EditableDashboardElement, LayoutParent } from '../types'; +import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior'; import { RowsLayoutManager } from './RowsLayoutManager'; export interface RowItemState extends SceneObjectState { @@ -23,6 +46,10 @@ export interface RowItemState extends SceneObjectState { } export class RowItem extends SceneObjectBase implements LayoutParent, EditableDashboardElement { + protected _variableDependency = new VariableDependencyConfig(this, { + statePaths: ['title'], + }); + public isEditableDashboardElement: true = true; public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { @@ -54,10 +81,25 @@ export class RowItem extends SceneObjectBase implements LayoutPare ); }, [row]); + const rowRepeatOptions = useMemo(() => { + const dashboard = getDashboardSceneFor(row); + + return new OptionsPaneCategoryDescriptor({ + title: 'Repeat options', + id: 'row-repeat-options', + isOpenDefault: true, + }).addItem( + new OptionsPaneItemDescriptor({ + title: 'Variable', + render: () => , + }) + ); + }, [row]); + const { layout } = this.useState(); const layoutOptions = useLayoutCategory(layout); - return [rowOptions, layoutOptions]; + return [rowOptions, rowRepeatOptions, layoutOptions]; } public getTypeName(): string { @@ -69,7 +111,7 @@ export class RowItem extends SceneObjectBase implements LayoutPare layout.removeRow(this); }; - public renderActions(): React.ReactNode { + public renderActions(): ReactNode { return ( <> - {isEditing && ( + {!isClone && isEditing && (