Commit f7ec8e0d by Alexander Zobnin

Merge branch 'master' into ace-editor

parents 776e6e2c d4dd0222
......@@ -88,7 +88,7 @@ You can switch to raw query mode by clicking hamburger icon and then `Switch edi
- $m = replaced with measurement name
- $measurement = replaced with measurement name
- $col = replaced with column name
- $tag_exampletag = replaced with the value of the `exampletag` tag. To use your tag as an alias in the ALIAS BY field then the tag must be used to group by in the query.
- $tag_exampletag = replaced with the value of the `exampletag` tag. The syntax is `$tag_yourTagName` (must start with `$tag_`). To use your tag as an alias in the ALIAS BY field then the tag must be used to group by in the query.
- You can also use [[tag_hostname]] pattern replacement syntax. For example, in the ALIAS BY field using this text `Host: [[tag_hostname]]` would substitute in the `hostname` tag value for each legend value and an example legend value would be: `Host: server1`.
### Table query / raw data
......
......@@ -29,8 +29,7 @@ data from a MySQL compatible database.
The database user you specify when you add the data source should only be granted SELECT permissions on
the specified database & tables you want to query. Grafana does not validate that the query is safe. The query
could include any SQL statement. For example, statements like `USE otherdb;` and `DROP TABLE user;` would be
executed. To protect against this we **Highly** recommmend you create a specific mysql user with
restricted permissions.
executed. To protect against this we **Highly** recommmend you create a specific mysql user with restricted permissions.
Example:
......@@ -49,11 +48,9 @@ Macro example | Description
------------ | -------------
*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn > FROM_UNIXTIME(1494410783) AND dateColumn < FROM_UNIXTIME(1494497183)*
We plan to add many more macros. If you have suggestions for what macros you would like to see, please
[open an issue](https://github.com/grafana/grafana) in our GitHub repo.
We plan to add many more macros. If you have suggestions for what macros you would like to see, please [open an issue](https://github.com/grafana/grafana) in our GitHub repo.
The query editor has a link named `Generated SQL` that show up after a query as been executed, while in panel edit mode. Click
on it and it will expand and show the raw interpolated SQL string that was executed.
The query editor has a link named `Generated SQL` that show up after a query as been executed, while in panel edit mode. Click on it and it will expand and show the raw interpolated SQL string that was executed.
## Table queries
......@@ -109,8 +106,71 @@ This is something we plan to add.
## Templating
You can use variables in your queries but there are currently no support for defining `Query` variables
that target a MySQL data source.
This feature is currently available in the nightly builds and will be included in the 5.0.0 release.
Instead of hard-coding things like server, application and sensor name in you metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of the dashboard. These dropdowns makes it easy to change the data being displayed in your dashboard.
Checkout the [Templating]({{< relref "reference/templating.md" >}}) documentation for an introduction to the templating feature and the different types of template variables.
### Query Variable
If you add a template variable of the type `Query`, you can write a MySQL query that can
return things like measurement names, key names or key values that are shown as a dropdown select box.
For example, you can have a variable that contains all values for the `hostname` column in a table if you specify a query like this in the templating variable *Query* setting.
```sql
SELECT hostname FROM my_host
```
A query can returns multiple columns and Grafana will automatically create a list from them. For example, the query below will return a list with values from `hostname` and `hostname2`.
```sql
SELECT my_host.hostname, my_other_host.hostname2 FROM my_host JOIN my_other_host ON my_host.city = my_other_host.city
```
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value:
```sql
SELECT hostname AS __text, id AS __value FROM my_host
```
You can also create nested variables. For example if you had another variable named `region`. Then you could have
the hosts variable only show hosts from the current selected region with a query like this (if `region` is a multi-value variable then use the `IN` comparison operator rather than `=` to match against multiple values):
```sql
SELECT hostname FROM my_host WHERE region IN($region)
```
### Using Variables in Queries
Template variables are quoted automatically so if it is a string value do not wrap them in quotes in where clauses. If the variable is a multi-value variable then use the `IN` comparison operator rather than `=` to match against multiple values.
There are two syntaxes:
`$<varname>` Example with a template variable named `hostname`:
```sql
SELECT
UNIX_TIMESTAMP(atimestamp) as time_sec,
aint as value,
avarchar as metric
FROM my_table
WHERE $__timeFilter(atimestamp) and hostname in($hostname)
ORDER BY atimestamp ASC
```
`[[varname]]` Example with a template variable named `hostname`:
```sql
SELECT
UNIX_TIMESTAMP(atimestamp) as time_sec,
aint as value,
avarchar as metric
FROM my_table
WHERE $__timeFilter(atimestamp) and hostname in([[hostname]])
ORDER BY atimestamp ASC
```
## Alerting
......
......@@ -20,9 +20,9 @@ parent = "http_api"
GET /api/users HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
Authorization: Basic YWRtaW46YWRtaW4=
Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`.
Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
......@@ -55,10 +55,12 @@ Default value for the `perpage` parameter is `1000` and for the `page` parameter
GET /api/users/search?perpage=10&page=1&query=mygraf HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
Authorization: Basic YWRtaW46YWRtaW4=
Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. The `totalCount` field in the response can be used for pagination of the user list E.g. if `totalCount` is equal to 100 users and the `perpage` parameter is set to 10 then there are 10 pages of users. The `query` parameter is optional and it will return results where the query value is contained in one of the `name`, `login` or `email` fields. Query values with spaces need to be url encoded e.g. `query=Jane%20Doe`.
Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
HTTP/1.1 200
......@@ -94,7 +96,9 @@ Default value for the `perpage` parameter is `1000` and for the `page` parameter
GET /api/users/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
Authorization: Basic YWRtaW46YWRtaW4=
Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
......@@ -126,7 +130,9 @@ Default value for the `perpage` parameter is `1000` and for the `page` parameter
GET /api/users/lookup?loginOrEmail=admin HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
Authorization: Basic YWRtaW46YWRtaW4=
Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
......@@ -152,7 +158,7 @@ Default value for the `perpage` parameter is `1000` and for the `page` parameter
PUT /api/users/2 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
Authorization: Basic YWRtaW46YWRtaW4=
{
"email":"user@mygraf.com",
......@@ -161,6 +167,8 @@ Default value for the `perpage` parameter is `1000` and for the `page` parameter
"theme":"light"
}
Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
HTTP/1.1 200
......@@ -178,7 +186,9 @@ Default value for the `perpage` parameter is `1000` and for the `page` parameter
GET /api/users/1/orgs HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
Authorization: Basic YWRtaW46YWRtaW4=
Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
......@@ -246,11 +256,29 @@ Changes the password for the user
{"message":"User password changed"}
## Switch user context
## Switch user context for a specified user
`POST /api/users/:userId/using/:organizationId`
Switch user context to the given organization. Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Request**:
POST /api/users/7/using/2 HTTP/1.1
Authorization: Basic YWRtaW46YWRtaW4=
**Example Response**:
HTTP/1.1 200
Content-Type: application/json
{"message":"Active organization changed"}
## Switch user context for signed in user
`POST /api/user/using/:organisationId`
`POST /api/user/using/:organizationId`
Switch user context to the given organisation.
Switch user context to the given organization.
**Example Request**:
......
......@@ -183,7 +183,7 @@ func (e MysqlExecutor) getTypedRowData(types []*sql.ColumnType, rows *core.Rows)
values := make([]interface{}, len(types))
for i, stype := range types {
e.log.Info("type", "type", stype)
e.log.Debug("type", "type", stype)
switch stype.DatabaseTypeName() {
case mysql.FieldTypeNameTiny:
values[i] = new(int8)
......
......@@ -122,7 +122,7 @@ function (angular, _, coreModule) {
vm.selectValue = function(option, event, commitChange, excludeOthers) {
if (!option) { return; }
option.selected = !option.selected;
option.selected = vm.variable.multi ? !option.selected: true;
commitChange = commitChange || false;
excludeOthers = excludeOthers || false;
......
......@@ -29,10 +29,6 @@ export class SubmenuCtrl {
var search = _.extend(this.$location.search(), {editview: editview});
this.$location.search(search);
}
exitBuildMode() {
this.dashboard.toggleEditMode();
}
}
export function submenuDirective() {
......
......@@ -106,7 +106,7 @@ export class VariableEditorCtrl {
var clone = _.cloneDeep(variable.getSaveModel());
$scope.current = variableSrv.createVariableFromModel(clone);
$scope.current.name = 'copy_of_'+variable.name;
$scope.variableSrv.addVariable($scope.current);
variableSrv.addVariable($scope.current);
};
$scope.update = function() {
......
......@@ -128,7 +128,7 @@ export class QueryVariable implements Variable {
}
metricFindQuery(datasource, query) {
var options = {range: undefined};
var options = {range: undefined, variable: this};
if (this.refresh === 2) {
options.range = this.timeSrv.timeRange();
......
......@@ -51,6 +51,31 @@ describe('templateSrv', function() {
});
});
describe('getAdhocFilters', function() {
beforeEach(function() {
initTemplateSrv([
{type: 'datasource', name: 'ds', current: {value: 'logstash', text: 'logstash'}},
{type: 'adhoc', name: 'test', datasource: 'oogle', filters: [1]},
{type: 'adhoc', name: 'test2', datasource: '$ds', filters: [2]},
]);
});
it('should return filters if datasourceName match', function() {
var filters = _templateSrv.getAdhocFilters('oogle');
expect(filters).to.eql([1]);
});
it('should return empty array if datasourceName does not match', function() {
var filters = _templateSrv.getAdhocFilters('oogleasdasd');
expect(filters).to.eql([]);
});
it('should return filters when datasourceName match via data source variable', function() {
var filters = _templateSrv.getAdhocFilters('logstash');
expect(filters).to.eql([2]);
});
});
describe('replace can pass multi / all format', function() {
beforeEach(function() {
initTemplateSrv([{type: 'query', name: 'test', current: {value: ['value1', 'value2'] }}]);
......
......@@ -15,7 +15,6 @@ function (angular, _, kbn) {
this._index = {};
this._texts = {};
this._grafanaVariables = {};
this._adhocVariables = {};
// default built ins
this._builtIns = {};
......@@ -30,24 +29,16 @@ function (angular, _, kbn) {
this.updateTemplateData = function() {
this._index = {};
this._filters = {};
this._adhocVariables = {};
for (var i = 0; i < this.variables.length; i++) {
var variable = this.variables[i];
// add adhoc filters to it's own index
if (variable.type === 'adhoc') {
this._adhocVariables[variable.datasource] = variable;
continue;
}
if (!variable.current || !variable.current.isNone && !variable.current.value) {
continue;
}
this._index[variable.name] = variable;
}
};
this.variableInitialized = function(variable) {
......@@ -55,11 +46,26 @@ function (angular, _, kbn) {
};
this.getAdhocFilters = function(datasourceName) {
var variable = this._adhocVariables[datasourceName];
if (variable) {
return variable.filters || [];
var filters = [];
for (var i = 0; i < this.variables.length; i++) {
var variable = this.variables[i];
if (variable.type !== 'adhoc') {
continue;
}
if (variable.datasource === datasourceName) {
filters = filters.concat(variable.filters);
}
if (variable.datasource.indexOf('$') === 0) {
if (this.replace(variable.datasource) === datasourceName) {
filters = filters.concat(variable.filters);
}
}
}
return [];
return filters;
};
function luceneEscape(value) {
......
......@@ -247,8 +247,6 @@ export class VariableSrv {
}
filter.operator = options.operator;
variable.setFilters(filters);
this.variableUpdated(variable, true);
}
......
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
import ResponseParser from './response_parser';
export class MysqlDatasource {
id: any;
name: any;
responseParser: ResponseParser;
/** @ngInject **/
constructor(instanceSettings, private backendSrv, private $q, private templateSrv) {
this.name = instanceSettings.name;
this.id = instanceSettings.id;
this.responseParser = new ResponseParser(this.$q);
}
interpolateVariable(value) {
if (typeof value === 'string') {
return '\"' + value + '\"';
return '\'' + value + '\'';
}
var quotedValues = _.map(value, function(val) {
return '\"' + val + '\"';
return '\'' + val + '\'';
});
return quotedValues.join(',');
}
......@@ -49,7 +52,7 @@ export class MysqlDatasource {
to: options.range.to.valueOf().toString(),
queries: queries,
}
}).then(this.processQueryResult.bind(this));
}).then(this.responseParser.processQueryResult);
}
annotationQuery(options) {
......@@ -72,46 +75,30 @@ export class MysqlDatasource {
to: options.range.to.valueOf().toString(),
queries: [query],
}
}).then(this.transformAnnotationResponse.bind(this, options));
}).then(data => this.responseParser.transformAnnotationResponse(options, data));
}
transformAnnotationResponse(options, data) {
const table = data.data.results[options.annotation.name].tables[0];
let timeColumnIndex = -1;
let titleColumnIndex = -1;
let textColumnIndex = -1;
let tagsColumnIndex = -1;
for (let i = 0; i < table.columns.length; i++) {
if (table.columns[i].text === 'time_sec') {
timeColumnIndex = i;
} else if (table.columns[i].text === 'title') {
titleColumnIndex = i;
} else if (table.columns[i].text === 'text') {
textColumnIndex = i;
} else if (table.columns[i].text === 'tags') {
tagsColumnIndex = i;
}
}
if (timeColumnIndex === -1) {
return this.$q.reject({message: 'Missing mandatory time column (with time_sec column alias) in annotation query.'});
metricFindQuery(query, optionalOptions) {
let refId = 'tempvar';
if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) {
refId = optionalOptions.variable.name;
}
const list = [];
for (let i = 0; i < table.rows.length; i++) {
const row = table.rows[i];
list.push({
annotation: options.annotation,
time: Math.floor(row[timeColumnIndex]) * 1000,
title: row[titleColumnIndex],
text: row[textColumnIndex],
tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : []
});
}
const interpolatedQuery = {
refId: refId,
datasourceId: this.id,
rawSql: this.templateSrv.replace(query, {}, this.interpolateVariable),
format: 'table',
};
return list;
return this.backendSrv.datasourceRequest({
url: '/api/tsdb/query',
method: 'POST',
data: {
queries: [interpolatedQuery],
}
})
.then(data => this.responseParser.parseMetricFindQueryResult(refId, data));
}
testDatasource() {
......@@ -141,39 +128,5 @@ export class MysqlDatasource {
}
});
}
processQueryResult(res) {
var data = [];
if (!res.data.results) {
return {data: data};
}
for (let key in res.data.results) {
let queryRes = res.data.results[key];
if (queryRes.series) {
for (let series of queryRes.series) {
data.push({
target: series.name,
datapoints: series.points,
refId: queryRes.refId,
meta: queryRes.meta,
});
}
}
if (queryRes.tables) {
for (let table of queryRes.tables) {
table.type = 'table';
table.refId = queryRes.refId;
table.meta = queryRes.meta;
data.push(table);
}
}
}
return {data: data};
}
}
......@@ -14,7 +14,6 @@
}
},
"state": "alpha",
"alerting": true,
"annotations": true,
"metrics": true
......
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
export default class ResponseParser {
constructor(private $q){}
processQueryResult(res) {
var data = [];
if (!res.data.results) {
return {data: data};
}
for (let key in res.data.results) {
let queryRes = res.data.results[key];
if (queryRes.series) {
for (let series of queryRes.series) {
data.push({
target: series.name,
datapoints: series.points,
refId: queryRes.refId,
meta: queryRes.meta,
});
}
}
if (queryRes.tables) {
for (let table of queryRes.tables) {
table.type = 'table';
table.refId = queryRes.refId;
table.meta = queryRes.meta;
data.push(table);
}
}
}
return {data: data};
}
parseMetricFindQueryResult(refId, results) {
if (!results || results.data.length === 0 || results.data.results[refId].meta.rowCount === 0) { return []; }
const columns = results.data.results[refId].tables[0].columns;
const rows = results.data.results[refId].tables[0].rows;
const textColIndex = this.findColIndex(columns, '__text');
const valueColIndex = this.findColIndex(columns, '__value');
if (columns.length === 2 && textColIndex !== -1 && valueColIndex !== -1){
return this.transformToKeyValueList(rows, textColIndex, valueColIndex);
}
return this.transformToSimpleList(rows);
}
transformToKeyValueList(rows, textColIndex, valueColIndex) {
const res = [];
for (let i = 0; i < rows.length; i++) {
if (!this.containsKey(res, rows[i][textColIndex])) {
res.push({text: rows[i][textColIndex], value: rows[i][valueColIndex]});
}
}
return res;
}
transformToSimpleList(rows) {
const res = [];
for (let i = 0; i < rows.length; i++) {
for (let j = 0; j < rows[i].length; j++) {
const value = rows[i][j];
if ( res.indexOf( value ) === -1 ) {
res.push(value);
}
}
}
return _.map(res, value => {
return { text: value};
});
}
findColIndex(columns, colName) {
for (let i = 0; i < columns.length; i++) {
if (columns[i].text === colName) {
return i;
}
}
return -1;
}
containsKey(res, key) {
for (let i = 0; i < res.length; i++) {
if (res[i].text === key) {
return true;
}
}
return false;
}
transformAnnotationResponse(options, data) {
const table = data.data.results[options.annotation.name].tables[0];
let timeColumnIndex = -1;
let titleColumnIndex = -1;
let textColumnIndex = -1;
let tagsColumnIndex = -1;
for (let i = 0; i < table.columns.length; i++) {
if (table.columns[i].text === 'time_sec') {
timeColumnIndex = i;
} else if (table.columns[i].text === 'title') {
titleColumnIndex = i;
} else if (table.columns[i].text === 'text') {
textColumnIndex = i;
} else if (table.columns[i].text === 'tags') {
tagsColumnIndex = i;
}
}
if (timeColumnIndex === -1) {
return this.$q.reject({message: 'Missing mandatory time column (with time_sec column alias) in annotation query.'});
}
const list = [];
for (let i = 0; i < table.rows.length; i++) {
const row = table.rows[i];
list.push({
annotation: options.annotation,
time: Math.floor(row[timeColumnIndex]) * 1000,
title: row[titleColumnIndex],
text: row[textColumnIndex],
tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : []
});
}
return list;
}
}
......@@ -76,4 +76,122 @@ describe('MySQLDatasource', function() {
});
});
describe('When performing metricFindQuery', function() {
let results;
const query = 'select * from atable';
const response = {
results: {
tempvar: {
meta: {
rowCount: 3
},
refId: 'tempvar',
tables: [
{
columns: [{text: 'title'}, {text: 'text'}],
rows: [
['aTitle', 'some text'],
['aTitle2', 'some text2'],
['aTitle3', 'some text3']
]
}
]
}
}
};
beforeEach(function() {
ctx.backendSrv.datasourceRequest = function(options) {
return ctx.$q.when({data: response, status: 200});
};
ctx.ds.metricFindQuery(query).then(function(data) { results = data; });
ctx.$rootScope.$apply();
});
it('should return list of all column values', function() {
expect(results.length).to.be(6);
expect(results[0].text).to.be('aTitle');
expect(results[5].text).to.be('some text3');
});
});
describe('When performing metricFindQuery with key, value columns', function() {
let results;
const query = 'select * from atable';
const response = {
results: {
tempvar: {
meta: {
rowCount: 3
},
refId: 'tempvar',
tables: [
{
columns: [{text: '__value'}, {text: '__text'}],
rows: [
['value1', 'aTitle'],
['value2', 'aTitle2'],
['value3', 'aTitle3']
]
}
]
}
}
};
beforeEach(function() {
ctx.backendSrv.datasourceRequest = function(options) {
return ctx.$q.when({data: response, status: 200});
};
ctx.ds.metricFindQuery(query).then(function(data) { results = data; });
ctx.$rootScope.$apply();
});
it('should return list of as text, value', function() {
expect(results.length).to.be(3);
expect(results[0].text).to.be('aTitle');
expect(results[0].value).to.be('value1');
expect(results[2].text).to.be('aTitle3');
expect(results[2].value).to.be('value3');
});
});
describe('When performing metricFindQuery with key, value columns and with duplicate keys', function() {
let results;
const query = 'select * from atable';
const response = {
results: {
tempvar: {
meta: {
rowCount: 3
},
refId: 'tempvar',
tables: [
{
columns: [{text: '__text'}, {text: '__value'}],
rows: [
['aTitle', 'same'],
['aTitle', 'same'],
['aTitle', 'diff']
]
}
]
}
}
};
beforeEach(function() {
ctx.backendSrv.datasourceRequest = function(options) {
return ctx.$q.when({data: response, status: 200});
};
ctx.ds.metricFindQuery(query).then(function(data) { results = data; });
ctx.$rootScope.$apply();
});
it('should return list of unique keys', function() {
expect(results.length).to.be(1);
expect(results[0].text).to.be('aTitle');
expect(results[0].value).to.be('same');
});
});
});
<query-editor-row query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="true">
<query-editor-row query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="false">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<code-editor content="ctrl.target.expr" on-change="ctrl.refreshMetricData()"
......
......@@ -157,9 +157,9 @@ export class TableRenderer {
// because of the fixed table headers css only solution
// there is an issue if header cell is wider the cell
// this hack adds header content to cell (not visible)
var widthHack = '';
var columnHtml = '';
if (addWidthHack) {
widthHack = '<div class="table-panel-width-hack">' + this.table.columns[columnIndex].title + '</div>';
columnHtml = '<div class="table-panel-width-hack">' + this.table.columns[columnIndex].title + '</div>';
}
if (value === undefined) {
......@@ -173,8 +173,6 @@ export class TableRenderer {
cellClasses.push("table-panel-cell-pre");
}
var columnHtml = widthHack + value;
if (column.style && column.style.link) {
// Render cell as link
var scopedVars = this.renderRowVariables(rowIndex);
......@@ -185,11 +183,13 @@ export class TableRenderer {
var cellTarget = column.style.linkTargetBlank ? '_blank' : '';
cellClasses.push("table-panel-cell-link");
columnHtml = `
columnHtml += `
<a href="${cellLink}" target="${cellTarget}" data-link-tooltip data-original-title="${cellLinkTooltip}" data-placement="right">
${columnHtml}
${value}
</a>
`;
} else {
columnHtml += value;
}
if (column.filterable) {
......
......@@ -84,7 +84,7 @@
a {
padding: 0.45em 0 0.45em 1.1em;
height: 100%;
width: 100%;
display: inline-block;
}
}
......
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