mirror of
https://github.com/grafana/grafana.git
synced 2026-01-09 13:37:42 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
423a25fc32 | ||
|
|
86241d8bff | ||
|
|
670ee15dbd | ||
|
|
9abfbf18e0 | ||
|
|
882ed637c1 | ||
|
|
676972e798 | ||
|
|
3be4685589 | ||
|
|
aa227c5c20 | ||
|
|
ba26ac343b | ||
|
|
27a8112e06 | ||
|
|
a0a9ca220c | ||
|
|
0287819e36 | ||
|
|
16f3fe7e15 | ||
|
|
ca6d08d5cb | ||
|
|
d01bdb517d | ||
|
|
63dfdb7066 | ||
|
|
e95667fffb | ||
|
|
c08b901664 | ||
|
|
7cd6fef466 | ||
|
|
1b4f93b88c |
@@ -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:
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "6.7.0"
|
||||
"packages": ["packages/*"],
|
||||
"version": "6.7.2"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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};
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
101
public/app/core/utils/fetch.test.ts
Normal file
101
public/app/core/utils/fetch.test.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
});
|
||||
107
public/app/core/utils/fetch.ts
Normal file
107
public/app/core/utils/fetch.ts
Normal 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;
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user