Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
79 files changed
+1892
-782
No files matched your search
+4
-20
@@ -538,8 +538,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> 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 <Trans /> 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 <Trans /> 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 <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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"]
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
<!-- 11.5.1 START -->
|
||||
|
||||
# 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.1 END -->
|
||||
<!-- 11.5.0 START -->
|
||||
|
||||
# 11.5.0 (2025-01-28)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
plugin/src
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <a href='https://grafana.com/docs/grafana/latest/upgrade-guide/upgrade-v11.2/#grafana-data-source-uid-format-enforcement' target=_blank>documentation</a> 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 <a href='/connections/datasources/edit/%s'>data source configuration</a>"+
|
||||
|
||||
@@ -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 <a href='https://grafana.com/legal/plugin-deprecation/#a-plugin-i-use-is-deprecated-what-should-i-do' target=_blank>documentation</a> 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 <a href='/plugins/%s?page=version-history'>plugin admin page</a>"+
|
||||
|
||||
@@ -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 <a href='https://grafana.com/legal/plugin-deprecation/#a-plugin-i-use-is-deprecated-what-should-i-do' target=_blank>documentation</a> 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 <a href='/plugins/plugin2?page=version-history'>plugin admin page</a> 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 <a href='/plugins/plugin2?page=version-history'>plugin admin page</a> 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{},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -237,11 +237,11 @@ describe('Combobox', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} createCustomValue />);
|
||||
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(<Combobox options={options} value={null} onChange={onChangeHandler} createCustomValue />);
|
||||
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 () => {
|
||||
|
||||
@@ -135,12 +135,10 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
|
||||
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);
|
||||
|
||||
@@ -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(<MultiCombobox options={options} value={[]} onChange={onChange} createCustomValue />);
|
||||
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(<MultiCombobox options={options} value={['a', 'c']} onChange={onChange} createCustomValue />);
|
||||
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' },
|
||||
|
||||
@@ -37,8 +37,19 @@ interface MultiComboboxBaseProps<T extends string | number> extends Omit<Combobo
|
||||
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
|
||||
|
||||
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
|
||||
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 = <T extends string | number>(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 = <T extends string | number>(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 ?? '');
|
||||
|
||||
@@ -20,9 +20,6 @@ export function itemToString<T extends string | number>(item?: ComboboxOption<T>
|
||||
if (item == null) {
|
||||
return '';
|
||||
}
|
||||
if (item.label?.startsWith('Custom value: ')) {
|
||||
return item.value.toString();
|
||||
}
|
||||
return item.label ?? item.value.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T extends string | number>(rawOptions: AsyncOptions<T>) {
|
||||
export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T>, createCustomValue: boolean) {
|
||||
const isAsync = typeof rawOptions === 'function';
|
||||
|
||||
const loadOptions = useLatestAsyncCall(isAsync ? rawOptions : asyncNoop);
|
||||
@@ -56,6 +58,27 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
|
||||
// told it for async options loading anyway.
|
||||
const [userTypedSearch, setUserTypedSearch] = useState('');
|
||||
|
||||
const addCustomValue = useCallback(
|
||||
(opts: Array<ComboboxOption<T>>) => {
|
||||
let currentOptions: Array<ComboboxOption<T>> = 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<T extends string | number>(rawOptions: AsyncOptions<T
|
||||
);
|
||||
|
||||
const finalOptions = useMemo(() => {
|
||||
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 };
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<DashboardEditPaneState> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<DashboardSceneState>) {
|
||||
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' }] }),
|
||||
}),
|
||||
|
||||
@@ -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<DashboardSceneState> {
|
||||
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<DashboardSceneState> {
|
||||
|
||||
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<DataQueryRequest> {
|
||||
const dashboard = getDashboardSceneFor(sceneObject);
|
||||
@@ -632,9 +632,12 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
@@ -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 };
|
||||
}
|
||||
+96
-13
@@ -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<SceneGridItemLike[]>((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<SceneGridItemLike[]>((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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
+21
-11
@@ -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',
|
||||
+81
-107
@@ -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<RowRepeaterBehaviorStat
|
||||
|
||||
const layout = this._getLayout();
|
||||
const originalRow = this._getRow();
|
||||
const filterKey = originalRow.state.key + '-clone-';
|
||||
const originalRowNonClonedPanels = originalRow.state.children.filter((child) => !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<RowRepeaterBehaviorStat
|
||||
continue;
|
||||
}
|
||||
|
||||
const rowNonClonedPanels = row.state.children.filter((child) => !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<RowRepeaterBehaviorStat
|
||||
this._prevRepeatValues = values;
|
||||
|
||||
this._clonedRows = [];
|
||||
|
||||
const rowContent = rowToRepeat.state.children;
|
||||
const rowContentHeight = getRowContentHeight(rowContent);
|
||||
|
||||
let maxYOfRows = 0;
|
||||
|
||||
// when variable has no options (due to error or similar) it will not render any panels at all
|
||||
// adding a placeholder in this case so that there is at least empty panel that can display error
|
||||
// adding a placeholder in this case so that there is at least empty panel that can display error
|
||||
const emptyVariablePlaceholderOption = {
|
||||
values: [''],
|
||||
texts: variable.hasAllValue() ? ['All'] : ['None'],
|
||||
@@ -150,108 +164,73 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
|
||||
const variableValues = values.length ? values : emptyVariablePlaceholderOption.values;
|
||||
const variableTexts = texts.length ? texts : emptyVariablePlaceholderOption.texts;
|
||||
|
||||
// Loop through variable values and create repeates
|
||||
for (let index = 0; index < variableValues.length; index++) {
|
||||
const children: SceneGridItemLike[] = [];
|
||||
const localValue = variableValues[index];
|
||||
for (let rowIndex = 0; rowIndex < variableValues.length; rowIndex++) {
|
||||
const isSourceRow = rowIndex === 0;
|
||||
const rowClone = isSourceRow
|
||||
? rowToRepeat
|
||||
: rowToRepeat.clone({
|
||||
y: (rowToRepeat.state.y ?? 0) + rowContentHeight * rowIndex + rowIndex,
|
||||
$behaviors: [],
|
||||
actions: undefined,
|
||||
});
|
||||
|
||||
// Loop through panels inside row
|
||||
for (const source of rowContent) {
|
||||
const sourceItemY = source.state.y ?? 0;
|
||||
const itemY = sourceItemY + (rowContentHeight + 1) * index;
|
||||
const itemKey = index > 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);
|
||||
});
|
||||
}
|
||||
+27
-12
@@ -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<RowActionsState> {
|
||||
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<RowActionsState> {
|
||||
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<RowActionsState> {
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
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.
|
||||
<Trans i18nKey="dashboard.default-layout.row-actions.repeat.warning.text">
|
||||
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.
|
||||
</Trans>
|
||||
</p>
|
||||
<TextLink
|
||||
external
|
||||
@@ -116,7 +127,7 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
|
||||
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
|
||||
}
|
||||
>
|
||||
Learn more
|
||||
<Trans i18nKey="dashboard.default-layout.row-actions.repeat.warning.learn-more">Learn more</Trans>
|
||||
</TextLink>
|
||||
</div>
|
||||
);
|
||||
@@ -146,7 +157,11 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
|
||||
onUpdate={model.onUpdate}
|
||||
warning={model.getWarning()}
|
||||
/>
|
||||
<button type="button" onClick={model.onDelete} aria-label="Delete row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={model.onDelete}
|
||||
aria-label={t('dashboard.default-layout.row-actions.delete', 'Delete row')}
|
||||
>
|
||||
<Icon name="trash-alt" />
|
||||
</button>
|
||||
</div>
|
||||
+2
-1
@@ -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
|
||||
<button
|
||||
type="button"
|
||||
className="pointer"
|
||||
aria-label="Row options"
|
||||
aria-label={t('dashboard.default-layout.row-options.button.label', 'Row options')}
|
||||
onClick={() => {
|
||||
showModal(RowOptionsModal, {
|
||||
title,
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { TestProvider } from 'test/helpers/TestProvider';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { DashboardScene } from '../../DashboardScene';
|
||||
|
||||
import { RowOptionsForm } from './RowOptionsForm';
|
||||
|
||||
+7
-4
@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { SceneObject } from '@grafana/scenes';
|
||||
import { Button, Field, Modal, Input, Alert } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
|
||||
|
||||
export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void;
|
||||
@@ -34,10 +35,10 @@ export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate,
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(submit)}>
|
||||
<Field label="Title">
|
||||
<Field label={t('dashboard.default-layout.row-options.form.title', 'Title')}>
|
||||
<Input {...register('title')} type="text" />
|
||||
</Field>
|
||||
<Field label="Repeat for">
|
||||
<Field label={t('dashboard.default-layout.row-options.form.repeat-for', 'Repeat for')}>
|
||||
<RepeatRowSelect2 sceneContext={sceneContext} repeat={newRepeat} onChange={onChangeRepeat} />
|
||||
</Field>
|
||||
{warning && (
|
||||
@@ -53,9 +54,11 @@ export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate,
|
||||
)}
|
||||
<Modal.ButtonRow>
|
||||
<Button type="button" variant="secondary" onClick={onCancel} fill="outline">
|
||||
Cancel
|
||||
<Trans i18nKey="dashboard.default-layout.row-options.form.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Trans i18nKey="dashboard.default-layout.row-options.form.update">Update</Trans>
|
||||
</Button>
|
||||
<Button type="submit">Update</Button>
|
||||
</Modal.ButtonRow>
|
||||
</form>
|
||||
);
|
||||
+7
-1
@@ -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 (
|
||||
<Modal isOpen={true} title="Row options" onDismiss={onDismiss} className={styles.modal}>
|
||||
<Modal
|
||||
isOpen={true}
|
||||
title={t('dashboard.default-layout.row-options.modal.title', 'Row options')}
|
||||
onDismiss={onDismiss}
|
||||
className={styles.modal}
|
||||
>
|
||||
<RowOptionsForm
|
||||
sceneContext={parent}
|
||||
repeat={repeat}
|
||||
+6
-1
@@ -38,7 +38,7 @@ export class ResponsiveGridLayoutManager
|
||||
getDashboardSceneFor(this).switchLayout(rowsLayout);
|
||||
}
|
||||
|
||||
public getNextPanelId(): number {
|
||||
public getMaxPanelId(): number {
|
||||
let max = 0;
|
||||
|
||||
for (const child of this.state.layout.state.children) {
|
||||
@@ -54,6 +54,10 @@ export class ResponsiveGridLayoutManager
|
||||
return max;
|
||||
}
|
||||
|
||||
public getNextPanelId(): number {
|
||||
return getDashboardSceneFor(this).getNextPanelId();
|
||||
}
|
||||
|
||||
public removePanel(panel: VizPanel) {
|
||||
const element = panel.parent;
|
||||
this.state.layout.setState({ children: this.state.layout.state.children.filter((child) => child !== element) });
|
||||
@@ -126,6 +130,7 @@ export class ResponsiveGridLayoutManager
|
||||
activateRepeaters?(): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) => {
|
||||
return <model.state.layout.Component model={model.state.layout} />;
|
||||
};
|
||||
|
||||
@@ -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<RowItemState> 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<RowItemState> 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: () => <RowRepeatSelect row={row} dashboard={dashboard} />,
|
||||
})
|
||||
);
|
||||
}, [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<RowItemState> implements LayoutPare
|
||||
layout.removeRow(this);
|
||||
};
|
||||
|
||||
public renderActions(): React.ReactNode {
|
||||
public renderActions(): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
@@ -91,12 +133,14 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
|
||||
};
|
||||
|
||||
public onAddPanel = (vizPanel = getDefaultVizPanel()) => {
|
||||
this.state.layout.addPanel(vizPanel);
|
||||
this.getLayout().addPanel(vizPanel);
|
||||
};
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<RowItem>) => {
|
||||
const { layout, title, isCollapsed, height = 'expand', isHeaderHidden, key } = model.useState();
|
||||
const { isEditing, showHiddenElements } = getDashboardSceneFor(model).useState();
|
||||
const isClone = useMemo(() => isClonedKey(key!), [key]);
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const { isEditing, showHiddenElements } = dashboard.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -109,7 +153,7 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
|
||||
styles.wrapper,
|
||||
isCollapsed && styles.wrapperCollapsed,
|
||||
shouldGrow && styles.wrapperGrow,
|
||||
isSelected && 'dashboard-selected-element'
|
||||
!isClone && isSelected && 'dashboard-selected-element'
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
@@ -119,14 +163,14 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
|
||||
onClick={model.onCollapseToggle}
|
||||
className={styles.rowTitleButton}
|
||||
aria-label={isCollapsed ? 'Expand row' : 'Collapse row'}
|
||||
data-testid={selectors.components.DashboardRow.title(titleInterpolated)}
|
||||
data-testid={selectors.components.DashboardRow.title(titleInterpolated!)}
|
||||
>
|
||||
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
|
||||
<span className={styles.rowTitle} role="heading">
|
||||
{titleInterpolated}
|
||||
</span>
|
||||
</button>
|
||||
{isEditing && (
|
||||
{!isClone && isEditing && (
|
||||
<Button icon="pen" variant="secondary" size="sm" fill="text" onPointerDown={(evt) => onSelect?.(evt)} />
|
||||
)}
|
||||
</div>
|
||||
@@ -181,6 +225,7 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
minHeight: '100px',
|
||||
}),
|
||||
wrapperGrow: css({
|
||||
flexGrow: 1,
|
||||
@@ -188,6 +233,7 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
wrapperCollapsed: css({
|
||||
flexGrow: 0,
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
minHeight: 'unset',
|
||||
}),
|
||||
rowActions: css({
|
||||
display: 'flex',
|
||||
@@ -237,3 +283,68 @@ export function RowHeightSelect({ row }: { row: RowItem }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RowRepeatSelect({ row, dashboard }: { row: RowItem; dashboard: DashboardScene }) {
|
||||
const { layout, $behaviors } = row.useState();
|
||||
|
||||
let repeatBehavior: RowItemRepeaterBehavior | undefined = $behaviors?.find(
|
||||
(b) => b instanceof RowItemRepeaterBehavior
|
||||
);
|
||||
const { variableName } = repeatBehavior?.state ?? {};
|
||||
|
||||
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
|
||||
const runner = getQueryRunnerFor(vizPanel);
|
||||
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 (
|
||||
<>
|
||||
<RepeatRowSelect2
|
||||
sceneContext={dashboard}
|
||||
repeat={variableName}
|
||||
onChange={(repeat) => {
|
||||
if (repeat) {
|
||||
// Remove repeat behavior if it exists to trigger repeat when adding new one
|
||||
if (repeatBehavior) {
|
||||
repeatBehavior.removeBehavior();
|
||||
}
|
||||
|
||||
repeatBehavior = new RowItemRepeaterBehavior({ variableName: repeat });
|
||||
row.setState({ $behaviors: [...(row.state.$behaviors ?? []), repeatBehavior] });
|
||||
repeatBehavior.activate();
|
||||
} else {
|
||||
repeatBehavior?.removeBehavior();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isAnyPanelUsingDashboardDS ? (
|
||||
<Alert
|
||||
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
|
||||
severity="warning"
|
||||
title=""
|
||||
topSpacing={3}
|
||||
bottomSpacing={0}
|
||||
>
|
||||
<p>
|
||||
<Trans i18nKey="dashboard.rows-layout.row.repeat.warning">
|
||||
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.
|
||||
</Trans>
|
||||
</p>
|
||||
<TextLink
|
||||
external
|
||||
href={
|
||||
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="dashboard.rows-layout.row.repeat.learn-more">Learn more</Trans>
|
||||
</TextLink>
|
||||
</Alert>
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { VariableRefresh } from '@grafana/data';
|
||||
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
||||
import { setPluginImportUtils } from '@grafana/runtime';
|
||||
import {
|
||||
SceneGridRow,
|
||||
SceneTimeRange,
|
||||
SceneVariableSet,
|
||||
TestVariable,
|
||||
VariableValueOption,
|
||||
PanelBuilders,
|
||||
} from '@grafana/scenes';
|
||||
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
|
||||
import { TextMode } from 'app/plugins/panel/text/panelcfg.gen';
|
||||
|
||||
import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone';
|
||||
import { activateFullSceneTree } from '../../utils/test-utils';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
setPluginExtensionGetter: jest.fn(),
|
||||
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
||||
}));
|
||||
|
||||
setPluginImportUtils({
|
||||
importPanelPlugin: () => Promise.resolve(getPanelPlugin({})),
|
||||
getPanelPluginFromCache: () => undefined,
|
||||
});
|
||||
|
||||
describe('RowItemRepeaterBehavior', () => {
|
||||
describe('Given scene with variable with 5 values', () => {
|
||||
let scene: DashboardScene, layout: RowsLayoutManager, repeatBehavior: RowItemRepeaterBehavior;
|
||||
let layoutStateUpdates: unknown[];
|
||||
|
||||
beforeEach(async () => {
|
||||
({ scene, layout, repeatBehavior } = buildScene({ variableQueryTime: 0 }));
|
||||
|
||||
layoutStateUpdates = [];
|
||||
layout.subscribeToState((state) => layoutStateUpdates.push(state));
|
||||
|
||||
activateFullSceneTree(scene);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
});
|
||||
|
||||
it('Should repeat row', () => {
|
||||
// Verify that first row still has repeat behavior
|
||||
const row1 = layout.state.rows[0];
|
||||
expect(row1.state.key).toBe(getCloneKey('row-1', 0));
|
||||
expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior);
|
||||
expect(row1.state.$variables!.state.variables[0].getValue()).toBe('A1');
|
||||
|
||||
const row1Children = getRowChildren(row1);
|
||||
expect(row1Children[0].state.key!).toBe(joinCloneKeys(row1.state.key!, 'grid-item-0'));
|
||||
expect(row1Children[0].state.body?.state.key).toBe(joinCloneKeys(row1Children[0].state.key!, 'panel-0'));
|
||||
|
||||
const row2 = layout.state.rows[1];
|
||||
expect(row2.state.key).toBe(getCloneKey('row-1', 1));
|
||||
expect(row2.state.$behaviors).toEqual([]);
|
||||
expect(row2.state.$variables!.state.variables[0].getValueText?.()).toBe('B');
|
||||
|
||||
const row2Children = getRowChildren(row2);
|
||||
expect(row2Children[0].state.key!).toBe(joinCloneKeys(row2.state.key!, 'grid-item-0'));
|
||||
expect(row2Children[0].state.body?.state.key).toBe(joinCloneKeys(row2Children[0].state.key!, 'panel-0'));
|
||||
});
|
||||
|
||||
it('Repeated rows should be read only', () => {
|
||||
const row1 = layout.state.rows[0];
|
||||
expect(isInCloneChain(row1.state.key!)).toBe(false);
|
||||
|
||||
const row2 = layout.state.rows[1];
|
||||
expect(isInCloneChain(row2.state.key!)).toBe(true);
|
||||
});
|
||||
|
||||
it('Should push row at the bottom down', () => {
|
||||
// Should push row at the bottom down
|
||||
const rowAtTheBottom = layout.state.rows[5];
|
||||
expect(rowAtTheBottom.state.title).toBe('Row at the bottom');
|
||||
});
|
||||
|
||||
it('Should handle second repeat cycle and update remove old repeats', async () => {
|
||||
// trigger another repeat cycle by changing the variable
|
||||
const variable = scene.state.$variables!.state.variables[0] as TestVariable;
|
||||
variable.changeValueTo(['B1', 'C1']);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
// should now only have 2 repeated rows (and the panel above + the row at the bottom)
|
||||
expect(layout.state.rows.length).toBe(3);
|
||||
});
|
||||
|
||||
it('Should ignore repeat process if variable values are the same', async () => {
|
||||
// trigger another repeat cycle by changing the variable
|
||||
repeatBehavior.performRepeat();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
expect(layoutStateUpdates.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Given a scene with empty variable', () => {
|
||||
it('Should preserve repeat row', async () => {
|
||||
const { scene, layout } = buildScene({ variableQueryTime: 0 }, []);
|
||||
activateFullSceneTree(scene);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
// Should have 2 rows, one without repeat and one with the dummy row
|
||||
expect(layout.state.rows.length).toBe(2);
|
||||
expect(layout.state.rows[0].state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface SceneOptions {
|
||||
variableQueryTime: number;
|
||||
variableRefresh?: VariableRefresh;
|
||||
}
|
||||
|
||||
function buildTextPanel(key: string, content: string) {
|
||||
const panel = PanelBuilders.text().setOption('content', content).setOption('mode', TextMode.Markdown).build();
|
||||
panel.setState({ key });
|
||||
return panel;
|
||||
}
|
||||
|
||||
function buildScene(
|
||||
options: SceneOptions,
|
||||
variableOptions?: VariableValueOption[],
|
||||
variableStateOverrides?: { isMulti: boolean }
|
||||
) {
|
||||
const repeatBehavior = new RowItemRepeaterBehavior({ variableName: 'server' });
|
||||
|
||||
const rows = [
|
||||
new RowItem({
|
||||
key: 'row-1',
|
||||
$behaviors: [repeatBehavior],
|
||||
layout: DefaultGridLayoutManager.fromGridItems([
|
||||
new DashboardGridItem({
|
||||
key: 'griditem-1',
|
||||
x: 0,
|
||||
y: 11,
|
||||
width: 24,
|
||||
height: 5,
|
||||
body: buildTextPanel('text-1', 'Panel inside repeated row, server = $server'),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
new RowItem({
|
||||
key: 'row-2',
|
||||
title: 'Row at the bottom',
|
||||
layout: DefaultGridLayoutManager.fromGridItems([
|
||||
new DashboardGridItem({
|
||||
key: 'griditem-2',
|
||||
x: 0,
|
||||
y: 17,
|
||||
body: buildTextPanel('text-2', 'Panel inside row, server = $server'),
|
||||
}),
|
||||
new DashboardGridItem({
|
||||
key: 'griditem-3',
|
||||
x: 0,
|
||||
y: 25,
|
||||
body: buildTextPanel('text-3', 'Panel inside row, server = $server'),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
];
|
||||
|
||||
const layout = new RowsLayoutManager({ rows });
|
||||
|
||||
const scene = new DashboardScene({
|
||||
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [
|
||||
new TestVariable({
|
||||
name: 'server',
|
||||
query: 'A.*',
|
||||
value: ALL_VARIABLE_VALUE,
|
||||
text: ALL_VARIABLE_TEXT,
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
delayMs: options.variableQueryTime,
|
||||
refresh: options.variableRefresh,
|
||||
optionsToReturn: variableOptions ?? [
|
||||
{ label: 'A', value: 'A1' },
|
||||
{ label: 'B', value: 'B1' },
|
||||
{ label: 'C', value: 'C1' },
|
||||
{ label: 'D', value: 'D1' },
|
||||
{ label: 'E', value: 'E1' },
|
||||
],
|
||||
...variableStateOverrides,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body: layout,
|
||||
});
|
||||
|
||||
const rowToRepeat = repeatBehavior.parent as SceneGridRow;
|
||||
|
||||
return { scene, layout, rows, repeatBehavior, rowToRepeat };
|
||||
}
|
||||
|
||||
function getRowLayout(row: RowItem): DefaultGridLayoutManager {
|
||||
const layout = row.getLayout();
|
||||
|
||||
if (!(layout instanceof DefaultGridLayoutManager)) {
|
||||
throw new Error('Invalid layout');
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
function getRowChildren(row: RowItem): DashboardGridItem[] {
|
||||
const layout = getRowLayout(row);
|
||||
|
||||
const filteredChildren = layout.state.grid.state.children.filter((child) => child instanceof DashboardGridItem);
|
||||
|
||||
if (filteredChildren.length !== layout.state.grid.state.children.length) {
|
||||
throw new Error('Invalid layout');
|
||||
}
|
||||
|
||||
return filteredChildren;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import {
|
||||
LocalValueVariable,
|
||||
MultiValueVariable,
|
||||
sceneGraph,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
SceneVariableSet,
|
||||
VariableDependencyConfig,
|
||||
VariableValueSingle,
|
||||
} from '@grafana/scenes';
|
||||
|
||||
import { isClonedKeyOf, getCloneKey } from '../../utils/clone';
|
||||
import { getMultiVariableValues } from '../../utils/utils';
|
||||
import { DashboardRepeatsProcessedEvent } from '../types';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
interface RowItemRepeaterBehaviorState extends SceneObjectState {
|
||||
variableName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This behavior will run an effect function when specified variables change
|
||||
*/
|
||||
|
||||
export class RowItemRepeaterBehavior extends SceneObjectBase<RowItemRepeaterBehaviorState> {
|
||||
protected _variableDependency = new VariableDependencyConfig(this, {
|
||||
variableNames: [this.state.variableName],
|
||||
onVariableUpdateCompleted: () => this.performRepeat(),
|
||||
});
|
||||
|
||||
private _prevRepeatValues?: VariableValueSingle[];
|
||||
private _clonedRows?: RowItem[];
|
||||
|
||||
public constructor(state: RowItemRepeaterBehaviorState) {
|
||||
super(state);
|
||||
|
||||
this.addActivationHandler(() => this._activationHandler());
|
||||
}
|
||||
|
||||
private _activationHandler() {
|
||||
this.performRepeat();
|
||||
}
|
||||
|
||||
private _getRow(): RowItem {
|
||||
if (!(this.parent instanceof RowItem)) {
|
||||
throw new Error('RepeatedRowItemBehavior: Parent is not a RowItem');
|
||||
}
|
||||
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
private _getLayout(): RowsLayoutManager {
|
||||
const layout = this._getRow().parent;
|
||||
|
||||
if (!(layout instanceof RowsLayoutManager)) {
|
||||
throw new Error('RepeatedRowItemBehavior: Layout is not a RowsLayoutManager');
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
public performRepeat(force = false) {
|
||||
if (this._variableDependency.hasDependencyInLoadingState()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variable = sceneGraph.lookupVariable(this.state.variableName, this.parent?.parent!);
|
||||
|
||||
if (!variable) {
|
||||
console.error('RepeatedRowItemBehavior: Variable not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(variable instanceof MultiValueVariable)) {
|
||||
console.error('RepeatedRowItemBehavior: Variable is not a MultiValueVariable');
|
||||
return;
|
||||
}
|
||||
|
||||
const rowToRepeat = this._getRow();
|
||||
const layout = this._getLayout();
|
||||
const { values, texts } = getMultiVariableValues(variable);
|
||||
|
||||
// Do nothing if values are the same
|
||||
if (isEqual(this._prevRepeatValues, values) && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._prevRepeatValues = values;
|
||||
|
||||
this._clonedRows = [];
|
||||
|
||||
const rowContent = rowToRepeat.getLayout();
|
||||
|
||||
// when variable has no options (due to error or similar) it will not render any panels at all
|
||||
// adding a placeholder in this case so that there is at least empty panel that can display error
|
||||
const emptyVariablePlaceholderOption = {
|
||||
values: [''],
|
||||
texts: variable.hasAllValue() ? ['All'] : ['None'],
|
||||
};
|
||||
|
||||
const variableValues = values.length ? values : emptyVariablePlaceholderOption.values;
|
||||
const variableTexts = texts.length ? texts : emptyVariablePlaceholderOption.texts;
|
||||
|
||||
// Loop through variable values and create repeats
|
||||
for (let rowIndex = 0; rowIndex < variableValues.length; rowIndex++) {
|
||||
const isSourceRow = rowIndex === 0;
|
||||
const rowClone = isSourceRow ? rowToRepeat : rowToRepeat.clone({ $behaviors: [] });
|
||||
|
||||
const rowCloneKey = getCloneKey(rowToRepeat.state.key!, rowIndex);
|
||||
|
||||
rowClone.setState({
|
||||
key: rowCloneKey,
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [
|
||||
new LocalValueVariable({
|
||||
name: this.state.variableName,
|
||||
value: variableValues[rowIndex],
|
||||
text: String(variableTexts[rowIndex]),
|
||||
isMulti: variable.state.isMulti,
|
||||
includeAll: variable.state.includeAll,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
layout: rowContent.cloneLayout?.(rowCloneKey, isSourceRow),
|
||||
});
|
||||
|
||||
this._clonedRows.push(rowClone);
|
||||
}
|
||||
|
||||
updateLayout(layout, this._clonedRows, rowToRepeat.state.key!);
|
||||
|
||||
// Used from dashboard url sync
|
||||
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
|
||||
}
|
||||
|
||||
public removeBehavior() {
|
||||
const row = this._getRow();
|
||||
const layout = this._getLayout();
|
||||
const rows = getRowsFilterOutRepeatClones(layout, row.state.key!);
|
||||
|
||||
layout.setState({ rows });
|
||||
|
||||
// Remove behavior and the scoped local variable
|
||||
row.setState({ $behaviors: row.state.$behaviors!.filter((b) => b !== this), $variables: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
function updateLayout(layout: RowsLayoutManager, rows: RowItem[], rowKey: string) {
|
||||
const allRows = getRowsFilterOutRepeatClones(layout, rowKey);
|
||||
const index = allRows.findIndex((row) => row.state.key!.includes(rowKey));
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('RowItemRepeaterBehavior: Row not found in layout');
|
||||
}
|
||||
|
||||
layout.setState({ rows: [...allRows.slice(0, index), ...rows, ...allRows.slice(index + 1)] });
|
||||
}
|
||||
|
||||
function getRowsFilterOutRepeatClones(layout: RowsLayoutManager, rowKey: string) {
|
||||
return layout.state.rows.filter((rows) => !isClonedKeyOf(rows.state.key!, rowKey));
|
||||
}
|
||||
@@ -12,13 +12,16 @@ import {
|
||||
} from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { isClonedKey } from '../../utils/clone';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
|
||||
import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior';
|
||||
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
|
||||
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
|
||||
|
||||
interface RowsLayoutManagerState extends SceneObjectState {
|
||||
rows: RowItem[];
|
||||
@@ -58,6 +61,10 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
});
|
||||
}
|
||||
|
||||
public getMaxPanelId(): number {
|
||||
return Math.max(...this.state.rows.map((row) => row.getLayout().getMaxPanelId()));
|
||||
}
|
||||
|
||||
public getNextPanelId(): number {
|
||||
return 0;
|
||||
}
|
||||
@@ -78,7 +85,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
const panels: VizPanel[] = [];
|
||||
|
||||
for (const row of this.state.rows) {
|
||||
const innerPanels = row.state.layout.getVizPanels();
|
||||
const innerPanels = row.getLayout().getVizPanels();
|
||||
panels.push(...innerPanels);
|
||||
}
|
||||
|
||||
@@ -89,6 +96,23 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
return [];
|
||||
}
|
||||
|
||||
public activateRepeaters() {
|
||||
this.state.rows.forEach((row) => {
|
||||
if (row.state.$behaviors) {
|
||||
for (const behavior of row.state.$behaviors) {
|
||||
if (behavior instanceof RowItemRepeaterBehavior && !row.isActive) {
|
||||
row.activate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!row.getLayout().isActive) {
|
||||
row.getLayout().activate();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getDescriptor(): LayoutRegistryItem {
|
||||
return RowsLayoutManager.getDescriptor();
|
||||
}
|
||||
@@ -111,11 +135,16 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
}
|
||||
|
||||
public static createFromLayout(layout: DashboardLayoutManager): RowsLayoutManager {
|
||||
let rows: RowItem[];
|
||||
|
||||
if (layout instanceof DefaultGridLayoutManager) {
|
||||
const config: Array<{
|
||||
title?: string;
|
||||
isCollapsed?: boolean;
|
||||
isDraggable?: boolean;
|
||||
isResizable?: boolean;
|
||||
children: SceneGridItemLike[];
|
||||
repeat?: string;
|
||||
}> = [];
|
||||
let children: SceneGridItemLike[] | undefined;
|
||||
|
||||
@@ -125,12 +154,19 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
}
|
||||
|
||||
if (child instanceof SceneGridRow) {
|
||||
if (!child.state.key?.includes('-clone-')) {
|
||||
if (!isClonedKey(child.state.key!)) {
|
||||
const behaviour = child.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior);
|
||||
|
||||
config.push({
|
||||
title: child.state.title,
|
||||
isCollapsed: !!child.state.isCollapsed,
|
||||
isDraggable: child.state.isDraggable ?? layout.state.grid.state.isDraggable,
|
||||
isResizable: child.state.isResizable ?? layout.state.grid.state.isResizable,
|
||||
children: child.state.children,
|
||||
repeat: behaviour?.state.variableName,
|
||||
});
|
||||
|
||||
// Since we encountered a row item, any subsequent panels should be added to a new row
|
||||
children = undefined;
|
||||
}
|
||||
} else {
|
||||
@@ -143,21 +179,24 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
}
|
||||
});
|
||||
|
||||
const rows = config.map(
|
||||
rows = config.map(
|
||||
(rowConfig) =>
|
||||
new RowItem({
|
||||
title: rowConfig.title ?? 'Row title',
|
||||
isCollapsed: !!rowConfig.isCollapsed,
|
||||
layout: DefaultGridLayoutManager.fromGridItems(rowConfig.children),
|
||||
layout: DefaultGridLayoutManager.fromGridItems(
|
||||
rowConfig.children,
|
||||
rowConfig.isDraggable,
|
||||
rowConfig.isResizable
|
||||
),
|
||||
$behaviors: rowConfig.repeat ? [new RowItemRepeaterBehavior({ variableName: rowConfig.repeat })] : [],
|
||||
})
|
||||
);
|
||||
|
||||
return new RowsLayoutManager({ rows });
|
||||
} else {
|
||||
rows = [new RowItem({ layout: layout.clone(), title: 'Row title' })];
|
||||
}
|
||||
|
||||
const row = new RowItem({ layout: layout.clone(), title: 'Row title' });
|
||||
|
||||
return new RowsLayoutManager({ rows: [row] });
|
||||
return new RowsLayoutManager({ rows });
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<RowsLayoutManager>) => {
|
||||
@@ -167,7 +206,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{rows.map((row) => (
|
||||
<RowItem.Component model={row} key={row.state.key!} />
|
||||
<row.Component model={row} key={row.state.key!} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,50 +10,72 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
|
||||
export interface DashboardLayoutManager extends SceneObject {
|
||||
/** Marks it as a DashboardLayoutManager */
|
||||
isDashboardLayoutManager: true;
|
||||
|
||||
/**
|
||||
* Notify the layout manager that the edit mode has changed
|
||||
* @param isEditing
|
||||
*/
|
||||
editModeChanged(isEditing: boolean): void;
|
||||
|
||||
/**
|
||||
* Remove an element / panel
|
||||
* @param element
|
||||
*/
|
||||
removePanel(panel: VizPanel): void;
|
||||
|
||||
/**
|
||||
* Creates a copy of an existing element and adds it to the layout
|
||||
* @param element
|
||||
*/
|
||||
duplicatePanel(panel: VizPanel): void;
|
||||
|
||||
/**
|
||||
* Adds a new panel to the layout
|
||||
*/
|
||||
addPanel(panel: VizPanel): void;
|
||||
|
||||
/**
|
||||
* Add row
|
||||
*/
|
||||
addNewRow(): void;
|
||||
|
||||
/**
|
||||
* getVizPanels
|
||||
*/
|
||||
getVizPanels(): VizPanel[];
|
||||
|
||||
/**
|
||||
* Turn into a save model
|
||||
* @param saveModel
|
||||
*/
|
||||
toSaveModel?(): any;
|
||||
|
||||
/**
|
||||
* For dynamic panels that need to be viewed in isolation (SoloRoute)
|
||||
*/
|
||||
activateRepeaters?(): void;
|
||||
|
||||
/**
|
||||
* Get's the layout descriptor (which has the name and id)
|
||||
* Gets the layout descriptor (which has the name and id)
|
||||
*/
|
||||
getDescriptor(): LayoutRegistryItem;
|
||||
|
||||
/**
|
||||
* Renders options and layout actions
|
||||
*/
|
||||
getOptions?(): OptionsPaneItemDescriptor[];
|
||||
|
||||
/**
|
||||
* Create a clone of the layout manager given an ancestor key
|
||||
* @param ancestorKey
|
||||
* @param isSource
|
||||
*/
|
||||
cloneLayout?(ancestorKey: string, isSource: boolean): DashboardLayoutManager;
|
||||
|
||||
/**
|
||||
* Returns the highest panel id in the layout
|
||||
*/
|
||||
getMaxPanelId(): number;
|
||||
}
|
||||
|
||||
export function isDashboardLayoutManager(obj: SceneObject): obj is DashboardLayoutManager {
|
||||
|
||||
+2
-2
@@ -81,11 +81,11 @@ import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
|
||||
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
|
||||
import { PanelNotices } from '../scene/PanelNotices';
|
||||
import { PanelTimeRange } from '../scene/PanelTimeRange';
|
||||
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
||||
import { AngularDeprecation } from '../scene/angular/AngularDeprecation';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowActions } from '../scene/row-actions/RowActions';
|
||||
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
|
||||
import { RowActions } from '../scene/layout-default/row-actions/RowActions';
|
||||
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
|
||||
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
|
||||
import { getDashboardSceneFor, getIntervalsFromQueryString, getVizPanelKeyForPanelId } from '../utils/utils';
|
||||
|
||||
@@ -30,9 +30,9 @@ import { DashboardDataDTO } from 'app/types';
|
||||
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { PanelTimeRange } from '../scene/PanelTimeRange';
|
||||
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
|
||||
import { NEW_LINK } from '../settings/links/utils';
|
||||
import { getQueryRunnerFor } from '../utils/utils';
|
||||
|
||||
|
||||
@@ -41,11 +41,11 @@ import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
|
||||
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
|
||||
import { PanelNotices } from '../scene/PanelNotices';
|
||||
import { PanelTimeRange } from '../scene/PanelTimeRange';
|
||||
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
||||
import { AngularDeprecation } from '../scene/angular/AngularDeprecation';
|
||||
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowActions } from '../scene/row-actions/RowActions';
|
||||
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
|
||||
import { RowActions } from '../scene/layout-default/row-actions/RowActions';
|
||||
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
|
||||
import { createPanelDataProvider } from '../utils/createPanelDataProvider';
|
||||
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
|
||||
|
||||
@@ -25,9 +25,9 @@ import { DashboardDataDTO } from 'app/types';
|
||||
|
||||
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
|
||||
import { NEW_LINK } from '../settings/links/utils';
|
||||
import { activateFullSceneTree, buildPanelRepeaterScene } from '../utils/test-utils';
|
||||
import { getVizPanelKeyForPanelId } from '../utils/utils';
|
||||
|
||||
@@ -33,9 +33,10 @@ import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
|
||||
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { PanelTimeRange } from '../scene/PanelTimeRange';
|
||||
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
|
||||
import { isClonedKey } from '../utils/clone';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import {
|
||||
calculateGridItemDimensions,
|
||||
@@ -72,7 +73,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
||||
|
||||
if (child instanceof SceneGridRow) {
|
||||
// Skip repeat clones or when generating a snapshot
|
||||
if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) {
|
||||
if (isClonedKey(child.state.key!) && !isSnapshot) {
|
||||
continue;
|
||||
}
|
||||
gridRowToSaveModel(child, panels, isSnapshot);
|
||||
|
||||
+1
-1
@@ -30,9 +30,9 @@ import { DashboardControls } from '../scene/DashboardControls';
|
||||
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
|
||||
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
|
||||
|
||||
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
|
||||
|
||||
|
||||
+3
-2
@@ -47,9 +47,10 @@ import {
|
||||
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
|
||||
import { PanelTimeRange } from '../scene/PanelTimeRange';
|
||||
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
|
||||
import { isClonedKey } from '../utils/clone';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import {
|
||||
getLibraryPanelBehavior,
|
||||
@@ -174,7 +175,7 @@ function getGridLayoutItems(
|
||||
elements.push(gridItemToGridLayoutItemKind(child, isSnapshot));
|
||||
}
|
||||
} else if (child instanceof SceneGridRow) {
|
||||
if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) {
|
||||
if (isClonedKey(child.state.key!) && !isSnapshot) {
|
||||
// Skip repeat rows
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('useSoloPanel', () => {
|
||||
|
||||
it('should return the cloned panel when panel is found', () => {
|
||||
const { dashboard } = setup();
|
||||
const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-1_clone'));
|
||||
const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-1-clone-1'));
|
||||
const panel = findVizPanelByKey(dashboard, 'panel-1');
|
||||
|
||||
expect(result.current[0]).not.toBe(panel);
|
||||
|
||||
@@ -4,7 +4,8 @@ import { VizPanel, UrlSyncManager } from '@grafana/scenes';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { DashboardRepeatsProcessedEvent } from '../scene/types';
|
||||
import { findVizPanelByKey, isPanelClone } from '../utils/utils';
|
||||
import { containsCloneKey } from '../utils/clone';
|
||||
import { findVizPanelByKey } from '../utils/utils';
|
||||
|
||||
export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPanel | undefined, string | undefined] {
|
||||
const [panel, setPanel] = useState<VizPanel>();
|
||||
@@ -26,7 +27,7 @@ export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPa
|
||||
if (panel) {
|
||||
activateParents(panel);
|
||||
setPanel(panel);
|
||||
} else if (isPanelClone(panelId)) {
|
||||
} else if (containsCloneKey(panelId)) {
|
||||
findRepeatClone(dashboard, panelId).then((panel) => {
|
||||
if (panel) {
|
||||
setPanel(panel);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
getCloneKey,
|
||||
getOriginalKey,
|
||||
isInCloneChain,
|
||||
isClonedKey,
|
||||
joinCloneKeys,
|
||||
containsCloneKey,
|
||||
getLastKeyFromClone,
|
||||
isClonedKeyOf,
|
||||
} from './clone';
|
||||
|
||||
describe('clone', () => {
|
||||
describe('getCloneKey', () => {
|
||||
it('should return the clone key', () => {
|
||||
expect(getCloneKey('panel', 1)).toBe('panel-clone-1');
|
||||
expect(getCloneKey('panel-clone-2', 1)).toBe('panel-clone-1');
|
||||
});
|
||||
|
||||
it('should not alter ancestors', () => {
|
||||
expect(getCloneKey('row-clone-1/panel', 2)).toBe('row-clone-1/panel-clone-2');
|
||||
expect(getCloneKey('tab-clone-0/row-clone-1/panel', 2)).toBe('tab-clone-0/row-clone-1/panel-clone-2');
|
||||
expect(getCloneKey('row-clone-1/panel-clone-3', 2)).toBe('row-clone-1/panel-clone-2');
|
||||
expect(getCloneKey('tab-clone-0/row-clone-1/panel-clone-3', 2)).toBe('tab-clone-0/row-clone-1/panel-clone-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOriginalKey', () => {
|
||||
it('should return the original key', () => {
|
||||
expect(getOriginalKey('panel')).toBe('panel');
|
||||
expect(getOriginalKey('panel-clone-1')).toBe('panel');
|
||||
expect(getOriginalKey('row-clone-1/panel-clone-2')).toBe('panel');
|
||||
expect(getOriginalKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe('panel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isClonedKey', () => {
|
||||
it('should return true for cloned keys', () => {
|
||||
expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe(true);
|
||||
expect(isClonedKey('row-clone-0/panel-clone-1')).toBe(true);
|
||||
expect(isClonedKey('panel-clone-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-cloned keys', () => {
|
||||
expect(isClonedKey('panel-clone-0')).toBe(false);
|
||||
expect(isClonedKey('tab-clone-1/row-clone-2/panel-clone-0')).toBe(false);
|
||||
expect(isClonedKey('row-clone-1/panel-clone-0')).toBe(false);
|
||||
expect(isClonedKey('panel')).toBe(false);
|
||||
expect(isClonedKey('tab-clone-1/row-clone-2/panel')).toBe(false);
|
||||
expect(isClonedKey('row-clone-1/panel')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isClonedKeyOf', () => {
|
||||
it('should return true for cloned keys', () => {
|
||||
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel-clone-2')).toBe(true);
|
||||
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel')).toBe(true);
|
||||
expect(isClonedKeyOf('panel-clone-2', 'panel-clone-2')).toBe(true);
|
||||
expect(isClonedKeyOf('panel-clone-2', 'panel')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-cloned keys', () => {
|
||||
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel2-clone-2')).toBe(false);
|
||||
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel2')).toBe(false);
|
||||
expect(isClonedKeyOf('panel-clone-2', 'panel2-clone-2')).toBe(false);
|
||||
expect(isClonedKeyOf('panel-clone-2', 'panel2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInCloneChain', () => {
|
||||
it('should return true for keys with cloned ancestors', () => {
|
||||
expect(isInCloneChain('tab-clone-1/row-clone-0/panel-clone-0')).toBe(true);
|
||||
expect(isInCloneChain('row-clone-0/row-clone-1/panel-clone-0')).toBe(true);
|
||||
expect(isInCloneChain('row-clone-0/row-clone-0/panel-clone-1')).toBe(true);
|
||||
expect(isInCloneChain('panel-clone-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for keys without cloned ancestors', () => {
|
||||
expect(isInCloneChain('panel-clone-0')).toBe(false);
|
||||
expect(isInCloneChain('row-clone-0/panel-clone-0')).toBe(false);
|
||||
expect(isInCloneChain('tab-clone-0/row-clone-0/panel-clone-0')).toBe(false);
|
||||
expect(isInCloneChain('panel')).toBe(false);
|
||||
expect(isInCloneChain('tab-clone-0/row-clone-0/panel')).toBe(false);
|
||||
expect(isInCloneChain('tab-clone-0/row/panel')).toBe(false);
|
||||
expect(isInCloneChain('tab-clone-0/row/panel-0')).toBe(false);
|
||||
expect(isInCloneChain('tab/row-clone-0/panel-0')).toBe(false);
|
||||
expect(isInCloneChain('row-clone-0/panel')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastKeyFromClone', () => {
|
||||
it('should return the last key', () => {
|
||||
expect(getLastKeyFromClone('tab-clone-1/row-clone-2/panel-clone-3')).toBe('panel-clone-3');
|
||||
expect(getLastKeyFromClone('row-clone-1/panel-clone-2')).toBe('panel-clone-2');
|
||||
expect(getLastKeyFromClone('row-clone-1/panel')).toBe('panel');
|
||||
expect(getLastKeyFromClone('panel')).toBe('panel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinCloneKeys', () => {
|
||||
it('should join keys with a separator', () => {
|
||||
expect(joinCloneKeys('row', 'panel-clone-1')).toBe('row/panel-clone-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsCloneKey', () => {
|
||||
it('should return true for keys with clone key', () => {
|
||||
expect(containsCloneKey('row-clone-0/panel-clone-1')).toBe(true);
|
||||
expect(containsCloneKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe(true);
|
||||
expect(containsCloneKey('panel-clone-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for keys without clone key', () => {
|
||||
expect(containsCloneKey('panel')).toBe(false);
|
||||
expect(containsCloneKey('tab-0/row-1/panel-2')).toBe(false);
|
||||
expect(containsCloneKey('row-1/panel-2')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
const CLONE_KEY = '-clone-';
|
||||
const CLONE_SEPARATOR = '/';
|
||||
|
||||
const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9]+$`);
|
||||
const ORIGINAL_REGEX = new RegExp(`${CLONE_KEY}\\d+$`);
|
||||
|
||||
/**
|
||||
* Create or alter the last key for a key
|
||||
* @param key
|
||||
* @param index
|
||||
*/
|
||||
export function getCloneKey(key: string, index: number): string {
|
||||
const parts = key.split(CLONE_SEPARATOR).slice(0, -1);
|
||||
const lastKey = getOriginalKey(getLastKeyFromClone(key));
|
||||
return [...parts, `${lastKey}${CLONE_KEY}${index}`].join(CLONE_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original key from a clone key
|
||||
* @param key
|
||||
*/
|
||||
export function getOriginalKey(key: string): string {
|
||||
return getLastKeyFromClone(key).replace(ORIGINAL_REGEX, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the last key is a clone key
|
||||
* @param key
|
||||
*/
|
||||
export function isClonedKey(key: string): boolean {
|
||||
return CLONED_KEY_REGEX.test(getLastKeyFromClone(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if key1 is a clone of key2
|
||||
* @param key1
|
||||
* @param key2
|
||||
*/
|
||||
export function isClonedKeyOf(key1: string, key2: string): boolean {
|
||||
return isClonedKey(key1) && getOriginalKey(key1) === getOriginalKey(key2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the key or any of its ancestors are cloned
|
||||
* @param key
|
||||
*/
|
||||
export function isInCloneChain(key: string): boolean {
|
||||
return key.split(CLONE_SEPARATOR).some(isClonedKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last key from a clone key
|
||||
* @param key
|
||||
*/
|
||||
export function getLastKeyFromClone(key: string): string {
|
||||
return key.split(CLONE_SEPARATOR).pop() ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Join clone keys
|
||||
* @param keys
|
||||
*/
|
||||
export function joinCloneKeys(...keys: string[]): string {
|
||||
return keys.filter(Boolean).join(CLONE_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a key contains the '-clone-' string
|
||||
* @param key
|
||||
*/
|
||||
export function containsCloneKey(key: string): boolean {
|
||||
return key.includes(CLONE_KEY);
|
||||
}
|
||||
@@ -17,9 +17,9 @@ import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/co
|
||||
import { DashboardDTO } from 'app/types';
|
||||
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
|
||||
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
||||
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
|
||||
|
||||
export function setupLoadDashboardMock(rsp: DeepPartial<DashboardDTO>, spy?: jest.Mock) {
|
||||
const loadDashboardMock = (spy || jest.fn()).mockResolvedValue(rsp);
|
||||
|
||||
@@ -21,6 +21,8 @@ import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types';
|
||||
|
||||
import { getLastKeyFromClone, getOriginalKey } from './clone';
|
||||
|
||||
export const NEW_PANEL_HEIGHT = 8;
|
||||
export const NEW_PANEL_WIDTH = 12;
|
||||
|
||||
@@ -29,7 +31,7 @@ export function getVizPanelKeyForPanelId(panelId: number) {
|
||||
}
|
||||
|
||||
export function getPanelIdForVizPanel(panel: SceneObject): number {
|
||||
return parseInt(panel.state.key!.replace('panel-', ''), 10);
|
||||
return parseInt(getOriginalKey(panel.state.key!).replace('panel-', ''), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +64,7 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP
|
||||
const panel = sceneGraph.findObject(scene, (obj) => {
|
||||
const objKey = obj.state.key!;
|
||||
|
||||
if (objKey === key) {
|
||||
if (objKey === key || getLastKeyFromClone(objKey) === getLastKeyFromClone(key) || getOriginalKey(objKey) === key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -212,30 +214,6 @@ export function getClosestVizPanel(sceneObject: SceneObject): VizPanel | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPanelClone(key: string) {
|
||||
return key.includes('clone');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursivly check the scene graph up until it finds a read only clone.
|
||||
* If the key contains clone-0 it is the reference object and can be edited
|
||||
*/
|
||||
export function isReadOnlyClone(sceneObject: SceneObject): boolean {
|
||||
const key = sceneObject.state.key!;
|
||||
|
||||
// Regular expression to match 'clone-' followed by a number, but not 'clone-0' as the is the reference object
|
||||
const pattern = /clone-(?!0)/;
|
||||
if (pattern.test(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sceneObject.parent) {
|
||||
return isReadOnlyClone(sceneObject.parent);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getDefaultVizPanel(): VizPanel {
|
||||
return new VizPanel({
|
||||
title: 'Panel Title',
|
||||
@@ -351,3 +329,7 @@ export function getLayoutManagerFor(sceneObject: SceneObject): DashboardLayoutMa
|
||||
|
||||
throw new Error('Could not find layout manager for scene object');
|
||||
}
|
||||
|
||||
export function getGridItemKeyForPanelId(panelId: number): string {
|
||||
return `grid-item-${panelId}`;
|
||||
}
|
||||
@@ -709,7 +709,7 @@
|
||||
"title": "Clear value"
|
||||
},
|
||||
"custom-value": {
|
||||
"label": "Custom value: "
|
||||
"description": "Use custom value"
|
||||
},
|
||||
"options": {
|
||||
"no-found": "No options found."
|
||||
@@ -882,6 +882,36 @@
|
||||
"redirect-link": "List in Grafana Alerting",
|
||||
"subtitle": "Alert rules related to this dashboard"
|
||||
},
|
||||
"default-layout": {
|
||||
"row-actions": {
|
||||
"delete": "Delete row",
|
||||
"modal": {
|
||||
"alt-action": "Delete row only",
|
||||
"text": "Are you sure you want to remove this row and all its panels?",
|
||||
"title": "Delete row"
|
||||
},
|
||||
"repeat": {
|
||||
"warning": {
|
||||
"learn-more": "Learn more",
|
||||
"text": "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."
|
||||
}
|
||||
}
|
||||
},
|
||||
"row-options": {
|
||||
"button": {
|
||||
"label": "Row options"
|
||||
},
|
||||
"form": {
|
||||
"cancel": "Cancel",
|
||||
"repeat-for": "Repeat for",
|
||||
"title": "Title",
|
||||
"update": "Update"
|
||||
},
|
||||
"modal": {
|
||||
"title": "Row options"
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"add-library-panel-body": "Add visualizations that are shared with other dashboards.",
|
||||
"add-library-panel-button": "Add library panel",
|
||||
@@ -954,6 +984,14 @@
|
||||
"no-rules": "There are no alert rules linked to this panel."
|
||||
}
|
||||
},
|
||||
"rows-layout": {
|
||||
"row": {
|
||||
"repeat": {
|
||||
"learn-more": "Learn more",
|
||||
"warning": "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."
|
||||
}
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
"add": "Add",
|
||||
"alert-rules": "Alert rules",
|
||||
|
||||
@@ -709,7 +709,7 @@
|
||||
"title": "Cľęäř väľūę"
|
||||
},
|
||||
"custom-value": {
|
||||
"label": "Cūşŧőm väľūę: "
|
||||
"description": "Ůşę čūşŧőm väľūę"
|
||||
},
|
||||
"options": {
|
||||
"no-found": "Ńő őpŧįőʼnş ƒőūʼnđ."
|
||||
@@ -882,6 +882,36 @@
|
||||
"redirect-link": "Ŀįşŧ įʼn Ğřäƒäʼnä Åľęřŧįʼnģ",
|
||||
"subtitle": "Åľęřŧ řūľęş řęľäŧęđ ŧő ŧĥįş đäşĥþőäřđ"
|
||||
},
|
||||
"default-layout": {
|
||||
"row-actions": {
|
||||
"delete": "Đęľęŧę řőŵ",
|
||||
"modal": {
|
||||
"alt-action": "Đęľęŧę řőŵ őʼnľy",
|
||||
"text": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő řęmővę ŧĥįş řőŵ äʼnđ äľľ įŧş päʼnęľş?",
|
||||
"title": "Đęľęŧę řőŵ"
|
||||
},
|
||||
"repeat": {
|
||||
"warning": {
|
||||
"learn-more": "Ŀęäřʼn mőřę",
|
||||
"text": "Päʼnęľş įʼn ŧĥįş řőŵ ūşę ŧĥę {{SHARED_DASHBOARD_QUERY}} đäŧä şőūřčę. Ŧĥęşę päʼnęľş ŵįľľ řęƒęřęʼnčę ŧĥę päʼnęľ įʼn ŧĥę őřįģįʼnäľ řőŵ, ʼnőŧ ŧĥę őʼnęş įʼn ŧĥę řępęäŧęđ řőŵş."
|
||||
}
|
||||
}
|
||||
},
|
||||
"row-options": {
|
||||
"button": {
|
||||
"label": "Ŗőŵ őpŧįőʼnş"
|
||||
},
|
||||
"form": {
|
||||
"cancel": "Cäʼnčęľ",
|
||||
"repeat-for": "Ŗępęäŧ ƒőř",
|
||||
"title": "Ŧįŧľę",
|
||||
"update": "Ůpđäŧę"
|
||||
},
|
||||
"modal": {
|
||||
"title": "Ŗőŵ őpŧįőʼnş"
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"add-library-panel-body": "Åđđ vįşūäľįžäŧįőʼnş ŧĥäŧ äřę şĥäřęđ ŵįŧĥ őŧĥęř đäşĥþőäřđş.",
|
||||
"add-library-panel-button": "Åđđ ľįþřäřy päʼnęľ",
|
||||
@@ -954,6 +984,14 @@
|
||||
"no-rules": "Ŧĥęřę äřę ʼnő äľęřŧ řūľęş ľįʼnĸęđ ŧő ŧĥįş päʼnęľ."
|
||||
}
|
||||
},
|
||||
"rows-layout": {
|
||||
"row": {
|
||||
"repeat": {
|
||||
"learn-more": "Ŀęäřʼn mőřę",
|
||||
"warning": "Päʼnęľş įʼn ŧĥįş řőŵ ūşę ŧĥę {{SHARED_DASHBOARD_QUERY}} đäŧä şőūřčę. Ŧĥęşę päʼnęľş ŵįľľ řęƒęřęʼnčę ŧĥę päʼnęľ įʼn ŧĥę őřįģįʼnäľ řőŵ, ʼnőŧ ŧĥę őʼnęş įʼn ŧĥę řępęäŧęđ řőŵş."
|
||||
}
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
"add": "Åđđ",
|
||||
"alert-rules": "Åľęřŧ řūľęş",
|
||||
|
||||
@@ -18,8 +18,8 @@ function processOpenAPISpec(spec: OpenAPIV3.Document) {
|
||||
// Process 'paths' property
|
||||
const newPaths: Record<string, unknown> = {};
|
||||
for (const [path, pathItem] of Object.entries<OpenAPIV3.PathItemObject>(newSpec.paths)) {
|
||||
// Remove 'watch' paths as they're deprecated / remove empty path items
|
||||
if (path.includes('/watch/') || !pathItem) {
|
||||
// Remove empty path items
|
||||
if (!pathItem) {
|
||||
continue;
|
||||
}
|
||||
// Remove the specified part from the path key
|
||||
@@ -27,12 +27,13 @@ function processOpenAPISpec(spec: OpenAPIV3.Document) {
|
||||
|
||||
// Process each method in the path (e.g., get, post)
|
||||
const newPathItem: Record<string, unknown> = {};
|
||||
for (const method of Object.keys(pathItem)) {
|
||||
// Filter out the 'namespace' param
|
||||
if (method === 'parameters' && Array.isArray(pathItem.parameters)) {
|
||||
pathItem.parameters = pathItem.parameters?.filter((param) => 'name' in param && param.name !== 'namespace');
|
||||
}
|
||||
|
||||
// Filter out namespace parameter at path level
|
||||
if (Array.isArray(pathItem.parameters)) {
|
||||
pathItem.parameters = filterNamespaceParameters(pathItem.parameters);
|
||||
}
|
||||
|
||||
for (const method of Object.keys(pathItem)) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const operation = pathItem[method as keyof OpenAPIV3.PathItemObject];
|
||||
|
||||
@@ -45,6 +46,16 @@ function processOpenAPISpec(spec: OpenAPIV3.Document) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out namespace parameter at operation level
|
||||
if (
|
||||
operation &&
|
||||
typeof operation === 'object' &&
|
||||
'parameters' in operation &&
|
||||
Array.isArray(operation.parameters)
|
||||
) {
|
||||
operation.parameters = filterNamespaceParameters(operation.parameters);
|
||||
}
|
||||
|
||||
updateRefs(operation);
|
||||
|
||||
newPathItem[method] = operation;
|
||||
@@ -69,6 +80,13 @@ function processOpenAPISpec(spec: OpenAPIV3.Document) {
|
||||
return newSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out namespace parameters from an array of parameters
|
||||
*/
|
||||
function filterNamespaceParameters(parameters: Array<OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject>) {
|
||||
return parameters.filter((param) => 'name' in param && param.name !== 'namespace');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively update all $ref fields to remove k8s metadata from names
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user