Compare commits

..

20 Commits

Author SHA1 Message Date
sam
423a25fc32 AzureMonitor: Fix Log Analytics and Application Insights for Azure China (#21803) (#22753)
Something funky going on with GitHub - the build has passed.
2020-04-02 10:02:56 +02:00
Torkel Ödegaard
86241d8bff Revert "grafana/data: PanelTypeChangedHandler API update to use PanelModel instead of panel options object [BREAKING] (#22754)"
This reverts commit 16f3fe7e15.
2020-04-02 10:01:59 +02:00
Torkel Ödegaard
670ee15dbd Bumped version 2020-04-01 18:29:54 +02:00
Torkel Ödegaard
9abfbf18e0 Snapshots: Sanitize orignal url (#23254)
(cherry picked from commit fb114a7524)
2020-04-01 18:28:50 +02:00
Torkel Ödegaard
882ed637c1 Plugins: Expose promiseToDigest (#23249)
(cherry picked from commit ccb8187ccd)
2020-04-01 18:28:49 +02:00
Torkel Ödegaard
676972e798 Variables: Do not update variable from url when value is the same (#23220)
(cherry picked from commit 49d2910e39)
2020-04-01 18:28:49 +02:00
Dominik Prokop
3be4685589 DashboardSave: Add new dashboard check (#23104)
(cherry picked from commit 046d9c1af4)
2020-04-01 18:28:49 +02:00
Torkel Ödegaard
aa227c5c20 Fix: reverted back to import * as module instead of using namespaces (#23069)
* Removed namespace declaration to prevent issues with external plugins.

* fixed imports and tests.

(cherry picked from commit f75387bd14)
2020-04-01 18:27:46 +02:00
Torkel Ödegaard
ba26ac343b BackendSrv: Adds config to response to fix external plugins that use this (#23032)
* BackendSrv: Added config to response

* QueryInspector: Removing config from showing up

* Replace config with request and make it be the unmodified params sent in

(cherry picked from commit 40d195e4a7)
2020-04-01 18:20:47 +02:00
Marcus Andersson
27a8112e06 DataLinks: make sure we use the correct datapoint when dataset contains null value. (#22981)
* Fix to make sure we have the correct dataIndex when using data links.

* fixed strict null errors.

* decreased number of errors.

(cherry picked from commit 41bc1aa3ae)
2020-04-01 18:20:36 +02:00
Steven Vachon
a0a9ca220c Fix mysterious Babel plugin errors (#22974)
(cherry picked from commit d8b346f441)
2020-04-01 18:18:51 +02:00
Torkel Ödegaard
0287819e36 Select: Fixed select text positition (#22952)
(cherry picked from commit 89203136ec)
2020-04-01 17:55:18 +02:00
Dominik Prokop
16f3fe7e15 grafana/data: PanelTypeChangedHandler API update to use PanelModel instead of panel options object [BREAKING] (#22754)
This changes PanelModel's API to support PanelModel API updates when changing panel type. Primary useful when changing panel type between Angular and React panels, as other migrations can be handled via DashboardMigrator.

API change: https://github.com/grafana/grafana/pull/22754/files#diff-d9e3f91dc7d5697f6d85ada008003b4b

(cherry picked from commit 1256d9e78d)
2020-04-01 17:55:17 +02:00
Robby Milo
ca6d08d5cb Docs: Fix Broken Link (#22894)
(cherry picked from commit a61af9ed1d)
2020-03-20 14:13:09 +01:00
Leonard Gram
d01bdb517d release 6.7.1 2020-03-20 14:13:09 +01:00
Torkel Ödegaard
63dfdb7066 Panels: Fixed size issue with panels when existing panel edit mode (#22912)
(cherry picked from commit 8e131384e3)
2020-03-20 14:13:09 +01:00
Torkel Ödegaard
e95667fffb Azure: Fixed dropdowns not showing current value (#22914)
(cherry picked from commit d16211b782)
2020-03-20 14:13:09 +01:00
Hugo Häggmark
c08b901664 BackendSrv: only add content-type on POST, PUT requests (#22910)
* BackendSrv: only add content-type on POST, PUT requests
Fixes #22869

* Tests: imports polyfill for Headers

(cherry picked from commit 8d5c6053db)
2020-03-20 14:13:09 +01:00
Cyril Tovena
7cd6fef466 Check if the datasource is of type loki using meta.id instead of name. (#22877)
Signed-off-by: Cyril Tovena <cyril.tovena@gmail.com>
(cherry picked from commit ec9167e972)
2020-03-20 14:13:09 +01:00
Arve Knudsen
1b4f93b88c CircleCI: Pin grabpl to 0.1.0 (#22904) 2020-03-19 19:06:28 +01:00
38 changed files with 782 additions and 670 deletions

View File

@@ -45,17 +45,13 @@ jobs:
description: Install the Grafana Build Pipeline tool
executor: grafana-build
steps:
- run:
name: Clone repo
command: |
mkdir -p ~/.ssh
echo 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==' >> ~/.ssh/known_hosts
git clone git@github.com:grafana/build-pipeline.git
- run:
name: Install Grafana Build Pipeline
command: |
cd build-pipeline
go build -o ../bin/grabpl ./cmd/grabpl
curl -fLO https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.1.0/grabpl
chmod +x grabpl
mkdir bin
mv grabpl bin/
- persist_to_workspace:
root: .
paths:

View File

@@ -45,9 +45,9 @@ Grafana 6.7 comes with a new OAuth integration for Microsoft Azure Active Direct
Allowing a low dashboard refresh interval can cause severe load on data sources and Grafana. Grafana v6.7 allows you to restrict the dashboard refresh interval so it cannot be set lower than a given interval. This provides a way for administrators to control dashboard refresh behavior on a global level.
Refer to min_refresh_interval in [Configuration]({{< relref "../administration/configuration/#min-refresh-interval" >}}) for further information and how to enable this.
Refer to min_refresh_interval in [Configuration]({{< relref "../installation/configuration#min-refresh-interval" >}}) for more information and how to enable this feature.
### Stackdriver Project Selector
### Stackdriver project selector
A Stackdriver data source in Grafana is configured for one service account only. That service account is always associated with a default project in Google Cloud Platform (GCP). Depending on your setup in GCP, the service account might be granted access to more projects than just the default project.
In Grafana 6.7, the query editor has been enhanced with a project selector that makes it possible to query different projects without changing datasource. Many thanks [Eraac](https://github.com/Eraac), [eliaslaouiti](https://github.com/eliaslaouiti) and [NaurisSadovskis](https://github.com/NaurisSadovskis) for making this happen!

View File

@@ -1,8 +1,6 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"packages": [
"packages/*"
],
"version": "6.7.0"
"packages": ["packages/*"],
"version": "6.7.2"
}

View File

@@ -3,7 +3,7 @@
"license": "Apache-2.0",
"private": true,
"name": "grafana",
"version": "6.7.0",
"version": "6.7.2",
"repository": "github:grafana/grafana",
"devDependencies": {
"@babel/core": "7.8.4",

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/data",
"version": "6.7.0",
"version": "6.7.2",
"description": "Grafana Data Library",
"keywords": [
"typescript"

View File

@@ -1,7 +1,7 @@
import sinon, { SinonFakeTimers } from 'sinon';
import each from 'lodash/each';
import { dateMath } from './datemath';
import * as dateMath from './datemath';
import { dateTime, DurationUnit, DateTime } from './moment_wrapper';
describe('DateMath', () => {

View File

@@ -5,157 +5,158 @@ import { TimeZone } from '../types/index';
const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's'];
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace dateMath {
export function isMathString(text: string | DateTime | Date): boolean {
if (!text) {
return false;
}
if (typeof text === 'string' && (text.substring(0, 3) === 'now' || text.includes('||'))) {
return true;
} else {
return false;
}
}
/**
* Parses different types input to a moment instance. There is a specific formatting language that can be used
* if text arg is string. See unit tests for examples.
* @param text
* @param roundUp See parseDateMath function.
* @param timezone Only string 'utc' is acceptable here, for anything else, local timezone is used.
*/
export function parse(text: string | DateTime | Date, roundUp?: boolean, timezone?: TimeZone): DateTime | undefined {
if (!text) {
return undefined;
}
if (typeof text !== 'string') {
if (isDateTime(text)) {
return text;
}
if (isDate(text)) {
return dateTime(text);
}
// We got some non string which is not a moment nor Date. TS should be able to check for that but not always.
return undefined;
} else {
let time;
let mathString = '';
let index;
let parseString;
if (text.substring(0, 3) === 'now') {
time = dateTimeForTimeZone(timezone);
mathString = text.substring('now'.length);
} else {
index = text.indexOf('||');
if (index === -1) {
parseString = text;
mathString = ''; // nothing else
} else {
parseString = text.substring(0, index);
mathString = text.substring(index + 2);
}
// We're going to just require ISO8601 timestamps, k?
time = dateTime(parseString, ISO_8601);
}
if (!mathString.length) {
return time;
}
return parseDateMath(mathString, time, roundUp);
}
}
/**
* Checks if text is a valid date which in this context means that it is either a Moment instance or it can be parsed
* by parse function. See parse function to see what is considered acceptable.
* @param text
*/
export function isValid(text: string | DateTime): boolean {
const date = parse(text);
if (!date) {
return false;
}
if (isDateTime(date)) {
return date.isValid();
}
export function isMathString(text: string | DateTime | Date): boolean {
if (!text) {
return false;
}
/**
* Parses math part of the time string and shifts supplied time according to that math. See unit tests for examples.
* @param mathString
* @param time
* @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit.
*/
// TODO: Had to revert Andrejs `time: moment.Moment` to `time: any`
export function parseDateMath(mathString: string, time: any, roundUp?: boolean): DateTime | undefined {
const strippedMathString = mathString.replace(/\s/g, '');
const dateTime = time;
let i = 0;
const len = strippedMathString.length;
if (typeof text === 'string' && (text.substring(0, 3) === 'now' || text.includes('||'))) {
return true;
} else {
return false;
}
}
while (i < len) {
const c = strippedMathString.charAt(i++);
let type;
let num;
let unit;
/**
* Parses different types input to a moment instance. There is a specific formatting language that can be used
* if text arg is string. See unit tests for examples.
* @param text
* @param roundUp See parseDateMath function.
* @param timezone Only string 'utc' is acceptable here, for anything else, local timezone is used.
*/
export function parse(
text?: string | DateTime | Date | null,
roundUp?: boolean,
timezone?: TimeZone
): DateTime | undefined {
if (!text) {
return undefined;
}
if (c === '/') {
type = 0;
} else if (c === '+') {
type = 1;
} else if (c === '-') {
type = 2;
if (typeof text !== 'string') {
if (isDateTime(text)) {
return text;
}
if (isDate(text)) {
return dateTime(text);
}
// We got some non string which is not a moment nor Date. TS should be able to check for that but not always.
return undefined;
} else {
let time;
let mathString = '';
let index;
let parseString;
if (text.substring(0, 3) === 'now') {
time = dateTimeForTimeZone(timezone);
mathString = text.substring('now'.length);
} else {
index = text.indexOf('||');
if (index === -1) {
parseString = text;
mathString = ''; // nothing else
} else {
return undefined;
parseString = text.substring(0, index);
mathString = text.substring(index + 2);
}
// We're going to just require ISO8601 timestamps, k?
time = dateTime(parseString, ISO_8601);
}
if (isNaN(parseInt(strippedMathString.charAt(i), 10))) {
num = 1;
} else if (strippedMathString.length === 2) {
num = strippedMathString.charAt(i);
} else {
const numFrom = i;
while (!isNaN(parseInt(strippedMathString.charAt(i), 10))) {
i++;
if (i > 10) {
return undefined;
}
}
num = parseInt(strippedMathString.substring(numFrom, i), 10);
}
if (!mathString.length) {
return time;
}
if (type === 0) {
// rounding is only allowed on whole, single, units (eg M or 1M, not 0.5M or 2M)
if (num !== 1) {
return parseDateMath(mathString, time, roundUp);
}
}
/**
* Checks if text is a valid date which in this context means that it is either a Moment instance or it can be parsed
* by parse function. See parse function to see what is considered acceptable.
* @param text
*/
export function isValid(text: string | DateTime): boolean {
const date = parse(text);
if (!date) {
return false;
}
if (isDateTime(date)) {
return date.isValid();
}
return false;
}
/**
* Parses math part of the time string and shifts supplied time according to that math. See unit tests for examples.
* @param mathString
* @param time
* @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit.
*/
// TODO: Had to revert Andrejs `time: moment.Moment` to `time: any`
export function parseDateMath(mathString: string, time: any, roundUp?: boolean): DateTime | undefined {
const strippedMathString = mathString.replace(/\s/g, '');
const dateTime = time;
let i = 0;
const len = strippedMathString.length;
while (i < len) {
const c = strippedMathString.charAt(i++);
let type;
let num;
let unit;
if (c === '/') {
type = 0;
} else if (c === '+') {
type = 1;
} else if (c === '-') {
type = 2;
} else {
return undefined;
}
if (isNaN(parseInt(strippedMathString.charAt(i), 10))) {
num = 1;
} else if (strippedMathString.length === 2) {
num = strippedMathString.charAt(i);
} else {
const numFrom = i;
while (!isNaN(parseInt(strippedMathString.charAt(i), 10))) {
i++;
if (i > 10) {
return undefined;
}
}
unit = strippedMathString.charAt(i++);
num = parseInt(strippedMathString.substring(numFrom, i), 10);
}
if (!includes(units, unit)) {
if (type === 0) {
// rounding is only allowed on whole, single, units (eg M or 1M, not 0.5M or 2M)
if (num !== 1) {
return undefined;
} else {
if (type === 0) {
if (roundUp) {
dateTime.endOf(unit);
} else {
dateTime.startOf(unit);
}
} else if (type === 1) {
dateTime.add(num, unit);
} else if (type === 2) {
dateTime.subtract(num, unit);
}
}
}
return dateTime;
unit = strippedMathString.charAt(i++);
if (!includes(units, unit)) {
return undefined;
} else {
if (type === 0) {
if (roundUp) {
dateTime.endOf(unit);
} else {
dateTime.startOf(unit);
}
} else if (type === 1) {
dateTime.add(num, unit);
} else if (type === 2) {
dateTime.subtract(num, unit);
}
}
}
return dateTime;
}

View File

@@ -1,6 +1,7 @@
// Names are too general to export globally
export { dateMath } from './datemath';
export { rangeUtil } from './rangeutil';
import * as dateMath from './datemath';
import * as rangeUtil from './rangeutil';
export * from './moment_wrapper';
export * from './timezones';
export * from './formats';
export { dateMath, rangeUtil };

View File

@@ -3,183 +3,180 @@ import groupBy from 'lodash/groupBy';
import { RawTimeRange } from '../types/time';
import { dateMath } from './datemath';
import * as dateMath from './datemath';
import { isDateTime, DateTime } from './moment_wrapper';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace rangeUtil {
const spans: { [key: string]: { display: string; section?: number } } = {
s: { display: 'second' },
m: { display: 'minute' },
h: { display: 'hour' },
d: { display: 'day' },
w: { display: 'week' },
M: { display: 'month' },
y: { display: 'year' },
};
const spans: { [key: string]: { display: string; section?: number } } = {
s: { display: 'second' },
m: { display: 'minute' },
h: { display: 'hour' },
d: { display: 'day' },
w: { display: 'week' },
M: { display: 'month' },
y: { display: 'year' },
};
const rangeOptions = [
{ from: 'now/d', to: 'now/d', display: 'Today', section: 2 },
{ from: 'now/d', to: 'now', display: 'Today so far', section: 2 },
{ from: 'now/w', to: 'now/w', display: 'This week', section: 2 },
{ from: 'now/w', to: 'now', display: 'This week so far', section: 2 },
{ from: 'now/M', to: 'now/M', display: 'This month', section: 2 },
{ from: 'now/M', to: 'now', display: 'This month so far', section: 2 },
{ from: 'now/y', to: 'now/y', display: 'This year', section: 2 },
{ from: 'now/y', to: 'now', display: 'This year so far', section: 2 },
const rangeOptions = [
{ from: 'now/d', to: 'now/d', display: 'Today', section: 2 },
{ from: 'now/d', to: 'now', display: 'Today so far', section: 2 },
{ from: 'now/w', to: 'now/w', display: 'This week', section: 2 },
{ from: 'now/w', to: 'now', display: 'This week so far', section: 2 },
{ from: 'now/M', to: 'now/M', display: 'This month', section: 2 },
{ from: 'now/M', to: 'now', display: 'This month so far', section: 2 },
{ from: 'now/y', to: 'now/y', display: 'This year', section: 2 },
{ from: 'now/y', to: 'now', display: 'This year so far', section: 2 },
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 1 },
{
from: 'now-2d/d',
to: 'now-2d/d',
display: 'Day before yesterday',
section: 1,
},
{
from: 'now-7d/d',
to: 'now-7d/d',
display: 'This day last week',
section: 1,
},
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 1 },
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 1 },
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 },
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 1 },
{
from: 'now-2d/d',
to: 'now-2d/d',
display: 'Day before yesterday',
section: 1,
},
{
from: 'now-7d/d',
to: 'now-7d/d',
display: 'This day last week',
section: 1,
},
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 1 },
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 1 },
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 },
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 0 },
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 0 },
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 0 },
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 0 },
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 0 },
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 0 },
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 0 },
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 0 },
];
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 0 },
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 0 },
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 0 },
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 0 },
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 0 },
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 0 },
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 0 },
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 0 },
];
const absoluteFormat = 'YYYY-MM-DD HH:mm:ss';
const absoluteFormat = 'YYYY-MM-DD HH:mm:ss';
const rangeIndex: any = {};
each(rangeOptions, (frame: any) => {
rangeIndex[frame.from + ' to ' + frame.to] = frame;
const rangeIndex: any = {};
each(rangeOptions, (frame: any) => {
rangeIndex[frame.from + ' to ' + frame.to] = frame;
});
export function getRelativeTimesList(timepickerSettings: any, currentDisplay: any) {
const groups = groupBy(rangeOptions, (option: any) => {
option.active = option.display === currentDisplay;
return option.section;
});
export function getRelativeTimesList(timepickerSettings: any, currentDisplay: any) {
const groups = groupBy(rangeOptions, (option: any) => {
option.active = option.display === currentDisplay;
return option.section;
});
// _.each(timepickerSettings.time_options, (duration: string) => {
// let info = describeTextRange(duration);
// if (info.section) {
// groups[info.section].push(info);
// }
// });
// _.each(timepickerSettings.time_options, (duration: string) => {
// let info = describeTextRange(duration);
// if (info.section) {
// groups[info.section].push(info);
// }
// });
return groups;
}
return groups;
function formatDate(date: DateTime) {
return date.format(absoluteFormat);
}
// handles expressions like
// 5m
// 5m to now/d
// now/d to now
// now/d
// if no to <expr> then to now is assumed
export function describeTextRange(expr: any) {
const isLast = expr.indexOf('+') !== 0;
if (expr.indexOf('now') === -1) {
expr = (isLast ? 'now-' : 'now') + expr;
}
function formatDate(date: DateTime) {
return date.format(absoluteFormat);
}
// handles expressions like
// 5m
// 5m to now/d
// now/d to now
// now/d
// if no to <expr> then to now is assumed
export function describeTextRange(expr: any) {
const isLast = expr.indexOf('+') !== 0;
if (expr.indexOf('now') === -1) {
expr = (isLast ? 'now-' : 'now') + expr;
}
let opt = rangeIndex[expr + ' to now'];
if (opt) {
return opt;
}
if (isLast) {
opt = { from: expr, to: 'now' };
} else {
opt = { from: 'now', to: expr };
}
const parts = /^now([-+])(\d+)(\w)/.exec(expr);
if (parts) {
const unit = parts[3];
const amount = parseInt(parts[2], 10);
const span = spans[unit];
if (span) {
opt.display = isLast ? 'Last ' : 'Next ';
opt.display += amount + ' ' + span.display;
opt.section = span.section;
if (amount > 1) {
opt.display += 's';
}
}
} else {
opt.display = opt.from + ' to ' + opt.to;
opt.invalid = true;
}
let opt = rangeIndex[expr + ' to now'];
if (opt) {
return opt;
}
/**
* Use this function to get a properly formatted string representation of a {@link @grafana/data:RawTimeRange | range}.
*
* @example
* ```
* // Prints "2":
* console.log(add(1,1));
* ```
* @category TimeUtils
* @param range - a time range (usually specified by the TimePicker)
* @alpha
*/
export function describeTimeRange(range: RawTimeRange): string {
const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()];
if (option) {
return option.display;
}
if (isDateTime(range.from) && isDateTime(range.to)) {
return formatDate(range.from) + ' to ' + formatDate(range.to);
}
if (isDateTime(range.from)) {
const toMoment = dateMath.parse(range.to, true);
return toMoment ? formatDate(range.from) + ' to ' + toMoment.fromNow() : '';
}
if (isDateTime(range.to)) {
const from = dateMath.parse(range.from, false);
return from ? from.fromNow() + ' to ' + formatDate(range.to) : '';
}
if (range.to.toString() === 'now') {
const res = describeTextRange(range.from);
return res.display;
}
return range.from.toString() + ' to ' + range.to.toString();
if (isLast) {
opt = { from: expr, to: 'now' };
} else {
opt = { from: 'now', to: expr };
}
export const isValidTimeSpan = (value: string) => {
if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) {
return true;
const parts = /^now([-+])(\d+)(\w)/.exec(expr);
if (parts) {
const unit = parts[3];
const amount = parseInt(parts[2], 10);
const span = spans[unit];
if (span) {
opt.display = isLast ? 'Last ' : 'Next ';
opt.display += amount + ' ' + span.display;
opt.section = span.section;
if (amount > 1) {
opt.display += 's';
}
}
} else {
opt.display = opt.from + ' to ' + opt.to;
opt.invalid = true;
}
const info = describeTextRange(value);
return info.invalid !== true;
};
return opt;
}
/**
* Use this function to get a properly formatted string representation of a {@link @grafana/data:RawTimeRange | range}.
*
* @example
* ```
* // Prints "2":
* console.log(add(1,1));
* ```
* @category TimeUtils
* @param range - a time range (usually specified by the TimePicker)
* @alpha
*/
export function describeTimeRange(range: RawTimeRange): string {
const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()];
if (option) {
return option.display;
}
if (isDateTime(range.from) && isDateTime(range.to)) {
return formatDate(range.from) + ' to ' + formatDate(range.to);
}
if (isDateTime(range.from)) {
const toMoment = dateMath.parse(range.to, true);
return toMoment ? formatDate(range.from) + ' to ' + toMoment.fromNow() : '';
}
if (isDateTime(range.to)) {
const from = dateMath.parse(range.from, false);
return from ? from.fromNow() + ' to ' + formatDate(range.to) : '';
}
if (range.to.toString() === 'now') {
const res = describeTextRange(range.from);
return res.display;
}
return range.from.toString() + ' to ' + range.to.toString();
}
export const isValidTimeSpan = (value: string) => {
if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) {
return true;
}
const info = describeTextRange(value);
return info.invalid !== true;
};

View File

@@ -5,11 +5,8 @@ export interface AppEvent<T> {
payload?: T;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AppEvents {
export type AlertPayload = [string, string?];
export type AlertPayload = [string, string?];
export const alertSuccess = eventFactory<AlertPayload>('alert-success');
export const alertWarning = eventFactory<AlertPayload>('alert-warning');
export const alertError = eventFactory<AlertPayload>('alert-error');
}
export const alertSuccess = eventFactory<AlertPayload>('alert-success');
export const alertWarning = eventFactory<AlertPayload>('alert-warning');
export const alertError = eventFactory<AlertPayload>('alert-error');

View File

@@ -24,5 +24,9 @@ export * from './theme';
export * from './orgs';
export * from './flot';
export { AppEvent, AppEvents } from './appEvents';
export { PanelEvents } from './panelEvents';
import * as AppEvents from './appEvents';
import { AppEvent } from './appEvents';
export { AppEvent, AppEvents };
import * as PanelEvents from './panelEvents';
export { PanelEvents };

View File

@@ -2,28 +2,25 @@ import { eventFactory } from './utils';
import { DataQueryError, DataQueryResponseData } from './datasource';
import { AngularPanelMenuItem } from './panel';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace PanelEvents {
/** Payloads */
export interface PanelChangeViewPayload {
fullscreen?: boolean;
edit?: boolean;
panelId?: number;
toggle?: boolean;
}
/** Events */
export const refresh = eventFactory('refresh');
export const componentDidMount = eventFactory('component-did-mount');
export const dataError = eventFactory<DataQueryError>('data-error');
export const dataReceived = eventFactory<DataQueryResponseData[]>('data-received');
export const dataSnapshotLoad = eventFactory<DataQueryResponseData[]>('data-snapshot-load');
export const editModeInitialized = eventFactory('init-edit-mode');
export const initPanelActions = eventFactory<AngularPanelMenuItem[]>('init-panel-actions');
export const panelChangeView = eventFactory<PanelChangeViewPayload>('panel-change-view');
export const panelInitialized = eventFactory('panel-initialized');
export const panelSizeChanged = eventFactory('panel-size-changed');
export const panelTeardown = eventFactory('panel-teardown');
export const render = eventFactory<any>('render');
export const viewModeChanged = eventFactory('view-mode-changed');
/** Payloads */
export interface PanelChangeViewPayload {
fullscreen?: boolean;
edit?: boolean;
panelId?: number;
toggle?: boolean;
}
/** Events */
export const refresh = eventFactory('refresh');
export const componentDidMount = eventFactory('component-did-mount');
export const dataError = eventFactory<DataQueryError>('data-error');
export const dataReceived = eventFactory<DataQueryResponseData[]>('data-received');
export const dataSnapshotLoad = eventFactory<DataQueryResponseData[]>('data-snapshot-load');
export const editModeInitialized = eventFactory('init-edit-mode');
export const initPanelActions = eventFactory<AngularPanelMenuItem[]>('init-panel-actions');
export const panelChangeView = eventFactory<PanelChangeViewPayload>('panel-change-view');
export const panelInitialized = eventFactory('panel-initialized');
export const panelSizeChanged = eventFactory('panel-size-changed');
export const panelTeardown = eventFactory('panel-teardown');
export const render = eventFactory<any>('render');
export const viewModeChanged = eventFactory('view-mode-changed');

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/e2e",
"version": "6.7.0",
"version": "6.7.2",
"description": "Grafana End-to-End Test Library",
"keywords": [
"cli",

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/runtime",
"version": "6.7.0",
"version": "6.7.2",
"description": "Grafana Runtime Library",
"keywords": [
"grafana",
@@ -23,8 +23,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@grafana/data": "6.7.0",
"@grafana/ui": "6.7.0",
"@grafana/data": "6.7.2",
"@grafana/ui": "6.7.2",
"systemjs": "0.20.19",
"systemjs-plugin-css": "0.1.37"
},

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/toolkit",
"version": "6.7.0",
"version": "6.7.2",
"description": "Grafana Toolkit",
"keywords": [
"grafana",
@@ -27,12 +27,12 @@
},
"main": "src/index.ts",
"dependencies": {
"@babel/core": "7.8.3",
"@babel/preset-env": "7.8.3",
"@grafana/data": "6.7.0",
"@babel/core": "7.9.0",
"@babel/preset-env": "7.9.0",
"@grafana/data": "6.7.2",
"@grafana/eslint-config": "^1.0.0-rc1",
"@grafana/tsconfig": "^1.0.0-rc1",
"@grafana/ui": "6.7.0",
"@grafana/ui": "6.7.2",
"@types/command-exists": "^1.2.0",
"@types/execa": "^0.9.0",
"@types/expect-puppeteer": "3.3.1",
@@ -52,7 +52,7 @@
"@typescript-eslint/parser": "2.19.2",
"axios": "0.19.0",
"babel-jest": "24.8.0",
"babel-loader": "8.0.6",
"babel-loader": "8.1.0",
"babel-plugin-angularjs-annotate": "0.10.0",
"chalk": "^2.4.2",
"command-exists": "^1.2.8",

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/ui",
"version": "6.7.0",
"version": "6.7.2",
"description": "Grafana Components Library",
"keywords": [
"grafana",
@@ -28,7 +28,7 @@
},
"dependencies": {
"@emotion/core": "^10.0.27",
"@grafana/data": "6.7.0",
"@grafana/data": "6.7.2",
"@grafana/slate-react": "0.22.9-grafana",
"@grafana/tsconfig": "^1.0.0-rc1",
"@torkelo/react-select": "3.0.8",

View File

@@ -122,7 +122,7 @@ $select-input-bg-disabled: $input-bg-disabled;
.gf-form-select-box__value-container {
display: inline-block;
padding: 6px 16px 6px 10px;
padding: 8px 16px 8px 10px;
vertical-align: middle;
> div {

View File

@@ -3,4 +3,6 @@ import { getTheme, mockTheme } from './getTheme';
import { selectThemeVariant } from './selectThemeVariant';
export { stylesFactory } from './stylesFactory';
export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant, useTheme, mockThemeContext };
export { styleMixins } from './mixins';
import * as styleMixins from './mixins';
export { styleMixins };

View File

@@ -1,10 +1,8 @@
import { GrafanaTheme } from '@grafana/data';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace styleMixins {
export function cardChrome(theme: GrafanaTheme): string {
if (theme.isDark) {
return `
export function cardChrome(theme: GrafanaTheme): string {
if (theme.isDark) {
return `
background: linear-gradient(135deg, ${theme.colors.dark8}, ${theme.colors.dark6});
&:hover {
background: linear-gradient(135deg, ${theme.colors.dark9}, ${theme.colors.dark6});
@@ -12,9 +10,9 @@ export namespace styleMixins {
box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.3);
border-radius: ${theme.border.radius.md};
`;
}
}
return `
return `
background: linear-gradient(135deg, ${theme.colors.gray6}, ${theme.colors.gray7});
&:hover {
background: linear-gradient(135deg, ${theme.colors.gray7}, ${theme.colors.gray6});
@@ -22,11 +20,11 @@ export namespace styleMixins {
box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.1);
border-radius: ${theme.border.radius.md};
`;
}
}
export function listItem(theme: GrafanaTheme): string {
if (theme.isDark) {
return `
export function listItem(theme: GrafanaTheme): string {
if (theme.isDark) {
return `
background: ${theme.colors.dark7};
&:hover {
background: ${theme.colors.dark9};
@@ -34,9 +32,9 @@ export namespace styleMixins {
box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.3);
border-radius: ${theme.border.radius.md};
`;
}
}
return `
return `
background: ${theme.colors.gray7};
&:hover {
background: ${theme.colors.gray6};
@@ -44,5 +42,4 @@ export namespace styleMixins {
box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.1);
border-radius: ${theme.border.radius.md};
`;
}
}

View File

@@ -1,44 +1,41 @@
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DOMUtil {
// Node.closest() polyfill
if ('Element' in window && !Element.prototype.closest) {
Element.prototype.closest = function(this: any, s: string) {
const matches = (this.document || this.ownerDocument).querySelectorAll(s);
let el = this;
let i;
// Node.closest() polyfill
if ('Element' in window && !Element.prototype.closest) {
Element.prototype.closest = function(this: any, s: string) {
const matches = (this.document || this.ownerDocument).querySelectorAll(s);
let el = this;
let i;
// eslint-disable-next-line
do {
i = matches.length;
// eslint-disable-next-line
do {
i = matches.length;
// eslint-disable-next-line
while (--i >= 0 && matches.item(i) !== el) {}
el = el.parentElement;
} while (i < 0 && el);
return el;
};
}
export function getPreviousCousin(node: any, selector: string) {
let sibling = node.parentElement.previousSibling;
let el;
while (sibling) {
el = sibling.querySelector(selector);
if (el) {
return el;
}
sibling = sibling.previousSibling;
}
return undefined;
}
export function getNextCharacter(global?: any) {
const selection = (global || window).getSelection();
if (!selection || !selection.anchorNode) {
return null;
}
const range = selection.getRangeAt(0);
const text = selection.anchorNode.textContent;
const offset = range.startOffset;
return text!.substr(offset, 1);
}
while (--i >= 0 && matches.item(i) !== el) {}
el = el.parentElement;
} while (i < 0 && el);
return el;
};
}
export function getPreviousCousin(node: any, selector: string) {
let sibling = node.parentElement.previousSibling;
let el;
while (sibling) {
el = sibling.querySelector(selector);
if (el) {
return el;
}
sibling = sibling.previousSibling;
}
return undefined;
}
export function getNextCharacter(global?: any) {
const selection = (global || window).getSelection();
if (!selection || !selection.anchorNode) {
return null;
}
const range = selection.getRangeAt(0);
const text = selection.anchorNode.textContent;
const offset = range.startOffset;
return text!.substr(offset, 1);
}

View File

@@ -6,5 +6,5 @@ export * from './tags';
export * from './measureText';
export { default as ansicolor } from './ansicolor';
// Export with a namespace
export { DOMUtil } from './dom'; // includes Element.closest polyfil
import * as DOMUtil from './dom'; // includes Element.closest polyfil
export { DOMUtil };

View File

@@ -1,6 +1,7 @@
import _ from 'lodash';
import coreModule from '../../core_module';
import { ISCEService } from 'angular';
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
function typeaheadMatcher(this: any, item: string) {
let str = this.query;
@@ -101,8 +102,7 @@ export class FormDropdownCtrl {
}
getOptionsInternal(query: string) {
const result = this.getOptions({ $query: query });
return Promise.resolve(result);
return promiseToDigest(this.$scope)(Promise.resolve(this.getOptions({ $query: query })));
}
isPromiseLike(obj: any) {

View File

@@ -1,4 +1,3 @@
import omitBy from 'lodash/omitBy';
import { from, merge, MonoTypeOperatorFunction, Observable, of, Subject, throwError } from 'rxjs';
import { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, throwIfEmpty } from 'rxjs/operators';
import { fromFetch } from 'rxjs/fetch';
@@ -14,6 +13,7 @@ import { ContextSrv, contextSrv } from './context_srv';
import { coreModule } from 'app/core/core_module';
import { Emitter } from '../utils/emitter';
import { DataSourceResponse } from '../../types/events';
import { parseInitFromOptions, parseUrlFromOptions } from '../utils/fetch';
export interface DatasourceRequestOptions {
retry?: number;
@@ -54,18 +54,6 @@ enum CancellationType {
dataSourceRequest,
}
function serializeParams(data: Record<string, any>): string {
return Object.keys(data)
.map(key => {
const value = data[key];
if (Array.isArray(value)) {
return value.map(arrayValue => `${encodeURIComponent(key)}=${encodeURIComponent(arrayValue)}`).join('&');
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join('&');
}
export interface BackendSrvDependencies {
fromFetch: (input: string | Request, init?: RequestInit) => Observable<Response>;
appEvents: Emitter;
@@ -459,7 +447,7 @@ export class BackendSrv implements BackendService {
url,
type,
redirected,
request: { url, ...init },
config: options,
};
return fetchResponse;
}),
@@ -567,7 +555,7 @@ export class BackendSrv implements BackendService {
data: [],
status: this.HTTP_REQUEST_CANCELED,
statusText: 'Request was aborted',
request: { url: parseUrlFromOptions(options), ...parseInitFromOptions(options) },
config: options,
});
}
@@ -580,62 +568,3 @@ coreModule.factory('backendSrv', () => backendSrv);
// Used for testing and things that really need BackendSrv
export const backendSrv = new BackendSrv();
export const getBackendSrv = (): BackendSrv => backendSrv;
export const parseUrlFromOptions = (options: BackendSrvRequest): string => {
const cleanParams = omitBy(options.params, v => v === undefined || (v && v.length === 0));
const serializedParams = serializeParams(cleanParams);
return options.params && serializedParams.length ? `${options.url}?${serializedParams}` : options.url;
};
export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit => {
const method = options.method;
const headers = parseHeaders(options);
const isAppJson = isContentTypeApplicationJson(headers);
const body = parseBody(options, isAppJson);
return {
method,
headers,
body,
};
};
export const parseHeaders = (options: BackendSrvRequest) => {
const headers = new Headers({
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
});
if (options && options.headers) {
Object.keys(options.headers).forEach(key => {
headers.set(key, options.headers[key]);
});
}
return headers;
};
export const isContentTypeApplicationJson = (headers: Headers) => {
if (!headers) {
return false;
}
const contentType = headers.get('content-type');
if (contentType && contentType.toLowerCase() === 'application/json') {
return true;
}
return false;
};
export const parseBody = (options: BackendSrvRequest, isAppJson: boolean) => {
if (!options) {
return options;
}
if (!options.data || typeof options.data === 'string') {
return options.data;
}
return isAppJson ? JSON.stringify(options.data) : new URLSearchParams(options.data);
};

View File

@@ -3,15 +3,7 @@ import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { AppEvents } from '@grafana/data';
import {
BackendSrv,
getBackendSrv,
isContentTypeApplicationJson,
parseBody,
parseHeaders,
parseInitFromOptions,
parseUrlFromOptions,
} from '../services/backend_srv';
import { BackendSrv, getBackendSrv } from '../services/backend_srv';
import { Emitter } from '../utils/emitter';
import { ContextSrv, User } from '../services/context_srv';
import { CoreEvents } from '../../types';
@@ -27,7 +19,6 @@ const getTestContext = (overides?: object) => {
redirected: false,
type: 'basic',
url: 'http://localhost:3000/api/some-mock',
headers: { 'Content-Type': 'application/json' },
};
const props = { ...defaults, ...overides };
const textMock = jest.fn().mockResolvedValue(JSON.stringify(props.data));
@@ -40,7 +31,6 @@ const getTestContext = (overides?: object) => {
redirected: false,
type: 'basic',
url: 'http://localhost:3000/api/some-mock',
headers: { 'Content-Type': 'application/json' },
};
return of(mockedResponse);
});
@@ -355,29 +345,17 @@ describe('backendSrv', () => {
it('then it should not emit message', async () => {
const url = 'http://localhost:3000/api/some-mock';
const { backendSrv, appEventsMock, expectDataSourceRequestCallChain } = getTestContext({ url });
const result = await backendSrv.datasourceRequest({ url, method: 'GET', silent: true });
const options = { url, method: 'GET', silent: true };
const result = await backendSrv.datasourceRequest(options);
expect(result).toEqual({
data: { test: 'hello world' },
headers: {
'Content-Type': 'application/json',
},
ok: true,
redirected: false,
status: 200,
statusText: 'Ok',
type: 'basic',
url,
request: {
url,
method: 'GET',
body: undefined,
headers: {
map: {
'content-type': 'application/json',
accept: 'application/json, text/plain, */*',
},
},
},
config: options,
});
expect(appEventsMock.emit).not.toHaveBeenCalled();
expectDataSourceRequestCallChain({ url, method: 'GET', silent: true });
@@ -388,29 +366,17 @@ describe('backendSrv', () => {
it('then it should not emit message', async () => {
const url = 'http://localhost:3000/api/some-mock';
const { backendSrv, appEventsMock, expectDataSourceRequestCallChain } = getTestContext({ url });
const result = await backendSrv.datasourceRequest({ url, method: 'GET' });
const options = { url, method: 'GET' };
const result = await backendSrv.datasourceRequest(options);
const expectedResult = {
data: { test: 'hello world' },
headers: {
'Content-Type': 'application/json',
},
ok: true,
redirected: false,
status: 200,
statusText: 'Ok',
type: 'basic',
url,
request: {
url,
method: 'GET',
body: undefined as any,
headers: {
map: {
'content-type': 'application/json',
accept: 'application/json, text/plain, */*',
},
},
},
config: options,
};
expect(result).toEqual(expectedResult);
@@ -432,11 +398,6 @@ describe('backendSrv', () => {
status: 200,
statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(slowData)),
headers: {
map: {
'content-type': 'application/json',
},
},
redirected: false,
type: 'basic',
url,
@@ -449,11 +410,6 @@ describe('backendSrv', () => {
status: 200,
statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(fastData)),
headers: {
map: {
'content-type': 'application/json',
},
},
redirected: false,
type: 'basic',
url,
@@ -469,28 +425,13 @@ describe('backendSrv', () => {
const fastResponse = await backendSrv.datasourceRequest(options);
expect(fastResponse).toEqual({
data: { message: 'Fast Request' },
headers: {
map: {
'content-type': 'application/json',
},
},
ok: true,
redirected: false,
status: 200,
statusText: 'Ok',
type: 'basic',
url: '/api/dashboard/',
request: {
url: '/api/dashboard/',
method: 'GET',
body: undefined,
headers: {
map: {
'content-type': 'application/json',
accept: 'application/json, text/plain, */*',
},
},
},
config: options,
});
const result = await slowRequest;
@@ -498,17 +439,7 @@ describe('backendSrv', () => {
data: [],
status: -1,
statusText: 'Request was aborted',
request: {
url: '/api/dashboard/',
method: 'GET',
body: undefined,
headers: {
map: {
'content-type': 'application/json',
accept: 'application/json, text/plain, */*',
},
},
},
config: options,
});
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
@@ -641,86 +572,3 @@ describe('backendSrv', () => {
});
});
});
describe('parseUrlFromOptions', () => {
it.each`
params | url | expected
${undefined} | ${'api/dashboard'} | ${'api/dashboard'}
${{ key: 'value' }} | ${'api/dashboard'} | ${'api/dashboard?key=value'}
${{ key: undefined }} | ${'api/dashboard'} | ${'api/dashboard'}
${{ firstKey: 'first value', secondValue: 'second value' }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value&secondValue=second%20value'}
${{ firstKey: 'first value', secondValue: undefined }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value'}
${{ id: [1, 2, 3] }} | ${'api/dashboard'} | ${'api/dashboard?id=1&id=2&id=3'}
${{ id: [] }} | ${'api/dashboard'} | ${'api/dashboard'}
`(
"when called with params: '$params' and url: '$url' then result should be '$expected'",
({ params, url, expected }) => {
expect(parseUrlFromOptions({ params, url })).toEqual(expected);
}
);
});
describe('parseInitFromOptions', () => {
it.each`
method | expected
${undefined} | ${{ method: undefined, headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
${'GET'} | ${{ method: 'GET', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
${'POST'} | ${{ method: 'POST', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
${'monkey'} | ${{ method: 'monkey', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
`(
"when called with method: '$method', headers: '$headers' and data: '$data' then result should be '$expected'",
({ method, expected }) => {
expect(parseInitFromOptions({ method, data: { id: '0' }, url: '' })).toEqual(expected);
}
);
});
describe('parseHeaders', () => {
it.each`
options | expected
${undefined} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ propKey: 'some prop value' }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ headers: { 'cOnTent-tYpe': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ headers: { 'content-type': 'AppLiCatIon/JsOn' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'AppLiCatIon/JsOn' } }}
${{ headers: { 'cOnTent-tYpe': 'AppLiCatIon/JsOn' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'AppLiCatIon/JsOn' } }}
${{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }}
${{ headers: { Accept: 'text/plain' } }} | ${{ map: { accept: 'text/plain', 'content-type': 'application/json' } }}
${{ headers: { Auth: 'Basic asdasdasd' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json', auth: 'Basic asdasdasd' } }}
`("when called with options: '$options' then the result should be '$expected'", ({ options, expected }) => {
expect(parseHeaders(options)).toEqual(expected);
});
});
describe('isContentTypeApplicationJson', () => {
it.each`
headers | expected
${undefined} | ${false}
${new Headers({ 'cOnTent-tYpe': 'application/json' })} | ${true}
${new Headers({ 'content-type': 'AppLiCatIon/JsOn' })} | ${true}
${new Headers({ 'cOnTent-tYpe': 'AppLiCatIon/JsOn' })} | ${true}
${new Headers({ 'content-type': 'application/x-www-form-urlencoded' })} | ${false}
${new Headers({ auth: 'Basic akdjasdkjalksdjasd' })} | ${false}
`("when called with headers: 'headers' then the result should be '$expected'", ({ headers, expected }) => {
expect(isContentTypeApplicationJson(headers)).toEqual(expected);
});
});
describe('parseBody', () => {
it.each`
options | isAppJson | expected
${undefined} | ${false} | ${undefined}
${undefined} | ${true} | ${undefined}
${{ data: undefined }} | ${false} | ${undefined}
${{ data: undefined }} | ${true} | ${undefined}
${{ data: 'some data' }} | ${false} | ${'some data'}
${{ data: 'some data' }} | ${true} | ${'some data'}
${{ data: { id: '0' } }} | ${false} | ${new URLSearchParams({ id: '0' })}
${{ data: { id: '0' } }} | ${true} | ${'{"id":"0"}'}
`(
"when called with options: '$options' and isAppJson: '$isAppJson' then the result should be '$expected'",
({ options, isAppJson, expected }) => {
expect(parseBody(options, isAppJson)).toEqual(expected);
}
);
});

View File

@@ -0,0 +1,101 @@
import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering
import {
isContentTypeApplicationJson,
parseBody,
parseHeaders,
parseInitFromOptions,
parseUrlFromOptions,
} from './fetch';
describe('parseUrlFromOptions', () => {
it.each`
params | url | expected
${undefined} | ${'api/dashboard'} | ${'api/dashboard'}
${{ key: 'value' }} | ${'api/dashboard'} | ${'api/dashboard?key=value'}
${{ key: undefined }} | ${'api/dashboard'} | ${'api/dashboard'}
${{ firstKey: 'first value', secondValue: 'second value' }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value&secondValue=second%20value'}
${{ firstKey: 'first value', secondValue: undefined }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value'}
${{ id: [1, 2, 3] }} | ${'api/dashboard'} | ${'api/dashboard?id=1&id=2&id=3'}
${{ id: [] }} | ${'api/dashboard'} | ${'api/dashboard'}
`(
"when called with params: '$params' and url: '$url' then result should be '$expected'",
({ params, url, expected }) => {
expect(parseUrlFromOptions({ params, url })).toEqual(expected);
}
);
});
describe('parseInitFromOptions', () => {
it.each`
method | data | expected
${undefined} | ${undefined} | ${{ method: undefined, headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
${'GET'} | ${undefined} | ${{ method: 'GET', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
${'POST'} | ${{ id: '0' }} | ${{ method: 'POST', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
${'PUT'} | ${{ id: '0' }} | ${{ method: 'PUT', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
${'monkey'} | ${undefined} | ${{ method: 'monkey', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
`(
"when called with method: '$method' and data: '$data' then result should be '$expected'",
({ method, data, expected }) => {
expect(parseInitFromOptions({ method, data, url: '' })).toEqual(expected);
}
);
});
describe('parseHeaders', () => {
it.each`
options | expected
${undefined} | ${{ map: { accept: 'application/json, text/plain, */*' } }}
${{ propKey: 'some prop value' }} | ${{ map: { accept: 'application/json, text/plain, */*' } }}
${{ method: 'GET' }} | ${{ map: { accept: 'application/json, text/plain, */*' } }}
${{ method: 'POST' }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ method: 'PUT' }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ method: 'GET', headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ method: 'POST', headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ method: 'PUT', headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ headers: { 'cOnTent-tYpe': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ headers: { 'content-type': 'AppLiCatIon/JsOn' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'AppLiCatIon/JsOn' } }}
${{ headers: { 'cOnTent-tYpe': 'AppLiCatIon/JsOn' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'AppLiCatIon/JsOn' } }}
${{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }}
${{ method: 'GET', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }}
${{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }}
${{ method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }}
${{ headers: { Accept: 'text/plain' } }} | ${{ map: { accept: 'text/plain' } }}
${{ headers: { Auth: 'Basic asdasdasd' } }} | ${{ map: { accept: 'application/json, text/plain, */*', auth: 'Basic asdasdasd' } }}
`("when called with options: '$options' then the result should be '$expected'", ({ options, expected }) => {
expect(parseHeaders(options)).toEqual(expected);
});
});
describe('isContentTypeApplicationJson', () => {
it.each`
headers | expected
${undefined} | ${false}
${new Headers({ 'cOnTent-tYpe': 'application/json' })} | ${true}
${new Headers({ 'content-type': 'AppLiCatIon/JsOn' })} | ${true}
${new Headers({ 'cOnTent-tYpe': 'AppLiCatIon/JsOn' })} | ${true}
${new Headers({ 'content-type': 'application/x-www-form-urlencoded' })} | ${false}
${new Headers({ auth: 'Basic akdjasdkjalksdjasd' })} | ${false}
`("when called with headers: 'headers' then the result should be '$expected'", ({ headers, expected }) => {
expect(isContentTypeApplicationJson(headers)).toEqual(expected);
});
});
describe('parseBody', () => {
it.each`
options | isAppJson | expected
${undefined} | ${false} | ${undefined}
${undefined} | ${true} | ${undefined}
${{ data: undefined }} | ${false} | ${undefined}
${{ data: undefined }} | ${true} | ${undefined}
${{ data: 'some data' }} | ${false} | ${'some data'}
${{ data: 'some data' }} | ${true} | ${'some data'}
${{ data: { id: '0' } }} | ${false} | ${new URLSearchParams({ id: '0' })}
${{ data: { id: '0' } }} | ${true} | ${'{"id":"0"}'}
`(
"when called with options: '$options' and isAppJson: '$isAppJson' then the result should be '$expected'",
({ options, isAppJson, expected }) => {
expect(parseBody(options, isAppJson)).toEqual(expected);
}
);
});

View File

@@ -0,0 +1,107 @@
import { BackendSrvRequest } from '@grafana/runtime';
import omitBy from 'lodash/omitBy';
export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit => {
const method = options.method;
const headers = parseHeaders(options);
const isAppJson = isContentTypeApplicationJson(headers);
const body = parseBody(options, isAppJson);
return {
method,
headers,
body,
};
};
interface HeaderParser {
canParse: (options: BackendSrvRequest) => boolean;
parse: (headers: Headers) => Headers;
}
const defaultHeaderParser: HeaderParser = {
canParse: () => true,
parse: headers => {
const accept = headers.get('accept');
if (accept) {
return headers;
}
headers.set('accept', 'application/json, text/plain, */*');
return headers;
},
};
const parseHeaderByMethodFactory = (methodPredicate: string): HeaderParser => ({
canParse: options => {
const method = options?.method ? options?.method.toLowerCase() : '';
return method === methodPredicate;
},
parse: headers => {
const contentType = headers.get('content-type');
if (contentType) {
return headers;
}
headers.set('content-type', 'application/json');
return headers;
},
});
const postHeaderParser: HeaderParser = parseHeaderByMethodFactory('post');
const putHeaderParser: HeaderParser = parseHeaderByMethodFactory('put');
const headerParsers = [postHeaderParser, putHeaderParser, defaultHeaderParser];
export const parseHeaders = (options: BackendSrvRequest) => {
const headers = options?.headers ? new Headers(options.headers) : new Headers();
const parsers = headerParsers.filter(parser => parser.canParse(options));
const combinedHeaders = parsers.reduce((prev, parser) => {
return parser.parse(prev);
}, headers);
return combinedHeaders;
};
export const isContentTypeApplicationJson = (headers: Headers) => {
if (!headers) {
return false;
}
const contentType = headers.get('content-type');
if (contentType && contentType.toLowerCase() === 'application/json') {
return true;
}
return false;
};
export const parseBody = (options: BackendSrvRequest, isAppJson: boolean) => {
if (!options) {
return options;
}
if (!options.data || typeof options.data === 'string') {
return options.data;
}
return isAppJson ? JSON.stringify(options.data) : new URLSearchParams(options.data);
};
function serializeParams(data: Record<string, any>): string {
return Object.keys(data)
.map(key => {
const value = data[key];
if (Array.isArray(value)) {
return value.map(arrayValue => `${encodeURIComponent(key)}=${encodeURIComponent(arrayValue)}`).join('&');
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join('&');
}
export const parseUrlFromOptions = (options: BackendSrvRequest): string => {
const cleanParams = omitBy(options.params, v => v === undefined || (v && v.length === 0));
const serializedParams = serializeParams(cleanParams);
return options.params && serializedParams.length ? `${options.url}?${serializedParams}` : options.url;
};

View File

@@ -17,6 +17,7 @@ import { DashboardModel } from '../../state';
import { CoreEvents, StoreState } from 'app/types';
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
import { sanitizeUrl } from 'app/core/utils/text';
export interface OwnProps {
dashboard: DashboardModel;
@@ -151,6 +152,7 @@ export class DashNav extends PureComponent<Props> {
const { canStar, canSave, canShare, showSettings, isStarred } = dashboard.meta;
const { snapshot } = dashboard;
const snapshotUrl = snapshot && snapshot.originalUrl;
return (
<div className="navbar">
{this.isInFullscreenOrSettings && this.renderBackButton()}
@@ -239,7 +241,7 @@ export class DashNav extends PureComponent<Props> {
tooltip="Open original dashboard"
classSuffix="snapshot-origin"
icon="gicon gicon-link"
href={snapshotUrl}
href={sanitizeUrl(snapshotUrl)}
/>
)}

View File

@@ -7,7 +7,7 @@ import { SaveDashboardModal } from './SaveDashboardModal';
export const SaveDashboardModalProxy: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => {
const isProvisioned = dashboard.meta.provisioned;
const isNew = dashboard.title === NEW_DASHBOARD_DEFAULT_TITLE;
const isNew = dashboard.title === NEW_DASHBOARD_DEFAULT_TITLE || dashboard.version === 0;
const isChanged = dashboard.version > 0;
const modalProps = {

View File

@@ -72,10 +72,12 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
};
renderPanel(plugin: PanelPlugin) {
const { dashboard, panel, isFullscreen, isInView, isInEditMode } = this.props;
const { dashboard, panel, isFullscreen, isEditing, isInView, isInEditMode } = this.props;
const autoSizerStyle = { height: isEditing ? '100%' : '' };
return (
<AutoSizer>
<AutoSizer style={autoSizerStyle}>
{({ width, height }) => {
if (width === 0) {
return null;

View File

@@ -96,7 +96,10 @@ export class QueryInspector extends PureComponent<Props, State> {
delete response.headers;
}
if (response.request) {
if (response.config) {
response.request = response.config;
delete response.config;
delete response.request.transformRequest;
delete response.request.transformResponse;
delete response.request.paramSerializer;
@@ -111,6 +114,7 @@ export class QueryInspector extends PureComponent<Props, State> {
if (response.data) {
response.response = response.data;
delete response.config;
delete response.data;
delete response.status;
delete response.statusText;
@@ -120,6 +124,7 @@ export class QueryInspector extends PureComponent<Props, State> {
delete response.type;
delete response.$$config;
}
this.setState(prevState => ({
...prevState,
dsQuery: {

View File

@@ -440,20 +440,21 @@ export const runQueries = (exploreId: ExploreId): ThunkResult<void> => {
stopQueryState(querySubscription);
const datasourceId = datasourceInstance.meta.id;
const queryOptions: QueryOptions = {
minInterval,
// maxDataPoints is used in:
// Loki - used for logs streaming for buffer size, with undefined it falls back to datasource config if it supports that.
// Elastic - limits the number of datapoints for the counts query and for logs it has hardcoded limit.
// Influx - used to correctly display logs in graph
maxDataPoints: mode === ExploreMode.Logs && datasourceInstance.name === 'Loki' ? undefined : containerWidth,
maxDataPoints: mode === ExploreMode.Logs && datasourceId === 'loki' ? undefined : containerWidth,
liveStreaming: live,
showingGraph,
showingTable,
mode,
};
const datasourceId = datasourceInstance.meta.id;
const datasourceName = exploreItemState.requestedDatasourceName;
const transaction = buildQueryTransaction(queries, queryOptions, range, scanning);

View File

@@ -36,6 +36,7 @@ import * as fileExport from 'app/core/utils/file_export';
import * as flatten from 'app/core/utils/flatten';
import * as ticks from 'app/core/utils/ticks';
import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
import impressionSrv from 'app/core/services/impression_srv';
import builtInPlugins from './built_in_plugins';
import * as d3 from 'd3';
@@ -134,6 +135,10 @@ exposeToPlugin('app/core/utils/file_export', fileExport);
exposeToPlugin('app/core/utils/flatten', flatten);
exposeToPlugin('app/core/utils/kbn', kbn);
exposeToPlugin('app/core/utils/ticks', ticks);
exposeToPlugin('app/core/utils/promiseToDigest', {
promiseToDigest: promiseToDigest,
__esModule: true,
});
exposeToPlugin('app/core/config', config);
exposeToPlugin('app/core/time_series', TimeSeries);

View File

@@ -330,9 +330,12 @@ export class VariableSrv {
for (const v of this.variables) {
const key = `var-${v.name}`;
if (vars.hasOwnProperty(key)) {
update.push(v.setValueFromUrl(vars[key]));
if (this.isVariableUrlValueDifferentFromCurrent(v, vars[key])) {
update.push(v.setValueFromUrl(vars[key]));
}
}
}
if (update.length) {
Promise.all(update).then(() => {
this.dashboard.templateVariableValueUpdated();
@@ -341,6 +344,11 @@ export class VariableSrv {
}
}
isVariableUrlValueDifferentFromCurrent(variable: any, urlValue: any) {
// lodash _.isEqual handles array of value equality checks as well
return !_.isEqual(variable.current.value, urlValue);
}
updateUrlParamsWithCurrentVariables() {
// update url
const params = this.$location.search();

View File

@@ -24,7 +24,23 @@ export default class AppInsightsDatasource {
constructor(instanceSettings: DataSourceInstanceSettings<AzureDataSourceJsonData>, private templateSrv: TemplateSrv) {
this.id = instanceSettings.id;
this.applicationId = instanceSettings.jsonData.appInsightsAppId;
this.baseUrl = `/appinsights/${this.version}/apps/${this.applicationId}`;
switch (instanceSettings.jsonData.cloudName) {
// Azure US Government
case 'govazuremonitor':
break;
// Azure Germany
case 'germanyazuremonitor':
break;
// Azue China
case 'chinaazuremonitor':
this.baseUrl = `/chinaappinsights/${this.version}/apps/${this.applicationId}`;
break;
// Azure Global
default:
this.baseUrl = `/appinsights/${this.version}/apps/${this.applicationId}`;
}
this.url = instanceSettings.url;
}

View File

@@ -21,7 +21,20 @@ export default class AzureLogAnalyticsDatasource {
private templateSrv: TemplateSrv
) {
this.id = instanceSettings.id;
this.baseUrl = '/loganalyticsazure';
switch (this.instanceSettings.jsonData.cloudName) {
case 'govazuremonitor': // Azure US Government
break;
case 'germanyazuremonitor': // Azure Germany
break;
case 'chinaazuremonitor': // Azue China
this.baseUrl = '/chinaloganalyticsazure';
break;
default:
// Azure Global
this.baseUrl = '/loganalyticsazure';
}
this.url = instanceSettings.url;
this.defaultOrFirstWorkspace = this.instanceSettings.jsonData.logAnalyticsDefaultWorkspace;
@@ -43,7 +56,19 @@ export default class AzureLogAnalyticsDatasource {
this.azureMonitorUrl = `/${azureCloud}/subscriptions`;
} else {
this.subscriptionId = this.instanceSettings.jsonData.logAnalyticsSubscriptionId;
this.azureMonitorUrl = `/workspacesloganalytics/subscriptions`;
switch (this.instanceSettings.jsonData.cloudName) {
case 'govazuremonitor': // Azure US Government
break;
case 'germanyazuremonitor': // Azure Germany
break;
case 'chinaazuremonitor': // Azue China
this.azureMonitorUrl = `/chinaworkspacesloganalytics/subscriptions`;
break;
default:
// Azure Global
this.azureMonitorUrl = `/workspacesloganalytics/subscriptions`;
}
}
}

View File

@@ -98,6 +98,15 @@
{ "name": "x-ms-app", "content": "Grafana" }
]
},
{
"path": "chinaappinsights",
"method": "GET",
"url": "https://api.applicationinsights.azure.cn",
"headers": [
{ "name": "X-API-Key", "content": "{{.SecureJsonData.appInsightsApiKey}}" },
{ "name": "x-ms-app", "content": "Grafana" }
]
},
{
"path": "workspacesloganalytics",
"method": "GET",
@@ -113,6 +122,21 @@
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{
"path": "chinaworkspacesloganalytics",
"method": "GET",
"url": "https://management.chinacloudapi.cn",
"tokenAuth": {
"url": "https://login.chinacloudapi.cn/{{.JsonData.logAnalyticsTenantId}}/oauth2/token",
"params": {
"grant_type": "client_credentials",
"client_id": "{{.JsonData.logAnalyticsClientId}}",
"client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}",
"resource": "https://management.chinacloudapi.cn/"
}
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{
"path": "loganalyticsazure",
"method": "GET",
@@ -131,6 +155,25 @@
{ "name": "Cache-Control", "content": "public, max-age=60" },
{ "name": "Accept-Encoding", "content": "gzip" }
]
},
{
"path": "chinaloganalyticsazure",
"method": "GET",
"url": "https://api.loganalytics.azure.cn/v1/workspaces",
"tokenAuth": {
"url": "https://login.chinacloudapi.cn/{{.JsonData.logAnalyticsTenantId}}/oauth2/token",
"params": {
"grant_type": "client_credentials",
"client_id": "{{.JsonData.logAnalyticsClientId}}",
"client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}",
"resource": "https://api.loganalytics.azure.cn"
}
},
"headers": [
{ "name": "x-ms-app", "content": "Grafana" },
{ "name": "Cache-Control", "content": "public, max-age=60" },
{ "name": "Accept-Encoding", "content": "gzip" }
]
}
],

View File

@@ -37,6 +37,8 @@ import {
PanelEvents,
formattedValueToString,
FieldType,
DataFrame,
getTimeField,
} from '@grafana/data';
import { GraphContextMenuCtrl } from './GraphContextMenuCtrl';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
@@ -254,6 +256,7 @@ class GraphElement {
const yAxisConfig = this.panel.yaxes[item.series.yaxis.n === 2 ? 1 : 0];
const dataFrame = this.ctrl.dataList[item.series.dataFrameIndex];
const field = dataFrame.fields[item.series.fieldIndex];
const dataIndex = this.getDataIndexWithNullValuesCorrection(item, dataFrame);
let links = this.panel.options.dataLinks || [];
if (field.config.links && field.config.links.length) {
@@ -267,13 +270,13 @@ class GraphElement {
const fieldDisplay = getDisplayProcessor({
field: { config: fieldConfig, type: FieldType.number },
theme: getCurrentTheme(),
})(field.values.get(item.dataIndex));
})(field.values.get(dataIndex));
linksSupplier = links.length
? getFieldLinksSupplier({
display: fieldDisplay,
name: field.name,
view: new DataFrameView(dataFrame),
rowIndex: item.dataIndex,
rowIndex: dataIndex,
colIndex: item.series.fieldIndex,
field: fieldConfig,
})
@@ -290,6 +293,36 @@ class GraphElement {
}
}
getDataIndexWithNullValuesCorrection(item: any, dataFrame: DataFrame): number {
/** This is one added to handle the scenario where we have null values in
* the time series data and the: "visualization options -> null value"
* set to "connected". In this scenario we will get the wrong dataIndex.
*
* https://github.com/grafana/grafana/issues/22651
*/
const { datapoint, dataIndex } = item;
if (!Array.isArray(datapoint) || datapoint.length === 0) {
return dataIndex;
}
const ts = datapoint[0];
const { timeField } = getTimeField(dataFrame);
if (!timeField || !timeField.values) {
return dataIndex;
}
const field = timeField.values.get(dataIndex);
if (field === ts) {
return dataIndex;
}
const correctIndex = timeField.values.toArray().findIndex(value => value === ts);
return correctIndex > -1 ? correctIndex : dataIndex;
}
shouldAbortRender() {
if (!this.data) {
return true;

View File

@@ -51,7 +51,7 @@ export interface DataSourceResponse<T> {
readonly redirected: boolean;
readonly type: ResponseType;
readonly url: string;
readonly request: any;
readonly config: any;
}
type DataSourceResponsePayload = DataSourceResponse<any>;