mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Search] Use session service on a dashboard (#81297)
This commit is contained in:
parent
3ee6656837
commit
6deafd06b8
18 changed files with 202 additions and 20 deletions
|
@ -19,5 +19,6 @@ export declare type EmbeddableInput = {
|
|||
timeRange?: TimeRange;
|
||||
query?: Query;
|
||||
filters?: Filter[];
|
||||
searchSessionId?: string;
|
||||
};
|
||||
```
|
||||
|
|
|
@ -139,7 +139,7 @@ export class DashboardAppController {
|
|||
dashboardCapabilities,
|
||||
scopedHistory,
|
||||
embeddableCapabilities: { visualizeCapabilities, mapsCapabilities },
|
||||
data: { query: queryService },
|
||||
data: { query: queryService, search: searchService },
|
||||
core: {
|
||||
notifications,
|
||||
overlays,
|
||||
|
@ -412,8 +412,9 @@ export class DashboardAppController {
|
|||
>(DASHBOARD_CONTAINER_TYPE);
|
||||
|
||||
if (dashboardFactory) {
|
||||
const searchSessionId = searchService.session.start();
|
||||
dashboardFactory
|
||||
.create(getDashboardInput())
|
||||
.create({ ...getDashboardInput(), searchSessionId })
|
||||
.then((container: DashboardContainer | ErrorEmbeddable | undefined) => {
|
||||
if (container && !isErrorEmbeddable(container)) {
|
||||
dashboardContainer = container;
|
||||
|
@ -572,7 +573,7 @@ export class DashboardAppController {
|
|||
differences.filters = appStateDashboardInput.filters;
|
||||
}
|
||||
|
||||
Object.keys(_.omit(containerInput, ['filters'])).forEach((key) => {
|
||||
Object.keys(_.omit(containerInput, ['filters', 'searchSessionId'])).forEach((key) => {
|
||||
const containerValue = (containerInput as { [key: string]: unknown })[key];
|
||||
const appStateValue = ((appStateDashboardInput as unknown) as { [key: string]: unknown })[
|
||||
key
|
||||
|
@ -590,7 +591,8 @@ export class DashboardAppController {
|
|||
const refreshDashboardContainer = () => {
|
||||
const changes = getChangesFromAppStateForContainerState();
|
||||
if (changes && dashboardContainer) {
|
||||
dashboardContainer.updateInput(changes);
|
||||
const searchSessionId = searchService.session.start();
|
||||
dashboardContainer.updateInput({ ...changes, searchSessionId });
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -1109,12 +1111,6 @@ export class DashboardAppController {
|
|||
$scope.model.filters = filterManager.getFilters();
|
||||
$scope.model.query = queryStringManager.getQuery();
|
||||
dashboardStateManager.applyFilters($scope.model.query, $scope.model.filters);
|
||||
if (dashboardContainer) {
|
||||
dashboardContainer.updateInput({
|
||||
filters: $scope.model.filters,
|
||||
query: $scope.model.query,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -1159,6 +1155,7 @@ export class DashboardAppController {
|
|||
if (dashboardContainer) {
|
||||
dashboardContainer.destroy();
|
||||
}
|
||||
searchService.session.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -134,3 +134,25 @@ test('Container view mode change propagates to new children', async () => {
|
|||
|
||||
expect(embeddable.getInput().viewMode).toBe(ViewMode.EDIT);
|
||||
});
|
||||
|
||||
test('searchSessionId propagates to children', async () => {
|
||||
const searchSessionId1 = 'searchSessionId1';
|
||||
const container = new DashboardContainer(
|
||||
getSampleDashboardInput({ searchSessionId: searchSessionId1 }),
|
||||
options
|
||||
);
|
||||
const embeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Bob',
|
||||
});
|
||||
|
||||
expect(embeddable.getInput().searchSessionId).toBe(searchSessionId1);
|
||||
|
||||
const searchSessionId2 = 'searchSessionId2';
|
||||
container.updateInput({ searchSessionId: searchSessionId2 });
|
||||
|
||||
expect(embeddable.getInput().searchSessionId).toBe(searchSessionId2);
|
||||
});
|
||||
|
|
|
@ -78,6 +78,7 @@ export interface InheritedChildInput extends IndexSignature {
|
|||
viewMode: ViewMode;
|
||||
hidePanelTitles?: boolean;
|
||||
id: string;
|
||||
searchSessionId?: string;
|
||||
}
|
||||
|
||||
export interface DashboardContainerOptions {
|
||||
|
@ -228,7 +229,15 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
|
|||
}
|
||||
|
||||
protected getInheritedInput(id: string): InheritedChildInput {
|
||||
const { viewMode, refreshConfig, timeRange, query, hidePanelTitles, filters } = this.input;
|
||||
const {
|
||||
viewMode,
|
||||
refreshConfig,
|
||||
timeRange,
|
||||
query,
|
||||
hidePanelTitles,
|
||||
filters,
|
||||
searchSessionId,
|
||||
} = this.input;
|
||||
return {
|
||||
filters,
|
||||
hidePanelTitles,
|
||||
|
@ -237,6 +246,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
|
|||
refreshConfig,
|
||||
viewMode,
|
||||
id,
|
||||
searchSessionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,6 +65,7 @@ export interface RequestHandlerParams {
|
|||
metricsAtAllLevels?: boolean;
|
||||
visParams?: any;
|
||||
abortSignal?: AbortSignal;
|
||||
searchSessionId?: string;
|
||||
}
|
||||
|
||||
const name = 'esaggs';
|
||||
|
@ -82,6 +83,7 @@ const handleCourierRequest = async ({
|
|||
inspectorAdapters,
|
||||
filterManager,
|
||||
abortSignal,
|
||||
searchSessionId,
|
||||
}: RequestHandlerParams) => {
|
||||
// Create a new search source that inherits the original search source
|
||||
// but has the appropriate timeRange applied via a filter.
|
||||
|
@ -143,6 +145,7 @@ const handleCourierRequest = async ({
|
|||
defaultMessage:
|
||||
'This request queries Elasticsearch to fetch the data for the visualization.',
|
||||
}),
|
||||
searchSessionId,
|
||||
}
|
||||
);
|
||||
request.stats(getRequestInspectorStats(requestSearchSource));
|
||||
|
@ -150,6 +153,7 @@ const handleCourierRequest = async ({
|
|||
try {
|
||||
const response = await requestSearchSource.fetch({
|
||||
abortSignal,
|
||||
sessionId: searchSessionId,
|
||||
});
|
||||
|
||||
request.stats(getResponseInspectorStats(response, searchSource)).ok({ json: response });
|
||||
|
@ -248,7 +252,7 @@ export const esaggs = (): EsaggsExpressionFunctionDefinition => ({
|
|||
multi: true,
|
||||
},
|
||||
},
|
||||
async fn(input, args, { inspectorAdapters, abortSignal }) {
|
||||
async fn(input, args, { inspectorAdapters, abortSignal, getSearchSessionId }) {
|
||||
const indexPatterns = getIndexPatterns();
|
||||
const { filterManager } = getQueryService();
|
||||
const searchService = getSearchService();
|
||||
|
@ -276,6 +280,7 @@ export const esaggs = (): EsaggsExpressionFunctionDefinition => ({
|
|||
inspectorAdapters: inspectorAdapters as Adapters,
|
||||
filterManager,
|
||||
abortSignal: (abortSignal as unknown) as AbortSignal,
|
||||
searchSessionId: getSearchSessionId(),
|
||||
});
|
||||
|
||||
const table: Datatable = {
|
||||
|
|
|
@ -266,6 +266,8 @@ export class SearchEmbeddable
|
|||
}
|
||||
|
||||
private fetch = async () => {
|
||||
const searchSessionId = this.input.searchSessionId;
|
||||
|
||||
if (!this.searchScope) return;
|
||||
|
||||
const { searchSource } = this.savedSearch;
|
||||
|
@ -292,7 +294,11 @@ export class SearchEmbeddable
|
|||
const description = i18n.translate('discover.embeddable.inspectorRequestDescription', {
|
||||
defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.',
|
||||
});
|
||||
const inspectorRequest = this.inspectorAdaptors.requests.start(title, { description });
|
||||
|
||||
const inspectorRequest = this.inspectorAdaptors.requests.start(title, {
|
||||
description,
|
||||
searchSessionId,
|
||||
});
|
||||
inspectorRequest.stats(getRequestInspectorStats(searchSource));
|
||||
searchSource.getSearchRequestBody().then((body: Record<string, unknown>) => {
|
||||
inspectorRequest.json(body);
|
||||
|
@ -303,6 +309,7 @@ export class SearchEmbeddable
|
|||
// Make the request
|
||||
const resp = await searchSource.fetch({
|
||||
abortSignal: this.abortController.signal,
|
||||
sessionId: searchSessionId,
|
||||
});
|
||||
this.updateOutput({ loading: false, error: undefined });
|
||||
|
||||
|
|
|
@ -67,4 +67,9 @@ export type EmbeddableInput = {
|
|||
* Visualization filters used to narrow down results.
|
||||
*/
|
||||
filters?: Filter[];
|
||||
|
||||
/**
|
||||
* Search session id to group searches
|
||||
*/
|
||||
searchSessionId?: string;
|
||||
};
|
||||
|
|
|
@ -25,6 +25,7 @@ import { EmbeddableOutput, EmbeddableInput } from './i_embeddable';
|
|||
import { ViewMode } from '../types';
|
||||
import { ContactCardEmbeddable } from '../test_samples/embeddables/contact_card/contact_card_embeddable';
|
||||
import { FilterableEmbeddable } from '../test_samples/embeddables/filterable_embeddable';
|
||||
import type { Filter } from '../../../../data/public';
|
||||
|
||||
class TestClass {
|
||||
constructor() {}
|
||||
|
@ -79,6 +80,20 @@ test('Embeddable reload is called if lastReloadRequest input time changes', asyn
|
|||
expect(hello.reload).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Embeddable reload is called if lastReloadRequest input time changed and new input is used', async () => {
|
||||
const hello = new FilterableEmbeddable({ id: '123', filters: [], lastReloadRequestTime: 0 });
|
||||
|
||||
const aFilter = ({} as unknown) as Filter;
|
||||
hello.reload = jest.fn(() => {
|
||||
// when reload is called embeddable already has new input
|
||||
expect(hello.getInput().filters).toEqual([aFilter]);
|
||||
});
|
||||
|
||||
hello.updateInput({ lastReloadRequestTime: 1, filters: [aFilter] });
|
||||
|
||||
expect(hello.reload).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Embeddable reload is not called if lastReloadRequest input time does not change', async () => {
|
||||
const hello = new FilterableEmbeddable({ id: '123', filters: [], lastReloadRequestTime: 1 });
|
||||
|
||||
|
|
|
@ -195,14 +195,15 @@ export abstract class Embeddable<
|
|||
|
||||
private onResetInput(newInput: TEmbeddableInput) {
|
||||
if (!isEqual(this.input, newInput)) {
|
||||
if (this.input.lastReloadRequestTime !== newInput.lastReloadRequestTime) {
|
||||
this.reload();
|
||||
}
|
||||
const oldLastReloadRequestTime = this.input.lastReloadRequestTime;
|
||||
this.input = newInput;
|
||||
this.input$.next(newInput);
|
||||
this.updateOutput({
|
||||
title: getPanelTitle(this.input, this.output),
|
||||
} as Partial<TEmbeddableOutput>);
|
||||
if (oldLastReloadRequestTime !== newInput.lastReloadRequestTime) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -425,6 +425,7 @@ export type EmbeddableInput = {
|
|||
timeRange?: TimeRange;
|
||||
query?: Query;
|
||||
filters?: Filter[];
|
||||
searchSessionId?: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EmbeddableInstanceConfiguration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
|
|
|
@ -49,6 +49,7 @@ export interface Request extends RequestParams {
|
|||
export interface RequestParams {
|
||||
id?: string;
|
||||
description?: string;
|
||||
searchSessionId?: string;
|
||||
}
|
||||
|
||||
export interface RequestStatistics {
|
||||
|
|
|
@ -153,6 +153,21 @@ export class RequestsViewComponent extends Component<InspectorViewProps, Request
|
|||
</EuiText>
|
||||
)}
|
||||
|
||||
{this.state.request && this.state.request.searchSessionId && (
|
||||
<EuiText size="xs">
|
||||
<p
|
||||
data-test-subj={'inspectorRequestSearchSessionId'}
|
||||
data-search-session-id={this.state.request.searchSessionId}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="inspector.requests.searchSessionId"
|
||||
defaultMessage="Search session id: {searchSessionId}"
|
||||
values={{ searchSessionId: this.state.request.searchSessionId }}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
)}
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
{this.state.request && <RequestDetails request={this.state.request} />}
|
||||
|
|
|
@ -374,6 +374,7 @@ export class VisualizeEmbeddable
|
|||
query: this.input.query,
|
||||
filters: this.input.filters,
|
||||
},
|
||||
searchSessionId: this.input.searchSessionId,
|
||||
uiState: this.vis.uiState,
|
||||
inspectorAdapters: this.inspectorAdapters,
|
||||
};
|
||||
|
|
|
@ -172,6 +172,7 @@ describe('embeddable', () => {
|
|||
timeRange,
|
||||
query,
|
||||
filters,
|
||||
searchSessionId: 'searchSessionId',
|
||||
});
|
||||
|
||||
expect(expressionRenderer).toHaveBeenCalledTimes(2);
|
||||
|
@ -182,7 +183,13 @@ describe('embeddable', () => {
|
|||
const query: Query = { language: 'kquery', query: '' };
|
||||
const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }];
|
||||
|
||||
const input = { savedObjectId: '123', timeRange, query, filters } as LensEmbeddableInput;
|
||||
const input = {
|
||||
savedObjectId: '123',
|
||||
timeRange,
|
||||
query,
|
||||
filters,
|
||||
searchSessionId: 'searchSessionId',
|
||||
} as LensEmbeddableInput;
|
||||
|
||||
const embeddable = new Embeddable(
|
||||
{
|
||||
|
@ -214,6 +221,8 @@ describe('embeddable', () => {
|
|||
filters,
|
||||
})
|
||||
);
|
||||
|
||||
expect(expressionRenderer.mock.calls[0][0].searchSessionId).toBe(input.searchSessionId);
|
||||
});
|
||||
|
||||
it('should merge external context with query and filters of the saved object', async () => {
|
||||
|
|
|
@ -177,6 +177,7 @@ export class Embeddable
|
|||
ExpressionRenderer={this.expressionRenderer}
|
||||
expression={this.expression || null}
|
||||
searchContext={this.getMergedSearchContext()}
|
||||
searchSessionId={this.input.searchSessionId}
|
||||
handleEvent={this.handleEvent}
|
||||
/>,
|
||||
domNode
|
||||
|
|
|
@ -19,6 +19,7 @@ export interface ExpressionWrapperProps {
|
|||
ExpressionRenderer: ReactExpressionRendererType;
|
||||
expression: string | null;
|
||||
searchContext: ExecutionContextSearch;
|
||||
searchSessionId?: string;
|
||||
handleEvent: (event: ExpressionRendererEvent) => void;
|
||||
}
|
||||
|
||||
|
@ -27,6 +28,7 @@ export function ExpressionWrapper({
|
|||
expression,
|
||||
searchContext,
|
||||
handleEvent,
|
||||
searchSessionId,
|
||||
}: ExpressionWrapperProps) {
|
||||
return (
|
||||
<I18nProvider>
|
||||
|
@ -51,6 +53,7 @@ export function ExpressionWrapper({
|
|||
padding="m"
|
||||
expression={expression}
|
||||
searchContext={searchContext}
|
||||
searchSessionId={searchSessionId}
|
||||
renderError={(errorMessage, error) => (
|
||||
<div data-test-subj="expression-renderer-error">
|
||||
<EuiFlexGroup direction="column" alignItems="center" justifyContent="center">
|
||||
|
|
|
@ -12,6 +12,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
const testSubjects = getService('testSubjects');
|
||||
const log = getService('log');
|
||||
const PageObjects = getPageObjects(['common', 'header', 'dashboard', 'visChart']);
|
||||
const dashboardPanelActions = getService('dashboardPanelActions');
|
||||
const inspector = getService('inspector');
|
||||
const queryBar = getService('queryBar');
|
||||
|
||||
describe('dashboard with async search', () => {
|
||||
before(async function () {
|
||||
|
@ -24,7 +27,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
it('not delayed should load', async () => {
|
||||
await PageObjects.common.navigateToApp('dashboard');
|
||||
await PageObjects.dashboard.gotoDashboardEditMode('Not Delayed');
|
||||
await PageObjects.dashboard.loadSavedDashboard('Not Delayed');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await testSubjects.missingOrFail('embeddableErrorLabel');
|
||||
const data = await PageObjects.visChart.getBarChartData('Sum of bytes');
|
||||
|
@ -33,7 +36,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
it('delayed should load', async () => {
|
||||
await PageObjects.common.navigateToApp('dashboard');
|
||||
await PageObjects.dashboard.gotoDashboardEditMode('Delayed 5s');
|
||||
await PageObjects.dashboard.loadSavedDashboard('Delayed 5s');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await testSubjects.missingOrFail('embeddableErrorLabel');
|
||||
const data = await PageObjects.visChart.getBarChartData('');
|
||||
|
@ -42,10 +45,47 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
it('timed out should show error', async () => {
|
||||
await PageObjects.common.navigateToApp('dashboard');
|
||||
await PageObjects.dashboard.gotoDashboardEditMode('Delayed 15s');
|
||||
await PageObjects.dashboard.loadSavedDashboard('Delayed 15s');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await testSubjects.existOrFail('embeddableErrorLabel');
|
||||
await testSubjects.existOrFail('searchTimeoutError');
|
||||
});
|
||||
|
||||
it('multiple searches are grouped and only single error popup is shown', async () => {
|
||||
await PageObjects.common.navigateToApp('dashboard');
|
||||
await PageObjects.dashboard.loadSavedDashboard('Multiple delayed');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await testSubjects.existOrFail('embeddableErrorLabel');
|
||||
// there should be two failed panels
|
||||
expect((await testSubjects.findAll('embeddableErrorLabel')).length).to.be(2);
|
||||
// but only single error toast because searches are grouped
|
||||
expect((await testSubjects.findAll('searchTimeoutError')).length).to.be(1);
|
||||
|
||||
// check that session ids are the same
|
||||
const getSearchSessionIdByPanel = async (panelTitle: string) => {
|
||||
await dashboardPanelActions.openInspectorByTitle(panelTitle);
|
||||
await inspector.openInspectorRequestsView();
|
||||
const searchSessionId = await (
|
||||
await testSubjects.find('inspectorRequestSearchSessionId')
|
||||
).getAttribute('data-search-session-id');
|
||||
await inspector.close();
|
||||
return searchSessionId;
|
||||
};
|
||||
|
||||
const panel1SessionId1 = await getSearchSessionIdByPanel('Sum of Bytes by Extension');
|
||||
const panel2SessionId1 = await getSearchSessionIdByPanel(
|
||||
'Sum of Bytes by Extension (Delayed 5s)'
|
||||
);
|
||||
expect(panel1SessionId1).to.be(panel2SessionId1);
|
||||
|
||||
await queryBar.clickQuerySubmitButton();
|
||||
|
||||
const panel1SessionId2 = await getSearchSessionIdByPanel('Sum of Bytes by Extension');
|
||||
const panel2SessionId2 = await getSearchSessionIdByPanel(
|
||||
'Sum of Bytes by Extension (Delayed 5s)'
|
||||
);
|
||||
expect(panel1SessionId2).to.be(panel2SessionId2);
|
||||
expect(panel1SessionId1).not.to.be(panel1SessionId2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -194,4 +194,52 @@
|
|||
}
|
||||
}
|
||||
|
||||
{
|
||||
"type": "doc",
|
||||
"value": {
|
||||
"id": "dashboard:a41c6790-075d-11eb-be70-0bd5e8b57d03",
|
||||
"index": ".kibana",
|
||||
"source": {
|
||||
"dashboard": {
|
||||
"description": "",
|
||||
"hits": 0,
|
||||
"kibanaSavedObjectMeta": {
|
||||
"searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"
|
||||
},
|
||||
"optionsJSON": "{\"useMargins\":true,\"hidePanelTitles\":false}",
|
||||
"panelsJSON": "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"ec585931-ce8e-43fd-aa94-a1a9612d24ba\"},\"panelIndex\":\"ec585931-ce8e-43fd-aa94-a1a9612d24ba\",\"embeddableConfig\":{},\"panelRefName\":\"panel_0\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"c7b18010-462b-4e55-a974-fdec2ae64b06\"},\"panelIndex\":\"c7b18010-462b-4e55-a974-fdec2ae64b06\",\"embeddableConfig\":{},\"panelRefName\":\"panel_1\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"e67704f7-20b7-4ade-8dee-972a9d187107\"},\"panelIndex\":\"e67704f7-20b7-4ade-8dee-972a9d187107\",\"embeddableConfig\":{},\"panelRefName\":\"panel_2\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":24,\"y\":15,\"w\":24,\"h\":15,\"i\":\"f0b03592-10f1-41cd-9929-0cb4163bcd16\"},\"panelIndex\":\"f0b03592-10f1-41cd-9929-0cb4163bcd16\",\"embeddableConfig\":{},\"panelRefName\":\"panel_3\"}]",
|
||||
"refreshInterval": { "pause": true, "value": 0 },
|
||||
"timeFrom": "2015-09-19T17:34:10.297Z",
|
||||
"timeRestore": true,
|
||||
"timeTo": "2015-09-23T00:09:17.180Z",
|
||||
"title": "Multiple delayed",
|
||||
"version": 1
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"id": "14501a50-01e3-11eb-9b63-176d7b28a352",
|
||||
"name": "panel_0",
|
||||
"type": "visualization"
|
||||
},
|
||||
{
|
||||
"id": "50a67010-075d-11eb-be70-0bd5e8b57d02",
|
||||
"name": "panel_1",
|
||||
"type": "visualization"
|
||||
},
|
||||
{
|
||||
"id": "6c9f3830-01e3-11eb-9b63-176d7b28a352",
|
||||
"name": "panel_2",
|
||||
"type": "visualization"
|
||||
},
|
||||
{
|
||||
"id": "50a67010-075d-11eb-be70-0bd5e8b57d02",
|
||||
"name": "panel_3",
|
||||
"type": "visualization"
|
||||
}
|
||||
],
|
||||
"type": "dashboard",
|
||||
"updated_at": "2020-03-19T11:59:53.701Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue