mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Export: Remove DS input when dashboard is imported with a lib panel that already exists (#69412)
This commit is contained in:
parent
649cd08a19
commit
427714f8d0
@ -2731,10 +2731,10 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "5"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "7"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "6"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
||||
@ -2746,8 +2746,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "16"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "17"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "18"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "19"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "20"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "19"]
|
||||
],
|
||||
"public/app/features/manage-dashboards/state/reducers.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
|
@ -56,7 +56,7 @@ func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) respon
|
||||
cmd.FolderID = folder.ID
|
||||
}
|
||||
}
|
||||
element, err := l.createLibraryElement(c.Req.Context(), c.SignedInUser, cmd)
|
||||
element, err := l.CreateElement(c.Req.Context(), c.SignedInUser, cmd)
|
||||
if err != nil {
|
||||
return toLibraryElementError(err, "Failed to create library element")
|
||||
}
|
||||
|
@ -120,12 +120,22 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn
|
||||
return model.LibraryElementDTO{}, model.ErrLibraryElementUIDTooLong
|
||||
}
|
||||
}
|
||||
|
||||
updatedModel := cmd.Model
|
||||
var err error
|
||||
if cmd.Kind == int64(model.PanelElement) {
|
||||
updatedModel, err = l.addUidToLibraryPanel(cmd.Model, createUID)
|
||||
if err != nil {
|
||||
return model.LibraryElementDTO{}, err
|
||||
}
|
||||
}
|
||||
|
||||
element := model.LibraryElement{
|
||||
OrgID: signedInUser.OrgID,
|
||||
FolderID: cmd.FolderID,
|
||||
UID: createUID,
|
||||
Name: cmd.Name,
|
||||
Model: cmd.Model,
|
||||
Model: updatedModel,
|
||||
Version: 1,
|
||||
Kind: cmd.Kind,
|
||||
|
||||
@ -140,7 +150,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn
|
||||
return model.LibraryElementDTO{}, err
|
||||
}
|
||||
|
||||
err := l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
|
||||
err = l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
|
||||
if err := l.requireEditPermissionsOnFolder(c, signedInUser, cmd.FolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -228,7 +238,7 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn
|
||||
}
|
||||
|
||||
// getLibraryElements gets a Library Element where param == value
|
||||
func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signedInUser *user.SignedInUser, params []Pair, features featuremgmt.FeatureToggles, cmd model.GetLibraryElementCommand) ([]model.LibraryElementDTO, error) {
|
||||
func (l *LibraryElementService) getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signedInUser *user.SignedInUser, params []Pair, features featuremgmt.FeatureToggles, cmd model.GetLibraryElementCommand) ([]model.LibraryElementDTO, error) {
|
||||
libraryElements := make([]model.LibraryElementWithMeta, 0)
|
||||
|
||||
recursiveQueriesAreSupported, err := store.RecursiveQueriesAreSupported()
|
||||
@ -267,6 +277,14 @@ func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signed
|
||||
|
||||
leDtos := make([]model.LibraryElementDTO, len(libraryElements))
|
||||
for i, libraryElement := range libraryElements {
|
||||
var updatedModel json.RawMessage
|
||||
if libraryElement.Kind == int64(model.PanelElement) {
|
||||
updatedModel, err = l.addUidToLibraryPanel(libraryElement.Model, libraryElement.UID)
|
||||
if err != nil {
|
||||
return []model.LibraryElementDTO{}, err
|
||||
}
|
||||
}
|
||||
|
||||
leDtos[i] = model.LibraryElementDTO{
|
||||
ID: libraryElement.ID,
|
||||
OrgID: libraryElement.OrgID,
|
||||
@ -277,7 +295,7 @@ func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signed
|
||||
Kind: libraryElement.Kind,
|
||||
Type: libraryElement.Type,
|
||||
Description: libraryElement.Description,
|
||||
Model: libraryElement.Model,
|
||||
Model: updatedModel,
|
||||
Version: libraryElement.Version,
|
||||
Meta: model.LibraryElementDTOMeta{
|
||||
FolderName: libraryElement.FolderName,
|
||||
@ -304,7 +322,7 @@ func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signed
|
||||
|
||||
// getLibraryElementByUid gets a Library Element by uid.
|
||||
func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signedInUser *user.SignedInUser, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) {
|
||||
libraryElements, err := getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{key: "org_id", value: signedInUser.OrgID}, {key: "uid", value: cmd.UID}}, l.features, cmd)
|
||||
libraryElements, err := l.getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{key: "org_id", value: signedInUser.OrgID}, {key: "uid", value: cmd.UID}}, l.features, cmd)
|
||||
if err != nil {
|
||||
return model.LibraryElementDTO{}, err
|
||||
}
|
||||
@ -317,7 +335,7 @@ func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signed
|
||||
|
||||
// getLibraryElementByName gets a Library Element by name.
|
||||
func (l *LibraryElementService) getLibraryElementsByName(c context.Context, signedInUser *user.SignedInUser, name string) ([]model.LibraryElementDTO, error) {
|
||||
return getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{"org_id", signedInUser.OrgID}, {"name", name}}, l.features,
|
||||
return l.getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{"org_id", signedInUser.OrgID}, {"name", name}}, l.features,
|
||||
model.GetLibraryElementCommand{
|
||||
FolderName: dashboards.RootFolderName,
|
||||
})
|
||||
|
@ -2,6 +2,7 @@ package libraryelements
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
@ -75,3 +76,24 @@ func (l *LibraryElementService) DisconnectElementsFromDashboard(c context.Contex
|
||||
func (l *LibraryElementService) DeleteLibraryElementsInFolder(c context.Context, signedInUser *user.SignedInUser, folderUID string) error {
|
||||
return l.deleteLibraryElementsInFolderUID(c, signedInUser, folderUID)
|
||||
}
|
||||
|
||||
func (l *LibraryElementService) addUidToLibraryPanel(model []byte, newUid string) (json.RawMessage, error) {
|
||||
var modelMap map[string]interface{}
|
||||
err := json.Unmarshal(model, &modelMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if libraryPanel, ok := modelMap["libraryPanel"].(map[string]interface{}); ok {
|
||||
if uid, ok := libraryPanel["uid"]; ok && uid == "" {
|
||||
libraryPanel["uid"] = newUid
|
||||
}
|
||||
}
|
||||
|
||||
updatedModel, err := json.Marshal(modelMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedModel, nil
|
||||
}
|
||||
|
@ -164,8 +164,8 @@ it('replaces datasource ref in library panel', async () => {
|
||||
if ('error' in exported) {
|
||||
throw new Error('error should not be returned when making exportable json');
|
||||
}
|
||||
expect(exported.__elements['c46a6b49-de40-43b3-982c-1b5e1ec084a4'].model.datasource.uid).toBe('${DS_GFDB}');
|
||||
expect(exported.__inputs[0].name).toBe('DS_GFDB');
|
||||
expect(exported.__elements!['c46a6b49-de40-43b3-982c-1b5e1ec084a4'].model.datasource.uid).toBe('${DS_GFDB}');
|
||||
expect(exported.__inputs![0].name).toBe('DS_GFDB');
|
||||
});
|
||||
|
||||
it('If a panel queries has no datasource prop ignore it', async () => {
|
||||
|
@ -13,12 +13,21 @@ import { VariableOption, VariableRefresh } from '../../../variables/types';
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
import { GridPos } from '../../state/PanelModel';
|
||||
|
||||
interface Input {
|
||||
export interface InputUsage {
|
||||
libraryPanels?: LibraryPanel[];
|
||||
}
|
||||
|
||||
export interface LibraryPanel {
|
||||
name: string;
|
||||
uid: string;
|
||||
}
|
||||
export interface Input {
|
||||
name: string;
|
||||
type: string;
|
||||
label: string;
|
||||
value: any;
|
||||
description: string;
|
||||
usage?: InputUsage;
|
||||
}
|
||||
|
||||
interface Requires {
|
||||
@ -30,20 +39,17 @@ interface Requires {
|
||||
};
|
||||
}
|
||||
|
||||
interface ExternalDashboard {
|
||||
__inputs: Input[];
|
||||
__elements: Record<string, LibraryElementExport>;
|
||||
__requires: Array<Requires[string]>;
|
||||
export interface ExternalDashboard {
|
||||
__inputs?: Input[];
|
||||
__elements?: Record<string, LibraryElementExport>;
|
||||
__requires?: Array<Requires[string]>;
|
||||
panels: Array<PanelModel | PanelWithExportableLibraryPanel>;
|
||||
}
|
||||
|
||||
interface PanelWithExportableLibraryPanel {
|
||||
gridPos: GridPos;
|
||||
id: number;
|
||||
libraryPanel: {
|
||||
name: string;
|
||||
uid: string;
|
||||
};
|
||||
libraryPanel: LibraryPanel;
|
||||
}
|
||||
|
||||
function isExportableLibraryPanel(p: any): p is PanelWithExportableLibraryPanel {
|
||||
@ -58,6 +64,7 @@ interface DataSources {
|
||||
type: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
usage?: InputUsage;
|
||||
};
|
||||
}
|
||||
|
||||
@ -132,7 +139,10 @@ export class DashboardExporter {
|
||||
return;
|
||||
}
|
||||
|
||||
const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase();
|
||||
const libraryPanel = obj.libraryPanel;
|
||||
const libraryPanelSuffix = !!libraryPanel ? '-for-library-panel' : '';
|
||||
let refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase() + libraryPanelSuffix.toUpperCase();
|
||||
|
||||
datasources[refName] = {
|
||||
name: refName,
|
||||
label: ds.name,
|
||||
@ -140,8 +150,18 @@ export class DashboardExporter {
|
||||
type: 'datasource',
|
||||
pluginId: ds.meta?.id,
|
||||
pluginName: ds.meta?.name,
|
||||
usage: datasources[refName]?.usage,
|
||||
};
|
||||
|
||||
if (!!libraryPanel) {
|
||||
const libPanels = datasources[refName]?.usage?.libraryPanels || [];
|
||||
libPanels.push({ name: libraryPanel.name, uid: libraryPanel.uid });
|
||||
|
||||
datasources[refName].usage = {
|
||||
libraryPanels: libPanels,
|
||||
};
|
||||
}
|
||||
|
||||
obj.datasource = { type: ds.meta.id, uid: '${' + refName + '}' };
|
||||
});
|
||||
};
|
||||
|
@ -118,6 +118,7 @@ export const ImportDashboardForm = ({
|
||||
return (
|
||||
<Field
|
||||
label={input.label}
|
||||
description={input.description}
|
||||
key={dataSourceOption}
|
||||
invalid={errors.dataSources && !!errors.dataSources[index]}
|
||||
error={errors.dataSources && errors.dataSources[index] && 'A data source is required'}
|
||||
|
@ -1,13 +1,23 @@
|
||||
import { thunkTester } from 'test/core/thunk/thunkTester';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { DataSourceInstanceSettings, ThresholdsMode } from '@grafana/data';
|
||||
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
|
||||
import { defaultDashboard, FieldColorModeId } from '@grafana/schema';
|
||||
import { getLibraryPanel } from 'app/features/library-panels/state/api';
|
||||
|
||||
import { PanelModel } from '../../dashboard/state';
|
||||
import { LibraryElementDTO } from '../../library-panels/types';
|
||||
import { DashboardJson } from '../types';
|
||||
import { validateDashboardJson } from '../utils/validation';
|
||||
|
||||
import { importDashboard } from './actions';
|
||||
import { getLibraryPanelInputs, importDashboard, processDashboard } from './actions';
|
||||
import { DataSourceInput, ImportDashboardDTO, initialImportDashboardState, InputType } from './reducers';
|
||||
|
||||
jest.mock('app/features/library-panels/state/api');
|
||||
const mocks = {
|
||||
getLibraryPanel: jest.mocked(getLibraryPanel),
|
||||
};
|
||||
|
||||
describe('importDashboard', () => {
|
||||
it('Should send data source uid', async () => {
|
||||
const form: ImportDashboardDTO = {
|
||||
@ -107,3 +117,643 @@ describe('validateDashboardJson', () => {
|
||||
expect(validateDashboardJsonNotValid).toBe('Not valid JSON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processDashboard', () => {
|
||||
const panel = new PanelModel({
|
||||
datasource: {
|
||||
type: 'mysql',
|
||||
uid: '${DS_GDEV-MYSQL}',
|
||||
},
|
||||
});
|
||||
|
||||
const panelWithLibPanel = {
|
||||
gridPos: {
|
||||
h: 8,
|
||||
w: 12,
|
||||
x: 0,
|
||||
y: 8,
|
||||
},
|
||||
id: 3,
|
||||
libraryPanel: {
|
||||
uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90',
|
||||
name: 'another prom lib panel',
|
||||
},
|
||||
};
|
||||
const libPanel = {
|
||||
'a0379b21-fa20-4313-bf12-d7fd7ceb6f90': {
|
||||
name: 'another prom lib panel',
|
||||
uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90',
|
||||
kind: 1,
|
||||
model: {
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: '${DS_GDEV-PROMETHEUS-FOR-LIBRARY-PANEL}',
|
||||
},
|
||||
description: '',
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
color: {
|
||||
mode: 'palette-classic',
|
||||
},
|
||||
custom: {
|
||||
axisCenteredZero: false,
|
||||
axisColorMode: 'text',
|
||||
axisLabel: '',
|
||||
axisPlacement: 'auto',
|
||||
barAlignment: 0,
|
||||
drawStyle: 'line',
|
||||
fillOpacity: 0,
|
||||
gradientMode: 'none',
|
||||
hideFrom: {
|
||||
legend: false,
|
||||
tooltip: false,
|
||||
viz: false,
|
||||
},
|
||||
lineInterpolation: 'linear',
|
||||
lineWidth: 1,
|
||||
pointSize: 5,
|
||||
scaleDistribution: {
|
||||
type: 'linear',
|
||||
},
|
||||
showPoints: 'auto',
|
||||
spanNulls: false,
|
||||
stacking: {
|
||||
group: 'A',
|
||||
mode: 'none',
|
||||
},
|
||||
thresholdsStyle: {
|
||||
mode: 'off',
|
||||
},
|
||||
},
|
||||
mappings: [],
|
||||
thresholds: {
|
||||
mode: 'absolute',
|
||||
steps: [
|
||||
{
|
||||
color: 'green',
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
color: 'red',
|
||||
value: 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
libraryPanel: {
|
||||
name: 'another prom lib panel',
|
||||
uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90',
|
||||
},
|
||||
options: {
|
||||
legend: {
|
||||
calcs: [],
|
||||
displayMode: 'list',
|
||||
placement: 'bottom',
|
||||
showLegend: true,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'single',
|
||||
sort: 'none',
|
||||
},
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'gdev-prometheus',
|
||||
},
|
||||
editorMode: 'builder',
|
||||
expr: 'access_evaluation_duration_bucket',
|
||||
instant: false,
|
||||
range: true,
|
||||
refId: 'A',
|
||||
},
|
||||
],
|
||||
title: 'Panel Title',
|
||||
type: 'timeseries',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const panelWithSecondLibPanel = {
|
||||
gridPos: {
|
||||
h: 8,
|
||||
w: 12,
|
||||
x: 0,
|
||||
y: 16,
|
||||
},
|
||||
id: 1,
|
||||
libraryPanel: {
|
||||
uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4',
|
||||
name: 'Testing lib panel',
|
||||
},
|
||||
};
|
||||
const secondLibPanel = {
|
||||
'c46a6b49-de40-43b3-982c-1b5e1ec084a4': {
|
||||
name: 'Testing lib panel',
|
||||
uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4',
|
||||
kind: 1,
|
||||
model: {
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: '${DS_GDEV-PROMETHEUS-FOR-LIBRARY-PANEL}',
|
||||
},
|
||||
description: '',
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
color: {
|
||||
mode: 'palette-classic',
|
||||
},
|
||||
custom: {
|
||||
axisCenteredZero: false,
|
||||
axisColorMode: 'text',
|
||||
axisLabel: '',
|
||||
axisPlacement: 'auto',
|
||||
barAlignment: 0,
|
||||
drawStyle: 'line',
|
||||
fillOpacity: 0,
|
||||
gradientMode: 'none',
|
||||
hideFrom: {
|
||||
legend: false,
|
||||
tooltip: false,
|
||||
viz: false,
|
||||
},
|
||||
lineInterpolation: 'linear',
|
||||
lineWidth: 1,
|
||||
pointSize: 5,
|
||||
scaleDistribution: {
|
||||
type: 'linear',
|
||||
},
|
||||
showPoints: 'auto',
|
||||
spanNulls: false,
|
||||
stacking: {
|
||||
group: 'A',
|
||||
mode: 'none',
|
||||
},
|
||||
thresholdsStyle: {
|
||||
mode: 'off',
|
||||
},
|
||||
},
|
||||
mappings: [],
|
||||
thresholds: {
|
||||
mode: 'absolute',
|
||||
steps: [
|
||||
{
|
||||
color: 'green',
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
color: 'red',
|
||||
value: 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
libraryPanel: {
|
||||
name: 'Testing lib panel',
|
||||
uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4',
|
||||
},
|
||||
options: {
|
||||
legend: {
|
||||
calcs: [],
|
||||
displayMode: 'list',
|
||||
placement: 'bottom',
|
||||
showLegend: true,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'single',
|
||||
sort: 'none',
|
||||
},
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'gdev-prometheus',
|
||||
},
|
||||
editorMode: 'builder',
|
||||
expr: 'access_evaluation_duration_count',
|
||||
instant: false,
|
||||
range: true,
|
||||
refId: 'A',
|
||||
},
|
||||
],
|
||||
title: 'Panel Title',
|
||||
type: 'timeseries',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const importedJson: DashboardJson = {
|
||||
...defaultDashboard,
|
||||
__inputs: [
|
||||
{
|
||||
name: 'DS_GDEV-MYSQL',
|
||||
label: 'gdev-mysql',
|
||||
description: '',
|
||||
type: 'datasource',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
name: 'DS_GDEV-PROMETHEUS-FOR-LIBRARY-PANEL',
|
||||
label: 'gdev-prometheus',
|
||||
description: '',
|
||||
type: 'datasource',
|
||||
value: '',
|
||||
usage: {
|
||||
libraryPanels: [
|
||||
{
|
||||
name: 'another prom lib panel',
|
||||
uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
__elements: {
|
||||
...libPanel,
|
||||
},
|
||||
__requires: [
|
||||
{
|
||||
type: 'grafana',
|
||||
id: 'grafana',
|
||||
name: 'Grafana',
|
||||
version: '10.1.0-pre',
|
||||
},
|
||||
{
|
||||
type: 'datasource',
|
||||
id: 'mysql',
|
||||
name: 'MySQL',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
type: 'datasource',
|
||||
id: 'prometheus',
|
||||
name: 'Prometheus',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
type: 'panel',
|
||||
id: 'table',
|
||||
name: 'Table',
|
||||
version: '',
|
||||
},
|
||||
],
|
||||
panels: [],
|
||||
};
|
||||
|
||||
it("Should return 2 inputs, 1 for library panel because it's used for 2 panels", async () => {
|
||||
mocks.getLibraryPanel.mockImplementation(() => {
|
||||
throw { status: 404 };
|
||||
});
|
||||
const importDashboardState = initialImportDashboardState;
|
||||
const dashboardJson: DashboardJson = {
|
||||
...importedJson,
|
||||
panels: [panel, panelWithLibPanel, panelWithLibPanel],
|
||||
};
|
||||
const libPanelInputs = await getLibraryPanelInputs(dashboardJson);
|
||||
const newDashboardState = {
|
||||
...importDashboardState,
|
||||
inputs: {
|
||||
...importDashboardState.inputs,
|
||||
libraryPanels: libPanelInputs!,
|
||||
},
|
||||
};
|
||||
|
||||
const processedDashboard = processDashboard(dashboardJson, newDashboardState);
|
||||
const dsInputsForLibPanels = processedDashboard.__inputs!.filter((input) => !!input.usage?.libraryPanels);
|
||||
expect(processedDashboard.__inputs).toHaveLength(2);
|
||||
expect(dsInputsForLibPanels).toHaveLength(1);
|
||||
});
|
||||
it('Should return 3 inputs, 2 for library panels', async () => {
|
||||
mocks.getLibraryPanel.mockImplementation(() => {
|
||||
throw { status: 404 };
|
||||
});
|
||||
const importDashboardState = initialImportDashboardState;
|
||||
const dashboardJson: DashboardJson = {
|
||||
...importedJson,
|
||||
__inputs: [
|
||||
{
|
||||
name: 'DS_GDEV-MYSQL',
|
||||
label: 'gdev-mysql',
|
||||
description: '',
|
||||
type: 'datasource',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
name: 'DS_GDEV-PROMETHEUS-FOR-LIBRARY-PANEL',
|
||||
label: 'gdev-prometheus',
|
||||
description: '',
|
||||
type: 'datasource',
|
||||
value: '',
|
||||
usage: {
|
||||
libraryPanels: [
|
||||
{
|
||||
name: 'another prom lib panel',
|
||||
uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'DS_GDEV-MYSQL-FOR-LIBRARY-PANEL',
|
||||
label: 'gdev-mysql-2',
|
||||
description: '',
|
||||
type: 'datasource',
|
||||
value: '',
|
||||
usage: {
|
||||
libraryPanels: [
|
||||
{
|
||||
uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4',
|
||||
name: 'Testing lib panel',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
__elements: {
|
||||
...libPanel,
|
||||
...secondLibPanel,
|
||||
},
|
||||
panels: [panel, panelWithLibPanel, panelWithSecondLibPanel],
|
||||
};
|
||||
const libPanelInputs = await getLibraryPanelInputs(dashboardJson);
|
||||
const newDashboardState = {
|
||||
...importDashboardState,
|
||||
inputs: {
|
||||
...importDashboardState.inputs,
|
||||
libraryPanels: libPanelInputs!,
|
||||
},
|
||||
};
|
||||
|
||||
const processedDashboard = processDashboard(dashboardJson, newDashboardState);
|
||||
const dsInputsForLibPanels = processedDashboard.__inputs!.filter((input) => !!input.usage?.libraryPanels);
|
||||
expect(processedDashboard.__inputs).toHaveLength(3);
|
||||
expect(dsInputsForLibPanels).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Should return 1 input, since library panels already exist in the instance', async () => {
|
||||
const getLibPanelFirstRS: LibraryElementDTO = {
|
||||
folderUid: '',
|
||||
uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90',
|
||||
name: 'another prom lib panel',
|
||||
type: 'timeseries',
|
||||
description: '',
|
||||
model: {
|
||||
transparent: false,
|
||||
transformations: [],
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'gdev-prometheus',
|
||||
},
|
||||
description: '',
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
color: {
|
||||
mode: FieldColorModeId.PaletteClassic,
|
||||
},
|
||||
custom: {
|
||||
axisCenteredZero: false,
|
||||
axisColorMode: 'text',
|
||||
axisLabel: '',
|
||||
axisPlacement: 'auto',
|
||||
barAlignment: 0,
|
||||
drawStyle: 'line',
|
||||
fillOpacity: 0,
|
||||
gradientMode: 'none',
|
||||
hideFrom: {
|
||||
legend: false,
|
||||
tooltip: false,
|
||||
viz: false,
|
||||
},
|
||||
lineInterpolation: 'linear',
|
||||
lineWidth: 1,
|
||||
pointSize: 5,
|
||||
scaleDistribution: {
|
||||
type: 'linear',
|
||||
},
|
||||
showPoints: 'auto',
|
||||
spanNulls: false,
|
||||
stacking: {
|
||||
group: 'A',
|
||||
mode: 'none',
|
||||
},
|
||||
thresholdsStyle: {
|
||||
mode: 'off',
|
||||
},
|
||||
},
|
||||
mappings: [],
|
||||
thresholds: {
|
||||
mode: ThresholdsMode.Absolute,
|
||||
steps: [
|
||||
{
|
||||
color: 'green',
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
color: 'red',
|
||||
value: 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
options: {
|
||||
legend: {
|
||||
calcs: [],
|
||||
displayMode: 'list',
|
||||
placement: 'bottom',
|
||||
showLegend: true,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'single',
|
||||
sort: 'none',
|
||||
},
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'gdev-prometheus',
|
||||
},
|
||||
editorMode: 'builder',
|
||||
expr: 'access_evaluation_duration_bucket',
|
||||
instant: false,
|
||||
range: true,
|
||||
refId: 'A',
|
||||
},
|
||||
],
|
||||
title: 'Panel Title',
|
||||
type: 'timeseries',
|
||||
},
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const getLibPanelSecondRS: LibraryElementDTO = {
|
||||
folderUid: '',
|
||||
uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4',
|
||||
name: 'Testing lib panel',
|
||||
type: 'timeseries',
|
||||
description: '',
|
||||
model: {
|
||||
transparent: false,
|
||||
transformations: [],
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'gdev-prometheus',
|
||||
},
|
||||
description: '',
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
color: {
|
||||
mode: FieldColorModeId.PaletteClassic,
|
||||
},
|
||||
custom: {
|
||||
axisCenteredZero: false,
|
||||
axisColorMode: 'text',
|
||||
axisLabel: '',
|
||||
axisPlacement: 'auto',
|
||||
barAlignment: 0,
|
||||
drawStyle: 'line',
|
||||
fillOpacity: 0,
|
||||
gradientMode: 'none',
|
||||
hideFrom: {
|
||||
legend: false,
|
||||
tooltip: false,
|
||||
viz: false,
|
||||
},
|
||||
lineInterpolation: 'linear',
|
||||
lineWidth: 1,
|
||||
pointSize: 5,
|
||||
scaleDistribution: {
|
||||
type: 'linear',
|
||||
},
|
||||
showPoints: 'auto',
|
||||
spanNulls: false,
|
||||
stacking: {
|
||||
group: 'A',
|
||||
mode: 'none',
|
||||
},
|
||||
thresholdsStyle: {
|
||||
mode: 'off',
|
||||
},
|
||||
},
|
||||
mappings: [],
|
||||
thresholds: {
|
||||
mode: ThresholdsMode.Absolute,
|
||||
steps: [
|
||||
{
|
||||
color: 'green',
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
color: 'red',
|
||||
value: 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
options: {
|
||||
legend: {
|
||||
calcs: [],
|
||||
displayMode: 'list',
|
||||
placement: 'bottom',
|
||||
showLegend: true,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'single',
|
||||
sort: 'none',
|
||||
},
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'gdev-prometheus',
|
||||
},
|
||||
editorMode: 'builder',
|
||||
expr: 'access_evaluation_duration_count',
|
||||
instant: false,
|
||||
range: true,
|
||||
refId: 'A',
|
||||
},
|
||||
],
|
||||
title: 'Panel Title',
|
||||
type: 'timeseries',
|
||||
},
|
||||
version: 1,
|
||||
};
|
||||
mocks.getLibraryPanel
|
||||
.mockReturnValueOnce(Promise.resolve(getLibPanelFirstRS))
|
||||
.mockReturnValueOnce(Promise.resolve(getLibPanelSecondRS));
|
||||
|
||||
const importDashboardState = initialImportDashboardState;
|
||||
const dashboardJson: DashboardJson = {
|
||||
...importedJson,
|
||||
__inputs: [
|
||||
{
|
||||
name: 'DS_GDEV-MYSQL',
|
||||
label: 'gdev-mysql',
|
||||
description: '',
|
||||
type: 'datasource',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
name: 'DS_GDEV-PROMETHEUS-FOR-LIBRARY-PANEL',
|
||||
label: 'gdev-prometheus',
|
||||
description: '',
|
||||
type: 'datasource',
|
||||
value: '',
|
||||
usage: {
|
||||
libraryPanels: [
|
||||
{
|
||||
name: 'another prom lib panel',
|
||||
uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'DS_GDEV-MYSQL-FOR-LIBRARY-PANEL',
|
||||
label: 'gdev-mysql-2',
|
||||
description: '',
|
||||
type: 'datasource',
|
||||
value: '',
|
||||
usage: {
|
||||
libraryPanels: [
|
||||
{
|
||||
uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4',
|
||||
name: 'Testing lib panel',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
__elements: {
|
||||
...libPanel,
|
||||
...secondLibPanel,
|
||||
},
|
||||
panels: [panel, panelWithLibPanel, panelWithSecondLibPanel],
|
||||
};
|
||||
const libPanelInputs = await getLibraryPanelInputs(dashboardJson);
|
||||
const newDashboardState = {
|
||||
...importDashboardState,
|
||||
inputs: {
|
||||
...importDashboardState.inputs,
|
||||
libraryPanels: libPanelInputs!,
|
||||
},
|
||||
};
|
||||
|
||||
const processedDashboard = processDashboard(dashboardJson, newDashboardState);
|
||||
const dsInputsForLibPanels = processedDashboard.__inputs!.filter((input) => !!input.usage?.libraryPanels);
|
||||
expect(processedDashboard.__inputs).toHaveLength(1);
|
||||
expect(dsInputsForLibPanels).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
@ -1,22 +1,28 @@
|
||||
import { DataSourceInstanceSettings, locationUtil } from '@grafana/data';
|
||||
import { getDataSourceSrv, locationService, getBackendSrv, isFetchError } from '@grafana/runtime';
|
||||
import { getBackendSrv, getDataSourceSrv, isFetchError, locationService } from '@grafana/runtime';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { DashboardDTO, FolderInfo, PermissionLevelString, SearchQueryType, ThunkResult } from 'app/types';
|
||||
|
||||
import { LibraryElementExport } from '../../dashboard/components/DashExportModal/DashboardExporter';
|
||||
import {
|
||||
Input,
|
||||
InputUsage,
|
||||
LibraryElementExport,
|
||||
LibraryPanel,
|
||||
} from '../../dashboard/components/DashExportModal/DashboardExporter';
|
||||
import { getLibraryPanel } from '../../library-panels/state/api';
|
||||
import { LibraryElementDTO, LibraryElementKind } from '../../library-panels/types';
|
||||
import { DashboardSearchHit } from '../../search/types';
|
||||
import { DeleteDashboardResponse } from '../types';
|
||||
import { DashboardJson, DeleteDashboardResponse } from '../types';
|
||||
|
||||
import {
|
||||
clearDashboard,
|
||||
fetchDashboard,
|
||||
fetchFailed,
|
||||
ImportDashboardDTO,
|
||||
ImportDashboardState,
|
||||
InputType,
|
||||
LibraryPanelInput,
|
||||
LibraryPanelInputState,
|
||||
@ -31,9 +37,9 @@ export function fetchGcomDashboard(id: string): ThunkResult<void> {
|
||||
try {
|
||||
dispatch(fetchDashboard());
|
||||
const dashboard = await getBackendSrv().get(`/api/gnet/dashboards/${id}`);
|
||||
dispatch(setGcomDashboard(dashboard));
|
||||
dispatch(processInputs(dashboard.json));
|
||||
dispatch(processElements(dashboard.json));
|
||||
await dispatch(processElements(dashboard.json));
|
||||
await dispatch(processGcomDashboard(dashboard));
|
||||
dispatch(processInputs());
|
||||
} catch (error) {
|
||||
dispatch(fetchFailed());
|
||||
if (isFetchError(error)) {
|
||||
@ -45,17 +51,66 @@ export function fetchGcomDashboard(id: string): ThunkResult<void> {
|
||||
|
||||
export function importDashboardJson(dashboard: any): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
dispatch(setJsonDashboard(dashboard));
|
||||
dispatch(processInputs(dashboard));
|
||||
dispatch(processElements(dashboard));
|
||||
await dispatch(processElements(dashboard));
|
||||
await dispatch(processJsonDashboard(dashboard));
|
||||
dispatch(processInputs());
|
||||
};
|
||||
}
|
||||
|
||||
function processInputs(dashboardJson: any): ThunkResult<void> {
|
||||
return (dispatch) => {
|
||||
if (dashboardJson && dashboardJson.__inputs) {
|
||||
const getNewLibraryPanelsByInput = (input: Input, state: ImportDashboardState): LibraryPanel[] | undefined => {
|
||||
return input?.usage?.libraryPanels?.filter((usageLibPanel) =>
|
||||
state.inputs.libraryPanels.some(
|
||||
(libPanel) => libPanel.state !== LibraryPanelInputState.Exists && libPanel.model.uid === usageLibPanel.uid
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export function processDashboard(dashboardJson: DashboardJson, state: ImportDashboardState): DashboardJson {
|
||||
let inputs = dashboardJson.__inputs;
|
||||
if (!!state.inputs.libraryPanels?.length) {
|
||||
const filteredUsedInputs: Input[] = [];
|
||||
dashboardJson.__inputs?.forEach((input: Input) => {
|
||||
if (!input?.usage?.libraryPanels) {
|
||||
filteredUsedInputs.push(input);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLibraryPanels = getNewLibraryPanelsByInput(input, state);
|
||||
input.usage = { libraryPanels: newLibraryPanels };
|
||||
|
||||
const isInputBeingUsedByANewLibraryPanel = !!newLibraryPanels?.length;
|
||||
if (isInputBeingUsedByANewLibraryPanel) {
|
||||
filteredUsedInputs.push(input);
|
||||
}
|
||||
});
|
||||
inputs = filteredUsedInputs;
|
||||
}
|
||||
|
||||
return { ...dashboardJson, __inputs: inputs };
|
||||
}
|
||||
|
||||
function processGcomDashboard(dashboard: { json: DashboardJson }): ThunkResult<void> {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState().importDashboard;
|
||||
const dashboardJson = processDashboard(dashboard.json, state);
|
||||
dispatch(setGcomDashboard({ ...dashboard, json: dashboardJson }));
|
||||
};
|
||||
}
|
||||
|
||||
function processJsonDashboard(dashboardJson: DashboardJson): ThunkResult<void> {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState().importDashboard;
|
||||
const dashboard = processDashboard(dashboardJson, state);
|
||||
dispatch(setJsonDashboard(dashboard));
|
||||
};
|
||||
}
|
||||
|
||||
function processInputs(): ThunkResult<void> {
|
||||
return (dispatch, getState) => {
|
||||
const dashboard = getState().importDashboard.dashboard;
|
||||
if (dashboard && dashboard.__inputs) {
|
||||
const inputs: any[] = [];
|
||||
dashboardJson.__inputs.forEach((input: any) => {
|
||||
dashboard.__inputs.forEach((input: any) => {
|
||||
const inputModel: any = {
|
||||
name: input.name,
|
||||
label: input.label,
|
||||
@ -66,6 +121,8 @@ function processInputs(dashboardJson: any): ThunkResult<void> {
|
||||
options: [],
|
||||
};
|
||||
|
||||
inputModel.description = getDataSourceDescription(input);
|
||||
|
||||
if (input.type === InputType.DataSource) {
|
||||
getDataSourceOptions(input, inputModel);
|
||||
} else if (!inputModel.info) {
|
||||
@ -81,50 +138,57 @@ function processInputs(dashboardJson: any): ThunkResult<void> {
|
||||
|
||||
function processElements(dashboardJson?: { __elements?: Record<string, LibraryElementExport> }): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
if (!dashboardJson || !dashboardJson.__elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
const libraryPanelInputs: LibraryPanelInput[] = [];
|
||||
|
||||
for (const element of Object.values(dashboardJson.__elements)) {
|
||||
if (element.kind !== LibraryElementKind.Panel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = element.model;
|
||||
const { type, description } = model;
|
||||
const { uid, name } = element;
|
||||
const input: LibraryPanelInput = {
|
||||
model: {
|
||||
model,
|
||||
uid,
|
||||
name,
|
||||
version: 0,
|
||||
type,
|
||||
kind: LibraryElementKind.Panel,
|
||||
description,
|
||||
} as LibraryElementDTO,
|
||||
state: LibraryPanelInputState.New,
|
||||
};
|
||||
|
||||
try {
|
||||
const panelInDb = await getLibraryPanel(uid, true);
|
||||
input.state = LibraryPanelInputState.Exists;
|
||||
input.model = panelInDb;
|
||||
} catch (e: any) {
|
||||
if (e.status !== 404) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
libraryPanelInputs.push(input);
|
||||
}
|
||||
|
||||
const libraryPanelInputs = await getLibraryPanelInputs(dashboardJson);
|
||||
dispatch(setLibraryPanelInputs(libraryPanelInputs));
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLibraryPanelInputs(dashboardJson?: {
|
||||
__elements?: Record<string, LibraryElementExport>;
|
||||
}): Promise<LibraryPanelInput[]> {
|
||||
if (!dashboardJson || !dashboardJson.__elements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const libraryPanelInputs: LibraryPanelInput[] = [];
|
||||
|
||||
for (const element of Object.values(dashboardJson.__elements)) {
|
||||
if (element.kind !== LibraryElementKind.Panel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = element.model;
|
||||
const { type, description } = model;
|
||||
const { uid, name } = element;
|
||||
const input: LibraryPanelInput = {
|
||||
model: {
|
||||
model,
|
||||
uid,
|
||||
name,
|
||||
version: 0,
|
||||
type,
|
||||
kind: LibraryElementKind.Panel,
|
||||
description,
|
||||
} as LibraryElementDTO,
|
||||
state: LibraryPanelInputState.New,
|
||||
};
|
||||
|
||||
try {
|
||||
const panelInDb = await getLibraryPanel(uid, true);
|
||||
input.state = LibraryPanelInputState.Exists;
|
||||
input.model = panelInDb;
|
||||
} catch (e: any) {
|
||||
if (e.status !== 404) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
libraryPanelInputs.push(input);
|
||||
}
|
||||
|
||||
return libraryPanelInputs;
|
||||
}
|
||||
|
||||
export function clearLoadedDashboard(): ThunkResult<void> {
|
||||
return (dispatch) => {
|
||||
dispatch(clearDashboard());
|
||||
@ -182,6 +246,22 @@ const getDataSourceOptions = (input: { pluginId: string; pluginName: string }, i
|
||||
}
|
||||
};
|
||||
|
||||
const getDataSourceDescription = (input: { usage?: InputUsage }): string | undefined => {
|
||||
if (!input.usage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (input.usage.libraryPanels) {
|
||||
const libPanelNames = input.usage.libraryPanels.reduce(
|
||||
(acc: string, libPanel, index) => (index === 0 ? libPanel.name : `${acc}, ${libPanel.name}`),
|
||||
''
|
||||
);
|
||||
return `List of affected library panels: ${libPanelNames}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export async function moveFolders(folderUIDs: string[], toFolder: FolderInfo) {
|
||||
const result = {
|
||||
totalCount: folderUIDs.length,
|
||||
|
@ -34,6 +34,7 @@ export enum LibraryPanelInputState {
|
||||
export interface DashboardInput {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
info: string;
|
||||
value: string;
|
||||
type: InputType;
|
||||
@ -100,7 +101,7 @@ const importDashboardSlice = createSlice({
|
||||
state.inputs = {
|
||||
dataSources: action.payload.filter((p) => p.type === InputType.DataSource),
|
||||
constants: action.payload.filter((p) => p.type === InputType.Constant),
|
||||
libraryPanels: [],
|
||||
libraryPanels: state.inputs.libraryPanels || [],
|
||||
};
|
||||
},
|
||||
setLibraryPanelInputs: (state: Draft<ImportDashboardState>, action: PayloadAction<LibraryPanelInput[]>) => {
|
||||
|
@ -1,3 +1,7 @@
|
||||
import { Dashboard } from '@grafana/schema/src/veneer/dashboard.types';
|
||||
|
||||
import { ExternalDashboard } from '../dashboard/components/DashExportModal/DashboardExporter';
|
||||
|
||||
export interface Snapshot {
|
||||
created: string;
|
||||
expires: string;
|
||||
@ -36,3 +40,5 @@ export interface PublicDashboardListResponse {
|
||||
export interface PublicDashboardListWithPagination extends PublicDashboardListWithPaginationResponse {
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type DashboardJson = ExternalDashboard & Omit<Dashboard, 'panels'>;
|
||||
|
Loading…
Reference in New Issue
Block a user