Compare commits

...

27 Commits

Author SHA1 Message Date
Torkel Ödegaard
b076394cd3 BackendSrv: Fix error alert logic (#26453)
(cherry picked from commit fba329f3ac)
2020-08-05 13:48:06 +02:00
Hugo Häggmark
dbb3750610 Release v7.1.2 2020-08-05 13:48:06 +02:00
Ivana Huckova
3e4c0bf8f8 Explore: Run queries when queries imported (#26704)
(cherry picked from commit 744e108b04)
2020-08-05 13:48:06 +02:00
Ryan McKinley
de815d4746 Fields: __field.name as field name and __field.displayName as displayName (#26531)
* name vs displayName

* name vs displayName

* add __values

* add docs for displayName expressions

* Update docs/sources/panels/field-configuration-options.md

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Update docs/sources/panels/field-configuration-options.md

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Update docs/sources/panels/field-configuration-options.md

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Update docs/sources/panels/field-configuration-options.md

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Update docs/sources/panels/field-configuration-options.md

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Update docs/sources/panels/field-configuration-options.md

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

Co-authored-by: kyle <kyle@grafana.com>
Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>
(cherry picked from commit ec783fbff4)
2020-08-05 13:48:06 +02:00
Ryan McKinley
a4b7050609 Test datasource: fix query editor html partial (#26788)
(cherry picked from commit 9e5fe8dbdb)
2020-08-05 13:48:06 +02:00
Ivana Huckova
d9868dc2d1 Query history: Fix search filtering if null value (#26768)
* Fix filtering if null value

* Add test coverage

(cherry picked from commit 6a86e66d76)
2020-08-05 13:48:06 +02:00
Hugo Häggmark
7402337bd1 TimePicker: Fixes app crash when changing custom range to nothing (#26775)
(cherry picked from commit 353b3822c1)
2020-08-05 13:48:06 +02:00
Sofia Papagiannaki
5d3b150687 Cloud monitoring: do not create deep link if there are no timeseries (#26737)
(cherry picked from commit 41f7bccb24)
2020-08-05 13:48:06 +02:00
Paul Sneddon
d81f6e6925 Flux: Ensure connections to InflxuDB are closed (#26735)
* Flux: Ensure connections to InflxuDB are closed

(cherry picked from commit ec756f3729)
2020-08-05 13:48:06 +02:00
Leonard Gram
89e62f985d LDAP: users without org mappings are marked as disabled (#26650)
* LDAP: users without org mappings are marked as disabled

* Update pkg/services/ldap/ldap.go

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* LDAP: verifies that unmapped users are tagged as isDisabled

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>
(cherry picked from commit c266f45858)
2020-08-05 13:48:06 +02:00
Torkel Ödegaard
94b53ecc08 Inspect: Fix link to error tab from panel header (#26682)
(cherry picked from commit eb0b9de044)
2020-08-05 13:48:06 +02:00
Torkel Ödegaard
c0fe700c01 TextPanel: Remove semicolon in markup (#26680)
(cherry picked from commit 3075b71848)
2020-08-05 13:48:06 +02:00
Torkel Ödegaard
2f276f97d3 Dashboard: Fixed disable draggable panels on correct breakpoint (#26636)
(cherry picked from commit ef223d4143)
2020-08-05 13:48:06 +02:00
Alex Khomenko
f7201c7be2 Grafana-UI: Reverse TimeRangeInput range pickers (#26652)
* Grafana-UI: Reverse TimeRangeInput range pickers

* Grafana-UI: Add comment

* Grafana-UI: Update snapshots

(cherry picked from commit 0b8ad9176d)
2020-08-05 13:48:06 +02:00
Torkel Ödegaard
44665ff9fc Templating: Fixed access to system variables like __dashboard, __user & __org during dashboard load & variable queries (#26637)
* Templating: Fixed access to system variables like __dashboard, __user & __org during dashboard load & variable queries

* Fixed tests

(cherry picked from commit 34c2f440db)
2020-08-05 13:48:06 +02:00
Levente Balogh
f7d5e4a8c5 fix(Toolkit/Plugin): throw an Error instead of a string (#26618)
(cherry picked from commit bdbea6d1f2)
2020-08-05 13:48:06 +02:00
Torkel Ödegaard
03b688ce0a StatPanel: Fix stat panel display name not showing when explicitly set (#26616)
* StatPanel: Fix stat panel display name now showing when explicitly set

* StatPanel: Updarted auto mode to also take panel title into consideration

* fixed test

(cherry picked from commit cbe1d7b08c)
2020-08-05 13:48:06 +02:00
Torkel Ödegaard
a89cd8ab7f TemplateSrv: Formatting options for ${__from} and ${__to}, unix seconds epoch, ISO 8601/RFC 3339 (#26466)
* TemplateSrv: WIP date formats

* Templating: formats with arguments

* WIP docs updates

* Docs: Updated docs

* fixed spelling

* Update docs/sources/variables/global-variables.md

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Update docs/sources/variables/global-variables.md

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Update docs/sources/variables/global-variables.md

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Update docs/sources/variables/global-variables.md

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>
(cherry picked from commit 6c49fdb57d)
2020-08-05 13:48:06 +02:00
Torkel Ödegaard
97debfdcce Dashbard: Fix refresh interval settings to allow setting it to equal min_refresh_intereval (#26615)
(cherry picked from commit e3ea725387)
2020-08-05 13:48:06 +02:00
Torkel Ödegaard
6f93a81be0 QueryOptions: Fix not being able to change cache timeout setting (#26614)
(cherry picked from commit cc0a8464ff)
2020-08-05 13:48:06 +02:00
Torkel Ödegaard
1a545a1826 TextPanel: Fix content overflow issue (#26612)
(cherry picked from commit ae3c285312)
2020-08-05 13:48:06 +02:00
tofurky
5274070e77 Units: Remove duplicate SI prefix from mSv and µSv (#26598)
(cherry picked from commit 9d68a1fa62)
2020-08-05 13:48:06 +02:00
Torkel Ödegaard
4996803e00 TimePicker: Fix position and responsive behavior (#26570)
* TimePicker: Fix position and responsive behavior

* Updated snapshots

(cherry picked from commit 93a4b8e8e4)
2020-08-05 13:48:06 +02:00
Alex Khomenko
825c4a5ce0 Grafana-UI: Pass timeZone to TimePickerButtonLabel (#26454)
(cherry picked from commit 9c3f47aa23)
2020-08-05 13:48:06 +02:00
Alex Khomenko
2248c282be Grafana-UI: Enable empty time range (#26320)
* Grafana-UI: Enable empty time range

* Grafana-UI: Add clearable prop

* Grafana-UI: Update types

* Grafana-UI: Use InputTimeRange type

* Grafana-UI: Remove InputTimeRange type

* Grafana-UI: Fix clear icon hover color

(cherry picked from commit 7b183971fd)
2020-08-05 13:48:06 +02:00
Alex Khomenko
5e3514749b Grafana-UI: Add time range input (#26158)
* Grafana UI: Do not submit form on range change

* Grafana UI: Add TimeRangeInput

* Grafana UI: Style input

* Grafana UI: Customize content

* Grafana UI: Adjust caret style

* Grafana UI: Add mdx

* Grafana UI: Fix caret styles

* Grafana UI: Fix typo

* Grafana UI: Do not reload page on timerange change

* Grafana UI: Sync TimeRangeForm state with external value

* Grafana UI: Close overlay on apply

* Grafana UI: Remove unused props

* Grafana UI: Fix story

* Grafana-UI: Make time zone optional

* Grafana-UI: Update styles

* Grafana-UI: Extract button label props

* Grafana-UI: hideHistory => showHistory

* Grafana-UI: Fix caret styles

(cherry picked from commit 718d6fb25d)
2020-08-05 13:48:06 +02:00
David
ae3fde6c7f Explore: Don't run queries on datasource change (#26033)
- more and more datasources are having long-running queries,
automatically triggering is becoming more of a burden than a help.
- some datasource queries might actually cost money, so running queries
should be explicit.

(cherry picked from commit 081f954a2b)
2020-08-05 13:48:06 +02:00
57 changed files with 1383 additions and 214 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -2,5 +2,5 @@
"npmClient": "yarn",
"useWorkspaces": true,
"packages": ["packages/*"],
"version": "7.1.1"
"version": "7.1.2"
}

View File

@@ -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",

View File

@@ -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"

View File

@@ -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 =

View File

@@ -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 });
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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 = {

View 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"');
});
});

View 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?
},
}
);
}

View File

@@ -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> {

View File

@@ -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('');
}

View File

@@ -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') },

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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');
}
});

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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} />

View File

@@ -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>
);
};

View 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;
`,
};
};

View File

@@ -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>
);
});

View File

@@ -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')]);
});
});
});

View File

@@ -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) {

View File

@@ -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>
)}
</>
);
};

View File

@@ -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}
/>
</>
);

View File

@@ -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]}
>

View File

@@ -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 },
];

View File

@@ -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

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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() {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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

View File

@@ -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)
)
);

View File

@@ -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);
});
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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 }));
}

View 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('&lt;script&gt;alert(asd)&lt;/script&gt;');
});
});
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');
});
});
});

View File

@@ -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

View File

@@ -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;
});

View File

@@ -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)));

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -84,6 +84,7 @@ function createBarGaugePanelWithData(data: PanelData): ReactWrapper<PanelProps<B
timeRange={timeRange}
timeZone={'utc'}
options={options}
title="hello"
fieldConfig={fieldConfig}
onFieldConfigChange={() => {}}
onOptionsChange={() => {}}

View File

@@ -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;

View File

@@ -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>
);
}
}