Compare commits

...

41 Commits

Author SHA1 Message Date
Hugo Häggmark
8406a5d319 Release v7.2.0-beta2 2020-09-17 10:44:02 +02:00
Giordano Ricci
8d71561898 Elasticsearch: Add support for date_nanos type (#27538)
(cherry picked from commit 0e34474099)
2020-09-17 10:44:02 +02:00
Hugo Häggmark
8269ed2407 DataLinks: Respects display name and adds field quoting (#27616)
* DataLinks: Adds field quoting and respects DisplayName

* Update public/app/features/panel/panellinks/link_srv.ts

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
(cherry picked from commit e86ff52d44)
2020-09-17 10:44:02 +02:00
Nick Svanidze
4676ee9dcf ApiKeys: Fixes close('X') button layout issue (#27625)
* ApiKeys: Fixes add API key layout

* ApiKeys: snapshot tests updated

(cherry picked from commit 5a06ed431c)
2020-09-17 10:44:02 +02:00
Torkel Ödegaard
35bdf11c0c SharedQuery: Error when switching to -- Dashboard -- data source caused "no data" no matter what source panel was selected (#27627)
* SharedQuery: Error when switching to -- Dashboard -- data source made observable subscription error and subsequent data results not update visualization

* added null check just in case

(cherry picked from commit 31e2b7e7c8)
2020-09-17 10:44:02 +02:00
Torkel Ödegaard
46bb0e3754 ImageRendering: Fix rendering panel using shared query in png, PDF reports and embedded scenarios (#27628)
* ImageRendering: Fixed issue rendering panel using shared query

* Fixed spelling

(cherry picked from commit c450ffd711)
2020-09-17 10:44:02 +02:00
Kyle Brandt
521f31a702 BackendPlugins: Point to request Headers in the wrapper (#27599)
In particular, so plugins can see the FromAlert header

(cherry picked from commit 50c680f3d7)
2020-09-17 10:44:02 +02:00
Torkel Ödegaard
460ccc326f InputControl: Fixed using InputControl in unit tests from plugins (#27615)
(cherry picked from commit 2ed9124736)
2020-09-17 10:44:02 +02:00
Torkel Ödegaard
eca1c505c7 NewsPanel: Fixed XSS issue when rendering rss links (#27612)
(cherry picked from commit b58864792d)
2020-09-17 10:44:02 +02:00
Ryan McKinley
b112b13c2d Graph: show range warning when all data is outside time range (#27603)
(cherry picked from commit 54b677bda4)
2020-09-17 10:44:02 +02:00
Wouter Smeenk
a4689e646a Dashboard: Support configuring default timezone via config file (#27404)
Add a default timezone that the administrator can set in the settings.
This setting is be used as default for the users timezone preference.
Can be used when creating Grafana instances without administrator
intervention, in order to give user the correct default timezone.

Fixes #25654

(cherry picked from commit 39eba5065b)
2020-09-17 10:44:02 +02:00
Torkel Ödegaard
cfc13f7e30 Docs: Field config docs update, Table docs update, Override matcher naming issue (#27558)
* Docs: Field Config Docs Update, Table Docs update, Override matcher naming sync

* removed sentance that feels duplicated

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

* removed bad newlines and minor tweaks, still need to figure out naming for matchers in UI and docs

* Updated matcher names and descriptions, and UX

* Updated field override docs

* Fixed plural

* Updated wording

* removed plurals for the selectors heading

* Updated names

* Updated docs

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>
(cherry picked from commit cade6dd010)
2020-09-17 10:44:02 +02:00
Ryan McKinley
7054834af0 Annotation: use DataFrame[] rather than a single DataFrame (#27587)
(cherry picked from commit 1e4846d443)
2020-09-17 10:44:02 +02:00
Dominik Prokop
48bae0232c Field config: Add support for paths in default field config setup (#27570)
* Add support for paths in default field config setup

* Typecheck fix

(cherry picked from commit e04e3e7d46)
2020-09-17 10:44:02 +02:00
Giordano Ricci
3a32c8f329 Elasticsearch: Increase maximum geohash aggregation precision to 12 (#27539)
(cherry picked from commit e350e1fff6)
2020-09-17 10:44:02 +02:00
Giordano Ricci
b9d4653737 Elasticsearch: Allow fields starting with underscore (#27520)
(cherry picked from commit a0beaa3bbe)
2020-09-17 10:44:02 +02:00
jonny
7220fb5ab8 Variables: Limit rendering of options in dropdown to improve search performance (#27525)
* fixed: bigdata dropdown first limit 1000

* fixed: change to use function of  applylimit

* fixed: remove both applyLimit callers

* feat: test for new logic with applimit

* feat: test showOptions  with the applyLimit logic

* fixed: test equal fixed

(cherry picked from commit 0d2fbd2acd)
2020-09-17 10:44:02 +02:00
Hugo Häggmark
f00222ad03 Postgres: Support request cancellation properly (Uses new backendSrv.fetch Observable request API) (#27478)
* Postgres: Replaces dataSourceRequest with fetch

* Postgres: Replaces dataSourceRequest with fetch

* Tests: removes unnecessary import

(cherry picked from commit a587bf4f56)
2020-09-17 10:44:02 +02:00
Ryan McKinley
6033499576 Chore: use visualization name in field config header (#27579)
(cherry picked from commit c180facba5)
2020-09-17 10:44:02 +02:00
Hansuuuuuuuuuu
1a598a8a41 Auth: Replace maximum inactive/lifetime settings of days to duration (#27150)
Allows login_maximum_inactive_lifetime_duration and
login_maximum_lifetime_duration to be configured using
time.Duration-compatible values while retaining backward compatibility.

Fixes #17554

Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
(cherry picked from commit 8d971ab2f2)
2020-09-17 10:44:02 +02:00
Sofia Papagiannaki
4ca888c798 Fix instrumentation panic if there is no response (#27567)
(cherry picked from commit f529223455)
2020-09-17 10:44:02 +02:00
Torkel Ödegaard
569e2a2c74 Annotations: Fixes issue with showing error notice for cancelled annotation queries (#27557)
(cherry picked from commit b37e132cec)
2020-09-17 10:44:02 +02:00
Agnès Toulet
27bfb61d2b Org API: enrich add user to org endpoints with user ID in the response (#27551)
(cherry picked from commit eb970a4985)
2020-09-17 10:44:02 +02:00
Torkel Ödegaard
ac7112876b PanelEdit: Drag and drop query order & UI tweaks (#27502)
* PanelEdit: Drag and drop query order & UI tweaks

* Fixed width of title issue

* added correct color and hover

* Updated e2e tests

(cherry picked from commit 8759a91222)
2020-09-17 10:44:02 +02:00
Marcus Andersson
711a051725 Templating: global/system variables should be properly replaced in templated values. (#27394)
* Fixed so we try to use the variables in the redux store to replace values in template variables.

* First draft of working version.

* Including fieldPath when adding :text format.

* cleaned up code by introducing helper function.

* some minor refactoring.

* Added tests and support for multi variables.

* added test and code to handle the All scenario of a multivariable.

* fixed according to feedback.

* added docs.

* added text format to gdev dashboard.

* updated e2e tests.

* make sure we use the same function for formatting och variable lable.

* increased the number to 22.

* changed label for tests to be All.

* existing format should be respected.

(cherry picked from commit 0c8390cea2)
2020-09-17 10:44:02 +02:00
Ryan McKinley
c3afe89ea2 Toolkit: clean node_modules/@grafana/data/node_modules in prepare (#27554)
(cherry picked from commit b3b72b8ae6)
2020-09-17 10:44:02 +02:00
Agnès Toulet
dc4b97966f Alerting API: send 404 not found error and enrich delete with UID endpoint response with alert notification ID (#27550)
* Alerting API: Send back 404 not found error for update and delete endpoints

* Alerting API: send back alert notification id for delete with uid endpoint

(cherry picked from commit 0c4b7d3f5d)
2020-09-17 10:44:02 +02:00
Ryan McKinley
4d9f298098 Annotations: add standard annotations support (and use it for flux queries) (#27375)
(cherry picked from commit 5d11d8faa3)
2020-09-17 10:44:02 +02:00
Alex Khomenko
95a688a469 Grafana-UI: Expand ConfirmModal docs (#27541)
(cherry picked from commit 126683929c)
2020-09-17 10:44:02 +02:00
Marcus Efraimsson
1f6d68b38e BootData: Fix nav tree sort regression (#27533)
#26395 introduced a regression regarding sort order of nav tree
items set in Grafana boot data and used for rendering the sidemenu.
This fixes so that sort happens after RunIndexDataHooks is called
in case the hook make changes to the nav tree.

(cherry picked from commit 1983de962c)
2020-09-17 10:44:02 +02:00
Torkel Ödegaard
d842db21d2 Transform: Fixed issue in labels to fields and update docs (#27501)
(cherry picked from commit 61463aa123)
2020-09-17 10:44:02 +02:00
Sofia Papagiannaki
adb6d93442 Revert "Alerting: New feature toggle for enabling standalone alerts (#25984)" (#27531)
This reverts commit 20b603ee1a.

(cherry picked from commit 924224eefb)
2020-09-17 10:44:02 +02:00
Berbe
80a19f014c Binary-prefixed data rates (#27022)
* Dashboard: Merge Data units categories

Prefixes already allow to distinguish IEC units from SI ones
+ Prefer using binary function over decimal one when equal

* Dashboard: Clarify SI & binary prefixes

* Dashboard: Homogeneise rate units

* Dashboard: Add Binary (IEC) prefix for data rates

(cherry picked from commit 794333de3d)
2020-09-17 10:44:02 +02:00
Maksim Nabokikh
25c7090cda Provisioning: Remove provisioned dashboards without parental reader (#26143)
(cherry picked from commit 6e3e0dead8)
2020-09-17 10:44:02 +02:00
Torkel Ödegaard
89dbb0f074 Notifications: UX tweak to redesign of form and sections to make it left aligned and cleaner (#27479)
* UX: Redesign of form and sections to make it left aligned and cleaner

* reduced padding

* design tweaks

(cherry picked from commit 0132bca93a)
2020-09-17 10:44:02 +02:00
Zoltán Bedi
a2b97958bd Prometheus: Fix min step variable interpolation (#27505)
* Add missing dependency to lockfile

* Prometheus: Fix min step variable interpolation

(cherry picked from commit a7ac3f1419)
2020-09-17 10:44:02 +02:00
Marcus Efraimsson
619d985214 Azure/Insights: Fix handling of none dimension values (#27513)
Properly handle legacy dimension values in the backend.

Fixes #27512

(cherry picked from commit e85b266f2e)
2020-09-17 10:44:02 +02:00
Marcus Efraimsson
ee59974edb Alerting: Fix integration key so it's stored encrypted for Pagerduty notifier (#27484)
Fixes an issue introduced by migration in #25980 which
removed storing the Pagerduty integrating key encrypted.

(cherry picked from commit 91a8937e6c)
2020-09-17 10:44:02 +02:00
Oana Mangiurea
109754eeb4 Update Input.mdx (#26226)
* Update Input.mdx

clarify form validation text

* Update packages/grafana-ui/src/components/Input/Input.mdx

Align with textArea

Co-authored-by: Peter Holmberg <peterholmberg@users.noreply.github.com>

Co-authored-by: Peter Holmberg <peterholmberg@users.noreply.github.com>
Co-authored-by: Clarity-89 <homes89@ukr.net>
(cherry picked from commit b01a64e146)
2020-09-17 10:44:02 +02:00
Alex Khomenko
2c6020a57a Grafana-UI: Add docs for ConfirmButton (#27477)
* Grafana-UI: Add docs for ConfirmButton

* Grafana-UI: Add comment

* Update packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.mdx

Co-authored-by: Peter Holmberg <peterholmberg@users.noreply.github.com>

Co-authored-by: Peter Holmberg <peterholmberg@users.noreply.github.com>
(cherry picked from commit 1a5c049883)
2020-09-17 10:44:02 +02:00
Marcus Andersson
dfa808ea25 release 7.0.2-beta1 2020-09-09 14:58:44 +02:00
142 changed files with 3233 additions and 1119 deletions

View File

@@ -279,11 +279,11 @@ editors_can_admin = false
# Login cookie name
login_cookie_name = grafana_session
# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days.
login_maximum_inactive_lifetime_days = 7
# The maximum lifetime (duration) an authenticated user can be inactive before being required to login at next visit. Default is 7 days (7d). This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month). The lifetime resets at each successful token rotation (token_rotation_interval_minutes).
login_maximum_inactive_lifetime_duration =
# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
login_maximum_lifetime_days = 30
# The maximum lifetime (duration) an authenticated user can be logged in since login time before being required to login. Default is 30 days (30d). This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month).
login_maximum_lifetime_duration =
# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
token_rotation_interval_minutes = 10
@@ -823,4 +823,5 @@ interval_year = YYYY
# Experimental feature
use_browser_locale = false
# Default timezone for user preferences. Options are 'browser' for the browser local timezone or a timezone name from IANA Time Zone database, e.g. 'UTC' or 'Europe/Amsterdam' etc.
default_timezone = browser

View File

@@ -278,11 +278,11 @@
# Login cookie name
;login_cookie_name = grafana_session
# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days,
;login_maximum_inactive_lifetime_days = 7
# The maximum lifetime (duration) an authenticated user can be inactive before being required to login at next visit. Default is 7 days (7d). This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month). The lifetime resets at each successful token rotation
;login_maximum_inactive_lifetime_duration =
# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
;login_maximum_lifetime_days = 30
# The maximum lifetime (duration) an authenticated user can be logged in since login time before being required to login. Default is 30 days (30d). This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month).
;login_maximum_lifetime_duration =
# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
;token_rotation_interval_minutes = 10
@@ -811,3 +811,6 @@
# Experimental feature
;use_browser_locale = false
# Default timezone for user preferences. Options are 'browser' for the browser local timezone or a timezone name from IANA Time Zone database, e.g. 'UTC' or 'Europe/Amsterdam' etc.
;default_timezone = browser

View File

@@ -34,7 +34,7 @@
},
"id": 11,
"options": {
"content": "## Global variables\n\n* `__dashboard` = `${__dashboard}`\n* `__dashboard.name` = `${__dashboard.name}`\n* `__dashboard.uid` = `${__dashboard.uid}`\n* `__org.name` = `${__org.name}`\n* `__org.id` = `${__org.id}`\n* `__user.id` = `${__user.id}`\n* `__user.login` = `${__user.login}`\n \n## Formats\n\n* `Server:raw` = `${Server:raw}`\n* `Server:regex` = `${Server:regex}`\n* `Server:lucene` = `${Server:lucene}`\n* `Server:glob` = `${Server:glob}`\n* `Server:pipe` = `${Server:pipe}`\n* `Server:distributed` = `${Server:distributed}`\n* `Server:csv` = `${Server:csv}`\n* `Server:html` = `${Server:html}`\n* `Server:json` = `${Server:json}`\n* `Server:percentencode` = `${Server:percentencode}`\n* `Server:singlequote` = `${Server:singlequote}`\n* `Server:doublequote` = `${Server:doublequote}`\n* `Server:sqlstring` = `${Server:sqlstring}`\n* `Server:date` = `${Server:date}`\n\n",
"content": "## Global variables\n\n* `__dashboard` = `${__dashboard}`\n* `__dashboard.name` = `${__dashboard.name}`\n* `__dashboard.uid` = `${__dashboard.uid}`\n* `__org.name` = `${__org.name}`\n* `__org.id` = `${__org.id}`\n* `__user.id` = `${__user.id}`\n* `__user.login` = `${__user.login}`\n \n## Formats\n\n* `Server:raw` = `${Server:raw}`\n* `Server:regex` = `${Server:regex}`\n* `Server:lucene` = `${Server:lucene}`\n* `Server:glob` = `${Server:glob}`\n* `Server:pipe` = `${Server:pipe}`\n* `Server:distributed` = `${Server:distributed}`\n* `Server:csv` = `${Server:csv}`\n* `Server:html` = `${Server:html}`\n* `Server:json` = `${Server:json}`\n* `Server:percentencode` = `${Server:percentencode}`\n* `Server:singlequote` = `${Server:singlequote}`\n* `Server:doublequote` = `${Server:doublequote}`\n* `Server:sqlstring` = `${Server:sqlstring}`\n* `Server:date` = `${Server:date}`\n* `Server:text` = `${Server:text}`\n\n",
"mode": "markdown"
},
"pluginVersion": "7.1.0",

View File

@@ -1391,7 +1391,7 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise]({{<
### enable
Keys of alpha features to enable, separated by space. Available alpha features are: `transformations`, `standaloneAlerts`
Keys of alpha features to enable, separated by space. Available alpha features are: `transformations`
## [date_formats]
@@ -1426,3 +1426,6 @@ interval_year = YYYY
Set this to `true` to have date formats be automatically be derived from browser locale. Defaults to `false`. This
is an experimental feature right now with a few problems that remain unsolved.
### default_timezone
Used as the default timezone for user preferences. Can be either `browser` for the browser local timezone or a timezone name from IANA Time Zone database, e.g. `UTC` or `Europe/Amsterdam` etc.

View File

@@ -32,7 +32,7 @@ Your Grafana preferences include whether uses the dark or light theme, your home
1. In the Preferences section, you can edit any of the following:
- **UI Theme -** Click to set the **Dark** or **Light** to select a theme. **Default** is either the dark theme or the theme selected by your Grafana administrator.
- **Home Dashboard -** Refer to [Set your personal home dashboard]({{< relref "change-home-dashboard.md#set-your-personal-home-dashboard" >}}) for more information.
- **Timezone -** Click to select an option in the **Timezone** list. Refer to [Time range controls]({{< relref "../dashboards/time-range-controls.md" >}}) for more information about Grafana time settings.
- **Timezone -** Click to select an option in the **Timezone** list. **Default** is either the browser local timezone or the timezone selected by your Grafana administrator. Refer to [Time range controls]({{< relref "../dashboards/time-range-controls.md" >}}) for more information about Grafana time settings.
1. Click **Save**.
## View your assigned organizations

View File

@@ -59,11 +59,13 @@ Example:
# Login cookie name
login_cookie_name = grafana_session
# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days.
login_maximum_inactive_lifetime_days = 7
# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
login_maximum_lifetime_days = 30
# The maximum lifetime (duration) an authenticated user can be inactive before being required to login at next visit. Default is 7 days (7d). This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month). The lifetime resets at each successful token rotation (token_rotation_interval_minutes).
login_maximum_inactive_lifetime_duration =
# The maximum lifetime (duration) an authenticated user can be logged in since login time before being required to login. Default is 30 days (30d). This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month).
login_maximum_lifetime_duration =
# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
token_rotation_interval_minutes = 10

View File

@@ -212,7 +212,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
`GET /api/orgs/name/:orgName`
Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api).
**Example Request**:
```http
@@ -463,7 +463,7 @@ Content-Type: application/json
HTTP/1.1 200
Content-Type: application/json
{"message":"User added to organization"}
{"message":"User added to organization", "userId": 1}
```
### Update Users in Organization

View File

@@ -8,23 +8,19 @@ weight = 300
# Field configuration options
This page explains what field configurations and field overrides in Grafana are and how to use them. It also includes [examples](#examples) if you need an idea of how this feature might be useful in the real world.
This page explains what field options and field overrides in Grafana are and how to use them. It also includes
[examples](#examples) if you need an idea of how this feature might be useful in the real world.
> **Note:** This documentation refers to a Grafana 7.0 beta feature. This documentation will be frequently updated to reflect updates to the feature, and it will probably be broken into smaller sections when the feature moves out of beta.
The data model behind Grafana, the [data frame]({{< relref "../developers/plugins/data-frames.md" >}}), is a columnar-oriented table structure.
Each column within this structure is called a _field_. Grafana allows to customize how a particular field is displayed in the visualization.
The data model used in Grafana, the [data frame]({{< relref "../developers/plugins/data-frames.md" >}}),
is a columnar-oriented table structure that unifies both time series and table query results. Each column within this structure is called a _field_. A field can represent a single time series or table column.
## Field configuration options and overrides
Field configuration options allow you to change how the data is displayed in your visualizations. Options and overrides that you apply do not change the data, they change how Grafana displays the data.
> **Note:** The time fields are not affected by field configuration options or overrides.
_Field configuration options_, both standard and custom, can be found in the **Field** tab in the panel editor. Changes on this tab apply to all fields (i.e. series/columns). For example, if you change the unit to percentage, then all fields with numeric values are displayed in percentages. [Apply a field option](#configure-all-fields).
_Field configuration options_, both standard and custom, are applied in the **Field** tab in the panel editor. Changes on this panel apply to all fields in the visualization. For example, if you change the unit to percentage, then all fields with numeric values will be displayed in percentages. [Apply a field option](#configure-all-fields).
_Field overrides_ are applied in the _Overrides_ tab in the panel editor. They are exactly the same as field configuration options except that they only change fields you select. The current feature only allows you to change one field at a time, but future improvements will offer more flexibility. [Apply an override](#override-a-field).
_Field overrides_ can be added in the **Overrides** tab in the panel editor. There you can add the same options as you find in the **Field** tab, but they are only applied to specific fields. [Apply an override](#override-a-field).
All [field options](#field-options) are defined below.
@@ -42,26 +38,16 @@ Standard field options are:
- [Value mappings](#value-mapping)
- [Data links](#data-links)
You can apply standard field options to the following panel visualizations:
- [Bar gauge]({{< relref "visualizations/bar-gauge-panel.md" >}})
- [Gauge]({{< relref "visualizations/gauge-panel.md" >}})
- [Stat]({{< relref "visualizations/stat-panel.md" >}})
- [Table]({{< relref "visualizations/table-panel.md" >}})
You can apply standard field options to most of the built-in Grafana panels. Some older panels and community panels that have yet to update to the new panel and data model will be missing either all or some of these field options.
### Custom field options
You can only apply custom field options to table visualizations. Plugin authors can add their own custom field options as well, and they might differ across visualizations.
Custom field options are:
- [Column width](#column-width)
- [Column alignment](#column-alignment)
- [Cell display mode](#cell-display-mode)
Some visualizations have custom field options. For example the [Table]({{< relref "visualizations/table-panel.md" >}}) visualization has many custom field options. Community panels can add their own custom field options as well, and they might differ across visualizations.
## Configure all fields
To change how all fields display data, you apply a [field option](#field-options). Usually you apply changes that you want to most of or all of the fields here, rather than applying field overrides to exceptions.
To change how all fields display data, you change an option in the **Field** tab. In the **Overrides** tab
you then override that for specific fields.
1. Navigate to the panel you want to edit, click the panel title, and then click **Edit**.
1. Click the **Field** tab.
@@ -69,9 +55,9 @@ To change how all fields display data, you apply a [field option](#field-options
1. Enter options by adding values in the fields. To return options to default values, delete the white text in the fields.
1. When finished, click **Save** to save all panel edits to the dashboard.
## Override a field
## Configure specific fields with overrides
Field overrides allow you to change the settings for one field (column in tables) to be different than the others. Field options for overrides are exactly the same as the field options available in a particular visualization. The only difference is that you choose which field to apply them to.
Overrides allow you to change the settings for one or more fields (i.e. series or column). What fields are targeted by the override depends on the matcher. Field options for overrides are exactly the same as the field options available in a particular visualization. The only difference is that you choose which fields to apply them to.
1. Navigate to the panel you want to edit, click the panel title, and then click **Edit**.
1. Click the **Overrides** tab.
@@ -83,21 +69,21 @@ Field overrides allow you to change the settings for one field (column in tables
1. Continue to add overrides to this field by clicking **Add override property**, or you can click **Add override** and select a different field to add overrides to.
1. When finished, click **Save** to save all panel edits to the dashboard.
## Filter options
## Select fields
This section explains all available filter options for field overrides. They are listed in alphabetical order.
This section explains the different ways you can select which fields an override rule will be applied to.
### Filter field by name
### Fields with name
Allows you to select a field from the list of all available fields that the override will be applied to.
Allows you to select a field from the list of all available fields. Properties you add to a rule with this selector will only be applied to this single field.
### Filter field by name using regex
### Fields with name matching regex
Allows you to type in a regular expression against which fields to be overridden will be matched.
Allows you to specify a regular expression. Properties you add to a rule with this selector will be applied to all fields where the field name match the regex.
### Filter field by type
### Fields with type
Allows you to select fields by their type (string, numeric, etc).
Allows you to select fields by their type (string, numeric, etc). Properties you add to a rule with this selector will be applied to all fields of matching type.
## Field options
@@ -105,37 +91,6 @@ This section explains all available field options. They are listed in alphabetic
Most field options will not affect the visualization until you click outside of the field option box you are editing or press Enter.
### Cell display mode
This custom field option applies only to table visualizations.
By default, Grafana automatically chooses display settings. You can override the settings by choosing one of the following options to change all fields.
- **Color text -** If thresholds are set, then the field text is displayed in the appropriate threshold color.
- **Color background -** If thresholds are set, then the field background is displayed in the appropriate threshold color.
- **Gradient gauge -** The threshold levels define a gradient.
- **LCD gauge -** The gauge is split up in small cells that are lit or unlit.
- **JSON view -** Shows value formatted as code. If a value is an object the JSON view allowing browsing the JSON object will appear on hover
### Column alignment
This custom field option applies only to table visualizations.
Choose how Grafana should align cell contents:
- Auto (default)
- Left
- Center
- Right
### Column width
This custom field option applies only to table visualizations.
By default, Grafana automatically calculates the column width based on the cell contents. In this field option, can override the setting and define the width for all columns in pixels.
For example, if you enter `100` in the field, then when you click outside the field, all the columns will be set to 100 pixels wide.
### Decimals
Number of decimals to render value with. Leave empty for Grafana to use the number of decimals provided by the data source.

View File

@@ -45,8 +45,8 @@ Transformations are available from the Transform tab in the bottom pane of the p
1. Navigate to the panel that you want to add transformations, click the panel title and then click **Edit**.
1. Click the **Transform** tab.
1. Click a transformation to select it.
1. Click a transformation to select it.
A transformation row appears that allows you to configure the transformation options.
Click **Add transformation** to apply another transformation. Keep in mind that the next transformation acts on the result set returned by the previous transformation.
@@ -107,25 +107,24 @@ In the example below, we have two queries returning table data. It is visualized
Query A:
| Time | Job | Uptime |
|---------------------|---------|-----------|
| ------------------- | ------- | --------- |
| 2020-07-07 11:34:20 | node | 25260122 |
| 2020-07-07 11:24:20 | postgre | 123001233 |
Query B:
| Time | Job | Errors |
|---------------------|---------|--------|
| ------------------- | ------- | ------ |
| 2020-07-07 11:34:20 | node | 15 |
| 2020-07-07 11:24:20 | postgre | 5 |
Here is the result after applying the `Merge` transformation.
| Time | Job | Errors | Uptime |
|---------------------|---------|--------|-----------|
| ------------------- | ------- | ------ | --------- |
| 2020-07-07 11:34:20 | node | 15 | 25260122 |
| 2020-07-07 11:24:20 | postgre | 5 | 123001233 |
### Filter by name
Use this transformation to remove portions of the query results.
@@ -170,7 +169,7 @@ Use this transformation to rename, reorder, or hide fields returned by the query
Grafana displays a list of fields returned by the query. You can:
- Change field order by hovering your cursor over a field. The cursor turns into a hand and then you can drag the field to its new place.
- Hide or show a field by clicking the eye icon next to the field name.
- Hide or show a field by clicking the eye icon next to the field name.
- Rename fields by typing a new name in the **Rename <field>** box.
In the example below, I hid the value field and renamed Max and Min.
@@ -209,12 +208,30 @@ In the example below, I added two fields together and named them Sum.
### Labels to fields
Use this transformation to group series by time and return labels or tags as fields.
> **Note:** In order to apply this transformation, you must have a query to a data source that returns labeled fields.
> **Note:** In order to apply this transformation, your query needs to returns labeled fields.
When you select this transformation, Grafana automatically transforms all labeled data into fields.
Example: Given a query result of two time series
1: labels Server=Server A, Datacenter=EU
2: labels Server=Server B, Datacenter=EU
This would result in a table like this
| Time | Server | Datacenter | Value |
| ------------------- | -------- | ---------- | ----- |
| 2020-07-07 11:34:20 | Server A | EU | 1 |
| 2020-07-07 11:34:20 | Server B | EU | 2 |
**Value field name**
If you where to select `Server` as in the **Value field name** you would get one field for every value of the `Server`
label.
| Time | Datacenter | Server A | Server B |
| ------------------- | ---------- | -------- | -------- |
| 2020-07-07 11:34:20 | EU | 1 | 2 |
For this example, I manually defined labels in the Random Walk visualization of TestData DB.
{{< docs-imagebox img="/img/docs/transformations/labels-to-fields-before-7-0.png" class="docs-image--no-shadow" max-width= "1100px" >}}
@@ -223,7 +240,6 @@ After I apply the transformation, my labels appear in the table as fields.
{{< docs-imagebox img="/img/docs/transformations/labels-to-fields-after-7-0.png" class="docs-image--no-shadow" max-width= "1100px" >}}
### Group By
> **Note:** This transformation is only available in Grafana 7.2+.
@@ -232,57 +248,58 @@ This transformation groups the data by a specified field (column) value and proc
Here's an example of original data.
| Time | Server ID | CPU Temperature | Server Status
|---------------------|-------------|-----------------|----------
| 2020-07-07 11:34:20 | server 1 | 80 | Shutdown
| 2020-07-07 11:34:20 | server 3 | 62 | OK
| 2020-07-07 10:32:20 | server 2 | 90 | Overload
| 2020-07-07 10:31:22 | server 3 | 55 | OK
| 2020-07-07 09:30:57 | server 3 | 62 | Rebooting
| 2020-07-07 09:30:05 | server 2 | 88 | OK
| 2020-07-07 09:28:06 | server 1 | 80 | OK
| 2020-07-07 09:25:05 | server 2 | 88 | OK
| 2020-07-07 09:23:07 | server 1 | 86 | OK
| Time | Server ID | CPU Temperature | Server Status |
| ------------------- | --------- | --------------- | ------------- |
| 2020-07-07 11:34:20 | server 1 | 80 | Shutdown |
| 2020-07-07 11:34:20 | server 3 | 62 | OK |
| 2020-07-07 10:32:20 | server 2 | 90 | Overload |
| 2020-07-07 10:31:22 | server 3 | 55 | OK |
| 2020-07-07 09:30:57 | server 3 | 62 | Rebooting |
| 2020-07-07 09:30:05 | server 2 | 88 | OK |
| 2020-07-07 09:28:06 | server 1 | 80 | OK |
| 2020-07-07 09:25:05 | server 2 | 88 | OK |
| 2020-07-07 09:23:07 | server 1 | 86 | OK |
This transformation goes in two steps. First you specify one or multiple fields to group the data by. This will group all the same values of those fields together, as if you sorted them. For instance if we `Group By` the `Server ID` field, it would group the data this way:
| Time | Server ID | CPU Temperature | Server Status
|---------------------|-------------|-----------------|----------
| 2020-07-07 11:34:20 | **server 1** | 80 | Shutdown
| 2020-07-07 09:28:06 | **server 1** | 80 | OK
| 2020-07-07 09:23:07 | **server 1** | 86 | OK
| Time | Server ID | CPU Temperature | Server Status |
| ------------------- | ------------ | --------------- | ------------- |
| 2020-07-07 11:34:20 | **server 1** | 80 | Shutdown |
| 2020-07-07 09:28:06 | **server 1** | 80 | OK |
| 2020-07-07 09:23:07 | **server 1** | 86 | OK |
|
| 2020-07-07 10:32:20 | server 2 | 90 | Overload
| 2020-07-07 09:30:05 | server 2 | 88 | OK
| 2020-07-07 09:25:05 | server 2 | 88 | OK
| 2020-07-07 10:32:20 | server 2 | 90 | Overload
| 2020-07-07 09:30:05 | server 2 | 88 | OK
| 2020-07-07 09:25:05 | server 2 | 88 | OK
|
| 2020-07-07 11:34:20 | ***server 3*** | 62 | OK
| 2020-07-07 10:31:22 | ***server 3*** | 55 | OK
| 2020-07-07 09:30:57 | ***server 3*** | 62 | Rebooting
| 2020-07-07 11:34:20 | **_server 3_** | 62 | OK
| 2020-07-07 10:31:22 | **_server 3_** | 55 | OK
| 2020-07-07 09:30:57 | **_server 3_** | 62 | Rebooting
All rows with the same value of `Server ID` are grouped together.
After choosing which field you want to group your data by, you can add various calculations on the other fields, and the calculation will be applied on each group of rows. For instance, we could want to calculate the average `CPU temperature` for each of those servers. So we can add the _mean_ calculation applied on the `CPU Temperature` field to get the following:
| Server ID | CPU Temperature (mean)
|-----------|--------------------------
| server 1 | 82
| server 2 | 88.6
| server 3 | 59.6
| Server ID | CPU Temperature (mean) |
| --------- | ---------------------- |
| server 1 | 82 |
| server 2 | 88.6 |
| server 3 | 59.6 |
And we can add more than one of those calculation. For instance :
- For field `Time`, we can calculate the *Last* value, to know when the last data point was received for each server
- For field `Server Status`, we can calculate the *Last* value to know what is the last state value for each server
- For field `Temperature`, we can also calculate the *Last* value to know what is the latest monitored temperature for each server
- For field `Time`, we can calculate the _Last_ value, to know when the last data point was received for each server
- For field `Server Status`, we can calculate the _Last_ value to know what is the last state value for each server
- For field `Temperature`, we can also calculate the _Last_ value to know what is the latest monitored temperature for each server
We would then get :
| Server ID | CPU Temperature (mean) | CPU Temperature (last) | Time (last) | Server Status (last)
|-----------|-------------------------- |------------------------|------------------|----------------------
| server 1 | 82 | 80 | 2020-07-07 11:34:20 | Shutdown
| server 2 | 88.6 | 90 | 2020-07-07 10:32:20 | Overload
| server 3 | 59.6 | 62 | 2020-07-07 11:34:20 | OK
| Server ID | CPU Temperature (mean) | CPU Temperature (last) | Time (last) | Server Status (last) |
| --------- | ---------------------- | ---------------------- | ------------------- | -------------------- |
| server 1 | 82 | 80 | 2020-07-07 11:34:20 | Shutdown |
| server 2 | 88.6 | 90 | 2020-07-07 10:32:20 | Overload |
| server 3 | 59.6 | 62 | 2020-07-07 11:34:20 | OK |
This transformation allows you to extract some key information out of your time series and display them in a convenient way.
@@ -290,7 +307,7 @@ This transformation allows you to extract some key information out of your time
> **Note:** This transformation is only available in Grafana 7.1+.
Use this transformation to combine the result from multiple time series data queries into one single result. This is helpful when using the table panel visualization.
Use this transformation to combine the result from multiple time series data queries into one single result. This is helpful when using the table panel visualization.
The result from this transformation will contain three columns: `Time`, `Metric`, and `Value`. The `Metric` column is added so you easily can see from which query the metric originates from. Customize this value by defining `Label` on the source query.
@@ -299,7 +316,7 @@ In the example below, we have two queries returning time series data. It is visu
Query A:
| Time | Temperature |
|---------------------|-------------|
| ------------------- | ----------- |
| 2020-07-07 11:34:20 | 25 |
| 2020-07-07 10:31:22 | 22 |
| 2020-07-07 09:30:05 | 19 |
@@ -307,7 +324,7 @@ Query A:
Query B:
| Time | Humidity |
|---------------------|----------|
| ------------------- | -------- |
| 2020-07-07 11:34:20 | 24 |
| 2020-07-07 10:32:20 | 29 |
| 2020-07-07 09:30:57 | 33 |
@@ -315,7 +332,7 @@ Query B:
Here is the result after applying the `Series to rows` transformation.
| Time | Metric | Value |
|---------------------|-------------|-------|
| ------------------- | ----------- | ----- |
| 2020-07-07 11:34:20 | Temperature | 25 |
| 2020-07-07 11:34:20 | Humidity | 22 |
| 2020-07-07 10:32:20 | Humidity | 29 |

View File

@@ -27,6 +27,43 @@ Table visualizations allow you to apply:
- **Show header -** Show or hide column names imported from your data source..
### Custom field options
- [Column width](#column-width)
- [Column alignment](#column-alignment)
- [Cell display mode](#cell-display-mode)
### Column alignment
This custom field option applies only to table visualizations.
Choose how Grafana should align cell contents:
- Auto (default)
- Left
- Center
- Right
### Column width
This custom field option applies only to table visualizations.
By default, Grafana automatically calculates the column width based on the cell contents. In this field option, can override the setting and define the width for all columns in pixels.
For example, if you enter `100` in the field, then when you click outside the field, all the columns will be set to 100 pixels wide.
#### Cell display mode
This custom field option applies only to table visualizations.
By default, Grafana automatically chooses display settings. You can override the settings by choosing one of the following options to change all fields.
- **Color text -** If thresholds are set, then the field text is displayed in the appropriate threshold color.
- **Color background -** If thresholds are set, then the field background is displayed in the appropriate threshold color.
- **Gradient gauge -** The threshold levels define a gradient.
- **LCD gauge -** The gauge is split up in small cells that are lit or unlit.
- **JSON view -** Shows value formatted as code. If a value is an object the JSON view allowing browsing the JSON object will appear on hover
## Tips
### Display original string value

View File

@@ -143,3 +143,13 @@ servers = ["test'1", "test2"]
String to interpolate: '${servers:sqlstring}'
Interpolation result: "'test''1','test2'"
```
## Text
Formats single- and multi-valued variables into their text representation. For a single variable it will just return the text representation. For multi-valued variables it will return the text representation combined with `+`.
```bash
servers = ["test1", "test2"]
String to interpolate: '${servers:text}'
Interpolation result: "test1 + test2"
```

View File

@@ -33,11 +33,12 @@ e2e.scenario({
`Server:doublequote = "A'A\\"A","BB\\B","CCC"`,
`Server:sqlstring = 'A''A"A','BB\\\B','CCC'`,
`Server:date = null`,
`Server:text = All`,
];
e2e()
.get('.markdown-html li')
.should('have.length', 21)
.should('have.length', 22)
.each(element => {
items.push(element.text());
})

View File

@@ -75,44 +75,6 @@ e2e.scenario({
e2e().wait('@apiPostQuery');
// Change order or query rows
// Check the order of the rows before
e2e.components.QueryEditorRows.rows()
.eq(0)
.within(() => {
e2e.components.QueryEditorRow.title('B').should('be.visible');
});
e2e.components.QueryEditorRows.rows()
.eq(1)
.within(() => {
e2e.components.QueryEditorRow.title('A').should('be.visible');
});
// Change so A is first
e2e.components.QueryEditorRow.actionButton('Move query up')
.eq(1)
.click();
e2e().wait('@apiPostQuery');
// Avoid flaky tests
// Maybe the virtual dom performs optimzations such as node position swapping, meaning 1 becomes 0 and it gets that element before the change because and never finds title 'A'
e2e().wait(250);
// Check the order of the rows after change
e2e.components.QueryEditorRows.rows()
.eq(0)
.within(() => {
e2e.components.QueryEditorRow.title('A').should('be.visible');
});
e2e.components.QueryEditorRows.rows()
.eq(1)
.within(() => {
e2e.components.QueryEditorRow.title('B').should('be.visible');
});
// Disable / enable row
expectInspectorResultAndClose(keys => {
const length = keys.length;
@@ -120,7 +82,7 @@ e2e.scenario({
expect(keys[length - 1].innerText).equals('B:');
});
// Disable row with refId B
// Disable row with refId A
e2e.components.QueryEditorRow.actionButton('Disable/enable query')
.eq(1)
.should('be.visible')
@@ -130,7 +92,7 @@ e2e.scenario({
expectInspectorResultAndClose(keys => {
const length = keys.length;
expect(keys[length - 1].innerText).equals('A:');
expect(keys[length - 1].innerText).equals('B:');
});
// Enable row with refId B

View File

@@ -0,0 +1,17 @@
import { e2e } from '@grafana/e2e';
e2e.scenario({
describeName: 'Solo Route',
itName: 'Can view panels with shared queries in fullsceen',
addScenarioDataSource: false,
addScenarioDashBoard: false,
skipScenario: false,
scenario: () => {
// open Panel Tests - Bar Gauge
e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4');
e2e()
.get('canvas')
.should('have.length', 6);
},
});

View File

@@ -2,5 +2,5 @@
"npmClient": "yarn",
"useWorkspaces": true,
"packages": ["packages/*"],
"version": "7.2.0-pre.0"
"version": "7.2.0-beta.2"
}

View File

@@ -3,7 +3,7 @@
"license": "Apache-2.0",
"private": true,
"name": "grafana",
"version": "7.2.0-beta1",
"version": "7.2.0-beta2",
"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.2.0-pre.0",
"version": "7.2.0-beta.2",
"description": "Grafana Data Library",
"keywords": [
"typescript"

View File

@@ -317,7 +317,7 @@ export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = an
for (const customProp of builder.getRegistry().list()) {
customProp.isCustom = true;
customProp.category = ['Custom options'].concat(customProp.category || []);
customProp.category = [`${this.meta.name} options`].concat(customProp.category || []);
// need to do something to make the custom items not conflict with standard ones
// problem is id (registry index) is used as property path
// so sort of need a property path on the FieldPropertyEditorItem

View File

@@ -50,7 +50,16 @@ describe('Labels as Columns', () => {
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000] },
{ name: 'Value', type: FieldType.number, values: [1, 2], labels: { location: 'inside', name: 'Request' } },
{
name: 'Value',
type: FieldType.number,
values: [1, 2],
labels: { location: 'inside', name: 'Request' },
config: {
displayName: 'Custom1',
displayNameFromDS: 'Custom2',
},
},
],
});

View File

@@ -51,7 +51,9 @@ export const labelsToFieldsTransformer: DataTransformerInfo<LabelsToFieldsOption
name,
config: {
...field.config,
// we need to clear thes for this transform as these can contain label names that we no longer want
displayName: undefined,
displayNameFromDS: undefined,
},
labels: undefined,
});

View File

@@ -0,0 +1,87 @@
import { DataQuery, QueryEditorProps } from './datasource';
import { DataFrame } from './dataFrame';
import { ComponentType } from 'react';
/**
* This JSON object is stored in the dashboard json model.
*/
export interface AnnotationQuery<TQuery extends DataQuery = DataQuery> {
datasource: string;
enable: boolean;
name: string;
iconColor: string;
// Standard datasource query
target?: TQuery;
// Convert a dataframe to an AnnotationEvent
mappings?: AnnotationEventMappings;
}
export interface AnnotationEvent {
id?: string;
annotation?: any;
dashboardId?: number;
panelId?: number;
userId?: number;
login?: string;
email?: string;
avatarUrl?: string;
time?: number;
timeEnd?: number;
isRegion?: boolean;
title?: string;
text?: string;
type?: string;
tags?: string[];
// Currently used to merge annotations from alerts and dashboard
source?: any; // source.type === 'dashboard'
}
/**
* @alpha -- any value other than `field` is experimental
*/
export enum AnnotationEventFieldSource {
Field = 'field', // Default -- find the value with a matching key
Text = 'text', // Write a constant string into the value
Skip = 'skip', // Do not include the field
}
export interface AnnotationEventFieldMapping {
source?: AnnotationEventFieldSource; // defautls to 'field'
value?: string;
regex?: string;
}
export type AnnotationEventMappings = Partial<Record<keyof AnnotationEvent, AnnotationEventFieldMapping>>;
/**
* Since Grafana 7.2
*
* This offers a generic approach to annotation processing
*/
export interface AnnotationSupport<TQuery extends DataQuery = DataQuery, TAnno = AnnotationQuery<TQuery>> {
/**
* This hook lets you manipulate any existing stored values before running them though the processor.
* This is particularly helpful when dealing with migrating old formats. ie query as a string vs object
*/
prepareAnnotation?(json: any): TAnno;
/**
* Convert the stored JSON model to a standard datasource query object.
* This query will be executed in the datasource and the results converted into events.
* Returning an undefined result will quietly skip query execution
*/
prepareQuery?(anno: TAnno): TQuery | undefined;
/**
* When the standard frame > event processing is insufficient, this allows explicit control of the mappings
*/
processEvents?(anno: TAnno, data: DataFrame[]): AnnotationEvent[] | undefined;
/**
* Specify a custom QueryEditor for the annotation page. If not specified, the standard one will be used
*/
QueryEditor?: ComponentType<QueryEditorProps<any, TQuery>>;
}

View File

@@ -42,7 +42,6 @@ export interface FeatureToggles {
meta: boolean;
datasourceInsights: boolean;
reportGrid: boolean;
standaloneAlerts: boolean;
}
/**

View File

@@ -132,27 +132,6 @@ export enum NullValueMode {
AsZero = 'null as zero',
}
export interface AnnotationEvent {
id?: string;
annotation?: any;
dashboardId?: number;
panelId?: number;
userId?: number;
login?: string;
email?: string;
avatarUrl?: string;
time?: number;
timeEnd?: number;
isRegion?: boolean;
title?: string;
text?: string;
type?: string;
tags?: string[];
// Currently used to merge annotations from alerts and dashboard
source?: any; // source.type === 'dashboard'
}
/**
* Describes and API for exposing panel specific data configurations.
*/

View File

@@ -3,7 +3,8 @@ import { ComponentType } from 'react';
import { GrafanaPlugin, PluginMeta } from './plugin';
import { PanelData } from './panel';
import { LogRowModel } from './logs';
import { AnnotationEvent, KeyValue, LoadingState, TableData, TimeSeries } from './data';
import { AnnotationEvent, AnnotationSupport } from './annotations';
import { KeyValue, LoadingState, TableData, TimeSeries } from './data';
import { DataFrame, DataFrameDTO } from './dataFrame';
import { RawTimeRange, TimeRange } from './time';
import { ScopedVars } from './ScopedVars';
@@ -155,8 +156,7 @@ export interface DataSourceConstructor<
*/
export abstract class DataSourceApi<
TQuery extends DataQuery = DataQuery,
TOptions extends DataSourceJsonData = DataSourceJsonData,
TAnno = TQuery // defatult to direct query
TOptions extends DataSourceJsonData = DataSourceJsonData
> {
/**
* Set in constructor
@@ -267,13 +267,23 @@ export abstract class DataSourceApi<
showContextToggle?(row?: LogRowModel): boolean;
/**
* Can be optionally implemented to allow datasource to be a source of annotations for dashboard. To be visible
* in the annotation editor `annotations` capability also needs to be enabled in plugin.json.
*/
annotationQuery?(options: AnnotationQueryRequest<TAnno>): Promise<AnnotationEvent[]>;
interpolateVariablesInQueries?(queries: TQuery[], scopedVars: ScopedVars | {}): TQuery[];
/**
* An annotation processor allows explict control for how annotations are managed.
*
* It is only necessary to configure an annotation processor if the default behavior is not desirable
*/
annotations?: AnnotationSupport<TQuery>;
/**
* Can be optionally implemented to allow datasource to be a source of annotations for dashboard.
* This function will only be called if an angular {@link AnnotationsQueryCtrl} is configured and
* the {@link annotations} is undefined
*
* @deprecated -- prefer using {@link AnnotationSupport}
*/
annotationQuery?(options: AnnotationQueryRequest<TQuery>): Promise<AnnotationEvent[]>;
}
export interface MetadataInspectorProps<
@@ -473,12 +483,6 @@ export interface MetricFindValue {
expandable?: boolean;
}
export interface BaseAnnotationQuery {
datasource: string;
enable: boolean;
name: string;
}
export interface DataSourceJsonData {
authType?: string;
defaultRegion?: string;
@@ -547,20 +551,19 @@ export interface DataSourceSelectItem {
/**
* Options passed to the datasource.annotationQuery method. See docs/plugins/developing/datasource.md
*
* @deprecated -- use {@link AnnotationSupport}
*/
export interface AnnotationQueryRequest<TAnno = {}> {
export interface AnnotationQueryRequest<MoreOptions = {}> {
range: TimeRange;
rangeRaw: RawTimeRange;
interval: string;
intervalMs: number;
maxDataPoints?: number;
app: CoreApp | string;
// Should be DataModel but cannot import that here from the main app. Needs to be moved to package first.
dashboard: any;
// The annotation query and common properties
annotation: BaseAnnotationQuery & TAnno;
annotation: {
datasource: string;
enable: boolean;
name: string;
} & MoreOptions;
}
export interface HistoryItem<TQuery extends DataQuery = DataQuery> {

View File

@@ -1,6 +1,7 @@
export * from './data';
export * from './dataFrame';
export * from './dataLink';
export * from './annotations';
export * from './logs';
export * from './navModel';
export * from './select';

View File

@@ -22,7 +22,7 @@ import {
toTimeTicks,
} from './dateTimeFormatters';
import { toHex, sci, toHex0x, toPercent, toPercentUnit } from './arithmeticFormatters';
import { binarySIPrefix, currency, decimalSIPrefix } from './symbolFormatters';
import { binaryPrefix, currency, SIPrefix } from './symbolFormatters';
export const getCategories = (): ValueFormatCategory[] => [
{
@@ -75,14 +75,14 @@ export const getCategories = (): ValueFormatCategory[] => [
{
name: 'Computation',
formats: [
{ name: 'FLOP/s', id: 'flops', fn: decimalSIPrefix('FLOP/s') },
{ name: 'MFLOP/s', id: 'mflops', fn: decimalSIPrefix('FLOP/s', 2) },
{ name: 'GFLOP/s', id: 'gflops', fn: decimalSIPrefix('FLOP/s', 3) },
{ name: 'TFLOP/s', id: 'tflops', fn: decimalSIPrefix('FLOP/s', 4) },
{ name: 'PFLOP/s', id: 'pflops', fn: decimalSIPrefix('FLOP/s', 5) },
{ name: 'EFLOP/s', id: 'eflops', fn: decimalSIPrefix('FLOP/s', 6) },
{ name: 'ZFLOP/s', id: 'zflops', fn: decimalSIPrefix('FLOP/s', 7) },
{ name: 'YFLOP/s', id: 'yflops', fn: decimalSIPrefix('FLOP/s', 8) },
{ name: 'FLOP/s', id: 'flops', fn: SIPrefix('FLOP/s') },
{ name: 'MFLOP/s', id: 'mflops', fn: SIPrefix('FLOP/s', 2) },
{ name: 'GFLOP/s', id: 'gflops', fn: SIPrefix('FLOP/s', 3) },
{ name: 'TFLOP/s', id: 'tflops', fn: SIPrefix('FLOP/s', 4) },
{ name: 'PFLOP/s', id: 'pflops', fn: SIPrefix('FLOP/s', 5) },
{ name: 'EFLOP/s', id: 'eflops', fn: SIPrefix('FLOP/s', 6) },
{ name: 'ZFLOP/s', id: 'zflops', fn: SIPrefix('FLOP/s', 7) },
{ name: 'YFLOP/s', id: 'yflops', fn: SIPrefix('FLOP/s', 8) },
],
},
{
@@ -128,45 +128,52 @@ export const getCategories = (): ValueFormatCategory[] => [
],
},
{
name: 'Data (IEC)',
name: 'Data',
formats: [
{ name: 'bits(IEC)', id: 'bits', fn: binarySIPrefix('b') },
{ name: 'bytes(IEC)', id: 'bytes', fn: binarySIPrefix('B') },
{ name: 'kibibytes', id: 'kbytes', fn: binarySIPrefix('B', 1) },
{ name: 'mebibytes', id: 'mbytes', fn: binarySIPrefix('B', 2) },
{ name: 'gibibytes', id: 'gbytes', fn: binarySIPrefix('B', 3) },
{ name: 'tebibytes', id: 'tbytes', fn: binarySIPrefix('B', 4) },
{ name: 'pebibytes', id: 'pbytes', fn: binarySIPrefix('B', 5) },
],
},
{
name: 'Data (metric)',
formats: [
{ name: 'bits(Metric)', id: 'decbits', fn: decimalSIPrefix('b') },
{ name: 'bytes(Metric)', id: 'decbytes', fn: decimalSIPrefix('B') },
{ name: 'kilobytes', id: 'deckbytes', fn: decimalSIPrefix('B', 1) },
{ name: 'megabytes', id: 'decmbytes', fn: decimalSIPrefix('B', 2) },
{ name: 'gigabytes', id: 'decgbytes', fn: decimalSIPrefix('B', 3) },
{ name: 'terabytes', id: 'dectbytes', fn: decimalSIPrefix('B', 4) },
{ name: 'petabytes', id: 'decpbytes', fn: decimalSIPrefix('B', 5) },
{ name: 'bytes(IEC)', id: 'bytes', fn: binaryPrefix('B') },
{ name: 'bytes(SI)', id: 'decbytes', fn: SIPrefix('B') },
{ name: 'bits(IEC)', id: 'bits', fn: binaryPrefix('b') },
{ name: 'bits(SI)', id: 'decbits', fn: SIPrefix('b') },
{ name: 'kibibytes', id: 'kbytes', fn: binaryPrefix('B', 1) },
{ name: 'kilobytes', id: 'deckbytes', fn: SIPrefix('B', 1) },
{ name: 'mebibytes', id: 'mbytes', fn: binaryPrefix('B', 2) },
{ name: 'megabytes', id: 'decmbytes', fn: SIPrefix('B', 2) },
{ name: 'gibibytes', id: 'gbytes', fn: binaryPrefix('B', 3) },
{ name: 'gigabytes', id: 'decgbytes', fn: SIPrefix('B', 3) },
{ name: 'tebibytes', id: 'tbytes', fn: binaryPrefix('B', 4) },
{ name: 'terabytes', id: 'dectbytes', fn: SIPrefix('B', 4) },
{ name: 'pebibytes', id: 'pbytes', fn: binaryPrefix('B', 5) },
{ name: 'petabytes', id: 'decpbytes', fn: SIPrefix('B', 5) },
],
},
{
name: 'Data rate',
formats: [
{ name: 'packets/sec', id: 'pps', fn: decimalSIPrefix('pps') },
{ name: 'bits/sec', id: 'bps', fn: decimalSIPrefix('bps') },
{ name: 'bytes/sec', id: 'Bps', fn: decimalSIPrefix('B/s') },
{ name: 'kilobytes/sec', id: 'KBs', fn: decimalSIPrefix('B/s', 1) },
{ name: 'kilobits/sec', id: 'Kbits', fn: decimalSIPrefix('bps', 1) },
{ name: 'megabytes/sec', id: 'MBs', fn: decimalSIPrefix('B/s', 2) },
{ name: 'megabits/sec', id: 'Mbits', fn: decimalSIPrefix('bps', 2) },
{ name: 'gigabytes/sec', id: 'GBs', fn: decimalSIPrefix('B/s', 3) },
{ name: 'gigabits/sec', id: 'Gbits', fn: decimalSIPrefix('bps', 3) },
{ name: 'terabytes/sec', id: 'TBs', fn: decimalSIPrefix('B/s', 4) },
{ name: 'terabits/sec', id: 'Tbits', fn: decimalSIPrefix('bps', 4) },
{ name: 'petabytes/sec', id: 'PBs', fn: decimalSIPrefix('B/s', 5) },
{ name: 'petabits/sec', id: 'Pbits', fn: decimalSIPrefix('bps', 5) },
{ name: 'packets/sec', id: 'pps', fn: SIPrefix('p/s') },
{ name: 'bytes/sec(IEC)', id: 'Bps', fn: binaryPrefix('B/s') },
{ name: 'bytes/sec(SI)', id: 'decBps', fn: SIPrefix('B/s') },
{ name: 'bits/sec(IEC)', id: 'bps', fn: binaryPrefix('b/s') },
{ name: 'bits/sec(SI)', id: 'decbps', fn: SIPrefix('b/s') },
{ name: 'kibibytes/sec', id: 'KiBs', fn: binaryPrefix('B/s', 1) },
{ name: 'kibibits/sec', id: 'Kibits', fn: binaryPrefix('b/s', 1) },
{ name: 'kilobytes/sec', id: 'KBs', fn: SIPrefix('B/s', 1) },
{ name: 'kilobits/sec', id: 'Kbits', fn: SIPrefix('b/s', 1) },
{ name: 'mibibytes/sec', id: 'MiBs', fn: binaryPrefix('B/s', 2) },
{ name: 'mibibits/sec', id: 'Mibits', fn: binaryPrefix('b/s', 2) },
{ name: 'megabytes/sec', id: 'MBs', fn: SIPrefix('B/s', 2) },
{ name: 'megabits/sec', id: 'Mbits', fn: SIPrefix('b/s', 2) },
{ name: 'gibibytes/sec', id: 'GiBs', fn: binaryPrefix('B/s', 3) },
{ name: 'gibibits/sec', id: 'Gibits', fn: binaryPrefix('b/s', 3) },
{ name: 'gigabytes/sec', id: 'GBs', fn: SIPrefix('B/s', 3) },
{ name: 'gigabits/sec', id: 'Gbits', fn: SIPrefix('b/s', 3) },
{ name: 'tebibytes/sec', id: 'TiBs', fn: binaryPrefix('B/s', 4) },
{ name: 'tebibits/sec', id: 'Tibits', fn: binaryPrefix('b/s', 4) },
{ name: 'terabytes/sec', id: 'TBs', fn: SIPrefix('B/s', 4) },
{ name: 'terabits/sec', id: 'Tbits', fn: SIPrefix('b/s', 4) },
{ name: 'petibytes/sec', id: 'PiBs', fn: binaryPrefix('B/s', 5) },
{ name: 'petibits/sec', id: 'Pibits', fn: binaryPrefix('b/s', 5) },
{ name: 'petabytes/sec', id: 'PBs', fn: SIPrefix('B/s', 5) },
{ name: 'petabits/sec', id: 'Pbits', fn: SIPrefix('b/s', 5) },
],
},
{
@@ -183,44 +190,44 @@ export const getCategories = (): ValueFormatCategory[] => [
{
name: 'Energy',
formats: [
{ name: 'Watt (W)', id: 'watt', fn: decimalSIPrefix('W') },
{ name: 'Kilowatt (kW)', id: 'kwatt', fn: decimalSIPrefix('W', 1) },
{ name: 'Megawatt (MW)', id: 'megwatt', fn: decimalSIPrefix('W', 2) },
{ name: 'Gigawatt (GW)', id: 'gwatt', fn: decimalSIPrefix('W', 3) },
{ name: 'Milliwatt (mW)', id: 'mwatt', fn: decimalSIPrefix('W', -1) },
{ name: 'Watt (W)', id: 'watt', fn: SIPrefix('W') },
{ name: 'Kilowatt (kW)', id: 'kwatt', fn: SIPrefix('W', 1) },
{ name: 'Megawatt (MW)', id: 'megwatt', fn: SIPrefix('W', 2) },
{ name: 'Gigawatt (GW)', id: 'gwatt', fn: SIPrefix('W', 3) },
{ name: 'Milliwatt (mW)', id: 'mwatt', fn: SIPrefix('W', -1) },
{ name: 'Watt per square meter (W/m²)', id: 'Wm2', fn: toFixedUnit('W/m²') },
{ name: 'Volt-ampere (VA)', id: 'voltamp', fn: decimalSIPrefix('VA') },
{ name: 'Kilovolt-ampere (kVA)', id: 'kvoltamp', fn: decimalSIPrefix('VA', 1) },
{ name: 'Volt-ampere reactive (var)', id: 'voltampreact', fn: decimalSIPrefix('var') },
{ name: 'Kilovolt-ampere reactive (kvar)', id: 'kvoltampreact', fn: decimalSIPrefix('var', 1) },
{ name: 'Watt-hour (Wh)', id: 'watth', fn: decimalSIPrefix('Wh') },
{ name: 'Watt-hour per Kilogram (Wh/kg)', id: 'watthperkg', fn: decimalSIPrefix('Wh/kg') },
{ name: 'Kilowatt-hour (kWh)', id: 'kwatth', fn: decimalSIPrefix('Wh', 1) },
{ name: 'Kilowatt-min (kWm)', id: 'kwattm', fn: decimalSIPrefix('W-Min', 1) },
{ name: 'Ampere-hour (Ah)', id: 'amph', fn: decimalSIPrefix('Ah') },
{ name: 'Kiloampere-hour (kAh)', id: 'kamph', fn: decimalSIPrefix('Ah', 1) },
{ name: 'Milliampere-hour (mAh)', id: 'mamph', fn: decimalSIPrefix('Ah', -1) },
{ name: 'Joule (J)', id: 'joule', fn: decimalSIPrefix('J') },
{ name: 'Electron volt (eV)', id: 'ev', fn: decimalSIPrefix('eV') },
{ name: 'Ampere (A)', id: 'amp', fn: decimalSIPrefix('A') },
{ name: 'Kiloampere (kA)', id: 'kamp', fn: decimalSIPrefix('A', 1) },
{ name: 'Milliampere (mA)', id: 'mamp', fn: decimalSIPrefix('A', -1) },
{ name: 'Volt (V)', id: 'volt', fn: decimalSIPrefix('V') },
{ name: 'Kilovolt (kV)', id: 'kvolt', fn: decimalSIPrefix('V', 1) },
{ name: 'Millivolt (mV)', id: 'mvolt', fn: decimalSIPrefix('V', -1) },
{ name: 'Decibel-milliwatt (dBm)', id: 'dBm', fn: decimalSIPrefix('dBm') },
{ name: 'Ohm (Ω)', id: 'ohm', fn: decimalSIPrefix('Ω') },
{ name: 'Kiloohm (kΩ)', id: 'kohm', fn: decimalSIPrefix('Ω', 1) },
{ name: 'Megaohm (MΩ)', id: 'Mohm', fn: decimalSIPrefix('Ω', 2) },
{ name: 'Farad (F)', id: 'farad', fn: decimalSIPrefix('F') },
{ name: 'Microfarad (µF)', id: 'µfarad', fn: decimalSIPrefix('F', -2) },
{ name: 'Nanofarad (nF)', id: 'nfarad', fn: decimalSIPrefix('F', -3) },
{ name: 'Picofarad (pF)', id: 'pfarad', fn: decimalSIPrefix('F', -4) },
{ name: 'Femtofarad (fF)', id: 'ffarad', fn: decimalSIPrefix('F', -5) },
{ name: 'Henry (H)', id: 'henry', fn: decimalSIPrefix('H') },
{ name: 'Millihenry (mH)', id: 'mhenry', fn: decimalSIPrefix('H', -1) },
{ name: 'Microhenry (µH)', id: 'µhenry', fn: decimalSIPrefix('H', -2) },
{ name: 'Lumens (Lm)', id: 'lumens', fn: decimalSIPrefix('Lm') },
{ name: 'Volt-ampere (VA)', id: 'voltamp', fn: SIPrefix('VA') },
{ name: 'Kilovolt-ampere (kVA)', id: 'kvoltamp', fn: SIPrefix('VA', 1) },
{ name: 'Volt-ampere reactive (var)', id: 'voltampreact', fn: SIPrefix('var') },
{ name: 'Kilovolt-ampere reactive (kvar)', id: 'kvoltampreact', fn: SIPrefix('var', 1) },
{ name: 'Watt-hour (Wh)', id: 'watth', fn: SIPrefix('Wh') },
{ name: 'Watt-hour per Kilogram (Wh/kg)', id: 'watthperkg', fn: SIPrefix('Wh/kg') },
{ name: 'Kilowatt-hour (kWh)', id: 'kwatth', fn: SIPrefix('Wh', 1) },
{ name: 'Kilowatt-min (kWm)', id: 'kwattm', fn: SIPrefix('W-Min', 1) },
{ name: 'Ampere-hour (Ah)', id: 'amph', fn: SIPrefix('Ah') },
{ name: 'Kiloampere-hour (kAh)', id: 'kamph', fn: SIPrefix('Ah', 1) },
{ name: 'Milliampere-hour (mAh)', id: 'mamph', fn: SIPrefix('Ah', -1) },
{ name: 'Joule (J)', id: 'joule', fn: SIPrefix('J') },
{ name: 'Electron volt (eV)', id: 'ev', fn: SIPrefix('eV') },
{ name: 'Ampere (A)', id: 'amp', fn: SIPrefix('A') },
{ name: 'Kiloampere (kA)', id: 'kamp', fn: SIPrefix('A', 1) },
{ name: 'Milliampere (mA)', id: 'mamp', fn: SIPrefix('A', -1) },
{ name: 'Volt (V)', id: 'volt', fn: SIPrefix('V') },
{ name: 'Kilovolt (kV)', id: 'kvolt', fn: SIPrefix('V', 1) },
{ name: 'Millivolt (mV)', id: 'mvolt', fn: SIPrefix('V', -1) },
{ name: 'Decibel-milliwatt (dBm)', id: 'dBm', fn: SIPrefix('dBm') },
{ name: 'Ohm (Ω)', id: 'ohm', fn: SIPrefix('Ω') },
{ name: 'Kiloohm (kΩ)', id: 'kohm', fn: SIPrefix('Ω', 1) },
{ name: 'Megaohm (MΩ)', id: 'Mohm', fn: SIPrefix('Ω', 2) },
{ name: 'Farad (F)', id: 'farad', fn: SIPrefix('F') },
{ name: 'Microfarad (µF)', id: 'µfarad', fn: SIPrefix('F', -2) },
{ name: 'Nanofarad (nF)', id: 'nfarad', fn: SIPrefix('F', -3) },
{ name: 'Picofarad (pF)', id: 'pfarad', fn: SIPrefix('F', -4) },
{ name: 'Femtofarad (fF)', id: 'ffarad', fn: SIPrefix('F', -5) },
{ name: 'Henry (H)', id: 'henry', fn: SIPrefix('H') },
{ name: 'Millihenry (mH)', id: 'mhenry', fn: SIPrefix('H', -1) },
{ name: 'Microhenry (µH)', id: 'µhenry', fn: SIPrefix('H', -2) },
{ name: 'Lumens (Lm)', id: 'lumens', fn: SIPrefix('Lm') },
],
},
{
@@ -239,50 +246,50 @@ export const getCategories = (): ValueFormatCategory[] => [
{
name: 'Force',
formats: [
{ name: 'Newton-meters (Nm)', id: 'forceNm', fn: decimalSIPrefix('Nm') },
{ name: 'Kilonewton-meters (kNm)', id: 'forcekNm', fn: decimalSIPrefix('Nm', 1) },
{ name: 'Newtons (N)', id: 'forceN', fn: decimalSIPrefix('N') },
{ name: 'Kilonewtons (kN)', id: 'forcekN', fn: decimalSIPrefix('N', 1) },
{ name: 'Newton-meters (Nm)', id: 'forceNm', fn: SIPrefix('Nm') },
{ name: 'Kilonewton-meters (kNm)', id: 'forcekNm', fn: SIPrefix('Nm', 1) },
{ name: 'Newtons (N)', id: 'forceN', fn: SIPrefix('N') },
{ name: 'Kilonewtons (kN)', id: 'forcekN', fn: SIPrefix('N', 1) },
],
},
{
name: 'Hash rate',
formats: [
{ name: 'hashes/sec', id: 'Hs', fn: decimalSIPrefix('H/s') },
{ name: 'kilohashes/sec', id: 'KHs', fn: decimalSIPrefix('H/s', 1) },
{ name: 'megahashes/sec', id: 'MHs', fn: decimalSIPrefix('H/s', 2) },
{ name: 'gigahashes/sec', id: 'GHs', fn: decimalSIPrefix('H/s', 3) },
{ name: 'terahashes/sec', id: 'THs', fn: decimalSIPrefix('H/s', 4) },
{ name: 'petahashes/sec', id: 'PHs', fn: decimalSIPrefix('H/s', 5) },
{ name: 'exahashes/sec', id: 'EHs', fn: decimalSIPrefix('H/s', 6) },
{ name: 'hashes/sec', id: 'Hs', fn: SIPrefix('H/s') },
{ name: 'kilohashes/sec', id: 'KHs', fn: SIPrefix('H/s', 1) },
{ name: 'megahashes/sec', id: 'MHs', fn: SIPrefix('H/s', 2) },
{ name: 'gigahashes/sec', id: 'GHs', fn: SIPrefix('H/s', 3) },
{ name: 'terahashes/sec', id: 'THs', fn: SIPrefix('H/s', 4) },
{ name: 'petahashes/sec', id: 'PHs', fn: SIPrefix('H/s', 5) },
{ name: 'exahashes/sec', id: 'EHs', fn: SIPrefix('H/s', 6) },
],
},
{
name: 'Mass',
formats: [
{ name: 'milligram (mg)', id: 'massmg', fn: decimalSIPrefix('g', -1) },
{ name: 'gram (g)', id: 'massg', fn: decimalSIPrefix('g') },
{ name: 'kilogram (kg)', id: 'masskg', fn: decimalSIPrefix('g', 1) },
{ name: 'milligram (mg)', id: 'massmg', fn: SIPrefix('g', -1) },
{ name: 'gram (g)', id: 'massg', fn: SIPrefix('g') },
{ name: 'kilogram (kg)', id: 'masskg', fn: SIPrefix('g', 1) },
{ name: 'metric ton (t)', id: 'masst', fn: toFixedUnit('t') },
],
},
{
name: 'Length',
formats: [
{ name: 'millimeter (mm)', id: 'lengthmm', fn: decimalSIPrefix('m', -1) },
{ name: 'millimeter (mm)', id: 'lengthmm', fn: SIPrefix('m', -1) },
{ name: 'feet (ft)', id: 'lengthft', fn: toFixedUnit('ft') },
{ name: 'meter (m)', id: 'lengthm', fn: decimalSIPrefix('m') },
{ name: 'kilometer (km)', id: 'lengthkm', fn: decimalSIPrefix('m', 1) },
{ name: 'meter (m)', id: 'lengthm', fn: SIPrefix('m') },
{ name: 'kilometer (km)', id: 'lengthkm', fn: SIPrefix('m', 1) },
{ name: 'mile (mi)', id: 'lengthmi', fn: toFixedUnit('mi') },
],
},
{
name: 'Pressure',
formats: [
{ name: 'Millibars', id: 'pressurembar', fn: decimalSIPrefix('bar', -1) },
{ name: 'Bars', id: 'pressurebar', fn: decimalSIPrefix('bar') },
{ name: 'Kilobars', id: 'pressurekbar', fn: decimalSIPrefix('bar', 1) },
{ name: 'Pascals', id: 'pressurepa', fn: decimalSIPrefix('Pa') },
{ name: 'Millibars', id: 'pressurembar', fn: SIPrefix('bar', -1) },
{ name: 'Bars', id: 'pressurebar', fn: SIPrefix('bar') },
{ name: 'Kilobars', id: 'pressurekbar', fn: SIPrefix('bar', 1) },
{ name: 'Pascals', id: 'pressurepa', fn: SIPrefix('Pa') },
{ name: 'Hectopascals', id: 'pressurehpa', fn: toFixedUnit('hPa') },
{ name: 'Kilopascals', id: 'pressurekpa', fn: toFixedUnit('kPa') },
{ name: 'Inches of mercury', id: 'pressurehg', fn: toFixedUnit('"Hg') },
@@ -292,26 +299,26 @@ export const getCategories = (): ValueFormatCategory[] => [
{
name: 'Radiation',
formats: [
{ name: 'Becquerel (Bq)', id: 'radbq', fn: decimalSIPrefix('Bq') },
{ name: 'curie (Ci)', id: 'radci', fn: decimalSIPrefix('Ci') },
{ 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('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') },
{ name: 'Sievert/hour (Sv/h)', id: 'radsvh', fn: decimalSIPrefix('Sv/h') },
{ name: 'milliSievert/hour (mSv/h)', id: 'radmsvh', fn: decimalSIPrefix('Sv/h', -1) },
{ name: 'microSievert/hour (µSv/h)', id: 'radusvh', fn: decimalSIPrefix('Sv/h', -2) },
{ name: 'Becquerel (Bq)', id: 'radbq', fn: SIPrefix('Bq') },
{ name: 'curie (Ci)', id: 'radci', fn: SIPrefix('Ci') },
{ name: 'Gray (Gy)', id: 'radgy', fn: SIPrefix('Gy') },
{ name: 'rad', id: 'radrad', fn: SIPrefix('rad') },
{ name: 'Sievert (Sv)', id: 'radsv', fn: SIPrefix('Sv') },
{ name: 'milliSievert (mSv)', id: 'radmsv', fn: SIPrefix('Sv', -1) },
{ name: 'microSievert (µSv)', id: 'radusv', fn: SIPrefix('Sv', -2) },
{ name: 'rem', id: 'radrem', fn: SIPrefix('rem') },
{ name: 'Exposure (C/kg)', id: 'radexpckg', fn: SIPrefix('C/kg') },
{ name: 'roentgen (R)', id: 'radr', fn: SIPrefix('R') },
{ name: 'Sievert/hour (Sv/h)', id: 'radsvh', fn: SIPrefix('Sv/h') },
{ name: 'milliSievert/hour (mSv/h)', id: 'radmsvh', fn: SIPrefix('Sv/h', -1) },
{ name: 'microSievert/hour (µSv/h)', id: 'radusvh', fn: SIPrefix('Sv/h', -2) },
],
},
{
name: 'Rotational Speed',
formats: [
{ name: 'Revolutions per minute (rpm)', id: 'rotrpm', fn: toFixedUnit('rpm') },
{ name: 'Hertz (Hz)', id: 'rothz', fn: decimalSIPrefix('Hz') },
{ name: 'Hertz (Hz)', id: 'rothz', fn: SIPrefix('Hz') },
{ name: 'Radians per second (rad/s)', id: 'rotrads', fn: toFixedUnit('rad/s') },
{ name: 'Degrees per second (°/s)', id: 'rotdegs', fn: toFixedUnit('°/s') },
],
@@ -327,7 +334,7 @@ export const getCategories = (): ValueFormatCategory[] => [
{
name: 'Time',
formats: [
{ name: 'Hertz (1/s)', id: 'hertz', fn: decimalSIPrefix('Hz') },
{ name: 'Hertz (1/s)', id: 'hertz', fn: SIPrefix('Hz') },
{ name: 'nanoseconds (ns)', id: 'ns', fn: toNanoSeconds },
{ name: 'microseconds (µs)', id: 'µs', fn: toMicroSeconds },
{ name: 'milliseconds (ms)', id: 'ms', fn: toMilliSeconds },
@@ -371,8 +378,8 @@ export const getCategories = (): ValueFormatCategory[] => [
{
name: 'Volume',
formats: [
{ name: 'millilitre (mL)', id: 'mlitre', fn: decimalSIPrefix('L', -1) },
{ name: 'litre (L)', id: 'litre', fn: decimalSIPrefix('L') },
{ name: 'millilitre (mL)', id: 'mlitre', fn: SIPrefix('L', -1) },
{ name: 'litre (L)', id: 'litre', fn: SIPrefix('L') },
{ name: 'cubic meter', id: 'm3', fn: toFixedUnit('m³') },
{ name: 'Normal cubic meter', id: 'Nm3', fn: toFixedUnit('Nm³') },
{ name: 'cubic decimeter', id: 'dm3', fn: toFixedUnit('dm³') },

View File

@@ -53,7 +53,7 @@ export function getOffsetFromSIPrefix(c: string): number {
return 0;
}
export function binarySIPrefix(unit: string, offset = 0): ValueFormatter {
export function binaryPrefix(unit: string, offset = 0): ValueFormatter {
const prefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'].slice(offset);
const units = prefixes.map(p => {
return ' ' + p + unit;
@@ -61,7 +61,7 @@ export function binarySIPrefix(unit: string, offset = 0): ValueFormatter {
return scaledUnits(1024, units);
}
export function decimalSIPrefix(unit: string, offset = 0): ValueFormatter {
export function SIPrefix(unit: string, offset = 0): ValueFormatter {
let prefixes = ['f', 'p', 'n', 'µ', 'm', '', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
prefixes = prefixes.slice(5 + (offset || 0));
const units = prefixes.map(p => {

View File

@@ -1,7 +1,7 @@
import { getCategories } from './categories';
import { DecimalCount } from '../types/displayValue';
import { toDateTimeValueFormatter } from './dateTimeFormatters';
import { getOffsetFromSIPrefix, decimalSIPrefix, currency } from './symbolFormatters';
import { getOffsetFromSIPrefix, SIPrefix, currency } from './symbolFormatters';
import { TimeZone } from '../types';
export interface FormattedValue {
@@ -213,7 +213,7 @@ export function getValueFormat(id?: string | null): ValueFormatter {
if (key === 'si') {
const offset = getOffsetFromSIPrefix(sub.charAt(0));
const unit = offset === 0 ? sub : sub.substring(1);
return decimalSIPrefix(unit, offset);
return SIPrefix(unit, offset);
}
if (key === 'count') {

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/e2e-selectors",
"version": "7.2.0-pre.0",
"version": "7.2.0-beta.2",
"description": "Grafana End-to-End Test Selectors Library",
"keywords": [
"cli",

View File

@@ -133,4 +133,7 @@ export const Pages = {
navBar: () => '.explore-toolbar',
},
},
SoloPanel: {
url: (page: string) => `/d-solo/${page}`,
},
};

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/e2e",
"version": "7.2.0-pre.0",
"version": "7.2.0-beta.2",
"description": "Grafana End-to-End Test Library",
"keywords": [
"cli",
@@ -44,7 +44,7 @@
"types": "src/index.ts",
"dependencies": {
"@cypress/webpack-preprocessor": "4.1.3",
"@grafana/e2e-selectors": "7.2.0-pre.0",
"@grafana/e2e-selectors": "7.2.0-beta.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.2.0-pre.0",
"version": "7.2.0-beta.2",
"description": "Grafana Runtime Library",
"keywords": [
"grafana",
@@ -22,8 +22,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@grafana/data": "7.2.0-pre.0",
"@grafana/ui": "7.2.0-pre.0",
"@grafana/data": "7.2.0-beta.2",
"@grafana/ui": "7.2.0-beta.2",
"systemjs": "0.20.19",
"systemjs-plugin-css": "0.1.37"
},

View File

@@ -56,7 +56,6 @@ export class GrafanaBootConfig implements GrafanaConfig {
meta: false,
datasourceInsights: false,
reportGrid: false,
standaloneAlerts: false,
};
licenseInfo: LicenseInfo = {} as LicenseInfo;
rendererAvailable = false;

View File

@@ -122,6 +122,8 @@ export class DataSourceWithBackend<
/**
* Override to skip executing a query
*
* @returns false if the query should be skipped
*
* @virtual
*/
filterQuery?(query: TQuery): boolean;

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/toolkit",
"version": "7.2.0-pre.0",
"version": "7.2.0-beta.2",
"description": "Grafana Toolkit",
"keywords": [
"grafana",

View File

@@ -37,6 +37,10 @@ const copyIfNonExistent = (srcPath: string, destPath: string) =>
export const prepare = useSpinner<void>('Preparing', async () => {
await Promise.all([
// Remove local dependencies for @grafana/data/node_modules
// See: https://github.com/grafana/grafana/issues/26748
rimraf(resolvePath(__dirname, 'node_modules/@grafana/data/node_modules')),
// Copy only if local tsconfig does not exist. Otherwise this will work, but have odd behavior
copyIfNonExistent(
resolvePath(__dirname, '../../config/tsconfig.plugin.local.json'),

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/ui",
"version": "7.2.0-pre.0",
"version": "7.2.0-beta.2",
"description": "Grafana Components Library",
"keywords": [
"grafana",
@@ -27,8 +27,8 @@
},
"dependencies": {
"@emotion/core": "^10.0.27",
"@grafana/data": "7.2.0-pre.0",
"@grafana/e2e-selectors": "7.2.0-pre.0",
"@grafana/data": "7.2.0-beta.2",
"@grafana/e2e-selectors": "7.2.0-beta.2",
"@grafana/slate-react": "0.22.9-grafana",
"@grafana/tsconfig": "^1.0.0-rc1",
"@iconscout/react-unicons": "1.1.4",

View File

@@ -1,11 +1,11 @@
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { Meta, Props } from '@storybook/addon-docs/blocks';
import { Cascader } from './Cascader';
<Meta title="MDX|Cascader" component={Cascader} />
# Cascader
The cascader component is a `Select` with a cascading flyout menu. When you have lots of options in your select, they can be hard to navigate from a regular dropdown list. In that case you can use the cascader to organize your options into groups hierarchically. Just like in the `Select` component, the cascader input doubles as a search field to quickly jump to a selection without navigating the list.
The cascader component is a `Select` with a cascading flyout menu. When you have lots of options in your select, they can be hard to navigate from a regular dropdown list. In that case you can use the cascader to organize your options into groups hierarchically. Just like in the `Select` component, the cascader input doubles as a search field to quickly jump to a selection without navigating the list.
You can either use the `Simple` cascader component for an empty input as default state or use the `initialValue` or `allowCustomValue` fields to pre-fill your cascader. Initial value means that one of the options from your cascaded list is pre-selected. Custom value means that apart from existing options from the list, your users can add custom values to the list by typing them in the `Select` input.

View File

@@ -1,5 +1,5 @@
import React, { FC, ReactNode, useState } from 'react';
import { css } from 'emotion';
import { css, cx } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { useStyles } from '../../themes';
import { Icon } from '..';
@@ -13,14 +13,20 @@ export interface Props {
export const CollapsableSection: FC<Props> = ({ label, isOpen, children }) => {
const [open, toggleOpen] = useState<boolean>(isOpen);
const styles = useStyles(collapsableSectionStyles);
const headerClass = cx({
[styles.header]: true,
[styles.headerCollapsed]: !open,
});
const tooltip = `Click to ${open ? 'collapse' : 'expand'}`;
return (
<div>
<div onClick={() => toggleOpen(!open)} className={styles.header}>
<Icon name={open ? 'angle-down' : 'angle-right'} size="xl" />
<div onClick={() => toggleOpen(!open)} className={headerClass} title={tooltip}>
{label}
<Icon name={open ? 'angle-down' : 'angle-right'} size="xl" className={styles.icon} />
</div>
<div className={styles.content}>{open && children}</div>
{open && <div className={styles.content}>{children}</div>}
</div>
);
};
@@ -28,11 +34,19 @@ export const CollapsableSection: FC<Props> = ({ label, isOpen, children }) => {
const collapsableSectionStyles = (theme: GrafanaTheme) => {
return {
header: css`
display: flex;
justify-content: space-between;
font-size: ${theme.typography.size.lg};
cursor: pointer;
`,
headerCollapsed: css`
border-bottom: 1px solid ${theme.colors.border2};
`,
icon: css`
color: ${theme.colors.textWeak};
`,
content: css`
padding: ${theme.spacing.md} 0 ${theme.spacing.md} ${theme.spacing.md};
padding: ${theme.spacing.md} 0;
`,
};
};

View File

@@ -1,11 +1,31 @@
import { Meta, Props } from '@storybook/addon-docs/blocks';
import { ConfirmButton } from './ConfirmButton';
<Meta title="MDX|ConfirmButton" component={ConfirmButton} />
# ConfirmButton
The ConfirmButton is an interactive component that adds a double-confirm option to a clickable action. When clicked, the action is replaced by an inline confirmation with the option to cancel. In Grafana, this is used for example for editing values in settings tables.
The ConfirmButton is an interactive component that adds a double-confirm option to a clickable action. When clicked, the action is replaced by an inline confirmation with the option to cancel. In Grafana, this is used, for example, for editing values in settings tables.
## Variants
There are four variants of the `ConfirmButton`: primary, secondary, destructive, and link. The primary and secondary variants include a primary or secondary `Button` component. The primary and secondary variant should be used to confirm actions like saving or adding data. The destructive variant includes a destructive `Button` component. The destructive variant should be used to double-confirm a deletion or removal of an element. The link variant doesn't include any button and double-confirms as links instead.
Apart from the button variant, you can also modify the button size and the button text.
## Usage
```jsx
<ConfirmButton
closeOnConfirm
size='md'
confirmText='Are you sure?'
confirmVariant='secondary'
onConfirm={() => {
console.log('Action confirmed!')
}}
>
Click me
</ConfirmButton>
```
<Props of={ConfirmButton} />

View File

@@ -5,6 +5,7 @@ import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { action } from '@storybook/addon-actions';
import { Button } from '../Button';
import { DeleteButton } from './DeleteButton';
import mdx from './ConfirmButton.mdx';
const getKnobs = () => {
return {
@@ -31,6 +32,11 @@ export default {
component: ConfirmButton,
decorators: [withCenteredStory],
subcomponents: { DeleteButton },
parameters: {
docs: {
page: mdx,
},
},
};
export const basic = () => {

View File

@@ -53,15 +53,24 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
});
export interface Props extends Themeable {
/** Confirm action callback */
onConfirm(): void;
/** Custom button styles */
className?: string;
/** Button size */
size?: ComponentSize;
/** Text for the Confirm button */
confirmText?: string;
/** Disable button click action */
disabled?: boolean;
/** Variant of the Confirm button */
confirmVariant?: ButtonVariant;
/** Hide confirm actions when after of them is clicked */
closeOnConfirm?: boolean;
onConfirm(): void;
/** Optional on click handler for the original button */
onClick?(): void;
/** Callback for the cancel action */
onCancel?(): void;
}
@@ -70,13 +79,6 @@ interface State {
}
class UnThemedConfirmButton extends PureComponent<Props, State> {
static defaultProps: Partial<Props> = {
size: 'md',
confirmText: 'Save',
disabled: false,
confirmVariant: 'primary',
};
state: State = {
showConfirm: false,
};
@@ -106,7 +108,7 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
this.props.onCancel();
}
};
onConfirm = (event: SyntheticEvent) => {
onConfirm = () => {
this.props.onConfirm();
if (this.props.closeOnConfirm) {
this.setState({
@@ -166,4 +168,13 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
}
export const ConfirmButton = withTheme(UnThemedConfirmButton);
// Declare defaultProps directly on the themed component so they are displayed
// in the props table
ConfirmButton.defaultProps = {
size: 'md',
confirmText: 'Save',
disabled: false,
confirmVariant: 'primary',
};
ConfirmButton.displayName = 'ConfirmButton';

View File

@@ -4,9 +4,12 @@ import { ComponentSize } from '../../types/size';
import { Button } from '../Button';
export interface Props {
size?: ComponentSize;
disabled?: boolean;
/** Confirm action callback */
onConfirm(): void;
/** Button size */
size?: ComponentSize;
/** Disable button click action */
disabled?: boolean;
}
export const DeleteButton: FC<Props> = ({ size, disabled, onConfirm }) => {

View File

@@ -7,4 +7,18 @@ import { ConfirmModal } from './ConfirmModal';
Used to request user for action confirmation, e.g. deleting items. Triggers provided 'onConfirm' callback.
# Usage
```jsx
<ConfirmModal
isOpen={false}
title='Delete user'
body='Are you sure you want to delete this user?'
confirmText='Confirm'
icon='exclamation-triangle'
onConfirm={() => console.log('Confirm action')}
onDismiss={() => console.log('Dismiss action')}
/>
```
<Props of={ConfirmModal} />

View File

@@ -8,13 +8,21 @@ import { GrafanaTheme } from '@grafana/data';
import { HorizontalGroup } from '..';
export interface Props {
/** Toggle modal's open/closed state */
isOpen: boolean;
/** Title for the modal header */
title: string;
/** Modal content */
body: React.ReactNode;
/** Text for confirm button */
confirmText: string;
/** Text for dismiss button */
dismissText?: string;
/** Icon for the modal header */
icon?: IconName;
/** Confirm action callback */
onConfirm(): void;
/** Dismiss action callback */
onDismiss(): void;
}

View File

@@ -21,7 +21,7 @@ To add more context to the input you can add either text or an icon before or af
## Usage in forms with Field
`Input` should be used with the `Field` component to get labels and descriptions. It should also be used for validation. See the `Field` component for more information.
`Input` should be used with the `Field` component to get labels and descriptions. It can also be used for validation by using the `required` attribute. See the `Field` component for more information.
```jsx
<Field label="Important information" description="This information is very important, so you really need to fill it in">

View File

@@ -1 +1,5 @@
export { Controller as InputControl } from 'react-hook-form';
/**
* Rollup does not support renamed exports so do not change this to export { Controller as InputControl } ...
*/
import { Controller } from 'react-hook-form';
export const InputControl = Controller;

View File

@@ -20,7 +20,7 @@ export const fieldNameByRegexMatcherItem: FieldMatcherUIRegistryItem<string> = {
id: FieldMatcherID.byRegexp,
component: FieldNameByRegexMatcherEditor,
matcher: fieldMatchers.get(FieldMatcherID.byRegexp),
name: 'Filter by field using regex',
description: 'Set properties for fields with names matching provided regex',
name: 'Fields with name matching regex',
description: 'Set properties for fields with names matching a regex',
optionsToLabel: options => options,
};

View File

@@ -26,8 +26,8 @@ export const fieldNameMatcherItem: FieldMatcherUIRegistryItem<string> = {
id: FieldMatcherID.byName,
component: FieldNameMatcherEditor,
matcher: fieldMatchers.get(FieldMatcherID.byName),
name: 'Filter by field',
description: 'Set properties for fields matching the name',
name: 'Fields with name',
description: 'Set properties for a specific field',
optionsToLabel: options => options,
};

View File

@@ -79,7 +79,7 @@ export const fieldTypeMatcherItem: FieldMatcherUIRegistryItem<string> = {
id: FieldMatcherID.byType,
component: FieldTypeMatcherEditor,
matcher: fieldMatchers.get(FieldMatcherID.byType),
name: 'Filter by type',
description: 'Set properties for fields matching a type',
name: 'Fields with type',
description: 'Set properties for fields of a specific type (number, string, boolean)',
optionsToLabel: options => options,
};

View File

@@ -1,6 +1,6 @@
{
"name": "@jaegertracing/jaeger-ui-components",
"version": "7.2.0-pre.0",
"version": "7.2.0-beta.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.2.0-pre.0",
"@grafana/ui": "7.2.0-pre.0",
"@grafana/data": "7.2.0-beta.2",
"@grafana/ui": "7.2.0-beta.2",
"@types/classnames": "^2.2.7",
"@types/deep-freeze": "^0.1.1",
"@types/hoist-non-react-statics": "^3.3.1",

View File

@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/util"
)
func ValidateOrgAlert(c *models.ReqContext) {
@@ -287,13 +288,12 @@ func UpdateAlertNotification(c *models.ReqContext, cmd models.UpdateAlertNotific
}
if err := bus.Dispatch(&cmd); err != nil {
if err == models.ErrAlertNotificationNotFound {
return Error(404, err.Error(), err)
}
return Error(500, "Failed to update alert notification", err)
}
if cmd.Result == nil {
return Error(404, "Alert notification not found", nil)
}
query := models.GetAlertNotificationsQuery{
OrgId: c.OrgId,
Id: cmd.Id,
@@ -316,13 +316,12 @@ func UpdateAlertNotificationByUID(c *models.ReqContext, cmd models.UpdateAlertNo
}
if err := bus.Dispatch(&cmd); err != nil {
if err == models.ErrAlertNotificationNotFound {
return Error(404, err.Error(), nil)
}
return Error(500, "Failed to update alert notification", err)
}
if cmd.Result == nil {
return Error(404, "Alert notification not found", nil)
}
query := models.GetAlertNotificationsWithUidQuery{
OrgId: cmd.OrgId,
Uid: cmd.Uid,
@@ -390,6 +389,9 @@ func DeleteAlertNotification(c *models.ReqContext) Response {
}
if err := bus.Dispatch(&cmd); err != nil {
if err == models.ErrAlertNotificationNotFound {
return Error(404, err.Error(), nil)
}
return Error(500, "Failed to delete alert notification", err)
}
@@ -403,10 +405,16 @@ func DeleteAlertNotificationByUID(c *models.ReqContext) Response {
}
if err := bus.Dispatch(&cmd); err != nil {
if err == models.ErrAlertNotificationNotFound {
return Error(404, err.Error(), nil)
}
return Error(500, "Failed to delete alert notification", err)
}
return Success("Notification deleted")
return JSON(200, util.DynMap{
"message": "Notification deleted",
"id": cmd.DeletedAlertNotificationId,
})
}
//POST /api/alert-notifications/test

View File

@@ -327,10 +327,6 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
Children: []*dtos.NavLink{},
})
sort.SliceStable(navTree, func(i, j int) bool {
return navTree[i].SortWeight < navTree[j].SortWeight
})
return navTree, nil
}
@@ -434,6 +430,10 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat
hs.HooksService.RunIndexDataHooks(&data, c)
sort.SliceStable(data.NavTree, func(i, j int) bool {
return data.NavTree[i].SortWeight < data.NavTree[j].SortWeight
})
return &data, nil
}

View File

@@ -249,7 +249,7 @@ func (hs *HTTPServer) loginUserWithUser(user *models.User, c *models.ReqContext)
}
hs.log.Info("Successful Login", "User", user.Email)
middleware.WriteSessionCookie(c, userToken.UnhashedToken, hs.Cfg.LoginMaxLifetimeDays)
middleware.WriteSessionCookie(c, userToken.UnhashedToken, hs.Cfg.LoginMaxLifetime)
return nil
}

View File

@@ -123,7 +123,10 @@ func inviteExistingUserToOrg(c *models.ReqContext, user *models.User, inviteDto
}
}
return Success(fmt.Sprintf("Existing Grafana user %s added to org %s", user.NameOrFallback(), c.OrgName))
return JSON(200, util.DynMap{
"message": fmt.Sprintf("Existing Grafana user %s added to org %s", user.NameOrFallback(), c.OrgName),
"userId": user.Id,
})
}
func RevokeInvite(c *models.ReqContext) Response {

View File

@@ -4,6 +4,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util"
)
// POST /api/org/users
@@ -35,12 +36,18 @@ func addOrgUserHelper(cmd models.AddOrgUserCommand) Response {
if err := bus.Dispatch(&cmd); err != nil {
if err == models.ErrOrgUserAlreadyAdded {
return Error(409, "User is already member of this organization", nil)
return JSON(409, util.DynMap{
"message": "User is already member of this organization",
"userId": cmd.UserId,
})
}
return Error(500, "Could not add user to organization", err)
}
return Success("User added to organization")
return JSON(200, util.DynMap{
"message": "User added to organization",
"userId": cmd.UserId,
})
}
// GET /api/org/users

View File

@@ -261,22 +261,21 @@ func rotateEndOfRequestFunc(ctx *models.ReqContext, authTokenService models.User
}
if rotated {
WriteSessionCookie(ctx, token.UnhashedToken, setting.LoginMaxLifetimeDays)
WriteSessionCookie(ctx, token.UnhashedToken, setting.LoginMaxLifetime)
}
}
}
func WriteSessionCookie(ctx *models.ReqContext, value string, maxLifetimeDays int) {
func WriteSessionCookie(ctx *models.ReqContext, value string, maxLifetime time.Duration) {
if setting.Env == setting.DEV {
ctx.Logger.Info("New token", "unhashed token", value)
}
var maxAge int
if maxLifetimeDays <= 0 {
if maxLifetime <= 0 {
maxAge = -1
} else {
maxAgeHours := (time.Duration(setting.LoginMaxLifetimeDays) * 24 * time.Hour) + time.Hour
maxAge = int(maxAgeHours.Seconds())
maxAge = int(maxLifetime.Seconds())
}
WriteCookie(ctx.Resp, setting.LoginCookieName, url.QueryEscape(value), maxAge, newCookieOptions)

View File

@@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/gtime"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
authproxy "github.com/grafana/grafana/pkg/middleware/auth_proxy"
@@ -253,8 +254,7 @@ func TestMiddlewareContext(t *testing.T) {
return true, nil
}
maxAgeHours := (time.Duration(setting.LoginMaxLifetimeDays) * 24 * time.Hour)
maxAge := (maxAgeHours + time.Hour).Seconds()
maxAge := int(setting.LoginMaxLifetime.Seconds())
sameSitePolicies := []http.SameSite{
http.SameSiteNoneMode,
@@ -272,7 +272,7 @@ func TestMiddlewareContext(t *testing.T) {
Value: "rotated",
Path: expectedCookiePath,
HttpOnly: true,
MaxAge: int(maxAge),
MaxAge: maxAge,
Secure: setting.CookieSecure,
SameSite: sameSitePolicy,
}
@@ -303,7 +303,7 @@ func TestMiddlewareContext(t *testing.T) {
Value: "rotated",
Path: expectedCookiePath,
HttpOnly: true,
MaxAge: int(maxAge),
MaxAge: maxAge,
Secure: setting.CookieSecure,
}
@@ -546,7 +546,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc) {
defer bus.ClearBusHandlers()
setting.LoginCookieName = "grafana_session"
setting.LoginMaxLifetimeDays = 30
setting.LoginMaxLifetime, _ = gtime.ParseInterval("30d")
sc := &scenarioContext{}
@@ -637,7 +637,7 @@ func TestTokenRotationAtEndOfRequest(t *testing.T) {
func initTokenRotationTest(ctx context.Context) (*models.ReqContext, *httptest.ResponseRecorder, error) {
setting.LoginCookieName = "login_token"
setting.LoginMaxLifetimeDays = 7
setting.LoginMaxLifetime, _ = gtime.ParseInterval("7d")
rr := httptest.NewRecorder()
req, err := http.NewRequestWithContext(ctx, "", "", nil)

View File

@@ -9,6 +9,7 @@ import (
)
var (
ErrAlertNotificationNotFound = errors.New("Alert notification not found")
ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
ErrAlertNotificationStateNotFound = errors.New("alert notification state not found")
ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict")
@@ -94,6 +95,8 @@ type DeleteAlertNotificationCommand struct {
type DeleteAlertNotificationWithUidCommand struct {
Uid string
OrgId int64
DeletedAlertNotificationId int64
}
type GetAlertNotificationUidQuery struct {

View File

@@ -384,6 +384,10 @@ type ValidateDashboardBeforeSaveCommand struct {
Result *ValidateDashboardBeforeSaveResult
}
type DeleteOrphanedProvisionedDashboardsCommand struct {
ReaderNames []string
}
//
// QUERIES
//

View File

@@ -84,9 +84,9 @@ func instrumentRoundtrip(datasourceName string, next http.RoundTripper) promhttp
promhttp.InstrumentRoundTripperInFlight(requestInFlight, next))).
RoundTrip(r)
// we avoid measing contentlength less the zero because it indicates
// we avoid measuring contentlength less than zero because it indicates
// that the content size is unknown. https://godoc.org/github.com/badu/http#Response
if res.ContentLength > 0 {
if res != nil && res.ContentLength > 0 {
responseSizeSummary.Observe(float64(res.ContentLength))
}

View File

@@ -59,6 +59,7 @@ func (a *queryEndpointAdapter) Query(ctx context.Context, ds *models.DataSource,
DataSourceInstanceSettings: instanceSettings,
},
Queries: []backend.DataQuery{},
Headers: query.Headers,
}
for _, q := range query.Queries {

View File

@@ -59,6 +59,7 @@ func (tw *DatasourcePluginWrapperV2) Query(ctx context.Context, ds *models.DataS
DataSourceInstanceSettings: backend.ToProto().DataSourceInstanceSettings(instanceSettings),
},
Queries: []*pluginv2.DataQuery{},
Headers: query.Headers,
}
for _, q := range query.Queries {

View File

@@ -28,6 +28,7 @@ func init() {
Placeholder: "Pagerduty Integration Key",
PropertyName: "integrationKey",
Required: true,
Secure: true,
},
{
Label: "Severity",

View File

@@ -397,13 +397,11 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64)
}
func (s *UserAuthTokenService) createdAfterParam() int64 {
tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
return getTime().Add(-tokenMaxLifetime).Unix()
return getTime().Add(-s.Cfg.LoginMaxLifetime).Unix()
}
func (s *UserAuthTokenService) rotatedAfterParam() int64 {
tokenMaxInactiveLifetime := time.Duration(s.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour
return getTime().Add(-tokenMaxInactiveLifetime).Unix()
return getTime().Add(-s.Cfg.LoginMaxInactiveLifetime).Unix()
}
func hashToken(token string) string {

View File

@@ -494,13 +494,14 @@ func TestUserAuthToken(t *testing.T) {
func createTestContext(t *testing.T) *testContext {
t.Helper()
maxInactiveDurationVal, _ := time.ParseDuration("168h")
maxLifetimeDurationVal, _ := time.ParseDuration("720h")
sqlstore := sqlstore.InitTestDB(t)
tokenService := &UserAuthTokenService{
SQLStore: sqlstore,
Cfg: &setting.Cfg{
LoginMaxInactiveLifetimeDays: 7,
LoginMaxLifetimeDays: 30,
LoginMaxInactiveLifetime: maxInactiveDurationVal,
LoginMaxLifetime: maxLifetimeDurationVal,
TokenRotationIntervalMinutes: 10,
},
log: log.New("test-logger"),

View File

@@ -8,11 +8,12 @@ import (
)
func (srv *UserAuthTokenService) Run(ctx context.Context) error {
var err error
ticker := time.NewTicker(time.Hour)
maxInactiveLifetime := time.Duration(srv.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour
maxLifetime := time.Duration(srv.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
maxInactiveLifetime := srv.Cfg.LoginMaxInactiveLifetime
maxLifetime := srv.Cfg.LoginMaxLifetime
err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
err = srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
if _, err := srv.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil {
srv.log.Error("An error occurred while deleting expired tokens", "err", err)
}
@@ -24,7 +25,7 @@ func (srv *UserAuthTokenService) Run(ctx context.Context) error {
for {
select {
case <-ticker.C:
err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
err = srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
if _, err := srv.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil {
srv.log.Error("An error occurred while deleting expired tokens", "err", err)
}

View File

@@ -12,8 +12,10 @@ import (
func TestUserAuthTokenCleanup(t *testing.T) {
Convey("Test user auth token cleanup", t, func() {
ctx := createTestContext(t)
ctx.tokenService.Cfg.LoginMaxInactiveLifetimeDays = 7
ctx.tokenService.Cfg.LoginMaxLifetimeDays = 30
maxInactiveLifetime, _ := time.ParseDuration("168h")
maxLifetime, _ := time.ParseDuration("720h")
ctx.tokenService.Cfg.LoginMaxInactiveLifetime = maxInactiveLifetime
ctx.tokenService.Cfg.LoginMaxLifetime = maxLifetime
insertToken := func(token string, prev string, createdAt, rotatedAt int64) {
ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, CreatedAt: createdAt, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""}
@@ -27,7 +29,7 @@ func TestUserAuthTokenCleanup(t *testing.T) {
}
Convey("should delete tokens where token rotation age is older than or equal 7 days", func() {
from := t.Add(-7 * 24 * time.Hour)
from := t.Add(-168 * time.Hour)
// insert three old tokens that should be deleted
for i := 0; i < 3; i++ {
@@ -40,7 +42,7 @@ func TestUserAuthTokenCleanup(t *testing.T) {
insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), from.Unix(), from.Unix())
}
affected, err := ctx.tokenService.deleteExpiredTokens(context.Background(), 7*24*time.Hour, 30*24*time.Hour)
affected, err := ctx.tokenService.deleteExpiredTokens(context.Background(), 168*time.Hour, 30*24*time.Hour)
So(err, ShouldBeNil)
So(affected, ShouldEqual, 3)
})

View File

@@ -5,7 +5,9 @@ import (
"fmt"
"os"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util/errutil"
)
@@ -16,6 +18,7 @@ type DashboardProvisioner interface {
PollChanges(ctx context.Context)
GetProvisionerResolvedPath(name string) string
GetAllowUIUpdatesFromConfig(name string) bool
CleanUpOrphanedDashboards()
}
// DashboardProvisionerFactory creates DashboardProvisioners based on input
@@ -71,6 +74,19 @@ func (provider *Provisioner) Provision() error {
return nil
}
// CleanUpOrphanedDashboards deletes provisioned dashboards missing a linked reader.
func (provider *Provisioner) CleanUpOrphanedDashboards() {
currentReaders := make([]string, len(provider.fileReaders))
for index, reader := range provider.fileReaders {
currentReaders[index] = reader.Cfg.Name
}
if err := bus.Dispatch(&models.DeleteOrphanedProvisionedDashboardsCommand{ReaderNames: currentReaders}); err != nil {
provider.log.Warn("Failed to delete orphaned provisioned dashboards", "err", err)
}
}
// PollChanges starts polling for changes in dashboard definition files. It creates goroutine for each provider
// defined in the config.
func (provider *Provisioner) PollChanges(ctx context.Context) {

View File

@@ -60,3 +60,6 @@ func (dpm *ProvisionerMock) GetAllowUIUpdatesFromConfig(name string) bool {
}
return false
}
// CleanUpOrphanedDashboards not implemented for mocks
func (dpm *ProvisionerMock) CleanUpOrphanedDashboards() {}

View File

@@ -143,9 +143,11 @@ func (ps *provisioningServiceImpl) ProvisionDashboards() error {
defer ps.mutex.Unlock()
ps.cancelPolling()
dashProvisioner.CleanUpOrphanedDashboards()
if err := dashProvisioner.Provision(); err != nil {
// If we fail to provision with the new provisioner, mutex will unlock and the polling we restart with the
err = dashProvisioner.Provision()
if err != nil {
// If we fail to provision with the new provisioner, the mutex will unlock and the polling will restart with the
// old provisioner as we did not switch them yet.
return errutil.Wrap("Failed to provision dashboards", err)
}

View File

@@ -33,9 +33,18 @@ func init() {
func DeleteAlertNotification(cmd *models.DeleteAlertNotificationCommand) error {
return inTransaction(func(sess *DBSession) error {
sql := "DELETE FROM alert_notification WHERE alert_notification.org_id = ? AND alert_notification.id = ?"
if _, err := sess.Exec(sql, cmd.OrgId, cmd.Id); err != nil {
res, err := sess.Exec(sql, cmd.OrgId, cmd.Id)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return models.ErrAlertNotificationNotFound
}
if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_notification_state.org_id = ? AND alert_notification_state.notifier_id = ?", cmd.OrgId, cmd.Id); err != nil {
return err
@@ -51,14 +60,17 @@ func DeleteAlertNotificationWithUid(cmd *models.DeleteAlertNotificationWithUidCo
return err
}
if existingNotification.Result != nil {
deleteCommand := &models.DeleteAlertNotificationCommand{
Id: existingNotification.Result.Id,
OrgId: existingNotification.Result.OrgId,
}
if err := bus.Dispatch(deleteCommand); err != nil {
return err
}
if existingNotification.Result == nil {
return models.ErrAlertNotificationNotFound
}
cmd.DeletedAlertNotificationId = existingNotification.Result.Id
deleteCommand := &models.DeleteAlertNotificationCommand{
Id: existingNotification.Result.Id,
OrgId: existingNotification.Result.OrgId,
}
if err := bus.Dispatch(deleteCommand); err != nil {
return err
}
return nil
@@ -367,6 +379,10 @@ func UpdateAlertNotification(cmd *models.UpdateAlertNotificationCommand) error {
return err
}
if current.Id == 0 {
return models.ErrAlertNotificationNotFound
}
// check if name exists
sameNameQuery := &models.GetAlertNotificationsQuery{OrgId: cmd.OrgId, Name: cmd.Name}
if err := getAlertNotificationInternal(sameNameQuery, sess); err != nil {
@@ -433,7 +449,7 @@ func UpdateAlertNotificationWithUid(cmd *models.UpdateAlertNotificationWithUidCo
current := getAlertNotificationWithUidQuery.Result
if current == nil {
return fmt.Errorf("Cannot update, alert notification uid %s doesn't exist", cmd.Uid)
return models.ErrAlertNotificationNotFound
}
if cmd.NewUid == "" {

View File

@@ -390,5 +390,87 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
So(result, ShouldBeNil)
})
})
Convey("Cannot update non-existing Alert Notification", func() {
updateCmd := &models.UpdateAlertNotificationCommand{
Name: "NewName",
Type: "webhook",
OrgId: 1,
SendReminder: true,
DisableResolveMessage: true,
Frequency: "60s",
Settings: simplejson.New(),
Id: 1,
}
err := UpdateAlertNotification(updateCmd)
So(err, ShouldEqual, models.ErrAlertNotificationNotFound)
Convey("using UID", func() {
updateWithUidCmd := &models.UpdateAlertNotificationWithUidCommand{
Name: "NewName",
Type: "webhook",
OrgId: 1,
SendReminder: true,
DisableResolveMessage: true,
Frequency: "60s",
Settings: simplejson.New(),
Uid: "uid",
NewUid: "newUid",
}
err := UpdateAlertNotificationWithUid(updateWithUidCmd)
So(err, ShouldEqual, models.ErrAlertNotificationNotFound)
})
})
Convey("Can delete Alert Notification", func() {
cmd := &models.CreateAlertNotificationCommand{
Name: "ops update",
Type: "email",
OrgId: 1,
SendReminder: false,
Settings: simplejson.New(),
}
err := CreateAlertNotificationCommand(cmd)
So(err, ShouldBeNil)
deleteCmd := &models.DeleteAlertNotificationCommand{
Id: cmd.Result.Id,
OrgId: 1,
}
err = DeleteAlertNotification(deleteCmd)
So(err, ShouldBeNil)
Convey("using UID", func() {
err := CreateAlertNotificationCommand(cmd)
So(err, ShouldBeNil)
deleteWithUidCmd := &models.DeleteAlertNotificationWithUidCommand{
Uid: cmd.Result.Uid,
OrgId: 1,
}
err = DeleteAlertNotificationWithUid(deleteWithUidCmd)
So(err, ShouldBeNil)
So(deleteWithUidCmd.DeletedAlertNotificationId, ShouldEqual, cmd.Result.Id)
})
})
Convey("Cannot delete non-existing Alert Notification", func() {
deleteCmd := &models.DeleteAlertNotificationCommand{
Id: 1,
OrgId: 1,
}
err := DeleteAlertNotification(deleteCmd)
So(err, ShouldEqual, models.ErrAlertNotificationNotFound)
Convey("using UID", func() {
deleteWithUidCmd := &models.DeleteAlertNotificationWithUidCommand{
Uid: "uid",
OrgId: 1,
}
err = DeleteAlertNotificationWithUid(deleteWithUidCmd)
So(err, ShouldEqual, models.ErrAlertNotificationNotFound)
})
})
})
}

View File

@@ -353,57 +353,61 @@ func GetDashboardTags(query *models.GetDashboardTagsQuery) error {
func DeleteDashboard(cmd *models.DeleteDashboardCommand) error {
return inTransaction(func(sess *DBSession) error {
dashboard := models.Dashboard{Id: cmd.Id, OrgId: cmd.OrgId}
has, err := sess.Get(&dashboard)
return deleteDashboard(cmd, sess)
})
}
func deleteDashboard(cmd *models.DeleteDashboardCommand, sess *DBSession) error {
dashboard := models.Dashboard{Id: cmd.Id, OrgId: cmd.OrgId}
has, err := sess.Get(&dashboard)
if err != nil {
return err
} else if !has {
return models.ErrDashboardNotFound
}
deletes := []string{
"DELETE FROM dashboard_tag WHERE dashboard_id = ? ",
"DELETE FROM star WHERE dashboard_id = ? ",
"DELETE FROM dashboard WHERE id = ?",
"DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?",
"DELETE FROM dashboard_version WHERE dashboard_id = ?",
"DELETE FROM annotation WHERE dashboard_id = ?",
"DELETE FROM dashboard_provisioning WHERE dashboard_id = ?",
}
if dashboard.IsFolder {
deletes = append(deletes, "DELETE FROM dashboard_provisioning WHERE dashboard_id in (select id from dashboard where folder_id = ?)")
deletes = append(deletes, "DELETE FROM dashboard WHERE folder_id = ?")
dashIds := []struct {
Id int64
}{}
err := sess.SQL("select id from dashboard where folder_id = ?", dashboard.Id).Find(&dashIds)
if err != nil {
return err
} else if !has {
return models.ErrDashboardNotFound
}
deletes := []string{
"DELETE FROM dashboard_tag WHERE dashboard_id = ? ",
"DELETE FROM star WHERE dashboard_id = ? ",
"DELETE FROM dashboard WHERE id = ?",
"DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?",
"DELETE FROM dashboard_version WHERE dashboard_id = ?",
"DELETE FROM annotation WHERE dashboard_id = ?",
"DELETE FROM dashboard_provisioning WHERE dashboard_id = ?",
}
if dashboard.IsFolder {
deletes = append(deletes, "DELETE FROM dashboard_provisioning WHERE dashboard_id in (select id from dashboard where folder_id = ?)")
deletes = append(deletes, "DELETE FROM dashboard WHERE folder_id = ?")
dashIds := []struct {
Id int64
}{}
err := sess.SQL("select id from dashboard where folder_id = ?", dashboard.Id).Find(&dashIds)
if err != nil {
for _, id := range dashIds {
if err := deleteAlertDefinition(id.Id, sess); err != nil {
return err
}
for _, id := range dashIds {
if err := deleteAlertDefinition(id.Id, sess); err != nil {
return err
}
}
}
}
if err := deleteAlertDefinition(dashboard.Id, sess); err != nil {
if err := deleteAlertDefinition(dashboard.Id, sess); err != nil {
return err
}
for _, sql := range deletes {
_, err := sess.Exec(sql, dashboard.Id)
if err != nil {
return err
}
}
for _, sql := range deletes {
_, err := sess.Exec(sql, dashboard.Id)
if err != nil {
return err
}
}
return nil
})
return nil
}
func GetDashboards(query *models.GetDashboardsQuery) error {

View File

@@ -1,6 +1,8 @@
package sqlstore
import (
"errors"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
)
@@ -10,6 +12,7 @@ func init() {
bus.AddHandler("sql", SaveProvisionedDashboard)
bus.AddHandler("sql", GetProvisionedDataByDashboardId)
bus.AddHandler("sql", UnprovisionDashboard)
bus.AddHandler("sql", DeleteOrphanedProvisionedDashboards)
}
type DashboardExtras struct {
@@ -88,3 +91,26 @@ func UnprovisionDashboard(cmd *models.UnprovisionDashboardCommand) error {
}
return nil
}
func DeleteOrphanedProvisionedDashboards(cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error {
var result []*models.DashboardProvisioning
convertedReaderNames := make([]interface{}, len(cmd.ReaderNames))
for index, readerName := range cmd.ReaderNames {
convertedReaderNames[index] = readerName
}
err := x.NotIn("name", convertedReaderNames...).Find(&result)
if err != nil {
return err
}
for _, deleteDashCommand := range result {
err := DeleteDashboard(&models.DeleteDashboardCommand{Id: deleteDashCommand.DashboardId})
if err != nil && !errors.Is(err, models.ErrDashboardNotFound) {
return err
}
}
return nil
}

View File

@@ -54,6 +54,43 @@ func TestDashboardProvisioningTest(t *testing.T) {
So(cmd.Result.Id, ShouldNotEqual, 0)
dashId := cmd.Result.Id
Convey("Deleting orphaned provisioned dashboards", func() {
anotherCmd := &models.SaveProvisionedDashboardCommand{
DashboardCmd: &models.SaveDashboardCommand{
OrgId: 1,
IsFolder: false,
FolderId: folderCmd.Result.Id,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": nil,
"title": "another_dashboard",
}),
},
DashboardProvisioning: &models.DashboardProvisioning{
Name: "another_reader",
ExternalId: "/var/grafana.json",
Updated: now.Unix(),
},
}
err := SaveProvisionedDashboard(anotherCmd)
So(err, ShouldBeNil)
query := &models.GetDashboardsQuery{DashboardIds: []int64{anotherCmd.Result.Id}}
err = GetDashboards(query)
So(err, ShouldBeNil)
So(query.Result, ShouldNotBeNil)
deleteCmd := &models.DeleteOrphanedProvisionedDashboardsCommand{ReaderNames: []string{"default"}}
So(DeleteOrphanedProvisionedDashboards(deleteCmd), ShouldBeNil)
query = &models.GetDashboardsQuery{DashboardIds: []int64{cmd.Result.Id, anotherCmd.Result.Id}}
err = GetDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Id, ShouldEqual, dashId)
})
Convey("Can query for provisioned dashboards", func() {
query := &models.GetProvisionedDashboardDataQuery{Name: "default"}
err := GetProvisionedDashboardDataQuery(query)

View File

@@ -10,13 +10,13 @@ import (
"github.com/grafana/grafana/pkg/setting"
)
func init() {
func (ss *SqlStore) addPreferencesQueryAndCommandHandlers() {
bus.AddHandler("sql", GetPreferences)
bus.AddHandler("sql", GetPreferencesWithDefaults)
bus.AddHandler("sql", ss.GetPreferencesWithDefaults)
bus.AddHandler("sql", SavePreferences)
}
func GetPreferencesWithDefaults(query *models.GetPreferencesWithDefaultsQuery) error {
func (ss *SqlStore) GetPreferencesWithDefaults(query *models.GetPreferencesWithDefaultsQuery) error {
params := make([]interface{}, 0)
filter := ""
@@ -43,7 +43,7 @@ func GetPreferencesWithDefaults(query *models.GetPreferencesWithDefaultsQuery) e
res := &models.Preferences{
Theme: setting.DefaultTheme,
Timezone: "browser",
Timezone: ss.Cfg.DateFormats.DefaultTimezone,
HomeDashboardId: 0,
}

View File

@@ -11,14 +11,17 @@ import (
func TestPreferencesDataAccess(t *testing.T) {
Convey("Testing preferences data access", t, func() {
InitTestDB(t)
ss := InitTestDB(t)
Convey("GetPreferencesWithDefaults with no saved preferences should return defaults", func() {
setting.DefaultTheme = "light"
ss.Cfg.DateFormats.DefaultTimezone = "UTC"
query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{}}
err := GetPreferencesWithDefaults(query)
err := ss.GetPreferencesWithDefaults(query)
So(err, ShouldBeNil)
So(query.Result.Theme, ShouldEqual, setting.DefaultTheme)
So(query.Result.Timezone, ShouldEqual, "browser")
So(query.Result.Theme, ShouldEqual, "light")
So(query.Result.Timezone, ShouldEqual, "UTC")
So(query.Result.HomeDashboardId, ShouldEqual, 0)
})
@@ -29,7 +32,7 @@ func TestPreferencesDataAccess(t *testing.T) {
So(err, ShouldBeNil)
query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1, UserId: 1}}
err = GetPreferencesWithDefaults(query)
err = ss.GetPreferencesWithDefaults(query)
So(err, ShouldBeNil)
So(query.Result.HomeDashboardId, ShouldEqual, 4)
})
@@ -41,7 +44,7 @@ func TestPreferencesDataAccess(t *testing.T) {
So(err, ShouldBeNil)
query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1, UserId: 2}}
err = GetPreferencesWithDefaults(query)
err = ss.GetPreferencesWithDefaults(query)
So(err, ShouldBeNil)
So(query.Result.HomeDashboardId, ShouldEqual, 1)
})
@@ -57,7 +60,7 @@ func TestPreferencesDataAccess(t *testing.T) {
query := &models.GetPreferencesWithDefaultsQuery{
User: &models.SignedInUser{OrgId: 1, Teams: []int64{2, 3}},
}
err = GetPreferencesWithDefaults(query)
err = ss.GetPreferencesWithDefaults(query)
So(err, ShouldBeNil)
So(query.Result.HomeDashboardId, ShouldEqual, 3)
})
@@ -71,7 +74,7 @@ func TestPreferencesDataAccess(t *testing.T) {
So(err, ShouldBeNil)
query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1}}
err = GetPreferencesWithDefaults(query)
err = ss.GetPreferencesWithDefaults(query)
So(err, ShouldBeNil)
So(query.Result.HomeDashboardId, ShouldEqual, 1)
})
@@ -89,7 +92,7 @@ func TestPreferencesDataAccess(t *testing.T) {
query := &models.GetPreferencesWithDefaultsQuery{
User: &models.SignedInUser{OrgId: 1, UserId: 1, Teams: []int64{2, 3}},
}
err = GetPreferencesWithDefaults(query)
err = ss.GetPreferencesWithDefaults(query)
So(err, ShouldBeNil)
So(query.Result.HomeDashboardId, ShouldEqual, 4)
})
@@ -107,7 +110,7 @@ func TestPreferencesDataAccess(t *testing.T) {
query := &models.GetPreferencesWithDefaultsQuery{
User: &models.SignedInUser{OrgId: 1, UserId: 2},
}
err = GetPreferencesWithDefaults(query)
err = ss.GetPreferencesWithDefaults(query)
So(err, ShouldBeNil)
So(query.Result.HomeDashboardId, ShouldEqual, 1)
})

View File

@@ -102,6 +102,7 @@ func (ss *SqlStore) Init() error {
// Register handlers
ss.addUserQueryAndCommandHandlers()
ss.addAlertNotificationUidByIdHandler()
ss.addPreferencesQueryAndCommandHandlers()
err = ss.logOrgsNotice()
if err != nil {

View File

@@ -1,9 +1,16 @@
package setting
import (
"time"
"gopkg.in/ini.v1"
)
type DateFormats struct {
FullDate string `json:"fullDate"`
UseBrowserLocale bool `json:"useBrowserLocale"`
Interval DateFormatIntervals `json:"interval"`
DefaultTimezone string `json:"defaultTimezone"`
}
type DateFormatIntervals struct {
@@ -15,6 +22,23 @@ type DateFormatIntervals struct {
Year string `json:"year"`
}
const LocalBrowserTimezone = "browser"
func valueAsTimezone(section *ini.Section, keyName string, defaultValue string) (string, error) {
timezone := section.Key(keyName).MustString(defaultValue)
if timezone == LocalBrowserTimezone {
return LocalBrowserTimezone, nil
}
location, err := time.LoadLocation(timezone)
if err != nil {
return LocalBrowserTimezone, err
}
return location.String(), nil
}
func (cfg *Cfg) readDateFormats() {
dateFormats := cfg.Raw.Section("date_formats")
cfg.DateFormats.FullDate = valueAsString(dateFormats, "full_date", "YYYY-MM-DD HH:mm:ss")
@@ -25,4 +49,10 @@ func (cfg *Cfg) readDateFormats() {
cfg.DateFormats.Interval.Month = valueAsString(dateFormats, "interval_month", "YYYY-MM")
cfg.DateFormats.Interval.Year = "YYYY"
cfg.DateFormats.UseBrowserLocale = dateFormats.Key("date_format_use_browser_locale").MustBool(false)
timezone, err := valueAsTimezone(dateFormats, "default_timezone", LocalBrowserTimezone)
if err != nil {
cfg.Logger.Warn("Unknown timezone as default_timezone", "err", err)
}
cfg.DateFormats.DefaultTimezone = timezone
}

View File

@@ -0,0 +1,37 @@
package setting
import (
"testing"
"gopkg.in/ini.v1"
"github.com/stretchr/testify/assert"
)
func TestValueAsTimezone(t *testing.T) {
tests := map[string]struct {
output string
hasErr bool
}{
"browser": {"browser", false},
"UTC": {"UTC", false},
"utc": {"browser", true},
"Amsterdam": {"browser", true},
"europe/amsterdam": {"browser", true},
"Europe/Amsterdam": {"Europe/Amsterdam", false},
}
sec, err := ini.Empty().NewSection("test")
assert.NoError(t, err)
key, err := sec.NewKey("test", "")
assert.NoError(t, err)
for input, expected := range tests {
key.SetValue(input)
output, err := valueAsTimezone(sec, "test", "default")
assert.Equal(t, expected.hasErr, err != nil, "Invalid has err for input: %s err: %v", input, err)
assert.Equal(t, expected.output, output, "Invalid output for input: %s", input)
}
}

View File

@@ -140,10 +140,10 @@ var (
ViewersCanEdit bool
// Http auth
AdminUser string
AdminPassword string
LoginCookieName string
LoginMaxLifetimeDays int
AdminUser string
AdminPassword string
LoginCookieName string
LoginMaxLifetime time.Duration
AnonymousEnabled bool
AnonymousOrgName string
@@ -278,8 +278,8 @@ type Cfg struct {
// Auth
LoginCookieName string
LoginMaxInactiveLifetimeDays int
LoginMaxLifetimeDays int
LoginMaxInactiveLifetime time.Duration
LoginMaxLifetime time.Duration
TokenRotationIntervalMinutes int
// OAuth
@@ -315,11 +315,6 @@ func (c Cfg) IsExpressionsEnabled() bool {
return c.FeatureToggles["expressions"]
}
// IsStandaloneAlertsEnabled returns whether the standalone alerts feature is enabled.
func (c Cfg) IsStandaloneAlertsEnabled() bool {
return c.FeatureToggles["standaloneAlerts"]
}
// IsLiveEnabled returns if grafana live should be enabled
func (c Cfg) IsLiveEnabled() bool {
return c.FeatureToggles["live"]
@@ -951,15 +946,38 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error {
return nil
}
func readAuthSettings(iniFile *ini.File, cfg *Cfg) error {
func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) {
auth := iniFile.Section("auth")
LoginCookieName = valueAsString(auth, "login_cookie_name", "grafana_session")
cfg.LoginCookieName = LoginCookieName
cfg.LoginMaxInactiveLifetimeDays = auth.Key("login_maximum_inactive_lifetime_days").MustInt(7)
maxInactiveDaysVal := auth.Key("login_maximum_inactive_lifetime_days").MustString("")
if maxInactiveDaysVal != "" {
maxInactiveDaysVal = fmt.Sprintf("%sd", maxInactiveDaysVal)
cfg.Logger.Warn("[Deprecated] the configuration setting 'login_maximum_inactive_lifetime_days' is deprecated, please use 'login_maximum_inactive_lifetime_duration' instead")
} else {
maxInactiveDaysVal = "7d"
}
maxInactiveDurationVal := valueAsString(auth, "login_maximum_inactive_lifetime_duration", maxInactiveDaysVal)
cfg.LoginMaxInactiveLifetime, err = gtime.ParseInterval(maxInactiveDurationVal)
if err != nil {
return err
}
maxLifetimeDaysVal := auth.Key("login_maximum_lifetime_days").MustString("")
if maxLifetimeDaysVal != "" {
maxLifetimeDaysVal = fmt.Sprintf("%sd", maxLifetimeDaysVal)
cfg.Logger.Warn("[Deprecated] the configuration setting 'login_maximum_lifetime_days' is deprecated, please use 'login_maximum_lifetime_duration' instead")
} else {
maxLifetimeDaysVal = "7d"
}
maxLifetimeDurationVal := valueAsString(auth, "login_maximum_lifetime_duration", maxLifetimeDaysVal)
cfg.LoginMaxLifetime, err = gtime.ParseInterval(maxLifetimeDurationVal)
if err != nil {
return err
}
LoginMaxLifetime = cfg.LoginMaxLifetime
LoginMaxLifetimeDays = auth.Key("login_maximum_lifetime_days").MustInt(30)
cfg.LoginMaxLifetimeDays = LoginMaxLifetimeDays
cfg.ApiKeyMaxSecondsToLive = auth.Key("api_key_max_seconds_to_live").MustInt64(-1)
cfg.TokenRotationIntervalMinutes = auth.Key("token_rotation_interval_minutes").MustInt(10)

View File

@@ -8,6 +8,7 @@ import (
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
@@ -314,3 +315,49 @@ func TestParseAppUrlAndSubUrl(t *testing.T) {
require.Equal(t, tc.expectedAppSubURL, appSubURL)
}
}
func TestAuthDurationSettings(t *testing.T) {
f := ini.Empty()
cfg := NewCfg()
sec, err := f.NewSection("auth")
require.NoError(t, err)
_, err = sec.NewKey("login_maximum_inactive_lifetime_days", "10")
require.NoError(t, err)
_, err = sec.NewKey("login_maximum_inactive_lifetime_duration", "")
require.NoError(t, err)
maxInactiveDaysTest, _ := time.ParseDuration("240h")
err = readAuthSettings(f, cfg)
require.NoError(t, err)
require.Equal(t, maxInactiveDaysTest, cfg.LoginMaxInactiveLifetime)
f = ini.Empty()
sec, err = f.NewSection("auth")
require.NoError(t, err)
_, err = sec.NewKey("login_maximum_inactive_lifetime_duration", "824h")
require.NoError(t, err)
maxInactiveDurationTest, _ := time.ParseDuration("824h")
err = readAuthSettings(f, cfg)
require.NoError(t, err)
require.Equal(t, maxInactiveDurationTest, cfg.LoginMaxInactiveLifetime)
f = ini.Empty()
sec, err = f.NewSection("auth")
require.NoError(t, err)
_, err = sec.NewKey("login_maximum_lifetime_days", "24")
require.NoError(t, err)
_, err = sec.NewKey("login_maximum_lifetime_duration", "")
require.NoError(t, err)
maxLifetimeDaysTest, _ := time.ParseDuration("576h")
err = readAuthSettings(f, cfg)
require.NoError(t, err)
require.Equal(t, maxLifetimeDaysTest, cfg.LoginMaxLifetime)
f = ini.Empty()
sec, err = f.NewSection("auth")
require.NoError(t, err)
_, err = sec.NewKey("login_maximum_lifetime_duration", "824h")
require.NoError(t, err)
maxLifetimeDurationTest, _ := time.ParseDuration("824h")
err = readAuthSettings(f, cfg)
require.NoError(t, err)
require.Equal(t, maxLifetimeDurationTest, cfg.LoginMaxLifetime)
}

View File

@@ -211,6 +211,11 @@ func TestAppInsightsPluginRoutes(t *testing.T) {
func TestInsightsDimensionsUnmarshalJSON(t *testing.T) {
a := []byte(`"foo"`)
b := []byte(`["foo"]`)
c := []byte(`["none"]`)
d := []byte(`["None"]`)
e := []byte("null")
f := []byte(`""`)
g := []byte(`"none"`)
var as InsightsDimensions
var bs InsightsDimensions
@@ -223,4 +228,29 @@ func TestInsightsDimensionsUnmarshalJSON(t *testing.T) {
require.NoError(t, err)
require.Equal(t, []string{"foo"}, []string(bs))
var cs InsightsDimensions
err = json.Unmarshal(c, &cs)
require.NoError(t, err)
require.Empty(t, cs)
var ds InsightsDimensions
err = json.Unmarshal(d, &ds)
require.NoError(t, err)
require.Empty(t, ds)
var es InsightsDimensions
err = json.Unmarshal(e, &es)
require.NoError(t, err)
require.Empty(t, es)
var fs InsightsDimensions
err = json.Unmarshal(f, &fs)
require.NoError(t, err)
require.Empty(t, fs)
var gs InsightsDimensions
err = json.Unmarshal(g, &gs)
require.NoError(t, err)
require.Empty(t, gs)
}

View File

@@ -170,7 +170,14 @@ func (s *InsightsDimensions) UnmarshalJSON(data []byte) error {
if err != nil {
return err
}
*s = InsightsDimensions(sa)
dimensions := []string{}
for _, v := range sa {
if v == "none" || v == "None" {
continue
}
dimensions = append(dimensions, v)
}
*s = InsightsDimensions(dimensions)
return nil
}

View File

@@ -1,6 +1,6 @@
{
"name": "@grafana-plugins/input-datasource",
"version": "7.2.0-pre.0",
"version": "7.2.0-beta.2",
"description": "Input Datasource",
"private": true,
"repository": {
@@ -16,9 +16,9 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"devDependencies": {
"@grafana/data": "7.2.0-pre.0",
"@grafana/toolkit": "7.2.0-pre.0",
"@grafana/ui": "7.2.0-pre.0"
"@grafana/data": "7.2.0-beta.2",
"@grafana/toolkit": "7.2.0-beta.2",
"@grafana/ui": "7.2.0-beta.2"
},
"volta": {
"node": "12.16.2"

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react';
import { HorizontalGroup, Icon, renderOrCallToRender, stylesFactory, useTheme } from '@grafana/ui';
import { Icon, renderOrCallToRender, stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { css } from 'emotion';
import { useUpdateEffect } from 'react-use';
@@ -66,33 +66,36 @@ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
const rowHeader = (
<div className={styles.header}>
<HorizontalGroup justify="space-between">
<div className={styles.titleWrapper} onClick={onRowToggle} aria-label="Query operation row title">
{draggable && (
<Icon title="Drag and drop to reorder" name="draggabledots" size="lg" className={styles.dragIcon} />
)}
<Icon name={isContentVisible ? 'angle-down' : 'angle-right'} className={styles.collapseIcon} />
{title && <span className={styles.title}>{titleElement}</span>}
{headerElement}
</div>
{actions && actionsElement}
</HorizontalGroup>
<div className={styles.titleWrapper} onClick={onRowToggle} aria-label="Query operation row title">
<Icon name={isContentVisible ? 'angle-down' : 'angle-right'} className={styles.collapseIcon} />
{title && <span className={styles.title}>{titleElement}</span>}
{headerElement}
</div>
{actions && actionsElement}
{draggable && (
<Icon title="Drag and drop to reorder" name="draggabledots" size="lg" className={styles.dragIcon} />
)}
</div>
);
return draggable ? (
<Draggable draggableId={id} index={index}>
{provided => {
return (
<>
<div ref={provided.innerRef} className={styles.wrapper} {...provided.draggableProps}>
<div {...provided.dragHandleProps}>{rowHeader}</div>
{isContentVisible && <div className={styles.content}>{children}</div>}
</div>
</>
);
}}
</Draggable>
) : (
if (draggable) {
return (
<Draggable draggableId={id} index={index}>
{provided => {
return (
<>
<div ref={provided.innerRef} className={styles.wrapper} {...provided.draggableProps}>
<div {...provided.dragHandleProps}>{rowHeader}</div>
{isContentVisible && <div className={styles.content}>{children}</div>}
</div>
</>
);
}}
</Draggable>
);
}
return (
<div className={styles.wrapper}>
{rowHeader}
{isContentVisible && <div className={styles.content}>{children}</div>}
@@ -116,8 +119,11 @@ const getQueryOperationRowStyles = stylesFactory((theme: GrafanaTheme) => {
justify-content: space-between;
`,
dragIcon: css`
opacity: 0.4;
cursor: drag;
color: ${theme.colors.textWeak};
&:hover {
color: ${theme.colors.text};
}
`,
collapseIcon: css`
color: ${theme.colors.textWeak};
@@ -128,7 +134,10 @@ const getQueryOperationRowStyles = stylesFactory((theme: GrafanaTheme) => {
titleWrapper: css`
display: flex;
align-items: center;
flex-grow: 1;
cursor: pointer;
overflow: hidden;
margin-right: ${theme.spacing.sm};
`,
title: css`
font-weight: ${theme.typography.weight.semibold};

View File

@@ -33,7 +33,7 @@ import {
import { getThemeColor } from 'app/core/utils/colors';
import { deduplicateLogRowsById } from 'app/core/utils/explore';
import { decimalSIPrefix } from '@grafana/data/src/valueFormats/symbolFormatters';
import { SIPrefix } from '@grafana/data/src/valueFormats/symbolFormatters';
export const LogLevelColor = {
[LogLevel.critical]: colors[7],
@@ -437,7 +437,7 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi
}
if (totalBytes > 0) {
const { text, suffix } = decimalSIPrefix('B')(totalBytes);
const { text, suffix } = SIPrefix('B')(totalBytes);
meta.push({
label: 'Total bytes processed',
value: `${text} ${suffix}`,

View File

@@ -342,6 +342,7 @@ export class BackendSrv implements BackendService {
// in throwIfEmpty we'll then throw an cancelled error and then we'll return the correct result in the catchError or rethrow
throwIfEmpty(() => ({
type: DataQueryErrorType.Cancelled,
cancelled: true,
data: null,
status: this.HTTP_REQUEST_CANCELED,
statusText: 'Request was aborted',

View File

@@ -353,6 +353,7 @@ describe('backendSrv', () => {
expect(slowError).toEqual({
type: DataQueryErrorType.Cancelled,
cancelled: true,
data: null,
status: -1,
statusText: 'Request was aborted',

View File

@@ -86,7 +86,7 @@ export class EditNotificationChannelPage extends PureComponent<Props> {
<h2 className="page-sub-heading">Edit notification channel</h2>
{notificationChannel && notificationChannel.id > 0 ? (
<Form
width={600}
maxWidth={600}
onSubmit={this.onSubmit}
defaultValues={{
...notificationChannel,

View File

@@ -52,7 +52,7 @@ class NewNotificationChannelPage extends PureComponent<Props> {
<Page navModel={navModel}>
<Page.Contents>
<h2 className="page-sub-heading">New notification channel</h2>
<Form onSubmit={this.onSubmit} validateOn="onChange" defaultValues={defaultValues}>
<Form onSubmit={this.onSubmit} validateOn="onChange" defaultValues={defaultValues} maxWidth={600}>
{({ register, errors, control, getValues, watch }) => {
const selectedChannel = notificationChannelTypes.find(c => c.value === getValues().type.value);

View File

@@ -1,6 +1,6 @@
import React, { FC } from 'react';
import { SelectableValue } from '@grafana/data';
import { CollapsableSection, Field, Input, InputControl, Select } from '@grafana/ui';
import { Field, Input, InputControl, Select } from '@grafana/ui';
import { NotificationChannelOptions } from './NotificationChannelOptions';
import { NotificationSettingsProps } from './NotificationChannelForm';
import { NotificationChannelSecureFields, NotificationChannelType } from '../../../types';
@@ -23,7 +23,7 @@ export const BasicSettings: FC<Props> = ({
resetSecureField,
}) => {
return (
<CollapsableSection label="Channel" isOpen>
<>
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message}>
<Input name="name" ref={register({ required: 'Name is required' })} />
</Field>
@@ -39,6 +39,6 @@ export const BasicSettings: FC<Props> = ({
errors={errors}
control={control}
/>
</CollapsableSection>
</>
);
};

View File

@@ -41,9 +41,14 @@ export const NotificationChannelForm: FC<Props> = ({
}, []);
const currentFormValues = getValues();
return selectedChannel ? (
<>
<div className={styles.basicSettings}>
if (!selectedChannel) {
return <Spinner />;
}
return (
<div className={styles.formContainer}>
<div className={styles.formItem}>
<BasicSettings
selectedChannel={selectedChannel}
channels={selectableChannels}
@@ -54,8 +59,10 @@ export const NotificationChannelForm: FC<Props> = ({
errors={errors}
control={control}
/>
{/* If there are no non-required fields, don't render this section*/}
{selectedChannel.options.filter(o => !o.required).length > 0 && (
</div>
{/* If there are no non-required fields, don't render this section*/}
{selectedChannel.options.filter(o => !o.required).length > 0 && (
<div className={styles.formItem}>
<ChannelSettings
selectedChannel={selectedChannel}
secureFields={secureFields}
@@ -65,7 +72,9 @@ export const NotificationChannelForm: FC<Props> = ({
errors={errors}
control={control}
/>
)}
</div>
)}
<div className={styles.formItem}>
<NotificationSettings
imageRendererAvailable={imageRendererAvailable}
currentFormValues={currentFormValues}
@@ -74,27 +83,32 @@ export const NotificationChannelForm: FC<Props> = ({
control={control}
/>
</div>
<HorizontalGroup>
<Button type="submit">Save</Button>
<Button type="button" variant="secondary" onClick={() => onTestChannel(getValues({ nest: true }))}>
Test
</Button>
<a href="/alerting/notifications">
<Button type="button" variant="secondary">
Back
<div className={styles.formButtons}>
<HorizontalGroup>
<Button type="submit">Save</Button>
<Button type="button" variant="secondary" onClick={() => onTestChannel(getValues({ nest: true }))}>
Test
</Button>
</a>
</HorizontalGroup>
</>
) : (
<Spinner />
<a href="/alerting/notifications">
<Button type="button" variant="secondary">
Back
</Button>
</a>
</HorizontalGroup>
</div>
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
basicSettings: css`
margin-bottom: ${theme.spacing.xl};
formContainer: css``,
formItem: css`
flex-grow: 1;
padding-top: ${theme.spacing.md};
`,
formButtons: css`
padding-top: ${theme.spacing.xl};
`,
};
});

View File

@@ -59,9 +59,14 @@ export const NotificationChannelOptions: FC<Props> = ({
<Input
readOnly={true}
value="Configured"
addonAfter={
<Button onClick={() => onResetSecureField(option.propertyName)} variant="secondary" type="button">
Reset
suffix={
<Button
onClick={() => onResetSecureField(option.propertyName)}
variant="link"
type="button"
size="sm"
>
Clear
</Button>
}
/>

View File

@@ -7,12 +7,30 @@ import coreModule from 'app/core/core_module';
// Utils & Services
import { dedupAnnotations } from './events_processing';
// Types
import { DashboardModel, PanelModel } from '../dashboard/state';
import { AnnotationEvent, AppEvents, DataSourceApi, PanelEvents, TimeRange, CoreApp } from '@grafana/data';
import { DashboardModel } from '../dashboard/state';
import {
AnnotationEvent,
AppEvents,
DataSourceApi,
PanelEvents,
rangeUtil,
DataQueryRequest,
CoreApp,
ScopedVars,
} from '@grafana/data';
import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime';
import { appEvents } from 'app/core/core';
import { getTimeSrv } from '../dashboard/services/TimeSrv';
import kbn from 'app/core/utils/kbn';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { AnnotationQueryResponse, AnnotationQueryOptions } from './types';
import { standardAnnotationSupport } from './standardAnnotationSupport';
import { runRequest } from '../dashboard/state/runRequest';
let counter = 100;
function getNextRequestId() {
return 'AQ' + counter++;
}
export class AnnotationsSrv {
globalAnnotationsPromise: any;
@@ -32,7 +50,7 @@ export class AnnotationsSrv {
this.datasourcePromises = null;
}
getAnnotations(options: { dashboard: DashboardModel; panel: PanelModel; range: TimeRange }) {
getAnnotations(options: AnnotationQueryOptions) {
return Promise.all([this.getGlobalAnnotations(options), this.getAlertStates(options)])
.then(results => {
// combine the annotations and flatten results
@@ -103,7 +121,7 @@ export class AnnotationsSrv {
return this.alertStatesPromise;
}
getGlobalAnnotations(options: { dashboard: DashboardModel; panel: PanelModel; range: TimeRange }) {
getGlobalAnnotations(options: AnnotationQueryOptions) {
const dashboard = options.dashboard;
if (this.globalAnnotationsPromise) {
@@ -114,9 +132,6 @@ export class AnnotationsSrv {
const promises = [];
const dsPromises = [];
// No more points than pixels
const maxDataPoints = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
for (const annotation of dashboard.annotations.list) {
if (!annotation.enable) {
continue;
@@ -130,21 +145,21 @@ export class AnnotationsSrv {
promises.push(
datasourcePromise
.then((datasource: DataSourceApi) => {
if (!datasource.annotationQuery) {
return [];
// Use the legacy annotationQuery unless annotation support is explicitly defined
if (datasource.annotationQuery && !datasource.annotations) {
return datasource.annotationQuery({
range,
rangeRaw: range.raw,
annotation: annotation,
dashboard: dashboard,
});
}
// Add interval to annotation queries
const interval = kbn.calculateInterval(range, maxDataPoints, datasource.interval);
return datasource.annotationQuery({
...interval,
app: CoreApp.Dashboard,
range,
rangeRaw: range.raw,
annotation: annotation,
dashboard: dashboard,
});
// Note: future annotatoin lifecycle will use observables directly
return executeAnnotationQuery(options, datasource, annotation)
.toPromise()
.then(res => {
return res.events ?? [];
});
})
.then(results => {
// store response in annotation object if this is a snapshot call
@@ -195,4 +210,63 @@ export class AnnotationsSrv {
}
}
export function executeAnnotationQuery(
options: AnnotationQueryOptions,
datasource: DataSourceApi,
savedJsonAnno: any
): Observable<AnnotationQueryResponse> {
const processor = {
...standardAnnotationSupport,
...datasource.annotations,
};
const annotation = processor.prepareAnnotation!(savedJsonAnno);
if (!annotation) {
return of({});
}
const query = processor.prepareQuery!(annotation);
if (!query) {
return of({});
}
// No more points than pixels
const maxDataPoints = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
// Add interval to annotation queries
const interval = rangeUtil.calculateInterval(options.range, maxDataPoints, datasource.interval);
const scopedVars: ScopedVars = {
__interval: { text: interval.interval, value: interval.interval },
__interval_ms: { text: interval.intervalMs.toString(), value: interval.intervalMs },
__annotation: { text: annotation.name, value: annotation },
};
const queryRequest: DataQueryRequest = {
startTime: Date.now(),
requestId: getNextRequestId(),
range: options.range,
maxDataPoints,
scopedVars,
...interval,
app: CoreApp.Dashboard,
timezone: options.dashboard.timezone,
targets: [
{
...query,
refId: 'Anno',
},
],
};
return runRequest(datasource, queryRequest).pipe(
map(panelData => {
const events = panelData.series ? processor.processEvents!(annotation, panelData.series) : [];
return { panelData, events };
})
);
}
coreModule.service('annotationsSrv', AnnotationsSrv);

View File

@@ -0,0 +1,195 @@
import React, { PureComponent } from 'react';
import {
SelectableValue,
getFieldDisplayName,
AnnotationEvent,
AnnotationEventMappings,
AnnotationEventFieldMapping,
formattedValueToString,
AnnotationEventFieldSource,
getValueFormat,
} from '@grafana/data';
import { annotationEventNames, AnnotationFieldInfo } from '../standardAnnotationSupport';
import { Select, Tooltip, Icon } from '@grafana/ui';
import { AnnotationQueryResponse } from '../types';
// const valueOptions: Array<SelectableValue<AnnotationEventFieldSource>> = [
// { value: AnnotationEventFieldSource.Field, label: 'Field', description: 'Set the field value from a response field' },
// { value: AnnotationEventFieldSource.Text, label: 'Text', description: 'Enter direct text for the value' },
// { value: AnnotationEventFieldSource.Skip, label: 'Skip', description: 'Hide this field' },
// ];
interface Props {
response?: AnnotationQueryResponse;
mappings?: AnnotationEventMappings;
change: (mappings?: AnnotationEventMappings) => void;
}
interface State {
fieldNames: Array<SelectableValue<string>>;
}
export class AnnotationFieldMapper extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
fieldNames: [],
};
}
updateFields = () => {
const frame = this.props.response?.panelData?.series[0];
if (frame && frame.fields) {
const fieldNames = frame.fields.map(f => {
const name = getFieldDisplayName(f, frame);
let description = '';
for (let i = 0; i < frame.length; i++) {
if (i > 0) {
description += ', ';
}
if (i > 2) {
description += '...';
break;
}
description += f.values.get(i);
}
if (description.length > 50) {
description = description.substring(0, 50) + '...';
}
return {
label: `${name} (${f.type})`,
value: name,
description,
};
});
this.setState({ fieldNames });
}
};
componentDidMount() {
this.updateFields();
}
componentDidUpdate(oldProps: Props) {
if (oldProps.response !== this.props.response) {
this.updateFields();
}
}
onFieldSourceChange = (k: keyof AnnotationEvent, v: SelectableValue<AnnotationEventFieldSource>) => {
const mappings = this.props.mappings || {};
const mapping = mappings[k] || {};
this.props.change({
...mappings,
[k]: {
...mapping,
source: v.value || AnnotationEventFieldSource.Field,
},
});
};
onFieldNameChange = (k: keyof AnnotationEvent, v: SelectableValue<string>) => {
const mappings = this.props.mappings || {};
const mapping = mappings[k] || {};
this.props.change({
...mappings,
[k]: {
...mapping,
value: v.value,
source: AnnotationEventFieldSource.Field,
},
});
};
renderRow(row: AnnotationFieldInfo, mapping: AnnotationEventFieldMapping, first?: AnnotationEvent) {
const { fieldNames } = this.state;
let picker = fieldNames;
const current = mapping.value;
let currentValue = fieldNames.find(f => current === f.value);
if (current) {
picker = [...fieldNames];
if (!currentValue) {
picker.push({
label: current,
value: current,
});
}
}
let value = first ? first[row.key] : '';
if (value && row.key.startsWith('time')) {
const fmt = getValueFormat('dateTimeAsIso');
value = formattedValueToString(fmt(value as number));
}
if (value === null || value === undefined) {
value = ''; // empty string
}
return (
<tr key={row.key}>
<td>
{row.key}{' '}
{row.help && (
<Tooltip content={row.help}>
<Icon name="info-circle" />
</Tooltip>
)}
</td>
{/* <td>
<Select
value={valueOptions.find(v => v.value === mapping.source) || valueOptions[0]}
options={valueOptions}
onChange={(v: SelectableValue<AnnotationEventFieldSource>) => {
this.onFieldSourceChange(row.key, v);
}}
/>
</td> */}
<td>
<Select
value={currentValue}
options={picker}
placeholder={row.placeholder || row.key}
onChange={(v: SelectableValue<string>) => {
this.onFieldNameChange(row.key, v);
}}
noOptionsMessage="Unknown field names"
allowCustomValue={true}
/>
</td>
<td>{`${value}`}</td>
</tr>
);
}
render() {
const first = this.props.response?.events?.[0];
const mappings = this.props.mappings || {};
return (
<table className="filter-table">
<thead>
<tr>
<th>Annotation</th>
<th>From</th>
<th>First Value</th>
</tr>
</thead>
<tbody>
{annotationEventNames.map(row => {
return this.renderRow(row, mappings[row.key] || {}, first);
})}
</tbody>
</table>
);
}
}

View File

@@ -0,0 +1,188 @@
import React, { PureComponent } from 'react';
import { AnnotationEventMappings, DataQuery, LoadingState, DataSourceApi, AnnotationQuery } from '@grafana/data';
import { Spinner, Icon, IconName, Button } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { cx, css } from 'emotion';
import { standardAnnotationSupport } from '../standardAnnotationSupport';
import { executeAnnotationQuery } from '../annotations_srv';
import { PanelModel } from 'app/features/dashboard/state';
import { AnnotationQueryResponse } from '../types';
import { AnnotationFieldMapper } from './AnnotationResultMapper';
import coreModule from 'app/core/core_module';
interface Props {
datasource: DataSourceApi;
annotation: AnnotationQuery<DataQuery>;
change: (annotation: AnnotationQuery<DataQuery>) => void;
}
interface State {
running?: boolean;
response?: AnnotationQueryResponse;
}
export default class StandardAnnotationQueryEditor extends PureComponent<Props, State> {
state = {} as State;
componentDidMount() {
this.verifyDataSource();
}
componentDidUpdate(oldProps: Props) {
if (this.props.annotation !== oldProps.annotation) {
this.verifyDataSource();
}
}
verifyDataSource() {
const { datasource, annotation } = this.props;
// Handle any migration issues
const processor = {
...standardAnnotationSupport,
...datasource.annotations,
};
const fixed = processor.prepareAnnotation!(annotation);
if (fixed !== annotation) {
this.props.change(fixed);
} else {
this.onRunQuery();
}
}
onRunQuery = async () => {
const { datasource, annotation } = this.props;
this.setState({
running: true,
});
const response = await executeAnnotationQuery(
{
range: getTimeSrv().timeRange(),
panel: {} as PanelModel,
dashboard: getDashboardSrv().getCurrent(),
},
datasource,
annotation
).toPromise();
this.setState({
running: false,
response,
});
};
onQueryChange = (target: DataQuery) => {
this.props.change({
...this.props.annotation,
target,
});
};
onMappingChange = (mappings: AnnotationEventMappings) => {
this.props.change({
...this.props.annotation,
mappings,
});
};
renderStatus() {
const { response, running } = this.state;
let rowStyle = 'alert-info';
let text = '...';
let icon: IconName | undefined = undefined;
if (running || response?.panelData?.state === LoadingState.Loading || !response) {
text = 'loading...';
} else {
const { events, panelData } = response;
if (panelData?.error) {
rowStyle = 'alert-error';
icon = 'exclamation-triangle';
text = panelData.error.message ?? 'error';
} else if (!events?.length) {
rowStyle = 'alert-warning';
icon = 'exclamation-triangle';
text = 'No events found';
} else {
const frame = panelData?.series[0];
text = `${events.length} events (from ${frame?.fields.length} fields)`;
}
}
return (
<div
className={cx(
rowStyle,
css`
margin: 4px 0px;
padding: 4px;
display: flex;
justify-content: space-between;
align-items: center;
`
)}
>
<div>
{icon && (
<>
<Icon name={icon} />
&nbsp;
</>
)}
{text}
</div>
<div>
{running ? (
<Spinner />
) : (
<Button variant="secondary" size="xs" onClick={this.onRunQuery}>
TEST
</Button>
)}
</div>
</div>
);
}
render() {
const { datasource, annotation } = this.props;
const { response } = this.state;
// Find the annotaiton runner
let QueryEditor = datasource.annotations?.QueryEditor || datasource.components?.QueryEditor;
if (!QueryEditor) {
return <div>Annotations are not supported. This datasource needs to export a QueryEditor</div>;
}
const query = annotation.target ?? { refId: 'Anno' };
return (
<>
<QueryEditor
key={datasource?.name}
query={query}
datasource={datasource}
onChange={this.onQueryChange}
onRunQuery={this.onRunQuery}
data={response?.panelData}
range={getTimeSrv().timeRange()}
/>
{this.renderStatus()}
<AnnotationFieldMapper response={response} mappings={annotation.mappings} change={this.onMappingChange} />
<br />
</>
);
}
}
// Careful to use a unique directive name! many plugins already use "annotationEditor" and have conflicts
coreModule.directive('standardAnnotationEditor', [
'reactDirective',
(reactDirective: any) => {
return reactDirective(StandardAnnotationQueryEditor, ['annotation', 'datasource', 'change']);
},
]);

View File

@@ -7,10 +7,13 @@ import DatasourceSrv from '../plugins/datasource_srv';
import appEvents from 'app/core/app_events';
import { AppEvents } from '@grafana/data';
// Registeres the angular directive
import './components/StandardAnnotationQueryEditor';
export class AnnotationsEditorCtrl {
mode: any;
datasources: any;
annotations: any;
annotations: any[];
currentAnnotation: any;
currentDatasource: any;
currentIsNew: any;
@@ -69,6 +72,19 @@ export class AnnotationsEditorCtrl {
});
}
/**
* Called from the react editor
*/
onAnnotationChange = (annotation: any) => {
const currentIndex = this.dashboard.annotations.list.indexOf(this.currentAnnotation);
if (currentIndex >= 0) {
this.dashboard.annotations.list[currentIndex] = annotation;
} else {
console.warn('updating annotatoin, but not in the dashboard', annotation);
}
this.currentAnnotation = annotation;
};
edit(annotation: any) {
this.currentAnnotation = annotation;
this.currentAnnotation.showIn = this.currentAnnotation.showIn || 0;

View File

@@ -117,7 +117,15 @@
<h5 class="section-heading">Query</h5>
<rebuild-on-change property="ctrl.currentDatasource">
<plugin-component type="annotations-query-ctrl"> </plugin-component>
<!-- Legacy angular -->
<plugin-component ng-if="!ctrl.currentDatasource.annotations" type="annotations-query-ctrl"> </plugin-component>
<!-- React query editor -->
<standard-annotation-editor
ng-if="ctrl.currentDatasource.annotations"
annotation="ctrl.currentAnnotation"
datasource="ctrl.currentDatasource"
change="ctrl.onAnnotationChange" />
</rebuild-on-change>
<div class="gf-form">

Some files were not shown because too many files have changed in this diff Show More