Merge remote-tracking branch 'origin/main' into resource-store

This commit is contained in:
Ryan McKinley 2024-06-26 16:04:23 +03:00
commit 86a7064334
57 changed files with 532 additions and 612 deletions

View File

@ -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"]
],

1
.github/CODEOWNERS vendored
View File

@ -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

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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:*",

View File

@ -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({

View File

@ -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 = <div className={styles.content}>{children}</div>;
const overrideWidth = drawerWidth ?? width ?? drawerSizes[size].width;
const minWidth = drawerSizes[size].minWidth;
return (
<RcDrawer
@ -95,7 +102,16 @@ export function Drawer({
placement="right"
getContainer={'.main-view'}
className={styles.drawerContent}
rootClassName={rootClass}
rootClassName={styles.drawer}
classNames={{
wrapper: wrapperStyles,
}}
styles={{
wrapper: {
width: overrideWidth,
minWidth,
},
}}
width={''}
motion={{
motionAppear: true,
@ -356,27 +372,14 @@ const getStyles = (theme: GrafanaTheme2) => {
};
};
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,
},
});
}

View File

@ -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}

View File

@ -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"`

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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}}

View File

@ -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{}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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))
})
}
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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]

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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"

View File

@ -11,6 +11,9 @@ import {
ResourceList,
ResourceClient,
ObjectMeta,
AnnoKeyOriginPath,
AnnoKeyOriginHash,
AnnoKeyOriginName,
} from './types';
export interface GroupVersionResource {
@ -103,7 +106,7 @@ function setOriginAsUI(meta: Partial<ObjectMeta>) {
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;
}

View File

@ -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 = {

View File

@ -331,17 +331,15 @@ export const browseDashboardsAPI = createApi({
// save an existing dashboard
saveDashboard: builder.mutation<SaveDashboardResponseDTO, SaveDashboardCommand>({
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 () => {

View File

@ -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) {

View File

@ -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<ScopesDashboardsScene
dashboards: [],
filteredDashboards: [],
isLoading: false,
scopesSelected: false,
searchQuery: '',
});
}
public async fetchDashboards(scopes: Scope[]) {
if (scopes.length === 0) {
return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false });
return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false, scopesSelected: false });
}
this.setState({ isLoading: true });
@ -42,6 +44,7 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
dashboards,
filteredDashboards: this.filterDashboards(dashboards, this.state.searchQuery),
isLoading: false,
scopesSelected: scopes.length > 0,
});
}
@ -62,31 +65,38 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
}
export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<ScopesDashboardsScene>) {
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 (
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundNoScopes">
<Trans i18nKey="scopes.suggestedDashboards.noResultsNoScopes">No scopes selected</Trans>
</p>
);
} else if (dashboards.length === 0) {
return (
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForScope">
<Trans i18nKey="scopes.suggestedDashboards.noResultsForScopes">
No dashboards found for the selected scopes
</Trans>
</p>
);
}
}
return (
<>
<div className={styles.searchInputContainer}>
<Input
prefix={<Icon name="search" />}
placeholder={t('scopes.suggestedDashboards.search', 'Search')}
<FilterInput
disabled={isLoading}
data-testid="scopes-dashboards-search"
placeholder={t('scopes.suggestedDashboards.search', 'Search')}
value={searchQuery}
suffix={
searchQuery && !isLoading ? (
<IconButton
aria-label={t('scopes.suggestedDashboards.clear', 'Clear search')}
name="times"
data-testid="scopes-dashboards-clear"
onClick={() => model.changeSearchQuery('')}
/>
) : undefined
}
onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)}
data-testid="scopes-dashboards-search"
onChange={(value) => model.changeSearchQuery(value)}
/>
</div>
@ -96,7 +106,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
text={t('scopes.suggestedDashboards.loading', 'Loading dashboards')}
data-testid="scopes-dashboards-loading"
/>
) : (
) : filteredDashboards.length > 0 ? (
<CustomScrollbar>
{filteredDashboards.map(({ dashboard, dashboardTitle }) => (
<Link
@ -109,6 +119,18 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
</Link>
))}
</CustomScrollbar>
) : (
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter">
<Trans i18nKey="scopes.suggestedDashboards.noResultsForFilter">No results found for your query</Trans>
<Button
variant="secondary"
onClick={() => model.changeSearchQuery('')}
data-testid="scopes-dashboards-notFoundForFilter-clear"
>
<Trans i18nKey="scopes.suggestedDashboards.noResultsForFilterClear">Clear search</Trans>
</Button>
</p>
)}
</>
);
@ -116,6 +138,14 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
const getStyles = (theme: GrafanaTheme2) => {
return {
noResultsContainer: css({
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
justifyContent: 'center',
textAlign: 'center',
}),
searchInputContainer: css({
flex: '0 1 auto',
}),

View File

@ -166,7 +166,19 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
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<ScopesFiltersSceneState>
this.setState({ isOpened: false });
}
private closeNodes(nodes: NodesMap): NodesMap {
return Object.entries(nodes).reduce<NodesMap>((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,

View File

@ -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', () => {

View File

@ -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 && (
<Input
prefix={<Icon name="filter" />}
className={styles.searchInput}
<FilterInput
placeholder={t('scopes.tree.search', 'Search')}
defaultValue={node.query}
value={queryValue}
className={styles.searchInput}
data-testid={`scopes-tree-${nodeId}-search`}
onInput={(evt) => onQueryUpdate(nodePath, true, evt.currentTarget.value)}
onChange={(value) => {
setQueryValue(value);
onQueryUpdate(nodePath, true, value);
}}
/>
)}
@ -99,20 +105,24 @@ export function ScopesTreeLevel({
)
) : null}
{childNode.isExpandable && (
<IconButton
name={!childNode.isExpanded ? 'angle-right' : 'angle-down'}
{childNode.isExpandable ? (
<button
className={styles.itemExpand}
data-testid={`scopes-tree-${childNode.name}-expand`}
aria-label={
childNode.isExpanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
}
data-testid={`scopes-tree-${childNode.name}-expand`}
onClick={() => {
onNodeUpdate(childNodePath, !childNode.isExpanded, childNode.query);
}}
/>
)}
>
<Icon name={!childNode.isExpanded ? 'angle-right' : 'angle-down'} />
<span data-testid={`scopes-tree-${childNode.name}-title`}>{childNode.title}</span>
{childNode.title}
</button>
) : (
<span data-testid={`scopes-tree-${childNode.name}-title`}>{childNode.title}</span>
)}
</div>
<div className={styles.itemChildren}>
@ -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),
}),

View File

@ -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<ScopeNode & { parent: string }> = [
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<HTMLInputElement>(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<HTMLInputElement>(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<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterNorth'));
export const getClustersSlothClusterSouthRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterSouth'));
export const getClustersSlothClusterEastRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterEast'));
export function buildTestScene(overrides: Partial<DashboardScene> = {}) {
return new DashboardScene({

View File

@ -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: () => {

View File

@ -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<DashboardDataDTO, 'DashboardW
class K8sDashboardAPI implements DashboardAPI {
private client: ResourceClient<DashboardDataDTO>;
constructor(private legacy: DashboardAPI) {
constructor() {
this.client = new ScopedResourceClient<DashboardDataDTO>({
group: 'dashboard.grafana.app',
version: 'v0alpha1',
@ -62,23 +68,69 @@ class K8sDashboardAPI implements DashboardAPI {
}
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> {
return this.legacy.saveDashboard(options);
const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property
const obj: ResourceForCreate<DashboardDataDTO> = {
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<DashboardDataDTO>): 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<DeleteDashboardResponse> {
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<DashboardDTO> {
const dto = await this.client.subresource<DashboardWithAccessInfo>(uid, 'dto');
const dash = await this.client.subresource<DashboardWithAccessInfo>(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;
}

View File

@ -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<ObjectMeta>;
}
export interface SaveDashboardFormProps {

View File

@ -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) {

View File

@ -12,17 +12,21 @@ export const supportedDatasources = new Set<string>([
'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<string>([
'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<string>([
'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<string>([
'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',

View File

@ -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<BackendSrvRequest, 'showErrorAlert' | 'showSuccessAlert'>
) {
return lastValueFrom(
getBackendSrv().fetch<SaveDashboardResponse>({
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) {

View File

@ -13,7 +13,7 @@ export const CallToAction = () => {
return (
<>
<Box display="flex" padding={5} gap={2} direction="column" alignItems="center" backgroundColor="secondary">
<Box display="flex" gap={2} direction="column" alignItems="center" backgroundColor="secondary">
<Text variant="h3" textAlignment="center">
<Trans i18nKey="migrate-to-cloud.cta.header">Let us manage your Grafana stack</Trans>
</Text>

View File

@ -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 (
<div className={styles.container}>
<Stack direction="column">
<CallToAction />
<Box backgroundColor="secondary" display="flex" alignItems="center" direction="column">
<Box maxWidth={180} paddingY={6} paddingX={2}>
<Stack gap={5} direction="column">
<CallToAction />
<Grid
alignItems="flex-start"
gap={1}
columns={{
xs: 1,
lg: 2,
}}
>
<InfoPaneLeft />
<InfoPaneRight />
</Grid>
</Stack>
</div>
<Grid
alignItems="flex-start"
gap={4}
columns={{
xs: 1,
lg: 2,
}}
>
<InfoPaneLeft />
<InfoPaneRight />
</Grid>
</Stack>
</Box>
</Box>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
maxWidth: theme.breakpoints.values.xl,
}),
});

View File

@ -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 (
<Box alignItems="flex-start" display="flex" padding={2} gap={2} direction="column" backgroundColor="secondary">
<Stack gap={4} direction="column">
<InfoItem
title={t('migrate-to-cloud.what-is-cloud.title', 'What is Grafana Cloud?')}
linkTitle={t('migrate-to-cloud.what-is-cloud.link-title', 'Learn about cloud features')}
@ -17,6 +17,7 @@ export const InfoPaneLeft = () => {
installation.
</Trans>
</InfoItem>
<InfoItem
title={t('migrate-to-cloud.why-host.title', 'Why host with Grafana?')}
linkTitle={t('migrate-to-cloud.why-host.link-title', 'More questions? Talk to an expert')}
@ -27,6 +28,7 @@ export const InfoPaneLeft = () => {
SLOs, incident management, machine learning, and powerful observability integrations.
</Trans>
</InfoItem>
<InfoItem
title={t('migrate-to-cloud.is-it-secure.title', 'Is it secure?')}
linkTitle={t('migrate-to-cloud.is-it-secure.link-title', 'Grafana Labs Trust Center')}
@ -38,6 +40,6 @@ export const InfoPaneLeft = () => {
unauthorized access, use, or disclosure.
</Trans>
</InfoItem>
</Box>
</Stack>
);
};

View File

@ -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 (
<Box alignItems="flex-start" display="flex" direction="column" gap={2} padding={2} backgroundColor="secondary">
<Stack gap={4} direction="column">
<InfoItem
title={t('migrate-to-cloud.pdc.title', 'Not all my data sources are on the public internet')}
linkTitle={t('migrate-to-cloud.pdc.link-title', 'Learn about PDC')}
@ -36,6 +36,6 @@ export const InfoPaneRight = () => {
dashboards.
</Trans>
</InfoItem>
</Box>
</Stack>
);
};

View File

@ -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<ObjectMeta>;
}
export interface AnnotationActions {

View File

@ -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",

View File

@ -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ę ƒįľŧęřş",

View File

@ -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"