diff --git a/.betterer.results b/.betterer.results
index 6ad6560471f..35c44194c85 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -3100,6 +3100,9 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
+ "public/app/features/dashboard/api/dashboard_api.ts:5381": [
+ [0, 0, 0, "Do not use any type assertions.", "0"]
+ ],
"public/app/features/dashboard/components/AddLibraryPanelWidget/index.ts:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./AddLibraryPanelWidget\`)", "0"]
],
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 0e8ccfc1f9b..5f89bdaeb41 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -591,6 +591,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
/public/app/plugins/datasource/alertmanager/ @grafana/alerting-squad
# Grafana Sharing Squad
+/public/app/features/dashboard-scene/sharing/ @grafana/sharing-squad
/public/app/features/dashboard/components/ShareModal/ @grafana/sharing-squad
/public/app/features/manage-dashboards/components/PublicDashboardListTable/ @grafana/sharing-squad
/public/app/features/dashboard/containers/PublicDashboardPage.tsx @grafana/sharing-squad
diff --git a/e2e/scenes/various-suite/keybinds.spec.ts b/e2e/scenes/various-suite/keybinds.spec.ts
index 88e256b5576..624731dbec4 100644
--- a/e2e/scenes/various-suite/keybinds.spec.ts
+++ b/e2e/scenes/various-suite/keybinds.spec.ts
@@ -41,7 +41,7 @@ describe.skip('Keyboard shortcuts', () => {
e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
});
- it('multiple time range shortcuts should work', () => {
+ it('time range shortcuts should work', () => {
cy.get('body').type('ge');
e2e.pages.Explore.General.container().should('be.visible');
@@ -61,28 +61,10 @@ describe.skip('Keyboard shortcuts', () => {
expectedRange = `Time range selected: 2024-06-05 10:04:00 to 2024-06-05 10:05:00`; // 1 min back
e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
- cy.log('Trying two shift-lefts');
- cy.get('body').type('t{leftarrow}');
- e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
- expectedRange = `Time range selected: 2024-06-05 10:03:00 to 2024-06-05 10:04:00`; // 1 min back
- e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
- cy.get('body').type('t{leftarrow}');
- e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
- expectedRange = `Time range selected: 2024-06-05 10:02:00 to 2024-06-05 10:03:00`; // 2 mins back
- e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
-
- cy.log('Trying two shift-lefts and a shift-right');
- cy.get('body').type('t{leftarrow}');
- e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
- expectedRange = `Time range selected: 2024-06-05 10:01:00 to 2024-06-05 10:02:00`; // 1 min back
- e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
- cy.get('body').type('t{leftarrow}');
- e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
- expectedRange = `Time range selected: 2024-06-05 10:00:00 to 2024-06-05 10:01:00`; // 2 mins back
- e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
+ cy.log('Trying one shift-right');
cy.get('body').type('t{rightarrow}');
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
- expectedRange = `Time range selected: 2024-06-05 10:01:00 to 2024-06-05 10:02:00`; // 1 min forward (1 min back total)
+ expectedRange = `Time range selected: 2024-06-05 10:05:00 to 2024-06-05 10:06:00`; // 1 min forward
e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
});
});
diff --git a/e2e/various-suite/keybinds.spec.ts b/e2e/various-suite/keybinds.spec.ts
index 37f3c8da9c0..3f51fe04037 100644
--- a/e2e/various-suite/keybinds.spec.ts
+++ b/e2e/various-suite/keybinds.spec.ts
@@ -42,7 +42,7 @@ describe('Keyboard shortcuts', () => {
e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
});
- it('multiple time range shortcuts should work', () => {
+ it('time range shortcuts should work', () => {
cy.get('body').type('ge');
e2e.pages.Explore.General.container().should('be.visible');
@@ -62,28 +62,10 @@ describe('Keyboard shortcuts', () => {
expectedRange = `Time range selected: 2024-06-05 10:04:00 to 2024-06-05 10:05:00`; // 1 min back
e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
- cy.log('Trying two shift-lefts');
- cy.get('body').type('t{leftarrow}');
- e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
- expectedRange = `Time range selected: 2024-06-05 10:03:00 to 2024-06-05 10:04:00`; // 1 min back
- e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
- cy.get('body').type('t{leftarrow}');
- e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
- expectedRange = `Time range selected: 2024-06-05 10:02:00 to 2024-06-05 10:03:00`; // 2 mins back
- e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
-
- cy.log('Trying two shift-lefts and a shift-right');
- cy.get('body').type('t{leftarrow}');
- e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
- expectedRange = `Time range selected: 2024-06-05 10:01:00 to 2024-06-05 10:02:00`; // 1 min back
- e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
- cy.get('body').type('t{leftarrow}');
- e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
- expectedRange = `Time range selected: 2024-06-05 10:00:00 to 2024-06-05 10:01:00`; // 2 mins back
- e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
+ cy.log('Trying one shift-right');
cy.get('body').type('t{rightarrow}');
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
- expectedRange = `Time range selected: 2024-06-05 10:01:00 to 2024-06-05 10:02:00`; // 1 min forward (1 min back total)
+ expectedRange = `Time range selected: 2024-06-05 10:05:00 to 2024-06-05 10:06:00`; // 1 min forward
e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
});
});
diff --git a/package.json b/package.json
index d012e5d4771..452a65bccfd 100644
--- a/package.json
+++ b/package.json
@@ -260,7 +260,7 @@
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*",
- "@grafana/scenes": "^5.0.2",
+ "@grafana/scenes": "^5.3.0",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
diff --git a/packages/grafana-ui/.storybook/main.ts b/packages/grafana-ui/.storybook/main.ts
index 3fb1a5d1749..dfb53620b97 100644
--- a/packages/grafana-ui/.storybook/main.ts
+++ b/packages/grafana-ui/.storybook/main.ts
@@ -96,10 +96,19 @@ const mainConfig: StorybookConfig = {
tsconfigPath: path.resolve(__dirname, 'tsconfig.json'),
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
- propFilter: (prop: any) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
+ propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
savePropValueAsString: true,
},
},
+ swc: () => ({
+ jsc: {
+ transform: {
+ react: {
+ runtime: 'automatic',
+ },
+ },
+ },
+ }),
webpackFinal: async (config) => {
// expose jquery as a global so jquery plugins don't break at runtime.
config.module?.rules?.push({
diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx
index f3b17f2511f..9d5defacf03 100644
--- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx
+++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx
@@ -54,6 +54,12 @@ export interface Props {
onClose: () => void;
}
+const drawerSizes = {
+ sm: { width: '25vw', minWidth: 384 },
+ md: { width: '50vw', minWidth: 568 },
+ lg: { width: '75vw', minWidth: 744 },
+};
+
export function Drawer({
children,
onClose,
@@ -68,7 +74,7 @@ export function Drawer({
const [drawerWidth, onMouseDown, onTouchStart] = useResizebleDrawer();
const styles = useStyles2(getStyles);
- const sizeStyles = useStyles2(getSizeStyles, size, drawerWidth ?? width);
+ const wrapperStyles = useStyles2(getWrapperStyles, size);
const dragStyles = useStyles2(getDragStyles);
const overlayRef = React.useRef(null);
@@ -85,8 +91,9 @@ export function Drawer({
// Adds body class while open so the toolbar nav can hide some actions while drawer is open
useBodyClassWhileOpen();
- const rootClass = cx(styles.drawer, sizeStyles);
const content =
{children}
;
+ const overrideWidth = drawerWidth ?? width ?? drawerSizes[size].width;
+ const minWidth = drawerSizes[size].minWidth;
return (
{
};
};
-const drawerSizes = {
- sm: { width: '25vw', minWidth: 384 },
- md: { width: '50vw', minWidth: 568 },
- lg: { width: '75vw', minWidth: 744 },
-};
-
-function getSizeStyles(theme: GrafanaTheme2, size: 'sm' | 'md' | 'lg', overrideWidth: number | string | undefined) {
- let width = overrideWidth ?? drawerSizes[size].width;
- let minWidth = drawerSizes[size].minWidth;
-
+function getWrapperStyles(theme: GrafanaTheme2, size: 'sm' | 'md' | 'lg') {
return css({
- '.rc-drawer-content-wrapper': {
- label: `drawer-content-wrapper-${size}`,
- width: width,
- minWidth: minWidth,
- overflow: 'unset',
+ label: `drawer-content-wrapper-${size}`,
+ overflow: 'unset !important',
- [theme.breakpoints.down('md')]: {
- width: `calc(100% - ${theme.spacing(2)}) !important`,
- minWidth: 0,
- },
+ [theme.breakpoints.down('md')]: {
+ width: `calc(100% - ${theme.spacing(2)}) !important`,
+ minWidth: 0,
},
});
}
diff --git a/pkg/apis/dashboard/v0alpha1/register.go b/pkg/apis/dashboard/v0alpha1/register.go
index 1bb1bf22351..2b8a97f1eb5 100644
--- a/pkg/apis/dashboard/v0alpha1/register.go
+++ b/pkg/apis/dashboard/v0alpha1/register.go
@@ -19,12 +19,6 @@ var DashboardResourceInfo = common.NewResourceInfo(GROUP, VERSION,
func() runtime.Object { return &DashboardList{} },
)
-var DashboardSummaryResourceInfo = common.NewResourceInfo(GROUP, VERSION,
- "summary", "summary", "DashboardSummary",
- func() runtime.Object { return &DashboardSummary{} },
- func() runtime.Object { return &DashboardSummaryList{} },
-)
-
var (
// SchemeGroupVersion is group version used to register these objects
SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION}
diff --git a/pkg/apis/dashboard/v0alpha1/types.go b/pkg/apis/dashboard/v0alpha1/types.go
index 3b531127031..b2fd3897317 100644
--- a/pkg/apis/dashboard/v0alpha1/types.go
+++ b/pkg/apis/dashboard/v0alpha1/types.go
@@ -27,29 +27,6 @@ type DashboardList struct {
Items []Dashboard `json:"items,omitempty"`
}
-// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
-type DashboardSummary struct {
- metav1.TypeMeta `json:",inline"`
- metav1.ObjectMeta `json:"metadata,omitempty"`
-
- // The dashboard body
- Spec DashboardSummarySpec `json:"spec,omitempty"`
-}
-
-type DashboardSummarySpec struct {
- Title string `json:"title"`
- Tags []string `json:"tags,omitempty"`
-}
-
-// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
-type DashboardSummaryList struct {
- metav1.TypeMeta `json:",inline"`
- // +optional
- metav1.ListMeta `json:"metadata,omitempty"`
-
- Items []DashboardSummary `json:"items,omitempty"`
-}
-
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type DashboardVersionList struct {
metav1.TypeMeta `json:",inline"`
diff --git a/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go
index acac319e53d..46e4700ab07 100644
--- a/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go
@@ -126,87 +126,6 @@ func (in *DashboardList) DeepCopyObject() runtime.Object {
return nil
}
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *DashboardSummary) DeepCopyInto(out *DashboardSummary) {
- *out = *in
- out.TypeMeta = in.TypeMeta
- in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
- in.Spec.DeepCopyInto(&out.Spec)
- return
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSummary.
-func (in *DashboardSummary) DeepCopy() *DashboardSummary {
- if in == nil {
- return nil
- }
- out := new(DashboardSummary)
- in.DeepCopyInto(out)
- return out
-}
-
-// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
-func (in *DashboardSummary) DeepCopyObject() runtime.Object {
- if c := in.DeepCopy(); c != nil {
- return c
- }
- return nil
-}
-
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *DashboardSummaryList) DeepCopyInto(out *DashboardSummaryList) {
- *out = *in
- out.TypeMeta = in.TypeMeta
- in.ListMeta.DeepCopyInto(&out.ListMeta)
- if in.Items != nil {
- in, out := &in.Items, &out.Items
- *out = make([]DashboardSummary, len(*in))
- for i := range *in {
- (*in)[i].DeepCopyInto(&(*out)[i])
- }
- }
- return
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSummaryList.
-func (in *DashboardSummaryList) DeepCopy() *DashboardSummaryList {
- if in == nil {
- return nil
- }
- out := new(DashboardSummaryList)
- in.DeepCopyInto(out)
- return out
-}
-
-// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
-func (in *DashboardSummaryList) DeepCopyObject() runtime.Object {
- if c := in.DeepCopy(); c != nil {
- return c
- }
- return nil
-}
-
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *DashboardSummarySpec) DeepCopyInto(out *DashboardSummarySpec) {
- *out = *in
- if in.Tags != nil {
- in, out := &in.Tags, &out.Tags
- *out = make([]string, len(*in))
- copy(*out, *in)
- }
- return
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSummarySpec.
-func (in *DashboardSummarySpec) DeepCopy() *DashboardSummarySpec {
- if in == nil {
- return nil
- }
- out := new(DashboardSummarySpec)
- in.DeepCopyInto(out)
- return out
-}
-
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardVersionInfo) DeepCopyInto(out *DashboardVersionInfo) {
*out = *in
diff --git a/pkg/apiserver/rest/dualwriter.go b/pkg/apiserver/rest/dualwriter.go
index f979c932d6b..09c1e1ed717 100644
--- a/pkg/apiserver/rest/dualwriter.go
+++ b/pkg/apiserver/rest/dualwriter.go
@@ -1,7 +1,9 @@
package rest
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
"fmt"
@@ -35,8 +37,6 @@ type Storage interface {
rest.CreaterUpdater
rest.GracefulDeleter
rest.CollectionDeleter
- // Compare asserts on the equality of objects returned from both stores (object storage and legacy storage)
- Compare(storageObj, legacyObj runtime.Object) bool
}
// LegacyStorage is a storage implementation that writes to the Grafana SQL database.
@@ -207,3 +207,25 @@ func SetDualWritingMode(
return NewDualWriter(currentMode, legacy, storage, reg), nil
}
+
+var defaultConverter = runtime.UnstructuredConverter(runtime.DefaultUnstructuredConverter)
+
+// Compare asserts on the equality of objects returned from both stores (object storage and legacy storage)
+func Compare(storageObj, legacyObj runtime.Object) bool {
+ return bytes.Equal(removeMeta(storageObj), removeMeta(legacyObj))
+}
+
+func removeMeta(obj runtime.Object) []byte {
+ cpy := obj.DeepCopyObject()
+ unstObj, err := defaultConverter.ToUnstructured(cpy)
+ if err != nil {
+ return nil
+ }
+ // we don't want to compare meta fields
+ delete(unstObj, "meta")
+ jsonObj, err := json.Marshal(cpy)
+ if err != nil {
+ return nil
+ }
+ return jsonObj
+}
diff --git a/pkg/apiserver/rest/dualwriter_mode1.go b/pkg/apiserver/rest/dualwriter_mode1.go
index 4fb6b40d1ba..75aa3a10c43 100644
--- a/pkg/apiserver/rest/dualwriter_mode1.go
+++ b/pkg/apiserver/rest/dualwriter_mode1.go
@@ -243,7 +243,3 @@ func (d *DualWriterMode1) NewList() runtime.Object {
func (d *DualWriterMode1) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return d.Legacy.ConvertToTable(ctx, object, tableOptions)
}
-
-func (d *DualWriterMode1) Compare(storageObj, legacyObj runtime.Object) bool {
- return d.Storage.Compare(storageObj, legacyObj)
-}
diff --git a/pkg/apiserver/rest/dualwriter_mode1_test.go b/pkg/apiserver/rest/dualwriter_mode1_test.go
index 998d683bedf..a9efdf22268 100644
--- a/pkg/apiserver/rest/dualwriter_mode1_test.go
+++ b/pkg/apiserver/rest/dualwriter_mode1_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
+ "time"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
@@ -15,10 +16,10 @@ import (
"k8s.io/apiserver/pkg/apis/example"
)
-var exampleObj = &example.Pod{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "1"}, Spec: example.PodSpec{}, Status: example.PodStatus{}}
-var exampleObjNoRV = &example.Pod{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: ""}, Spec: example.PodSpec{}, Status: example.PodStatus{}}
+var exampleObj = &example.Pod{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "1", CreationTimestamp: metav1.Time{}}, Spec: example.PodSpec{}, Status: example.PodStatus{StartTime: &metav1.Time{Time: time.Now()}}}
+var exampleObjNoRV = &example.Pod{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "", CreationTimestamp: metav1.Time{}}, Spec: example.PodSpec{}, Status: example.PodStatus{StartTime: &metav1.Time{Time: time.Now()}}}
var exampleObjDifferentRV = &example.Pod{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "3"}, Spec: example.PodSpec{}, Status: example.PodStatus{}}
-var anotherObj = &example.Pod{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "bar", ResourceVersion: "2"}, Spec: example.PodSpec{}, Status: example.PodStatus{}}
+var anotherObj = &example.Pod{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "bar", ResourceVersion: "2"}, Spec: example.PodSpec{}, Status: example.PodStatus{StartTime: &metav1.Time{Time: time.Now()}}}
var failingObj = &example.Pod{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "object-fail", ResourceVersion: "2"}, Spec: example.PodSpec{}, Status: example.PodStatus{}}
var exampleList = &example.PodList{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ListMeta: metav1.ListMeta{}, Items: []example.Pod{*exampleObj}}
var anotherList = &example.PodList{Items: []example.Pod{*anotherObj}}
diff --git a/pkg/apiserver/rest/dualwriter_mode2.go b/pkg/apiserver/rest/dualwriter_mode2.go
index ff269800563..954dd1d7668 100644
--- a/pkg/apiserver/rest/dualwriter_mode2.go
+++ b/pkg/apiserver/rest/dualwriter_mode2.go
@@ -306,10 +306,6 @@ func (d *DualWriterMode2) ConvertToTable(ctx context.Context, object runtime.Obj
return d.Storage.ConvertToTable(ctx, object, tableOptions)
}
-func (d *DualWriterMode2) Compare(storageObj, legacyObj runtime.Object) bool {
- return d.Storage.Compare(storageObj, legacyObj)
-}
-
func parseList(legacyList []runtime.Object) (metainternalversion.ListOptions, map[string]int, error) {
options := metainternalversion.ListOptions{}
originKeys := []string{}
diff --git a/pkg/apiserver/rest/dualwriter_mode3.go b/pkg/apiserver/rest/dualwriter_mode3.go
index f24d58627ae..12bf63865a1 100644
--- a/pkg/apiserver/rest/dualwriter_mode3.go
+++ b/pkg/apiserver/rest/dualwriter_mode3.go
@@ -155,7 +155,3 @@ func (d *DualWriterMode3) NewList() runtime.Object {
func (d *DualWriterMode3) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return d.Storage.ConvertToTable(ctx, object, tableOptions)
}
-
-func (d *DualWriterMode3) Compare(storageObj, legacyObj runtime.Object) bool {
- return d.Storage.Compare(storageObj, legacyObj)
-}
diff --git a/pkg/apiserver/rest/dualwriter_mode4.go b/pkg/apiserver/rest/dualwriter_mode4.go
index 7985e69332e..34c8a09af56 100644
--- a/pkg/apiserver/rest/dualwriter_mode4.go
+++ b/pkg/apiserver/rest/dualwriter_mode4.go
@@ -83,7 +83,3 @@ func (d *DualWriterMode4) NewList() runtime.Object {
func (d *DualWriterMode4) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return d.Storage.ConvertToTable(ctx, object, tableOptions)
}
-
-func (d *DualWriterMode4) Compare(storageObj, legacyObj runtime.Object) bool {
- return d.Storage.Compare(storageObj, legacyObj)
-}
diff --git a/pkg/apiserver/rest/dualwriter_test.go b/pkg/apiserver/rest/dualwriter_test.go
index 6c9a8da9619..486e11a5160 100644
--- a/pkg/apiserver/rest/dualwriter_test.go
+++ b/pkg/apiserver/rest/dualwriter_test.go
@@ -10,6 +10,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
+ "k8s.io/apimachinery/pkg/runtime"
)
func TestSetDualWritingMode(t *testing.T) {
@@ -58,3 +59,26 @@ func TestSetDualWritingMode(t *testing.T) {
assert.Equal(t, val, fmt.Sprint(tt.expectedMode))
}
}
+
+func TestCompare(t *testing.T) {
+ testCase := []struct {
+ name string
+ input runtime.Object
+ expected bool
+ }{
+ {
+ name: "should return true when both objects are the same",
+ input: exampleObj,
+ expected: true,
+ },
+ {
+ name: "should return false when objects are different",
+ input: anotherObj,
+ },
+ }
+ for _, tt := range testCase {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expected, Compare(tt.input, exampleObj))
+ })
+ }
+}
diff --git a/pkg/registry/apis/dashboard/access/sql_dashboards.go b/pkg/registry/apis/dashboard/access/sql_dashboards.go
index f043e84b9fb..86bbbfe1239 100644
--- a/pkg/registry/apis/dashboard/access/sql_dashboards.go
+++ b/pkg/registry/apis/dashboard/access/sql_dashboards.go
@@ -189,58 +189,6 @@ func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid
return nil, fmt.Errorf("not found")
}
-// GetDashboards implements DashboardAccess.
-func (a *dashboardSqlAccess) GetDashboardSummaries(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardSummaryList, error) {
- rows, limit, err := a.getRows(ctx, query, true)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
-
- totalSize := 0
- list := &dashboardsV0.DashboardSummaryList{}
- for {
- row, err := rows.Next()
- if err != nil || row == nil {
- return list, err
- }
-
- totalSize += row.Bytes
- if len(list.Items) > 0 && (totalSize > query.MaxBytes || len(list.Items) >= limit) {
- if query.Requirements.Folder != nil {
- row.token.folder = *query.Requirements.Folder
- }
- list.Continue = row.token.String() // will skip this one but start here next time
- return list, err
- }
- list.Items = append(list.Items, toSummary(row))
- }
-}
-
-func (a *dashboardSqlAccess) GetDashboardSummary(ctx context.Context, orgId int64, uid string) (*dashboardsV0.DashboardSummary, error) {
- r, err := a.GetDashboardSummaries(ctx, &DashboardQuery{
- OrgID: orgId,
- UID: uid,
- })
- if err != nil {
- return nil, err
- }
- if len(r.Items) > 0 {
- return &r.Items[0], nil
- }
- return nil, fmt.Errorf("not found")
-}
-
-func toSummary(row *dashboardRow) dashboardsV0.DashboardSummary {
- return dashboardsV0.DashboardSummary{
- ObjectMeta: row.Dash.ObjectMeta,
- Spec: dashboardsV0.DashboardSummarySpec{
- Title: row.Title,
- Tags: row.Tags,
- },
- }
-}
-
func (a *dashboardSqlAccess) doQuery(ctx context.Context, query string, args ...any) (*rowsWrapper, error) {
user, err := appcontext.User(ctx)
if err != nil {
diff --git a/pkg/registry/apis/dashboard/access/types.go b/pkg/registry/apis/dashboard/access/types.go
index cf5df48e19f..50065c8f2a2 100644
--- a/pkg/registry/apis/dashboard/access/types.go
+++ b/pkg/registry/apis/dashboard/access/types.go
@@ -30,9 +30,6 @@ type DashboardAccess interface {
GetDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, error)
GetDashboards(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardList, error)
- GetDashboardSummary(ctx context.Context, orgId int64, uid string) (*dashboardsV0.DashboardSummary, error)
- GetDashboardSummaries(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardSummaryList, error)
-
SaveDashboard(ctx context.Context, orgId int64, dash *dashboardsV0.Dashboard) (*dashboardsV0.Dashboard, bool, error)
DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, bool, error)
}
diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go
index 25617a82a56..df874d4ea38 100644
--- a/pkg/registry/apis/dashboard/register.go
+++ b/pkg/registry/apis/dashboard/register.go
@@ -89,8 +89,6 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
&v0alpha1.DashboardList{},
&v0alpha1.DashboardWithAccessInfo{},
&v0alpha1.DashboardVersionList{},
- &v0alpha1.DashboardSummary{},
- &v0alpha1.DashboardSummaryList{},
&v0alpha1.VersionsQueryOptions{},
)
}
@@ -159,14 +157,6 @@ func (b *DashboardsAPIBuilder) GetAPIGroupInfo(
reg)
}
- // Summary
- resourceInfo2 := v0alpha1.DashboardSummaryResourceInfo
- storage[resourceInfo2.StoragePath()] = &summaryStorage{
- resource: resourceInfo2,
- access: b.access,
- tableConverter: store.TableConvertor,
- }
-
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
return &apiGroupInfo, nil
}
@@ -185,7 +175,6 @@ func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op
// Hide the ability to list or watch across all tenants
delete(oas.Paths.Paths, root+v0alpha1.DashboardResourceInfo.GroupResource().Resource)
delete(oas.Paths.Paths, root+"watch/"+v0alpha1.DashboardResourceInfo.GroupResource().Resource)
- delete(oas.Paths.Paths, root+v0alpha1.DashboardSummaryResourceInfo.GroupResource().Resource)
// The root API discovery list
sub := oas.Paths.Paths[root]
diff --git a/pkg/registry/apis/dashboard/storage.go b/pkg/registry/apis/dashboard/storage.go
index c426dca409a..ace79bc02e3 100644
--- a/pkg/registry/apis/dashboard/storage.go
+++ b/pkg/registry/apis/dashboard/storage.go
@@ -54,21 +54,7 @@ func newStorage(scheme *runtime.Scheme) (*storage, error) {
}, nil
}
}
- summary, ok := obj.(*v0alpha1.DashboardSummary)
- if ok {
- return []interface{}{
- dash.Name,
- summary.Spec.Title,
- dash.CreationTimestamp.UTC().Format(time.RFC3339),
- }, nil
- }
return nil, fmt.Errorf("expected dashboard or summary")
})
return &storage{Store: store}, nil
}
-
-// Compare asserts on the equality of objects returned from both stores (object storage and legacy storage)
-func (s *storage) Compare(storageObj, legacyObj runtime.Object) bool {
- //TODO: define the comparison logic between a dashboard returned by the storage and a dashboard returned by the legacy storage
- return false
-}
diff --git a/pkg/registry/apis/dashboard/summary_storage.go b/pkg/registry/apis/dashboard/summary_storage.go
deleted file mode 100644
index 85fe3bff0ff..00000000000
--- a/pkg/registry/apis/dashboard/summary_storage.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package dashboard
-
-import (
- "context"
-
- "k8s.io/apimachinery/pkg/apis/meta/internalversion"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/runtime"
- "k8s.io/apiserver/pkg/registry/rest"
-
- common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
- "github.com/grafana/grafana/pkg/registry/apis/dashboard/access"
- "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
- "github.com/grafana/grafana/pkg/services/apiserver/storage/entity"
-)
-
-var (
- _ rest.Storage = (*summaryStorage)(nil)
- _ rest.Scoper = (*summaryStorage)(nil)
- _ rest.SingularNameProvider = (*summaryStorage)(nil)
- _ rest.Getter = (*summaryStorage)(nil)
- _ rest.Lister = (*summaryStorage)(nil)
-)
-
-type summaryStorage struct {
- resource common.ResourceInfo
- access access.DashboardAccess
- tableConverter rest.TableConvertor
-}
-
-func (s *summaryStorage) New() runtime.Object {
- return s.resource.NewFunc()
-}
-
-func (s *summaryStorage) Destroy() {}
-
-func (s *summaryStorage) NamespaceScoped() bool {
- return true
-}
-
-func (s *summaryStorage) GetSingularName() string {
- return s.resource.GetSingularName()
-}
-
-func (s *summaryStorage) NewList() runtime.Object {
- return s.resource.NewListFunc()
-}
-
-func (s *summaryStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
- return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
-}
-
-func (s *summaryStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
- orgId, err := request.OrgIDForList(ctx)
- if err != nil {
- return nil, err
- }
-
- // translate grafana.app/* label selectors into field requirements
- requirements, newSelector, err := entity.ReadLabelSelectors(options.LabelSelector)
- if err != nil {
- return nil, err
- }
-
- query := &access.DashboardQuery{
- OrgID: orgId,
- Limit: int(options.Limit),
- MaxBytes: 2 * 1024 * 1024, // 2MB,
- ContinueToken: options.Continue,
- Requirements: requirements,
- Labels: newSelector,
- }
- return s.access.GetDashboardSummaries(ctx, query)
-}
-
-func (s *summaryStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
- info, err := request.NamespaceInfoFrom(ctx, true)
- if err != nil {
- return nil, err
- }
-
- return s.access.GetDashboardSummary(ctx, info.OrgID, name)
-}
diff --git a/pkg/registry/apis/folders/storage.go b/pkg/registry/apis/folders/storage.go
index 1e36d8f8d71..3d20ee20f89 100644
--- a/pkg/registry/apis/folders/storage.go
+++ b/pkg/registry/apis/folders/storage.go
@@ -40,9 +40,3 @@ func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, le
}
return &storage{Store: store}, nil
}
-
-// Compare asserts on the equality of objects returned from both stores (object storage and legacy storage)
-func (s *storage) Compare(storageObj, legacyObj runtime.Object) bool {
- //TODO: define the comparison logic between a folder returned by the storage and a folder returned by the legacy storage
- return false
-}
diff --git a/pkg/registry/apis/peakq/storage.go b/pkg/registry/apis/peakq/storage.go
index 849134b1f42..32df4308595 100644
--- a/pkg/registry/apis/peakq/storage.go
+++ b/pkg/registry/apis/peakq/storage.go
@@ -62,9 +62,3 @@ func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*
}
return &storage{Store: store}, nil
}
-
-// Compare asserts on the equality of objects returned from both stores (object storage and legacy storage)
-func (s *storage) Compare(storageObj, legacyObj runtime.Object) bool {
- //TODO: define the comparison logic between a query template returned by the storage and a query template returned by the legacy storage
- return false
-}
diff --git a/pkg/registry/apis/playlist/storage.go b/pkg/registry/apis/playlist/storage.go
index 230cbfef6ca..ead063391b7 100644
--- a/pkg/registry/apis/playlist/storage.go
+++ b/pkg/registry/apis/playlist/storage.go
@@ -1,7 +1,6 @@
package playlist
import (
- "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic"
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
@@ -41,17 +40,3 @@ func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, le
}
return &storage{Store: store}, nil
}
-
-// Compare asserts on the equality of objects returned from both stores (object storage and legacy storage)
-func (s *storage) Compare(storageObj, legacyObj runtime.Object) bool {
- accStr, err := meta.Accessor(storageObj)
- if err != nil {
- return false
- }
- accLegacy, err := meta.Accessor(legacyObj)
- if err != nil {
- return false
- }
-
- return accStr.GetName() == accLegacy.GetName()
-}
diff --git a/pkg/registry/apis/scope/storage.go b/pkg/registry/apis/scope/storage.go
index 8b1216b3af2..453eea59712 100644
--- a/pkg/registry/apis/scope/storage.go
+++ b/pkg/registry/apis/scope/storage.go
@@ -207,9 +207,3 @@ func SelectableScopeNodeFields(obj *scope.ScopeNode) fields.Set {
"spec.parentName": parentName,
})
}
-
-// Compare asserts on the equality of objects returned from both stores (object storage and legacy storage)
-func (s *storage) Compare(storageObj, legacyObj runtime.Object) bool {
- //TODO: define the comparison logic between a scope returned by the storage and a scope returned by the legacy storage
- return false
-}
diff --git a/pkg/registry/apis/service/storage.go b/pkg/registry/apis/service/storage.go
index 30a654d9001..86b1fbd3a2e 100644
--- a/pkg/registry/apis/service/storage.go
+++ b/pkg/registry/apis/service/storage.go
@@ -62,9 +62,3 @@ func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*
}
return &storage{Store: store}, nil
}
-
-// Compare asserts on the equality of objects returned from both stores (object storage and legacy storage)
-func (s *storage) Compare(storageObj, legacyObj runtime.Object) bool {
- //TODO: define the comparison logic between a generic object returned by the storage and a generic object returned by the legacy storage
- return false
-}
diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go
index d5c6343f780..7953772e5b1 100644
--- a/pkg/services/ngalert/api/api_prometheus.go
+++ b/pkg/services/ngalert/api/api_prometheus.go
@@ -235,9 +235,6 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon
return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse)
}
-// TODO: Refactor this function to reduce the cylomatic complexity
-//
-//nolint:gocyclo
func PrepareRuleGroupStatuses(log log.Logger, manager state.AlertInstanceManager, store ListAlertRulesStore, opts RuleGroupStatusesOptions) apimodels.RuleResponse {
ruleResponse := apimodels.RuleResponse{
DiscoveryBase: apimodels.DiscoveryBase{
@@ -330,28 +327,7 @@ func PrepareRuleGroupStatuses(log log.Logger, manager state.AlertInstanceManager
ruleNamesSet[rn] = struct{}{}
}
- // Group rules together by Namespace and Rule Group. Rules are also grouped by Org ID,
- // but in this API all rules belong to the same organization. Also filter by rule name if
- // it was provided as a query param.
- groupedRules := make(map[ngmodels.AlertRuleGroupKey][]*ngmodels.AlertRule)
- for _, rule := range ruleList {
- if len(ruleNamesSet) > 0 {
- if _, exists := ruleNamesSet[rule.Title]; !exists {
- continue
- }
- }
-
- groupKey := rule.GetGroupKey()
- ruleGroup := groupedRules[groupKey]
- ruleGroup = append(ruleGroup, rule)
- groupedRules[groupKey] = ruleGroup
- }
- // Sort the rules in each rule group by index. We do this at the end instead of
- // after each append to avoid having to sort each group multiple times.
- for _, groupRules := range groupedRules {
- ngmodels.AlertRulesBy(ngmodels.AlertRulesByIndex).Sort(groupRules)
- }
-
+ groupedRules := getGroupedRules(ruleList, ruleNamesSet)
rulesTotals := make(map[string]int64, len(groupedRules))
for groupKey, rules := range groupedRules {
folder, ok := opts.Namespaces[groupKey.NamespaceUID]
@@ -377,27 +353,7 @@ func PrepareRuleGroupStatuses(log log.Logger, manager state.AlertInstanceManager
}
if len(withStates) > 0 {
- // Filtering is weird but firing, pending, and normal filters also need to be
- // applied to the rule. Others such as nodata and error should have no effect.
- // This is to match the current behavior in the UI.
- filteredRules := make([]apimodels.AlertingRule, 0, len(ruleGroup.Rules))
- for _, rule := range ruleGroup.Rules {
- var state *eval.State
- switch rule.State {
- case "normal", "inactive":
- state = util.Pointer(eval.Normal)
- case "alerting", "firing":
- state = util.Pointer(eval.Alerting)
- case "pending":
- state = util.Pointer(eval.Pending)
- }
- if state != nil {
- if _, ok := withStatesFast[*state]; ok {
- filteredRules = append(filteredRules, rule)
- }
- }
- }
- ruleGroup.Rules = filteredRules
+ filterRules(ruleGroup, withStatesFast)
}
if limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > limitRulesPerGroup {
@@ -418,6 +374,54 @@ func PrepareRuleGroupStatuses(log log.Logger, manager state.AlertInstanceManager
return ruleResponse
}
+func getGroupedRules(ruleList ngmodels.RulesGroup, ruleNamesSet map[string]struct{}) map[ngmodels.AlertRuleGroupKey][]*ngmodels.AlertRule {
+ // Group rules together by Namespace and Rule Group. Rules are also grouped by Org ID,
+ // but in this API all rules belong to the same organization. Also filter by rule name if
+ // it was provided as a query param.
+ groupedRules := make(map[ngmodels.AlertRuleGroupKey][]*ngmodels.AlertRule)
+ for _, rule := range ruleList {
+ if len(ruleNamesSet) > 0 {
+ if _, exists := ruleNamesSet[rule.Title]; !exists {
+ continue
+ }
+ }
+ groupKey := rule.GetGroupKey()
+ ruleGroup := groupedRules[groupKey]
+ ruleGroup = append(ruleGroup, rule)
+ groupedRules[groupKey] = ruleGroup
+ }
+ // Sort the rules in each rule group by index. We do this at the end instead of
+ // after each append to avoid having to sort each group multiple times.
+ for _, groupRules := range groupedRules {
+ ngmodels.AlertRulesBy(ngmodels.AlertRulesByIndex).Sort(groupRules)
+ }
+ return groupedRules
+}
+
+func filterRules(ruleGroup *apimodels.RuleGroup, withStatesFast map[eval.State]struct{}) {
+ // Filtering is weird but firing, pending, and normal filters also need to be
+ // applied to the rule. Others such as nodata and error should have no effect.
+ // This is to match the current behavior in the UI.
+ filteredRules := make([]apimodels.AlertingRule, 0, len(ruleGroup.Rules))
+ for _, rule := range ruleGroup.Rules {
+ var state *eval.State
+ switch rule.State {
+ case "normal", "inactive":
+ state = util.Pointer(eval.Normal)
+ case "alerting", "firing":
+ state = util.Pointer(eval.Alerting)
+ case "pending":
+ state = util.Pointer(eval.Pending)
+ }
+ if state != nil {
+ if _, ok := withStatesFast[*state]; ok {
+ filteredRules = append(filteredRules, rule)
+ }
+ }
+ }
+ ruleGroup.Rules = filteredRules
+}
+
// This is the same as matchers.Matches but avoids the need to create a LabelSet
func matchersMatch(matchers []*labels.Matcher, labels map[string]string) bool {
for _, m := range matchers {
diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go
index 901a083b53c..e5fbb77b581 100644
--- a/pkg/services/ngalert/ngalert.go
+++ b/pkg/services/ngalert/ngalert.go
@@ -203,6 +203,7 @@ func (ng *AlertNG) init() error {
URL: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.URL,
PromoteConfig: true,
SyncInterval: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.SyncInterval,
+ ExternalURL: ng.Cfg.AppURL,
}
remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, autogenFn, m, ng.tracer)
if err != nil {
@@ -237,6 +238,7 @@ func (ng *AlertNG) init() error {
PromoteConfig: true,
TenantID: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.TenantID,
URL: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.URL,
+ ExternalURL: ng.Cfg.AppURL,
}
remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, autogenFn, m, ng.tracer)
if err != nil {
@@ -273,6 +275,7 @@ func (ng *AlertNG) init() error {
TenantID: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.TenantID,
URL: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.URL,
SyncInterval: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.SyncInterval,
+ ExternalURL: ng.Cfg.AppURL,
}
remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, autogenFn, m, ng.tracer)
if err != nil {
diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go
index 2014220eda9..819df0792ec 100644
--- a/pkg/services/ngalert/remote/alertmanager.go
+++ b/pkg/services/ngalert/remote/alertmanager.go
@@ -77,10 +77,12 @@ type AlertmanagerConfig struct {
BasicAuthPassword string
DefaultConfig string
+
+ // ExternalURL is used in notifications sent by the remote Alertmanager.
+ ExternalURL string
// PromoteConfig is a flag that determines whether the configuration should be used in the remote Alertmanager.
// The same flag is used for promoting state.
PromoteConfig bool
-
// SyncInterval determines how often we should attempt to synchronize configuration.
SyncInterval time.Duration
}
@@ -117,6 +119,7 @@ func NewAlertmanager(cfg AlertmanagerConfig, store stateStore, decryptFn Decrypt
TenantID: cfg.TenantID,
URL: u,
PromoteConfig: cfg.PromoteConfig,
+ ExternalURL: cfg.ExternalURL,
}
mc, err := remoteClient.New(mcCfg, metrics, tracer)
if err != nil {
diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go
index 9617a0f820d..3f61d163245 100644
--- a/pkg/services/ngalert/remote/alertmanager_test.go
+++ b/pkg/services/ngalert/remote/alertmanager_test.go
@@ -168,6 +168,7 @@ func TestApplyConfig(t *testing.T) {
DefaultConfig: defaultGrafanaConfig,
PromoteConfig: true,
SyncInterval: 1 * time.Hour,
+ ExternalURL: "https://test.grafana.com",
}
ctx := context.Background()
@@ -198,6 +199,9 @@ func TestApplyConfig(t *testing.T) {
require.JSONEq(t, testGrafanaConfigWithSecret, string(amCfg))
require.True(t, configSent.Promoted)
+ // Grafana's URL should be sent alongside the configuration.
+ require.Equal(t, cfg.ExternalURL, configSent.ExternalURL)
+
// If we already got a 200 status code response and the sync interval hasn't elapsed,
// we shouldn't send the state/configuration again.
expStateSync := lastStateSync
diff --git a/pkg/services/ngalert/remote/client/alertmanager_configuration.go b/pkg/services/ngalert/remote/client/alertmanager_configuration.go
index ebb61ce9196..2a94b851a38 100644
--- a/pkg/services/ngalert/remote/client/alertmanager_configuration.go
+++ b/pkg/services/ngalert/remote/client/alertmanager_configuration.go
@@ -21,6 +21,7 @@ type UserGrafanaConfig struct {
CreatedAt int64 `json:"created"`
Default bool `json:"default"`
Promoted bool `json:"promoted"`
+ ExternalURL string `json:"external_url"`
}
func (mc *Mimir) ShouldPromoteConfig() bool {
@@ -53,6 +54,7 @@ func (mc *Mimir) CreateGrafanaAlertmanagerConfig(ctx context.Context, cfg *apimo
CreatedAt: createdAt,
Default: isDefault,
Promoted: mc.promoteConfig,
+ ExternalURL: mc.externalURL,
})
if err != nil {
return err
diff --git a/pkg/services/ngalert/remote/client/mimir.go b/pkg/services/ngalert/remote/client/mimir.go
index 1fda8206c4d..4b58bb22b60 100644
--- a/pkg/services/ngalert/remote/client/mimir.go
+++ b/pkg/services/ngalert/remote/client/mimir.go
@@ -40,6 +40,7 @@ type Mimir struct {
logger log.Logger
metrics *metrics.RemoteAlertmanager
promoteConfig bool
+ externalURL string
}
type Config struct {
@@ -49,6 +50,7 @@ type Config struct {
Logger log.Logger
PromoteConfig bool
+ ExternalURL string
}
// successResponse represents a successful response from the Mimir API.
@@ -91,6 +93,7 @@ func New(cfg *Config, metrics *metrics.RemoteAlertmanager, tracer tracing.Tracer
logger: cfg.Logger,
metrics: metrics,
promoteConfig: cfg.PromoteConfig,
+ externalURL: cfg.ExternalURL,
}, nil
}
diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go
index cad2432d7b1..8ff9569d91e 100644
--- a/pkg/tests/apis/dashboard/dashboards_test.go
+++ b/pkg/tests/apis/dashboard/dashboards_test.go
@@ -95,20 +95,6 @@ func TestIntegrationDashboardsApp(t *testing.T) {
"patch",
"update"
]
- },
- {
- "resource": "summary",
- "responseKind": {
- "group": "",
- "kind": "DashboardSummary",
- "version": ""
- },
- "scope": "Namespaced",
- "singularResource": "summary",
- "verbs": [
- "get",
- "list"
- ]
}
],
"version": "v0alpha1"
diff --git a/public/app/features/apiserver/client.ts b/public/app/features/apiserver/client.ts
index ee784d4df4d..6a23c3dce50 100644
--- a/public/app/features/apiserver/client.ts
+++ b/public/app/features/apiserver/client.ts
@@ -11,6 +11,9 @@ import {
ResourceList,
ResourceClient,
ObjectMeta,
+ AnnoKeyOriginPath,
+ AnnoKeyOriginHash,
+ AnnoKeyOriginName,
} from './types';
export interface GroupVersionResource {
@@ -103,7 +106,7 @@ function setOriginAsUI(meta: Partial) {
if (!meta.annotations) {
meta.annotations = {};
}
- meta.annotations.AnnoKeyOriginName = 'UI';
- meta.annotations.AnnoKeyOriginPath = window.location.pathname;
- meta.annotations.AnnoKeyOriginHash = config.buildInfo.versionString;
+ meta.annotations[AnnoKeyOriginName] = 'UI';
+ meta.annotations[AnnoKeyOriginPath] = window.location.pathname;
+ meta.annotations[AnnoKeyOriginHash] = config.buildInfo.versionString;
}
diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts
index 97fe0317f30..9065f67f668 100644
--- a/public/app/features/apiserver/types.ts
+++ b/public/app/features/apiserver/types.ts
@@ -37,9 +37,9 @@ export const AnnoKeyMessage = 'grafana.app/message';
export const AnnoKeySlug = 'grafana.app/slug';
// Identify where values came from
-const AnnoKeyOriginName = 'grafana.app/originName';
-const AnnoKeyOriginPath = 'grafana.app/originPath';
-const AnnoKeyOriginHash = 'grafana.app/originHash';
+export const AnnoKeyOriginName = 'grafana.app/originName';
+export const AnnoKeyOriginPath = 'grafana.app/originPath';
+export const AnnoKeyOriginHash = 'grafana.app/originHash';
const AnnoKeyOriginTimestamp = 'grafana.app/originTimestamp';
type GrafanaAnnotations = {
diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts
index 48c6b6b916a..47567dcf153 100644
--- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts
+++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts
@@ -331,17 +331,15 @@ export const browseDashboardsAPI = createApi({
// save an existing dashboard
saveDashboard: builder.mutation({
- query: ({ dashboard, folderUid, message, overwrite, showErrorAlert }) => ({
- url: `/dashboards/db`,
- method: 'POST',
- showErrorAlert,
- data: {
- dashboard,
- folderUid,
- message: message ?? '',
- overwrite: Boolean(overwrite),
- },
- }),
+ queryFn: async (cmd) => {
+ try {
+ const rsp = await getDashboardAPI().saveDashboard(cmd);
+ return { data: rsp };
+ } catch (error) {
+ return { error };
+ }
+ },
+
onQueryStarted: ({ folderUid }, { queryFulfilled, dispatch }) => {
dashboardWatcher.ignoreNextSave();
queryFulfilled.then(async () => {
diff --git a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts
index ebd71b6ca63..04ab768aaad 100644
--- a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts
+++ b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts
@@ -28,6 +28,7 @@ export function useSaveDashboard(isCopy = false) {
message: options.message,
overwrite: options.overwrite,
showErrorAlert: false,
+ k8s: undefined, // TODO? pass the original metadata
});
if ('error' in result) {
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx
index 3fa727e583b..d9bc1cc88d5 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx
+++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx
@@ -3,9 +3,9 @@ import { Link } from 'react-router-dom';
import { GrafanaTheme2, Scope, urlUtil } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
-import { CustomScrollbar, Icon, IconButton, Input, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
+import { Button, CustomScrollbar, FilterInput, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
-import { t } from 'app/core/internationalization';
+import { t, Trans } from 'app/core/internationalization';
import { fetchSuggestedDashboards } from './api';
import { SuggestedDashboard } from './types';
@@ -14,6 +14,7 @@ export interface ScopesDashboardsSceneState extends SceneObjectState {
dashboards: SuggestedDashboard[];
filteredDashboards: SuggestedDashboard[];
isLoading: boolean;
+ scopesSelected: boolean;
searchQuery: string;
}
@@ -25,13 +26,14 @@ export class ScopesDashboardsScene extends SceneObjectBase 0,
});
}
@@ -62,31 +65,38 @@ export class ScopesDashboardsScene extends SceneObjectBase) {
- const { filteredDashboards, isLoading, searchQuery } = model.useState();
+ const { dashboards, filteredDashboards, isLoading, searchQuery, scopesSelected } = model.useState();
const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams();
+ if (!isLoading) {
+ if (!scopesSelected) {
+ return (
+
+ No scopes selected
+
+ );
+ } else if (dashboards.length === 0) {
+ return (
+
+
+ No dashboards found for the selected scopes
+
+
+ );
+ }
+ }
+
return (
<>
- }
- placeholder={t('scopes.suggestedDashboards.search', 'Search')}
+ model.changeSearchQuery('')}
- />
- ) : undefined
- }
- onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)}
+ data-testid="scopes-dashboards-search"
+ onChange={(value) => model.changeSearchQuery(value)}
/>
@@ -96,7 +106,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps
- ) : (
+ ) : filteredDashboards.length > 0 ? (
{filteredDashboards.map(({ dashboard, dashboardTitle }) => (
))}
+ ) : (
+
+ No results found for your query
+
+
+
)}
>
);
@@ -116,6 +138,14 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps {
return {
+ noResultsContainer: css({
+ alignItems: 'center',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(1),
+ justifyContent: 'center',
+ textAlign: 'center',
+ }),
searchInputContainer: css({
flex: '0 1 auto',
}),
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx
index f0d9ddfb503..7af747fc9d8 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx
+++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx
@@ -166,7 +166,19 @@ export class ScopesFiltersScene extends SceneObjectBase
public open() {
if (!this.scopesParent.state.isViewing) {
- this.setState({ isOpened: true });
+ let nodes = { ...this.state.nodes };
+
+ // First close all nodes
+ nodes = this.closeNodes(nodes);
+
+ // Extract the path of a scope
+ let path = [...(this.state.scopes[0]?.path ?? ['', ''])];
+ path.splice(path.length - 1, 1);
+
+ // Expand the nodes to the selected scope
+ nodes = this.expandNodes(nodes, path);
+
+ this.setState({ isOpened: true, nodes });
}
}
@@ -207,6 +219,35 @@ export class ScopesFiltersScene extends SceneObjectBase
this.setState({ isOpened: false });
}
+ private closeNodes(nodes: NodesMap): NodesMap {
+ return Object.entries(nodes).reduce((acc, [id, node]) => {
+ acc[id] = {
+ ...node,
+ isExpanded: false,
+ nodes: this.closeNodes(node.nodes),
+ };
+
+ return acc;
+ }, {});
+ }
+
+ private expandNodes(nodes: NodesMap, path: string[]): NodesMap {
+ nodes = { ...nodes };
+ let currentNodes = nodes;
+
+ for (let i = 0; i < path.length; i++) {
+ const nodeId = path[i];
+
+ currentNodes[nodeId] = {
+ ...currentNodes[nodeId],
+ isExpanded: true,
+ };
+ currentNodes = currentNodes[nodeId].nodes;
+ }
+
+ return nodes;
+ }
+
private getTreeScopes(): TreeScope[] {
return this.state.scopes.map(({ scope, path }) => ({
scopeName: scope.metadata.name,
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx
index 839c76d5933..84cea5de184 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx
+++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx
@@ -45,7 +45,12 @@ import {
queryDashboardsContainer,
queryDashboardsExpand,
renderDashboard,
- getDashboardsClear,
+ getNotFoundForScope,
+ queryDashboardsSearch,
+ getNotFoundForFilter,
+ getClustersSlothClusterEastRadio,
+ getNotFoundForFilterClear,
+ getNotFoundNoScopes,
} from './testUtils';
jest.mock('@grafana/runtime', () => ({
@@ -182,6 +187,17 @@ describe('ScopesScene', () => {
expect(getApplicationsSlothVoteTrackerSelect()).toBeInTheDocument();
expect(queryApplicationsClustersTitle()).not.toBeInTheDocument();
});
+
+ it('Opens to a selected scope', async () => {
+ await userEvents.click(getFiltersInput());
+ await userEvents.click(getApplicationsExpand());
+ await userEvents.click(getApplicationsSlothPictureFactorySelect());
+ await userEvents.click(getApplicationsExpand());
+ await userEvents.click(getClustersExpand());
+ await userEvents.click(getFiltersApply());
+ await userEvents.click(getFiltersInput());
+ expect(queryApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
+ });
});
describe('Filters', () => {
@@ -286,22 +302,6 @@ describe('ScopesScene', () => {
expect(queryDashboard('2')).not.toBeInTheDocument();
});
- it('Clears the filter', async () => {
- await userEvents.click(getDashboardsExpand());
- await userEvents.click(getFiltersInput());
- await userEvents.click(getApplicationsExpand());
- await userEvents.click(getApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
- expect(getDashboard('1')).toBeInTheDocument();
- expect(getDashboard('2')).toBeInTheDocument();
- await userEvents.type(getDashboardsSearch(), '1');
- expect(queryDashboard('2')).not.toBeInTheDocument();
- await userEvents.click(getDashboardsClear());
- expect(getDashboardsSearch().value).toBe('');
- expect(getDashboard('1')).toBeInTheDocument();
- expect(getDashboard('2')).toBeInTheDocument();
- });
-
it('Deduplicates the dashboards list', async () => {
await userEvents.click(getDashboardsExpand());
await userEvents.click(getFiltersInput());
@@ -315,6 +315,35 @@ describe('ScopesScene', () => {
expect(queryAllDashboard('7')).toHaveLength(1);
expect(queryAllDashboard('8')).toHaveLength(1);
});
+
+ it('Does show a proper message when no scopes are selected', async () => {
+ await userEvents.click(getDashboardsExpand());
+ expect(getNotFoundNoScopes()).toBeInTheDocument();
+ expect(queryDashboardsSearch()).not.toBeInTheDocument();
+ });
+
+ it('Does not show the input when there are no dashboards found for scope', async () => {
+ await userEvents.click(getDashboardsExpand());
+ await userEvents.click(getFiltersInput());
+ await userEvents.click(getClustersExpand());
+ await userEvents.click(getClustersSlothClusterEastRadio());
+ await userEvents.click(getFiltersApply());
+ expect(getNotFoundForScope()).toBeInTheDocument();
+ expect(queryDashboardsSearch()).not.toBeInTheDocument();
+ });
+
+ it('Does show the input and a message when there are no dashboards found for filter', async () => {
+ await userEvents.click(getDashboardsExpand());
+ await userEvents.click(getFiltersInput());
+ await userEvents.click(getApplicationsExpand());
+ await userEvents.click(getApplicationsSlothPictureFactorySelect());
+ await userEvents.click(getFiltersApply());
+ await userEvents.type(getDashboardsSearch(), 'unknown');
+ expect(queryDashboardsSearch()).toBeInTheDocument();
+ expect(getNotFoundForFilter()).toBeInTheDocument();
+ await userEvents.click(getNotFoundForFilterClear());
+ expect(getDashboardsSearch().value).toBe('');
+ });
});
describe('View mode', () => {
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx
index 399472cfa00..8d57b74d825 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx
+++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx
@@ -1,10 +1,10 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
-import { useMemo } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
-import { Checkbox, Icon, IconButton, Input, RadioButtonDot, useStyles2 } from '@grafana/ui';
+import { Checkbox, FilterInput, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { NodesMap, TreeScope } from './types';
@@ -37,18 +37,24 @@ export function ScopesTreeLevel({
const scopeNames = scopes.map(({ scopeName }) => scopeName);
const anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded);
+ const [queryValue, setQueryValue] = useState(node.query);
+ useEffect(() => {
+ setQueryValue(node.query);
+ }, [node.query]);
const onQueryUpdate = useMemo(() => debounce(onNodeUpdate, 500), [onNodeUpdate]);
return (
<>
{!anyChildExpanded && (
- }
- className={styles.searchInput}
+ onQueryUpdate(nodePath, true, evt.currentTarget.value)}
+ onChange={(value) => {
+ setQueryValue(value);
+ onQueryUpdate(nodePath, true, value);
+ }}
/>
)}
@@ -99,20 +105,24 @@ export function ScopesTreeLevel({
)
) : null}
- {childNode.isExpandable && (
- {
onNodeUpdate(childNodePath, !childNode.isExpanded, childNode.query);
}}
- />
- )}
+ >
+
- {childNode.title}
+ {childNode.title}
+
+ ) : (
+ {childNode.title}
+ )}
@@ -159,6 +169,15 @@ const getStyles = (theme: GrafanaTheme2) => {
gap: 0,
}),
}),
+ itemExpand: css({
+ alignItems: 'center',
+ background: 'none',
+ border: 0,
+ display: 'flex',
+ gap: theme.spacing(1),
+ margin: 0,
+ padding: 0,
+ }),
itemChildren: css({
paddingLeft: theme.spacing(4),
}),
diff --git a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx
index a66ed6a9e37..cdc9053a163 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx
+++ b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx
@@ -49,6 +49,16 @@ export const mocksScopes: Scope[] = [
filters: [{ key: 'cluster', value: 'slothClusterSouth', operator: 'equals' }],
},
},
+ {
+ metadata: { name: 'slothClusterEast' },
+ spec: {
+ title: 'slothClusterEast',
+ type: 'cluster',
+ description: 'slothClusterEast',
+ category: 'clusters',
+ filters: [{ key: 'cluster', value: 'slothClusterEast', operator: 'equals' }],
+ },
+ },
{
metadata: { name: 'slothPictureFactory' },
spec: {
@@ -213,6 +223,17 @@ export const mocksNodes: Array
= [
linkId: 'slothClusterSouth',
},
},
+ {
+ parent: 'clusters',
+ metadata: { name: 'clusters-slothClusterEast' },
+ spec: {
+ nodeType: 'leaf',
+ title: 'slothClusterEast',
+ description: 'slothClusterEast',
+ linkType: 'scope',
+ linkId: 'slothClusterEast',
+ },
+ },
{
parent: 'clusters',
metadata: { name: 'clusters.applications' },
@@ -310,9 +331,12 @@ const selectors = {
expand: 'scopes-dashboards-expand',
container: 'scopes-dashboards-container',
search: 'scopes-dashboards-search',
- clear: 'scopes-dashboards-clear',
loading: 'scopes-dashboards-loading',
dashboard: (uid: string) => `scopes-dashboards-${uid}`,
+ notFoundNoScopes: 'scopes-dashboards-notFoundNoScopes',
+ notFoundForScope: 'scopes-dashboards-notFoundForScope',
+ notFoundForFilter: 'scopes-dashboards-notFoundForFilter',
+ notFoundForFilterClear: 'scopes-dashboards-notFoundForFilter-clear',
},
};
@@ -325,14 +349,18 @@ export const queryDashboardsExpand = () => screen.queryByTestId(selectors.dashbo
export const getDashboardsExpand = () => screen.getByTestId(selectors.dashboards.expand);
export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container);
export const getDashboardsContainer = () => screen.getByTestId(selectors.dashboards.container);
+export const queryDashboardsSearch = () => screen.queryByTestId(selectors.dashboards.search);
export const getDashboardsSearch = () => screen.getByTestId(selectors.dashboards.search);
-export const getDashboardsClear = () => screen.getByTestId(selectors.dashboards.clear);
export const queryAllDashboard = (uid: string) => screen.queryAllByTestId(selectors.dashboards.dashboard(uid));
export const queryDashboard = (uid: string) => screen.queryByTestId(selectors.dashboards.dashboard(uid));
export const getDashboard = (uid: string) => screen.getByTestId(selectors.dashboards.dashboard(uid));
+export const getNotFoundNoScopes = () => screen.getByTestId(selectors.dashboards.notFoundNoScopes);
+export const getNotFoundForScope = () => screen.getByTestId(selectors.dashboards.notFoundForScope);
+export const getNotFoundForFilter = () => screen.getByTestId(selectors.dashboards.notFoundForFilter);
+export const getNotFoundForFilterClear = () => screen.getByTestId(selectors.dashboards.notFoundForFilterClear);
export const getApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications'));
-export const getApplicationsSearch = () => screen.getByTestId(selectors.tree.search('applications'));
+export const getApplicationsSearch = () => screen.getByTestId(selectors.tree.search('applications'));
export const queryApplicationsSlothPictureFactoryTitle = () =>
screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory'));
export const getApplicationsSlothPictureFactoryTitle = () =>
@@ -359,6 +387,8 @@ export const getClustersSlothClusterNorthRadio = () =>
screen.getByTestId(selectors.tree.radio('clusters-slothClusterNorth'));
export const getClustersSlothClusterSouthRadio = () =>
screen.getByTestId(selectors.tree.radio('clusters-slothClusterSouth'));
+export const getClustersSlothClusterEastRadio = () =>
+ screen.getByTestId(selectors.tree.radio('clusters-slothClusterEast'));
export function buildTestScene(overrides: Partial = {}) {
return new DashboardScene({
diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts
index f0edd586360..b5282772143 100644
--- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts
+++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts
@@ -102,6 +102,7 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
handleZoomOut(scene);
},
});
+
keybindings.addBinding({
key: 'ctrl+z',
onTrigger: () => {
@@ -109,6 +110,15 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
},
});
+ // Relative -> Absolute time range
+ keybindings.addBinding({
+ key: 't a',
+ onTrigger: () => {
+ const timePicker = dashboardSceneGraph.getTimePicker(scene);
+ timePicker?.toAbsolute();
+ },
+ });
+
keybindings.addBinding({
key: 't left',
onTrigger: () => {
diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts
index 40bb77523ec..477495fda96 100644
--- a/public/app/features/dashboard/api/dashboard_api.ts
+++ b/public/app/features/dashboard/api/dashboard_api.ts
@@ -1,6 +1,12 @@
import { config, getBackendSrv } from '@grafana/runtime';
import { ScopedResourceClient } from 'app/features/apiserver/client';
-import { Resource, ResourceClient } from 'app/features/apiserver/types';
+import {
+ AnnoKeyFolder,
+ AnnoKeyMessage,
+ Resource,
+ ResourceClient,
+ ResourceForCreate,
+} from 'app/features/apiserver/types';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
@@ -53,7 +59,7 @@ interface DashboardWithAccessInfo extends Resource;
- constructor(private legacy: DashboardAPI) {
+ constructor() {
this.client = new ScopedResourceClient({
group: 'dashboard.grafana.app',
version: 'v0alpha1',
@@ -62,23 +68,69 @@ class K8sDashboardAPI implements DashboardAPI {
}
saveDashboard(options: SaveDashboardCommand): Promise {
- return this.legacy.saveDashboard(options);
+ const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property
+ const obj: ResourceForCreate = {
+ metadata: {
+ ...options?.k8s,
+ },
+ spec: {
+ ...dashboard,
+ },
+ };
+
+ if (options.message) {
+ obj.metadata.annotations = {
+ ...obj.metadata.annotations,
+ [AnnoKeyMessage]: options.message,
+ };
+ } else if (obj.metadata.annotations) {
+ delete obj.metadata.annotations[AnnoKeyMessage];
+ }
+
+ if (options.folderUid) {
+ obj.metadata.annotations = {
+ ...obj.metadata.annotations,
+ [AnnoKeyFolder]: options.folderUid,
+ };
+ }
+
+ if (dashboard.uid) {
+ obj.metadata.name = dashboard.uid;
+ return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v));
+ }
+ return this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v));
+ }
+
+ asSaveDashboardResponseDTO(v: Resource): SaveDashboardResponseDTO {
+ return {
+ uid: v.metadata.name,
+ version: v.spec.version ?? 0,
+ id: v.spec.id ?? 0,
+ status: 'success',
+ slug: '',
+ url: '',
+ };
}
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise {
- return this.legacy.deleteDashboard(uid, showSuccessAlert);
+ return this.client.delete(uid).then((v) => ({
+ id: 0,
+ message: v.message,
+ title: 'deleted',
+ }));
}
async getDashboardDTO(uid: string): Promise {
- const dto = await this.client.subresource(uid, 'dto');
+ const dash = await this.client.subresource(uid, 'dto');
return {
meta: {
- ...dto.access,
+ ...dash.access,
isNew: false,
isFolder: false,
- uid: dto.metadata.name,
+ uid: dash.metadata.name,
+ k8s: dash.metadata,
},
- dashboard: dto.spec,
+ dashboard: dash.spec,
};
}
}
@@ -87,8 +139,7 @@ let instance: DashboardAPI | undefined = undefined;
export function getDashboardAPI() {
if (!instance) {
- const legacy = new LegacyDashboardAPI();
- instance = config.featureToggles.kubernetesDashboards ? new K8sDashboardAPI(legacy) : legacy;
+ instance = config.featureToggles.kubernetesDashboards ? new K8sDashboardAPI() : new LegacyDashboardAPI();
}
return instance;
}
diff --git a/public/app/features/dashboard/components/SaveDashboard/types.ts b/public/app/features/dashboard/components/SaveDashboard/types.ts
index a8af17cb806..f5b9418a5ee 100644
--- a/public/app/features/dashboard/components/SaveDashboard/types.ts
+++ b/public/app/features/dashboard/components/SaveDashboard/types.ts
@@ -1,4 +1,5 @@
import { Dashboard } from '@grafana/schema';
+import { ObjectMeta } from 'app/features/apiserver/types';
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils';
@@ -22,6 +23,9 @@ export interface SaveDashboardCommand {
folderUid?: string;
overwrite?: boolean;
showErrorAlert?: boolean;
+
+ // When loading dashboards from k8s, we need to have access to the metadata wrapper
+ k8s?: Partial;
}
export interface SaveDashboardFormProps {
diff --git a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx
index 8bf16da07e9..08de75d36c0 100644
--- a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx
+++ b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx
@@ -26,6 +26,7 @@ const saveDashboard = async (
folderUid: options.folderUid ?? dashboard.meta.folderUid ?? saveModel.meta?.folderUid,
message: options.message,
overwrite: options.overwrite,
+ k8s: dashboard.meta.k8s,
});
if ('error' in query) {
@@ -70,7 +71,7 @@ export const useDashboardSave = (isCopy = false) => {
const currentPath = locationService.getLocation().pathname;
const newUrl = locationUtil.stripBaseFromUrl(result.url);
- if (newUrl !== currentPath) {
+ if (newUrl !== currentPath && result.url) {
setTimeout(() => locationService.replace(newUrl));
}
if (dashboard.meta.isStarred) {
diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SupportedPubdashDatasources.ts b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SupportedPubdashDatasources.ts
index aac1aaaf0f5..d33b843451e 100644
--- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SupportedPubdashDatasources.ts
+++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SupportedPubdashDatasources.ts
@@ -12,17 +12,21 @@ export const supportedDatasources = new Set([
'dlopes7-appdynamics-datasource',
'dvelop-odata-datasource',
'elasticsearch',
+ 'factry-historian-datasource',
+ 'fiskaly-surrealdb-datasource',
'frser-sqlite-datasource',
'grafadruid-druid-datasource',
'grafana-athena-datasource',
'grafana-azure-data-explorer-datasource',
'grafana-azure-monitor-datasource',
+ 'grafana-azurecosmosdb-datasource',
'grafana-bigquery-datasource',
+ 'grafana-catchpoint-datasource',
'grafana-clickhouse-datasource',
'grafana-databricks-datasource',
'grafana-datadog-datasource',
+ 'grafana-dynamodb-datasource',
'grafana-dynatrace-datasource',
- 'grafana-es-open-distro-datasource',
'grafana-falconlogscale-datasource',
'grafana-github-datasource',
'grafana-gitlab-datasource',
@@ -34,10 +38,10 @@ export const supportedDatasources = new Set([
'grafana-mongodb-datasource',
'grafana-newrelic-datasource',
'grafana-odbc-datasource',
- 'grafana-opcua-datasource',
'grafana-opensearch-datasource',
'grafana-oracle-datasource',
'grafana-orbit-datasource',
+ 'grafana-pagerduty-datasource',
'grafana-redshift-datasource',
'grafana-salesforce-datasource',
'grafana-saphana-datasource',
@@ -47,12 +51,16 @@ export const supportedDatasources = new Set([
'grafana-splunk-datasource',
'grafana-splunk-monitoring-datasource',
'grafana-sumologic-datasource',
+ 'grafana-surrealdb-datasource',
'grafana-testdata-datasource',
'grafana-timestream-datasource',
'grafana-wavefront-datasource',
'grafana-x-ray-datasource',
'graphite',
'hadesarchitect-cassandra-datasource',
+ 'highlightinc-highlight-datasource',
+ 'ibm-aql-datasource',
+ 'ibm-kql-datasource',
'influxdata-flightsql-datasource',
'influxdb',
'innius-grpc-datasource',
@@ -64,16 +72,18 @@ export const supportedDatasources = new Set([
'mysql',
'nagasudhirpulla-api-datasource',
'needleinajaystack-haystack-datasource',
+ 'oci-metrics-datasource',
'opentsdb',
+ 'parseable-parseable-datasource',
'postgres',
'prometheus',
+ 'questdb-questdb-datasource',
'quickwit-quickwit-datasource',
'redis-datasource',
- 'sentinelone-dataset-datasource',
- 'sneller-sneller-datasource',
- 'spiceai-spicexyz-datasource',
+ 'retrodaredevil-wildgraphql-datasource',
'stackdriver',
'tdengine-datasource',
+ 'timeplus-proton-datasource',
'trino-datasource',
'vertamedia-clickhouse-datasource',
'vertica-grafana-datasource',
diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts
index 8482ea555ff..e89ad53668a 100644
--- a/public/app/features/dashboard/services/DashboardSrv.ts
+++ b/public/app/features/dashboard/services/DashboardSrv.ts
@@ -1,5 +1,3 @@
-import { lastValueFrom } from 'rxjs';
-
import { AppEvents } from '@grafana/data';
import { BackendSrvRequest } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
@@ -28,15 +26,6 @@ export interface SaveDashboardOptions {
refresh?: string;
}
-interface SaveDashboardResponse {
- id: number;
- slug: string;
- status: string;
- uid: string;
- url: string;
- version: number;
-}
-
export class DashboardSrv {
dashboard?: DashboardModel;
@@ -75,17 +64,12 @@ export class DashboardSrv {
data: SaveDashboardOptions,
requestOptions?: Pick
) {
- return lastValueFrom(
- getBackendSrv().fetch({
- url: '/api/dashboards/db/',
- method: 'POST',
- data: {
- ...data,
- dashboard: data.dashboard.getSaveModelClone(),
- },
- ...requestOptions,
- })
- );
+ return getDashboardAPI().saveDashboard({
+ message: data.message,
+ folderUid: data.folderUid,
+ dashboard: data.dashboard.getSaveModelClone(),
+ showErrorAlert: requestOptions?.showErrorAlert,
+ });
}
starDashboard(dashboardUid: string, isStarred: boolean) {
diff --git a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx
index 56bd133fbb8..d9c6153780a 100644
--- a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx
+++ b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx
@@ -13,7 +13,7 @@ export const CallToAction = () => {
return (
<>
-
+
Let us manage your Grafana stack
diff --git a/public/app/features/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx b/public/app/features/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx
index 37f56297a91..ee5a399b954 100644
--- a/public/app/features/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx
+++ b/public/app/features/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx
@@ -1,38 +1,29 @@
-import { css } from '@emotion/css';
-
-import { GrafanaTheme2 } from '@grafana/data';
-import { Grid, Stack, useStyles2 } from '@grafana/ui';
+import { Box, Grid, Stack } from '@grafana/ui';
import { CallToAction } from './CallToAction/CallToAction';
import { InfoPaneLeft } from './InfoPaneLeft';
import { InfoPaneRight } from './InfoPaneRight';
export const EmptyState = () => {
- const styles = useStyles2(getStyles);
-
return (
-
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
);
};
-
-const getStyles = (theme: GrafanaTheme2) => ({
- container: css({
- maxWidth: theme.breakpoints.values.xl,
- }),
-});
diff --git a/public/app/features/migrate-to-cloud/onprem/EmptyState/InfoPaneLeft.tsx b/public/app/features/migrate-to-cloud/onprem/EmptyState/InfoPaneLeft.tsx
index 693332bd4f6..37ca5356f8c 100644
--- a/public/app/features/migrate-to-cloud/onprem/EmptyState/InfoPaneLeft.tsx
+++ b/public/app/features/migrate-to-cloud/onprem/EmptyState/InfoPaneLeft.tsx
@@ -1,11 +1,11 @@
-import { Box } from '@grafana/ui';
+import { Stack } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { InfoItem } from '../../shared/InfoItem';
export const InfoPaneLeft = () => {
return (
-
+
{
installation.
+
{
SLOs, incident management, machine learning, and powerful observability integrations.
+
{
unauthorized access, use, or disclosure.
-
+
);
};
diff --git a/public/app/features/migrate-to-cloud/onprem/EmptyState/InfoPaneRight.tsx b/public/app/features/migrate-to-cloud/onprem/EmptyState/InfoPaneRight.tsx
index f0967a6604d..01173bab9e8 100644
--- a/public/app/features/migrate-to-cloud/onprem/EmptyState/InfoPaneRight.tsx
+++ b/public/app/features/migrate-to-cloud/onprem/EmptyState/InfoPaneRight.tsx
@@ -1,11 +1,11 @@
-import { Box } from '@grafana/ui';
+import { Stack } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { InfoItem } from '../../shared/InfoItem';
export const InfoPaneRight = () => {
return (
-
+
{
dashboards.
-
+
);
};
diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts
index 51c0dcca4db..395255e2784 100644
--- a/public/app/types/dashboard.ts
+++ b/public/app/types/dashboard.ts
@@ -1,5 +1,6 @@
import { DataQuery } from '@grafana/data';
import { Dashboard, DataSourceRef } from '@grafana/schema';
+import { ObjectMeta } from 'app/features/apiserver/types';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
export interface DashboardDTO {
@@ -67,6 +68,11 @@ export interface DashboardMeta {
dashboardNotFound?: boolean;
isEmbedded?: boolean;
isNew?: boolean;
+
+ // When loaded from kubernetes, we stick the raw metadata here
+ // yes weird, but this means all the editor structures can exist unchanged
+ // until we use the resource as the main container
+ k8s?: Partial;
}
export interface AnnotationActions {
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 42e225bb28c..afbd474048c 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -1656,8 +1656,11 @@
"title": "Select scopes"
},
"suggestedDashboards": {
- "clear": "Clear search",
"loading": "Loading dashboards",
+ "noResultsForFilter": "No results found for your query",
+ "noResultsForFilterClear": "Clear search",
+ "noResultsForScopes": "No dashboards found for the selected scopes",
+ "noResultsNoScopes": "No scopes selected",
"search": "Search",
"toggle": {
"collapse": "Collapse scope filters",
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 695dbb06a45..c3eb128f08d 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -1656,8 +1656,11 @@
"title": "Ŝęľęčŧ şčőpęş"
},
"suggestedDashboards": {
- "clear": "Cľęäř şęäřčĥ",
"loading": "Ŀőäđįʼnģ đäşĥþőäřđş",
+ "noResultsForFilter": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy",
+ "noResultsForFilterClear": "Cľęäř şęäřčĥ",
+ "noResultsForScopes": "Ńő đäşĥþőäřđş ƒőūʼnđ ƒőř ŧĥę şęľęčŧęđ şčőpęş",
+ "noResultsNoScopes": "Ńő şčőpęş şęľęčŧęđ",
"search": "Ŝęäřčĥ",
"toggle": {
"collapse": "Cőľľäpşę şčőpę ƒįľŧęřş",
diff --git a/yarn.lock b/yarn.lock
index e42cada06fb..393f7b62e03 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3580,9 +3580,9 @@ __metadata:
languageName: unknown
linkType: soft
-"@grafana/scenes@npm:^5.0.2":
- version: 5.1.2
- resolution: "@grafana/scenes@npm:5.1.2"
+"@grafana/scenes@npm:^5.3.0":
+ version: 5.3.0
+ resolution: "@grafana/scenes@npm:5.3.0"
dependencies:
"@grafana/e2e-selectors": "npm:^11.0.0"
"@leeoniya/ufuzzy": "npm:^1.0.14"
@@ -3597,7 +3597,7 @@ __metadata:
"@grafana/ui": ^10.4.1
react: ^18.0.0
react-dom: ^18.0.0
- checksum: 10/814fe81537d267640cf0e4d91c1fc5805290fd6f46bbf37633edfbc8fbeae8a064a2b9540d482adb061235bd20148ddf246db6e41a8141380e98d49ca31638f3
+ checksum: 10/625b7e009af1b79de8903eb2fbe9e2da3558e229d51e532bbb385bc72c316f9a5df9e1e81caff49e3b245dd1e00a019331f4ce11cdd980fe112917992c80c3c2
languageName: node
linkType: hard
@@ -17145,7 +17145,7 @@ __metadata:
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*"
- "@grafana/scenes": "npm:^5.0.2"
+ "@grafana/scenes": "npm:^5.3.0"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^1.3.0-rc1"