Commit 68f74135 by Ryan McKinley Committed by GitHub

SingleStat: use DataFrame results rather than TimeSeries/TableData (#18580)

parent e6fbf358
......@@ -501,6 +501,237 @@
}
],
"valueName": "current"
},
{
"cacheTimeout": null,
"colorBackground": false,
"colorPrefix": false,
"colorValue": false,
"colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"],
"datasource": "gdev-testdata",
"decimals": null,
"description": "",
"format": "none",
"gauge": {
"maxValue": 150,
"minValue": 0,
"show": false,
"thresholdLabels": false,
"thresholdMarkers": true
},
"gridPos": {
"h": 4,
"w": 8,
"x": 0,
"y": 14
},
"id": 8,
"interval": null,
"links": [],
"mappingType": 1,
"mappingTypes": [
{
"name": "value to text",
"value": 1
},
{
"name": "range to text",
"value": 2
}
],
"maxDataPoints": 100,
"nullPointMode": "connected",
"nullText": null,
"options": {},
"postfix": "",
"postfixFontSize": "50%",
"prefix": "",
"prefixFontSize": "50%",
"rangeMaps": [
{
"from": "null",
"text": "N/A",
"to": "null"
}
],
"sparkline": {
"fillColor": "rgba(31, 118, 189, 0.18)",
"full": true,
"lineColor": "rgb(31, 120, 193)",
"show": false
},
"tableColumn": "Info",
"targets": [
{
"alias": "",
"expr": "",
"format": "time_series",
"intervalFactor": 1,
"refId": "A",
"scenarioId": "random_walk_table",
"stringInput": ""
}
],
"thresholds": "81,90",
"title": "TableData 'Info' string Column",
"type": "singlestat",
"valueFontSize": "80%",
"valueMaps": [],
"valueName": "current"
},
{
"cacheTimeout": null,
"colorBackground": false,
"colorPrefix": false,
"colorValue": false,
"colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"],
"datasource": "gdev-testdata",
"decimals": 2,
"description": "",
"format": "celsius",
"gauge": {
"maxValue": 150,
"minValue": 0,
"show": false,
"thresholdLabels": false,
"thresholdMarkers": true
},
"gridPos": {
"h": 4,
"w": 8,
"x": 8,
"y": 14
},
"id": 9,
"interval": null,
"links": [],
"mappingType": 1,
"mappingTypes": [
{
"name": "value to text",
"value": 1
},
{
"name": "range to text",
"value": 2
}
],
"maxDataPoints": 100,
"nullPointMode": "connected",
"nullText": null,
"options": {},
"postfix": "",
"postfixFontSize": "50%",
"prefix": "",
"prefixFontSize": "50%",
"rangeMaps": [
{
"from": "null",
"text": "N/A",
"to": "null"
}
],
"sparkline": {
"fillColor": "rgba(31, 118, 189, 0.18)",
"full": true,
"lineColor": "rgb(31, 120, 193)",
"show": false
},
"tableColumn": "Min",
"targets": [
{
"alias": "",
"expr": "",
"format": "time_series",
"intervalFactor": 1,
"refId": "A",
"scenarioId": "random_walk_table",
"stringInput": ""
}
],
"thresholds": "81,90",
"title": "TableData 'Value' as temp Column",
"type": "singlestat",
"valueFontSize": "80%",
"valueMaps": [],
"valueName": "current"
},
{
"cacheTimeout": null,
"colorBackground": false,
"colorPrefix": false,
"colorValue": false,
"colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"],
"datasource": "gdev-testdata",
"decimals": null,
"description": "",
"format": "dateTimeFromNow",
"gauge": {
"maxValue": 150,
"minValue": 0,
"show": false,
"thresholdLabels": false,
"thresholdMarkers": true
},
"gridPos": {
"h": 4,
"w": 8,
"x": 16,
"y": 14
},
"id": 10,
"interval": null,
"links": [],
"mappingType": 1,
"mappingTypes": [
{
"name": "value to text",
"value": 1
},
{
"name": "range to text",
"value": 2
}
],
"maxDataPoints": 100,
"nullPointMode": "connected",
"nullText": null,
"options": {},
"postfix": "",
"postfixFontSize": "50%",
"prefix": "",
"prefixFontSize": "50%",
"rangeMaps": [
{
"from": "null",
"text": "N/A",
"to": "null"
}
],
"sparkline": {
"fillColor": "rgba(31, 118, 189, 0.18)",
"full": true,
"lineColor": "rgb(31, 120, 193)",
"show": false
},
"tableColumn": "time",
"targets": [
{
"alias": "",
"expr": "",
"format": "time_series",
"intervalFactor": 1,
"refId": "A",
"scenarioId": "random_walk",
"stringInput": ""
}
],
"thresholds": "81,90",
"title": "last_time display (a few seconds ago)",
"type": "singlestat",
"valueFontSize": "80%",
"valueMaps": [],
"valueName": "last_time"
}
],
"refresh": false,
......
......@@ -192,7 +192,7 @@ export function migrateOldThresholds(thresholds?: any[]): Threshold[] | undefine
/**
* Convert the angular single stat mapping to new react style
*/
function convertOldAngulrValueMapping(panel: any): ValueMapping[] {
export function convertOldAngulrValueMapping(panel: any): ValueMapping[] {
const mappings: ValueMapping[] = [];
// Guess the right type based on options
......
......@@ -5,4 +5,5 @@ export {
SingleStatBaseOptions,
sharedSingleStatPanelChangedHandler,
sharedSingleStatMigrationHandler,
convertOldAngulrValueMapping,
} from './SingleStatBaseOptions';
......@@ -58,6 +58,14 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP
if (shouldFormat && !_.isBoolean(value)) {
const { decimals, scaledDecimals } = getDecimalsForValue(value, field.decimals);
text = formatFunc(numeric, decimals, scaledDecimals, options.isUtc);
// Check if the formatted text mapped to a different value
if (mappings && mappings.length > 0) {
const mappedValue = getMappedValue(mappings, text);
if (mappedValue) {
text = mappedValue.text;
}
}
}
if (thresholds && thresholds.length) {
color = getColorFromThreshold(numeric, thresholds, theme);
......
......@@ -40,16 +40,18 @@
<h5 class="section-heading">Value</h5>
<div class="gf-form-inline">
<div class="gf-form" ng-show="ctrl.dataType === 'timeseries'">
<label class="gf-form-label width-6">Stat</label>
<div class="gf-form" ng-if="ctrl.fieldNames.length > 1">
<label class="gf-form-label width-6">Field</label>
<div class="gf-form-select-wrapper width-12">
<select class="gf-form-input" ng-model="ctrl.panel.valueName" ng-options="f.value as f.text for f in ctrl.valueNameOptions" ng-change="ctrl.refresh()"></select>
<select class="gf-form-input" ng-model="ctrl.panel.tableColumn" ng-options="f for f in ctrl.fieldNames" ng-change="ctrl.refresh()"></select>
</div>
</div>
<div class="gf-form" ng-show="ctrl.dataType === 'table'">
<label class="gf-form-label width-6">Column</label>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label width-6">Show</label>
<div class="gf-form-select-wrapper width-12">
<select class="gf-form-input" ng-model="ctrl.panel.tableColumn" ng-options="f for f in ctrl.tableColumnOptions" ng-change="ctrl.refresh()"></select>
<select class="gf-form-input" ng-model="ctrl.panel.valueName" ng-options="f.value as f.text for f in ctrl.valueNameOptions" ng-change="ctrl.refresh()"></select>
</div>
</div>
<div class="gf-form">
......@@ -64,19 +66,24 @@
<div class="gf-form">
<label class="gf-form-label width-6">Prefix</label>
<input type="text" class="gf-form-input width-12" ng-model="ctrl.panel.prefix" ng-change="ctrl.render()" ng-model-onblur>
</div>
<div class="gf-form">
<label class="gf-form-label width-6">Font size</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.panel.prefixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()" ng-disabled="!ctrl.canModifyText()"></select>
</div>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-6">Postfix</label>
<input type="text" class="gf-form-input width-12" ng-model="ctrl.panel.postfix" ng-change="ctrl.render()" ng-model-onblur>
<label class="gf-form-label width-6">Font size</label>
<div class="gf-form-select-wrapper">
<select class="input-small gf-form-input" ng-model="ctrl.panel.postfixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()" ng-disabled="!ctrl.canModifyText()"></select>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label width-6">Postfix</label>
<input type="text" class="gf-form-input width-12" ng-model="ctrl.panel.postfix" ng-change="ctrl.render()" ng-model-onblur>
</div>
<div class="gf-form">
<label class="gf-form-label width-6">Font size</label>
<div class="gf-form-select-wrapper">
<select class="input-small gf-form-input" ng-model="ctrl.panel.postfixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()" ng-disabled="!ctrl.canModifyText()"></select>
</div>
</div>
</div>
<div class="gf-form">
......@@ -122,7 +129,7 @@
<div class="section gf-form-group">
<h5 class="section-heading">Spark lines</h5>
<gf-form-switch class="gf-form" label-class="width-9" label="Show" checked="ctrl.panel.sparkline.show" on-change="ctrl.render()"></gf-form-switch>
<gf-form-switch class="gf-form" label-class="width-9" label="Show" checked="ctrl.panel.sparkline.show" on-change="ctrl.refresh()"></gf-form-switch>
<div ng-if="ctrl.panel.sparkline.show">
<gf-form-switch class="gf-form" label-class="width-9" label="Full height" checked="ctrl.panel.sparkline.full" on-change="ctrl.render()"></gf-form-switch>
<div class="gf-form">
......
......@@ -6,7 +6,7 @@
</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.panel.mappingType"
ng-options="f.value as f.name for f in ctrl.panel.mappingTypes" ng-change="ctrl.render()"></select>
ng-options="f.value as f.name for f in ctrl.panel.mappingTypes" ng-change="ctrl.refresh()"></select>
</div>
</div>
</div>
......@@ -18,11 +18,11 @@
<span class="gf-form-label">
<i class="fa fa-remove pointer" ng-click="ctrl.removeValueMap(map)"></i>
</span>
<input type="text" ng-model="map.value" placeholder="value" class="gf-form-input max-width-6" ng-blur="ctrl.render()">
<input type="text" ng-model="map.value" placeholder="value" class="gf-form-input max-width-6" ng-blur="ctrl.refresh()">
<span class="gf-form-label">
<i class="fa fa-arrow-right"></i>
</span>
<input type="text" placeholder="text" ng-model="map.text" class="gf-form-input max-width-8" ng-blur="ctrl.render()">
<input type="text" placeholder="text" ng-model="map.text" class="gf-form-input max-width-8" ng-blur="ctrl.refresh()">
</div>
<div class="gf-form-button-row">
......@@ -41,11 +41,11 @@
<i class="fa fa-remove pointer" ng-click="ctrl.removeRangeMap(rangeMap)"></i>
</span>
<span class="gf-form-label">From</span>
<input type="text" ng-model="rangeMap.from" class="gf-form-input max-width-6" ng-blur="ctrl.render()">
<input type="text" ng-model="rangeMap.from" class="gf-form-input max-width-6" ng-blur="ctrl.refresh()">
<span class="gf-form-label">To</span>
<input type="text" ng-model="rangeMap.to" class="gf-form-input max-width-6" ng-blur="ctrl.render()">
<input type="text" ng-model="rangeMap.to" class="gf-form-input max-width-6" ng-blur="ctrl.refresh()">
<span class="gf-form-label">Text</span>
<input type="text" ng-model="rangeMap.text" class="gf-form-input max-width-8" ng-blur="ctrl.render()">
<input type="text" ng-model="rangeMap.text" class="gf-form-input max-width-8" ng-blur="ctrl.refresh()">
</div>
<div class="gf-form-button-row">
......
......@@ -3,34 +3,56 @@ import $ from 'jquery';
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.gauge';
import 'app/features/panel/panellinks/link_srv';
import { getDecimalsForValue } from '@grafana/ui';
import {
LegacyResponseData,
getFlotPairs,
getDisplayProcessor,
convertOldAngulrValueMapping,
getColorFromHexRgbOrName,
} from '@grafana/ui';
import kbn from 'app/core/utils/kbn';
import config from 'app/core/config';
import TimeSeries from 'app/core/time_series2';
import { MetricsPanelCtrl } from 'app/plugins/sdk';
import { isTableData } from '@grafana/data';
import { GrafanaThemeType, getValueFormat, getColorFromHexRgbOrName } from '@grafana/ui';
import {
DataFrame,
FieldType,
reduceField,
ReducerID,
Field,
GraphSeriesValue,
DisplayValue,
fieldReducers,
KeyValue,
} from '@grafana/data';
import { auto } from 'angular';
import { LinkSrv, LinkModel } from 'app/features/panel/panellinks/link_srv';
import TableModel from 'app/core/table_model';
import { PanelQueryRunnerFormat } from 'app/features/dashboard/state/PanelQueryRunner';
import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState';
const BASE_FONT_SIZE = 38;
interface DataFormat {
value: string | number;
valueFormatted: string;
valueRounded: number;
export interface ShowData {
field: Field;
value: any;
sparkline: GraphSeriesValue[][];
display: DisplayValue;
scopedVars: any;
thresholds: any[];
colorMap: any;
}
class SingleStatCtrl extends MetricsPanelCtrl {
static templateUrl = 'module.html';
dataType = 'timeseries';
series: any[];
data: any;
data: Partial<ShowData> = {};
fontSizes: any[];
unitFormats: any[];
fieldNames: string[] = [];
invalidGaugeRange: boolean;
panel: any;
events: any;
......@@ -47,7 +69,6 @@ class SingleStatCtrl extends MetricsPanelCtrl {
{ value: 'range', text: 'Range' },
{ value: 'last_time', text: 'Time of last point' },
];
tableColumnOptions: any;
// Set and populate defaults
panelDefaults: any = {
......@@ -102,6 +123,8 @@ class SingleStatCtrl extends MetricsPanelCtrl {
this.events.on('data-snapshot-load', this.onDataReceived.bind(this));
this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
this.dataFormat = PanelQueryRunnerFormat.frames;
this.onSparklineColorChange = this.onSparklineColorChange.bind(this);
this.onSparklineFillChange = this.onSparklineFillChange.bind(this);
}
......@@ -128,104 +151,115 @@ class SingleStatCtrl extends MetricsPanelCtrl {
}
onDataError(err: any) {
this.onDataReceived([]);
this.handleDataFrames([]);
}
onDataReceived(dataList: any[]) {
const data: any = {
scopedVars: _.extend({}, this.panel.scopedVars),
};
// This should only be called from the snapshot callback
onDataReceived(dataList: LegacyResponseData[]) {
this.handleDataFrames(getProcessedDataFrames(dataList));
}
// Directly support DataFrame skipping event callbacks
handleDataFrames(frames: DataFrame[]) {
const { panel } = this;
super.handleDataFrames(frames);
this.loading = false;
const distinct = getDistinctNames(frames);
let fieldInfo = distinct.byName[panel.tableColumn]; //
this.fieldNames = distinct.names;
if (!fieldInfo) {
fieldInfo = distinct.first;
}
if (dataList.length > 0 && isTableData(dataList[0])) {
this.dataType = 'table';
const tableData = dataList.map(this.tableHandler.bind(this));
this.setTableValues(tableData, data);
if (!fieldInfo) {
// When we don't have any field
this.data = {
value: 'No Data',
display: {
text: 'No Data',
numeric: NaN,
},
};
} else {
this.dataType = 'timeseries';
this.series = dataList.map(this.seriesHandler.bind(this));
this.setValues(data);
this.data = this.processField(fieldInfo);
}
this.data = data;
this.render();
}
seriesHandler(dataFrame: any) {
const series = new TimeSeries({
datapoints: dataFrame.datapoints || [],
alias: dataFrame.target,
});
processField(fieldInfo: FieldInfo) {
const { panel, dashboard } = this;
series.flotpairs = series.getFlotPairs(this.panel.nullPointMode);
return series;
}
const name = fieldInfo.field.config.title || fieldInfo.field.name;
let calc = panel.valueName;
let calcField = fieldInfo.field;
let val: any = undefined;
tableHandler(tableData: TableModel) {
const datapoints: any[] = [];
const columnNames: string[] = [];
if ('name' === calc) {
val = name;
} else {
if ('last_time' === calc) {
if (fieldInfo.frame.firstTimeField) {
calcField = fieldInfo.frame.firstTimeField;
calc = ReducerID.last;
}
}
tableData.columns.forEach((column, columnIndex) => {
columnNames[columnIndex] = column.text;
});
// Normalize functions (avg -> mean, etc)
const r = fieldReducers.getIfExists(calc);
if (r) {
calc = r.id;
// With strings, don't accidentally use a math function
if (calcField.type === FieldType.string) {
const avoid = [ReducerID.mean, ReducerID.sum];
if (avoid.includes(calc)) {
calc = panel.valueName = ReducerID.first;
}
}
} else {
calc = ReducerID.lastNotNull;
}
this.tableColumnOptions = columnNames;
if (!_.find(tableData.columns, ['text', this.panel.tableColumn])) {
this.setTableColumnToSensibleDefault(tableData);
// Calculate the value
val = reduceField({
field: calcField,
reducers: [calc],
})[calc];
}
tableData.rows.forEach(row => {
const datapoint: any = {};
row.forEach((value: any, columnIndex: number) => {
const key = columnNames[columnIndex];
datapoint[key] = value;
});
datapoints.push(datapoint);
const processor = getDisplayProcessor({
field: {
...fieldInfo.field.config,
unit: panel.format,
decimals: panel.decimals,
mappings: convertOldAngulrValueMapping(panel),
},
theme: config.theme,
isUtc: dashboard.isTimezoneUtc && dashboard.isTimezoneUtc(),
});
return datapoints;
}
setTableColumnToSensibleDefault(tableData: TableModel) {
if (tableData.columns.length === 1) {
this.panel.tableColumn = tableData.columns[0].text;
} else {
this.panel.tableColumn = _.find(tableData.columns, col => {
return col.type !== 'time';
}).text;
}
}
setTableValues(tableData: any[], data: DataFormat) {
if (!tableData || tableData.length === 0) {
return;
}
if (tableData[0].length === 0 || tableData[0][0][this.panel.tableColumn] === undefined) {
return;
}
const data = {
field: fieldInfo.field,
value: val,
display: processor(val),
scopedVars: _.extend({}, panel.scopedVars),
};
const datapoint = tableData[0][0];
data.value = datapoint[this.panel.tableColumn];
data.scopedVars['__name'] = name;
panel.tableColumn = this.fieldNames.length > 1 ? name : '';
if (_.isString(data.value)) {
data.valueFormatted = _.escape(data.value);
data.value = 0;
data.valueRounded = 0;
} else {
const decimalInfo = getDecimalsForValue(data.value, this.panel.decimals);
const formatFunc = getValueFormat(this.panel.format);
data.valueFormatted = formatFunc(
datapoint[this.panel.tableColumn],
decimalInfo.decimals,
decimalInfo.scaledDecimals
);
data.valueRounded = kbn.roundValue(data.value, decimalInfo.decimals);
// Get the fields for a sparkline
if (panel.sparkline && panel.sparkline.show && fieldInfo.frame.firstTimeField) {
this.data.sparkline = getFlotPairs({
xField: fieldInfo.frame.firstTimeField,
yField: fieldInfo.field,
nullValueMode: panel.nullPointMode,
});
}
this.setValueMapping(data);
return data;
}
canModifyText() {
......@@ -267,106 +301,6 @@ class SingleStatCtrl extends MetricsPanelCtrl {
this.render();
}
setValues(data: any) {
data.flotpairs = [];
if (this.series.length > 1) {
const error: any = new Error();
error.message = 'Multiple Series Error';
error.data =
'Metric query returns ' +
this.series.length +
' series. Single Stat Panel expects a single series.\n\nResponse:\n' +
JSON.stringify(this.series);
throw error;
}
if (this.series && this.series.length > 0) {
const lastPoint: any = _.last(this.series[0].datapoints);
const lastValue = _.isArray(lastPoint) ? lastPoint[0] : null;
const formatFunc = getValueFormat(this.panel.format);
if (this.panel.valueName === 'name') {
data.value = 0;
data.valueRounded = 0;
data.valueFormatted = this.series[0].alias;
} else if (_.isString(lastValue)) {
data.value = 0;
data.valueFormatted = _.escape(lastValue);
data.valueRounded = 0;
} else if (this.panel.valueName === 'last_time') {
data.value = lastPoint[1];
data.valueRounded = data.value;
data.valueFormatted = formatFunc(data.value, 0, 0, this.dashboard.isTimezoneUtc());
} else {
data.value = this.series[0].stats[this.panel.valueName];
data.flotpairs = this.series[0].flotpairs;
const decimalInfo = getDecimalsForValue(data.value, this.panel.decimals);
data.valueFormatted = formatFunc(
data.value,
decimalInfo.decimals,
decimalInfo.scaledDecimals,
this.dashboard.isTimezoneUtc()
);
data.valueRounded = kbn.roundValue(data.value, decimalInfo.decimals);
}
// Add $__name variable for using in prefix or postfix
data.scopedVars['__name'] = { value: this.series[0].label };
}
this.setValueMapping(data);
}
setValueMapping(data: DataFormat) {
// check value to text mappings if its enabled
if (this.panel.mappingType === 1) {
for (let i = 0; i < this.panel.valueMaps.length; i++) {
const map = this.panel.valueMaps[i];
// special null case
if (map.value === 'null') {
if (data.value === null || data.value === void 0) {
data.valueFormatted = map.text;
return;
}
continue;
}
// value/number to text mapping
const value = parseFloat(map.value);
if (value === data.valueRounded) {
data.valueFormatted = map.text;
return;
}
}
} else if (this.panel.mappingType === 2) {
for (let i = 0; i < this.panel.rangeMaps.length; i++) {
const map = this.panel.rangeMaps[i];
// special null case
if (map.from === 'null' && map.to === 'null') {
if (data.value === null || data.value === void 0) {
data.valueFormatted = map.text;
return;
}
continue;
}
// value/number to range mapping
const from = parseFloat(map.from);
const to = parseFloat(map.to);
if (to >= data.valueRounded && from <= data.valueRounded) {
data.valueFormatted = map.text;
return;
}
}
}
if (data.value === null || data.value === void 0) {
data.valueFormatted = 'no value';
}
}
removeValueMap(map: any) {
const index = _.indexOf(this.panel.valueMaps, map);
this.panel.valueMaps.splice(index, 1);
......@@ -394,12 +328,12 @@ class SingleStatCtrl extends MetricsPanelCtrl {
const $sanitize = this.$sanitize;
const panel = ctrl.panel;
const templateSrv = this.templateSrv;
let data: any;
let linkInfo: LinkModel | null = null;
const $panelContainer = elem.find('.panel-container');
elem = elem.find('.singlestat-panel');
function applyColoringThresholds(valueString: string) {
const data = ctrl.data;
const color = getColorForValue(data, data.value);
if (color) {
return '<span style="color:' + color + '">' + valueString + '</span>';
......@@ -409,20 +343,21 @@ class SingleStatCtrl extends MetricsPanelCtrl {
}
function getSpan(className: string, fontSizePercent: string, applyColoring: any, value: string) {
value = $sanitize(templateSrv.replace(value, data.scopedVars));
value = $sanitize(templateSrv.replace(value, ctrl.data.scopedVars));
value = applyColoring ? applyColoringThresholds(value) : value;
const pixelSize = (parseInt(fontSizePercent, 10) / 100) * BASE_FONT_SIZE;
return '<span class="' + className + '" style="font-size:' + pixelSize + 'px">' + value + '</span>';
}
function getBigValueHtml() {
const data: ShowData = ctrl.data;
let body = '<div class="singlestat-panel-value-container">';
if (panel.prefix) {
body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, panel.colorPrefix, panel.prefix);
}
body += getSpan('singlestat-panel-value', panel.valueFontSize, panel.colorValue, data.valueFormatted);
body += getSpan('singlestat-panel-value', panel.valueFontSize, panel.colorValue, data.display.text);
if (panel.postfix) {
body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, panel.colorPostfix, panel.postfix);
......@@ -434,14 +369,16 @@ class SingleStatCtrl extends MetricsPanelCtrl {
}
function getValueText() {
const data: ShowData = ctrl.data;
let result = panel.prefix ? templateSrv.replace(panel.prefix, data.scopedVars) : '';
result += data.valueFormatted;
result += data.display.text;
result += panel.postfix ? templateSrv.replace(panel.postfix, data.scopedVars) : '';
return result;
}
function addGauge() {
const data: ShowData = ctrl.data;
const width = elem.width();
const height = elem.height();
// Allow to use a bit more space for wide gauges
......@@ -513,7 +450,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
width: thresholdMarkersWidth,
},
value: {
color: panel.colorValue ? getColorForValue(data, data.valueRounded) : null,
color: panel.colorValue ? getColorForValue(data, data.display.numeric) : null,
formatter: () => {
return getValueText();
},
......@@ -537,6 +474,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
}
function addSparkline() {
const data: ShowData = ctrl.data;
const width = elem.width();
if (width < 30) {
// element has not gotten it's width yet
......@@ -544,6 +482,10 @@ class SingleStatCtrl extends MetricsPanelCtrl {
setTimeout(addSparkline, 30);
return;
}
if (!data.sparkline || !data.sparkline.length) {
// no sparkline data
return;
}
const height = ctrl.height;
const plotCanvas = $('<div></div>');
......@@ -592,7 +534,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
elem.append(plotCanvas);
const plotSeries = {
data: data.flotpairs,
data: data.sparkline,
color: getColorFromHexRgbOrName(panel.sparkline.lineColor, config.theme.type),
};
......@@ -603,26 +545,24 @@ class SingleStatCtrl extends MetricsPanelCtrl {
if (!ctrl.data) {
return;
}
data = ctrl.data;
const { data, panel } = ctrl;
// get thresholds
data.thresholds = panel.thresholds.split(',').map((strVale: string) => {
return Number(strVale.trim());
});
data.thresholds = panel.thresholds
? panel.thresholds.split(',').map((strVale: string) => {
return Number(strVale.trim());
})
: [];
// Map panel colors to hex or rgb/a values
data.colorMap = panel.colors.map((color: string) =>
getColorFromHexRgbOrName(
color,
config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark
)
);
if (panel.colors) {
data.colorMap = panel.colors.map((color: string) => getColorFromHexRgbOrName(color, config.theme.type));
}
const body = panel.gauge.show ? '' : getBigValueHtml();
if (panel.colorBackground) {
const color = getColorForValue(data, data.value);
console.log(color);
const color = getColorForValue(data, data.display.numeric);
if (color) {
$panelContainer.css('background-color', color);
if (scope.fullscreen) {
......@@ -729,4 +669,59 @@ function getColorForValue(data: any, value: number) {
return _.first(data.colorMap);
}
//------------------------------------------------
// Private utility functions
// Somethign like this should be avaliable in a
// DataFrame[] abstraction helper
//------------------------------------------------
interface FrameInfo {
firstTimeField?: Field;
frame: DataFrame;
}
interface FieldInfo {
field: Field;
frame: FrameInfo;
}
interface DistinctFieldsInfo {
first?: FieldInfo;
byName: KeyValue<FieldInfo>;
names: string[];
}
function getDistinctNames(data: DataFrame[]): DistinctFieldsInfo {
const distinct: DistinctFieldsInfo = {
byName: {},
names: [],
};
for (const frame of data) {
const info: FrameInfo = { frame };
for (const field of frame.fields) {
if (field.type === FieldType.time) {
if (!info.firstTimeField) {
info.firstTimeField = field;
}
} else {
const f = { field, frame: info };
if (!distinct.first) {
distinct.first = f;
}
let t = field.config.title;
if (t && !distinct.byName[t]) {
distinct.byName[t] = f;
distinct.names.push(t);
}
t = field.name;
if (t && !distinct.byName[t]) {
distinct.byName[t] = f;
distinct.names.push(t);
}
}
}
}
return distinct;
}
export { SingleStatCtrl, SingleStatCtrl as PanelCtrl, getColorForValue };
import { SingleStatCtrl } from '../module';
import { dateTime } from '@grafana/data';
import { SingleStatCtrl, ShowData } from '../module';
import { dateTime, ReducerID } from '@grafana/data';
import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { LegacyResponseData } from '@grafana/ui';
interface TestContext {
ctrl: SingleStatCtrl;
input: LegacyResponseData[];
data: Partial<ShowData>;
setup: (setupFunc: any) => void;
}
describe('SingleStatCtrl', () => {
const ctx = {} as any;
const ctx: TestContext = {} as TestContext;
const epoch = 1505826363746;
Date.now = () => epoch;
......@@ -37,7 +45,7 @@ describe('SingleStatCtrl', () => {
// @ts-ignore
ctx.ctrl = new SingleStatCtrl($scope, $injector, {} as LinkSrv, $sanitize);
setupFunc();
ctx.ctrl.onDataReceived(ctx.data);
ctx.ctrl.onDataReceived(ctx.input);
ctx.data = ctx.ctrl.data;
});
};
......@@ -46,40 +54,38 @@ describe('SingleStatCtrl', () => {
});
}
singleStatScenario('with defaults', (ctx: any) => {
singleStatScenario('with defaults', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 1], [20, 2]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[10, 1], [20, 2]] }];
});
it('Should use series avg as default main value', () => {
expect(ctx.data.value).toBe(15);
expect(ctx.data.valueRounded).toBe(15);
});
it('should set formatted falue', () => {
expect(ctx.data.valueFormatted).toBe('15');
expect(ctx.data.display.text).toBe('15');
});
});
singleStatScenario('showing serie name instead of value', (ctx: any) => {
singleStatScenario('showing serie name instead of value', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 1], [20, 2]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[10, 1], [20, 2]] }];
ctx.ctrl.panel.valueName = 'name';
});
it('Should use series avg as default main value', () => {
expect(ctx.data.value).toBe(0);
expect(ctx.data.valueRounded).toBe(0);
expect(ctx.data.value).toBe('test.cpu1');
});
it('should set formatted value', () => {
expect(ctx.data.valueFormatted).toBe('test.cpu1');
expect(ctx.data.display.text).toBe('test.cpu1');
});
});
singleStatScenario('showing last iso time instead of value', (ctx: any) => {
singleStatScenario('showing last iso time instead of value', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.ctrl.panel.valueName = 'last_time';
ctx.ctrl.panel.format = 'dateTimeAsIso';
ctx.ctrl.dashboard.isTimezoneUtc = () => false;
......@@ -87,30 +93,29 @@ describe('SingleStatCtrl', () => {
it('Should use time instead of value', () => {
expect(ctx.data.value).toBe(1505634997920);
expect(ctx.data.valueRounded).toBe(1505634997920);
});
it('should set formatted value', () => {
expect(dateTime(ctx.data.valueFormatted).valueOf()).toBe(1505634997000);
expect(dateTime(ctx.data.display.text).valueOf()).toBe(1505634997000);
});
});
singleStatScenario('showing last iso time instead of value (in UTC)', (ctx: any) => {
singleStatScenario('showing last iso time instead of value (in UTC)', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 5000]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 5000]] }];
ctx.ctrl.panel.valueName = 'last_time';
ctx.ctrl.panel.format = 'dateTimeAsIso';
ctx.ctrl.dashboard.isTimezoneUtc = () => true;
});
it('should set value', () => {
expect(ctx.data.valueFormatted).toBe('1970-01-01 00:00:05');
expect(ctx.data.display.text).toBe('1970-01-01 00:00:05');
});
});
singleStatScenario('showing last us time instead of value', (ctx: any) => {
singleStatScenario('showing last us time instead of value', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.ctrl.panel.valueName = 'last_time';
ctx.ctrl.panel.format = 'dateTimeAsUS';
ctx.ctrl.dashboard.isTimezoneUtc = () => false;
......@@ -118,79 +123,76 @@ describe('SingleStatCtrl', () => {
it('Should use time instead of value', () => {
expect(ctx.data.value).toBe(1505634997920);
expect(ctx.data.valueRounded).toBe(1505634997920);
});
it('should set formatted value', () => {
expect(ctx.data.valueFormatted).toBe(dateTime(1505634997920).format('MM/DD/YYYY h:mm:ss a'));
expect(ctx.data.display.text).toBe(dateTime(1505634997920).format('MM/DD/YYYY h:mm:ss a'));
});
});
singleStatScenario('showing last us time instead of value (in UTC)', (ctx: any) => {
singleStatScenario('showing last us time instead of value (in UTC)', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 5000]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 5000]] }];
ctx.ctrl.panel.valueName = 'last_time';
ctx.ctrl.panel.format = 'dateTimeAsUS';
ctx.ctrl.dashboard.isTimezoneUtc = () => true;
});
it('should set formatted value', () => {
expect(ctx.data.valueFormatted).toBe('01/01/1970 12:00:05 am');
expect(ctx.data.display.text).toBe('01/01/1970 12:00:05 am');
});
});
singleStatScenario('showing last time from now instead of value', (ctx: any) => {
singleStatScenario('showing last time from now instead of value', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.ctrl.panel.valueName = 'last_time';
ctx.ctrl.panel.format = 'dateTimeFromNow';
});
it('Should use time instead of value', () => {
expect(ctx.data.value).toBe(1505634997920);
expect(ctx.data.valueRounded).toBe(1505634997920);
});
it('should set formatted value', () => {
expect(ctx.data.valueFormatted).toBe('2 days ago');
expect(ctx.data.display.text).toBe('2 days ago');
});
});
singleStatScenario('showing last time from now instead of value (in UTC)', (ctx: any) => {
singleStatScenario('showing last time from now instead of value (in UTC)', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.ctrl.panel.valueName = 'last_time';
ctx.ctrl.panel.format = 'dateTimeFromNow';
});
it('should set formatted value', () => {
expect(ctx.data.valueFormatted).toBe('2 days ago');
expect(ctx.data.display.text).toBe('2 days ago');
});
});
singleStatScenario(
'MainValue should use same number for decimals as displayed when checking thresholds',
(ctx: any) => {
(ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[99.999, 1], [99.99999, 2]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[99.999, 1], [99.99999, 2]] }];
ctx.ctrl.panel.valueName = 'avg';
ctx.ctrl.panel.format = 'none';
});
it('Should be rounded', () => {
expect(ctx.data.value).toBe(99.999495);
expect(ctx.data.valueRounded).toBe(100);
});
it('should set formatted value', () => {
expect(ctx.data.valueFormatted).toBe('100');
expect(ctx.data.display.text).toBe('100');
});
}
);
singleStatScenario('When value to text mapping is specified', (ctx: any) => {
singleStatScenario('When value to text mapping is specified', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[9.9, 1]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[9.9, 1]] }];
ctx.ctrl.panel.valueMaps = [{ value: '10', text: 'OK' }];
});
......@@ -198,36 +200,32 @@ describe('SingleStatCtrl', () => {
expect(ctx.data.value).toBe(9.9);
});
it('round should be rounded up', () => {
expect(ctx.data.valueRounded).toBe(10);
});
it('Should replace value with text', () => {
expect(ctx.data.valueFormatted).toBe('OK');
expect(ctx.data.display.text).toBe('OK');
});
});
singleStatScenario('When range to text mapping is specified for first range', (ctx: any) => {
singleStatScenario('When range to text mapping is specified for first range', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[41, 50]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[41, 50]] }];
ctx.ctrl.panel.mappingType = 2;
ctx.ctrl.panel.rangeMaps = [{ from: '10', to: '50', text: 'OK' }, { from: '51', to: '100', text: 'NOT OK' }];
});
it('Should replace value with text OK', () => {
expect(ctx.data.valueFormatted).toBe('OK');
expect(ctx.data.display.text).toBe('OK');
});
});
singleStatScenario('When range to text mapping is specified for other ranges', (ctx: any) => {
singleStatScenario('When range to text mapping is specified for other ranges', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = [{ target: 'test.cpu1', datapoints: [[65, 75]] }];
ctx.input = [{ target: 'test.cpu1', datapoints: [[65, 75]] }];
ctx.ctrl.panel.mappingType = 2;
ctx.ctrl.panel.rangeMaps = [{ from: '10', to: '50', text: 'OK' }, { from: '51', to: '100', text: 'NOT OK' }];
});
it('Should replace value with text NOT OK', () => {
expect(ctx.data.valueFormatted).toBe('NOT OK');
expect(ctx.data.display.text).toBe('NOT OK');
});
});
......@@ -240,9 +238,9 @@ describe('SingleStatCtrl', () => {
},
];
singleStatScenario('with default values', (ctx: any) => {
singleStatScenario('with default values', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = tableData;
ctx.input = tableData;
ctx.ctrl.panel = {
emit: () => {},
};
......@@ -252,17 +250,16 @@ describe('SingleStatCtrl', () => {
it('Should use first rows value as default main value', () => {
expect(ctx.data.value).toBe(15);
expect(ctx.data.valueRounded).toBe(15);
});
it('should set formatted value', () => {
expect(ctx.data.valueFormatted).toBe('15');
expect(ctx.data.display.text).toBe('15');
});
});
singleStatScenario('When table data has multiple columns', (ctx: any) => {
singleStatScenario('When table data has multiple columns', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = tableData;
ctx.input = tableData;
ctx.ctrl.panel.tableColumn = '';
});
......@@ -273,29 +270,28 @@ describe('SingleStatCtrl', () => {
singleStatScenario(
'MainValue should use same number for decimals as displayed when checking thresholds',
(ctx: any) => {
(ctx: TestContext) => {
ctx.setup(() => {
ctx.data = tableData;
ctx.data[0].rows[0] = [1492759673649, 'ignore1', 99.99999, 'ignore2'];
ctx.input = tableData;
ctx.input[0].rows[0] = [1492759673649, 'ignore1', 99.99999, 'ignore2'];
ctx.ctrl.panel.mappingType = 0;
ctx.ctrl.panel.tableColumn = 'mean';
});
it('Should be rounded', () => {
expect(ctx.data.value).toBe(99.99999);
expect(ctx.data.valueRounded).toBe(100);
});
it('should set formatted falue', () => {
expect(ctx.data.valueFormatted).toBe('100');
expect(ctx.data.display.text).toBe('100');
});
}
);
singleStatScenario('When value to text mapping is specified', (ctx: any) => {
singleStatScenario('When value to text mapping is specified', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = tableData;
ctx.data[0].rows[0] = [1492759673649, 'ignore1', 9.9, 'ignore2'];
ctx.input = tableData;
ctx.input[0].rows[0] = [1492759673649, 'ignore1', 9.9, 'ignore2'];
ctx.ctrl.panel.mappingType = 2;
ctx.ctrl.panel.tableColumn = 'mean';
ctx.ctrl.panel.valueMaps = [{ value: '10', text: 'OK' }];
......@@ -305,59 +301,60 @@ describe('SingleStatCtrl', () => {
expect(ctx.data.value).toBe(9.9);
});
it('round should be rounded up', () => {
expect(ctx.data.valueRounded).toBe(10);
});
// it('round should be rounded up', () => {
// expect(ctx.data.valueRounded).toBe(10);
// });
it('Should replace value with text', () => {
expect(ctx.data.valueFormatted).toBe('OK');
expect(ctx.data.display.text).toBe('OK');
});
});
singleStatScenario('When range to text mapping is specified for first range', (ctx: any) => {
singleStatScenario('When range to text mapping is specified for first range', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = tableData;
ctx.data[0].rows[0] = [1492759673649, 'ignore1', 41, 'ignore2'];
ctx.input = tableData;
ctx.input[0].rows[0] = [1492759673649, 'ignore1', 41, 'ignore2'];
ctx.ctrl.panel.tableColumn = 'mean';
ctx.ctrl.panel.mappingType = 2;
ctx.ctrl.panel.rangeMaps = [{ from: '10', to: '50', text: 'OK' }, { from: '51', to: '100', text: 'NOT OK' }];
});
it('Should replace value with text OK', () => {
expect(ctx.data.valueFormatted).toBe('OK');
expect(ctx.data.display.text).toBe('OK');
});
});
singleStatScenario('When range to text mapping is specified for other ranges', (ctx: any) => {
singleStatScenario('When range to text mapping is specified for other ranges', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = tableData;
ctx.data[0].rows[0] = [1492759673649, 'ignore1', 65, 'ignore2'];
ctx.input = tableData;
ctx.input[0].rows[0] = [1492759673649, 'ignore1', 65, 'ignore2'];
ctx.ctrl.panel.tableColumn = 'mean';
ctx.ctrl.panel.mappingType = 2;
ctx.ctrl.panel.rangeMaps = [{ from: '10', to: '50', text: 'OK' }, { from: '51', to: '100', text: 'NOT OK' }];
});
it('Should replace value with text NOT OK', () => {
expect(ctx.data.valueFormatted).toBe('NOT OK');
expect(ctx.data.display.text).toBe('NOT OK');
});
});
singleStatScenario('When value is string', (ctx: any) => {
singleStatScenario('When value is string', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = tableData;
ctx.data[0].rows[0] = [1492759673649, 'ignore1', 65, 'ignore2'];
ctx.input = tableData;
ctx.input[0].rows[0] = [1492759673649, 'ignore1', 65, 'ignore2'];
ctx.ctrl.panel.tableColumn = 'test1';
ctx.ctrl.panel.valueName = ReducerID.first;
});
it('Should replace value with text NOT OK', () => {
expect(ctx.data.valueFormatted).toBe('ignore1');
expect(ctx.data.display.text).toBe('ignore1');
});
});
singleStatScenario('When value is zero', (ctx: any) => {
singleStatScenario('When value is zero', (ctx: TestContext) => {
ctx.setup(() => {
ctx.data = tableData;
ctx.data[0].rows[0] = [1492759673649, 'ignore1', 0, 'ignore2'];
ctx.input = tableData;
ctx.input[0].rows[0] = [1492759673649, 'ignore1', 0, 'ignore2'];
ctx.ctrl.panel.tableColumn = 'mean';
});
......
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