mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Elastic: Add data links in datasource config (#20186)
This commit is contained in:
@@ -202,6 +202,7 @@ export function dataFrameToLogsModel(dataFrame: DataFrame[], intervalMs: number,
|
||||
// Create metrics from logs
|
||||
logsModel.series = makeSeriesForLogs(logsModel.rows, intervalMs, timeZone);
|
||||
} else {
|
||||
// We got metrics in the dataFrame so process those
|
||||
logsModel.series = getGraphSeriesModel(
|
||||
metricSeries,
|
||||
timeZone,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
|
||||
import { ElasticsearchOptions } from '../types';
|
||||
import { defaultMaxConcurrentShardRequests, ElasticDetails } from './ElasticDetails';
|
||||
import { LogsConfig } from './LogsConfig';
|
||||
import { DataLinks } from './DataLinks';
|
||||
|
||||
export type Props = DataSourcePluginOptionsEditorProps<ElasticsearchOptions>;
|
||||
export const ConfigEditor = (props: Props) => {
|
||||
@@ -46,6 +47,19 @@ export const ConfigEditor = (props: Props) => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<DataLinks
|
||||
value={options.jsonData.dataLinks}
|
||||
onChange={newValue => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
jsonData: {
|
||||
...options.jsonData,
|
||||
dataLinks: newValue,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { Button, FormField, VariableSuggestion, DataLinkInput, stylesFactory } from '@grafana/ui';
|
||||
import { DataLinkConfig } from '../types';
|
||||
|
||||
const getStyles = stylesFactory(() => ({
|
||||
firstRow: css`
|
||||
display: flex;
|
||||
`,
|
||||
nameField: css`
|
||||
flex: 2;
|
||||
`,
|
||||
regexField: css`
|
||||
flex: 3;
|
||||
`,
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
value: DataLinkConfig;
|
||||
onChange: (value: DataLinkConfig) => void;
|
||||
onDelete: () => void;
|
||||
suggestions: VariableSuggestion[];
|
||||
className?: string;
|
||||
};
|
||||
export const DataLink = (props: Props) => {
|
||||
const { value, onChange, onDelete, suggestions, className } = props;
|
||||
const styles = getStyles();
|
||||
|
||||
const handleChange = (field: keyof typeof value) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
...value,
|
||||
[field]: event.currentTarget.value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={styles.firstRow}>
|
||||
<FormField
|
||||
className={styles.nameField}
|
||||
labelWidth={6}
|
||||
// A bit of a hack to prevent using default value for the width from FormField
|
||||
inputWidth={null}
|
||||
label="Field"
|
||||
type="text"
|
||||
value={value.field}
|
||||
tooltip={'Can be exact field name or a regex pattern that will match on the field name.'}
|
||||
onChange={handleChange('field')}
|
||||
/>
|
||||
<Button
|
||||
variant={'inverse'}
|
||||
title="Remove field"
|
||||
icon={'fa fa-times'}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
onDelete();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="URL"
|
||||
labelWidth={6}
|
||||
inputEl={
|
||||
<DataLinkInput
|
||||
placeholder={'http://example.com/${__value.raw}'}
|
||||
value={value.url || ''}
|
||||
onChange={newValue =>
|
||||
onChange({
|
||||
...value,
|
||||
url: newValue,
|
||||
})
|
||||
}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
}
|
||||
className={css`
|
||||
width: 100%;
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { DataLinks } from './DataLinks';
|
||||
import { Button } from '@grafana/ui';
|
||||
import { DataLink } from './DataLink';
|
||||
|
||||
describe('DataLinks', () => {
|
||||
let originalGetSelection: typeof window.getSelection;
|
||||
beforeAll(() => {
|
||||
originalGetSelection = window.getSelection;
|
||||
window.getSelection = () => null;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
window.getSelection = originalGetSelection;
|
||||
});
|
||||
|
||||
it('renders correctly when no fields', () => {
|
||||
const wrapper = mount(<DataLinks onChange={() => {}} />);
|
||||
expect(wrapper.find(Button).length).toBe(1);
|
||||
expect(wrapper.find(Button).contains('Add')).toBeTruthy();
|
||||
expect(wrapper.find(DataLink).length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders correctly when there are fields', () => {
|
||||
const wrapper = mount(<DataLinks value={testValue} onChange={() => {}} />);
|
||||
|
||||
expect(wrapper.find(Button).filterWhere(button => button.contains('Add')).length).toBe(1);
|
||||
expect(wrapper.find(DataLink).length).toBe(2);
|
||||
});
|
||||
|
||||
it('adds new field', () => {
|
||||
const onChangeMock = jest.fn();
|
||||
const wrapper = mount(<DataLinks onChange={onChangeMock} />);
|
||||
const addButton = wrapper.find(Button).filterWhere(button => button.contains('Add'));
|
||||
addButton.simulate('click');
|
||||
expect(onChangeMock.mock.calls[0][0].length).toBe(1);
|
||||
});
|
||||
|
||||
it('removes field', () => {
|
||||
const onChangeMock = jest.fn();
|
||||
const wrapper = mount(<DataLinks value={testValue} onChange={onChangeMock} />);
|
||||
const removeButton = wrapper
|
||||
.find(DataLink)
|
||||
.at(0)
|
||||
.find(Button);
|
||||
removeButton.simulate('click');
|
||||
const newValue = onChangeMock.mock.calls[0][0];
|
||||
expect(newValue.length).toBe(1);
|
||||
expect(newValue[0]).toMatchObject({
|
||||
field: 'regex2',
|
||||
url: 'localhost2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const testValue = [
|
||||
{
|
||||
field: 'regex1',
|
||||
url: 'localhost1',
|
||||
},
|
||||
{
|
||||
field: 'regex2',
|
||||
url: 'localhost2',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { Button, DataLinkBuiltInVars, stylesFactory, useTheme, VariableOrigin } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { DataLinkConfig } from '../types';
|
||||
import { DataLink } from './DataLink';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
infoText: css`
|
||||
padding-bottom: ${theme.spacing.md};
|
||||
color: ${theme.colors.textWeak};
|
||||
`,
|
||||
dataLink: css`
|
||||
margin-bottom: ${theme.spacing.sm};
|
||||
`,
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
value?: DataLinkConfig[];
|
||||
onChange: (value: DataLinkConfig[]) => void;
|
||||
};
|
||||
export const DataLinks = (props: Props) => {
|
||||
const { value, onChange } = props;
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="page-heading">Data links</h3>
|
||||
|
||||
<div className={styles.infoText}>
|
||||
Add links to existing fields. Links will be shown in log row details next to the field value.
|
||||
</div>
|
||||
|
||||
<div className="gf-form-group">
|
||||
{value &&
|
||||
value.map((field, index) => {
|
||||
return (
|
||||
<DataLink
|
||||
className={styles.dataLink}
|
||||
key={index}
|
||||
value={field}
|
||||
onChange={newField => {
|
||||
const newDataLinks = [...value];
|
||||
newDataLinks.splice(index, 1, newField);
|
||||
onChange(newDataLinks);
|
||||
}}
|
||||
onDelete={() => {
|
||||
const newDataLinks = [...value];
|
||||
newDataLinks.splice(index, 1);
|
||||
onChange(newDataLinks);
|
||||
}}
|
||||
suggestions={[
|
||||
{
|
||||
value: DataLinkBuiltInVars.valueRaw,
|
||||
label: 'Raw value',
|
||||
documentation: 'Raw value of the field',
|
||||
origin: VariableOrigin.Value,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button
|
||||
variant={'inverse'}
|
||||
className={css`
|
||||
margin-right: 10px;
|
||||
`}
|
||||
icon="fa fa-plus"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
const newDataLinks = [...(value || []), { field: '', url: '' }];
|
||||
onChange(newDataLinks);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import angular from 'angular';
|
||||
import { dateMath } from '@grafana/data';
|
||||
import { dateMath, Field } from '@grafana/data';
|
||||
import _ from 'lodash';
|
||||
import { ElasticDatasource } from '../datasource';
|
||||
import { ElasticDatasource } from './datasource';
|
||||
import { toUtc, dateTime } from '@grafana/data';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { ElasticsearchOptions } from '../types';
|
||||
import { ElasticsearchOptions } from './types';
|
||||
|
||||
describe('ElasticDatasource', function(this: any) {
|
||||
const backendSrv: any = {
|
||||
@@ -153,73 +153,23 @@ describe('ElasticDatasource', function(this: any) {
|
||||
});
|
||||
|
||||
describe('When issuing logs query with interval pattern', () => {
|
||||
let query, queryBuilderSpy: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
async function setupDataSource(jsonData?: Partial<ElasticsearchOptions>) {
|
||||
createDatasource({
|
||||
url: 'http://es.com',
|
||||
database: 'mock-index',
|
||||
jsonData: { interval: 'Daily', esVersion: 2, timeField: '@timestamp' } as ElasticsearchOptions,
|
||||
jsonData: {
|
||||
interval: 'Daily',
|
||||
esVersion: 2,
|
||||
timeField: '@timestamp',
|
||||
...(jsonData || {}),
|
||||
} as ElasticsearchOptions,
|
||||
} as DataSourceInstanceSettings<ElasticsearchOptions>);
|
||||
|
||||
ctx.backendSrv.datasourceRequest = jest.fn(options => {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
responses: [
|
||||
{
|
||||
aggregations: {
|
||||
'2': {
|
||||
buckets: [
|
||||
{
|
||||
doc_count: 10,
|
||||
key: 1000,
|
||||
},
|
||||
{
|
||||
doc_count: 15,
|
||||
key: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
'@timestamp': ['2019-06-24T09:51:19.765Z'],
|
||||
_id: 'fdsfs',
|
||||
_type: '_doc',
|
||||
_index: 'mock-index',
|
||||
_source: {
|
||||
'@timestamp': '2019-06-24T09:51:19.765Z',
|
||||
host: 'djisaodjsoad',
|
||||
message: 'hello, i am a message',
|
||||
},
|
||||
fields: {
|
||||
'@timestamp': ['2019-06-24T09:51:19.765Z'],
|
||||
},
|
||||
},
|
||||
{
|
||||
'@timestamp': ['2019-06-24T09:52:19.765Z'],
|
||||
_id: 'kdospaidopa',
|
||||
_type: '_doc',
|
||||
_index: 'mock-index',
|
||||
_source: {
|
||||
'@timestamp': '2019-06-24T09:52:19.765Z',
|
||||
host: 'dsalkdakdop',
|
||||
message: 'hello, i am also message',
|
||||
},
|
||||
fields: {
|
||||
'@timestamp': ['2019-06-24T09:52:19.765Z'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return Promise.resolve(logsResponse);
|
||||
});
|
||||
|
||||
query = {
|
||||
const query = {
|
||||
range: {
|
||||
from: toUtc([2015, 4, 30, 10]),
|
||||
to: toUtc([2019, 7, 1, 10]),
|
||||
@@ -238,12 +188,30 @@ describe('ElasticDatasource', function(this: any) {
|
||||
],
|
||||
};
|
||||
|
||||
queryBuilderSpy = jest.spyOn(ctx.ds.queryBuilder, 'getLogsQuery');
|
||||
await ctx.ds.query(query);
|
||||
const queryBuilderSpy = jest.spyOn(ctx.ds.queryBuilder, 'getLogsQuery');
|
||||
const response = await ctx.ds.query(query);
|
||||
return { queryBuilderSpy, response };
|
||||
}
|
||||
|
||||
it('should call getLogsQuery()', async () => {
|
||||
const { queryBuilderSpy } = await setupDataSource();
|
||||
expect(queryBuilderSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call getLogsQuery()', () => {
|
||||
expect(queryBuilderSpy).toHaveBeenCalled();
|
||||
it('should enhance fields with links', async () => {
|
||||
const { response } = await setupDataSource({
|
||||
dataLinks: [
|
||||
{
|
||||
field: 'host',
|
||||
url: 'http://localhost:3000/${__value.raw}',
|
||||
},
|
||||
],
|
||||
});
|
||||
// 1 for logs and 1 for counts.
|
||||
expect(response.data.length).toBe(2);
|
||||
const links = response.data[0].fields.find((field: Field) => field.name === 'host').config.links;
|
||||
expect(links.length).toBe(1);
|
||||
expect(links[0].url).toBe('http://localhost:3000/${__value.raw}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -645,3 +613,58 @@ describe('ElasticDatasource', function(this: any) {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const logsResponse = {
|
||||
data: {
|
||||
responses: [
|
||||
{
|
||||
aggregations: {
|
||||
'2': {
|
||||
buckets: [
|
||||
{
|
||||
doc_count: 10,
|
||||
key: 1000,
|
||||
},
|
||||
{
|
||||
doc_count: 15,
|
||||
key: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
'@timestamp': ['2019-06-24T09:51:19.765Z'],
|
||||
_id: 'fdsfs',
|
||||
_type: '_doc',
|
||||
_index: 'mock-index',
|
||||
_source: {
|
||||
'@timestamp': '2019-06-24T09:51:19.765Z',
|
||||
host: 'djisaodjsoad',
|
||||
message: 'hello, i am a message',
|
||||
},
|
||||
fields: {
|
||||
'@timestamp': ['2019-06-24T09:51:19.765Z'],
|
||||
},
|
||||
},
|
||||
{
|
||||
'@timestamp': ['2019-06-24T09:52:19.765Z'],
|
||||
_id: 'kdospaidopa',
|
||||
_type: '_doc',
|
||||
_index: 'mock-index',
|
||||
_source: {
|
||||
'@timestamp': '2019-06-24T09:52:19.765Z',
|
||||
host: 'dsalkdakdop',
|
||||
message: 'hello, i am also message',
|
||||
},
|
||||
fields: {
|
||||
'@timestamp': ['2019-06-24T09:52:19.765Z'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
import angular from 'angular';
|
||||
import _ from 'lodash';
|
||||
import { DataSourceApi, DataSourceInstanceSettings, DataQueryRequest, DataQueryResponse } from '@grafana/data';
|
||||
import {
|
||||
DataSourceApi,
|
||||
DataSourceInstanceSettings,
|
||||
DataQueryRequest,
|
||||
DataQueryResponse,
|
||||
DataFrame,
|
||||
} from '@grafana/data';
|
||||
import { ElasticResponse } from './elastic_response';
|
||||
import { IndexPattern } from './index_pattern';
|
||||
import { ElasticQueryBuilder } from './query_builder';
|
||||
@@ -9,7 +15,7 @@ import * as queryDef from './query_def';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { ElasticsearchOptions, ElasticsearchQuery } from './types';
|
||||
import { DataLinkConfig, ElasticsearchOptions, ElasticsearchQuery } from './types';
|
||||
|
||||
export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, ElasticsearchOptions> {
|
||||
basicAuth: string;
|
||||
@@ -25,6 +31,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
||||
indexPattern: IndexPattern;
|
||||
logMessageField?: string;
|
||||
logLevelField?: string;
|
||||
dataLinks: DataLinkConfig[];
|
||||
|
||||
/** @ngInject */
|
||||
constructor(
|
||||
@@ -52,6 +59,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
||||
});
|
||||
this.logMessageField = settingsData.logMessageField || '';
|
||||
this.logLevelField = settingsData.logLevelField || '';
|
||||
this.dataLinks = settingsData.dataLinks || [];
|
||||
|
||||
if (this.logMessageField === '') {
|
||||
this.logMessageField = null;
|
||||
@@ -369,7 +377,11 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
||||
return this.post(url, payload).then((res: any) => {
|
||||
const er = new ElasticResponse(sentTargets, res);
|
||||
if (sentTargets.some(target => target.isLogsQuery)) {
|
||||
return er.getLogs(this.logMessageField, this.logLevelField);
|
||||
const response = er.getLogs(this.logMessageField, this.logLevelField);
|
||||
for (const dataFrame of response.data) {
|
||||
this.enhanceDataFrame(dataFrame);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
return er.getTimeSeries();
|
||||
@@ -547,6 +559,24 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
||||
return false;
|
||||
}
|
||||
|
||||
enhanceDataFrame(dataFrame: DataFrame) {
|
||||
if (this.dataLinks.length) {
|
||||
for (const field of dataFrame.fields) {
|
||||
const dataLink = this.dataLinks.find(dataLink => field.name && field.name.match(dataLink.field));
|
||||
if (dataLink) {
|
||||
field.config = field.config || {};
|
||||
field.config.links = [
|
||||
...(field.config.links || []),
|
||||
{
|
||||
url: dataLink.url,
|
||||
title: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isPrimitive(obj: any) {
|
||||
if (obj === null || obj === undefined) {
|
||||
return true;
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ElasticsearchOptions extends DataSourceJsonData {
|
||||
maxConcurrentShardRequests?: number;
|
||||
logMessageField?: string;
|
||||
logLevelField?: string;
|
||||
dataLinks?: DataLinkConfig[];
|
||||
}
|
||||
|
||||
export interface ElasticsearchAggregation {
|
||||
@@ -24,3 +25,8 @@ export interface ElasticsearchQuery extends DataQuery {
|
||||
bucketAggs?: ElasticsearchAggregation[];
|
||||
metrics?: ElasticsearchAggregation[];
|
||||
}
|
||||
|
||||
export type DataLinkConfig = {
|
||||
field: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
@@ -25,9 +25,7 @@
|
||||
"limit": 100,
|
||||
"name": "Annotations & Alerts",
|
||||
"showIn": 0,
|
||||
"tags": [
|
||||
"metrictank"
|
||||
],
|
||||
"tags": ["metrictank"],
|
||||
"type": "tags"
|
||||
}
|
||||
]
|
||||
@@ -3258,11 +3256,11 @@
|
||||
"target": "groupByNodes(perSecond(metrictank.stats.$environment.$instance.idx.*.ops.*.counter32), 'sum', 5, 7)",
|
||||
"textEditor": false
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"target": "aliasByNode(sumSeriesWithWildcards(metrictank.stats.$environment.$instance.idx.*.add.values.rate32, 2, 3), 3, 4)",
|
||||
"textEditor": false
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"target": "aliasByNode(sumSeriesWithWildcards(metrictank.stats.$environment.$instance.idx.*.add.values.rate32, 2, 3), 3, 4)",
|
||||
"textEditor": false
|
||||
},
|
||||
{
|
||||
"refId": "C",
|
||||
"target": "aliasByNode(sumSeriesWithWildcards(metrictank.stats.$environment.$instance.idx.*.query-insert.exec.values.rate32, 2, 3), 3, 4)",
|
||||
@@ -4783,30 +4781,9 @@
|
||||
"enable": true,
|
||||
"notice": false,
|
||||
"now": true,
|
||||
"refresh_intervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
],
|
||||
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
|
||||
"status": "Stable",
|
||||
"time_options": [
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"6h",
|
||||
"12h",
|
||||
"24h",
|
||||
"2d",
|
||||
"7d",
|
||||
"30d"
|
||||
],
|
||||
"time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"],
|
||||
"type": "timepicker"
|
||||
},
|
||||
"timezone": "utc",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"category": "tsdb",
|
||||
|
||||
"includes": [
|
||||
{ "type": "dashboard", "name": "Graphite Carbon Metrics", "path": "dashboards/carbon_metrics.json" },
|
||||
{ "type": "dashboard", "name": "Metrictank (Graphite alternative)", "path": "dashboards/metrictank.json" }
|
||||
{ "type": "dashboard", "name": "Graphite Carbon Metrics", "path": "dashboards/carbon_metrics.json" },
|
||||
{ "type": "dashboard", "name": "Metrictank (Graphite alternative)", "path": "dashboards/metrictank.json" }
|
||||
],
|
||||
|
||||
"metrics": true,
|
||||
|
||||
Reference in New Issue
Block a user