mirror of
https://github.com/timberjoegithub/grafana-statusmap.git
synced 2026-07-22 07:49:47 +00:00
- use babel to 7, update dependencies, fix #72 - proper babel build to support ngInject, fix #78 - proper buckets display in 6.3.0+, fix #76 - rename js to ts, build d3 lib as js, build src as ts
483 lines
14 KiB
TypeScript
483 lines
14 KiB
TypeScript
// Libraries
|
|
import _ from 'lodash';
|
|
import { auto } from 'angular';
|
|
|
|
// Components
|
|
import './color_legend';
|
|
|
|
// Utils
|
|
import kbn from 'app/core/utils/kbn';
|
|
import {loadPluginCss} from 'app/plugins/sdk';
|
|
|
|
// Types
|
|
import { MetricsPanelCtrl } from 'app/plugins/sdk';
|
|
import { contextSrv } from 'app/core/core';
|
|
import { AnnotationsSrv } from 'app/features/annotations/annotations_srv';
|
|
|
|
import {CardsStorage, Card} from './graph';
|
|
import rendering from './rendering';
|
|
// import aggregates, { aggregatesMap } from './aggregates';
|
|
// import fragments, { fragmentsMap } from './fragments';
|
|
// import { labelFormats } from './xAxisLabelFormats';
|
|
import {statusHeatmapOptionsEditor} from './options_editor';
|
|
import {ColorModeDiscrete} from "./color_mode_discrete";
|
|
|
|
|
|
const CANVAS = 'CANVAS';
|
|
const SVG = 'SVG';
|
|
const VALUE_INDEX = 0,
|
|
TIME_INDEX = 1;
|
|
|
|
const renderer = CANVAS;
|
|
|
|
const colorSchemes = [
|
|
// Diverging
|
|
{ name: 'Spectral', value: 'interpolateSpectral', invert: 'always' },
|
|
{ name: 'RdYlGn', value: 'interpolateRdYlGn', invert: 'always' },
|
|
{ name: 'GnYlRd', value: 'interpolateGnYlRd', invert: 'always' },
|
|
|
|
// Sequential (Single Hue)
|
|
{ name: 'Blues', value: 'interpolateBlues', invert: 'dark' },
|
|
{ name: 'Greens', value: 'interpolateGreens', invert: 'dark' },
|
|
{ name: 'Greys', value: 'interpolateGreys', invert: 'dark' },
|
|
{ name: 'Oranges', value: 'interpolateOranges', invert: 'dark' },
|
|
{ name: 'Purples', value: 'interpolatePurples', invert: 'dark' },
|
|
{ name: 'Reds', value: 'interpolateReds', invert: 'dark' },
|
|
|
|
// Sequential (Multi-Hue)
|
|
{ name: 'BuGn', value: 'interpolateBuGn', invert: 'dark' },
|
|
{ name: 'BuPu', value: 'interpolateBuPu', invert: 'dark' },
|
|
{ name: 'GnBu', value: 'interpolateGnBu', invert: 'dark' },
|
|
{ name: 'OrRd', value: 'interpolateOrRd', invert: 'dark' },
|
|
{ name: 'PuBuGn', value: 'interpolatePuBuGn', invert: 'dark' },
|
|
{ name: 'PuBu', value: 'interpolatePuBu', invert: 'dark' },
|
|
{ name: 'PuRd', value: 'interpolatePuRd', invert: 'dark' },
|
|
{ name: 'RdPu', value: 'interpolateRdPu', invert: 'dark' },
|
|
{ name: 'YlGnBu', value: 'interpolateYlGnBu', invert: 'dark' },
|
|
{ name: 'YlGn', value: 'interpolateYlGn', invert: 'dark' },
|
|
{ name: 'YlOrBr', value: 'interpolateYlOrBr', invert: 'dark' },
|
|
{ name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'dark' }
|
|
];
|
|
|
|
let colorModes = ['opacity', 'spectrum', 'discrete'];
|
|
let opacityScales = ['linear', 'sqrt'];
|
|
|
|
interface DataWarning {
|
|
title: string;
|
|
tip: string;
|
|
}
|
|
|
|
interface DataWarnings {
|
|
noColorDefined: DataWarning;
|
|
multipleValues: DataWarning;
|
|
}
|
|
|
|
interface ColorThreshold {
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadPluginCss({
|
|
dark: 'plugins/flant-statusmap-panel/css/statusmap.dark.css',
|
|
light: 'plugins/flant-statusmap-panel/css/statusmap.light.css'
|
|
});
|
|
|
|
class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
|
static templateUrl = 'module.html';
|
|
|
|
opacityScales: any = [];
|
|
colorModes: any = [];
|
|
colorSchemes: any = [];
|
|
unitFormats: any;
|
|
data: any;
|
|
cardsData: any;
|
|
graph: any;
|
|
multipleValues: boolean;
|
|
noColorDefined: boolean;
|
|
discreteHelper: ColorModeDiscrete;
|
|
dataWarnings: DataWarnings;
|
|
|
|
annotations: object[] = [];
|
|
annotationsPromise: any;
|
|
|
|
panelDefaults: any = {
|
|
// datasource name, null = default datasource
|
|
datasource: null,
|
|
// color mode
|
|
color: {
|
|
mode: 'spectrum',
|
|
cardColor: '#b4ff00',
|
|
colorScale: 'sqrt',
|
|
exponent: 0.5,
|
|
colorScheme: 'interpolateGnYlRd',
|
|
// discrete mode settings
|
|
defaultColor: '#757575',
|
|
thresholds: [] // manual colors
|
|
},
|
|
// buckets settings
|
|
cards: {
|
|
cardMinWidth: 5,
|
|
cardVSpacing: 2,
|
|
cardHSpacing: 2,
|
|
cardRound: null
|
|
},
|
|
xAxis: {
|
|
show: true,
|
|
showWeekends: true,
|
|
minBucketWidthToShowWeekends: 4,
|
|
showCrosshair: true,
|
|
labelFormat: '%a %m/%d'
|
|
},
|
|
yAxis: {
|
|
show: true,
|
|
showCrosshair: false
|
|
},
|
|
tooltip: {
|
|
show: true
|
|
},
|
|
legend: {
|
|
show: true
|
|
},
|
|
data: {
|
|
unitFormat: 'short',
|
|
decimals: null
|
|
},
|
|
// how null points should be handled
|
|
nullPointMode: 'as empty',
|
|
yAxisSort: 'metrics',
|
|
highlightCards: true,
|
|
useMax: true
|
|
};
|
|
|
|
/** @ngInject */
|
|
constructor($scope: any, $injector: auto.IInjectorService, private annotationsSrv: AnnotationsSrv) {
|
|
super($scope, $injector);
|
|
|
|
_.defaultsDeep(this.panel, this.panelDefaults);
|
|
|
|
this.opacityScales = opacityScales;
|
|
this.colorModes = colorModes;
|
|
this.colorSchemes = colorSchemes;
|
|
|
|
// default graph width for discrete card width calculation
|
|
this.graph = {
|
|
"chartWidth" : -1
|
|
};
|
|
|
|
this.multipleValues = false;
|
|
this.noColorDefined = false;
|
|
|
|
this.discreteHelper = new ColorModeDiscrete($scope);
|
|
|
|
this.dataWarnings = {
|
|
"noColorDefined": {
|
|
title: 'Data has value with undefined color',
|
|
tip: 'Check metric values, color values or define a new color',
|
|
},
|
|
"multipleValues": {
|
|
title: 'Data has multiple values for one target',
|
|
tip: 'Change targets definitions or set "use max value"',
|
|
}
|
|
};
|
|
|
|
this.annotations = [];
|
|
|
|
this.events.on('render', this.onRender.bind(this));
|
|
this.events.on('data-received', this.onDataReceived.bind(this));
|
|
this.events.on('data-error', this.onDataError.bind(this));
|
|
this.events.on('data-snapshot-load', this.onDataReceived.bind(this));
|
|
this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
|
|
this.events.on('refresh', this.postRefresh.bind(this));
|
|
// custom event from rendering.js
|
|
this.events.on('render-complete', this.onRenderComplete.bind(this));
|
|
}
|
|
|
|
onRenderComplete(data):void {
|
|
this.graph.chartWidth = data.chartWidth;
|
|
this.renderingCompleted();
|
|
}
|
|
|
|
getChartWidth():number {
|
|
const wndWidth = $(window).width();
|
|
// gripPos.w is a width in grid's measurements. Grid size in Grafana is 24.
|
|
const panelWidthFactor = this.panel.gridPos.w / 24;
|
|
const panelWidth = Math.ceil(wndWidth * panelWidthFactor);
|
|
// approximate chartWidth because y axis ticks not rendered yet on first data receive.
|
|
const chartWidth = _.max([
|
|
panelWidth - 200,
|
|
panelWidth/2
|
|
]);
|
|
|
|
return chartWidth!;
|
|
}
|
|
|
|
// override calculateInterval for discrete color mode
|
|
calculateInterval() {
|
|
let chartWidth = this.getChartWidth();
|
|
|
|
let minCardWidth = this.panel.cards.cardMinWidth;
|
|
let minSpacing = this.panel.cards.cardHSpacing;
|
|
let maxCardsCount = Math.ceil((chartWidth-minCardWidth) / (minCardWidth + minSpacing));
|
|
|
|
let intervalMs;
|
|
let rangeMs = this.range.to.valueOf() - this.range.from.valueOf();
|
|
|
|
// this is minimal interval! kbn.round_interval will lower it.
|
|
intervalMs = this.discreteHelper.roundIntervalCeil(rangeMs / maxCardsCount);
|
|
|
|
// Calculate low limit of interval
|
|
let lowLimitMs = 1; // 1 millisecond default low limit
|
|
|
|
let intervalOverride = this.panel.interval;
|
|
|
|
// if no panel interval check datasource
|
|
if (intervalOverride) {
|
|
intervalOverride = this.templateSrv.replace(intervalOverride, this.panel.scopedVars);
|
|
} else if (this.datasource && this.datasource.interval) {
|
|
intervalOverride = this.datasource.interval;
|
|
}
|
|
|
|
if (intervalOverride) {
|
|
if (intervalOverride[0] === '>') {
|
|
intervalOverride = intervalOverride.slice(1);
|
|
}
|
|
lowLimitMs = kbn.interval_to_ms(intervalOverride);
|
|
}
|
|
|
|
if (lowLimitMs > intervalMs) {
|
|
intervalMs = lowLimitMs;
|
|
}
|
|
let interval = kbn.secondsToHms(intervalMs / 1000);
|
|
|
|
this.intervalMs = intervalMs;
|
|
this.interval = interval;
|
|
}
|
|
|
|
issueQueries(datasource: any) {
|
|
this.annotationsPromise = this.annotationsSrv.getAnnotations({
|
|
dashboard: this.dashboard,
|
|
panel: this.panel,
|
|
range: this.range,
|
|
});
|
|
|
|
/* Wait for annotationSrv requests to get datasources to
|
|
* resolve before issuing queries. This allows the annotations
|
|
* service to fire annotations queries before graph queries
|
|
* (but not wait for completion). This resolves
|
|
* issue 11806.
|
|
*/
|
|
// 5.x before 5.4 doesn't have datasourcePromises.
|
|
|
|
if ("undefined" !== typeof(this.annotationsSrv.datasourcePromises)) {
|
|
return this.annotationsSrv.datasourcePromises.then(r => {
|
|
return this.issueQueriesWithInterval(datasource, this.interval);
|
|
});
|
|
} else {
|
|
return this.issueQueriesWithInterval(datasource, this.interval);
|
|
}
|
|
}
|
|
|
|
// Grafana 6.2 (and older) is using this.interval for queries,
|
|
// but Grafana 6.3+ is calculating interval again in queryRunner,
|
|
// so we need to save-restore this.panel.interval.
|
|
issueQueriesWithInterval(datasource: any, interval: any) {
|
|
var origInterval = this.panel.interval;
|
|
this.panel.interval = this.interval;
|
|
var res = super.issueQueries(datasource);
|
|
this.panel.interval = origInterval;
|
|
return res;
|
|
}
|
|
|
|
|
|
onDataReceived(dataList: any) {
|
|
this.data = dataList;
|
|
this.cardsData = this.convertToCards(this.data);
|
|
|
|
console.log("OnDataReceived");
|
|
|
|
this.annotationsPromise.then(
|
|
(result: { alertState: any; annotations: any }) => {
|
|
this.loading = false;
|
|
//this.alertState = result.alertState;
|
|
if (result.annotations && result.annotations.length > 0) {
|
|
this.annotations = result.annotations;
|
|
} else {
|
|
this.annotations = [];
|
|
}
|
|
console.log("annotationsPromise result " + this.annotations.length + " annotations");
|
|
this.render();
|
|
},
|
|
() => {
|
|
this.loading = false;
|
|
this.annotations = [];
|
|
console.log("annotationsPromise onrejected");
|
|
this.render();
|
|
}
|
|
);
|
|
|
|
//this.render();
|
|
}
|
|
|
|
onInitEditMode() {
|
|
this.addEditorTab('Options', statusHeatmapOptionsEditor, 2);
|
|
this.unitFormats = kbn.getUnitFormats();
|
|
}
|
|
|
|
onRender() {
|
|
if (!this.range || !this.data) { return; }
|
|
|
|
this.multipleValues = false;
|
|
if (!this.panel.useMax) {
|
|
if (this.cardsData) {
|
|
this.multipleValues = this.cardsData.multipleValues;
|
|
}
|
|
}
|
|
|
|
this.noColorDefined = false;
|
|
if (this.panel.color.mode === 'discrete') {
|
|
this.discreteHelper.updateCardsValuesHasColorInfo();
|
|
if (this.cardsData) {
|
|
this.noColorDefined = this.cardsData.noColorDefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
onCardColorChange(newColor) {
|
|
this.panel.color.cardColor = newColor;
|
|
this.render();
|
|
}
|
|
|
|
onDataError() {
|
|
this.data = [];
|
|
this.annotations = [];
|
|
this.render();
|
|
}
|
|
|
|
postRefresh() {
|
|
this.noColorDefined = false;
|
|
}
|
|
|
|
onEditorAddThreshold() {
|
|
this.panel.color.thresholds.push({ color: this.panel.defaultColor });
|
|
this.render();
|
|
}
|
|
|
|
onEditorRemoveThreshold(index:number) {
|
|
this.panel.color.thresholds.splice(index, 1);
|
|
this.render();
|
|
}
|
|
|
|
onEditorRemoveThresholds() {
|
|
this.panel.color.thresholds = [];
|
|
this.render();
|
|
}
|
|
|
|
onEditorAddThreeLights() {
|
|
this.panel.color.thresholds.push({color: "red", value: 2, tooltip: "error" });
|
|
this.panel.color.thresholds.push({color: "yellow", value: 1, tooltip: "warning" });
|
|
this.panel.color.thresholds.push({color: "green", value: 0, tooltip: "ok" });
|
|
this.render();
|
|
}
|
|
|
|
/* https://ethanschoonover.com/solarized/ */
|
|
onEditorAddSolarized() {
|
|
this.panel.color.thresholds.push({color: "#b58900", value: 0, tooltip: "yellow" });
|
|
this.panel.color.thresholds.push({color: "#cb4b16", value: 1, tooltip: "orange" });
|
|
this.panel.color.thresholds.push({color: "#dc322f", value: 2, tooltip: "red" });
|
|
this.panel.color.thresholds.push({color: "#d33682", value: 3, tooltip: "magenta" });
|
|
this.panel.color.thresholds.push({color: "#6c71c4", value: 4, tooltip: "violet" });
|
|
this.panel.color.thresholds.push({color: "#268bd2", value: 5, tooltip: "blue" });
|
|
this.panel.color.thresholds.push({color: "#2aa198", value: 6, tooltip: "cyan" });
|
|
this.panel.color.thresholds.push({color: "#859900", value: 7, tooltip: "green" });
|
|
this.render();
|
|
}
|
|
|
|
link(scope, elem, attrs, ctrl) {
|
|
rendering(scope, elem, attrs, ctrl);
|
|
}
|
|
|
|
// group values into buckets by target
|
|
convertToCards(data) {
|
|
let cardsData = <CardsStorage> {
|
|
cards: [],
|
|
xBucketSize: 0,
|
|
yBucketSize: 0,
|
|
maxValue: 0,
|
|
minValue: 0,
|
|
multipleValues: false,
|
|
noColorDefined: false,
|
|
targets: [], // array of available unique targets
|
|
targetIndex: {} // indices in data array for each of available unique targets
|
|
};
|
|
|
|
if (!data || data.length == 0) { return cardsData;}
|
|
|
|
// Collect uniq timestamps from data and spread over targets and timestamps
|
|
|
|
// collect uniq targets and their indices
|
|
_.map(data, (d, i) => {
|
|
cardsData.targetIndex[d.target] = _.concat(_.toArray(cardsData.targetIndex[d.target]), i)
|
|
});
|
|
|
|
// TODO add some logic for targets heirarchy
|
|
cardsData.targets = _.keys(cardsData.targetIndex);
|
|
cardsData.yBucketSize = cardsData.targets.length;
|
|
// Maximum number of buckets over x axis
|
|
cardsData.xBucketSize = _.max(_.map(data, d => d.datapoints.length));
|
|
|
|
// Collect all values for each bucket from datapoints with similar target.
|
|
// TODO aggregate values into buckets over datapoint[TIME_INDEX] not over datapoint index (j).
|
|
for(let i = 0; i < cardsData.targets.length; i++) {
|
|
let target = cardsData.targets[i];
|
|
|
|
for (let j = 0; j < cardsData.xBucketSize; j++) {
|
|
let card = new Card();
|
|
card.id = i*cardsData.xBucketSize + j;
|
|
card.values = [];
|
|
card.y = target;
|
|
card.x = -1;
|
|
|
|
// collect values from all timeseries with target
|
|
for (let si = 0; si < cardsData.targetIndex[target].length; si++) {
|
|
let s = data[cardsData.targetIndex[target][si]];
|
|
if (s.datapoints.length <= j) {
|
|
continue;
|
|
}
|
|
let datapoint = s.datapoints[j];
|
|
if (card.values.length === 0) {
|
|
card.x = datapoint[TIME_INDEX];
|
|
}
|
|
card.values.push(datapoint[VALUE_INDEX]);
|
|
}
|
|
card.minValue = _.min(card.values);
|
|
card.maxValue = _.max(card.values);
|
|
if (card.values.length > 1) {
|
|
cardsData.multipleValues = true;
|
|
card.multipleValues = true;
|
|
card.value = card.maxValue; // max value by default
|
|
} else {
|
|
card.value = card.maxValue; // max value by default
|
|
}
|
|
|
|
if (cardsData.maxValue < card.maxValue)
|
|
cardsData.maxValue = card.maxValue;
|
|
|
|
if (cardsData.minValue > card.minValue)
|
|
cardsData.minValue = card.minValue;
|
|
|
|
if (card.x != -1) {
|
|
cardsData.cards.push(card);
|
|
}
|
|
}
|
|
}
|
|
|
|
return cardsData;
|
|
}
|
|
}
|
|
|
|
export {
|
|
StatusHeatmapCtrl, StatusHeatmapCtrl as PanelCtrl
|
|
};
|