Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b076394cd3 | ||
|
|
dbb3750610 | ||
|
|
3e4c0bf8f8 | ||
|
|
de815d4746 | ||
|
|
a4b7050609 | ||
|
|
d9868dc2d1 | ||
|
|
7402337bd1 | ||
|
|
5d3b150687 | ||
|
|
d81f6e6925 | ||
|
|
89e62f985d | ||
|
|
94b53ecc08 | ||
|
|
c0fe700c01 | ||
|
|
2f276f97d3 | ||
|
|
f7201c7be2 | ||
|
|
44665ff9fc | ||
|
|
f7d5e4a8c5 | ||
|
|
03b688ce0a | ||
|
|
a89cd8ab7f | ||
|
|
97debfdcce | ||
|
|
6f93a81be0 | ||
|
|
1a545a1826 | ||
|
|
5274070e77 | ||
|
|
4996803e00 | ||
|
|
825c4a5ce0 | ||
|
|
2248c282be | ||
|
|
5e3514749b | ||
|
|
ae3fde6c7f |
@@ -140,10 +140,10 @@ You can use a variable in a metric node path or as a parameter to a function.
|
||||
There are two syntaxes:
|
||||
|
||||
- `$<varname>` Example: apps.frontend.$server.requests.count
|
||||
- `[[varname]]` Example: apps.frontend.[[server]].requests.count
|
||||
- `${varname}` Example: apps.frontend.${server}.requests.count
|
||||
|
||||
Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. Use
|
||||
the second syntax in expressions like `my.server[[serverNumber]].count`.
|
||||
the second syntax in expressions like `my.server${serverNumber}.count`.
|
||||
|
||||
Example:
|
||||
[Graphite Templated Dashboard](https://play.grafana.org/dashboard/db/graphite-templated-nested)
|
||||
|
||||
@@ -139,7 +139,19 @@ For more information and instructions, refer to [Data links]({{< relref "../link
|
||||
|
||||
Lets you set the display title of all fields. You can use [variables]({{< relref "../variables/templates-and-variables.md" >}}) in the field title.
|
||||
|
||||
When multiple stats are shown, this field controls the title in each stat. By default this is the series name and field name. You can use expressions like ${__series.name} or ${**field.name} to use only series name or field name in title or \${**cell_2} to refer to other fields (2 being field/column with index 2).
|
||||
When multiple stats, fields, or series are shown, this field controls the title in each stat. You can use expressions like `${__field.name}` to use only the series name or the field name in title.
|
||||
|
||||
Given a field with a name of Temp, and labels of {"Loc"="PBI", "Sensor"="3"}
|
||||
|
||||
| Expression syntax | Example | Renders to | Explanation |
|
||||
| ---------------------------- | ---------------------- | --------------------------------- | ----------- |
|
||||
| `${__field.displayName}` | Same as syntax | `Temp {Loc="PBI", Sensor="3"}` | Displays the field name, and labels in `{}` if they are present. If there is only one label key in the response, then for the label portion, Grafana displays the value of the label without the enclosing braces. |
|
||||
| `${__field.name}` | Same as syntax | `Temp` | Displays the name of the field (without labels). |
|
||||
| `${__field.labels}` | Same as syntax | `Loc="PBI", Sensor="3"` | Displays the labels without the name. |
|
||||
| `${__field.labels.X}` | `${__field.labels.Loc}` | `PBI` | Displays the value of the specified label key. |
|
||||
| `${__field.labels.__values}` | Same as Syntax | `PBI, 3` | Displays the values of the labels separated by a comma (without label keys). |
|
||||
|
||||
If the value is an empty string after rendering the expression for a particular field, then the default display method is used.
|
||||
|
||||
### Max
|
||||
|
||||
|
||||
@@ -10,23 +10,32 @@ weight = 200
|
||||
|
||||
# Global variables
|
||||
|
||||
Grafana has global built-in variables that can be used in expressions in the query editor. This topic lists them in alphabetical order and defines them.
|
||||
Grafana has global built-in variables that can be used in expressions in the query editor. This topic lists them in alphabetical order and defines them. These variables are useful in queries, dashboard links, panel links, and data links.
|
||||
|
||||
## $__dashboard
|
||||
> Only available in Grafana v6.7+
|
||||
|
||||
This variable is the UID of the current dashboard.
|
||||
`${__dashboard.name}` is the name of the current dashboard.
|
||||
- `${__dashboard.name}` is the name of the current dashboard
|
||||
- `${__dashboard.uid}` is the UID of the current dashboard (used in url)
|
||||
|
||||
## $__from and $__to
|
||||
|
||||
> Only available in Grafana v6.0+
|
||||
Grafana has two built in time range variables: `$__from` and `$__to`. They are currently always interpolated as epoch milliseconds by default but you can control date formatting.
|
||||
|
||||
Grafana has two built in time range variables: `$__from` and `$__to`. They are currently always interpolated as epoch milliseconds.
|
||||
> This special formatting syntax is only available in Grafan a 7.1.2+
|
||||
|
||||
| Syntax | Example result | Description |
|
||||
| ------------------------ | ------------------------ | ----------- |
|
||||
| `${__from}` | 1594671549254 | Unix millisecond epoch |
|
||||
| `${__from:date}` | 2020-07-13T20:19:09.254Z | No args, defaults to ISO 8601/RFC 3339 |
|
||||
| `${__from:date:iso}` | 2020-07-13T20:19:09.254Z | ISO 8601/RFC 3339 |
|
||||
| `${__from:date:seconds}` | 1594671549 | Unix seconds epoch |
|
||||
| `${__from:date:YYYY-MM}` | 2020-07 | Any custom [date format](https://momentjs.com/docs/#/displaying/) |
|
||||
|
||||
The above syntax works with `${__to}` as well.
|
||||
|
||||
## $__interval
|
||||
|
||||
The `$__interval` variable can be used as a parameter to group by time (for InfluxDB, MySQL, Postgres, MSSQL), Date histogram interval (for Elasticsearch) or as a *summarize* function parameter (for Graphite).
|
||||
You can use the `$__interval` variable as a parameter to group by time (for InfluxDB, MySQL, Postgres, MSSQL), Date histogram interval (for Elasticsearch), or as a _summarize_ function parameter (for Graphite).
|
||||
|
||||
Grafana automatically calculates an interval that can be used to group by time in queries. When there are more data points than can be shown on a graph then queries can be made more efficient by grouping by a larger interval. It is more efficient to group by 1 day than by 10s when looking at 3 months of data and the graph will look the same and the query will be faster. The `$__interval` is calculated using the time range and the width of the graph (the number of pixels).
|
||||
|
||||
@@ -47,12 +56,12 @@ This variable is the `$__interval` variable in milliseconds, not a time interval
|
||||
This variable is only available in the Singlestat panel and can be used in the prefix or suffix fields on the Options tab. The variable will be replaced with the series name or alias.
|
||||
|
||||
## $__org
|
||||
> Only available in Grafana v6.7+
|
||||
|
||||
This variable is the ID of the current organization.
|
||||
`${__org.name}` is the name of the current organization.
|
||||
|
||||
## $__user
|
||||
|
||||
> Only available in Grafana v7.1+
|
||||
|
||||
`${__user.id}` is the ID of the current user.
|
||||
@@ -60,8 +69,6 @@ This variable is the ID of the current organization.
|
||||
|
||||
## $__range
|
||||
|
||||
> Only available in Grafana v5.3+
|
||||
|
||||
Currently only supported for Prometheus data sources. This variable represents the range for the current dashboard. It is calculated by `to - from`. It has a millisecond and a second representation called `$__range_ms` and `$__range_s`.
|
||||
|
||||
## $timeFilter or $__timeFilter
|
||||
@@ -69,7 +76,8 @@ Currently only supported for Prometheus data sources. This variable represents t
|
||||
The `$timeFilter` variable returns the currently selected time range as an expression. For example, the time range interval `Last 7 days` expression is `time > now() - 7d`.
|
||||
|
||||
This is used in several places, including:
|
||||
* The WHERE clause for the InfluxDB data source. Grafana adds it automatically to InfluxDB queries when in Query Editor Mode. It has to be added manually in Text Editor Mode: `WHERE $timeFilter`.
|
||||
* Log Analytics queries in the Azure Monitor data source.
|
||||
* SQL queries in MySQL, Postgres, and MSSQL
|
||||
* The `$__timeFilter` variable is used in the MySQL data source.
|
||||
|
||||
- The WHERE clause for the InfluxDB data source. Grafana adds it automatically to InfluxDB queries when in Query Editor mode. You can add it manually in Text Editor mode: `WHERE $timeFilter`.
|
||||
- Log Analytics queries in the Azure Monitor data source.
|
||||
- SQL queries in MySQL, Postgres, and MSSQL
|
||||
- The `$__timeFilter` variable is used in the MySQL data source.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"packages": ["packages/*"],
|
||||
"version": "7.1.1"
|
||||
"version": "7.1.2"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"name": "grafana",
|
||||
"version": "7.1.1",
|
||||
"version": "7.1.2",
|
||||
"repository": "github:grafana/grafana",
|
||||
"scripts": {
|
||||
"api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/data",
|
||||
"version": "7.1.1",
|
||||
"version": "7.1.2",
|
||||
"description": "Grafana Data Library",
|
||||
"keywords": [
|
||||
"typescript"
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface DateTimeBuiltinFormat {
|
||||
__momentBuiltinFormatBrand: any;
|
||||
}
|
||||
export const ISO_8601: DateTimeBuiltinFormat = moment.ISO_8601;
|
||||
export type DateTimeInput = Date | string | number | Array<string | number> | DateTime; // null | undefined
|
||||
export type DateTimeInput = Date | string | number | Array<string | number> | DateTime | null; // | undefined;
|
||||
export type FormatInput = string | DateTimeBuiltinFormat | undefined;
|
||||
export type DurationInput = string | number | DateTimeDuration;
|
||||
export type DurationUnit =
|
||||
|
||||
@@ -203,7 +203,7 @@ export const isValidTimeSpan = (value: string) => {
|
||||
return info.invalid !== true;
|
||||
};
|
||||
|
||||
export const describeTimeRangeAbbrevation = (range: TimeRange, timeZone?: TimeZone) => {
|
||||
export const describeTimeRangeAbbreviation = (range: TimeRange, timeZone?: TimeZone) => {
|
||||
if (isDateTime(range.from)) {
|
||||
return timeZoneAbbrevation(range.from, { timeZone });
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface ReduceDataOptions {
|
||||
|
||||
// TODO: use built in variables, same as for data links?
|
||||
export const VAR_SERIES_NAME = '__series.name';
|
||||
export const VAR_FIELD_NAME = '__field.name';
|
||||
export const VAR_FIELD_NAME = '__field.displayName'; // Includes the rendered tags and naming strategy
|
||||
export const VAR_FIELD_LABELS = '__field.labels';
|
||||
export const VAR_CALC = '__calc';
|
||||
export const VAR_CELL_PREFIX = '__cell_'; // consistent with existing table templates
|
||||
|
||||
@@ -127,11 +127,7 @@ describe('applyFieldOverrides', () => {
|
||||
Object {
|
||||
"__field": Object {
|
||||
"text": "Field",
|
||||
"value": Object {
|
||||
"formattedLabels": "",
|
||||
"labels": undefined,
|
||||
"name": "A message",
|
||||
},
|
||||
"value": Object {},
|
||||
},
|
||||
"__series": Object {
|
||||
"text": "Series",
|
||||
@@ -146,11 +142,7 @@ describe('applyFieldOverrides', () => {
|
||||
Object {
|
||||
"__field": Object {
|
||||
"text": "Field",
|
||||
"value": Object {
|
||||
"formattedLabels": "",
|
||||
"labels": undefined,
|
||||
"name": "B info",
|
||||
},
|
||||
"value": Object {},
|
||||
},
|
||||
"__series": Object {
|
||||
"text": "Series",
|
||||
|
||||
@@ -32,10 +32,10 @@ import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
|
||||
import { DataLinkBuiltInVars, locationUtil } from '../utils';
|
||||
import { formattedValueToString } from '../valueFormats';
|
||||
import { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy';
|
||||
import { formatLabels } from '../utils/labels';
|
||||
import { getFrameDisplayName, getFieldDisplayName } from './fieldState';
|
||||
import { getTimeField } from '../dataframe/processDataFrame';
|
||||
import { mapInternalLinkToExplore } from '../utils/dataLinks';
|
||||
import { getTemplateProxyForField } from './templateProxies';
|
||||
|
||||
interface OverrideProps {
|
||||
match: FieldMatcher;
|
||||
@@ -113,11 +113,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
||||
|
||||
fieldScopedVars['__field'] = {
|
||||
text: 'Field',
|
||||
value: {
|
||||
name: displayName, // Generally appropriate (may include the series name if useful)
|
||||
formattedLabels: formatLabels(field.labels!),
|
||||
labels: field.labels,
|
||||
},
|
||||
value: getTemplateProxyForField(field, frame, options.data),
|
||||
};
|
||||
|
||||
field.state = {
|
||||
|
||||
32
packages/grafana-data/src/field/templateProxies.test.ts
Normal file
32
packages/grafana-data/src/field/templateProxies.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getTemplateProxyForField } from './templateProxies';
|
||||
import { toDataFrame } from '../dataframe';
|
||||
|
||||
describe('Template proxies', () => {
|
||||
it('supports name and displayName', () => {
|
||||
const frames = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: '🔥',
|
||||
config: { displayName: '✨' },
|
||||
labels: {
|
||||
b: 'BBB',
|
||||
a: 'AAA',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const f = getTemplateProxyForField(frames[0].fields[0], frames[0], frames);
|
||||
|
||||
expect(f.name).toEqual('🔥');
|
||||
expect(f.displayName).toEqual('✨');
|
||||
expect(`${f.labels}`).toEqual('a="AAA", b="BBB"');
|
||||
expect(f.labels.__values).toEqual('AAA, BBB');
|
||||
expect(f.labels.a).toEqual('AAA');
|
||||
|
||||
// Deprecated syntax
|
||||
expect(`${f.formattedLabels}`).toEqual('a="AAA", b="BBB"');
|
||||
});
|
||||
});
|
||||
41
packages/grafana-data/src/field/templateProxies.ts
Normal file
41
packages/grafana-data/src/field/templateProxies.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { DataFrame, Field } from '../types';
|
||||
import { getFieldDisplayName } from './fieldState';
|
||||
import { formatLabels } from '../utils/labels';
|
||||
|
||||
/**
|
||||
* This object is created often, and only used when tmplates exist. Using a proxy lets us delay
|
||||
* calculations of the more complex structures (label names) until they are actually used
|
||||
*/
|
||||
export function getTemplateProxyForField(field: Field, frame?: DataFrame, frames?: DataFrame[]): any {
|
||||
return new Proxy(
|
||||
{} as any, // This object shows up in test snapshots
|
||||
{
|
||||
get: (obj: Field, key: string, reciever: any) => {
|
||||
if (key === 'name') {
|
||||
return field.name;
|
||||
}
|
||||
|
||||
if (key === 'displayName') {
|
||||
return getFieldDisplayName(field, frame, frames);
|
||||
}
|
||||
|
||||
if (key === 'labels' || key === 'formattedLabels') {
|
||||
// formattedLabels deprecated
|
||||
if (!field.labels) {
|
||||
return '';
|
||||
}
|
||||
return {
|
||||
...field.labels,
|
||||
__values: Object.values(field.labels)
|
||||
.sort()
|
||||
.join(', '),
|
||||
toString: () => {
|
||||
return formatLabels(field.labels!, '', true);
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined; // (field as any)[key]; // any property?
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,8 @@ export interface PanelProps<T = any> {
|
||||
onChangeTimeRange: (timeRange: AbsoluteTimeRange) => void;
|
||||
/** @internal */
|
||||
renderCounter: number;
|
||||
/** Panel title */
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface PanelEditorProps<T = any> {
|
||||
|
||||
@@ -62,11 +62,14 @@ export function findUniqueLabels(labels: Labels | undefined, commonLabels: Label
|
||||
/**
|
||||
* Serializes the given labels to a string.
|
||||
*/
|
||||
export function formatLabels(labels: Labels, defaultValue = ''): string {
|
||||
export function formatLabels(labels: Labels, defaultValue = '', withoutBraces?: boolean): string {
|
||||
if (!labels || Object.keys(labels).length === 0) {
|
||||
return defaultValue;
|
||||
}
|
||||
const labelKeys = Object.keys(labels).sort();
|
||||
const cleanSelector = labelKeys.map(key => `${key}="${labels[key]}"`).join(', ');
|
||||
if (withoutBraces) {
|
||||
return cleanSelector;
|
||||
}
|
||||
return ['{', cleanSelector, '}'].join('');
|
||||
}
|
||||
|
||||
@@ -290,8 +290,8 @@ export const getCategories = (): ValueFormatCategory[] => [
|
||||
{ name: 'Gray (Gy)', id: 'radgy', fn: decimalSIPrefix('Gy') },
|
||||
{ name: 'rad', id: 'radrad', fn: decimalSIPrefix('rad') },
|
||||
{ name: 'Sievert (Sv)', id: 'radsv', fn: decimalSIPrefix('Sv') },
|
||||
{ name: 'milliSievert (mSv)', id: 'radmsv', fn: decimalSIPrefix('mSv', -1) },
|
||||
{ name: 'microSievert (µSv)', id: 'radusv', fn: decimalSIPrefix('µSv', -2) },
|
||||
{ name: 'milliSievert (mSv)', id: 'radmsv', fn: decimalSIPrefix('Sv', -1) },
|
||||
{ name: 'microSievert (µSv)', id: 'radusv', fn: decimalSIPrefix('Sv', -2) },
|
||||
{ name: 'rem', id: 'radrem', fn: decimalSIPrefix('rem') },
|
||||
{ name: 'Exposure (C/kg)', id: 'radexpckg', fn: decimalSIPrefix('C/kg') },
|
||||
{ name: 'roentgen (R)', id: 'radr', fn: decimalSIPrefix('R') },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e-selectors",
|
||||
"version": "7.1.1",
|
||||
"version": "7.1.2",
|
||||
"description": "Grafana End-to-End Test Selectors Library",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e",
|
||||
"version": "7.1.1",
|
||||
"version": "7.1.2",
|
||||
"description": "Grafana End-to-End Test Library",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -45,7 +45,7 @@
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@cypress/webpack-preprocessor": "4.1.3",
|
||||
"@grafana/e2e-selectors": "7.1.1",
|
||||
"@grafana/e2e-selectors": "7.1.2",
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@mochajs/json-file-reporter": "^1.2.0",
|
||||
"blink-diff": "1.0.13",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/runtime",
|
||||
"version": "7.1.1",
|
||||
"version": "7.1.2",
|
||||
"description": "Grafana Runtime Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -23,8 +23,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/data": "7.1.1",
|
||||
"@grafana/ui": "7.1.1",
|
||||
"@grafana/data": "7.1.2",
|
||||
"@grafana/ui": "7.1.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": "7.1.1",
|
||||
"version": "7.1.2",
|
||||
"description": "Grafana Toolkit",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
|
||||
@@ -116,7 +116,7 @@ const packagePluginRunner: TaskRunner<PluginCIOptions> = async () => {
|
||||
|
||||
fs.exists(jobsDir, jobsDirExists => {
|
||||
if (!jobsDirExists) {
|
||||
throw 'You must run plugin:ci-build prior to running plugin:ci-package';
|
||||
throw new Error('You must run plugin:ci-build prior to running plugin:ci-package');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/ui",
|
||||
"version": "7.1.1",
|
||||
"version": "7.1.2",
|
||||
"description": "Grafana Components Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -28,8 +28,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/core": "^10.0.27",
|
||||
"@grafana/data": "7.1.1",
|
||||
"@grafana/e2e-selectors": "7.1.1",
|
||||
"@grafana/data": "7.1.2",
|
||||
"@grafana/e2e-selectors": "7.1.2",
|
||||
"@grafana/slate-react": "0.22.9-grafana",
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@iconscout/react-unicons": "^1.0.0",
|
||||
|
||||
@@ -30,7 +30,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
transform: translateY(-50%);
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
z-index: 1071;
|
||||
color: ${theme.colors.textWeak};
|
||||
`,
|
||||
picker: css`
|
||||
.rc-time-picker-panel-select {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Story, Preview, Props } from '@storybook/addon-docs/blocks';
|
||||
import { TimeRangeInput } from './TimeRangeInput';
|
||||
|
||||
# TimeRangeInput
|
||||
|
||||
A variant of `TimeRangePicker` for use in forms.
|
||||
|
||||
`dateTime(null)` can be used to provide empty time range value. The shape of the return value on input clear is:
|
||||
|
||||
```javascript
|
||||
{
|
||||
from: dateTime(null),
|
||||
to: dateTime(null),
|
||||
raw: {
|
||||
from: dateTime(null),
|
||||
to: dateTime(null),
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
`dateMath.isValid()` from `@grafana/data` can be used to check for a valid time range value.
|
||||
|
||||
|
||||
### Usage
|
||||
|
||||
```jsx
|
||||
import { TimeRangeInput } from '@grafana/ui';
|
||||
|
||||
<TimeRangeInput
|
||||
value={timeRange}
|
||||
onChange={range => console.log('range', range)}
|
||||
onChangeTimeZone={tz => console.log('timezone', tz)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Props
|
||||
<Props of={TimeRangeInput} />
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { dateTime, TimeFragment } from '@grafana/data';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { UseState } from '../../utils/storybook/UseState';
|
||||
import { TimeRangeInput } from './TimeRangeInput';
|
||||
import mdx from './TimeRangeInput.mdx';
|
||||
|
||||
export default {
|
||||
title: 'Pickers and Editors/TimePickers/TimeRangeInput',
|
||||
component: TimeRangeInput,
|
||||
decorators: [withCenteredStory],
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const basic = () => {
|
||||
return (
|
||||
<UseState
|
||||
initialState={{
|
||||
from: dateTime(),
|
||||
to: dateTime(),
|
||||
raw: { from: 'now-6h' as TimeFragment, to: 'now' as TimeFragment },
|
||||
}}
|
||||
>
|
||||
{(value, updateValue) => {
|
||||
return (
|
||||
<TimeRangeInput
|
||||
value={value}
|
||||
onChange={timeRange => {
|
||||
action('onChange fired')(timeRange);
|
||||
updateValue(timeRange);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</UseState>
|
||||
);
|
||||
};
|
||||
|
||||
export const clearable = () => {
|
||||
return (
|
||||
<UseState
|
||||
initialState={{
|
||||
from: dateTime(),
|
||||
to: dateTime(),
|
||||
raw: { from: 'now-6h' as TimeFragment, to: 'now' as TimeFragment },
|
||||
}}
|
||||
>
|
||||
{(value, updateValue) => {
|
||||
return (
|
||||
<TimeRangeInput
|
||||
clearable
|
||||
value={value}
|
||||
onChange={timeRange => {
|
||||
action('onChange fired')(timeRange);
|
||||
updateValue(timeRange);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</UseState>
|
||||
);
|
||||
};
|
||||
144
packages/grafana-ui/src/components/TimePicker/TimeRangeInput.tsx
Normal file
144
packages/grafana-ui/src/components/TimePicker/TimeRangeInput.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { FC, FormEvent, MouseEvent, useState } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { dateTime, GrafanaTheme, TimeRange, TimeZone, dateMath } from '@grafana/data';
|
||||
import { useStyles } from '../../themes/ThemeContext';
|
||||
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
import { getFocusStyle } from '../Forms/commonStyles';
|
||||
import { TimePickerButtonLabel } from './TimeRangePicker';
|
||||
import { TimePickerContent } from './TimeRangePicker/TimePickerContent';
|
||||
import { otherOptions, quickOptions } from './rangeOptions';
|
||||
|
||||
export const defaultTimeRange: TimeRange = {
|
||||
from: dateTime().subtract(6, 'hour'),
|
||||
to: dateTime(),
|
||||
raw: { from: 'now-6h', to: 'now' },
|
||||
};
|
||||
|
||||
const isValidTimeRange = (range: any) => {
|
||||
return dateMath.isValid(range.from) && dateMath.isValid(range.to);
|
||||
};
|
||||
|
||||
export interface Props {
|
||||
value: TimeRange;
|
||||
timeZone?: TimeZone;
|
||||
onChange: (timeRange: TimeRange) => void;
|
||||
onChangeTimeZone?: (timeZone: TimeZone) => void;
|
||||
hideTimeZone?: boolean;
|
||||
placeholder?: string;
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const TimeRangeInput: FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
onChangeTimeZone,
|
||||
clearable,
|
||||
hideTimeZone = true,
|
||||
timeZone = 'browser',
|
||||
placeholder = 'Select time range',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
const onOpen = (event: FormEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onRangeChange = (timeRange: TimeRange) => {
|
||||
onClose();
|
||||
onChange(timeRange);
|
||||
};
|
||||
|
||||
const onRangeClear = (event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
const from = dateTime(null);
|
||||
const to = dateTime(null);
|
||||
onChange({ from, to, raw: { from, to } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div tabIndex={0} className={styles.pickerInput} aria-label="TimePicker Open Button" onClick={onOpen}>
|
||||
{isValidTimeRange(value) ? (
|
||||
<TimePickerButtonLabel value={value as TimeRange} timeZone={timeZone} />
|
||||
) : (
|
||||
<span className={styles.placeholder}>{placeholder}</span>
|
||||
)}
|
||||
|
||||
<span className={styles.caretIcon}>
|
||||
{isValidTimeRange(value) && clearable && (
|
||||
<Icon className={styles.clearIcon} name="times" size="lg" onClick={onRangeClear} />
|
||||
)}
|
||||
<Icon name={isOpen ? 'angle-up' : 'angle-down'} size="lg" />
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ClickOutsideWrapper includeButtonPress={false} onClick={onClose}>
|
||||
<TimePickerContent
|
||||
timeZone={timeZone}
|
||||
value={isValidTimeRange(value) ? (value as TimeRange) : defaultTimeRange}
|
||||
onChange={onRangeChange}
|
||||
otherOptions={otherOptions}
|
||||
quickOptions={quickOptions}
|
||||
onChangeTimeZone={onChangeTimeZone || noop}
|
||||
className={styles.content}
|
||||
hideTimeZone={hideTimeZone}
|
||||
isReversed
|
||||
/>
|
||||
</ClickOutsideWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
const inputStyles = getInputStyles({ theme, invalid: false });
|
||||
return {
|
||||
container: css`
|
||||
display: flex;
|
||||
position: relative;
|
||||
`,
|
||||
content: css`
|
||||
margin-left: 0;
|
||||
`,
|
||||
pickerInput: cx(
|
||||
inputStyles.input,
|
||||
inputStyles.wrapper,
|
||||
css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding-right: 0;
|
||||
${getFocusStyle(theme)};
|
||||
`
|
||||
),
|
||||
caretIcon: cx(
|
||||
inputStyles.suffix,
|
||||
css`
|
||||
position: relative;
|
||||
margin-left: ${theme.spacing.xs};
|
||||
`
|
||||
),
|
||||
clearIcon: css`
|
||||
margin-right: ${theme.spacing.xs};
|
||||
&:hover {
|
||||
color: ${theme.colors.linkHover};
|
||||
}
|
||||
`,
|
||||
placeholder: css`
|
||||
color: ${theme.colors.formInputPlaceholderText};
|
||||
opacity: 1;
|
||||
`,
|
||||
};
|
||||
};
|
||||
@@ -14,44 +14,9 @@ import { withTheme, useTheme } from '../../themes/ThemeContext';
|
||||
|
||||
// Types
|
||||
import { isDateTime, rangeUtil, GrafanaTheme, dateTimeFormat, timeZoneFormatUserFriendly } from '@grafana/data';
|
||||
import { TimeRange, TimeOption, TimeZone, dateMath } from '@grafana/data';
|
||||
import { TimeRange, TimeZone, dateMath } from '@grafana/data';
|
||||
import { Themeable } from '../../types';
|
||||
|
||||
const quickOptions: TimeOption[] = [
|
||||
{ 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: 3 },
|
||||
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 3 },
|
||||
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 3 },
|
||||
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 3 },
|
||||
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 3 },
|
||||
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 3 },
|
||||
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 3 },
|
||||
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 3 },
|
||||
];
|
||||
|
||||
const otherOptions: TimeOption[] = [
|
||||
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 3 },
|
||||
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday', section: 3 },
|
||||
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week', section: 3 },
|
||||
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 3 },
|
||||
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 3 },
|
||||
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 3 },
|
||||
{ from: 'now/d', to: 'now/d', display: 'Today', section: 3 },
|
||||
{ from: 'now/d', to: 'now', display: 'Today so far', section: 3 },
|
||||
{ from: 'now/w', to: 'now/w', display: 'This week', section: 3 },
|
||||
{ from: 'now/w', to: 'now', display: 'This week so far', section: 3 },
|
||||
{ from: 'now/M', to: 'now/M', display: 'This month', section: 3 },
|
||||
{ from: 'now/M', to: 'now', display: 'This month so far', section: 3 },
|
||||
{ from: 'now/y', to: 'now/y', display: 'This year', section: 3 },
|
||||
{ from: 'now/y', to: 'now', display: 'This year so far', section: 3 },
|
||||
];
|
||||
import { otherOptions, quickOptions } from './rangeOptions';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
@@ -122,6 +87,7 @@ export class UnthemedTimeRangePicker extends PureComponent<Props, State> {
|
||||
onOpen = (event: FormEvent<HTMLButtonElement>) => {
|
||||
const { isOpen } = this.state;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.setState({ isOpen: !isOpen });
|
||||
};
|
||||
|
||||
@@ -178,6 +144,7 @@ export class UnthemedTimeRangePicker extends PureComponent<Props, State> {
|
||||
otherOptions={otherOptions}
|
||||
quickOptions={quickOptions}
|
||||
history={history}
|
||||
showHistory
|
||||
onChangeTimeZone={onChangeTimeZone}
|
||||
/>
|
||||
</ClickOutsideWrapper>
|
||||
@@ -225,7 +192,9 @@ const TimePickerTooltip = ({ timeRange, timeZone }: { timeRange: TimeRange; time
|
||||
);
|
||||
};
|
||||
|
||||
const TimePickerButtonLabel = memo<Props>(({ hideText, value, timeZone }) => {
|
||||
type LabelProps = Pick<Props, 'hideText' | 'value' | 'timeZone'>;
|
||||
|
||||
export const TimePickerButtonLabel = memo<LabelProps>(({ hideText, value, timeZone }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getLabelStyles(theme);
|
||||
|
||||
@@ -236,7 +205,7 @@ const TimePickerButtonLabel = memo<Props>(({ hideText, value, timeZone }) => {
|
||||
return (
|
||||
<span className={styles.container}>
|
||||
<span>{formattedRange(value, timeZone)}</span>
|
||||
<span className={styles.utc}>{rangeUtil.describeTimeRangeAbbrevation(value, timeZone)}</span>
|
||||
<span className={styles.utc}>{rangeUtil.describeTimeRangeAbbreviation(value, timeZone)}</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { dateTime } from '@grafana/data';
|
||||
|
||||
import { inputToValue } from './TimePickerCalendar';
|
||||
|
||||
describe('inputToValue', () => {
|
||||
describe('when called with valid dates', () => {
|
||||
describe('and from is after to', () => {
|
||||
it('then to should be first in the result', () => {
|
||||
const from = dateTime('2020-04-16T11:00:00.000Z');
|
||||
const to = dateTime('2020-04-16T10:00:00.000Z');
|
||||
|
||||
const result = inputToValue(from, to);
|
||||
|
||||
expect(result).toEqual([new Date('2020-04-16T10:00:00.000Z'), new Date('2020-04-16T11:00:00.000Z')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and from is before to', () => {
|
||||
it('then to should be second in the result', () => {
|
||||
const from = dateTime('2020-04-16T10:00:00.000Z');
|
||||
const to = dateTime('2020-04-16T11:00:00.000Z');
|
||||
|
||||
const result = inputToValue(from, to);
|
||||
|
||||
expect(result).toEqual([new Date('2020-04-16T10:00:00.000Z'), new Date('2020-04-16T11:00:00.000Z')]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with an invalid from datetime', () => {
|
||||
it('then from should replaced with specified default', () => {
|
||||
const from = dateTime('2020-04-32T10:00:00.000Z'); // invalid date
|
||||
const to = dateTime('2020-04-16T10:00:00.000Z');
|
||||
const invalidDateDefault = new Date('2020-04-16T11:00:00.000Z');
|
||||
|
||||
const result = inputToValue(from, to, invalidDateDefault);
|
||||
|
||||
expect(result).toEqual([new Date('2020-04-16T10:00:00.000Z'), new Date('2020-04-16T11:00:00.000Z')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with an invalid to datetime', () => {
|
||||
it('then to should replaced with specified default', () => {
|
||||
const from = dateTime('2020-04-16T10:00:00.000Z');
|
||||
const to = dateTime('2020-04-32T10:00:00.000Z'); // invalid date
|
||||
const invalidDateDefault = new Date('2020-04-16T11:00:00.000Z');
|
||||
|
||||
const result = inputToValue(from, to, invalidDateDefault);
|
||||
|
||||
expect(result).toEqual([new Date('2020-04-16T10:00:00.000Z'), new Date('2020-04-16T11:00:00.000Z')]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,23 @@
|
||||
import React, { memo, useState, useEffect, useCallback } from 'react';
|
||||
import React, { FormEvent, memo, useCallback, useEffect, useState } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import Calendar from 'react-calendar/dist/entry.nostyle';
|
||||
import { GrafanaTheme, DateTime, TimeZone, dateTimeParse } from '@grafana/data';
|
||||
import { useTheme, stylesFactory } from '../../../themes';
|
||||
import { dateTime, DateTime, dateTimeParse, GrafanaTheme, TimeZone } from '@grafana/data';
|
||||
import { stylesFactory, useTheme } from '../../../themes';
|
||||
import { TimePickerTitle } from './TimePickerTitle';
|
||||
import { Button } from '../../Button';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
import { Portal } from '../../Portal/Portal';
|
||||
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme, isReversed = false) => {
|
||||
const containerBorder = theme.isDark ? theme.palette.dark9 : theme.palette.gray5;
|
||||
|
||||
return {
|
||||
container: css`
|
||||
top: -1px;
|
||||
position: absolute;
|
||||
right: 544px;
|
||||
box-shadow: 0px 0px 20px ${theme.colors.dropdownShadow};
|
||||
${isReversed ? 'left' : 'right'}: 544px;
|
||||
box-shadow: ${isReversed ? '10px' : '0px'} 0px 20px ${theme.colors.dropdownShadow};
|
||||
background-color: ${theme.colors.bodyBg};
|
||||
z-index: -1;
|
||||
border: 1px solid ${containerBorder};
|
||||
@@ -28,7 +28,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
background-color: ${theme.colors.bodyBg};
|
||||
width: 19px;
|
||||
height: 100%;
|
||||
content: ' ';
|
||||
content: ${!isReversed ? ' ' : ''};
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -19px;
|
||||
@@ -189,17 +189,18 @@ interface Props {
|
||||
from: DateTime;
|
||||
to: DateTime;
|
||||
onClose: () => void;
|
||||
onApply: () => void;
|
||||
onApply: (e: FormEvent<HTMLButtonElement>) => void;
|
||||
onChange: (from: DateTime, to: DateTime) => void;
|
||||
isFullscreen: boolean;
|
||||
timeZone?: TimeZone;
|
||||
isReversed?: boolean;
|
||||
}
|
||||
|
||||
const stopPropagation = (event: React.MouseEvent<HTMLDivElement>) => event.stopPropagation();
|
||||
|
||||
export const TimePickerCalendar = memo<Props>(props => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
const styles = getStyles(theme, props.isReversed);
|
||||
const { isOpen, isFullscreen } = props;
|
||||
|
||||
if (!isOpen) {
|
||||
@@ -284,14 +285,16 @@ const Footer = memo<Props>(({ onClose, onApply }) => {
|
||||
);
|
||||
});
|
||||
|
||||
function inputToValue(from: DateTime, to: DateTime): Date[] {
|
||||
export function inputToValue(from: DateTime, to: DateTime, invalidDateDefault: Date = new Date()): Date[] {
|
||||
const fromAsDate = from.toDate();
|
||||
const toAsDate = to.toDate();
|
||||
const fromAsValidDate = dateTime(fromAsDate).isValid() ? fromAsDate : invalidDateDefault;
|
||||
const toAsValidDate = dateTime(toAsDate).isValid() ? toAsDate : invalidDateDefault;
|
||||
|
||||
if (fromAsDate > toAsDate) {
|
||||
return [toAsDate, fromAsDate];
|
||||
if (fromAsValidDate > toAsValidDate) {
|
||||
return [toAsValidDate, fromAsValidDate];
|
||||
}
|
||||
return [fromAsDate, toAsDate];
|
||||
return [fromAsValidDate, toAsValidDate];
|
||||
}
|
||||
|
||||
function useOnCalendarChange(onChange: (from: DateTime, to: DateTime) => void, timeZone?: TimeZone) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GrafanaTheme, isDateTime, TimeOption, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
import { css, cx } from 'emotion';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useMedia } from 'react-use';
|
||||
import { stylesFactory, useTheme } from '../../../themes';
|
||||
@@ -11,7 +11,7 @@ import { TimeRangeForm } from './TimeRangeForm';
|
||||
import { TimeRangeList } from './TimeRangeList';
|
||||
import { TimePickerFooter } from './TimePickerFooter';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme, isReversed) => {
|
||||
const containerBorder = theme.isDark ? theme.palette.dark9 : theme.palette.gray5;
|
||||
|
||||
return {
|
||||
@@ -22,18 +22,12 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
z-index: ${theme.zIndex.modal};
|
||||
width: 546px;
|
||||
top: 116%;
|
||||
margin-left: -322px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid ${containerBorder};
|
||||
right: ${isReversed ? 'unset' : 0};
|
||||
|
||||
@media only screen and (max-width: ${theme.breakpoints.lg}) {
|
||||
width: 218px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: ${theme.breakpoints.sm}) {
|
||||
width: 264px;
|
||||
margin-left: -100px;
|
||||
width: 262px;
|
||||
}
|
||||
`,
|
||||
body: css`
|
||||
@@ -43,9 +37,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
leftSide: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid ${theme.colors.border1};
|
||||
border-right: ${isReversed ? 'none' : `1px solid ${theme.colors.border1}`};
|
||||
width: 60%;
|
||||
overflow: hidden;
|
||||
order: ${isReversed ? 1 : 0};
|
||||
|
||||
@media only screen and (max-width: ${theme.breakpoints.lg}) {
|
||||
display: none;
|
||||
@@ -53,6 +48,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
`,
|
||||
rightSide: css`
|
||||
width: 40% !important;
|
||||
border-right: ${isReversed ? `1px solid ${theme.colors.border1}` : 'none'};
|
||||
|
||||
@media only screen and (max-width: ${theme.breakpoints.lg}) {
|
||||
width: 100% !important;
|
||||
@@ -134,6 +130,11 @@ interface Props {
|
||||
quickOptions?: TimeOption[];
|
||||
otherOptions?: TimeOption[];
|
||||
history?: TimeRange[];
|
||||
showHistory?: boolean;
|
||||
className?: string;
|
||||
hideTimeZone?: boolean;
|
||||
/** Reverse the order of relative and absolute range pickers. Used to left align the picker in forms */
|
||||
isReversed?: boolean;
|
||||
}
|
||||
|
||||
interface PropsWithScreenSize extends Props {
|
||||
@@ -147,12 +148,12 @@ interface FormProps extends Omit<Props, 'history'> {
|
||||
|
||||
export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = props => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
const styles = getStyles(theme, props.isReversed);
|
||||
const historyOptions = mapToHistoryOptions(props.history, props.timeZone);
|
||||
const { quickOptions = [], otherOptions = [], isFullscreen } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={cx(styles.container, props.className)}>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.leftSide}>
|
||||
<FullScreenForm {...props} visible={isFullscreen} historyOptions={historyOptions} />
|
||||
@@ -176,7 +177,9 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = pr
|
||||
/>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
{isFullscreen && <TimePickerFooter timeZone={props.timeZone} onChangeTimeZone={props.onChangeTimeZone} />}
|
||||
{!props.hideTimeZone && isFullscreen && (
|
||||
<TimePickerFooter timeZone={props.timeZone} onChangeTimeZone={props.onChangeTimeZone} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -218,14 +221,16 @@ const NarrowScreenForm: React.FC<FormProps> = props => {
|
||||
isFullscreen={false}
|
||||
/>
|
||||
</div>
|
||||
<TimeRangeList
|
||||
title="Recently used absolute ranges"
|
||||
options={props.historyOptions || []}
|
||||
onSelect={props.onChange}
|
||||
value={props.value}
|
||||
placeholderEmpty={null}
|
||||
timeZone={props.timeZone}
|
||||
/>
|
||||
{props.showHistory && (
|
||||
<TimeRangeList
|
||||
title="Recently used absolute ranges"
|
||||
options={props.historyOptions || []}
|
||||
onSelect={props.onChange}
|
||||
value={props.value}
|
||||
placeholderEmpty={null}
|
||||
timeZone={props.timeZone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -246,18 +251,26 @@ const FullScreenForm: React.FC<FormProps> = props => {
|
||||
<div aria-label="TimePicker absolute time range" className={styles.title}>
|
||||
<TimePickerTitle>Absolute time range</TimePickerTitle>
|
||||
</div>
|
||||
<TimeRangeForm value={props.value} timeZone={props.timeZone} onApply={props.onChange} isFullscreen={true} />
|
||||
</div>
|
||||
<div className={styles.recent}>
|
||||
<TimeRangeList
|
||||
title="Recently used absolute ranges"
|
||||
options={props.historyOptions || []}
|
||||
onSelect={props.onChange}
|
||||
<TimeRangeForm
|
||||
value={props.value}
|
||||
placeholderEmpty={<EmptyRecentList />}
|
||||
timeZone={props.timeZone}
|
||||
onApply={props.onChange}
|
||||
isFullscreen={true}
|
||||
isReversed={props.isReversed}
|
||||
/>
|
||||
</div>
|
||||
{props.showHistory && (
|
||||
<div className={styles.recent}>
|
||||
<TimeRangeList
|
||||
title="Recently used absolute ranges"
|
||||
options={props.historyOptions || []}
|
||||
onSelect={props.onChange}
|
||||
value={props.value}
|
||||
placeholderEmpty={<EmptyRecentList />}
|
||||
timeZone={props.timeZone}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { FormEvent, useState, useCallback } from 'react';
|
||||
import React, { FormEvent, useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
TimeZone,
|
||||
isDateTime,
|
||||
@@ -21,6 +21,7 @@ interface Props {
|
||||
onApply: (range: TimeRange) => void;
|
||||
timeZone?: TimeZone;
|
||||
roundup?: boolean;
|
||||
isReversed?: boolean;
|
||||
}
|
||||
|
||||
interface InputState {
|
||||
@@ -37,6 +38,12 @@ export const TimeRangeForm: React.FC<Props> = props => {
|
||||
const [to, setTo] = useState<InputState>(valueToState(value.raw.to, true, timeZone));
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
// Synchronize internal state with external value
|
||||
useEffect(() => {
|
||||
setFrom(valueToState(value.raw.from, false, timeZone));
|
||||
setTo(valueToState(value.raw.to, true, timeZone));
|
||||
}, [value.raw.from, value.raw.to, timeZone]);
|
||||
|
||||
const onOpen = useCallback(
|
||||
(event: FormEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -55,16 +62,20 @@ export const TimeRangeForm: React.FC<Props> = props => {
|
||||
[isFullscreen, onOpen]
|
||||
);
|
||||
|
||||
const onApply = useCallback(() => {
|
||||
if (to.invalid || from.invalid) {
|
||||
return;
|
||||
}
|
||||
const onApply = useCallback(
|
||||
(e: FormEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (to.invalid || from.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw: RawTimeRange = { from: from.value, to: to.value };
|
||||
const timeRange = rangeUtil.convertRawToRange(raw, timeZone);
|
||||
const raw: RawTimeRange = { from: from.value, to: to.value };
|
||||
const timeRange = rangeUtil.convertRawToRange(raw, timeZone);
|
||||
|
||||
props.onApply(timeRange);
|
||||
}, [from, to, roundup, timeZone]);
|
||||
props.onApply(timeRange);
|
||||
},
|
||||
[from, to, roundup, timeZone]
|
||||
);
|
||||
|
||||
const onChange = useCallback(
|
||||
(from: DateTime, to: DateTime) => {
|
||||
@@ -111,6 +122,7 @@ export const TimeRangeForm: React.FC<Props> = props => {
|
||||
onClose={() => setOpen(false)}
|
||||
onChange={onChange}
|
||||
timeZone={timeZone}
|
||||
isReversed={props.isReversed}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
exports[`TimePickerContent renders correctly in full screen 1`] = `
|
||||
<div
|
||||
className="css-1py9bjs"
|
||||
className="css-ajr8sn"
|
||||
>
|
||||
<div
|
||||
className="css-ooqtr4"
|
||||
>
|
||||
<div
|
||||
className="css-dlnzj7"
|
||||
className="css-1f2wc71"
|
||||
>
|
||||
<FullScreenForm
|
||||
historyOptions={Array []}
|
||||
@@ -35,7 +35,7 @@ exports[`TimePickerContent renders correctly in full screen 1`] = `
|
||||
autoHide={false}
|
||||
autoHideDuration={200}
|
||||
autoHideTimeout={200}
|
||||
className="css-1o1b8dr"
|
||||
className="css-10t714z"
|
||||
hideTracksWhenNotNeeded={false}
|
||||
setScrollTop={[Function]}
|
||||
>
|
||||
@@ -103,13 +103,13 @@ exports[`TimePickerContent renders correctly in full screen 1`] = `
|
||||
|
||||
exports[`TimePickerContent renders correctly in narrow screen 1`] = `
|
||||
<div
|
||||
className="css-1py9bjs"
|
||||
className="css-ajr8sn"
|
||||
>
|
||||
<div
|
||||
className="css-ooqtr4"
|
||||
>
|
||||
<div
|
||||
className="css-dlnzj7"
|
||||
className="css-1f2wc71"
|
||||
>
|
||||
<FullScreenForm
|
||||
historyOptions={Array []}
|
||||
@@ -136,7 +136,7 @@ exports[`TimePickerContent renders correctly in narrow screen 1`] = `
|
||||
autoHide={false}
|
||||
autoHideDuration={200}
|
||||
autoHideTimeout={200}
|
||||
className="css-1o1b8dr"
|
||||
className="css-10t714z"
|
||||
hideTracksWhenNotNeeded={false}
|
||||
setScrollTop={[Function]}
|
||||
>
|
||||
@@ -200,13 +200,13 @@ exports[`TimePickerContent renders correctly in narrow screen 1`] = `
|
||||
|
||||
exports[`TimePickerContent renders recent absolute ranges correctly 1`] = `
|
||||
<div
|
||||
className="css-1py9bjs"
|
||||
className="css-ajr8sn"
|
||||
>
|
||||
<div
|
||||
className="css-ooqtr4"
|
||||
>
|
||||
<div
|
||||
className="css-dlnzj7"
|
||||
className="css-1f2wc71"
|
||||
>
|
||||
<FullScreenForm
|
||||
history={
|
||||
@@ -268,7 +268,7 @@ exports[`TimePickerContent renders recent absolute ranges correctly 1`] = `
|
||||
autoHide={false}
|
||||
autoHideDuration={200}
|
||||
autoHideTimeout={200}
|
||||
className="css-1o1b8dr"
|
||||
className="css-10t714z"
|
||||
hideTracksWhenNotNeeded={false}
|
||||
setScrollTop={[Function]}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TimeOption } from '@grafana/data';
|
||||
|
||||
export const quickOptions: TimeOption[] = [
|
||||
{ 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: 3 },
|
||||
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 3 },
|
||||
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 3 },
|
||||
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 3 },
|
||||
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 3 },
|
||||
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 3 },
|
||||
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 3 },
|
||||
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 3 },
|
||||
];
|
||||
|
||||
export const otherOptions: TimeOption[] = [
|
||||
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 3 },
|
||||
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday', section: 3 },
|
||||
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week', section: 3 },
|
||||
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 3 },
|
||||
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 3 },
|
||||
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 3 },
|
||||
{ from: 'now/d', to: 'now/d', display: 'Today', section: 3 },
|
||||
{ from: 'now/d', to: 'now', display: 'Today so far', section: 3 },
|
||||
{ from: 'now/w', to: 'now/w', display: 'This week', section: 3 },
|
||||
{ from: 'now/w', to: 'now', display: 'This week so far', section: 3 },
|
||||
{ from: 'now/M', to: 'now/M', display: 'This month', section: 3 },
|
||||
{ from: 'now/M', to: 'now', display: 'This month so far', section: 3 },
|
||||
{ from: 'now/y', to: 'now/y', display: 'This year', section: 3 },
|
||||
{ from: 'now/y', to: 'now', display: 'This year so far', section: 3 },
|
||||
];
|
||||
@@ -159,6 +159,7 @@ export { Checkbox } from './Forms/Checkbox';
|
||||
|
||||
export { TextArea } from './TextArea/TextArea';
|
||||
export { FileUpload } from './FileUpload/FileUpload';
|
||||
export { TimeRangeInput } from './TimePicker/TimeRangeInput';
|
||||
|
||||
// Legacy forms
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jaegertracing/jaeger-ui-components",
|
||||
"version": "7.1.1",
|
||||
"version": "7.1.2",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -14,8 +14,8 @@
|
||||
"typescript": "3.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/data": "7.1.1",
|
||||
"@grafana/ui": "7.1.1",
|
||||
"@grafana/data": "7.1.2",
|
||||
"@grafana/ui": "7.1.2",
|
||||
"@types/classnames": "^2.2.7",
|
||||
"@types/deep-freeze": "^0.1.1",
|
||||
"@types/hoist-non-react-statics": "^3.3.1",
|
||||
|
||||
@@ -426,6 +426,12 @@ func (server *Server) buildGrafanaUser(user *ldap.Entry) (*models.ExternalUserIn
|
||||
}
|
||||
}
|
||||
|
||||
// If there are group org mappings configured, but no matching mappings,
|
||||
// the user will not be able to login and will be disabled
|
||||
if len(server.Config.Groups) > 0 && len(extUser.OrgRoles) == 0 {
|
||||
extUser.IsDisabled = true
|
||||
}
|
||||
|
||||
return extUser, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -113,8 +113,37 @@ func TestLDAPPrivateMethods(t *testing.T) {
|
||||
result, err := server.serializeUsers(users)
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(result[0].IsDisabled, ShouldBeFalse)
|
||||
So(result[0].Name, ShouldEqual, "Roel")
|
||||
})
|
||||
|
||||
Convey("a user without matching groups should be marked as disabled", func() {
|
||||
server := &Server{
|
||||
Config: &ServerConfig{
|
||||
Groups: []*GroupToOrgRole{{
|
||||
GroupDN: "foo",
|
||||
OrgId: 1,
|
||||
OrgRole: models.ROLE_EDITOR,
|
||||
}},
|
||||
},
|
||||
Connection: &MockConnection{},
|
||||
log: log.New("test-logger"),
|
||||
}
|
||||
|
||||
entry := ldap.Entry{
|
||||
DN: "dn",
|
||||
Attributes: []*ldap.EntryAttribute{
|
||||
{Name: "memberof", Values: []string{"admins"}},
|
||||
},
|
||||
}
|
||||
users := []*ldap.Entry{&entry}
|
||||
|
||||
result, err := server.serializeUsers(users)
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(len(result), ShouldEqual, 1)
|
||||
So(result[0].IsDisabled, ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("validateGrafanaUser()", t, func() {
|
||||
|
||||
@@ -221,7 +221,11 @@ func (e *CloudMonitoringExecutor) executeTimeSeriesQuery(ctx context.Context, ts
|
||||
break
|
||||
}
|
||||
query.Params.Set("resourceType", resourceType)
|
||||
queryRes.Meta.Set("deepLink", query.buildDeepLink())
|
||||
dl := ""
|
||||
if len(resp.TimeSeries) > 0 {
|
||||
dl = query.buildDeepLink()
|
||||
}
|
||||
queryRes.Meta.Set("deepLink", dl)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -29,6 +29,7 @@ func Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQ
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer runner.client.Close()
|
||||
|
||||
for _, query := range tsdbQuery.Queries {
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@grafana-plugins/input-datasource",
|
||||
"version": "7.1.1",
|
||||
"version": "7.1.2",
|
||||
"description": "Input Datasource",
|
||||
"private": true,
|
||||
"repository": {
|
||||
@@ -16,9 +16,9 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@grafana/data": "7.1.1",
|
||||
"@grafana/toolkit": "7.1.1",
|
||||
"@grafana/ui": "7.1.1"
|
||||
"@grafana/data": "7.1.2",
|
||||
"@grafana/toolkit": "7.1.2",
|
||||
"@grafana/ui": "7.1.2"
|
||||
},
|
||||
"volta": {
|
||||
"node": "12.16.2"
|
||||
|
||||
@@ -20,7 +20,7 @@ const mock: any = {
|
||||
datasourceId: 'datasource historyId',
|
||||
datasourceName: 'datasource history name',
|
||||
queries: [
|
||||
{ expr: 'query1', refId: '1' },
|
||||
{ expr: 'query1', maxLines: null, refId: '1' },
|
||||
{ expr: 'query2', refId: '2' },
|
||||
],
|
||||
sessionName: '',
|
||||
@@ -96,7 +96,7 @@ describe('addToRichHistory', () => {
|
||||
mock.storedHistory,
|
||||
mock.storedHistory[0].datasourceId,
|
||||
mock.storedHistory[0].datasourceName,
|
||||
[{ expr: 'query1', refId: 'A' } as DataQuery, { expr: 'query2', refId: 'B' } as DataQuery],
|
||||
[{ expr: 'query1', maxLines: null, refId: 'A' } as DataQuery, { expr: 'query2', refId: 'B' } as DataQuery],
|
||||
mock.testStarred,
|
||||
mock.testComment,
|
||||
mock.testSessionName
|
||||
@@ -110,7 +110,7 @@ describe('addToRichHistory', () => {
|
||||
mock.storedHistory,
|
||||
mock.storedHistory[0].datasourceId,
|
||||
mock.storedHistory[0].datasourceName,
|
||||
[{ expr: 'query1', refId: 'A' } as DataQuery, { expr: 'query2', refId: 'B' } as DataQuery],
|
||||
[{ expr: 'query1', maxLines: null, refId: 'A' } as DataQuery, { expr: 'query2', refId: 'B' } as DataQuery],
|
||||
mock.testStarred,
|
||||
mock.testComment,
|
||||
mock.testSessionName
|
||||
|
||||
@@ -330,7 +330,7 @@ export function filterQueriesBySearchFilter(queries: RichHistoryQuery[], searchF
|
||||
const listOfMatchingQueries = query.queries.filter(query =>
|
||||
// Remove fields in which we don't want to be searching
|
||||
Object.values(_.omit(query, ['datasource', 'key', 'refId', 'hide', 'queryType'])).some((value: any) =>
|
||||
value.toString().includes(searchFilter)
|
||||
value?.toString().includes(searchFilter)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export class TimePickerSettings extends PureComponent<Props, State> {
|
||||
|
||||
if (config.minRefreshInterval) {
|
||||
intervals = intervals.filter(rate => {
|
||||
return kbn.interval_to_ms(rate) > kbn.interval_to_ms(config.minRefreshInterval);
|
||||
return kbn.interval_to_ms(rate) >= kbn.interval_to_ms(config.minRefreshInterval);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +64,9 @@ function GridWrapper({
|
||||
/*
|
||||
Disable draggable if mobile device, solving an issue with unintentionally
|
||||
moving panels. https://github.com/grafana/grafana/issues/18497
|
||||
theme.breakpoints.md = 769
|
||||
*/
|
||||
const draggable = width <= 420 ? false : isDraggable;
|
||||
const draggable = width <= 769 ? false : isDraggable;
|
||||
|
||||
return (
|
||||
<ReactGridLayout
|
||||
|
||||
@@ -275,6 +275,7 @@ export class PanelChrome extends PureComponent<Props, State> {
|
||||
<PanelComponent
|
||||
id={panel.id}
|
||||
data={data}
|
||||
title={panel.title}
|
||||
timeRange={timeRange}
|
||||
timeZone={this.props.dashboard.getTimezone()}
|
||||
options={panelOptions}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class PanelHeaderCorner extends Component<Props> {
|
||||
* Open the Panel Inspector when we click on an error
|
||||
*/
|
||||
onClickError = () => {
|
||||
getLocationSrv().update({ partial: true, query: { inspect: this.props.panel.id, tab: InspectTab.Error } });
|
||||
getLocationSrv().update({ partial: true, query: { inspect: this.props.panel.id, inspectTab: InspectTab.Error } });
|
||||
};
|
||||
|
||||
renderCornerType(infoMode: InfoMode, content: PopoverContent, onClick?: () => void) {
|
||||
|
||||
@@ -160,8 +160,8 @@ export class QueryOptions extends PureComponent<Props, State> {
|
||||
placeholder="60"
|
||||
name={name}
|
||||
spellCheck={false}
|
||||
onBlur={this.onDataSourceOptionBlur('maxDataPoints')}
|
||||
onChange={this.onDataSourceOptionChange('maxDataPoints')}
|
||||
onBlur={this.onDataSourceOptionBlur('cacheTimeout')}
|
||||
onChange={this.onDataSourceOptionChange('cacheTimeout')}
|
||||
value={cacheTimeout}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -156,7 +156,11 @@ export function changeDatasource(
|
||||
}
|
||||
|
||||
await dispatch(loadDatasource(exploreId, newDataSourceInstance, orgId));
|
||||
dispatch(runQueries(exploreId));
|
||||
|
||||
// Exception - we only want to run queries on data source change, if the queries were imported
|
||||
if (options?.importQueries) {
|
||||
dispatch(runQueries(exploreId));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,11 +258,12 @@ export function loadExploreDatasourcesAndSetDatasource(
|
||||
exploreId: ExploreId,
|
||||
datasourceName: string
|
||||
): ThunkResult<void> {
|
||||
return dispatch => {
|
||||
return async dispatch => {
|
||||
const exploreDatasources = getExploreDatasources();
|
||||
|
||||
if (exploreDatasources.length >= 1) {
|
||||
dispatch(changeDatasource(exploreId, datasourceName, { importQueries: true }));
|
||||
await dispatch(changeDatasource(exploreId, datasourceName, { importQueries: true }));
|
||||
dispatch(runQueries(exploreId));
|
||||
} else {
|
||||
dispatch(loadDatasourceMissingAction({ exploreId }));
|
||||
}
|
||||
|
||||
644
public/app/features/templating/template_srv.test.ts
Normal file
644
public/app/features/templating/template_srv.test.ts
Normal file
@@ -0,0 +1,644 @@
|
||||
import { TemplateSrv } from './template_srv';
|
||||
import { convertToStoreState } from 'test/helpers/convertToStoreState';
|
||||
import { getTemplateSrvDependencies } from '../../../test/helpers/getTemplateSrvDependencies';
|
||||
import { variableAdapters } from '../variables/adapters';
|
||||
import { createQueryVariableAdapter } from '../variables/query/adapter';
|
||||
import { dateTime, TimeRange } from '@grafana/data';
|
||||
|
||||
describe('templateSrv', () => {
|
||||
let _templateSrv: any;
|
||||
|
||||
function initTemplateSrv(variables: any[], timeRange?: TimeRange) {
|
||||
const state = convertToStoreState(variables);
|
||||
|
||||
_templateSrv = new TemplateSrv(getTemplateSrvDependencies(state));
|
||||
_templateSrv.init(variables, timeRange);
|
||||
}
|
||||
|
||||
describe('init', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]);
|
||||
});
|
||||
|
||||
it('should initialize template data', () => {
|
||||
const target = _templateSrv.replace('this.[[test]].filters');
|
||||
expect(target).toBe('this.oogle.filters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace can pass scoped vars', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]);
|
||||
});
|
||||
|
||||
it('scoped vars should support objects', () => {
|
||||
const target = _templateSrv.replace('${series.name} ${series.nested.field}', {
|
||||
series: { value: { name: 'Server1', nested: { field: 'nested' } } },
|
||||
});
|
||||
expect(target).toBe('Server1 nested');
|
||||
});
|
||||
|
||||
it('built in vars should support objects', () => {
|
||||
_templateSrv.setGlobalVariable('__dashboard', {
|
||||
value: { name: 'hello' },
|
||||
});
|
||||
const target = _templateSrv.replace('${__dashboard.name}');
|
||||
expect(target).toBe('hello');
|
||||
});
|
||||
|
||||
it('scoped vars should support objects with propert names with dot', () => {
|
||||
const target = _templateSrv.replace('${series.name} ${series.nested["field.with.dot"]}', {
|
||||
series: { value: { name: 'Server1', nested: { 'field.with.dot': 'nested' } } },
|
||||
});
|
||||
expect(target).toBe('Server1 nested');
|
||||
});
|
||||
|
||||
it('scoped vars should support arrays of objects', () => {
|
||||
const target = _templateSrv.replace('${series.rows[0].name} ${series.rows[1].name}', {
|
||||
series: { value: { rows: [{ name: 'first' }, { name: 'second' }] } },
|
||||
});
|
||||
expect(target).toBe('first second');
|
||||
});
|
||||
|
||||
it('should replace $test with scoped value', () => {
|
||||
const target = _templateSrv.replace('this.$test.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.mupp.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test} with scoped value', () => {
|
||||
const target = _templateSrv.replace('this.${test}.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.mupp.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:glob} with scoped value', () => {
|
||||
const target = _templateSrv.replace('this.${test:glob}.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.mupp.filters');
|
||||
});
|
||||
|
||||
it('should replace $test with scoped text', () => {
|
||||
const target = _templateSrv.replaceWithText('this.$test.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.asd.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test} with scoped text', () => {
|
||||
const target = _templateSrv.replaceWithText('this.${test}.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.asd.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:glob} with scoped text', () => {
|
||||
const target = _templateSrv.replaceWithText('this.${test:glob}.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.asd.filters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAdhocFilters', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([
|
||||
{
|
||||
type: 'datasource',
|
||||
name: 'ds',
|
||||
current: { value: 'logstash', text: 'logstash' },
|
||||
},
|
||||
{ type: 'adhoc', name: 'test', datasource: 'oogle', filters: [1] },
|
||||
{ type: 'adhoc', name: 'test2', datasource: '$ds', filters: [2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return filters if datasourceName match', () => {
|
||||
const filters = _templateSrv.getAdhocFilters('oogle');
|
||||
expect(filters).toMatchObject([1]);
|
||||
});
|
||||
|
||||
it('should return empty array if datasourceName does not match', () => {
|
||||
const filters = _templateSrv.getAdhocFilters('oogleasdasd');
|
||||
expect(filters).toMatchObject([]);
|
||||
});
|
||||
|
||||
it('should return filters when datasourceName match via data source variable', () => {
|
||||
const filters = _templateSrv.getAdhocFilters('logstash');
|
||||
expect(filters).toMatchObject([2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace can pass multi / all format', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([
|
||||
{
|
||||
type: 'query',
|
||||
name: 'test',
|
||||
current: { value: ['value1', 'value2'] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should replace $test with globbed value', () => {
|
||||
const target = _templateSrv.replace('this.$test.filters', {}, 'glob');
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
describe('when the globbed variable only has one value', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([
|
||||
{
|
||||
type: 'query',
|
||||
name: 'test',
|
||||
current: { value: ['value1'] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not glob the value', () => {
|
||||
const target = _templateSrv.replace('this.$test.filters', {}, 'glob');
|
||||
expect(target).toBe('this.value1.filters');
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace ${test} with globbed value', () => {
|
||||
const target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:glob} with globbed value', () => {
|
||||
const target = _templateSrv.replace('this.${test:glob}.filters', {});
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
it('should replace $test with piped value', () => {
|
||||
const target = _templateSrv.replace('this=$test', {}, 'pipe');
|
||||
expect(target).toBe('this=value1|value2');
|
||||
});
|
||||
|
||||
it('should replace ${test} with piped value', () => {
|
||||
const target = _templateSrv.replace('this=${test}', {}, 'pipe');
|
||||
expect(target).toBe('this=value1|value2');
|
||||
});
|
||||
|
||||
it('should replace ${test:pipe} with piped value', () => {
|
||||
const target = _templateSrv.replace('this=${test:pipe}', {});
|
||||
expect(target).toBe('this=value1|value2');
|
||||
});
|
||||
|
||||
it('should replace ${test:pipe} with piped value and $test with globbed value', () => {
|
||||
const target = _templateSrv.replace('${test:pipe},$test', {}, 'glob');
|
||||
expect(target).toBe('value1|value2,{value1,value2}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variable with all option', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([
|
||||
{
|
||||
type: 'query',
|
||||
name: 'test',
|
||||
current: { value: '$__all' },
|
||||
options: [{ value: '$__all' }, { value: 'value1' }, { value: 'value2' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should replace $test with formatted all value', () => {
|
||||
const target = _templateSrv.replace('this.$test.filters', {}, 'glob');
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test} with formatted all value', () => {
|
||||
const target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:glob} with formatted all value', () => {
|
||||
const target = _templateSrv.replace('this.${test:glob}.filters', {});
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:pipe} with piped value and $test with globbed value', () => {
|
||||
const target = _templateSrv.replace('${test:pipe},$test', {}, 'glob');
|
||||
expect(target).toBe('value1|value2,{value1,value2}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variable with all option and custom value', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([
|
||||
{
|
||||
type: 'query',
|
||||
name: 'test',
|
||||
current: { value: '$__all' },
|
||||
allValue: '*',
|
||||
options: [{ value: 'value1' }, { value: 'value2' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should replace $test with formatted all value', () => {
|
||||
const target = _templateSrv.replace('this.$test.filters', {}, 'glob');
|
||||
expect(target).toBe('this.*.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test} with formatted all value', () => {
|
||||
const target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
|
||||
expect(target).toBe('this.*.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:glob} with formatted all value', () => {
|
||||
const target = _templateSrv.replace('this.${test:glob}.filters', {});
|
||||
expect(target).toBe('this.*.filters');
|
||||
});
|
||||
|
||||
it('should not escape custom all value', () => {
|
||||
const target = _templateSrv.replace('this.$test', {}, 'regex');
|
||||
expect(target).toBe('this.*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lucene format', () => {
|
||||
it('should properly escape $test with lucene escape sequences', () => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
|
||||
const target = _templateSrv.replace('this:$test', {}, 'lucene');
|
||||
expect(target).toBe('this:value\\/4');
|
||||
});
|
||||
|
||||
it('should properly escape ${test} with lucene escape sequences', () => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
|
||||
const target = _templateSrv.replace('this:${test}', {}, 'lucene');
|
||||
expect(target).toBe('this:value\\/4');
|
||||
});
|
||||
|
||||
it('should properly escape ${test:lucene} with lucene escape sequences', () => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
|
||||
const target = _templateSrv.replace('this:${test:lucene}', {});
|
||||
expect(target).toBe('this:value\\/4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('html format', () => {
|
||||
it('should encode values html escape sequences', () => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: '<script>alert(asd)</script>' } }]);
|
||||
const target = _templateSrv.replace('$test', {}, 'html');
|
||||
expect(target).toBe('<script>alert(asd)</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('format variable to string values', () => {
|
||||
it('single value should return value', () => {
|
||||
const result = _templateSrv.formatValue('test');
|
||||
expect(result).toBe('test');
|
||||
});
|
||||
|
||||
it('multi value and glob format should render glob string', () => {
|
||||
const result = _templateSrv.formatValue(['test', 'test2'], 'glob');
|
||||
expect(result).toBe('{test,test2}');
|
||||
});
|
||||
|
||||
it('multi value and lucene should render as lucene expr', () => {
|
||||
const result = _templateSrv.formatValue(['test', 'test2'], 'lucene');
|
||||
expect(result).toBe('("test" OR "test2")');
|
||||
});
|
||||
|
||||
it('multi value and regex format should render regex string', () => {
|
||||
const result = _templateSrv.formatValue(['test.', 'test2'], 'regex');
|
||||
expect(result).toBe('(test\\.|test2)');
|
||||
});
|
||||
|
||||
it('multi value and pipe should render pipe string', () => {
|
||||
const result = _templateSrv.formatValue(['test', 'test2'], 'pipe');
|
||||
expect(result).toBe('test|test2');
|
||||
});
|
||||
|
||||
it('multi value and distributed should render distributed string', () => {
|
||||
const result = _templateSrv.formatValue(['test', 'test2'], 'distributed', {
|
||||
name: 'build',
|
||||
});
|
||||
expect(result).toBe('test,build=test2');
|
||||
});
|
||||
|
||||
it('multi value and distributed should render when not string', () => {
|
||||
const result = _templateSrv.formatValue(['test'], 'distributed', {
|
||||
name: 'build',
|
||||
});
|
||||
expect(result).toBe('test');
|
||||
});
|
||||
|
||||
it('multi value and csv format should render csv string', () => {
|
||||
const result = _templateSrv.formatValue(['test', 'test2'], 'csv');
|
||||
expect(result).toBe('test,test2');
|
||||
});
|
||||
|
||||
it('multi value and percentencode format should render percent-encoded string', () => {
|
||||
const result = _templateSrv.formatValue(['foo()bar BAZ', 'test2'], 'percentencode');
|
||||
expect(result).toBe('%7Bfoo%28%29bar%20BAZ%2Ctest2%7D');
|
||||
});
|
||||
|
||||
it('slash should be properly escaped in regex format', () => {
|
||||
const result = _templateSrv.formatValue('Gi3/14', 'regex');
|
||||
expect(result).toBe('Gi3\\/14');
|
||||
});
|
||||
|
||||
it('single value and singlequote format should render string with value enclosed in single quotes', () => {
|
||||
const result = _templateSrv.formatValue('test', 'singlequote');
|
||||
expect(result).toBe("'test'");
|
||||
});
|
||||
|
||||
it('multi value and singlequote format should render string with values enclosed in single quotes', () => {
|
||||
const result = _templateSrv.formatValue(['test', "test'2"], 'singlequote');
|
||||
expect(result).toBe("'test','test\\'2'");
|
||||
});
|
||||
|
||||
it('single value and doublequote format should render string with value enclosed in double quotes', () => {
|
||||
const result = _templateSrv.formatValue('test', 'doublequote');
|
||||
expect(result).toBe('"test"');
|
||||
});
|
||||
|
||||
it('multi value and doublequote format should render string with values enclosed in double quotes', () => {
|
||||
const result = _templateSrv.formatValue(['test', 'test"2'], 'doublequote');
|
||||
expect(result).toBe('"test","test\\"2"');
|
||||
});
|
||||
|
||||
it('single value and sqlstring format should render string with value enclosed in single quotes', () => {
|
||||
const result = _templateSrv.formatValue("test'value", 'sqlstring');
|
||||
expect(result).toBe(`'test''value'`);
|
||||
});
|
||||
|
||||
it('multi value and sqlstring format should render string with values enclosed in single quotes', () => {
|
||||
const result = _templateSrv.formatValue(['test', "test'value2"], 'sqlstring');
|
||||
expect(result).toBe(`'test','test''value2'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('can check if variable exists', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]);
|
||||
});
|
||||
|
||||
it('should return true if $test exists', () => {
|
||||
const result = _templateSrv.variableExists('$test');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if $test exists in string', () => {
|
||||
const result = _templateSrv.variableExists('something $test something');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if [[test]] exists in string', () => {
|
||||
const result = _templateSrv.variableExists('something [[test]] something');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if [[test:csv]] exists in string', () => {
|
||||
const result = _templateSrv.variableExists('something [[test:csv]] something');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if ${test} exists in string', () => {
|
||||
const result = _templateSrv.variableExists('something ${test} something');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if ${test:raw} exists in string', () => {
|
||||
const result = _templateSrv.variableExists('something ${test:raw} something');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return null if there are no variables in string', () => {
|
||||
const result = _templateSrv.variableExists('string without variables');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('can highlight variables in string', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]);
|
||||
});
|
||||
|
||||
it('should insert html', () => {
|
||||
const result = _templateSrv.highlightVariablesAsHtml('$test');
|
||||
expect(result).toBe('<span class="template-variable">$test</span>');
|
||||
});
|
||||
|
||||
it('should insert html anywhere in string', () => {
|
||||
const result = _templateSrv.highlightVariablesAsHtml('this $test ok');
|
||||
expect(result).toBe('this <span class="template-variable">$test</span> ok');
|
||||
});
|
||||
|
||||
it('should ignore if variables does not exist', () => {
|
||||
const result = _templateSrv.highlightVariablesAsHtml('this $google ok');
|
||||
expect(result).toBe('this $google ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateIndex with simple value', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'muuuu' } }]);
|
||||
});
|
||||
|
||||
it('should set current value and update template data', () => {
|
||||
const target = _templateSrv.replace('this.[[test]].filters');
|
||||
expect(target).toBe('this.muuuu.filters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillVariableValuesForUrl with multi value', () => {
|
||||
beforeAll(() => {
|
||||
variableAdapters.register(createQueryVariableAdapter());
|
||||
});
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([
|
||||
{
|
||||
type: 'query',
|
||||
name: 'test',
|
||||
current: { value: ['val1', 'val2'] },
|
||||
getValueForUrl: function() {
|
||||
return this.current.value;
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should set multiple url params', () => {
|
||||
const params: any = {};
|
||||
_templateSrv.fillVariableValuesForUrl(params);
|
||||
expect(params['var-test']).toMatchObject(['val1', 'val2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillVariableValuesForUrl skip url sync', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([
|
||||
{
|
||||
name: 'test',
|
||||
skipUrlSync: true,
|
||||
current: { value: 'value' },
|
||||
getValueForUrl: function() {
|
||||
return this.current.value;
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not include template variable value in url', () => {
|
||||
const params: any = {};
|
||||
_templateSrv.fillVariableValuesForUrl(params);
|
||||
expect(params['var-test']).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillVariableValuesForUrl with multi value with skip url sync', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([
|
||||
{
|
||||
type: 'query',
|
||||
name: 'test',
|
||||
skipUrlSync: true,
|
||||
current: { value: ['val1', 'val2'] },
|
||||
getValueForUrl: function() {
|
||||
return this.current.value;
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not include template variable value in url', () => {
|
||||
const params: any = {};
|
||||
_templateSrv.fillVariableValuesForUrl(params);
|
||||
expect(params['var-test']).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillVariableValuesForUrl with multi value and scopedVars', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }]);
|
||||
});
|
||||
|
||||
it('should set scoped value as url params', () => {
|
||||
const params: any = {};
|
||||
_templateSrv.fillVariableValuesForUrl(params, {
|
||||
test: { value: 'val1' },
|
||||
});
|
||||
expect(params['var-test']).toBe('val1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillVariableValuesForUrl with multi value, scopedVars and skip url sync', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }]);
|
||||
});
|
||||
|
||||
it('should not set scoped value as url params', () => {
|
||||
const params: any = {};
|
||||
_templateSrv.fillVariableValuesForUrl(params, {
|
||||
test: { name: 'test', value: 'val1', skipUrlSync: true },
|
||||
});
|
||||
expect(params['var-test']).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceWithText', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([
|
||||
{
|
||||
type: 'query',
|
||||
name: 'server',
|
||||
current: { value: '{asd,asd2}', text: 'All' },
|
||||
},
|
||||
{
|
||||
type: 'interval',
|
||||
name: 'period',
|
||||
current: { value: '$__auto_interval_interval', text: 'auto' },
|
||||
},
|
||||
{
|
||||
type: 'textbox',
|
||||
name: 'empty_on_init',
|
||||
current: { value: '', text: '' },
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'foo',
|
||||
current: { value: 'constructor', text: 'constructor' },
|
||||
},
|
||||
]);
|
||||
_templateSrv.setGrafanaVariable('$__auto_interval_interval', '13m');
|
||||
_templateSrv.updateIndex();
|
||||
});
|
||||
|
||||
it('should replace with text except for grafanaVariables', () => {
|
||||
const target = _templateSrv.replaceWithText('Server: $server, period: $period');
|
||||
expect(target).toBe('Server: All, period: 13m');
|
||||
});
|
||||
|
||||
it('should replace empty string-values with an empty string', () => {
|
||||
const target = _templateSrv.replaceWithText('Hello $empty_on_init');
|
||||
expect(target).toBe('Hello ');
|
||||
});
|
||||
|
||||
it('should not return a string representation of a constructor property', () => {
|
||||
const target = _templateSrv.replaceWithText('$foo');
|
||||
expect(target).not.toBe('function Object() { [native code] }');
|
||||
expect(target).toBe('constructor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('built in interval variables', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([]);
|
||||
});
|
||||
|
||||
it('should be possible to fetch value with getBuilInIntervalValue', () => {
|
||||
const val = _templateSrv.getBuiltInIntervalValue();
|
||||
expect(val).toBe('1s');
|
||||
});
|
||||
|
||||
it('should replace $__interval_ms with interval milliseconds', () => {
|
||||
const target = _templateSrv.replace('10 * $__interval_ms', {
|
||||
__interval_ms: { text: '100', value: '100' },
|
||||
});
|
||||
expect(target).toBe('10 * 100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('date formating', () => {
|
||||
beforeEach(() => {
|
||||
initTemplateSrv([], {
|
||||
from: dateTime(1594671549254),
|
||||
to: dateTime(1595237229747),
|
||||
} as TimeRange);
|
||||
});
|
||||
|
||||
it('should replace ${__from} with ms epoch value', () => {
|
||||
const target = _templateSrv.replace('${__from}');
|
||||
expect(target).toBe('1594671549254');
|
||||
});
|
||||
|
||||
it('should replace ${__from:date:seconds} with epoch in seconds', () => {
|
||||
const target = _templateSrv.replace('${__from:date:seconds}');
|
||||
expect(target).toBe('1594671549');
|
||||
});
|
||||
|
||||
it('should replace ${__from:date} with iso date', () => {
|
||||
const target = _templateSrv.replace('${__from:date}');
|
||||
expect(target).toBe('2020-07-13T20:19:09.254Z');
|
||||
});
|
||||
|
||||
it('should replace ${__from:date:iso} with iso date', () => {
|
||||
const target = _templateSrv.replace('${__from:date:iso}');
|
||||
expect(target).toBe('2020-07-13T20:19:09.254Z');
|
||||
});
|
||||
|
||||
it('should replace ${__from:date:YYYY-MM} using custom format', () => {
|
||||
const target = _templateSrv.replace('${__from:date:YYYY-MM}');
|
||||
expect(target).toBe('2020-07');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import _ from 'lodash';
|
||||
import { deprecationWarning, ScopedVars, textUtil, TimeRange } from '@grafana/data';
|
||||
import { deprecationWarning, ScopedVars, textUtil, TimeRange, dateTime } from '@grafana/data';
|
||||
import { getFilteredVariables, getVariables, getVariableWithName } from '../variables/state/selectors';
|
||||
import { variableRegex } from '../variables/utils';
|
||||
import { isAdHoc } from '../variables/guard';
|
||||
@@ -154,6 +154,19 @@ export class TemplateSrv implements BaseTemplateSrv {
|
||||
return format(value, variable, this.formatValue);
|
||||
}
|
||||
|
||||
if (!format) {
|
||||
format = 'glob';
|
||||
}
|
||||
|
||||
// some formats have arguments that come after ':' character
|
||||
let args = format.split(':');
|
||||
if (args.length > 1) {
|
||||
format = args[0];
|
||||
args = args.slice(1);
|
||||
} else {
|
||||
args = [];
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
case 'regex': {
|
||||
if (typeof value === 'string') {
|
||||
@@ -227,7 +240,10 @@ export class TemplateSrv implements BaseTemplateSrv {
|
||||
}
|
||||
return `'${_.replace(value, regExp, "''")}'`;
|
||||
}
|
||||
default: {
|
||||
case 'date': {
|
||||
return this.formatDate(value, args);
|
||||
}
|
||||
case 'glob': {
|
||||
if (_.isArray(value) && value.length > 1) {
|
||||
return '{' + value.join(',') + '}';
|
||||
}
|
||||
@@ -236,6 +252,21 @@ export class TemplateSrv implements BaseTemplateSrv {
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(value: any, args: string[]): string {
|
||||
const arg = args[0] ?? 'iso';
|
||||
|
||||
switch (arg) {
|
||||
case 'ms':
|
||||
return value;
|
||||
case 'seconds':
|
||||
return `${Math.round(parseInt(value, 10)! / 1000)}`;
|
||||
case 'iso':
|
||||
return dateTime(parseInt(value, 10)).toISOString();
|
||||
default:
|
||||
return dateTime(parseInt(value, 10)).format(arg);
|
||||
}
|
||||
}
|
||||
|
||||
setGrafanaVariable(name: string, value: any) {
|
||||
this.grafanaVariables[name] = value;
|
||||
}
|
||||
@@ -267,7 +298,8 @@ export class TemplateSrv implements BaseTemplateSrv {
|
||||
|
||||
variableExists(expression: string) {
|
||||
const name = this.getVariableName(expression);
|
||||
return name && this.getVariableAtIndex(name) !== void 0;
|
||||
const variable = name && this.getVariableAtIndex(name);
|
||||
return variable !== null && variable !== undefined;
|
||||
}
|
||||
|
||||
highlightVariablesAsHtml(str: string) {
|
||||
@@ -427,7 +459,7 @@ export class TemplateSrv implements BaseTemplateSrv {
|
||||
return value.join(',');
|
||||
}
|
||||
|
||||
private getVariableAtIndex = (name: string): any => {
|
||||
private getVariableAtIndex(name: string) {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
@@ -437,11 +469,11 @@ export class TemplateSrv implements BaseTemplateSrv {
|
||||
}
|
||||
|
||||
return this.index[name];
|
||||
};
|
||||
}
|
||||
|
||||
private getAdHocVariables = (): any[] => {
|
||||
private getAdHocVariables(): any[] {
|
||||
return this.dependencies.getFilteredVariables(isAdHoc);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Expose the template srv
|
||||
|
||||
@@ -568,18 +568,19 @@ describe('shared actions', () => {
|
||||
|
||||
tester.thenDispatchedActionsPredicateShouldEqual(dispatchedActions => {
|
||||
expect(dispatchedActions[0]).toEqual(variablesInitTransaction({ uid }));
|
||||
expect(dispatchedActions[1]).toEqual(
|
||||
expect(dispatchedActions[1].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[1].payload.id).toEqual('__dashboard');
|
||||
expect(dispatchedActions[2].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[2].payload.id).toEqual('__org');
|
||||
expect(dispatchedActions[3].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[3].payload.id).toEqual('__user');
|
||||
expect(dispatchedActions[4]).toEqual(
|
||||
addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant }))
|
||||
);
|
||||
expect(dispatchedActions[2]).toEqual(addInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[3]).toEqual(resolveInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[4]).toEqual(removeInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[5].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[5].payload.id).toEqual('__dashboard');
|
||||
expect(dispatchedActions[6].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[6].payload.id).toEqual('__org');
|
||||
expect(dispatchedActions[7].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[7].payload.id).toEqual('__user');
|
||||
expect(dispatchedActions[5]).toEqual(addInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[6]).toEqual(resolveInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[7]).toEqual(removeInitLock(toVariablePayload(constant)));
|
||||
|
||||
expect(dispatchedActions[8]).toEqual(variablesCompleteTransaction({ uid }));
|
||||
return dispatchedActions.length === 9;
|
||||
});
|
||||
@@ -607,18 +608,18 @@ describe('shared actions', () => {
|
||||
expect(dispatchedActions[0]).toEqual(cleanVariables());
|
||||
expect(dispatchedActions[1]).toEqual(variablesClearTransaction());
|
||||
expect(dispatchedActions[2]).toEqual(variablesInitTransaction({ uid }));
|
||||
expect(dispatchedActions[3]).toEqual(
|
||||
expect(dispatchedActions[3].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[3].payload.id).toEqual('__dashboard');
|
||||
expect(dispatchedActions[4].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[4].payload.id).toEqual('__org');
|
||||
expect(dispatchedActions[5].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[5].payload.id).toEqual('__user');
|
||||
expect(dispatchedActions[6]).toEqual(
|
||||
addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant }))
|
||||
);
|
||||
expect(dispatchedActions[4]).toEqual(addInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[5]).toEqual(resolveInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[6]).toEqual(removeInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[7].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[7].payload.id).toEqual('__dashboard');
|
||||
expect(dispatchedActions[8].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[8].payload.id).toEqual('__org');
|
||||
expect(dispatchedActions[9].type).toEqual(addVariable.type);
|
||||
expect(dispatchedActions[9].payload.id).toEqual('__user');
|
||||
expect(dispatchedActions[7]).toEqual(addInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[8]).toEqual(resolveInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[9]).toEqual(removeInitLock(toVariablePayload(constant)));
|
||||
expect(dispatchedActions[10]).toEqual(variablesCompleteTransaction({ uid }));
|
||||
return dispatchedActions.length === 11;
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ export const initDashboardTemplating = (list: VariableModel[]): ThunkResult<void
|
||||
};
|
||||
};
|
||||
|
||||
export const completeDashboardTemplating = (dashboard: DashboardModel): ThunkResult<void> => {
|
||||
export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResult<void> => {
|
||||
return (dispatch, getState) => {
|
||||
const dashboardModel: DashboardVariableModel = {
|
||||
id: '__dashboard',
|
||||
@@ -552,12 +552,15 @@ export const initVariablesTransaction = (dashboardUid: string, dashboard: Dashbo
|
||||
dispatch(cancelVariables());
|
||||
}
|
||||
|
||||
// Start init transaction
|
||||
dispatch(variablesInitTransaction({ uid: dashboardUid }));
|
||||
|
||||
// Add system variables like __dashboard and __user
|
||||
dispatch(addSystemTemplateVariables(dashboard));
|
||||
// Load all variables into redux store
|
||||
dispatch(initDashboardTemplating(dashboard.templating.list));
|
||||
// Process all variable updates
|
||||
await dispatch(processVariables());
|
||||
dispatch(completeDashboardTemplating(dashboard));
|
||||
|
||||
// Mark update as complete
|
||||
dispatch(variablesCompleteTransaction({ uid: dashboardUid }));
|
||||
} catch (err) {
|
||||
dispatch(notifyApp(createErrorNotification('Templating init failed', err)));
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ScopedVars } from '@grafana/data';
|
||||
* \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]]
|
||||
* \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3}
|
||||
*/
|
||||
export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::(\w+))?}/g;
|
||||
export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g;
|
||||
|
||||
// Helper function since lastIndex is not reset
|
||||
export const variableRegexExec = (variableString: string) => {
|
||||
|
||||
@@ -127,8 +127,7 @@
|
||||
ng-model="ctrl.target.stream.type"
|
||||
class="gf-form-input"
|
||||
ng-options="type for type in ['signal','logs', 'fetch']"
|
||||
ng-change="ctrl.streamChanged()" />
|
||||
</select>
|
||||
ng-change="ctrl.streamChanged()" ></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
@@ -181,7 +180,7 @@
|
||||
ng-change="ctrl.streamChanged()"
|
||||
ng-model-onblur />
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow" ng-if="ctrl.target.stream.type !== 'fetch'">
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,6 +198,9 @@
|
||||
<div class="gf-form">
|
||||
<gf-form-switch class="gf-form" label="Level" label-class="query-keyword width-5" checked="ctrl.target.levelColumn" switch-class="max-width-6" on-change="ctrl.refresh()"></gf-form-switch>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-inline" ng-if="ctrl.scenario.id === 'grafana_api'">
|
||||
@@ -209,7 +211,7 @@
|
||||
ng-model="ctrl.target.stringInput"
|
||||
class="gf-form-input"
|
||||
ng-options="type for type in ['datasources', 'search', 'annotations']"
|
||||
ng-change="ctrl.refresh()" />
|
||||
ng-change="ctrl.refresh()">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,7 +226,7 @@
|
||||
placeholder="copy base64 text data from query result"
|
||||
ng-model="ctrl.target.stringInput"
|
||||
ng-change="ctrl.refresh()"
|
||||
ng-model-onblur />
|
||||
ng-model-onblur ></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ function createBarGaugePanelWithData(data: PanelData): ReactWrapper<PanelProps<B
|
||||
timeRange={timeRange}
|
||||
timeZone={'utc'}
|
||||
options={options}
|
||||
title="hello"
|
||||
fieldConfig={fieldConfig}
|
||||
onFieldConfigChange={() => {}}
|
||||
onOptionsChange={() => {}}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DataLinksContextMenu,
|
||||
VizRepeater,
|
||||
VizRepeaterRenderValueProps,
|
||||
BigValueTextMode,
|
||||
} from '@grafana/ui';
|
||||
import {
|
||||
DisplayValueAlignmentFactors,
|
||||
@@ -53,7 +54,7 @@ export class StatPanel extends PureComponent<PanelProps<StatPanelOptions>> {
|
||||
colorMode={options.colorMode}
|
||||
graphMode={options.graphMode}
|
||||
justifyMode={options.justifyMode}
|
||||
textMode={options.textMode}
|
||||
textMode={this.getTextMode()}
|
||||
alignmentFactors={alignmentFactors}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -63,6 +64,18 @@ export class StatPanel extends PureComponent<PanelProps<StatPanelOptions>> {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
getTextMode() {
|
||||
const { options, fieldConfig, title } = this.props;
|
||||
|
||||
// If we have manually set displayName or panel title switch text mode to value and name
|
||||
if (options.textMode === BigValueTextMode.Auto && (fieldConfig.defaults.displayName || !title)) {
|
||||
return BigValueTextMode.ValueAndName;
|
||||
}
|
||||
|
||||
return options.textMode;
|
||||
}
|
||||
|
||||
renderValue = (valueProps: VizRepeaterRenderValueProps<FieldDisplay, DisplayValueAlignmentFactors>): JSX.Element => {
|
||||
const { value } = valueProps;
|
||||
const { getLinks, hasLinks } = value;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PanelProps, renderMarkdown, textUtil } from '@grafana/data';
|
||||
import config from 'app/core/config';
|
||||
// Types
|
||||
import { TextOptions } from './types';
|
||||
import { stylesFactory } from '@grafana/ui';
|
||||
import { stylesFactory, CustomScrollbar } from '@grafana/ui';
|
||||
import { css, cx } from 'emotion';
|
||||
import DangerouslySetHtmlContent from 'dangerously-set-html-content';
|
||||
|
||||
@@ -80,7 +80,11 @@ export class TextPanel extends PureComponent<Props, State> {
|
||||
render() {
|
||||
const { html } = this.state;
|
||||
const styles = getStyles();
|
||||
return <DangerouslySetHtmlContent html={html} className={cx('markdown-html', styles.content)} />;
|
||||
return (
|
||||
<CustomScrollbar autoHeightMin="100%">
|
||||
<DangerouslySetHtmlContent html={html} className={cx('markdown-html', styles.content)} />
|
||||
</CustomScrollbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user