Commit f348accc by Marcus Efraimsson Committed by GitHub

Merge pull request #13758 from grafana/mysql_query_builder

mysql graphical query builder
parents 4954a49d a00999fd
......@@ -73,6 +73,58 @@ Example:
You can use wildcards (`*`) in place of database or table if you want to grant access to more databases and tables.
## Query Editor
> Only available in Grafana v5.4+.
{{< docs-imagebox img="/img/docs/v54/mysql_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v54/mysql_query.gif" >}}
You find the MySQL query editor in the metrics tab in a panel's edit mode. You enter edit mode by clicking the
panel title, then edit.
The query editor has a link named `Generated SQL` that shows up after a query has been executed, while in panel edit mode. Click on it and it will expand and show the raw interpolated SQL string that was executed.
### Select table, time column and metric column (FROM)
When you enter edit mode for the first time or add a new query Grafana will try to prefill the query builder with the first table that has a timestamp column and a numeric column.
In the FROM field, Grafana will suggest tables that are in the configured database. To select a table or view in another database that your database user has access to you can manually enter a fully qualified name (database.table) like `otherDb.metrics`.
The Time column field refers to the name of the column holding your time values. Selecting a value for the Metric column field is optional. If a value is selected, the Metric column field will be used as the series name.
The metric column suggestions will only contain columns with a text datatype (text, tinytext, mediumtext, longtext, varchar, char).
If you want to use a column with a different datatype as metric column you may enter the column name with a cast: `CAST(numericColumn as CHAR)`.
You may also enter arbitrary SQL expressions in the metric column field that evaluate to a text datatype like
`CONCAT(column1, " ", CAST(numericColumn as CHAR))`.
### Columns and Aggregation functions (SELECT)
In the `SELECT` row you can specify what columns and functions you want to use.
In the column field you may write arbitrary expressions instead of a column name like `column1 * column2 / column3`.
If you use aggregate functions you need to group your resultset. The editor will automatically add a `GROUP BY time` if you add an aggregate function.
You may add further value columns by clicking the plus button and selecting `Column` from the menu. Multiple value columns will be plotted as separate series in the graph panel.
### Filter data (WHERE)
To add a filter click the plus icon to the right of the `WHERE` condition. You can remove filters by clicking on
the filter and selecting `Remove`. A filter for the current selected timerange is automatically added to new queries.
### Group By
To group by time or any other columns click the plus icon at the end of the GROUP BY row. The suggestion dropdown will only show text columns of your currently selected table but you may manually enter any column.
You can remove the group by clicking on the item and then selecting `Remove`.
If you add any grouping, all selected columns need to have an aggregate function applied. The query builder will automatically add aggregate functions to all columns without aggregate functions when you add groupings.
#### Gap Filling
Grafana can fill in missing values when you group by time. The time function accepts two arguments. The first argument is the time window that you would like to group by, and the second argument is the value you want Grafana to fill missing items with.
### Text Editor Mode (RAW)
You can switch to the raw query editor mode by clicking the hamburger icon and selecting `Switch editor mode` or by clicking `Edit SQL` below the query.
> If you use the raw query editor, be sure your query at minimum has `ORDER BY time` and a filter on the returned time range.
## Macros
To simplify syntax and to allow for dynamic parts, like date range filters, the query can contain macros.
......
import _ from 'lodash';
import ResponseParser from './response_parser';
import MysqlQuery from 'app/plugins/datasource/mysql/mysql_query';
export class MysqlDatasource {
id: any;
name: any;
responseParser: ResponseParser;
queryModel: MysqlQuery;
interval: string;
/** @ngInject */
constructor(instanceSettings, private backendSrv, private $q, private templateSrv) {
constructor(instanceSettings, private backendSrv, private $q, private templateSrv, private timeSrv) {
this.name = instanceSettings.name;
this.id = instanceSettings.id;
this.responseParser = new ResponseParser(this.$q);
this.queryModel = new MysqlQuery({});
this.interval = (instanceSettings.jsonData || {}).timeInterval;
}
interpolateVariable(value, variable) {
interpolateVariable = (value, variable) => {
if (typeof value === 'string') {
if (variable.multi || variable.includeAll) {
return "'" + value.replace(/'/g, `''`) + "'";
return this.queryModel.quoteLiteral(value);
} else {
return value;
}
......@@ -28,27 +31,25 @@ export class MysqlDatasource {
return value;
}
const quotedValues = _.map(value, val => {
if (typeof value === 'number') {
return value;
}
return "'" + val.replace(/'/g, `''`) + "'";
const quotedValues = _.map(value, v => {
return this.queryModel.quoteLiteral(v);
});
return quotedValues.join(',');
}
};
query(options) {
const queries = _.filter(options.targets, item => {
return item.hide !== true;
}).map(item => {
const queries = _.filter(options.targets, target => {
return target.hide !== true;
}).map(target => {
const queryModel = new MysqlQuery(target, this.templateSrv, options.scopedVars);
return {
refId: item.refId,
refId: target.refId,
intervalMs: options.intervalMs,
maxDataPoints: options.maxDataPoints,
datasourceId: this.id,
rawSql: this.templateSrv.replace(item.rawSql, options.scopedVars, this.interpolateVariable),
format: item.format,
rawSql: queryModel.render(this.interpolateVariable),
format: target.format,
};
});
......@@ -109,8 +110,11 @@ export class MysqlDatasource {
format: 'table',
};
const range = this.timeSrv.timeRange();
const data = {
queries: [interpolatedQuery],
from: range.from.valueOf().toString(),
to: range.to.valueOf().toString(),
};
if (optionalOptions && optionalOptions.range && optionalOptions.range.from) {
......
export class MysqlMetaQuery {
constructor(private target, private queryModel) {}
getOperators(datatype: string) {
switch (datatype) {
case 'double':
case 'float': {
return ['=', '!=', '<', '<=', '>', '>='];
}
case 'text':
case 'tinytext':
case 'mediumtext':
case 'longtext':
case 'varchar':
case 'char': {
return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE'];
}
default: {
return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN'];
}
}
}
// quote identifier as literal to use in metadata queries
quoteIdentAsLiteral(value) {
return this.queryModel.quoteLiteral(this.queryModel.unquoteIdentifier(value));
}
findMetricTable() {
// query that returns first table found that has a timestamp(tz) column and a float column
const query = `
SELECT
table_name as table_name,
( SELECT
column_name as column_name
FROM information_schema.columns c
WHERE
c.table_schema = t.table_schema AND
c.table_name = t.table_name AND
c.data_type IN ('timestamp', 'datetime')
ORDER BY ordinal_position LIMIT 1
) AS time_column,
( SELECT
column_name AS column_name
FROM information_schema.columns c
WHERE
c.table_schema = t.table_schema AND
c.table_name = t.table_name AND
c.data_type IN('float', 'int', 'bigint')
ORDER BY ordinal_position LIMIT 1
) AS value_column
FROM information_schema.tables t
WHERE
t.table_schema = database() AND
EXISTS
( SELECT 1
FROM information_schema.columns c
WHERE
c.table_schema = t.table_schema AND
c.table_name = t.table_name AND
c.data_type IN ('timestamp', 'datetime')
) AND
EXISTS
( SELECT 1
FROM information_schema.columns c
WHERE
c.table_schema = t.table_schema AND
c.table_name = t.table_name AND
c.data_type IN('float', 'int', 'bigint')
)
LIMIT 1
;`;
return query;
}
buildTableConstraint(table: string) {
let query = '';
// check for schema qualified table
if (table.includes('.')) {
const parts = table.split('.');
query = 'table_schema = ' + this.quoteIdentAsLiteral(parts[0]);
query += ' AND table_name = ' + this.quoteIdentAsLiteral(parts[1]);
return query;
} else {
query = 'table_schema = database() AND table_name = ' + this.quoteIdentAsLiteral(table);
return query;
}
}
buildTableQuery() {
return 'SELECT table_name FROM information_schema.tables WHERE table_schema = database() ORDER BY table_name';
}
buildColumnQuery(type?: string) {
let query = 'SELECT column_name FROM information_schema.columns WHERE ';
query += this.buildTableConstraint(this.target.table);
switch (type) {
case 'time': {
query += " AND data_type IN ('timestamp','datetime','bigint','int','double','float')";
break;
}
case 'metric': {
query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')";
break;
}
case 'value': {
query += " AND data_type IN ('bigint','int','smallint','mediumint','tinyint','double','decimal','float')";
query += ' AND column_name <> ' + this.quoteIdentAsLiteral(this.target.timeColumn);
break;
}
case 'group': {
query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')";
break;
}
}
query += ' ORDER BY column_name';
return query;
}
buildValueQuery(column: string) {
let query = 'SELECT DISTINCT QUOTE(' + column + ')';
query += ' FROM ' + this.target.table;
query += ' WHERE $__timeFilter(' + this.target.timeColumn + ')';
query += ' ORDER BY 1 LIMIT 100';
return query;
}
buildDatatypeQuery(column: string) {
let query = `
SELECT data_type
FROM information_schema.columns
WHERE `;
query += ' table_name = ' + this.quoteIdentAsLiteral(this.target.table);
query += ' AND column_name = ' + this.quoteIdentAsLiteral(column);
return query;
}
}
import _ from 'lodash';
export default class MysqlQuery {
target: any;
templateSrv: any;
scopedVars: any;
/** @ngInject */
constructor(target, templateSrv?, scopedVars?) {
this.target = target;
this.templateSrv = templateSrv;
this.scopedVars = scopedVars;
target.format = target.format || 'time_series';
target.timeColumn = target.timeColumn || 'time';
target.metricColumn = target.metricColumn || 'none';
target.group = target.group || [];
target.where = target.where || [{ type: 'macro', name: '$__timeFilter', params: [] }];
target.select = target.select || [[{ type: 'column', params: ['value'] }]];
// handle pre query gui panels gracefully
if (!('rawQuery' in this.target)) {
if ('rawSql' in target) {
// pre query gui panel
target.rawQuery = true;
} else {
// new panel
target.rawQuery = false;
}
}
// give interpolateQueryStr access to this
this.interpolateQueryStr = this.interpolateQueryStr.bind(this);
}
// remove identifier quoting from identifier to use in metadata queries
unquoteIdentifier(value) {
if (value[0] === '"' && value[value.length - 1] === '"') {
return value.substring(1, value.length - 1).replace(/""/g, '"');
} else {
return value;
}
}
quoteIdentifier(value) {
return '"' + value.replace(/"/g, '""') + '"';
}
quoteLiteral(value) {
return "'" + value.replace(/'/g, "''") + "'";
}
escapeLiteral(value) {
return value.replace(/'/g, "''");
}
hasTimeGroup() {
return _.find(this.target.group, (g: any) => g.type === 'time');
}
hasMetricColumn() {
return this.target.metricColumn !== 'none';
}
interpolateQueryStr(value, variable, defaultFormatFn) {
// if no multi or include all do not regexEscape
if (!variable.multi && !variable.includeAll) {
return this.escapeLiteral(value);
}
if (typeof value === 'string') {
return this.quoteLiteral(value);
}
const escapedValues = _.map(value, this.quoteLiteral);
return escapedValues.join(',');
}
render(interpolate?) {
const target = this.target;
// new query with no table set yet
if (!this.target.rawQuery && !('table' in this.target)) {
return '';
}
if (!target.rawQuery) {
target.rawSql = this.buildQuery();
}
if (interpolate) {
return this.templateSrv.replace(target.rawSql, this.scopedVars, this.interpolateQueryStr);
} else {
return target.rawSql;
}
}
hasUnixEpochTimecolumn() {
return ['int', 'bigint', 'double'].indexOf(this.target.timeColumnType) > -1;
}
buildTimeColumn(alias = true) {
const timeGroup = this.hasTimeGroup();
let query;
let macro = '$__timeGroup';
if (timeGroup) {
let args;
if (timeGroup.params.length > 1 && timeGroup.params[1] !== 'none') {
args = timeGroup.params.join(',');
} else {
args = timeGroup.params[0];
}
if (this.hasUnixEpochTimecolumn()) {
macro = '$__unixEpochGroup';
}
if (alias) {
macro += 'Alias';
}
query = macro + '(' + this.target.timeColumn + ',' + args + ')';
} else {
query = this.target.timeColumn;
if (alias) {
query += ' AS "time"';
}
}
return query;
}
buildMetricColumn() {
if (this.hasMetricColumn()) {
return this.target.metricColumn + ' AS metric';
}
return '';
}
buildValueColumns() {
let query = '';
for (const column of this.target.select) {
query += ',\n ' + this.buildValueColumn(column);
}
return query;
}
buildValueColumn(column) {
let query = '';
const columnName = _.find(column, (g: any) => g.type === 'column');
query = columnName.params[0];
const aggregate = _.find(column, (g: any) => g.type === 'aggregate');
if (aggregate) {
const func = aggregate.params[0];
query = func + '(' + query + ')';
}
const alias = _.find(column, (g: any) => g.type === 'alias');
if (alias) {
query += ' AS ' + this.quoteIdentifier(alias.params[0]);
}
return query;
}
buildWhereClause() {
let query = '';
const conditions = _.map(this.target.where, (tag, index) => {
switch (tag.type) {
case 'macro':
return tag.name + '(' + this.target.timeColumn + ')';
break;
case 'expression':
return tag.params.join(' ');
break;
}
});
if (conditions.length > 0) {
query = '\nWHERE\n ' + conditions.join(' AND\n ');
}
return query;
}
buildGroupClause() {
let query = '';
let groupSection = '';
for (let i = 0; i < this.target.group.length; i++) {
const part = this.target.group[i];
if (i > 0) {
groupSection += ', ';
}
if (part.type === 'time') {
groupSection += '1';
} else {
groupSection += part.params[0];
}
}
if (groupSection.length) {
query = '\nGROUP BY ' + groupSection;
if (this.hasMetricColumn()) {
query += ',2';
}
}
return query;
}
buildQuery() {
let query = 'SELECT';
query += '\n ' + this.buildTimeColumn();
if (this.hasMetricColumn()) {
query += ',\n ' + this.buildMetricColumn();
}
query += this.buildValueColumns();
query += '\nFROM ' + this.target.table;
query += this.buildWhereClause();
query += this.buildGroupClause();
query += '\nORDER BY ' + this.buildTimeColumn(false);
return query;
}
}
<query-editor-row query-ctrl="ctrl" can-collapse="false">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<code-editor content="ctrl.target.rawSql" datasource="ctrl.datasource" on-change="ctrl.panelCtrl.refresh()" data-mode="sql">
</code-editor>
</div>
</div>
<query-editor-row query-ctrl="ctrl" has-text-edit-mode="true">
<div ng-if="ctrl.target.rawQuery">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<code-editor content="ctrl.target.rawSql" datasource="ctrl.datasource" on-change="ctrl.panelCtrl.refresh()" data-mode="sql">
</code-editor>
</div>
</div>
</div>
<div ng-if="!ctrl.target.rawQuery">
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-6">FROM</label>
<metric-segment segment="ctrl.tableSegment" get-options="ctrl.getTableSegments()" on-change="ctrl.tableChanged()"></metric-segment>
<label class="gf-form-label query-keyword width-7">Time column</label>
<metric-segment segment="ctrl.timeColumnSegment" get-options="ctrl.getTimeColumnSegments()" on-change="ctrl.timeColumnChanged()"></metric-segment>
<label class="gf-form-label query-keyword width-9">
Metric column
<info-popover mode="right-normal">Column to be used as metric name for the value column.</info-popover>
</label>
<metric-segment segment="ctrl.metricColumnSegment" get-options="ctrl.getMetricColumnSegments()" on-change="ctrl.metricColumnChanged()"></metric-segment>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline" ng-repeat="selectParts in ctrl.selectParts">
<div class="gf-form">
<label class="gf-form-label query-keyword width-6">
<span ng-show="$index === 0">SELECT</span>&nbsp;
</label>
</div>
<div class="gf-form" ng-repeat="part in selectParts">
<sql-part-editor class="gf-form-label sql-part" part="part" handle-event="ctrl.handleSelectPartEvent(selectParts, part, $event)">
</sql-part-editor>
</div>
<div class="gf-form">
<label class="dropdown"
dropdown-typeahead="ctrl.selectMenu"
dropdown-typeahead-on-select="ctrl.addSelectPart(selectParts, $item, $subItem)">
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-6">WHERE</label>
</div>
<div class="gf-form" ng-repeat="part in ctrl.whereParts">
<sql-part-editor class="gf-form-label sql-part" part="part" handle-event="ctrl.handleWherePartEvent(ctrl.whereParts, part, $event, $index)">
</sql-part-editor>
</div>
<div class="gf-form">
<metric-segment segment="ctrl.whereAdd" get-options="ctrl.getWhereOptions()" on-change="ctrl.addWhereAction(part, $index)"></metric-segment>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-6">
<span>GROUP BY</span>
</label>
<sql-part-editor ng-repeat="part in ctrl.groupParts"
part="part" class="gf-form-label sql-part"
handle-event="ctrl.handleGroupPartEvent(part, $index, $event)">
</sql-part-editor>
</div>
<div class="gf-form">
<metric-segment segment="ctrl.groupAdd" get-options="ctrl.getGroupOptions()" on-change="ctrl.addGroupAction(part, $index)"></metric-segment>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword">Format as</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="ctrl.target.format" ng-options="f.value as f.text for f in ctrl.formats" ng-change="ctrl.refresh()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
<label class="gf-form-label query-keyword">Format as</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="ctrl.target.format" ng-options="f.value as f.text for f in ctrl.formats" ng-change="ctrl.refresh()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword pointer" ng-click="ctrl.toggleEditorMode()" ng-show="ctrl.panelCtrl.panel.type !== 'table'">
<span ng-show="ctrl.target.rawQuery">Query Builder</span>
<span ng-hide="ctrl.target.rawQuery">Edit SQL</span>
</label>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword pointer" ng-click="ctrl.showHelp = !ctrl.showHelp">
Show Help
<i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
</label>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryMeta">
<label class="gf-form-label query-keyword" ng-click="ctrl.showLastQuerySQL = !ctrl.showLastQuerySQL">
</div>
<div class="gf-form" ng-show="ctrl.lastQueryMeta">
<label class="gf-form-label query-keyword pointer" ng-click="ctrl.showLastQuerySQL = !ctrl.showLastQuerySQL">
Generated SQL
<i class="fa fa-caret-down" ng-show="ctrl.showLastQuerySQL"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showLastQuerySQL"></i>
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form" ng-show="ctrl.showLastQuerySQL">
<pre class="gf-form-pre">{{ctrl.lastQueryMeta.sql}}</pre>
</div>
<div class="gf-form" ng-show="ctrl.showHelp">
<pre class="gf-form-pre alert alert-info">Time series:
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form" ng-show="ctrl.showLastQuerySQL">
<pre class="gf-form-pre">{{ctrl.lastQueryMeta.sql}}</pre>
</div>
<div class="gf-form" ng-show="ctrl.showHelp">
<pre class="gf-form-pre alert alert-info">Time series:
- return column named time or time_sec (in UTC), as a unix time stamp or any sql native date data type. You can use the macros below.
- return column(s) with numeric datatype as values
Optional:
......@@ -64,7 +162,7 @@ Macros:
Example of group by and order by with $__timeGroup:
SELECT
$__timeGroup(timestamp_col, '1h') AS time,
$__timeGroupAlias(timestamp_col, '1h'),
sum(value_double) as value
FROM yourtable
GROUP BY 1
......@@ -75,13 +173,13 @@ Or build your own conditionals using these macros which just return the values:
- $__timeTo() -&gt; '2017-04-21T05:01:17Z'
- $__unixEpochFrom() -&gt; 1492750877
- $__unixEpochTo() -&gt; 1492750877
</pre>
</div>
</pre>
</div>
</div>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div>
</query-editor-row>
......@@ -9,12 +9,23 @@ describe('MySQLDatasource', () => {
replace: jest.fn(text => text),
};
const raw = {
from: moment.utc('2018-04-25 10:00'),
to: moment.utc('2018-04-25 11:00'),
};
const ctx = {
backendSrv,
timeSrvMock: {
timeRange: () => ({
from: raw.from,
to: raw.to,
raw: raw,
}),
},
} as any;
beforeEach(() => {
ctx.ds = new MysqlDatasource(instanceSettings, backendSrv, {}, templateSrv);
ctx.ds = new MysqlDatasource(instanceSettings, backendSrv, {}, templateSrv, ctx.timeSrvMock);
});
describe('When performing annotationQuery', () => {
......
import { SqlPartDef, SqlPart } from 'app/core/components/sql_part/sql_part';
const index = [];
function createPart(part): any {
const def = index[part.type];
if (!def) {
return null;
}
return new SqlPart(part, def);
}
function register(options: any) {
index[options.type] = new SqlPartDef(options);
}
register({
type: 'column',
style: 'label',
params: [{ type: 'column', dynamicLookup: true }],
defaultParams: ['value'],
});
register({
type: 'expression',
style: 'expression',
label: 'Expr:',
params: [
{ name: 'left', type: 'string', dynamicLookup: true },
{ name: 'op', type: 'string', dynamicLookup: true },
{ name: 'right', type: 'string', dynamicLookup: true },
],
defaultParams: ['value', '=', 'value'],
});
register({
type: 'macro',
style: 'label',
label: 'Macro:',
params: [],
defaultParams: [],
});
register({
type: 'aggregate',
style: 'label',
params: [
{
name: 'name',
type: 'string',
options: ['avg', 'count', 'min', 'max', 'sum', 'stddev', 'variance'],
},
],
defaultParams: ['avg'],
});
register({
type: 'alias',
style: 'label',
params: [{ name: 'name', type: 'string', quote: 'double' }],
defaultParams: ['alias'],
});
register({
type: 'time',
style: 'function',
label: 'time',
params: [
{
name: 'interval',
type: 'interval',
options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'],
},
{
name: 'fill',
type: 'string',
options: ['none', 'NULL', 'previous', '0'],
},
],
defaultParams: ['$__interval', 'none'],
});
export default {
create: createPart,
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment