module.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728
  1. import _ from 'lodash';
  2. import $ from 'jquery';
  3. import 'vendor/flot/jquery.flot';
  4. import 'vendor/flot/jquery.flot.gauge';
  5. import 'app/features/panel/panellinks/link_srv';
  6. import {
  7. LegacyResponseData,
  8. getFlotPairs,
  9. getDisplayProcessor,
  10. convertOldAngulrValueMapping,
  11. getColorFromHexRgbOrName,
  12. } from '@grafana/ui';
  13. import kbn from 'app/core/utils/kbn';
  14. import config from 'app/core/config';
  15. import { MetricsPanelCtrl } from 'app/plugins/sdk';
  16. import {
  17. DataFrame,
  18. FieldType,
  19. reduceField,
  20. ReducerID,
  21. Field,
  22. GraphSeriesValue,
  23. DisplayValue,
  24. fieldReducers,
  25. KeyValue,
  26. LinkModel,
  27. } from '@grafana/data';
  28. import { auto } from 'angular';
  29. import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
  30. import { PanelQueryRunnerFormat } from 'app/features/dashboard/state/PanelQueryRunner';
  31. import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState';
  32. const BASE_FONT_SIZE = 38;
  33. export interface ShowData {
  34. field: Field;
  35. value: any;
  36. sparkline: GraphSeriesValue[][];
  37. display: DisplayValue;
  38. scopedVars: any;
  39. thresholds: any[];
  40. colorMap: any;
  41. }
  42. class SingleStatCtrl extends MetricsPanelCtrl {
  43. static templateUrl = 'module.html';
  44. data: Partial<ShowData> = {};
  45. fontSizes: any[];
  46. unitFormats: any[];
  47. fieldNames: string[] = [];
  48. invalidGaugeRange: boolean;
  49. panel: any;
  50. events: any;
  51. valueNameOptions: any[] = [
  52. { value: 'min', text: 'Min' },
  53. { value: 'max', text: 'Max' },
  54. { value: 'avg', text: 'Average' },
  55. { value: 'current', text: 'Current' },
  56. { value: 'total', text: 'Total' },
  57. { value: 'name', text: 'Name' },
  58. { value: 'first', text: 'First' },
  59. { value: 'delta', text: 'Delta' },
  60. { value: 'diff', text: 'Difference' },
  61. { value: 'range', text: 'Range' },
  62. { value: 'last_time', text: 'Time of last point' },
  63. ];
  64. // Set and populate defaults
  65. panelDefaults: any = {
  66. links: [],
  67. datasource: null,
  68. maxDataPoints: 100,
  69. interval: null,
  70. targets: [{}],
  71. cacheTimeout: null,
  72. format: 'none',
  73. prefix: '',
  74. postfix: '',
  75. nullText: null,
  76. valueMaps: [{ value: 'null', op: '=', text: 'N/A' }],
  77. mappingTypes: [{ name: 'value to text', value: 1 }, { name: 'range to text', value: 2 }],
  78. rangeMaps: [{ from: 'null', to: 'null', text: 'N/A' }],
  79. mappingType: 1,
  80. nullPointMode: 'connected',
  81. valueName: 'avg',
  82. prefixFontSize: '50%',
  83. valueFontSize: '80%',
  84. postfixFontSize: '50%',
  85. thresholds: '',
  86. colorBackground: false,
  87. colorValue: false,
  88. colors: ['#299c46', 'rgba(237, 129, 40, 0.89)', '#d44a3a'],
  89. sparkline: {
  90. show: false,
  91. full: false,
  92. ymin: null,
  93. ymax: null,
  94. lineColor: 'rgb(31, 120, 193)',
  95. fillColor: 'rgba(31, 118, 189, 0.18)',
  96. },
  97. gauge: {
  98. show: false,
  99. minValue: 0,
  100. maxValue: 100,
  101. thresholdMarkers: true,
  102. thresholdLabels: false,
  103. },
  104. tableColumn: '',
  105. };
  106. /** @ngInject */
  107. constructor($scope: any, $injector: auto.IInjectorService, private linkSrv: LinkSrv, private $sanitize: any) {
  108. super($scope, $injector);
  109. _.defaults(this.panel, this.panelDefaults);
  110. this.events.on('data-received', this.onDataReceived.bind(this));
  111. this.events.on('data-error', this.onDataError.bind(this));
  112. this.events.on('data-snapshot-load', this.onDataReceived.bind(this));
  113. this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
  114. this.dataFormat = PanelQueryRunnerFormat.frames;
  115. this.onSparklineColorChange = this.onSparklineColorChange.bind(this);
  116. this.onSparklineFillChange = this.onSparklineFillChange.bind(this);
  117. }
  118. onInitEditMode() {
  119. this.fontSizes = ['20%', '30%', '50%', '70%', '80%', '100%', '110%', '120%', '150%', '170%', '200%'];
  120. this.addEditorTab('Options', 'public/app/plugins/panel/singlestat/editor.html', 2);
  121. this.addEditorTab('Value Mappings', 'public/app/plugins/panel/singlestat/mappings.html', 3);
  122. this.unitFormats = kbn.getUnitFormats();
  123. }
  124. migrateToGaugePanel(migrate: boolean) {
  125. if (migrate) {
  126. this.onPluginTypeChange(config.panels['gauge']);
  127. } else {
  128. this.panel.gauge.show = false;
  129. this.render();
  130. }
  131. }
  132. setUnitFormat(subItem: { value: any }) {
  133. this.panel.format = subItem.value;
  134. this.refresh();
  135. }
  136. onDataError(err: any) {
  137. this.handleDataFrames([]);
  138. }
  139. // This should only be called from the snapshot callback
  140. onDataReceived(dataList: LegacyResponseData[]) {
  141. this.handleDataFrames(getProcessedDataFrames(dataList));
  142. }
  143. // Directly support DataFrame skipping event callbacks
  144. handleDataFrames(frames: DataFrame[]) {
  145. const { panel } = this;
  146. super.handleDataFrames(frames);
  147. this.loading = false;
  148. const distinct = getDistinctNames(frames);
  149. let fieldInfo = distinct.byName[panel.tableColumn]; //
  150. this.fieldNames = distinct.names;
  151. if (!fieldInfo) {
  152. fieldInfo = distinct.first;
  153. }
  154. if (!fieldInfo) {
  155. // When we don't have any field
  156. this.data = {
  157. value: 'No Data',
  158. display: {
  159. text: 'No Data',
  160. numeric: NaN,
  161. },
  162. };
  163. } else {
  164. this.data = this.processField(fieldInfo);
  165. }
  166. this.render();
  167. }
  168. processField(fieldInfo: FieldInfo) {
  169. const { panel, dashboard } = this;
  170. const name = fieldInfo.field.config.title || fieldInfo.field.name;
  171. let calc = panel.valueName;
  172. let calcField = fieldInfo.field;
  173. let val: any = undefined;
  174. if ('name' === calc) {
  175. val = name;
  176. } else {
  177. if ('last_time' === calc) {
  178. if (fieldInfo.frame.firstTimeField) {
  179. calcField = fieldInfo.frame.firstTimeField;
  180. calc = ReducerID.last;
  181. }
  182. }
  183. // Normalize functions (avg -> mean, etc)
  184. const r = fieldReducers.getIfExists(calc);
  185. if (r) {
  186. calc = r.id;
  187. // With strings, don't accidentally use a math function
  188. if (calcField.type === FieldType.string) {
  189. const avoid = [ReducerID.mean, ReducerID.sum];
  190. if (avoid.includes(calc)) {
  191. calc = panel.valueName = ReducerID.first;
  192. }
  193. }
  194. } else {
  195. calc = ReducerID.lastNotNull;
  196. }
  197. // Calculate the value
  198. val = reduceField({
  199. field: calcField,
  200. reducers: [calc],
  201. })[calc];
  202. }
  203. const processor = getDisplayProcessor({
  204. field: {
  205. ...fieldInfo.field.config,
  206. unit: panel.format,
  207. decimals: panel.decimals,
  208. mappings: convertOldAngulrValueMapping(panel),
  209. },
  210. theme: config.theme,
  211. isUtc: dashboard.isTimezoneUtc && dashboard.isTimezoneUtc(),
  212. });
  213. const data = {
  214. field: fieldInfo.field,
  215. value: val,
  216. display: processor(val),
  217. scopedVars: _.extend({}, panel.scopedVars),
  218. };
  219. data.scopedVars['__name'] = name;
  220. panel.tableColumn = this.fieldNames.length > 1 ? name : '';
  221. // Get the fields for a sparkline
  222. if (panel.sparkline && panel.sparkline.show && fieldInfo.frame.firstTimeField) {
  223. this.data.sparkline = getFlotPairs({
  224. xField: fieldInfo.frame.firstTimeField,
  225. yField: fieldInfo.field,
  226. nullValueMode: panel.nullPointMode,
  227. });
  228. }
  229. return data;
  230. }
  231. canModifyText() {
  232. return !this.panel.gauge.show;
  233. }
  234. setColoring(options: { background: any }) {
  235. if (options.background) {
  236. this.panel.colorValue = false;
  237. this.panel.colors = ['rgba(71, 212, 59, 0.4)', 'rgba(245, 150, 40, 0.73)', 'rgba(225, 40, 40, 0.59)'];
  238. } else {
  239. this.panel.colorBackground = false;
  240. this.panel.colors = ['rgba(50, 172, 45, 0.97)', 'rgba(237, 129, 40, 0.89)', 'rgba(245, 54, 54, 0.9)'];
  241. }
  242. this.render();
  243. }
  244. invertColorOrder() {
  245. const tmp = this.panel.colors[0];
  246. this.panel.colors[0] = this.panel.colors[2];
  247. this.panel.colors[2] = tmp;
  248. this.render();
  249. }
  250. onColorChange(panelColorIndex: number) {
  251. return (color: string) => {
  252. this.panel.colors[panelColorIndex] = color;
  253. this.render();
  254. };
  255. }
  256. onSparklineColorChange(newColor: string) {
  257. this.panel.sparkline.lineColor = newColor;
  258. this.render();
  259. }
  260. onSparklineFillChange(newColor: string) {
  261. this.panel.sparkline.fillColor = newColor;
  262. this.render();
  263. }
  264. removeValueMap(map: any) {
  265. const index = _.indexOf(this.panel.valueMaps, map);
  266. this.panel.valueMaps.splice(index, 1);
  267. this.render();
  268. }
  269. addValueMap() {
  270. this.panel.valueMaps.push({ value: '', op: '=', text: '' });
  271. }
  272. removeRangeMap(rangeMap: any) {
  273. const index = _.indexOf(this.panel.rangeMaps, rangeMap);
  274. this.panel.rangeMaps.splice(index, 1);
  275. this.render();
  276. }
  277. addRangeMap() {
  278. this.panel.rangeMaps.push({ from: '', to: '', text: '' });
  279. }
  280. link(scope: any, elem: JQuery, attrs: any, ctrl: any) {
  281. const $location = this.$location;
  282. const linkSrv = this.linkSrv;
  283. const $timeout = this.$timeout;
  284. const $sanitize = this.$sanitize;
  285. const panel = ctrl.panel;
  286. const templateSrv = this.templateSrv;
  287. let linkInfo: LinkModel<any> | null = null;
  288. const $panelContainer = elem.find('.panel-container');
  289. elem = elem.find('.singlestat-panel');
  290. function applyColoringThresholds(valueString: string) {
  291. const data = ctrl.data;
  292. const color = getColorForValue(data, data.value);
  293. if (color) {
  294. return '<span style="color:' + color + '">' + valueString + '</span>';
  295. }
  296. return valueString;
  297. }
  298. function getSpan(className: string, fontSizePercent: string, applyColoring: any, value: string) {
  299. value = $sanitize(templateSrv.replace(value, ctrl.data.scopedVars));
  300. value = applyColoring ? applyColoringThresholds(value) : value;
  301. const pixelSize = (parseInt(fontSizePercent, 10) / 100) * BASE_FONT_SIZE;
  302. return '<span class="' + className + '" style="font-size:' + pixelSize + 'px">' + value + '</span>';
  303. }
  304. function getBigValueHtml() {
  305. const data: ShowData = ctrl.data;
  306. let body = '<div class="singlestat-panel-value-container">';
  307. if (panel.prefix) {
  308. body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, panel.colorPrefix, panel.prefix);
  309. }
  310. body += getSpan('singlestat-panel-value', panel.valueFontSize, panel.colorValue, data.display.text);
  311. if (panel.postfix) {
  312. body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, panel.colorPostfix, panel.postfix);
  313. }
  314. body += '</div>';
  315. return body;
  316. }
  317. function getValueText() {
  318. const data: ShowData = ctrl.data;
  319. let result = panel.prefix ? templateSrv.replace(panel.prefix, data.scopedVars) : '';
  320. result += data.display.text;
  321. result += panel.postfix ? templateSrv.replace(panel.postfix, data.scopedVars) : '';
  322. return result;
  323. }
  324. function addGauge() {
  325. const data: ShowData = ctrl.data;
  326. const width = elem.width();
  327. const height = elem.height();
  328. // Allow to use a bit more space for wide gauges
  329. const dimension = Math.min(width, height * 1.3);
  330. ctrl.invalidGaugeRange = false;
  331. if (panel.gauge.minValue > panel.gauge.maxValue) {
  332. ctrl.invalidGaugeRange = true;
  333. return;
  334. }
  335. const plotCanvas = $('<div></div>');
  336. const plotCss = {
  337. top: '5px',
  338. margin: 'auto',
  339. position: 'relative',
  340. height: height * 0.9 + 'px',
  341. width: dimension + 'px',
  342. };
  343. plotCanvas.css(plotCss);
  344. const thresholds = [];
  345. for (let i = 0; i < data.thresholds.length; i++) {
  346. thresholds.push({
  347. value: data.thresholds[i],
  348. color: data.colorMap[i],
  349. });
  350. }
  351. thresholds.push({
  352. value: panel.gauge.maxValue,
  353. color: data.colorMap[data.colorMap.length - 1],
  354. });
  355. const bgColor = config.bootData.user.lightTheme ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
  356. const fontScale = parseInt(panel.valueFontSize, 10) / 100;
  357. const fontSize = Math.min(dimension / 5, 100) * fontScale;
  358. // Reduce gauge width if threshold labels enabled
  359. const gaugeWidthReduceRatio = panel.gauge.thresholdLabels ? 1.5 : 1;
  360. const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
  361. const thresholdMarkersWidth = gaugeWidth / 5;
  362. const thresholdLabelFontSize = fontSize / 2.5;
  363. const options: any = {
  364. series: {
  365. gauges: {
  366. gauge: {
  367. min: panel.gauge.minValue,
  368. max: panel.gauge.maxValue,
  369. background: { color: bgColor },
  370. border: { color: null },
  371. shadow: { show: false },
  372. width: gaugeWidth,
  373. },
  374. frame: { show: false },
  375. label: { show: false },
  376. layout: { margin: 0, thresholdWidth: 0 },
  377. cell: { border: { width: 0 } },
  378. threshold: {
  379. values: thresholds,
  380. label: {
  381. show: panel.gauge.thresholdLabels,
  382. margin: thresholdMarkersWidth + 1,
  383. font: { size: thresholdLabelFontSize },
  384. },
  385. show: panel.gauge.thresholdMarkers,
  386. width: thresholdMarkersWidth,
  387. },
  388. value: {
  389. color: panel.colorValue ? getColorForValue(data, data.display.numeric) : null,
  390. formatter: () => {
  391. return getValueText();
  392. },
  393. font: {
  394. size: fontSize,
  395. family: config.theme.typography.fontFamily.sansSerif,
  396. },
  397. },
  398. show: true,
  399. },
  400. },
  401. };
  402. elem.append(plotCanvas);
  403. const plotSeries = {
  404. data: [[0, data.value]],
  405. };
  406. $.plot(plotCanvas, [plotSeries], options);
  407. }
  408. function addSparkline() {
  409. const data: ShowData = ctrl.data;
  410. const width = elem.width();
  411. if (width < 30) {
  412. // element has not gotten it's width yet
  413. // delay sparkline render
  414. setTimeout(addSparkline, 30);
  415. return;
  416. }
  417. if (!data.sparkline || !data.sparkline.length) {
  418. // no sparkline data
  419. return;
  420. }
  421. const height = ctrl.height;
  422. const plotCanvas = $('<div></div>');
  423. const plotCss: any = {};
  424. plotCss.position = 'absolute';
  425. plotCss.bottom = '0px';
  426. if (panel.sparkline.full) {
  427. plotCss.left = '0px';
  428. plotCss.width = width + 'px';
  429. const dynamicHeightMargin = height <= 100 ? 5 : Math.round(height / 100) * 15 + 5;
  430. plotCss.height = height - dynamicHeightMargin + 'px';
  431. } else {
  432. plotCss.left = '0px';
  433. plotCss.width = width + 'px';
  434. plotCss.height = Math.floor(height * 0.25) + 'px';
  435. }
  436. plotCanvas.css(plotCss);
  437. const options = {
  438. legend: { show: false },
  439. series: {
  440. lines: {
  441. show: true,
  442. fill: 1,
  443. lineWidth: 1,
  444. fillColor: getColorFromHexRgbOrName(panel.sparkline.fillColor, config.theme.type),
  445. zero: false,
  446. },
  447. },
  448. yaxis: {
  449. show: false,
  450. min: panel.sparkline.ymin,
  451. max: panel.sparkline.ymax,
  452. },
  453. xaxis: {
  454. show: false,
  455. mode: 'time',
  456. min: ctrl.range.from.valueOf(),
  457. max: ctrl.range.to.valueOf(),
  458. },
  459. grid: { hoverable: false, show: false },
  460. };
  461. elem.append(plotCanvas);
  462. const plotSeries = {
  463. data: data.sparkline,
  464. color: getColorFromHexRgbOrName(panel.sparkline.lineColor, config.theme.type),
  465. };
  466. $.plot(plotCanvas, [plotSeries], options);
  467. }
  468. function render() {
  469. if (!ctrl.data) {
  470. return;
  471. }
  472. const { data, panel } = ctrl;
  473. // get thresholds
  474. data.thresholds = panel.thresholds
  475. ? panel.thresholds.split(',').map((strVale: string) => {
  476. return Number(strVale.trim());
  477. })
  478. : [];
  479. // Map panel colors to hex or rgb/a values
  480. if (panel.colors) {
  481. data.colorMap = panel.colors.map((color: string) => getColorFromHexRgbOrName(color, config.theme.type));
  482. }
  483. const body = panel.gauge.show ? '' : getBigValueHtml();
  484. if (panel.colorBackground) {
  485. const color = getColorForValue(data, data.display.numeric);
  486. if (color) {
  487. $panelContainer.css('background-color', color);
  488. if (scope.fullscreen) {
  489. elem.css('background-color', color);
  490. } else {
  491. elem.css('background-color', '');
  492. }
  493. } else {
  494. $panelContainer.css('background-color', '');
  495. elem.css('background-color', '');
  496. }
  497. } else {
  498. $panelContainer.css('background-color', '');
  499. elem.css('background-color', '');
  500. }
  501. elem.html(body);
  502. if (panel.sparkline.show) {
  503. addSparkline();
  504. }
  505. if (panel.gauge.show) {
  506. addGauge();
  507. }
  508. elem.toggleClass('pointer', panel.links.length > 0);
  509. if (panel.links.length > 0) {
  510. linkInfo = linkSrv.getDataLinkUIModel(panel.links[0], data.scopedVars, {});
  511. } else {
  512. linkInfo = null;
  513. }
  514. }
  515. function hookupDrilldownLinkTooltip() {
  516. // drilldown link tooltip
  517. const drilldownTooltip = $('<div id="tooltip" class="">hello</div>"');
  518. elem.mouseleave(() => {
  519. if (panel.links.length === 0) {
  520. return;
  521. }
  522. $timeout(() => {
  523. drilldownTooltip.detach();
  524. });
  525. });
  526. elem.click(evt => {
  527. if (!linkInfo) {
  528. return;
  529. }
  530. // ignore title clicks in title
  531. if ($(evt).parents('.panel-header').length > 0) {
  532. return;
  533. }
  534. if (linkInfo.target === '_blank') {
  535. window.open(linkInfo.href, '_blank');
  536. return;
  537. }
  538. if (linkInfo.href.indexOf('http') === 0) {
  539. window.location.href = linkInfo.href;
  540. } else {
  541. $timeout(() => {
  542. $location.url(linkInfo.href);
  543. });
  544. }
  545. drilldownTooltip.detach();
  546. });
  547. elem.mousemove(e => {
  548. if (!linkInfo) {
  549. return;
  550. }
  551. drilldownTooltip.text('click to go to: ' + linkInfo.title);
  552. drilldownTooltip.place_tt(e.pageX, e.pageY - 50);
  553. });
  554. }
  555. hookupDrilldownLinkTooltip();
  556. this.events.on('render', () => {
  557. render();
  558. ctrl.renderingCompleted();
  559. });
  560. }
  561. }
  562. function getColorForValue(data: any, value: number) {
  563. if (!_.isFinite(value)) {
  564. return null;
  565. }
  566. for (let i = data.thresholds.length; i > 0; i--) {
  567. if (value >= data.thresholds[i - 1]) {
  568. return data.colorMap[i];
  569. }
  570. }
  571. return _.first(data.colorMap);
  572. }
  573. //------------------------------------------------
  574. // Private utility functions
  575. // Somethign like this should be avaliable in a
  576. // DataFrame[] abstraction helper
  577. //------------------------------------------------
  578. interface FrameInfo {
  579. firstTimeField?: Field;
  580. frame: DataFrame;
  581. }
  582. interface FieldInfo {
  583. field: Field;
  584. frame: FrameInfo;
  585. }
  586. interface DistinctFieldsInfo {
  587. first?: FieldInfo;
  588. byName: KeyValue<FieldInfo>;
  589. names: string[];
  590. }
  591. function getDistinctNames(data: DataFrame[]): DistinctFieldsInfo {
  592. const distinct: DistinctFieldsInfo = {
  593. byName: {},
  594. names: [],
  595. };
  596. for (const frame of data) {
  597. const info: FrameInfo = { frame };
  598. for (const field of frame.fields) {
  599. if (field.type === FieldType.time) {
  600. if (!info.firstTimeField) {
  601. info.firstTimeField = field;
  602. }
  603. } else {
  604. const f = { field, frame: info };
  605. if (!distinct.first) {
  606. distinct.first = f;
  607. }
  608. let t = field.config.title;
  609. if (t && !distinct.byName[t]) {
  610. distinct.byName[t] = f;
  611. distinct.names.push(t);
  612. }
  613. t = field.name;
  614. if (t && !distinct.byName[t]) {
  615. distinct.byName[t] = f;
  616. distinct.names.push(t);
  617. }
  618. }
  619. }
  620. }
  621. return distinct;
  622. }
  623. export { SingleStatCtrl, SingleStatCtrl as PanelCtrl, getColorForValue };