Merge remote-tracking branch 'origin' into km/test-tag-build

This commit is contained in:
Kevin Minehart
2023-06-12 12:08:12 -05:00
175 changed files with 5539 additions and 1583 deletions
-3
View File
@@ -4144,9 +4144,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/plugins/datasource/influxdb/components/editor/query/influxql/InfluxCheatSheet.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/datasource/influxdb/datasource.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
+81 -7
View File
@@ -4550,7 +4550,7 @@ steps:
image: alpine/git
name: clone-rgm
- commands:
- apk update && apk add docker bash
- apk update && apk add docker
- export GRAFANA_DIR=$$(pwd)
- cd rgm && ./scripts/drone_publish_main.sh
environment:
@@ -4604,7 +4604,7 @@ steps:
image: alpine/git
name: clone-rgm
- commands:
- apk update && apk add docker bash
- apk update && apk add docker
- export GRAFANA_DIR=$$(pwd)
- cd rgm && ./scripts/drone_publish_tag.sh
environment:
@@ -6925,7 +6925,7 @@ steps:
- commands:
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/grafana:latest
image: aquasec/trivy:0.21.0
name: scan-unkown-low-medium-vulnerabilities
name: scan-unknown-low-medium-vulnerabilities
- commands:
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/grafana:latest
image: aquasec/trivy:0.21.0
@@ -6955,7 +6955,7 @@ steps:
- commands:
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/grafana:main
image: aquasec/trivy:0.21.0
name: scan-unkown-low-medium-vulnerabilities
name: scan-unknown-low-medium-vulnerabilities
- commands:
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/grafana:main
image: aquasec/trivy:0.21.0
@@ -6985,7 +6985,7 @@ steps:
- commands:
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/grafana:latest-ubuntu
image: aquasec/trivy:0.21.0
name: scan-unkown-low-medium-vulnerabilities
name: scan-unknown-low-medium-vulnerabilities
- commands:
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/grafana:latest-ubuntu
image: aquasec/trivy:0.21.0
@@ -7016,7 +7016,7 @@ steps:
- commands:
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/grafana:main-ubuntu
image: aquasec/trivy:0.21.0
name: scan-unkown-low-medium-vulnerabilities
name: scan-unknown-low-medium-vulnerabilities
- commands:
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/grafana:main-ubuntu
image: aquasec/trivy:0.21.0
@@ -7039,6 +7039,80 @@ type: docker
clone:
retries: 3
kind: pipeline
name: scan-build-test-and-publish-docker-images
platform:
arch: amd64
os: linux
steps:
- commands:
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM google/cloud-sdk:431.0.0
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/build-container:1.7.4
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/grafana-ci-deploy:1.3.3
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM alpine:3.17.1
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM byrnedo/alpine-curl:0.1.8
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM mcr.microsoft.com/windows:1809
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/ci-wix:0.1.1
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM golang:1.20.4
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/grafana-ci-windows-test:0.1.0
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM plugins/slack
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM postgres:12.3-alpine
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM mysql:5.7.39
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM mysql:8.0.32
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM redis:6.2.11-alpine
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM memcached:1.6.9-alpine
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM docker:windowsservercore-1809
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM us.gcr.io/kubernetes-dev/package-publish:latest
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM osixia/openldap:1.4.0
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/drone-downstream
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/docker-puppeteer:1.1.0
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/docs-base:latest
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM cypress/included:9.5.1-node16.14.0-slim-chrome99-ff97
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM us-docker.pkg.dev/grafanalabs-dev/cloud-data-sources/e2e:latest
image: aquasec/trivy:0.21.0
name: scan-unknown-low-medium-vulnerabilities
- commands:
- trivy --exit-code 1 --severity HIGH,CRITICAL google/cloud-sdk:431.0.0
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/build-container:1.7.4
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/grafana-ci-deploy:1.3.3
- trivy --exit-code 1 --severity HIGH,CRITICAL alpine:3.17.1
- trivy --exit-code 1 --severity HIGH,CRITICAL byrnedo/alpine-curl:0.1.8
- trivy --exit-code 1 --severity HIGH,CRITICAL mcr.microsoft.com/windows:1809
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/ci-wix:0.1.1
- trivy --exit-code 1 --severity HIGH,CRITICAL golang:1.20.4
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/grafana-ci-windows-test:0.1.0
- trivy --exit-code 1 --severity HIGH,CRITICAL plugins/slack
- trivy --exit-code 1 --severity HIGH,CRITICAL postgres:12.3-alpine
- trivy --exit-code 1 --severity HIGH,CRITICAL mysql:5.7.39
- trivy --exit-code 1 --severity HIGH,CRITICAL mysql:8.0.32
- trivy --exit-code 1 --severity HIGH,CRITICAL redis:6.2.11-alpine
- trivy --exit-code 1 --severity HIGH,CRITICAL memcached:1.6.9-alpine
- trivy --exit-code 1 --severity HIGH,CRITICAL docker:windowsservercore-1809
- trivy --exit-code 1 --severity HIGH,CRITICAL us.gcr.io/kubernetes-dev/package-publish:latest
- trivy --exit-code 1 --severity HIGH,CRITICAL osixia/openldap:1.4.0
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/drone-downstream
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/docker-puppeteer:1.1.0
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/docs-base:latest
- trivy --exit-code 1 --severity HIGH,CRITICAL cypress/included:9.5.1-node16.14.0-slim-chrome99-ff97
- trivy --exit-code 1 --severity HIGH,CRITICAL us-docker.pkg.dev/grafanalabs-dev/cloud-data-sources/e2e:latest
image: aquasec/trivy:0.21.0
name: scan-high-critical-vulnerabilities
- image: plugins/slack
name: slack-notify-failure
settings:
channel: grafana-backend-ops
template: 'Nightly docker image scan job for build-images failed: {{build.link}}'
webhook:
from_secret: slack_webhook_backend
when:
status: failure
trigger:
cron: nightly
event: cron
type: docker
---
clone:
retries: 3
kind: pipeline
name: grafana-com-nightly
platform:
arch: amd64
@@ -7242,6 +7316,6 @@ kind: secret
name: delivery-bot-app-private-key
---
kind: signature
hmac: 124ed302e0b2a80dd5e98057f9b03750af9bae084b3ef4f574e6f2888a1a741a
hmac: 44f95e0e3d9eb3fc8891e94f7205c18e85adacab25b69906d3c5212875baa383
...
+55 -19
View File
@@ -4,6 +4,14 @@
### Features and enhancements
- **Alerting:** Migrate unknown NoData\Error settings to the default. [#69010](https://github.com/grafana/grafana/issues/69010), [@grafanabot](https://github.com/grafanabot)
- **Drawer:** Position under nav & minor redesign . [#68396](https://github.com/grafana/grafana/issues/68396), [@grafanabot](https://github.com/grafanabot)
- **Navigation:** Add keyboard shortcut to navigate directly to Dashboards. [#68374](https://github.com/grafana/grafana/issues/68374), [@grafanabot](https://github.com/grafanabot)
- **Explore:** Promote exploreMixedDatasource to Stable & enable by default. [#68353](https://github.com/grafana/grafana/issues/68353), [@Elfo404](https://github.com/Elfo404)
- **Tempo:** Escape regex-sensitive characters in span name before building promql query. [#68313](https://github.com/grafana/grafana/issues/68313), [@grafanabot](https://github.com/grafanabot)
- **Drawer:** Introduce a size property that set's width percentage and minWidth . [#68128](https://github.com/grafana/grafana/issues/68128), [@grafanabot](https://github.com/grafanabot)
- **AngularDeprecation:** Show warnings in panel edit for angular panels. [#68083](https://github.com/grafana/grafana/issues/68083), [@grafanabot](https://github.com/grafanabot)
- **Dashboard:** Change add panel button to fill to remove outline border. [#68017](https://github.com/grafana/grafana/issues/68017), [@grafanabot](https://github.com/grafanabot)
- **Query History:** Remove migration. [#67470](https://github.com/grafana/grafana/issues/67470), [@Elfo404](https://github.com/Elfo404)
- **Alerting:** Implement template testing endpoint. [#67450](https://github.com/grafana/grafana/issues/67450), [@JacobsonMT](https://github.com/JacobsonMT)
- **Trace View:** Export trace button . [#67368](https://github.com/grafana/grafana/issues/67368), [@adrapereira](https://github.com/adrapereira)
@@ -100,6 +108,37 @@
### Bug fixes
- **ResourcePicker:** Fix missing border bug on cancel button. [#69113](https://github.com/grafana/grafana/issues/69113), [@nmarrs](https://github.com/nmarrs)
- **TimeSeries:** Fix centeredZero y axis ranging when all values are 0. [#69112](https://github.com/grafana/grafana/issues/69112), [@grafanabot](https://github.com/grafanabot)
- **StatusHistory:** Fix rendering of value-mapped null. [#69108](https://github.com/grafana/grafana/issues/69108), [@grafanabot](https://github.com/grafanabot)
- **Alerting:** Fix provenance guard checks for Alertmanager configuration to not cause panic when compared nested objects. [#69094](https://github.com/grafana/grafana/issues/69094), [@grafanabot](https://github.com/grafanabot)
- **Alerting:** Add support for Alert State History Loki primary. [#69077](https://github.com/grafana/grafana/issues/69077), [@grafanabot](https://github.com/grafanabot)
- **Dashboards:** Fix undefined aria labels in Annotation Checkboxes for Programmatic Access. [#68873](https://github.com/grafana/grafana/issues/68873), [@grafanabot](https://github.com/grafanabot)
- **Alerting:** Fix stale query preview error. [#68836](https://github.com/grafana/grafana/issues/68836), [@grafanabot](https://github.com/grafanabot)
- **AnonymousAuth:** Fix concurrent read-write crash. [#68803](https://github.com/grafana/grafana/issues/68803), [@grafanabot](https://github.com/grafanabot)
- **AzureMonitor:** Ensure legacy properties containing template variables are correctly migrated. [#68792](https://github.com/grafana/grafana/issues/68792), [@grafanabot](https://github.com/grafanabot)
- **Alerting:** Add additional contact points for external AM. [#68778](https://github.com/grafana/grafana/issues/68778), [@grafanabot](https://github.com/grafanabot)
- **RBAC:** Remove legacy AC editor and admin role on new dashboard route. [#68777](https://github.com/grafana/grafana/issues/68777), [@grafanabot](https://github.com/grafanabot)
- **Azure Monitor:** Fix bug with top value so more than 10 resources can be shown . [#68725](https://github.com/grafana/grafana/issues/68725), [@grafanabot](https://github.com/grafanabot)
- **NodeGraph:** Fix overlaps preventing opening an edge context menu when nodes were too close. [#68628](https://github.com/grafana/grafana/issues/68628), [@grafanabot](https://github.com/grafanabot)
- **Plugins:** Correct the usage of mutex for gRPC plugin implementation. [#68609](https://github.com/grafana/grafana/issues/68609), [@grafanabot](https://github.com/grafanabot)
- **Azure Monitor:** Fix bug that did not show alert rule preview. [#68581](https://github.com/grafana/grafana/issues/68581), [@grafanabot](https://github.com/grafanabot)
- **FlameGraph:** Fix table sort being reset when search changes. [#68454](https://github.com/grafana/grafana/issues/68454), [@grafanabot](https://github.com/grafanabot)
- **Command Palette:** Prevent stale search results from overwriting newer results. [#68392](https://github.com/grafana/grafana/issues/68392), [@grafanabot](https://github.com/grafanabot)
- **Search:** Fix Search returning results out of order. [#68387](https://github.com/grafana/grafana/issues/68387), [@joshhunt](https://github.com/joshhunt)
- **Explore:** Remove data source onboarding page. [#68381](https://github.com/grafana/grafana/issues/68381), [@grafanabot](https://github.com/grafanabot)
- **Flamegraph:** Fix tooltip positioning. [#68312](https://github.com/grafana/grafana/issues/68312), [@grafanabot](https://github.com/grafanabot)
- **Pyroscope:** Add authentication when calling backendType resource API. [#68311](https://github.com/grafana/grafana/issues/68311), [@grafanabot](https://github.com/grafanabot)
- **Histogram:** Respect min/max panel settings for x-axis. [#68245](https://github.com/grafana/grafana/issues/68245), [@grafanabot](https://github.com/grafanabot)
- **QueryRow:** Make toggle actions screen-readers accessible. [#68210](https://github.com/grafana/grafana/issues/68210), [@grafanabot](https://github.com/grafanabot)
- **Heatmap:** Fix color rendering for value ranges < 1. [#68164](https://github.com/grafana/grafana/issues/68164), [@grafanabot](https://github.com/grafanabot)
- **Heatmap:** Handle unsorted timestamps in calculate mode. [#68151](https://github.com/grafana/grafana/issues/68151), [@grafanabot](https://github.com/grafanabot)
- **Alerting:** Fixes Alert list panel "ungrouped" regression. [#68090](https://github.com/grafana/grafana/issues/68090), [@grafanabot](https://github.com/grafanabot)
- **Alerting:** Show export button for org admins. [#67995](https://github.com/grafana/grafana/issues/67995), [@grafanabot](https://github.com/grafanabot)
- **Navigation:** Fix 'Page not found' when sending or going back from 'Invitate user' page. [#67972](https://github.com/grafana/grafana/issues/67972), [@grafanabot](https://github.com/grafanabot)
- **InspectDrawer:** Fixes issue with double scrollbars. [#67888](https://github.com/grafana/grafana/issues/67888), [@grafanabot](https://github.com/grafanabot)
- **Connections:** Show core datasource plugins as well. [#67886](https://github.com/grafana/grafana/issues/67886), [@grafanabot](https://github.com/grafanabot)
- **Gauge:** Set min and max for percent unit. [#67719](https://github.com/grafana/grafana/issues/67719), [@grafanabot](https://github.com/grafanabot)
- **TimeSeries:** Fix leading null-fill for missing intervals. [#67570](https://github.com/grafana/grafana/issues/67570), [@leeoniya](https://github.com/leeoniya)
- **Pyroscope:** Fix autodetection in case of using Phlare backend. [#67536](https://github.com/grafana/grafana/issues/67536), [@aocenas](https://github.com/aocenas)
- **Dashboard:** Revert fixed header shown on mobile devices in the new panel header. [#67510](https://github.com/grafana/grafana/issues/67510), [@axelavargas](https://github.com/axelavargas)
@@ -162,8 +201,7 @@
### Breaking changes
The deprecated `plugin:build` command in the Grafana Toolkit have been removed in this release. The replacement [`create-plugin`](https://github.com/grafana/plugin-tools/tree/main/packages/create-plugin/) tool is recommended for plugin development.
Issue [#67485](https://github.com/grafana/grafana/issues/67485)
The deprecated `plugin:build` command in the Grafana Toolkit have been removed in this release. The replacement [`create-plugin`](https://github.com/grafana/plugin-tools/tree/main/packages/create-plugin/) tool is recommended for plugin development. Issue [#67485](https://github.com/grafana/grafana/issues/67485)
The deprecated `package:build`, `node-version-check` and `toolkit:build` commands in the Grafana Toolkit have been removed in this release. Issue [#67475](https://github.com/grafana/grafana/issues/67475)
@@ -181,13 +219,15 @@ The deprecated `plugin:test` and `plugin:dev` commands in the Grafana Toolkit ha
The type signature of the `testDatasource()` method on the `DataSourceWithBackend` class [has changed](https://github.com/grafana/grafana/pull/67014/files/a5608dc4f27ab4459e725b22ff60b8fc05390c08#diff-c58fc1a09e9b9b17e5f45efbfb646273e69145f7687facb134440da4edafc745R263), the returned Promise is now typed stricter, which is probably going to cause type-errors while building plugins against the latest Grafana versions.
````typescript
```typescript
// Before
abstract testDatasource(): Promise<any>;
// After
abstract testDatasource(): Promise<TestDataSourceResponse>;
``` Issue [#67014](https://github.com/grafana/grafana/issues/67014)
```
Issue [#67014](https://github.com/grafana/grafana/issues/67014)
Grafana requires an Elasticsearch version of 7.16 or newer. If you use an older Elasticsearch version, you will get warnings in the query editor and on the datasource configuration page. Issue [#66928](https://github.com/grafana/grafana/issues/66928)
@@ -199,20 +239,19 @@ We've removed some now unused properties from the `NavModel` interface. Issue [#
We removed previously deprecated components from `@grafana/data` : `getLogLevel`, `getLogLevelFromKey`, `addLogLevelToSeries`, `LogsParser`, `LogsParsers`, `calculateFieldStats`, `calculateLogsLabelStats`, `calculateStats`, `getParser`, `sortInAscendingOrder`, `sortInDescendingOrder`, `sortLogsResult`, `sortLogRows`, `checkLogsError`, `escapeUnescapedString`. Issue [#66271](https://github.com/grafana/grafana/issues/66271)
We removed previously deprecated components from `@grafana/ui` : `LogLabels`, `LogMessageAnsi`, `LogRows`, `getLogRowStyles`.
Issue [#66268](https://github.com/grafana/grafana/issues/66268)
We removed previously deprecated components from `@grafana/ui` : `LogLabels`, `LogMessageAnsi`, `LogRows`, `getLogRowStyles`. Issue [#66268](https://github.com/grafana/grafana/issues/66268)
We removed previously deprecated `DataSourceWithLogsVolumeSupport` that was replaced with `DataSourceWithSupplementaryQueriesSupport`. Both APIs are for internal use only. Issue [#66266](https://github.com/grafana/grafana/issues/66266)
We removed previously deprecated `DataSourceWithLogsVolumeSupport` that was replaced with `DataSourceWithSupplementaryQueriesSupport`. Both APIs are for internal use only. Issue [#66266](https://github.com/grafana/grafana/issues/66266)
Additional functions (map/filter/forEach/iterator) have been added to the root Vector interface. Any code using vectors will continue to work unchanged, but in the rare case that you have implemented Vector directly, it be missing these functions. The easiest fix is to extend [FunctionalVector](https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/vector/FunctionalVector.ts).
Additional functions (map/filter/forEach/iterator) have been added to the root Vector interface. Any code using vectors will continue to work unchanged, but in the rare case that you have implemented Vector directly, it be missing these functions. The easiest fix is to extend [FunctionalVector](https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/vector/FunctionalVector.ts).
The `ArrayVector` class now extends the native JavaScript `Array` and gains all of its prototype/instance methods as a result. Issue [#66187](https://github.com/grafana/grafana/issues/66187)
We've removed the ability for functions to be passed as children to the `Dropdown` component. Previously, this was used to access the `isOpen` state of the dropdown. This can be now be achieved with the `onVisibleChange` prop.
Before:
````
```
return (
<Dropdown overlay={MenuActions} placement="bottom-end">
{(isOpen) =>
@@ -220,12 +259,11 @@ return (
}
</Dropdown>
);
```
After:
```
```
const [isOpen, setIsOpen] = useState(false);
...
@@ -235,13 +273,11 @@ return (
<ToolbarButton iconOnly icon="plus" isOpen={isOpen} aria-label="New" />
</Dropdown>
);
```
````Issue [#65467](https://github.com/grafana/grafana/issues/65467)
Issue [#65467](https://github.com/grafana/grafana/issues/65467)
(relevant for plugin developers) The deprecated internal `dashboardId` is now removed from the request context. For usage tracking use the `dashboardUid`
Issue [#64786](https://github.com/grafana/grafana/issues/64786)
(relevant for plugin developers) The deprecated internal `dashboardId` is now removed from the request context. For usage tracking use the `dashboardUid` Issue [#64786](https://github.com/grafana/grafana/issues/64786)
Grafana has been upgraded to React 18 and now leverages the new React client rendering API. Plugin authors in particular should be aware, as there could be unintended side effects due to the changes around automatic batching of state updates and consistent `useEffect` timings. Be sure to test your plugins and reference the React 18 upgrade docs here: https://react.dev/blog/2022/03/08/react-18-upgrade-guide Issue [#64428](https://github.com/grafana/grafana/issues/64428)
@@ -2650,7 +2686,7 @@ await selectOptionInTest(selectEl, 'Option 2');
import { select } from 'react-select-event';
// ...test usage
await select(selectEl, 'Option 2', { container: document.body });
````
```
Issue [#50442](https://github.com/grafana/grafana/issues/50442)
@@ -3317,8 +3353,8 @@ The change in behavior is that negative-valued series are now stacked downwards
The meaning of the default data source has now changed from being a persisted property in a panel. Before when you selected the default data source for a panel and later changed the default data source to another data source it would change all panels who were configured to use the default data source. From now on the default data source is just the default for new panels and changing the default will not impact any currently saved dashboards. Issue [#45132](https://github.com/grafana/grafana/issues/45132)
The Tooltip component provided by `@grafana/ui` is no longer automatically interactive (that is you can hover onto it and click a link or select text). It will from now on by default close automatically when you mouse out from the trigger element. To make tooltips behave like before set the new `interactive` property to true.
Issue [#45053](https://github.com/grafana/grafana/issues/45053)
The Tooltip component provided by `@grafana/ui` is no longer automatically interactive (that is you can hover onto it and click a link or select text). It will from now on by default close automatically when you mouse out from the trigger element. To make tooltips behave like before set the new `interactive` property to true.
Issue [#45053](https://github.com/grafana/grafana/issues/45053)
### Deprecations
+225
View File
@@ -0,0 +1,225 @@
# Testing Guidelines
The goal of this document is to address the most frequently asked "How to" questions related to unit testing.
## Best practices
- Default to the `*ByRole` queries when testing components as it encourages testing with accessibility concerns in mind. It's also possible to use `*ByLabelText` queries. However, the `*ByRole` queries are [more robust](https://testing-library.com/docs/queries/bylabeltext/#name) and are generally recommended over the former.
## Testing User Interactions
We use the [user-event](https://testing-library.com/docs/user-event/intro) library for simulating user interactions during testing. This library is preferred over the built-in `fireEvent` method, as it more accurately mirrors real user interactions with elements.
There are two important considerations when working with `userEvent`:
1. All methods in `userEvent` are asynchronous, and thus require the use of `await` when called.
2. Directly calling methods from `userEvent` may not be supported in future versions. As such, it's necessary to first call `userEvent.setup()` prior to the tests. This method returns a `userEvent` instance, complete with all its methods. This setup process can be simplified using a utility function:
```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
function setup(jsx: JSX.Element) {
return {
user: userEvent.setup(),
...render(jsx),
};
}
it('should render', async () => {
const { user } = setup(<Button />);
await user.click(screen.getByRole('button'));
});
```
## Debugging Tests
There are a few utilities that can be useful for debugging tests:
- [screen.debug()](https://testing-library.com/docs/queries/about/#screendebug) - This function prints a human-readable representation of the document's DOM tree when called without arguments, or the DOM tree of specific node(s) when provided with arguments. It is internally using `console.log` to log the output to terminal.
- [Testing Playground](https://testing-playground.com/) - An interactive sandbox that allows testing which queries work with specific HTML elements.
- [logRoles](https://testing-library.com/docs/dom-testing-library/api-debugging/#prettydom) - A utility function that prints out all the implicit ARIA roles for a given DOM tree.
## Testing Select Components
Here, the [OrgRolePicker](https://github.com/grafana/grafana/blob/38863844e7ac72c7756038a1097f89632f9985ff/public/app/features/admin/OrgRolePicker.tsx) component is used as an example. This component essentially serves as a wrapper for the `Select` component, complete with its own set of options.
```tsx
import { OrgRole } from '@grafana/data';
import { Select } from '@grafana/ui';
interface Props {
value: OrgRole;
disabled?: boolean;
'aria-label'?: string;
inputId?: string;
onChange: (role: OrgRole) => void;
autoFocus?: boolean;
width?: number | 'auto';
}
const options = Object.keys(OrgRole).map((key) => ({ label: key, value: key }));
export function OrgRolePicker({ value, onChange, 'aria-label': ariaLabel, inputId, autoFocus, ...restProps }: Props) {
return (
<Select
inputId={inputId}
value={value}
options={options}
onChange={(val) => onChange(val.value as OrgRole)}
placeholder="Choose role..."
aria-label={ariaLabel}
autoFocus={autoFocus}
{...restProps}
/>
);
}
```
### Querying the Select Component
The recommended way to query `Select` components is by using a label. Add a `label` element and provide the `htmlFor` prop with a matching `inputId`. Alternatively, `aria-label` can be specified on the `Select`.
```tsx
describe('OrgRolePicker', () => {
it('should render the picker', () => {
setup(
<>
<label htmlFor={'role-picker'}>Role picker</label>
<OrgRolePicker value={OrgRole.Admin} inputId={'role-picker'} onChange={() => {}} />
</>
);
expect(screen.getByRole('combobox', { name: 'Role picker' })).toBeInTheDocument();
});
});
```
### Testing the Display of Correct Options
At times, it might be necessary to verify that the `Select` component is displaying the correct options. In such instances, the best solution is to click the `Select` component and match the desired option using the `*ByText` query.
```tsx
it('should have an "Editor" option', async () => {
const { user } = setup(
<>
<label htmlFor={'role-picker'}>Role picker</label>
<OrgRolePicker value={OrgRole.Admin} inputId={'role-picker'} onChange={() => {}} />
</>
);
await user.click(screen.getByRole('combobox', { name: 'Role picker' }));
expect(screen.getByText('Editor')).toBeInTheDocument();
});
```
### Selecting an option
To simplify the process of selecting an option from a `Select` component, there is a `selectOptionInTest` utility function. This function is a wrapper over the [react-select-event](https://testing-library.com/docs/ecosystem-react-select-event/) package.
```tsx
it('should select an option', async () => {
const mockOnChange = jest.fn();
setup(
<>
<label htmlFor={'role-picker'}>Role picker</label>
<OrgRolePicker value={OrgRole.Admin} inputId={'role-picker'} onChange={mockOnChange} />
</>
);
await selectOptionInTest(screen.getByRole('combobox', { name: 'Role picker' }), 'Viewer');
expect(mockOnChange).toHaveBeenCalledWith('Viewer');
});
```
## Mocking Objects and Functions
### Mocking the `window` Object and Its Methods
The recommended approach for mocking the `window` object is to use Jest spies. Jest's spy functions provide a built-in mechanism for restoring mocks. This feature eliminates the need to manually save a reference to the `window` object.
```tsx
let windowSpy: jest.SpyInstance;
beforeAll(() => {
windowSpy = jest.spyOn(window, 'location', 'get');
});
afterAll(() => {
windowSpy.mockRestore();
});
it('should test with window', function () {
windowSpy.mockImplementation(() => ({
href: 'www.example.com',
}));
expect(window.location.href).toBe('www.example.com');
});
```
### Mocking getBackendSrv()
The `getBackendSrv()` function is used to make HTTP requests to the Grafana backend. It is possible to mock this function using the `jest.mock` method.
```tsx
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({
post: postMock,
}),
}));
```
#### Mocking getBackendSrv for AsyncSelect
The `AsyncSelect` component is used to asynchronously load options. As such, it often relies on the `getBackendSrv` for loading the options.
Here's how the test would look like for this [OrgPicker](https://github.com/grafana/grafana/blob/38863844e7ac72c7756038a1097f89632f9985ff/public/app/core/components/Select/OrgPicker.tsx) component, which uses `AsyncSelect` under the hood.
```tsx
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { OrgPicker } from './OrgPicker';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({
get: () =>
Promise.resolve([
{ name: 'Org 1', id: 0 },
{ name: 'Org 2', id: 1 },
]),
}),
}));
function setup(jsx: JSX.Element) {
return {
user: userEvent.setup(),
...render(jsx),
};
}
describe('OrgPicker', () => {
it('should render', async () => {
render(
<>
<label htmlFor={'picker'}>Org picker</label>
<OrgPicker onSelected={() => {}} inputId={'picker'} />
</>
);
expect(await screen.findByRole('combobox', { name: 'Org picker' })).toBeInTheDocument();
});
it('should have the options', async () => {
const { user } = setup(
<>
<label htmlFor={'picker'}>Org picker</label>
<OrgPicker onSelected={() => {}} inputId={'picker'} />
</>
);
await user.click(await screen.findByRole('combobox', { name: 'Org picker' }));
expect(screen.getByText('Org 1')).toBeInTheDocument();
expect(screen.getByText('Org 2')).toBeInTheDocument();
});
});
```
+1
View File
@@ -63,6 +63,7 @@ datasources:
prometheusVersion: 2.40.0
- name: gdev-slow-prometheus
uid: gdev-slow-prometheus-uid
type: prometheus
access: proxy
url: http://localhost:3011
@@ -55,6 +55,38 @@
"pluginVersion": "8.4.0-pre",
"title": "Panel Title",
"type": "text"
},
{
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 9
},
"id": 2,
"options": {
"mode": "markdown",
"content": "VariableUnderTestText: ${VariableUnderTest:text}"
},
"pluginVersion": "8.4.0-pre",
"title": "Panel Title",
"type": "text"
},
{
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 18
},
"id": 2,
"options": {
"mode": "markdown",
"content": "VariableUnderTestRaw: ${VariableUnderTest:raw}"
},
"pluginVersion": "8.4.0-pre",
"title": "Panel Title",
"type": "text"
}
],
"schemaVersion": 35,
@@ -18,6 +18,14 @@ weight: 400
You can create and manage recording rules for an external Grafana Mimir or Loki instance. Recording rules calculate frequently needed expressions or computationally expensive expressions in advance and save the result as a new set of time series. Querying this new time series is faster, especially for dashboards since they query the same expression every time the dashboards refresh.
**Note:**
Recording rules are run as instant rules, which means that they run every 10s. To overwrite this configuration, update the min_interval in your custom configuration file.
[min_interval]({{< relref "../../setup-grafana/configure-grafana" >}}) sets the minimum interval to enforce between rule evaluations. The default value is 10s which equals the scheduler interval. Rules will be adjusted if they are less than this value or if they are not multiple of the scheduler interval (10s). Higher values can help with resource management as fewer evaluations are scheduled over time.
This setting has precedence over each individual rule frequency. If a rule frequency is lower than this value, then this value is enforced.
## Before you begin
- Verify that you have write permission to the Prometheus or Loki data source. Otherwise, you will not be able to create or update Grafana Mimir managed alerting rules.
@@ -44,25 +52,7 @@ To create a Grafana Mimir or Loki managed recording rule
1. In Step 2, select **Mimir or Loki recording rule** option.
- Select your Loki or Prometheus data source.
- Enter a PromQL or LogQL query.
1. In Step 3, add the namespace and the group.
- From the **Namespace** dropdown, select an existing rule namespace. Otherwise, click Add new and enter a name to create a new one. Namespaces can contain one or more rule groups and only have an organizational purpose. For more information, see [Grafana Mimir or Loki rule groups and namespaces]({{< relref "./edit-mimir-loki-namespace-group" >}}).
- From the **Group** dropdown, select an existing group within the selected namespace. Otherwise, click **Add new** and enter a name to create a new one.
1. In Step 4, add the custom labels.
- Add custom labels selecting existing key-value pairs from the drop down, or add new labels by entering the new key or value.
1. Click **Save** to save the recording rule or **Save and exit** to save the recording rule and go back to the Alerting page.
<!-- delete next steps? -->
1. In the left-side menu, click **Alerts & IRM** and then **Alerting**.
1. Click **Alert rules**.
1. Click **+ Create alert rule**.
1. In Step 1, add the rule name.
- In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule.
1. In Step 2, add the type, and storage location.
- From the **Rule type** dropdown, select **Mimir / Loki managed alert**.
- From the **Select data source** dropdown, select an external Prometheus, an external Loki, or a Grafana Cloud data source.
- Enter a PromQL or LogQL expression. The rule fires if the evaluation result has at least one series with a value that is greater than 0. An alert is created for each series.
1. In Step 3, add evaluation behavior.
1. In Step 3, add alert evaluation behavior.
- Enter a valid **For** duration. The expression has to be true for this long for the alert to be fired.
1. In Step 4, add additional metadata associated with the rule.
- From the **Namespace** dropdown, select an existing rule namespace. Otherwise, click Add new and enter a name to create a new one. Namespaces can contain one or more rule groups and only have an organizational purpose. For more information, see [Grafana Mimir or Loki rule groups and namespaces]({{< relref "./edit-mimir-loki-namespace-group" >}}).
@@ -61,3 +61,7 @@ To remove a silence, complete the following steps.
1. Select the silence you want to end, then click **Unsilence**.
> **Note:** You cannot remove a silence manually. Silences that have ended are retained and listed for five days.
## Useful links
[Aggregation operators](/docs/prometheus/latest/querying/operators/#aggregation-operators)
@@ -179,7 +179,13 @@ Template the title of a Slack message to contain the number of firing and resolv
## Template the content of a Slack message
Template the content of a Slack message to contain a description of all firing and resolved alerts, including their labels, annotations, Silence URL and Dashboard URL:
Template the content of a Slack message to contain a description of all firing and resolved alerts, including their labels, annotations, Silence URL and Dashboard URL.
**Note:**
This template is for Grafana-managed alerts only.
To use the template for Grafana Mimir/Loki-managed alerts, delete the references to DashboardURL and SilenceURL.
For more information, see the [Prometheus documentation on notifications](https://prometheus.io/docs/alerting/latest/notifications/).
```
1 firing alert(s):
@@ -0,0 +1,95 @@
---
aliases:
- /docs/grafana-cloud/alerts/
- /docs/grafana-cloud/how-do-i/alerts/
- /docs/grafana-cloud/legacy-alerting/
description: Legacy alerting
title: Legacy alerting
weight: 110
---
# Legacy alerting
**Note:**
Starting with Grafana v9.0.0, legacy alerting is deprecated. It is no longer actively maintained or supported by Grafana and will be removed in Grafana v11.0.0.
You have two options to configure alerts within the Grafana Cloud GUI and a third option that enables you to set Grafana Cloud Alerts using the command line.
- **Grafana alerts** are the same as in an on-prem instance of Grafana.
These alerts are created from a graph panel within a Grafana dashboard.
This is useful when you want to create a simple alert based on one metric from within a panel.
It also has a much simpler learning curve when you are getting started.
- **Grafana Cloud alerts - GUI** are an implementation of Prometheus-style rules that enable you to query your Grafana Cloud Metrics and then set up Prometheus Alertmanager-style alerts based on those rules.
This is useful when you want to create precise, PromQL-based rules or create alerts from across many metrics and logs being collected into your Grafana Cloud Metrics.
This form of alerting is much more powerful and configurable, but that comes with some complexity.
- **Grafana Cloud alerts - CLI** use mimirtool to create and upload the same types of Prometheus-style recording and alerting rules definitions to your Grafana Cloud Metrics instance.
Once created, you will also be able to view these rules from within the Grafana Cloud Alerting page in the GUI.
- **Synthetic Monitoring alerts** are built on Prometheus alerts, just like in Grafana Cloud alerting.
You can configure synthetic monitoring alerts separately using the UI in synthetic monitoring.
Another option to create alerts for synthetic monitoring checks is to simply use Grafana Cloud alerting.
## Using Grafana alerts in Grafana Cloud
Grafana alerts are dashboard panel-driven and can only be created using the Graph panel.
This style of alerting builds on top of the query defined for the graph visualization, so alerts and notifications are sent based on breaking some threshold in the associated panel.
This also means that there is a one-to-one relationship between a Grafana alert and a graph panel.
So although Grafana alerts can be viewed centrally, they can only be managed directly from the panel that they’re tied to.
As a result, Grafana alerting is best suited for smaller setups, where there are only a few individuals or teams responsible for a small set of dashboards and where there are few dependencies between the dashboards.
{{% admonition type="note" %}}
Most curated dashboards, such as those provided with an integration or with Synthetic Monitoring do not allow you to alert from panels.
This is to preserve the ability to upgrade these dashboards automatically when the integration or Synthetic Monitoring abilities are updated.
To create an editable copy that you can edit and alert from, click settings (the gear logo) within any dashboard and then click **Make Editable**.
The copy will not be upgraded when/if the curated dashboard receives an update.
This is one reason why Grafana Cloud Alerts may be considered a better option.
{{% /admonition %}}
### What makes Grafana alerts unique?
With Grafana alerts, alerts are limited to only graph panels within dashboards.
In addition:
- Alerts can be edited by both Editor and Admin roles
- Alerts are visual, with an associated alerting threshold line
- Alerts work with many non-Prometheus data sources, including Graphite
- Alert notifications can be routed to many external notifier systems, directly from Grafana
- Alerts are directly associated with a dashboard
- Alerts can be tested
## Using Grafana Cloud Alerts
Because the metrics you collect and send to Grafana Cloud are centrally stored in one large time-series database, Grafana Cloud Metrics, you can query across these metrics using [PromQL](https://prometheus.io/docs/prometheus/latest/querying/basics/) and build alerts directly around those metrics rather than around a panel.
You can also query across any logs you have sent using Loki.
Grafana Cloud Alerts are directly tied to metrics and log data.
They can be configured either through the UI or by uploading files containing Prometheus and Loki alert rules with mimirtool.
Grafana Cloud Alerting's Prometheus-style alerts are built by querying directly from the data source itself.
Because these alerts are based on the data, they are not tied to a single panel.
This makes it possible to evaluate and centrally manage alerts across several different Prometheus and Loki data source instances.
### What makes Grafana Cloud Alerts unique?
With Grafana Cloud Alerts, alerts are not limited to coming from a graph panel.
In addition, you can:
- Prevent alerts from being edited, except by users with accounts that are assigned Admin roles.
- Centrally manage and create alerts across many systems, teams, and dashboards.
Alerts are not bound to just one system, team, or dashboard.
- Create alerts for both metric _and_ log data, based on Prometheus and Loki, respectively.
- Silence and mute alerts in bulk, even using a schedule, using the Alertmanager.
- Route alert notifications to [many external notifier systems](https://prometheus.io/docs/operating/integrations/#alertmanager-webhook-receiver) using Alertmanager configurations
- Dedupe alert notifications automatically.
### Grafana Cloud Alert configuration methods
In a traditional on-prem environment, Prometheus-style alert configuration is done through the combination of defining a [Prometheus configuration file](https://prometheus.io/docs/prometheus/latest/configuration/configuration/) and an [Alertmanager configuration file](https://prometheus.io/docs/alerting/latest/configuration/), which live close to the Prometheus server.
With Grafana Cloud, you can still use this setup as well as more flexible architectures.
- You can use `mimirtool` to upload your configuration files to be hosted and evaluated entirely in Grafana Cloud.
- You can manage both alerting rules and Alertmanager configurations directly through the UI.
Configuration files are unnecessary with this setup.
- You can use both methods concurrently to manage the alerts.
For example, updates made using the `mimirtool` are automatically updated and visible within the Grafana Cloud Alerting interface in minutes.
@@ -0,0 +1,200 @@
---
aliases:
- /docs/grafana-cloud/alerts/alerts-rules/
- /docs/grafana-cloud/how-do-i/alerts/alerts-rules/
- /docs/grafana-cloud/legacy-alerting/alerts-rules/
- /docs/grafana-cloud/metrics/prometheus/alerts_rules/
- /docs/hosted-metrics/prometheus/alerts_rules/
description: Prometheus rules with mimirtool
title: Prometheus rules with mimirtool
weight: 100
---
# Prometheus rules with mimirtool
This page outlines the steps to use mimirtool and Prometheus-style rules with Grafana Cloud Alerting. You can load Prometheus alerting and recording rules that are evaluated entirely in Grafana Cloud. This allows for global rule evaluation over all of the metrics and logs stored in your Grafana Cloud stack.
{{% admonition type="note" %}}
`mimirtool` does _not_ support Loki.
{{% /admonition %}}
Prometheus-style alerting is driven by your Grafana Cloud Metrics, Grafana Cloud Logs, and Grafana Cloud Alerts instances. The Metrics and Logs instance holds the rules definition, while the Alerts instance is in charge of routing and managing the alerts that fire from the Metrics and Logs instance. These are separate systems that must be individually configured in order for alerting to work correctly.
The following sections cover all of these concepts:
- How to upload alerting and recording rules definition to your Grafana Cloud Metrics instance
- How to upload alerting rules definition to your Grafana Cloud Logs instance
- How to configure an Alertmanager for your Grafana Cloud Alerts instance, giving you access to the Alertmanager UI.
**Note:** You need an API key with proper permissions. You can use the same API key for your Metric, Log, and Alerting instances.
## Download and install mimirtool
mimirtool is a powerful command-line tool for interacting with Mimir, which powers Grafana Cloud Metrics and Alerts. You'll use mimirtool to upload your metric and log rules definition and the Alertmanager configuration using YAML files.
For more information, including installation instructions, see [Grafana Mimirtool](/docs/mimir/latest/operators-guide/tools/mimirtool).
{{% admonition type="note" %}}
For mimirtool to interact with Grafana Cloud, you must set the correct configuration variables. Set them using either environment variables or a command line flags.
{{% /admonition %}}
## Upload rules definition to your Grafana Cloud Metrics and Logs instance
First, you'll need to upload your alerting and recording rules to your Metrics and Logs instance. You'll need the instance ID and the URL. These should be part of /orgs/`<yourOrgName>`/.
### Metrics instance
Your Metrics instance is likely to be in the `us-central1` region. Its address would be in the form of [https://prometheus-us-central1.grafana.net](https://prometheus-us-central1.grafana.net).
### Logs instance
Your Logs instance is likely to be in the `us-central1` region. Its address would be in the form of [https://logs-prod-us-central1.grafana.net](https://logs-prod-us-central1.grafana.net).
### Using mimirtool
With your instance ID, URL, and API key you're now ready to upload your rules to your metrics instance. Use the following commands and files as a reference.
Below is an example alert and rule definition YAML file. Take note of the namespace key which replaces the concept of "files" in this context given each instance only supports 1 configuration file.
```yaml
# first_rules.yml
namespace: 'first_rules'
groups:
- name: 'shopping_service_rules_and_alerts'
rules:
- alert: 'PromScrapeFailed'
annotations:
message: 'Prometheus failed to scrape a target {{ $labels.job }} / {{ $labels.instance }}'
expr: 'up != 1'
for: '1m'
labels:
'severity': 'critical'
- record: 'job:up:sum'
expr: 'sum by(job) (up)'
```
Although both recording and alerting rules are defined under the key `rules` the difference between a rule and and alert is _generally_ (as there are others) whenever the key `record` or `alert` is defined.
With this file, you can run the following commands to upload your rules file in your Metrics or Logs instance. Keep in mind that these are example commands for your Metrics instance, and they use placeholders and command line flags. Follow a similar pattern for your Logs instances by switching the address to the correct one. The examples also assume that files are located in the same directory.
```bash
$ mimirtool rules load first_rules.yml \
--address=https://prometheus-us-central1.grafana.net \
--id=<yourID> \
--key=<yourKey>
```
Next, confirm that the rules were uploaded correctly by running:
```bash
$ mimirtool rules list \
--address=https://prometheus-us-central1.grafana.net \
--id=<yourID> \
--key=<yourKey>
```
Output is a list that shows you all the namespaces and rule groups for your instance ID:
```bash
Namespace | Rule Group
first_rules | shopping_service_rules_and_alerts
```
You can also print the rules:
```bash
$ mimirtool rules print \
--address=https://prometheus-us-central1.grafana.net \
--id=<yourID> \
--key=<yourKey>
```
Output from the print command should look like this:
```yaml
first_rules:
- name: shopping_service_rules_and_alerts
interval: 0s
rules:
- alert: PromScrapeFailed
expr: up != 1
for: 1m
labels:
severity: critical
annotations:
message: Prometheus failed to scrape a target {{ $labels.job }} / {{ $labels.instance }}
- record: job:up:sum
expr: sum by(job) (up)
```
## Upload Alertmanager configuration to your Grafana Cloud Alerts instance
To receive alerts you'll need to upload your Alertmanager configuration to your Grafana Cloud Alerts instance. Similar to the previous step, you'll need the corresponding instance ID, URL and API key. These should be part of /orgs/​`<yourOrgName>`/.
Your Alerts instance is likely to be in the `us-central1` region. Its address would be in the form of [https://alertmanager-us-central1.grafana.net](https://alertmanager-us-central1.grafana.net).
### Using mimirtool
With your instance ID, URL, and API key you're now ready to upload your Alertmanager configuration to your Alerts instance. Use the following commands and files as a reference.
Ultimately, you'll need to [write your own](https://prometheus.io/docs/alerting/latest/configuration/) or adapt an [example config file](https://github.com/prometheus/alertmanager/blob/master/doc/examples/simple.yml) for alerts to be delivered.
Below is an example Alertmanager configuration. Please take that this not a working configuration, your alerts won't be delivered with the following configuration but your Alertmanager UI will be accessible.
```yaml
# alertmanager.yml
global:
smtp_smarthost: 'localhost:25'
smtp_from: 'youraddress@example.org'
route:
receiver: example-email
receivers:
- name: example-email
email_configs:
- to: 'youraddress@example.org'
```
With this file, you can run the following commands to upload your Alertmanager configuration in your Alerts instance.
```bash
$ mimirtool alertmanager load alertmanager.yml \
--address=https://alertmanager-us-central1.grafana.net \
--id=<yourID> \
--key=<yourKey>
```
Then, confirm that the rules were uploaded correctly by running:
```bash
$ mimirtool alertmanager get \
--address=https://alertmanager-us-central1.grafana.net \
--id=<yourID> \
--key=<yourKey>
```
You should see output similar to the following:
```bash
global:
smtp_smarthost: 'localhost:25'
smtp_from: 'youraddress@example.org'
route:
receiver: example-email
receivers:
- name: example-email
email_configs:
- to: 'youraddress@example.org'
```
Finally, you can delete the configuration with:
```bash
$ mimirtool alertmanager delete \
--address=https://alertmanager-us-central1.grafana.net \
--id=<yourID> \
--key=<yourKey>
```
### UI access
After you upload a working Alertmanager configuration file, you can access the Alertmanager UI at: https://alertmanager-us-central1.grafana.net/alertmanager.
@@ -0,0 +1,76 @@
---
aliases:
- /docs/grafana-cloud/alerts/grafana-cloud-alerting/
- /docs/grafana-cloud/how-do-i/grafana-cloud-alerting/
- /docs/grafana-cloud/legacy-alerting/grafana-cloud-alerting/
description: Grafana Cloud Alerting
title: Grafana Cloud Alerting
weight: 100
---
# Grafana Cloud Alerting
Grafana Cloud Alerting allows you to create and manage all of your Prometheus-style alerting rules, for both Prometheus metrics and Loki log data. With this feature, you don't need to leave Grafana, upload or edit configuration files, or install additional tools.
![Grafana Cloud Alerting](/static/img/docs/grafana-cloud/grafana-cloud-alerting.png)
## Permissions
All members of an organization that have alerts set up can view alerts in Grafana Cloud Alerting. This includes everyone with a Viewer, Editor, or Admin role.
Users with the organization Admin role can also create, edit, or delete alerts.
## Data sources
Grafana Cloud Alerting supports rule management across multiple data sources, for both metrics and logs, across all of the stacks in your org. If you have more than one Prometheus or Loki data source, there will be a dropdown at the top for you to select the data source to configure rules.
{{% admonition type="note" %}}
Pay attention to which data source you select. Cloud alerts are tied to a specific data source. For example, if you have a Loki data source selected you will not be able to create an alert based on a Prometheus data source.
![Cloud Alerting Data Source](/static/img/docs/grafana-cloud/grafana-cloud-alerting-data-source.png)
{{% /admonition %}}
## Alerts and recording rules
Prometheus supports two types of rules:
- [Recording rules](https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/) - Recording rules allow you to execute expressions or queries, by saving them off as a stored rule instead.
- [Alerting rules](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/) - Alerting rules allow you to define alert conditions and to route those notifications to an external service. An alert fires if metrics meet criteria defined in the alerting rule.
Both of these rules are configurable from the Grafana Cloud Alerting interface and configured in the same way.
## Alert states
Alert states are identical to the standard format found in Prometheus rule configurations. In Grafana Cloud Alerting, each individual alert is highlighted by its state to more clearly distinguish between alerts.
- **Firing -** Alerts that have been active for longer than the configured threshold. Alerts are highlighted in red and tagged with a red `firing` label.
- **Pending -** Alerts that have been active for less than the configured threshold. Alerts are highlighted in orange.
- **Inactive -** Alerts that are neither firing nor pending. Alerts are highlighted in green.
## Notifications
The **Notifications** tab is where you can view all current notifications and sort them by various states, receivers, and labels.
![Grafana Cloud Alerting Notifications](/static/img/docs/grafana-cloud/grafana-cloud-alerting-notifications.png)
## Limits
There is a limit on how many rules can be created in a rule group. There is also a limit on how many rule groups can be created.
You can create:
- 20 rules per rule group
- 35 rule groups
> It is possible to increase these limits. Please contact customer support for further information.
If you exceed the limits, you will encounter an error similar to this:
```bash
ERROR[0000] requests failed fields.msg="request failed with response body
per-user rules per rule group limit (limit: 20 actual: 22) exceeded\n"
status="400 Bad Request"
ERROR[0000] unable to load rule group error="failed request to the cortex api"
group=limit_rules_per_group namespace=test
```
To increase the number of rules or rule groups you can configure, contact support to upgrade your account.
@@ -0,0 +1,55 @@
---
aliases:
- /docs/grafana-cloud/alerts/grafana-cloud-alerting/alertmanager/
- /docs/grafana-cloud/how-do-i/grafana-cloud-alerting/alertmanager/
- /docs/grafana-cloud/legacy-alerting/grafana-cloud-alerting/alertmanager/
description: Alertmanager
title: Alertmanager
weight: 500
---
# Alertmanager
Grafana Cloud Alerting allows you to edit and view configuration for your Alertmanager directly inside of Grafana. See the official [Alertmanager documentation](https://prometheus.io/docs/alerting/latest/configuration/) to learn how to configure.
{{% admonition type="note" %}}
Only organization Admins can view or update Alertmanger configurations.
{{% /admonition %}}
## Edit a config for Grafana Cloud Alerting
1. In Grafana, hover your cursor over the **Grafana Cloud Alerting** icon and then click **Alertmanager**.
1. If you have more than one Alertmanager source, there will be a dropdown at the top for you to select the data source to edit configurations.
1. Currently active configuration for the Alertmanager will be displayed. Click the **Edit** button to enter edit mode and start making changes. Click "Save and finish editing" once done to persist your changes.
1. Alternatively, updates to the Alertmanager configurations made using the mimirtool will also sync and appear here.
## Use the Grafana Labs-supplied SMTP option to configure email notifications
Grafana Cloud users who do not have an SMTP server available for sending alert emails may use Grafana-Labs supplied SMTP relay (available at `smtprelay:2525`).
1. In Grafana, hover your cursor over the **Grafana Cloud Alerting** icon and then click **Alertmanager**.
1. If you have more than one Alertmanager source, there will be a dropdown at the top for you to select the data source to edit configurations.
1. Find info box with heading **Send alert email notifications from Grafana Cloud** at the top
1. Enter desired email address into the **email address** field
1. Click **Update configuration** button. Alertmanager config will be updated with grafana SMTP relay settings and an "email" receiver that will send to the specified email address.
{{% admonition type="note" %}}
Following these steps will overwrite any custom global SMTP settings that you might have. Default route configuration will send all notifications to the "email" receiver. If you have already customized routes, they will not be updated and you will have to configure "email" receiver on the appropriate route.
{{% /admonition %}}
Use these settings in your Grafana Cloud Alerting YAML, if you do not find them already set. Most important is the `smtp_require_tls: false` line. If this is not set properly, alert emails will not be received. If you use mimirtool to configure alertmanager, by default this will be set to `true`, which will cause problems.
```yaml
global:
smtp_from: noreply@grafana.net
smtp_smarthost: smtprelay:2525
smtp_require_tls: false
```
## Troubleshooting Alertmanager failures
Configuration errors can cause Alertmanager notification failures, e.g. a typo in an email address recipient or an expired token for a webhook. Grafana Cloud provisions a Loki datasource `grafanacloud-<stack_slug>-usage-insights` which can be used to display select notification errors with a query similar to the example below. The `instance_type` label of `alerts` is what selects the Grafana Cloud Alertmanager logs.
```sql
{instance_type="alerts"} | logfmt | level="warn"
```
@@ -0,0 +1,59 @@
---
aliases:
- /docs/grafana-cloud/alerts/grafana-cloud-alerting/create-edit-rules/
- /docs/grafana-cloud/how-do-i/grafana-cloud-alerting/create-edit-rules/
- /docs/grafana-cloud/legacy-alerting/grafana-cloud-alerting/create-edit-rules/
description: Create and edit alert rules
title: Create and edit alert rules
weight: 200
---
# Create and edit alert rules
Creating alerts in Grafana Cloud differs from creating alerts directly with Prometheus or Loki. While the rule format is the same, everything is done in the Grafana Cloud Alerting interface, rather than with configuration files.
{{% admonition type="note" %}}
Only organization Admins can create or edit alert rules.
{{% /admonition %}}
## Create an alert rule
1. In Grafana, hover your cursor over the **Grafana Cloud Alerting** icon and then click **Alerts and rules**.
1. If you have more than one Prometheus or Loki data source, there will be a dropdown at the top for you to select the data source to create or edit rules.
1. Click **Edit rules**.
1. Click **Add rule**.
Grafana creates a new rule with placeholders.
```
alert: ""
expr: ""
```
Enter text according to regular Prometheus rule configuration guidelines:
- [Recording rules](https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/)
- [Alerting rules](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/)
{{% admonition type="note" %}}
Grafana Cloud Alerting does not support comments.
{{% /admonition %}}
When you are finished, click **Save**. You can then repeat the process to create more rules or click **Finish editing** to return to the rules list.
## Edit an alert rule
1. In Grafana, hover your cursor over the **Grafana Cloud Alerting** icon and then click **Alerts and rules**.
1. If you have more than one Prometheus or Loki data source, there will be a dropdown at the top for you to select the data source to create or edit rules.
1. Click **Edit rules**.
1. Scroll down to the rule that you want to edit and then click **Edit**.
1. Make any necessary changes to the rule text and then click **Save**.
1. Click **Finish editing** to return to the rules list.
## Delete an alert rule
1. In Grafana, hover your cursor over the **Grafana Cloud Alerting** icon and then click **Alerts and rules**.
1. If you have more than one Prometheus or Loki data source, there will be a dropdown at the top for you to select the data source to create or edit rules.
1. Click **Edit rules**.
1. Scroll down to the rule that you want to edit and then click **Delete**.
1. Click **Finish editing** to return to the rules list.
@@ -0,0 +1,50 @@
---
aliases:
- /docs/grafana-cloud/alerts/grafana-cloud-alerting/namespaces-and-groups/
- /docs/grafana-cloud/how-do-i/grafana-cloud-alerting/namespaces-and-groups/
- /docs/grafana-cloud/legacy-alerting/grafana-cloud-alerting/namespaces-and-groups/
description: Namespaces and rule groups
title: Namespaces and rule groups
weight: 400
---
# Namespaces and rule groups
By default, all alerting and recording rules created in Grafana Cloud Alerting will default to a single namespace and a single rule group.
## Managing namespaces
While Grafana Cloud Alerting does support viewing multiple namespaces that have been added through the mimirtool, it is currently not possible to add new namespaces or to rename the existing ones.
## Managing rule groups
Rule groups can be managed directly within the Grafana Cloud Alerting interface or through the mimirtool, similar to managing namespaces.
{{% admonition type="note" %}}
By default, Grafana Cloud limits the number of rule groups to 20, with a limit of up to 15 rules per group. If you wish to increase the default limits, please [open a support ticket](/profile/org#support) or reach out to your account manager.
{{% /admonition %}}
### Create a new rule group:
1. In Grafana, hover your cursor over the **Grafana Cloud Alerting** icon and then click **Alerts and rules**.
2. If you have more than one Prometheus or Loki data source, there will be a dropdown at the top for you to select the data source to create or edit rules.
3. Click **Create new rule group**.
4. Enter text to name your new rule group.
5. Enter text for the new rule in your new rule group, according to regular Prometheus rule configuration guidelines:
- [Recording rules](https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/)
- [Alerting rules](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/)
6. When you are finished naming your new rule group and adding new rule details, click **Save**.
{{% admonition type="note" %}}
In order to create a new rule group, you must also create a new rule for it.
{{% /admonition %}}
### Update a rule group
Existing rule groups can be renamed by selecting the **pencil** icon next to the rule group name.
### Delete a rule group
Rule groups will be automatically deleted once the all rules within a group are deleted.
@@ -0,0 +1,31 @@
---
aliases:
- /docs/grafana-cloud/alerts/grafana-cloud-alerting/silences/
- /docs/grafana-cloud/how-do-i/grafana-cloud-alerting/silences/
- /docs/grafana-cloud/legacy-alerting/grafana-cloud-alerting/silences/
description: Silences
title: Silences
weight: 600
---
# Silences
Grafana Cloud Alerting allows you to manage silences for your alertmanager notifications directly inside of Grafana. This applies to alerting rules created for both Prometheus metrics and Loki logs.
## Create a silence
1. In Grafana, hover your cursor over the **Grafana Cloud Alerting** icon and then click **Silences**.
2. Click **New silence**.
3. Enter a date in **Start of silence** to indicate when the silence should go into effect.
4. Enter a date in **End of silence** to indicate when the silence should expire.
5. Enter one or more matchers by filling out the **Name** and **Value** fields. Matchers determine which rules the silence will apply to.
6. Enter the name of the owner in **Creator**.
7. Enter a **Comment**.
8. To view which rules will be affected by your silence, click **Preview alerts**.
9. Otherwise, when you are finished, click **Create**
## Update an existing silence
You can always update an existing silence by clicking the **Edit silence** button under the silence.
It is also possible to expire a silence, on-demand, by clicking the **Expire silence** button under the silence. This will override the original scheduled expiration date of the silence.
@@ -0,0 +1,42 @@
---
aliases:
- /docs/grafana-cloud/alerts/grafana-cloud-alerting/view-filter-rules/
- /docs/grafana-cloud/how-do-i/grafana-cloud-alerting/view-filter-alerts/
- /docs/grafana-cloud/legacy-alerting/grafana-cloud-alerting/view-filter-rules/
description: View and filter alert rules
title: View and filter alert rules
weight: 300
---
# View and filter alert rules
Grafana Cloud Alerting displays a list of all recording and alerting rules assigned to a selected data source in the Alerts and rules tab.
All members of an organization that have access to a particular data source can view the list of rules and filter or reorder their view.
## View alert rules
1. Hover your cursor over the **Grafana Cloud Alerting** icon (alarm bell with Prometheus logo) and then click **Alerts and rules**.
1. In the list at the top of the tab, select the data source for which you want to view rules.
Grafana displays rules according to rule groups. If your instance has added namespaces and alert groups, then they will be ordered alphabetically. Otherwise, you will have one namespace called `default` and an alert group called `rules`.
If an alert is firing, then click the down carrot arrow to see additional information. The Label and annotations section appears.
## Filter your alert rule view
You can control which alerts you see and in what order they appear several ways. Combine different filters to personalize your view so that you can quickly find the information that you need.
- **Filter by alert state -** Click the toggles to show or hide alerts in different states. Turn off the toggle to hide alerts matching the state.
- **Filter by rule type -** Click the toggles to show or hide alerting rules or recording rules.
- **View options -** Click the toggle to show or hide the Prometheus annotations shown in the Labels and annotations section.
- **Rule sorting -** Click an option to sort alert rules within each rule group.
- **None -** No special sort is applied and sorts as if in a file, ordered according to the editing list order.
- **A-Z -** Sorts rules alphabetically according to the rule name.
- **Alert state -** Sorts rules according to the alert state (Firing, Pending, or Inactive).
## View alert in Explore
Click **View in Explore** or click the `expr` link to open the `expr` in [Explore](/docs/grafana/latest/explore/).
> **Note:** Only users with Admin or Editor roles in an organization can use the Explore feature unless the viewers can edit.
@@ -41,10 +41,30 @@ Guidance on migrating a plugin to React can be found in our [migration guide]({{
## Apps
### [BelugaCDN](https://grafana.com/grafana/plugins/belugacdn-app)
Latest Version: 1.2.1 | Signature: Commercial | Last Updated: 2023
> [Migration issue](https://github.com/belugacdn/grafana-belugacdn-app/issues/7) has been raised.
> **Warning:** Lack of recent activity in the [project repository](https://github.com/belugacdn/grafana-belugacdn-app) in the past 7 years suggests project _may_ not be actively maintained.
### [Bosun](https://grafana.com/grafana/plugins/bosun-app)
Latest Version: 0.0.29 | Signature: Community | Last Updated: 2023
> [Migration issue](https://github.com/bosun-monitor/bosun-grafana-app/issues/63) has been raised.
### [Cloudflare Grafana App](https://grafana.com/grafana/plugins/cloudflare-app/)
Latest Version: 0.2.4 | Signature: Commercial | Last Updated: 2022
### [GLPI](https://grafana.com/grafana/plugins/ddurieux-glpi-app)
Latest Version: 1.3.1 | Signature: Community | Last Updated: 2021
> [Migration issue](https://github.com/ddurieux/glpi_app_grafana/issues/96) has been raised.
### [DevOpsProdigy KubeGraf](https://grafana.com/grafana/plugins/devopsprodigy-kubegraf-app/)
Latest Version: 1.5.2 | Signature: Community | Last Updated: 2021
@@ -53,10 +73,46 @@ Latest Version: 1.5.2 | Signature: Community | Last Updated: 2021
> **Migration available - potential alternative:** Grafana Cloud includes a [Kubernetes integration](https://grafana.com/solutions/kubernetes/).
### [AWS IoT TwinMaker App](https://grafana.com/grafana/plugins/grafana-iot-twinmaker-app)
Latest Version: 1.6.2 | Signature: Commercial | Last Updated: 2023
> **Note:** Plugin should continue to work even if Angular is disabled, and a full removal of Angular related code is planned.
### [Kentik Connect Pro](https://grafana.com/grafana/plugins/kentik-connect-app/)
Latest Version: 1.6.2 | Signature: Commercial | Last Updated: 2023
### [Moogsoft AIOps](https://grafana.com/grafana/plugins/moogsoft-aiops-app)
Latest Version: 8.0.2 | Signature: Commercial | Last Updated: 2022
### [OpenNMS Helm](https://grafana.com/grafana/plugins/opennms-helm-app)
Latest Version: 8.0.4 | Signature: Community | Last Updated: 2023
> **Migration available - plugin superseded:** The plugin has effectively been replaced with a [new plugin](https://grafana.com/grafana/plugins/opennms-opennms-app/) based on React.
### [Percona](https://grafana.com/grafana/plugins/percona-percona-app/)
Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021
> **Warning:** [Project repository](https://github.com/percona/grafana-app) was archived on June 12, 2020.
### [Stagemonitor Elasticsearch](https://grafana.com/grafana/plugins/stagemonitor-elasticsearch-app)
Latest Version: 0.83.3 | Signature: Community | Last Updated: 2021
> [Migration issue](https://github.com/stagemonitor/stagemonitor-grafana-elasticsearch/issues/1) has been raised.
> **Warning:** Lack of recent activity in the [project repository](https://github.com/stagemonitor/stagemonitor-grafana-elasticsearch) in the past 4 years suggests project _may_ not be actively maintained.
### [Voxter VoIP Platform Metrics](https://grafana.com/grafana/plugins/voxter-app)
Latest Version: 0.0.2 | Signature: Community | Last Updated: 2021
> **Warning:** Lack of recent activity in the [project repository](https://github.com/raintank/voxter-app) in the past 3 years suggests project _may_ not be actively maintained.
## Datasources
### [Druid](https://grafana.com/grafana/plugins/abhisant-druid-datasource/)
@@ -103,10 +159,6 @@ Latest Version: 2.2.3 | Signature: Community | Last Updated: 2022
> **Warning:** Lack of recent activity in the [project repository](https://github.com/chaos-mesh/datasource) in the past year suggests project _may_ not be actively maintained.
### [Cognite Data Fusion](https://grafana.com/grafana/plugins/cognitedata-datasource/)
Latest Version: 3.0.0 | Signature: Commercial | Last Updated: 2023
### [DeviceHive](https://grafana.com/grafana/plugins/devicehive-devicehive-datasource/)
Latest Version: 2.0.2 | Signature: Community | Last Updated: 2021
@@ -185,12 +237,6 @@ Latest Version: 1.4.2 | Signature: Grafana | Last Updated: 2021
> **Note:** If you're looking for an example of a data source plugin to start from, refer to [grafana-starter-datasource-backend](https://github.com/grafana/grafana-starter-datasource-backend).
### [Splunk](https://grafana.com/grafana/plugins/grafana-splunk-datasource/)
Latest Version: 4.1.6 | Signature: Grafana | Last Updated: 2023
> **Note:** Removal of any angular dependency is on the near term roadmap.
### [Strava](https://grafana.com/grafana/plugins/grafana-strava-datasource/)
Latest Version: 1.5.1 | Signature: Grafana | Last Updated: 2022
@@ -203,12 +249,6 @@ Latest Version: 1.0.3 | Signature: Community | Last Updated: 2021
> **Warning:** Lack of recent activity in the [project repository](https://github.com/GridProtectionAlliance/openHistorian-grafana/) in the past 2 years suggests project _may_ not be actively maintained.
### [OSIsoft-PI](https://grafana.com/grafana/plugins/gridprotectionalliance-osisoftpi-datasource/)
Latest Version: 3.1.0 | Signature: Community | Last Updated: 2023
> **Note:** Fixed in 4.0.0 which should be published soon - [source](https://github.com/GridProtectionAlliance/osisoftpi-grafana/issues/119#issuecomment-1493566212).
### [Hawkular](https://grafana.com/grafana/plugins/hawkular-datasource/)
Latest Version: 1.1.2 | Signature: Community | Last Updated: 2021
@@ -283,7 +323,7 @@ Latest Version: 3.0.0 | Signature: Commercial | Last Updated: 2023
### [Oracle Cloud Infrastructure Metrics](https://grafana.com/grafana/plugins/oci-metrics-datasource/)
Latest Version: 4.0.0 | Signature: Commercial | Last Updated: 2023
Latest Version: 4.0.1 | Signature: Commercial | Last Updated: 2023
### [Warp 10](https://grafana.com/grafana/plugins/ovh-warp10-datasource/)
@@ -52,6 +52,10 @@ title: 'Alerting Provisioning HTTP API '
### Contact points
**Note:**
Contact point provisioning is for Grafana-managed alerts only.
| Method | URI | Name | Summary |
| ------ | ----------------------------------------- | --------------------------------------------------------- | --------------------------------- |
| DELETE | /api/v1/provisioning/contact-points/{UID} | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. |
@@ -16,59 +16,61 @@ weight: 300
Have you ever wondered what a dashboard is? In the observability world, this term is frequently used, but what exactly does it mean? The concept is borrowed from automobiles, where a dashboard gives drivers access to the controls necessary to operate a vehicle. Similarly, digital dashboards help us comprehend and manage systems. This topic explains how Grafana dashboards function, enabling you to create your own with greater ease.
The following image illustrates a sample Grafana dashboard.
The following image illustrates a sample Grafana dashboard:
{{< figure src="/media/docs/grafana/dashboards-overview/complex-dashboard-example.png" max-width="750px" caption="Example Grafana Dashboard" >}}
{{< figure src="/media/docs/grafana/dashboards-overview/complex-dashboard-example.png" max-width="750px" caption="Example Grafana dashboard" >}}
A Grafana dashboard consists of panels displaying data in beautiful graphs, charts, and other visualizations. These panels are created using components that transform raw data from a data source into visualizations. The process involves passing data through three gates: a plugin, a query, and an optional transformation.
The image below displays all the gates, followed by detailed explanations of their purpose, usage, and significance. The process of creating a visualization starts from the data source and progresses towards a Grafana dashboard.
The image below displays all the gates, followed by detailed explanations of their purpose, usage, and significance.
{{< figure src="/media/docs/grafana/dashboards-overview/dashboard-component-architecture.png" max-width="750px" caption="Dashboard component architecture" >}}
## Data source
## Data sources
A data source refers to any entity that consists of data. It can be a SQL database, Grafana Loki, Grafana Mimir, or a JSON-based API. It can even be a basic CSV file. The first step in creating a dashboard visualization is selecting the data source that contains the data you need.
A data source refers to any entity that consists of data. It can be an SQL database, Grafana Loki, Grafana Mimir, or a JSON-based API. It can even be a basic CSV file. The first step in creating a dashboard visualization is selecting the data source that contains the data you need.
It can be difficult to understand the distinctions between different data sources as each possesses its own structure and requires different query methods. However, to streamline this complexity, Grafana Labs has created plugins that unify data sources into one coherent view.
It can be difficult to understand the distinctions between different data sources as each possesses its own structure and requires different query methods. However, in dashboards, you can see different data sources visualized in one single view, making it easier to understand your data overall.
## Plugins
A Grafana plugin is software that adds new capabilities to Grafana. They come in many types, but right now we will address _data source plugins_. The job of a Grafana data source plugin is to take a query you want answered, retrieve the data from the data source, and reconcile the differences in data models using a unified data structure called a [data frame](/docs/grafana/latest/developers/plugins/data-frames/). The data coming into the plugin from the data source might be many different formats (such as JSON, rows and columns, or CSV), but when it leaves the plugin and moves through the rest of the gates toward a visualization, it is always data frames.
A Grafana plugin is software that adds new capabilities to Grafana. They come in many types, but right now we'll address _data source plugins_. The job of a Grafana data source plugin is to take a query you want answered, retrieve the data from the data source, and reconcile the differences between the data model of the data source and the data model of Grafana dashboards. It does this using a unified data structure called a [data frame](/docs/grafana/latest/developers/plugins/data-frames/).
The data coming into the plugin from the data source might be in many different formats (such as JSON, rows and columns, or CSV), but when it leaves the plugin and moves through the rest of the gates toward a visualization, it's always in data frames.
Currently, Grafana offers a diverse range of 155 data sources that you can use. The most commonly used options are already pre-installed and accessible. Before exploring other options, look for an existing data source that matches your requirements. Grafana constantly updates the list, but if you don't find a suitable data source, you can browse through the [plugin catalog](/grafana/plugins/?type=datasource) or [create a plugin](/tutorials/build-a-data-source-plugin/).
## Query
## Queries
Queries allow for the reduction of data to a specific dataset, providing a more manageable visualization. They help answer questions you have about system and operational processes. For instance, a company with an online store might want to determine the number of customers who add products to their shopping carts. This can be achieved through a query that aggregates access metrics for the shopping cart service, revealing the number of users accessing the service per second.
Queries allow you to reduce the entirety of your data to a specific dataset, providing a more manageable visualization. They help answer questions you have about system and operational processes. For instance, a company with an online store might want to determine the number of customers who add products to their shopping carts. This can be achieved through a query that aggregates access metrics for the shopping cart service, revealing the number of users accessing the service per second.
When working with data sources, it is crucial to recognize that each one has its own distinct query language. For example, Prometheus data sources utilize [PromQL](/blog/2020/02/04/introduction-to-promql-the-prometheus-query-language/), while [LogQL](/docs/loki/latest/logql/) is used for logs, and particular databases employ SQL. A query supports every visualization in Grafana, and a dashboard might feature a range of query languages.
When working with data sources, it's crucial to recognize that each one has its own distinct query language. For example, Prometheus data sources utilize [PromQL](/blog/2020/02/04/introduction-to-promql-the-prometheus-query-language/), while [LogQL](/docs/loki/latest/logql/) is used for logs, and particular databases employ SQL. A query is the foundation of every visualization in Grafana, and a dashboard might use a range of query languages.
The following image shows the Query Editor associated with the Prometheus data source. The `node_cpu_seconds_total` query is written in PromQL and requests just one metric.
The following image shows the Query Editor associated with the Prometheus data source. The `node_cpu_seconds_total` query is written in PromQL and requests just one metric:
{{< figure src="/media/docs/grafana/dashboards-overview/example-query.png" max-width="750px" caption="Example Query" >}}
{{< figure src="/media/docs/grafana/dashboards-overview/example-query.png" max-width="750px" caption="Example query" >}}
## Transforms
## Transformations
When the data format in a visualization doesn’t meet your requirements, you can apply a [transformation]({{< relref "../../panels-visualizations/query-transform-data/transform-data" >}}) that manipulates the data returned by a query. You might not need to transform data when you are first starting out, but they are powerful and worth mentioning.
When the data format in a visualization doesn’t meet your requirements, you can apply a [transformation]({{< relref "../../panels-visualizations/query-transform-data/transform-data" >}}) that manipulates the data returned by a query. You might not need to transform data when you're first starting out, but they're powerful and worth mentioning.
Transforming data is useful in the following kinds of situations:
- You want to combine two fields together, for example, concatenating `Given Name` and `Family Name` into a `Full Name` field
- You have CSV data (all text), and you want to convert a field types (such as parsing a date or a number out of a string)
- You want to filter, join, merge, or perform other SQL-like operations that might not be supported by the underlying data source or query language
- You want to combine two fields together, for example, concatenating `Given Name` and `Family Name` into a `Full Name` field.
- You have CSV data (all text), and you want to convert a field type (such as parsing a date or a number out of a string).
- You want to filter, join, merge, or perform other SQL-like operations that might not be supported by the underlying data source or query language.
Transforms are located next to the **Query** tab in the edit dialog for a panel. Select the transform you want, and define the transform. The following image shows that you can have as many transforms as you want, just like queries. For example, you can chain together a series of transforms that make a change to a data type, filter results, organize columns, and sort the result into one data pipeline. Every time the dashboard is refreshed, the transform applies to the latest data from the data source.
Transformations are located in the **Transform** tab in the edit dialog for a panel. Select the transformation you want, and define the transformation. The following image shows that you can have as many transformations as you want, just like queries. For example, you can chain together a series of transformations that make a change to a data type, filter results, organize columns, and sort the result into one data pipeline. Every time the dashboard is refreshed, the transformation applies to the latest data from the data source.
The following image shows the transform dialog.
The following image shows the transformation dialog:
{{< figure src="/media/docs/grafana/dashboards-overview/example-transform-chain.png" max-width="750px" caption="Example chain of transforms" >}}
{{< figure src="/media/docs/grafana/dashboards-overview/example-transform-chain.png" max-width="750px" caption="Example chain of transformations" >}}
## Panel
## Panels
After the data is sourced, queried, and transformed, it passes to a panel which is the final gate in the journey to a Grafana visualization. A panel is a container that displays the visualization and provides you with various controls to manipulate it. The panel configuration is where you specify how you want to see the data. For example, you use a drop-down menu in the top-right of the panel to specify the type of visualization you want to see, such as a bar chart, pie chart, or histogram.
After the data is sourced, queried, and transformed, it passes to a panel, which is the final gate in the journey to a Grafana visualization. A panel is a container that displays the visualization and provides you with various controls to manipulate it. The panel configuration is where you specify how you want to see the data. For example, you use a drop-down menu in the top-right of the panel to specify the type of visualization you want to see, such as a bar chart, pie chart, or histogram.
The panel options enable you to customize many aspects of the visualization and change them based on the visualization you select. Panels also contain queries that specify the data the panel is visualizing.
The panel options let you customize many aspects of the visualization and the options differ based on which visualization you select. Panels also contain queries that specify the data the panel is visualizing.
The following image shows a table panel being edited, the panel settings showing the query at the bottom, and the panel options on the right. In this image, you can see how the data source, plugin, query, and panel all come together.
@@ -78,9 +80,9 @@ Selecting the best visualization depends on the data and how you want it present
## Conclusion
With the data source, plugin, query, transform, and panel model in mind, you can now see right through any Grafana dashboard you encounter, and imagine how to build your own.
With the data source, plugin, query, transformation, and panel model in mind, you can now see right through any Grafana dashboard you encounter, and imagine how to build your own.
Building a Grafana dashboard is a process that starts with determining your dashboard requirements and identifying which data sources support those requirements. If you want to integrate a specialized database with a Grafana dashboard, you must ensure the correct plugin is installed so that you can create a data source to use with that plugin.
Building a Grafana dashboard is a process that starts with determining your dashboard requirements and identifying which data sources support those requirements. If you want to integrate a specialized database with a Grafana dashboard, you must ensure the correct plugin is installed so that you can add a data source to use with that plugin.
And with the data source identified and the plugin installed, you can write your query, transform the data, and format the visualization to meet your needs.
@@ -113,6 +113,7 @@ Experimental features might be changed or removed without prior notice.
| `extraThemes` | Enables extra themes |
| `lokiPredefinedOperations` | Adds predefined query operations to Loki query editor |
| `pluginsFrontendSandbox` | Enables the plugins frontend sandbox |
| `cloudWatchLogsMonacoEditor` | Enables the Monaco editor for CloudWatch Logs queries |
## Development feature toggles
@@ -5,6 +5,8 @@ headless: true
[//]: # 'This file documents the Search query type for the Tempo data source. It is available as a public preview.'
[//]: # 'This shared file is included in these locations:'
[//]: # '/grafana/docs/sources/datasources/tempo/query-editor/index.md'
[//]: # '/website/docs/grfana-cloud/data-configuration/traces/traces-query-editor.md'
[//]: #
[//]: # 'If you make changes to this file, verify that the meaning and content are not changed in any place where the file is included.'
[//]: # 'Any links should be fully qualified and not relative: /docs/grafana/ instead of ../grafana/.'
@@ -38,6 +38,7 @@ describe('Variables - Datasource', () => {
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('gdev-slow-prometheus').click();
// Assert it was rendered
e2e().get('.markdown-html').should('include.text', 'VariableUnderTest: gdev-slow-prometheus');
e2e().get('.markdown-html').should('include.text', 'VariableUnderTest: gdev-slow-prometheus-uid');
e2e().get('.markdown-html').should('include.text', 'VariableUnderTestText: gdev-slow-prometheus');
});
});
+7 -7
View File
@@ -88,7 +88,7 @@
"@babel/plugin-transform-typescript": "7.22.3",
"@babel/preset-env": "7.22.4",
"@babel/preset-react": "7.22.3",
"@babel/preset-typescript": "7.21.5",
"@babel/preset-typescript": "7.22.5",
"@babel/runtime": "7.22.3",
"@betterer/betterer": "5.4.0",
"@betterer/cli": "5.4.0",
@@ -174,7 +174,7 @@
"chance": "^1.0.10",
"codeowners": "^5.1.1",
"copy-webpack-plugin": "11.0.0",
"css-loader": "6.7.3",
"css-loader": "6.8.1",
"css-minimizer-webpack-plugin": "4.2.2",
"cypress": "9.5.1",
"esbuild": "0.17.19",
@@ -255,13 +255,13 @@
"@grafana/data": "workspace:*",
"@grafana/e2e-selectors": "workspace:*",
"@grafana/experimental": "1.4.2",
"@grafana/faro-core": "1.0.2",
"@grafana/faro-web-sdk": "1.0.2",
"@grafana/faro-core": "1.1.0",
"@grafana/faro-web-sdk": "1.1.0",
"@grafana/google-sdk": "0.1.1",
"@grafana/lezer-logql": "0.1.5",
"@grafana/monaco-logql": "^0.0.7",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^0.14.0",
"@grafana/scenes": "^0.15.0",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"@kusto/monaco-kusto": "^7.4.0",
@@ -314,12 +314,12 @@
"combokeys": "^3.0.0",
"comlink": "4.4.1",
"common-tags": "1.8.2",
"core-js": "3.30.2",
"core-js": "3.31.0",
"d3": "7.8.2",
"d3-force": "3.0.0",
"d3-scale-chromatic": "3.0.0",
"dangerously-set-html-content": "1.0.9",
"date-fns": "2.29.3",
"date-fns": "2.30.0",
"debounce-promise": "3.1.2",
"emotion": "11.0.0",
"eventemitter3": "5.0.0",
+1 -1
View File
@@ -40,7 +40,7 @@
"@types/d3-interpolate": "^3.0.0",
"@types/string-hash": "1.1.1",
"d3-interpolate": "3.0.1",
"date-fns": "2.29.3",
"date-fns": "2.30.0",
"dompurify": "^2.4.3",
"eventemitter3": "5.0.0",
"fast_array_intersect": "1.1.0",
@@ -100,4 +100,5 @@ export interface FeatureToggles {
lokiPredefinedOperations?: boolean;
pluginsFrontendSandbox?: boolean;
sqlDatasourceDatabaseSelection?: boolean;
cloudWatchLogsMonacoEditor?: boolean;
}
@@ -114,7 +114,8 @@ export const Pages = {
newButton: 'Variable editor New variable button',
table: 'Variable editor Table',
tableRowNameFields: (variableName: string) => `Variable editor Table Name field ${variableName}`,
tableRowDefinitionFields: (variableName: string) => `Variable editor Table Definition field ${variableName}`,
tableRowDescriptionFields: (variableName: string) =>
`Variable editor Table Description field ${variableName}`,
tableRowArrowUpButtons: (variableName: string) => `Variable editor Table ArrowUp button ${variableName}`,
tableRowArrowDownButtons: (variableName: string) => `Variable editor Table ArrowDown button ${variableName}`,
tableRowDuplicateButtons: (variableName: string) => `Variable editor Table Duplicate button ${variableName}`,
+1 -1
View File
@@ -39,7 +39,7 @@
"dependencies": {
"@grafana/data": "10.1.0-pre",
"@grafana/e2e-selectors": "10.1.0-pre",
"@grafana/faro-web-sdk": "1.0.2",
"@grafana/faro-web-sdk": "1.1.0",
"@grafana/ui": "10.1.0-pre",
"history": "4.10.1",
"lodash": "4.17.21",
@@ -1,16 +1,14 @@
import { faro, LogLevel } from '@grafana/faro-web-sdk';
import { faro, LogLevel, LogContext } from '@grafana/faro-web-sdk';
import { config } from '../config';
export { LogLevel };
type Contexts = Record<string, Record<string, number | string | Record<string, string | number>>>;
/**
* Log a message at INFO level
* @public
*/
export function logInfo(message: string, contexts?: Contexts) {
export function logInfo(message: string, contexts?: LogContext) {
if (config.grafanaJavascriptAgent.enabled) {
faro.api.pushLog([message], {
level: LogLevel.INFO,
@@ -24,7 +22,7 @@ export function logInfo(message: string, contexts?: Contexts) {
*
* @public
*/
export function logWarning(message: string, contexts?: Contexts) {
export function logWarning(message: string, contexts?: LogContext) {
if (config.grafanaJavascriptAgent.enabled) {
faro.api.pushLog([message], {
level: LogLevel.WARN,
@@ -38,7 +36,7 @@ export function logWarning(message: string, contexts?: Contexts) {
*
* @public
*/
export function logDebug(message: string, contexts?: Contexts) {
export function logDebug(message: string, contexts?: LogContext) {
if (config.grafanaJavascriptAgent.enabled) {
faro.api.pushLog([message], {
level: LogLevel.DEBUG,
@@ -52,7 +50,7 @@ export function logDebug(message: string, contexts?: Contexts) {
*
* @public
*/
export function logError(err: Error, contexts?: Contexts) {
export function logError(err: Error, contexts?: LogContext) {
if (config.grafanaJavascriptAgent.enabled) {
faro.api.pushError(err);
}
+1
View File
@@ -41,6 +41,7 @@
"@rollup/plugin-json": "5.0.1",
"@rollup/plugin-node-resolve": "15.1.0",
"esbuild": "0.17.19",
"glob": "^10.2.7",
"rimraf": "4.4.0",
"rollup": "2.79.1",
"rollup-plugin-dts": "^5.0.0",
+18 -1
View File
@@ -1,4 +1,6 @@
import resolve from '@rollup/plugin-node-resolve';
import glob from 'glob';
import { fileURLToPath } from 'node:url';
import path from 'path';
import dts from 'rollup-plugin-dts';
import esbuild from 'rollup-plugin-esbuild';
@@ -27,11 +29,26 @@ export default [
],
},
{
input: './compiled/index.d.ts',
input: './dist/esm/index.d.ts',
plugins: [dts()],
output: {
file: pkg.publishConfig.types,
format: 'es',
},
},
{
input: Object.fromEntries(
glob
.sync('src/raw/composable/**/*.ts')
.map((file) => [
path.relative('src', file.slice(0, file.length - path.extname(file).length)),
fileURLToPath(new URL(file, import.meta.url)),
])
),
plugins: [resolve(), esbuild()],
output: {
format: 'esm',
dir: path.dirname(pkg.publishConfig.module),
},
},
];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"declarationDir": "./compiled",
"declarationDir": "./dist/esm",
"emitDeclarationOnly": true,
"isolatedModules": true,
"rootDirs": ["."]
+4 -4
View File
@@ -51,7 +51,7 @@
"@emotion/react": "11.10.6",
"@grafana/data": "10.1.0-pre",
"@grafana/e2e-selectors": "10.1.0-pre",
"@grafana/faro-web-sdk": "1.0.2",
"@grafana/faro-web-sdk": "1.1.0",
"@grafana/schema": "10.1.0-pre",
"@leeoniya/ufuzzy": "1.0.6",
"@monaco-editor/react": "4.5.1",
@@ -66,9 +66,9 @@
"ansicolor": "1.1.100",
"calculate-size": "1.1.1",
"classnames": "2.3.2",
"core-js": "3.30.2",
"core-js": "3.31.0",
"d3": "7.8.2",
"date-fns": "2.29.3",
"date-fns": "2.30.0",
"hoist-non-react-statics": "3.3.2",
"i18next": "^22.0.0",
"immutable": "4.2.4",
@@ -165,7 +165,7 @@
"@types/tinycolor2": "1.4.3",
"@types/uuid": "9.0.1",
"common-tags": "1.8.2",
"css-loader": "6.7.3",
"css-loader": "6.8.1",
"csstype": "3.1.1",
"esbuild": "0.17.19",
"expose-loader": "4.0.0",
@@ -1,10 +1,23 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { ConfirmModal } from './ConfirmModal';
describe('ConfirmModal', () => {
const mockOnConfirm = jest.fn();
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
jest.useFakeTimers();
user = userEvent.setup({ delay: null });
});
afterEach(() => {
jest.useRealTimers();
});
it('should render correct title, body, dismiss-, alternative- and confirm-text', () => {
render(
<ConfirmModal
@@ -76,7 +89,7 @@ describe('ConfirmModal', () => {
dismissText="Dismiss Text"
isOpen={true}
confirmationText="My confirmation text"
onConfirm={() => {}}
onConfirm={mockOnConfirm}
onDismiss={() => {}}
onAlternative={() => {}}
/>
@@ -85,7 +98,49 @@ describe('ConfirmModal', () => {
expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeDisabled();
await userEvent.type(screen.getByPlaceholderText('Type "My confirmation text" to confirm'), 'mY CoNfIrMaTiOn TeXt');
expect(screen.getByRole('button', { name: 'Please Confirm' })).not.toBeDisabled();
await user.type(screen.getByPlaceholderText('Type "My confirmation text" to confirm'), 'mY CoNfIrMaTiOn TeXt');
expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Please Confirm' }));
expect(mockOnConfirm).toHaveBeenCalled();
});
it('returning a promise in the onConfirm callback disables the button whilst the callback is in progress', async () => {
mockOnConfirm.mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve('');
}, 1000);
});
});
render(
<ConfirmModal
title="Some Title"
body="Some Body"
confirmText="Please Confirm"
alternativeText="Alternative Text"
dismissText="Dismiss Text"
isOpen={true}
confirmationText="My confirmation text"
onConfirm={mockOnConfirm}
onDismiss={() => {}}
onAlternative={() => {}}
/>
);
expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeDisabled();
await user.type(screen.getByPlaceholderText('Type "My confirmation text" to confirm'), 'My confirmation text');
expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Please Confirm' }));
expect(mockOnConfirm).toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeDisabled();
jest.runAllTimers();
await waitFor(() => {
return expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeEnabled();
});
});
});
@@ -37,8 +37,10 @@ export interface ConfirmModalProps {
alternativeText?: string;
/** Confirm button variant */
confirmButtonVariant?: ButtonVariant;
/** Confirm action callback */
onConfirm(): void;
/** Confirm action callback
* Return a promise to disable the confirm button until the promise is resolved
*/
onConfirm(): void | Promise<void>;
/** Dismiss action callback */
onDismiss(): void;
/** Alternative action callback */
@@ -83,6 +85,15 @@ export const ConfirmModal = ({
}
}, [isOpen, confirmationText]);
const onConfirmClick = async () => {
setDisabled(true);
try {
await onConfirm();
} finally {
setDisabled(false);
}
};
return (
<Modal className={cx(styles.modal, modalClass)} title={title} icon={icon} isOpen={isOpen} onDismiss={onDismiss}>
<div className={styles.modalText}>
@@ -102,7 +113,7 @@ export const ConfirmModal = ({
</Button>
<Button
variant={confirmButtonVariant}
onClick={onConfirm}
onClick={onConfirmClick}
disabled={disabled}
ref={buttonRef}
data-testid={selectors.pages.ConfirmModal.delete}
@@ -75,6 +75,7 @@ export function RadioButtonGroup<T>({
{options.map((opt, i) => {
const isItemDisabled = disabledOptions && opt.value && disabledOptions.includes(opt.value);
const icon = opt.icon ? toIconName(opt.icon) : undefined;
const hasNonIconPart = Boolean(opt.imgUrl || opt.label || opt.component);
return (
<RadioButton
@@ -91,7 +92,7 @@ export function RadioButtonGroup<T>({
fullWidth={fullWidth}
ref={value === opt.value ? activeButtonRef : undefined}
>
{icon && <Icon name={icon} className={styles.icon} />}
{icon && <Icon name={icon} className={cx(hasNonIconPart && styles.icon)} />}
{opt.imgUrl && <img src={opt.imgUrl} alt={opt.label} className={styles.img} />}
{opt.label} {opt.component ? <opt.component /> : null}
</RadioButton>
@@ -261,14 +261,12 @@ const getStyles = (theme: GrafanaTheme2) => {
flexDirection: 'column',
'.show-on-hover': {
visibility: 'hidden',
opacity: '0',
},
'&:focus-visible, &:hover': {
// only show menu icon on hover or focused panel
'.show-on-hover': {
visibility: 'visible',
opacity: '1',
},
},
@@ -131,6 +131,8 @@ export function SelectBase<T>({
onCreateOption,
onInputChange,
onKeyDown,
onMenuScrollToBottom,
onMenuScrollToTop,
onOpenMenu,
onFocus,
openMenuOnFocus = false,
@@ -215,7 +217,7 @@ export function SelectBase<T>({
autoFocus,
backspaceRemovesValue,
blurInputOnSelect,
captureMenuScroll: false,
captureMenuScroll: onMenuScrollToBottom || onMenuScrollToTop,
closeMenuOnSelect,
// We don't want to close if we're actually scrolling the menu
// So only close if none of the parents are the select menu itself
@@ -252,6 +254,8 @@ export function SelectBase<T>({
onKeyDown,
onMenuClose: onCloseMenu,
onMenuOpen: onOpenMenu,
onMenuScrollToBottom: onMenuScrollToBottom,
onMenuScrollToTop: onMenuScrollToTop,
onFocus,
formatOptionLabel,
openMenuOnFocus,
@@ -24,6 +24,7 @@ export interface SelectCommonProps<T> {
autoFocus?: boolean;
backspaceRemovesValue?: boolean;
blurInputOnSelect?: boolean;
captureMenuScroll?: boolean;
className?: string;
closeMenuOnSelect?: boolean;
/** Used for custom components. For more information, see `react-select` */
@@ -70,6 +71,10 @@ export interface SelectCommonProps<T> {
onCreateOption?: (value: string) => void;
onInputChange?: (value: string, actionMeta: InputActionMeta) => void;
onKeyDown?: (event: React.KeyboardEvent) => void;
/** Callback which fires when the user scrolls to the bottom of the menu */
onMenuScrollToBottom?: (event: WheelEvent | TouchEvent) => void;
/** Callback which fires when the user scrolls to the top of the menu */
onMenuScrollToTop?: (event: WheelEvent | TouchEvent) => void;
onOpenMenu?: () => void;
onFocus?: () => void;
openMenuOnFocus?: boolean;
+1 -1
View File
@@ -26,7 +26,7 @@ func (hs *HTTPServer) CallResource(c *contextmodel.ReqContext) {
}
func (hs *HTTPServer) callPluginResource(c *contextmodel.ReqContext, pluginID string) {
pCtx, err := hs.pluginContextProvider.Get(c.Req.Context(), pluginID, c.SignedInUser)
pCtx, err := hs.pluginContextProvider.Get(c.Req.Context(), pluginID, c.SignedInUser, c.OrgID)
if err != nil {
if errors.Is(err, plugincontext.ErrPluginNotFound) {
c.JsonApiErr(404, "Plugin not found", nil)
+1 -1
View File
@@ -393,7 +393,7 @@ func (hs *HTTPServer) redirectCDNPluginAsset(c *contextmodel.ReqContext, plugin
// /api/plugins/:pluginId/health
func (hs *HTTPServer) CheckHealth(c *contextmodel.ReqContext) response.Response {
pluginID := web.Params(c.Req)[":pluginId"]
pCtx, err := hs.pluginContextProvider.Get(c.Req.Context(), pluginID, c.SignedInUser)
pCtx, err := hs.pluginContextProvider.Get(c.Req.Context(), pluginID, c.SignedInUser, c.OrgID)
if err != nil {
if errors.Is(err, plugincontext.ErrPluginNotFound) {
return response.Error(404, "Plugin not found", nil)
+18 -45
View File
@@ -10,11 +10,12 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/fakes"
)
func TestProcessManager_Start(t *testing.T) {
t.Run("Plugin not found in registry", func(t *testing.T) {
m := NewManager(newFakePluginRegistry(map[string]*plugins.Plugin{}))
m := NewManager(fakes.NewFakePluginRegistry())
err := m.Start(context.Background(), "non-existing-datasource")
require.ErrorIs(t, err, backendplugin.ErrPluginNotRegistered)
})
@@ -63,9 +64,11 @@ func TestProcessManager_Start(t *testing.T) {
plugin.SignatureError = tc.signatureError
})
m := NewManager(newFakePluginRegistry(map[string]*plugins.Plugin{
p.ID: p,
}))
m := NewManager(&fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
p.ID: p,
}},
)
err := m.Start(context.Background(), p.ID)
require.NoError(t, err)
@@ -83,7 +86,7 @@ func TestProcessManager_Start(t *testing.T) {
func TestProcessManager_Stop(t *testing.T) {
t.Run("Plugin not found in registry", func(t *testing.T) {
m := NewManager(newFakePluginRegistry(map[string]*plugins.Plugin{}))
m := NewManager(fakes.NewFakePluginRegistry())
err := m.Stop(context.Background(), "non-existing-datasource")
require.ErrorIs(t, err, backendplugin.ErrPluginNotRegistered)
})
@@ -97,9 +100,11 @@ func TestProcessManager_Stop(t *testing.T) {
plugin.Backend = true
})
m := NewManager(newFakePluginRegistry(map[string]*plugins.Plugin{
pluginID: p,
}))
m := NewManager(&fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
pluginID: p,
}},
)
err := m.Stop(context.Background(), pluginID)
require.NoError(t, err)
@@ -116,9 +121,11 @@ func TestProcessManager_ManagedBackendPluginLifecycle(t *testing.T) {
plugin.Backend = true
})
m := NewManager(newFakePluginRegistry(map[string]*plugins.Plugin{
p.ID: p,
}))
m := NewManager(&fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
p.ID: p,
}},
)
err := m.Start(context.Background(), p.ID)
require.NoError(t, err)
@@ -162,40 +169,6 @@ func TestProcessManager_ManagedBackendPluginLifecycle(t *testing.T) {
})
}
type fakePluginRegistry struct {
store map[string]*plugins.Plugin
}
func newFakePluginRegistry(m map[string]*plugins.Plugin) *fakePluginRegistry {
return &fakePluginRegistry{
store: m,
}
}
func (f *fakePluginRegistry) Plugin(_ context.Context, id string) (*plugins.Plugin, bool) {
p, exists := f.store[id]
return p, exists
}
func (f *fakePluginRegistry) Plugins(_ context.Context) []*plugins.Plugin {
var res []*plugins.Plugin
for _, p := range f.store {
res = append(res, p)
}
return res
}
func (f *fakePluginRegistry) Add(_ context.Context, p *plugins.Plugin) error {
f.store[p.ID] = p
return nil
}
func (f *fakePluginRegistry) Remove(_ context.Context, id string) error {
delete(f.store, id)
return nil
}
type fakeBackendPlugin struct {
managed bool
+44 -67
View File
@@ -54,10 +54,12 @@ func TestStore_Plugin(t *testing.T) {
p1.RegisterClient(&DecommissionedPlugin{})
p2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-panel"}}
ps := New(newFakePluginRegistry(map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
}))
ps := New(&fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
},
})
p, exists := ps.Plugin(context.Background(), p1.ID)
require.False(t, exists)
@@ -78,13 +80,15 @@ func TestStore_Plugins(t *testing.T) {
p5 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "e-test-panel", Type: plugins.TypePanel}}
p5.RegisterClient(&DecommissionedPlugin{})
ps := New(newFakePluginRegistry(map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
p3.ID: p3,
p4.ID: p4,
p5.ID: p5,
}))
ps := New(&fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
p3.ID: p3,
p4.ID: p4,
p5.ID: p5,
},
})
pss := ps.Plugins(context.Background())
require.Equal(t, pss, []plugins.PluginDTO{p1.ToDTO(), p2.ToDTO(), p3.ToDTO(), p4.ToDTO()})
@@ -113,14 +117,16 @@ func TestStore_Routes(t *testing.T) {
p6 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "f-test-app", Type: plugins.TypeApp}}
p6.RegisterClient(&DecommissionedPlugin{})
ps := New(newFakePluginRegistry(map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
p3.ID: p3,
p4.ID: p4,
p5.ID: p5,
p6.ID: p6,
}))
ps := New(&fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
p3.ID: p3,
p4.ID: p4,
p5.ID: p5,
p6.ID: p6,
},
})
sr := func(p *plugins.Plugin) *plugins.StaticRoute {
return &plugins.StaticRoute{PluginID: p.ID, Directory: p.FS.Base()}
@@ -137,11 +143,13 @@ func TestStore_Renderer(t *testing.T) {
p2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-panel", Type: plugins.TypePanel}}
p3 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-app", Type: plugins.TypeApp}}
ps := New(newFakePluginRegistry(map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
p3.ID: p3,
}))
ps := New(&fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
p3.ID: p3,
},
})
r := ps.Renderer(context.Background())
require.Equal(t, p1, r)
@@ -155,12 +163,14 @@ func TestStore_SecretsManager(t *testing.T) {
p3 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-secrets", Type: plugins.TypeSecretsManager}}
p4 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-datasource", Type: plugins.TypeDataSource}}
ps := New(newFakePluginRegistry(map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
p3.ID: p3,
p4.ID: p4,
}))
ps := New(&fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
p3.ID: p3,
p4.ID: p4,
},
})
r := ps.SecretsManager(context.Background())
require.Equal(t, p3, r)
@@ -173,12 +183,12 @@ func TestStore_availablePlugins(t *testing.T) {
p1.RegisterClient(&DecommissionedPlugin{})
p2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-app"}}
ps := New(
newFakePluginRegistry(map[string]*plugins.Plugin{
ps := New(&fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
}),
)
},
})
aps := ps.availablePlugins(context.Background())
require.Len(t, aps, 1)
@@ -197,36 +207,3 @@ func (p *DecommissionedPlugin) Decommission() error {
func (p *DecommissionedPlugin) IsDecommissioned() bool {
return true
}
type fakePluginRegistry struct {
store map[string]*plugins.Plugin
}
func newFakePluginRegistry(m map[string]*plugins.Plugin) *fakePluginRegistry {
return &fakePluginRegistry{
store: m,
}
}
func (f *fakePluginRegistry) Plugin(_ context.Context, id string) (*plugins.Plugin, bool) {
p, exists := f.store[id]
return p, exists
}
func (f *fakePluginRegistry) Plugins(_ context.Context) []*plugins.Plugin {
var res []*plugins.Plugin
for _, p := range f.store {
res = append(res, p)
}
return res
}
func (f *fakePluginRegistry) Add(_ context.Context, p *plugins.Plugin) error {
f.store[p.ID] = p
return nil
}
func (f *fakePluginRegistry) Remove(_ context.Context, id string) error {
delete(f.store, id)
return nil
}
+7
View File
@@ -557,5 +557,12 @@ var (
Stage: FeatureStagePublicPreview,
Owner: grafanaBiSquad,
},
{
Name: "cloudWatchLogsMonacoEditor",
Description: "Enables the Monaco editor for CloudWatch Logs queries",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: awsPluginsSquad,
},
}
)
+1
View File
@@ -81,3 +81,4 @@ extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,false,tr
lokiPredefinedOperations,experimental,@grafana/observability-logs,false,false,false,true
pluginsFrontendSandbox,experimental,@grafana/plugins-platform-backend,false,false,false,true
sqlDatasourceDatabaseSelection,preview,@grafana/grafana-bi-squad,false,false,false,true
cloudWatchLogsMonacoEditor,experimental,@grafana/aws-plugins,false,false,false,true
1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
81 lokiPredefinedOperations experimental @grafana/observability-logs false false false true
82 pluginsFrontendSandbox experimental @grafana/plugins-platform-backend false false false true
83 sqlDatasourceDatabaseSelection preview @grafana/grafana-bi-squad false false false true
84 cloudWatchLogsMonacoEditor experimental @grafana/aws-plugins false false false true
+4
View File
@@ -334,4 +334,8 @@ const (
// FlagSqlDatasourceDatabaseSelection
// Enables previous SQL data source dataset dropdown behavior
FlagSqlDatasourceDatabaseSelection = "sqlDatasourceDatabaseSelection"
// FlagCloudWatchLogsMonacoEditor
// Enables the Monaco editor for CloudWatch Logs queries
FlagCloudWatchLogsMonacoEditor = "cloudWatchLogsMonacoEditor"
)
+1 -1
View File
@@ -74,7 +74,7 @@ func NewContextGetter(pluginContextProvider *plugincontext.Provider, dataSourceC
func (g *ContextGetter) GetPluginContext(ctx context.Context, user *user.SignedInUser, pluginID string, datasourceUID string, skipCache bool) (backend.PluginContext, error) {
if datasourceUID == "" {
return g.pluginContextProvider.Get(ctx, pluginID, user)
return g.pluginContextProvider.Get(ctx, pluginID, user, user.OrgID)
}
ds, err := g.dataSourceCache.GetDatasourceByUID(ctx, datasourceUID, user, skipCache)
+1
View File
@@ -136,6 +136,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
cfg: &api.Cfg.UnifiedAlerting,
backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory),
featureManager: api.FeatureManager,
appUrl: api.AppUrl,
}), m)
api.RegisterConfigurationApiEndpoints(NewConfiguration(
&ConfigSrv{
+27 -23
View File
@@ -350,29 +350,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey
finalChanges = store.UpdateCalculatedRuleFields(groupChanges)
logger.Debug("updating database with the authorized changes", "add", len(finalChanges.New), "update", len(finalChanges.New), "delete", len(finalChanges.Delete))
if len(finalChanges.Update) > 0 || len(finalChanges.New) > 0 {
updates := make([]ngmodels.UpdateRule, 0, len(finalChanges.Update))
inserts := make([]ngmodels.AlertRule, 0, len(finalChanges.New))
for _, update := range finalChanges.Update {
logger.Debug("updating rule", "rule_uid", update.New.UID, "diff", update.Diff.String())
updates = append(updates, ngmodels.UpdateRule{
Existing: update.Existing,
New: *update.New,
})
}
for _, rule := range finalChanges.New {
inserts = append(inserts, *rule)
}
_, err = srv.store.InsertAlertRules(tranCtx, inserts)
if err != nil {
return fmt.Errorf("failed to add rules: %w", err)
}
err = srv.store.UpdateAlertRules(tranCtx, updates)
if err != nil {
return fmt.Errorf("failed to update rules: %w", err)
}
}
// Delete first as this could prevent future unique constraint violations.
if len(finalChanges.Delete) > 0 {
UIDs := make([]string, 0, len(finalChanges.Delete))
for _, rule := range finalChanges.Delete {
@@ -384,6 +362,32 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey
}
}
if len(finalChanges.Update) > 0 {
updates := make([]ngmodels.UpdateRule, 0, len(finalChanges.Update))
for _, update := range finalChanges.Update {
logger.Debug("updating rule", "rule_uid", update.New.UID, "diff", update.Diff.String())
updates = append(updates, ngmodels.UpdateRule{
Existing: update.Existing,
New: *update.New,
})
}
err = srv.store.UpdateAlertRules(tranCtx, updates)
if err != nil {
return fmt.Errorf("failed to update rules: %w", err)
}
}
if len(finalChanges.New) > 0 {
inserts := make([]ngmodels.AlertRule, 0, len(finalChanges.New))
for _, rule := range finalChanges.New {
inserts = append(inserts, *rule)
}
_, err = srv.store.InsertAlertRules(tranCtx, inserts)
if err != nil {
return fmt.Errorf("failed to add rules: %w", err)
}
}
if len(finalChanges.New) > 0 {
limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, ngmodels.QuotaTargetSrv, &quota.ScopeParameters{
OrgID: c.OrgID,
+57 -25
View File
@@ -8,7 +8,10 @@ import (
"strconv"
"time"
"github.com/benbjohnson/clock"
"github.com/grafana/alerting/models"
"github.com/grafana/grafana-plugin-sdk-go/data"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
@@ -16,10 +19,12 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/backtesting"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/state"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -33,46 +38,73 @@ type TestingApiSrv struct {
cfg *setting.UnifiedAlertingSettings
backtesting *backtesting.Engine
featureManager featuremgmt.FeatureToggles
appUrl *url.URL
}
func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response {
if body.Type() != apimodels.GrafanaBackend || body.GrafanaManagedCondition == nil {
return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.GrafanaBackend, body.Type().String()))
// RouteTestGrafanaRuleConfig returns a list of potential alerts for a given rule configuration. This is intended to be
// as true as possible to what would be generated by the ruler except that the resulting alerts are not filtered to
// only Resolved / Firing and ready to send.
func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.PostableExtendedRuleNodeExtended) response.Response {
rule, err := validateRuleNode(
&body.Rule,
body.RuleGroup,
srv.cfg.BaseInterval,
c.OrgID,
&folder.Folder{
OrgID: c.OrgID,
UID: body.NamespaceUID,
Title: body.NamespaceTitle,
},
func(condition ngmodels.Condition) error {
return srv.evaluator.Validate(eval.NewContext(c.Req.Context(), c.SignedInUser), condition)
},
srv.cfg,
)
if err != nil {
return ErrResp(http.StatusBadRequest, err, "")
}
queries := AlertQueriesFromApiAlertQueries(body.GrafanaManagedCondition.Data)
if !authorizeDatasourceAccessForRule(&ngmodels.AlertRule{Data: queries}, func(evaluator accesscontrol.Evaluator) bool {
if !authorizeDatasourceAccessForRule(rule, func(evaluator accesscontrol.Evaluator) bool {
return accesscontrol.HasAccess(srv.accessControl, c)(evaluator)
}) {
return errorToResponse(fmt.Errorf("%w to query one or many data sources used by the rule", ErrAuthorization))
}
evalCond := ngmodels.Condition{
Condition: body.GrafanaManagedCondition.Condition,
Data: queries,
}
ctx := eval.NewContext(c.Req.Context(), c.SignedInUser)
conditionEval, err := srv.evaluator.Create(ctx, evalCond)
evaluator, err := srv.evaluator.Create(eval.NewContext(c.Req.Context(), c.SignedInUser), rule.GetEvalCondition())
if err != nil {
return ErrResp(http.StatusBadRequest, err, "invalid condition")
return ErrResp(http.StatusBadRequest, err, "Failed to build evaluator for queries and expressions")
}
now := body.GrafanaManagedCondition.Now
if now.IsZero() {
now = timeNow()
}
evalResults, err := conditionEval.Evaluate(c.Req.Context(), now)
now := time.Now()
results, err := evaluator.Evaluate(c.Req.Context(), now)
if err != nil {
return ErrResp(500, err, "Failed to evaluate the rule")
return ErrResp(http.StatusInternalServerError, err, "Failed to evaluate queries")
}
frame := evalResults.AsDataFrame()
return response.JSONStreaming(http.StatusOK, util.DynMap{
"instances": []*data.Frame{&frame},
})
cfg := state.ManagerCfg{
Metrics: nil,
ExternalURL: srv.appUrl,
InstanceStore: nil,
Images: &backtesting.NoopImageService{},
Clock: clock.New(),
Historian: nil,
}
manager := state.NewManager(cfg)
includeFolder := !srv.cfg.ReservedLabels.IsReservedLabelDisabled(models.FolderTitleLabel)
transitions := manager.ProcessEvalResults(
c.Req.Context(),
now,
rule,
results,
state.GetRuleExtraLabels(rule, body.NamespaceTitle, includeFolder),
)
alerts := make([]*amv2.PostableAlert, 0, len(transitions))
for _, alertState := range transitions {
alerts = append(alerts, state.StateToPostableAlert(alertState.State, srv.appUrl))
}
return response.JSON(http.StatusOK, alerts)
}
func (srv TestingApiSrv) RouteTestRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload, datasourceUID string) response.Response {
+120 -21
View File
@@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"net/http"
"testing"
"time"
@@ -22,6 +23,107 @@ import (
"github.com/grafana/grafana/pkg/web"
)
func Test(t *testing.T) {
text := `{
"rule": {
"grafana_alert" : {
"condition": "C",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 600,
"to": 0
},
"queryType": "",
"datasourceUid": "PD8C576611E62080A",
"model": {
"refId": "A",
"hide": false,
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"scenarioId": "random_walk",
"seriesCount": 5,
"labels": "series=series-$seriesIndex"
}
},
{
"refId": "B",
"datasourceUid": "__expr__",
"queryType": "",
"model": {
"refId": "B",
"hide": false,
"type": "reduce",
"datasource": {
"uid": "__expr__",
"type": "__expr__"
},
"reducer": "last",
"expression": "A"
},
"relativeTimeRange": {
"from": 600,
"to": 0
}
},
{
"refId": "C",
"datasourceUid": "__expr__",
"queryType": "",
"model": {
"refId": "C",
"hide": false,
"type": "threshold",
"datasource": {
"uid": "__expr__",
"type": "__expr__"
},
"conditions": [
{
"type": "query",
"evaluator": {
"params": [
0
],
"type": "gt"
}
}
],
"expression": "B"
},
"relativeTimeRange": {
"from": 600,
"to": 0
}
}
],
"no_data_state": "Alerting",
"title": "string"
},
"for": "0s",
"labels": {
"additionalProp1": "string",
"additionalProp2": "string",
"additionalProp3": "string"
},
"annotations": {
"additionalProp1": "string",
"additionalProp2": "string",
"additionalProp3": "string"
}
},
"folderUid": "test-uid",
"folderTitle": "test-folder"
}`
var conf definitions.PostableExtendedRuleNodeExtended
require.NoError(t, json.Unmarshal([]byte(text), &conf))
require.Equal(t, "test-folder", conf.NamespaceTitle)
}
func TestRouteTestGrafanaRuleConfig(t *testing.T) {
t.Run("when fine-grained access is enabled", func(t *testing.T) {
rc := &contextmodel.ReqContext{
@@ -41,15 +143,14 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)},
})
srv := createTestingApiSrv(nil, ac, nil)
srv := createTestingApiSrv(t, nil, ac, eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{}))
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{
Expr: "",
GrafanaManagedCondition: &definitions.EvalAlertConditionCommand{
Condition: data1.RefID,
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}),
Now: time.Time{},
},
rule := validRule()
rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Rule: rule,
NamespaceUID: "test-folder",
NamespaceTitle: "test-folder",
})
require.Equal(t, http.StatusUnauthorized, response.Status())
@@ -59,8 +160,6 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
data1 := models.GenerateAlertQuery()
data2 := models.GenerateAlertQuery()
currentTime := time.Now()
ac := acMock.New().WithPermissions([]accesscontrol.Permission{
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)},
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data2.DatasourceUID)},
@@ -77,20 +176,19 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
evalFactory := eval_mocks.NewEvaluatorFactory(evaluator)
srv := createTestingApiSrv(ds, ac, evalFactory)
srv := createTestingApiSrv(t, ds, ac, evalFactory)
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{
Expr: "",
GrafanaManagedCondition: &definitions.EvalAlertConditionCommand{
Condition: data1.RefID,
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}),
Now: currentTime,
},
rule := validRule()
rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Rule: rule,
NamespaceUID: "test-folder",
NamespaceTitle: "test-folder",
})
require.Equal(t, http.StatusOK, response.Status())
evaluator.AssertCalled(t, "Evaluate", mock.Anything, currentTime)
evaluator.AssertCalled(t, "Evaluate", mock.Anything, mock.Anything)
})
})
}
@@ -153,7 +251,7 @@ func TestRouteEvalQueries(t *testing.T) {
}
evaluator.EXPECT().EvaluateRaw(mock.Anything, mock.Anything).Return(result, nil)
srv := createTestingApiSrv(ds, ac, eval_mocks.NewEvaluatorFactory(evaluator))
srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator))
response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}),
@@ -167,7 +265,7 @@ func TestRouteEvalQueries(t *testing.T) {
})
}
func createTestingApiSrv(ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory) *TestingApiSrv {
func createTestingApiSrv(t *testing.T, ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory) *TestingApiSrv {
if ac == nil {
ac = acMock.New().WithDisabled()
}
@@ -176,5 +274,6 @@ func createTestingApiSrv(ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator
DatasourceCache: ds,
accessControl: ac,
evaluator: evaluator,
cfg: config(t),
}
}
@@ -53,7 +53,7 @@ func (f *TestingApiHandler) RouteTestRuleConfig(ctx *contextmodel.ReqContext) re
}
func (f *TestingApiHandler) RouteTestRuleGrafanaConfig(ctx *contextmodel.ReqContext) response.Response {
// Parse Request Body
conf := apimodels.TestRulePayload{}
conf := apimodels.PostableExtendedRuleNodeExtended{}
if err := web.Bind(ctx.Req, &conf); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
+1 -1
View File
@@ -21,7 +21,7 @@ func (f *TestingApiHandler) handleRouteTestRuleConfig(c *contextmodel.ReqContext
return f.svc.RouteTestRuleConfig(c, body, dsUID)
}
func (f *TestingApiHandler) handleRouteTestRuleGrafanaConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response {
func (f *TestingApiHandler) handleRouteTestRuleGrafanaConfig(c *contextmodel.ReqContext, body apimodels.PostableExtendedRuleNodeExtended) response.Response {
return f.svc.RouteTestGrafanaRuleConfig(c, body)
}
+169 -6
View File
@@ -543,6 +543,12 @@
},
"type": "array"
},
"CounterResetHint": {
"description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.",
"format": "uint8",
"title": "CounterResetHint contains the known information about a counter reset,",
"type": "integer"
},
"DataLink": {
"description": "DataLink define what",
"properties": {
@@ -889,7 +895,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@@ -952,6 +958,56 @@
},
"type": "object"
},
"FloatHistogram": {
"description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.",
"properties": {
"Count": {
"description": "Total number of observations. Must be zero or positive.",
"format": "double",
"type": "number"
},
"CounterResetHint": {
"$ref": "#/definitions/CounterResetHint"
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"items": {
"format": "double",
"type": "number"
},
"type": "array"
},
"PositiveSpans": {
"description": "Spans for positive and negative buckets (see Span below).",
"items": {
"$ref": "#/definitions/Span"
},
"type": "array"
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"format": "int32",
"type": "integer"
},
"Sum": {
"description": "Sum of observations. This is also used as the stale marker.",
"format": "double",
"type": "number"
},
"ZeroCount": {
"description": "Observations falling into the zero bucket. Must be zero or positive.",
"format": "double",
"type": "number"
},
"ZeroThreshold": {
"description": "Width of the zero bucket.",
"format": "double",
"type": "number"
}
},
"title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.",
"type": "object"
},
"Frame": {
"description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.",
"properties": {
@@ -1553,12 +1609,20 @@
"description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.",
"type": "boolean"
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"oauth2": {
"$ref": "#/definitions/OAuth2"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@@ -1865,6 +1929,17 @@
},
"type": "object"
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@@ -2064,7 +2139,11 @@
"type": "object"
},
"Point": {
"description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.",
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"T": {
"format": "int64",
"type": "integer"
@@ -2232,6 +2311,29 @@
},
"type": "object"
},
"PostableExtendedRuleNodeExtended": {
"properties": {
"folderTitle": {
"example": "project_x",
"type": "string"
},
"folderUid": {
"example": "okrd3I0Vz",
"type": "string"
},
"rule": {
"$ref": "#/definitions/PostableExtendedRuleNode"
},
"ruleGroup": {
"example": "eval_group_1",
"type": "string"
}
},
"required": [
"rule"
],
"type": "object"
},
"PostableGrafanaReceiver": {
"properties": {
"disableResolveMessage": {
@@ -2508,8 +2610,30 @@
},
"type": "array"
},
"ProxyConfig": {
"properties": {
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
}
},
"type": "object"
},
"PushoverConfig": {
"properties": {
"device": {
"type": "string"
},
"expire": {
"type": "string"
},
@@ -2584,7 +2708,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@@ -3007,6 +3131,9 @@
},
"Sample": {
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"Metric": {
"$ref": "#/definitions/Labels"
},
@@ -3201,6 +3328,22 @@
"SmtpNotEnabled": {
"$ref": "#/definitions/ResponseDetails"
},
"Span": {
"properties": {
"Length": {
"description": "Length of the span.",
"format": "uint32",
"type": "integer"
},
"Offset": {
"description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).",
"format": "int32",
"type": "integer"
}
},
"title": "A Span defines a continuous sequence of buckets.",
"type": "object"
},
"Status": {
"format": "int64",
"type": "integer"
@@ -3273,6 +3416,9 @@
},
"token": {
"$ref": "#/definitions/Secret"
},
"token_file": {
"type": "string"
}
},
"title": "TelegramConfig configures notifications via Telegram.",
@@ -3684,7 +3830,10 @@
"type": "boolean"
},
"url": {
"$ref": "#/definitions/URL"
"$ref": "#/definitions/SecretURL"
},
"url_file": {
"type": "string"
}
},
"title": "WebhookConfig configures notifications via a generic webhook.",
@@ -3875,7 +4024,6 @@
"type": "object"
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
@@ -3931,13 +4079,13 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert"
},
"type": "array"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@@ -4136,6 +4284,7 @@
"type": "array"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@@ -4173,7 +4322,6 @@
"type": "object"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
@@ -5116,6 +5264,21 @@
"type": "array"
}
},
"StateHistory": {
"description": "",
"schema": {
"$ref": "#/definitions/Frame"
}
},
"TestGrafanaRuleResponse": {
"description": "",
"schema": {
"items": {
"$ref": "#/definitions/postableAlert"
},
"type": "array"
}
},
"receiversResponse": {
"description": "",
"schema": {
@@ -12,6 +12,8 @@ import "github.com/grafana/grafana-plugin-sdk-go/data"
// Responses:
// 200: StateHistory
// swagger:response StateHistory
type StateHistory struct {
// in:body
Results *data.Frame `json:"results"`
}
@@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model"
"github.com/prometheus/prometheus/promql"
@@ -23,7 +24,9 @@ import (
// - application/json
//
// Responses:
// 200: TestRuleResponse
// 200: TestGrafanaRuleResponse
// 400: ValidationError
// 404: NotFound
// swagger:route Post /api/v1/rule/test/{DatasourceUID} testing RouteTestRuleConfig
//
@@ -71,7 +74,7 @@ type TestReceiverRequest struct {
Body ExtendedReceiver
}
// swagger:parameters RouteTestRuleConfig RouteTestRuleGrafanaConfig
// swagger:parameters RouteTestRuleConfig
type TestRuleRequest struct {
// in:body
Body TestRulePayload
@@ -85,6 +88,38 @@ type TestRulePayload struct {
GrafanaManagedCondition *EvalAlertConditionCommand `json:"grafana_condition,omitempty"`
}
// swagger:response TestGrafanaRuleResponse
type TestGrafanaRuleResponse struct {
// in:body
Body []amv2.PostableAlert
}
// swagger:parameters RouteTestRuleGrafanaConfig
type TestGrafanaRuleRequest struct {
// in:body
Body PostableExtendedRuleNodeExtended
}
// swagger:model
type PostableExtendedRuleNodeExtended struct {
// required: true
Rule PostableExtendedRuleNode `json:"rule"`
// example: okrd3I0Vz
NamespaceUID string `json:"folderUid"`
// example: project_x
NamespaceTitle string `json:"folderTitle"`
// example: eval_group_1
RuleGroup string `json:"ruleGroup"`
}
func (n *PostableExtendedRuleNodeExtended) UnmarshalJSON(b []byte) error {
type plain PostableExtendedRuleNodeExtended
if err := json.Unmarshal(b, (*plain)(n)); err != nil {
return err
}
return nil
}
// swagger:parameters RouteEvalQueries
type EvalQueriesRequest struct {
// in:body
+186 -10
View File
@@ -543,6 +543,12 @@
},
"type": "array"
},
"CounterResetHint": {
"description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.",
"format": "uint8",
"title": "CounterResetHint contains the known information about a counter reset,",
"type": "integer"
},
"DataLink": {
"description": "DataLink define what",
"properties": {
@@ -889,7 +895,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@@ -952,6 +958,56 @@
},
"type": "object"
},
"FloatHistogram": {
"description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.",
"properties": {
"Count": {
"description": "Total number of observations. Must be zero or positive.",
"format": "double",
"type": "number"
},
"CounterResetHint": {
"$ref": "#/definitions/CounterResetHint"
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"items": {
"format": "double",
"type": "number"
},
"type": "array"
},
"PositiveSpans": {
"description": "Spans for positive and negative buckets (see Span below).",
"items": {
"$ref": "#/definitions/Span"
},
"type": "array"
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"format": "int32",
"type": "integer"
},
"Sum": {
"description": "Sum of observations. This is also used as the stale marker.",
"format": "double",
"type": "number"
},
"ZeroCount": {
"description": "Observations falling into the zero bucket. Must be zero or positive.",
"format": "double",
"type": "number"
},
"ZeroThreshold": {
"description": "Width of the zero bucket.",
"format": "double",
"type": "number"
}
},
"title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.",
"type": "object"
},
"Frame": {
"description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.",
"properties": {
@@ -1553,12 +1609,20 @@
"description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.",
"type": "boolean"
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"oauth2": {
"$ref": "#/definitions/OAuth2"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@@ -1865,6 +1929,17 @@
},
"type": "object"
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@@ -2064,7 +2139,11 @@
"type": "object"
},
"Point": {
"description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.",
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"T": {
"format": "int64",
"type": "integer"
@@ -2232,6 +2311,29 @@
},
"type": "object"
},
"PostableExtendedRuleNodeExtended": {
"properties": {
"folderTitle": {
"example": "project_x",
"type": "string"
},
"folderUid": {
"example": "okrd3I0Vz",
"type": "string"
},
"rule": {
"$ref": "#/definitions/PostableExtendedRuleNode"
},
"ruleGroup": {
"example": "eval_group_1",
"type": "string"
}
},
"required": [
"rule"
],
"type": "object"
},
"PostableGrafanaReceiver": {
"properties": {
"disableResolveMessage": {
@@ -2508,8 +2610,30 @@
},
"type": "array"
},
"ProxyConfig": {
"properties": {
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
}
},
"type": "object"
},
"PushoverConfig": {
"properties": {
"device": {
"type": "string"
},
"expire": {
"type": "string"
},
@@ -2584,7 +2708,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@@ -3007,6 +3131,9 @@
},
"Sample": {
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"Metric": {
"$ref": "#/definitions/Labels"
},
@@ -3201,6 +3328,22 @@
"SmtpNotEnabled": {
"$ref": "#/definitions/ResponseDetails"
},
"Span": {
"properties": {
"Length": {
"description": "Length of the span.",
"format": "uint32",
"type": "integer"
},
"Offset": {
"description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).",
"format": "int32",
"type": "integer"
}
},
"title": "A Span defines a continuous sequence of buckets.",
"type": "object"
},
"Status": {
"format": "int64",
"type": "integer"
@@ -3273,6 +3416,9 @@
},
"token": {
"$ref": "#/definitions/Secret"
},
"token_file": {
"type": "string"
}
},
"title": "TelegramConfig configures notifications via Telegram.",
@@ -3535,6 +3681,7 @@
"type": "object"
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -3570,7 +3717,7 @@
"$ref": "#/definitions/Userinfo"
}
},
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"type": "object"
},
"Userinfo": {
@@ -3684,7 +3831,10 @@
"type": "boolean"
},
"url": {
"$ref": "#/definitions/URL"
"$ref": "#/definitions/SecretURL"
},
"url_file": {
"type": "string"
}
},
"title": "WebhookConfig configures notifications via a generic webhook.",
@@ -3747,7 +3897,6 @@
"type": "object"
},
"alertGroup": {
"description": "AlertGroup alert group",
"properties": {
"alerts": {
"description": "alerts",
@@ -3771,7 +3920,6 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
@@ -3876,6 +4024,7 @@
"type": "object"
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
@@ -3931,12 +4080,14 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert"
},
"type": "array"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@@ -3985,7 +4136,6 @@
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},
@@ -4136,6 +4286,7 @@
"type": "array"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@@ -4173,6 +4324,7 @@
"type": "object"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
@@ -6926,7 +7078,7 @@
"in": "body",
"name": "Body",
"schema": {
"$ref": "#/definitions/TestRulePayload"
"$ref": "#/definitions/PostableExtendedRuleNodeExtended"
}
}
],
@@ -6935,9 +7087,18 @@
],
"responses": {
"200": {
"description": "TestRuleResponse",
"$ref": "#/responses/TestGrafanaRuleResponse"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/TestRuleResponse"
"$ref": "#/definitions/ValidationError"
}
},
"404": {
"description": "NotFound",
"schema": {
"$ref": "#/definitions/NotFound"
}
}
},
@@ -7022,6 +7183,21 @@
"type": "array"
}
},
"StateHistory": {
"description": "",
"schema": {
"$ref": "#/definitions/Frame"
}
},
"TestGrafanaRuleResponse": {
"description": "",
"schema": {
"items": {
"$ref": "#/definitions/postableAlert"
},
"type": "array"
}
},
"receiversResponse": {
"description": "",
"schema": {
+186 -10
View File
@@ -2683,15 +2683,24 @@
"name": "Body",
"in": "body",
"schema": {
"$ref": "#/definitions/TestRulePayload"
"$ref": "#/definitions/PostableExtendedRuleNodeExtended"
}
}
],
"responses": {
"200": {
"description": "TestRuleResponse",
"$ref": "#/responses/TestGrafanaRuleResponse"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/TestRuleResponse"
"$ref": "#/definitions/ValidationError"
}
},
"404": {
"description": "NotFound",
"schema": {
"$ref": "#/definitions/NotFound"
}
}
}
@@ -3300,6 +3309,12 @@
"$ref": "#/definitions/EmbeddedContactPoint"
}
},
"CounterResetHint": {
"description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.",
"type": "integer",
"format": "uint8",
"title": "CounterResetHint contains the known information about a counter reset,"
},
"DataLink": {
"description": "DataLink define what",
"type": "object",
@@ -3651,7 +3666,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@@ -3712,6 +3727,56 @@
}
}
},
"FloatHistogram": {
"description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.",
"type": "object",
"title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.",
"properties": {
"Count": {
"description": "Total number of observations. Must be zero or positive.",
"type": "number",
"format": "double"
},
"CounterResetHint": {
"$ref": "#/definitions/CounterResetHint"
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"type": "array",
"items": {
"type": "number",
"format": "double"
}
},
"PositiveSpans": {
"description": "Spans for positive and negative buckets (see Span below).",
"type": "array",
"items": {
"$ref": "#/definitions/Span"
}
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"type": "integer",
"format": "int32"
},
"Sum": {
"description": "Sum of observations. This is also used as the stale marker.",
"type": "number",
"format": "double"
},
"ZeroCount": {
"description": "Observations falling into the zero bucket. Must be zero or positive.",
"type": "number",
"format": "double"
},
"ZeroThreshold": {
"description": "Width of the zero bucket.",
"type": "number",
"format": "double"
}
}
},
"Frame": {
"description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.",
"type": "object",
@@ -4315,12 +4380,20 @@
"description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.",
"type": "boolean"
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"oauth2": {
"$ref": "#/definitions/OAuth2"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@@ -4628,6 +4701,17 @@
"type": "string"
}
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@@ -4825,9 +4909,13 @@
"type": "object"
},
"Point": {
"description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.",
"type": "object",
"title": "Point represents a single data point for a given timestamp.",
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"T": {
"type": "integer",
"format": "int64"
@@ -4993,6 +5081,29 @@
}
}
},
"PostableExtendedRuleNodeExtended": {
"type": "object",
"required": [
"rule"
],
"properties": {
"folderTitle": {
"type": "string",
"example": "project_x"
},
"folderUid": {
"type": "string",
"example": "okrd3I0Vz"
},
"rule": {
"$ref": "#/definitions/PostableExtendedRuleNode"
},
"ruleGroup": {
"type": "string",
"example": "eval_group_1"
}
}
},
"PostableGrafanaReceiver": {
"type": "object",
"properties": {
@@ -5269,9 +5380,31 @@
"$ref": "#/definitions/ProvisionedAlertRule"
}
},
"ProxyConfig": {
"type": "object",
"properties": {
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
}
}
},
"PushoverConfig": {
"type": "object",
"properties": {
"device": {
"type": "string"
},
"expire": {
"type": "string"
},
@@ -5347,7 +5480,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@@ -5770,6 +5903,9 @@
"type": "object",
"title": "Sample is a single sample belonging to a metric.",
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"Metric": {
"$ref": "#/definitions/Labels"
},
@@ -5962,6 +6098,22 @@
"SmtpNotEnabled": {
"$ref": "#/definitions/ResponseDetails"
},
"Span": {
"type": "object",
"title": "A Span defines a continuous sequence of buckets.",
"properties": {
"Length": {
"description": "Length of the span.",
"type": "integer",
"format": "uint32"
},
"Offset": {
"description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).",
"type": "integer",
"format": "int32"
}
}
},
"Status": {
"type": "integer",
"format": "int64"
@@ -6036,6 +6188,9 @@
},
"token": {
"$ref": "#/definitions/Secret"
},
"token_file": {
"type": "string"
}
}
},
@@ -6296,8 +6451,9 @@
}
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
"type": "object",
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -6447,7 +6603,10 @@
"type": "boolean"
},
"url": {
"$ref": "#/definitions/URL"
"$ref": "#/definitions/SecretURL"
},
"url_file": {
"type": "string"
}
}
},
@@ -6508,7 +6667,6 @@
}
},
"alertGroup": {
"description": "AlertGroup alert group",
"type": "object",
"required": [
"alerts",
@@ -6533,7 +6691,6 @@
"$ref": "#/definitions/alertGroup"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
@@ -6639,6 +6796,7 @@
}
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"type": "object",
"required": [
"labels",
@@ -6695,6 +6853,7 @@
"$ref": "#/definitions/gettableAlert"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"type": "array",
"items": {
"$ref": "#/definitions/gettableAlert"
@@ -6702,6 +6861,7 @@
"$ref": "#/definitions/gettableAlerts"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"type": "object",
"required": [
"comment",
@@ -6751,7 +6911,6 @@
"$ref": "#/definitions/gettableSilence"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"
@@ -6904,6 +7063,7 @@
}
},
"postableSilence": {
"description": "PostableSilence postable silence",
"type": "object",
"required": [
"comment",
@@ -6942,6 +7102,7 @@
"$ref": "#/definitions/postableSilence"
},
"receiver": {
"description": "Receiver receiver",
"type": "object",
"required": [
"active",
@@ -7066,6 +7227,21 @@
}
}
},
"StateHistory": {
"description": "",
"schema": {
"$ref": "#/definitions/Frame"
}
},
"TestGrafanaRuleResponse": {
"description": "",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/postableAlert"
}
}
},
"receiversResponse": {
"description": "",
"schema": {
+1 -1
View File
@@ -41,7 +41,7 @@ func (am *Alertmanager) TestTemplate(ctx context.Context, c apimodels.TestTempla
})
}
// addDefaultLabelsAndAnnotations is a slimmed down version of schedule.stateToPostableAlert and schedule.getRuleExtraLabels using default values.
// addDefaultLabelsAndAnnotations is a slimmed down version of state.StateToPostableAlert and state.GetRuleExtraLabels using default values.
func addDefaultLabelsAndAnnotations(alert *amv2.PostableAlert) {
if alert.Labels == nil {
alert.Labels = make(map[string]string)
@@ -260,53 +260,60 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, orgID int
}
return service.xact.InTransaction(ctx, func(ctx context.Context) error {
uids, err := service.ruleStore.InsertAlertRules(ctx, withoutNilAlertRules(delta.New))
if err != nil {
return fmt.Errorf("failed to insert alert rules: %w", err)
}
for uid := range uids {
if err := service.provenanceStore.SetProvenance(ctx, &models.AlertRule{UID: uid}, orgID, provenance); err != nil {
// Delete first as this could prevent future unique constraint violations.
if len(delta.Delete) > 0 {
for _, del := range delta.Delete {
// check that provenance is not changed in an invalid way
storedProvenance, err := service.provenanceStore.GetProvenance(ctx, del, orgID)
if err != nil {
return err
}
if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance)
}
}
if err := service.deleteRules(ctx, orgID, delta.Delete...); err != nil {
return err
}
}
updates := make([]models.UpdateRule, 0, len(delta.Update))
for _, update := range delta.Update {
// check that provenance is not changed in an invalid way
storedProvenance, err := service.provenanceStore.GetProvenance(ctx, update.New, orgID)
if err != nil {
return err
if len(delta.Update) > 0 {
updates := make([]models.UpdateRule, 0, len(delta.Update))
for _, update := range delta.Update {
// check that provenance is not changed in an invalid way
storedProvenance, err := service.provenanceStore.GetProvenance(ctx, update.New, orgID)
if err != nil {
return err
}
if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance)
}
updates = append(updates, models.UpdateRule{
Existing: update.Existing,
New: *update.New,
})
}
if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance)
if err = service.ruleStore.UpdateAlertRules(ctx, updates); err != nil {
return fmt.Errorf("failed to update alert rules: %w", err)
}
updates = append(updates, models.UpdateRule{
Existing: update.Existing,
New: *update.New,
})
}
if err = service.ruleStore.UpdateAlertRules(ctx, updates); err != nil {
return fmt.Errorf("failed to update alert rules: %w", err)
}
for _, update := range delta.Update {
if err := service.provenanceStore.SetProvenance(ctx, update.New, orgID, provenance); err != nil {
return err
for _, update := range delta.Update {
if err := service.provenanceStore.SetProvenance(ctx, update.New, orgID, provenance); err != nil {
return err
}
}
}
for _, delete := range delta.Delete {
// check that provenance is not changed in an invalid way
storedProvenance, err := service.provenanceStore.GetProvenance(ctx, delete, orgID)
if len(delta.New) > 0 {
uids, err := service.ruleStore.InsertAlertRules(ctx, withoutNilAlertRules(delta.New))
if err != nil {
return err
return fmt.Errorf("failed to insert alert rules: %w", err)
}
if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance)
for uid := range uids {
if err := service.provenanceStore.SetProvenance(ctx, &models.AlertRule{UID: uid}, orgID, provenance); err != nil {
return err
}
}
}
if err := service.deleteRules(ctx, orgID, delta.Delete...); err != nil {
return err
}
if err = service.checkLimitsTransactionCtx(ctx, orgID, userID); err != nil {
return err
@@ -160,6 +160,210 @@ func TestAlertRuleService(t *testing.T) {
require.Equal(t, int64(2), readGroup.Rules[0].Version)
})
t.Run("updating a group to temporarily overlap rule names should not throw unique constraint", func(t *testing.T) {
var orgID int64 = 1
group := models.AlertRuleGroup{
Title: "overlap-test",
Interval: 60,
FolderUID: "my-namespace",
Rules: []models.AlertRule{
dummyRule("overlap-test-rule-1", orgID),
dummyRule("overlap-test-rule-2", orgID),
},
}
err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI)
require.NoError(t, err)
updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "overlap-test")
require.NoError(t, err)
updatedGroup.Rules[0].Title = "overlap-test-rule-2"
updatedGroup.Rules[1].Title = "overlap-test-rule-3"
err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI)
require.NoError(t, err)
readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "overlap-test")
require.NoError(t, err)
require.NotEmpty(t, readGroup.Rules)
require.Len(t, readGroup.Rules, 2)
require.Equal(t, "overlap-test-rule-2", readGroup.Rules[0].Title)
require.Equal(t, "overlap-test-rule-3", readGroup.Rules[1].Title)
require.Equal(t, int64(3), readGroup.Rules[0].Version)
require.Equal(t, int64(3), readGroup.Rules[1].Version)
})
t.Run("updating a group to swap the name of two rules should not throw unique constraint", func(t *testing.T) {
var orgID int64 = 1
group := models.AlertRuleGroup{
Title: "swap-test",
Interval: 60,
FolderUID: "my-namespace",
Rules: []models.AlertRule{
dummyRule("swap-test-rule-1", orgID),
dummyRule("swap-test-rule-2", orgID),
},
}
err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI)
require.NoError(t, err)
updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "swap-test")
require.NoError(t, err)
updatedGroup.Rules[0].Title = "swap-test-rule-2"
updatedGroup.Rules[1].Title = "swap-test-rule-1"
err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI)
require.NoError(t, err)
readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "swap-test")
require.NoError(t, err)
require.NotEmpty(t, readGroup.Rules)
require.Len(t, readGroup.Rules, 2)
require.Equal(t, "swap-test-rule-2", readGroup.Rules[0].Title)
require.Equal(t, "swap-test-rule-1", readGroup.Rules[1].Title)
require.Equal(t, int64(3), readGroup.Rules[0].Version) // Needed an extra update to break the update cycle.
require.Equal(t, int64(3), readGroup.Rules[1].Version)
})
t.Run("updating a group that has a rule name cycle should not throw unique constraint", func(t *testing.T) {
var orgID int64 = 1
group := models.AlertRuleGroup{
Title: "cycle-test",
Interval: 60,
FolderUID: "my-namespace",
Rules: []models.AlertRule{
dummyRule("cycle-test-rule-1", orgID),
dummyRule("cycle-test-rule-2", orgID),
dummyRule("cycle-test-rule-3", orgID),
},
}
err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI)
require.NoError(t, err)
updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "cycle-test")
require.NoError(t, err)
updatedGroup.Rules[0].Title = "cycle-test-rule-2"
updatedGroup.Rules[1].Title = "cycle-test-rule-3"
updatedGroup.Rules[2].Title = "cycle-test-rule-1"
err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI)
require.NoError(t, err)
readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "cycle-test")
require.NoError(t, err)
require.NotEmpty(t, readGroup.Rules)
require.Len(t, readGroup.Rules, 3)
require.Equal(t, "cycle-test-rule-2", readGroup.Rules[0].Title)
require.Equal(t, "cycle-test-rule-3", readGroup.Rules[1].Title)
require.Equal(t, "cycle-test-rule-1", readGroup.Rules[2].Title)
require.Equal(t, int64(3), readGroup.Rules[0].Version) // Needed an extra update to break the update cycle.
require.Equal(t, int64(3), readGroup.Rules[1].Version)
require.Equal(t, int64(3), readGroup.Rules[2].Version)
})
t.Run("updating a group that has multiple rule name cycles should not throw unique constraint", func(t *testing.T) {
var orgID int64 = 1
group := models.AlertRuleGroup{
Title: "multi-cycle-test",
Interval: 60,
FolderUID: "my-namespace",
Rules: []models.AlertRule{
dummyRule("multi-cycle-test-rule-1", orgID),
dummyRule("multi-cycle-test-rule-2", orgID),
dummyRule("multi-cycle-test-rule-3", orgID),
dummyRule("multi-cycle-test-rule-4", orgID),
dummyRule("multi-cycle-test-rule-5", orgID),
},
}
err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI)
require.NoError(t, err)
updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "multi-cycle-test")
require.NoError(t, err)
updatedGroup.Rules[0].Title = "multi-cycle-test-rule-2"
updatedGroup.Rules[1].Title = "multi-cycle-test-rule-1"
updatedGroup.Rules[2].Title = "multi-cycle-test-rule-4"
updatedGroup.Rules[3].Title = "multi-cycle-test-rule-5"
updatedGroup.Rules[4].Title = "multi-cycle-test-rule-3"
err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI)
require.NoError(t, err)
readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "multi-cycle-test")
require.NoError(t, err)
require.NotEmpty(t, readGroup.Rules)
require.Len(t, readGroup.Rules, 5)
require.Equal(t, "multi-cycle-test-rule-2", readGroup.Rules[0].Title)
require.Equal(t, "multi-cycle-test-rule-1", readGroup.Rules[1].Title)
require.Equal(t, "multi-cycle-test-rule-4", readGroup.Rules[2].Title)
require.Equal(t, "multi-cycle-test-rule-5", readGroup.Rules[3].Title)
require.Equal(t, "multi-cycle-test-rule-3", readGroup.Rules[4].Title)
require.Equal(t, int64(3), readGroup.Rules[0].Version) // Needed an extra update to break the update cycle.
require.Equal(t, int64(3), readGroup.Rules[1].Version)
require.Equal(t, int64(3), readGroup.Rules[2].Version) // Needed an extra update to break the update cycle.
require.Equal(t, int64(3), readGroup.Rules[3].Version)
require.Equal(t, int64(3), readGroup.Rules[4].Version)
})
t.Run("updating a group to recreate a rule using the same name should not throw unique constraint", func(t *testing.T) {
var orgID int64 = 1
group := models.AlertRuleGroup{
Title: "recreate-test",
Interval: 60,
FolderUID: "my-namespace",
Rules: []models.AlertRule{
dummyRule("recreate-test-rule-1", orgID),
},
}
err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI)
require.NoError(t, err)
updatedGroup := models.AlertRuleGroup{
Title: "recreate-test",
Interval: 60,
FolderUID: "my-namespace",
Rules: []models.AlertRule{
dummyRule("recreate-test-rule-1", orgID),
},
}
err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI)
require.NoError(t, err)
readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "recreate-test")
require.NoError(t, err)
require.NotEmpty(t, readGroup.Rules)
require.Len(t, readGroup.Rules, 1)
require.Equal(t, "recreate-test-rule-1", readGroup.Rules[0].Title)
require.Equal(t, int64(1), readGroup.Rules[0].Version)
})
t.Run("updating a group to create a rule that temporarily overlaps an existing should not throw unique constraint", func(t *testing.T) {
var orgID int64 = 1
group := models.AlertRuleGroup{
Title: "create-overlap-test",
Interval: 60,
FolderUID: "my-namespace",
Rules: []models.AlertRule{
dummyRule("create-overlap-test-rule-1", orgID),
},
}
err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI)
require.NoError(t, err)
updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "create-overlap-test")
require.NoError(t, err)
updatedGroup.Rules[0].Title = "create-overlap-test-rule-2"
updatedGroup.Rules = append(updatedGroup.Rules, dummyRule("create-overlap-test-rule-1", orgID))
err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI)
require.NoError(t, err)
readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "create-overlap-test")
require.NoError(t, err)
require.NotEmpty(t, readGroup.Rules)
require.Len(t, readGroup.Rules, 2)
require.Equal(t, "create-overlap-test-rule-2", readGroup.Rules[0].Title)
require.Equal(t, "create-overlap-test-rule-1", readGroup.Rules[1].Title)
require.Equal(t, int64(2), readGroup.Rules[0].Version)
require.Equal(t, int64(1), readGroup.Rules[1].Version)
})
t.Run("updating a group by updating a rule should not remove dashboard and panel ids", func(t *testing.T) {
dashboardUid := "huYnkl7H"
panelId := int64(5678)
+9 -18
View File
@@ -8,9 +8,7 @@ import (
"time"
"github.com/benbjohnson/clock"
alertingModels "github.com/grafana/alerting/models"
"github.com/hashicorp/go-multierror"
prometheusModel "github.com/prometheus/common/model"
"go.opentelemetry.io/otel/attribute"
"golang.org/x/sync/errgroup"
@@ -355,7 +353,7 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR
evalTotalFailures := sch.metrics.EvalFailures.WithLabelValues(orgID)
notify := func(states []state.StateTransition) {
expiredAlerts := FromAlertsStateToStoppedAlert(states, sch.appURL, sch.clock)
expiredAlerts := state.FromAlertsStateToStoppedAlert(states, sch.appURL, sch.clock)
if len(expiredAlerts.PostableAlerts) > 0 {
sch.alertsSender.Send(key, expiredAlerts)
}
@@ -425,8 +423,14 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR
logger.Debug("Skip updating the state because the context has been cancelled")
return
}
processedStates := sch.stateManager.ProcessEvalResults(ctx, e.scheduledAt, e.rule, results, sch.getRuleExtraLabels(e))
alerts := FromStateTransitionToPostableAlerts(processedStates, sch.stateManager, sch.appURL)
processedStates := sch.stateManager.ProcessEvalResults(
ctx,
e.scheduledAt,
e.rule,
results,
state.GetRuleExtraLabels(e.rule, e.folderTitle, !sch.disableGrafanaFolder),
)
alerts := state.FromStateTransitionToPostableAlerts(processedStates, sch.stateManager, sch.appURL)
span.AddEvents(
[]string{"message", "state_transitions", "alerts_to_send"},
[]tracing.EventValue{
@@ -558,19 +562,6 @@ func (sch *schedule) stopApplied(alertDefKey ngmodels.AlertRuleKey) {
sch.stopAppliedFunc(alertDefKey)
}
func (sch *schedule) getRuleExtraLabels(evalCtx *evaluation) map[string]string {
extraLabels := make(map[string]string, 4)
extraLabels[alertingModels.NamespaceUIDLabel] = evalCtx.rule.NamespaceUID
extraLabels[prometheusModel.AlertNameLabel] = evalCtx.rule.Title
extraLabels[alertingModels.RuleUIDLabel] = evalCtx.rule.UID
if !sch.disableGrafanaFolder {
extraLabels[ngmodels.FolderTitleLabel] = evalCtx.folderTitle
}
return extraLabels
}
func SchedulerUserFor(orgID int64) *user.SignedInUser {
return &user.SignedInUser{
UserID: -1,
@@ -676,7 +676,7 @@ func TestSchedule_ruleRoutine(t *testing.T) {
args, ok := sender.Calls[0].Arguments[1].(definitions.PostableAlerts)
require.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls[0].Arguments[1]))
assert.Len(t, args.PostableAlerts, 1)
assert.Equal(t, ErrorAlertName, args.PostableAlerts[0].Labels[prometheusModel.AlertNameLabel])
assert.Equal(t, state.ErrorAlertName, args.PostableAlerts[0].Labels[prometheusModel.AlertNameLabel])
})
})
@@ -1,4 +1,4 @@
package schedule
package state
import (
"encoding/json"
@@ -18,7 +18,6 @@ import (
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/state"
)
const (
@@ -28,13 +27,13 @@ const (
Rulename = "rulename"
)
// stateToPostableAlert converts a state to a model that is accepted by Alertmanager. Annotations and Labels are copied from the state.
// StateToPostableAlert converts a state to a model that is accepted by Alertmanager. Annotations and Labels are copied from the state.
// - if state has at least one result, a new label '__value_string__' is added to the label set
// - the alert's GeneratorURL is constructed to point to the alert detail view
// - if evaluation state is either NoData or Error, the resulting set of labels is changed:
// - original alert name (label: model.AlertNameLabel) is backed up to OriginalAlertName
// - label model.AlertNameLabel is overwritten to either NoDataAlertName or ErrorAlertName
func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.PostableAlert {
func StateToPostableAlert(alertState *State, appURL *url.URL) *models.PostableAlert {
nL := alertState.Labels.Copy()
nA := data.Labels(alertState.Annotations).Copy()
@@ -50,7 +49,10 @@ func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.Post
}
if alertState.Image != nil {
nA[alertingModels.ImageTokenAnnotation] = generateImageURI(alertState.Image)
imageURI := generateImageURI(alertState.Image)
if imageURI != "" {
nA[alertingModels.ImageTokenAnnotation] = imageURI
}
}
if alertState.StateReason != "" {
@@ -95,7 +97,7 @@ func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.Post
// It effectively replaces the legacy behavior of "Keep Last State" by separating the regular alerting flow from the no data scenario into a separate alerts.
// The Alert is defined as:
// { alertname=DatasourceNoData rulename=original_alertname } + { rule labelset } + { rule annotations }
func noDataAlert(labels data.Labels, annotations data.Labels, alertState *state.State, urlStr string) *models.PostableAlert {
func noDataAlert(labels data.Labels, annotations data.Labels, alertState *State, urlStr string) *models.PostableAlert {
if name, ok := labels[model.AlertNameLabel]; ok {
labels[Rulename] = name
}
@@ -114,7 +116,7 @@ func noDataAlert(labels data.Labels, annotations data.Labels, alertState *state.
// errorAlert is a special alert sent when evaluation of an alert rule failed due to an error. Like noDataAlert, it
// replaces the old behaviour of "Keep Last State" creating a separate alert called DatasourceError.
func errorAlert(labels, annotations data.Labels, alertState *state.State, urlStr string) *models.PostableAlert {
func errorAlert(labels, annotations data.Labels, alertState *State, urlStr string) *models.PostableAlert {
if name, ok := labels[model.AlertNameLabel]; ok {
labels[Rulename] = name
}
@@ -131,16 +133,16 @@ func errorAlert(labels, annotations data.Labels, alertState *state.State, urlStr
}
}
func FromStateTransitionToPostableAlerts(firingStates []state.StateTransition, stateManager *state.Manager, appURL *url.URL) apimodels.PostableAlerts {
func FromStateTransitionToPostableAlerts(firingStates []StateTransition, stateManager *Manager, appURL *url.URL) apimodels.PostableAlerts {
alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))}
var sentAlerts []*state.State
var sentAlerts []*State
ts := time.Now()
for _, alertState := range firingStates {
if !alertState.NeedsSending(stateManager.ResendDelay) {
continue
}
alert := stateToPostableAlert(alertState.State, appURL)
alert := StateToPostableAlert(alertState.State, appURL)
alerts.PostableAlerts = append(alerts.PostableAlerts, *alert)
if alertState.StateReason == ngModels.StateReasonMissingSeries { // do not put stale state back to state manager
continue
@@ -154,14 +156,14 @@ func FromStateTransitionToPostableAlerts(firingStates []state.StateTransition, s
// FromAlertsStateToStoppedAlert selects only transitions from firing states (states eval.Alerting, eval.NoData, eval.Error)
// and converts them to models.PostableAlert with EndsAt set to time.Now
func FromAlertsStateToStoppedAlert(firingStates []state.StateTransition, appURL *url.URL, clock clock.Clock) apimodels.PostableAlerts {
func FromAlertsStateToStoppedAlert(firingStates []StateTransition, appURL *url.URL, clock clock.Clock) apimodels.PostableAlerts {
alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))}
ts := clock.Now()
for _, transition := range firingStates {
if transition.PreviousState == eval.Normal || transition.PreviousState == eval.Pending {
continue
}
postableAlert := stateToPostableAlert(transition.State, appURL)
postableAlert := StateToPostableAlert(transition.State, appURL)
postableAlert.EndsAt = strfmt.DateTime(ts)
alerts.PostableAlerts = append(alerts.PostableAlerts, *postableAlert)
}
@@ -175,5 +177,9 @@ func generateImageURI(image *ngModels.Image) string {
if image.URL != "" {
return image.URL
}
return "token://" + image.Token
if image.Token != "" {
return "token://" + image.Token
}
return ""
}
@@ -1,4 +1,4 @@
package schedule
package state
import (
"fmt"
@@ -16,11 +16,10 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/eval"
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/state"
"github.com/grafana/grafana/pkg/util"
)
func Test_stateToPostableAlert(t *testing.T) {
func Test_StateToPostableAlert(t *testing.T) {
appURL := &url.URL{
Scheme: "http:",
Host: fmt.Sprintf("host-%d", rand.Int()),
@@ -59,7 +58,7 @@ func Test_stateToPostableAlert(t *testing.T) {
t.Run("to alert rule", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Labels[alertingModels.RuleUIDLabel] = alertState.AlertRuleUID
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
u := *appURL
u.Path = u.Path + "/alerting/grafana/" + alertState.AlertRuleUID + "/view"
require.Equal(t, u.String(), result.Alert.GeneratorURL.String())
@@ -68,25 +67,25 @@ func Test_stateToPostableAlert(t *testing.T) {
t.Run("app URL as is if rule UID is not specified", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Labels[alertingModels.RuleUIDLabel] = ""
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, appURL.String(), result.Alert.GeneratorURL.String())
delete(alertState.Labels, alertingModels.RuleUIDLabel)
result = stateToPostableAlert(alertState, appURL)
result = StateToPostableAlert(alertState, appURL)
require.Equal(t, appURL.String(), result.Alert.GeneratorURL.String())
})
t.Run("empty string if app URL is not provided", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Labels[alertingModels.RuleUIDLabel] = alertState.AlertRuleUID
result := stateToPostableAlert(alertState, nil)
result := StateToPostableAlert(alertState, nil)
require.Equal(t, "", result.Alert.GeneratorURL.String())
})
})
t.Run("Start and End timestamps should be the same", func(t *testing.T) {
alertState := randomState(tc.state)
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, strfmt.DateTime(alertState.StartsAt), result.StartsAt)
require.Equal(t, strfmt.DateTime(alertState.EndsAt), result.EndsAt)
})
@@ -94,7 +93,7 @@ func Test_stateToPostableAlert(t *testing.T) {
t.Run("should copy annotations", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Annotations = randomMapOfStrings()
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, models.LabelSet(alertState.Annotations), result.Annotations)
t.Run("add __value_string__ if it has results", func(t *testing.T) {
@@ -103,7 +102,7 @@ func Test_stateToPostableAlert(t *testing.T) {
expectedValueString := util.GenerateShortUID()
alertState.LastEvaluationString = expectedValueString
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
expected := make(models.LabelSet, len(alertState.Annotations)+1)
for k, v := range alertState.Annotations {
@@ -115,7 +114,7 @@ func Test_stateToPostableAlert(t *testing.T) {
// even overwrites
alertState.Annotations["__value_string__"] = util.GenerateShortUID()
result = stateToPostableAlert(alertState, appURL)
result = StateToPostableAlert(alertState, appURL)
require.Equal(t, expected, result.Annotations)
})
@@ -124,7 +123,7 @@ func Test_stateToPostableAlert(t *testing.T) {
alertState.Annotations = randomMapOfStrings()
alertState.Image = &ngModels.Image{Token: "test_token"}
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
expected := make(models.LabelSet, len(alertState.Annotations)+1)
for k, v := range alertState.Annotations {
@@ -134,12 +133,27 @@ func Test_stateToPostableAlert(t *testing.T) {
require.Equal(t, expected, result.Annotations)
})
t.Run("don't add __alertImageToken__ if there's no image token", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Annotations = randomMapOfStrings()
alertState.Image = &ngModels.Image{}
result := StateToPostableAlert(alertState, appURL)
expected := make(models.LabelSet, len(alertState.Annotations)+1)
for k, v := range alertState.Annotations {
expected[k] = v
}
require.Equal(t, expected, result.Annotations)
})
})
t.Run("should add state reason annotation if not empty", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.StateReason = "TEST_STATE_REASON"
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, alertState.StateReason, result.Annotations[ngModels.StateReasonAnnotation])
})
@@ -151,7 +165,7 @@ func Test_stateToPostableAlert(t *testing.T) {
alertName := util.GenerateShortUID()
alertState.Labels[model.AlertNameLabel] = alertName
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
expected := make(models.LabelSet, len(alertState.Labels)+1)
for k, v := range alertState.Labels {
@@ -167,7 +181,7 @@ func Test_stateToPostableAlert(t *testing.T) {
alertState.Labels = randomMapOfStrings()
delete(alertState.Labels, model.AlertNameLabel)
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, NoDataAlertName, result.Labels[model.AlertNameLabel])
require.NotContains(t, result.Labels[model.AlertNameLabel], Rulename)
@@ -180,7 +194,7 @@ func Test_stateToPostableAlert(t *testing.T) {
alertName := util.GenerateShortUID()
alertState.Labels[model.AlertNameLabel] = alertName
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
expected := make(models.LabelSet, len(alertState.Labels)+1)
for k, v := range alertState.Labels {
@@ -196,7 +210,7 @@ func Test_stateToPostableAlert(t *testing.T) {
alertState.Labels = randomMapOfStrings()
delete(alertState.Labels, model.AlertNameLabel)
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, ErrorAlertName, result.Labels[model.AlertNameLabel])
require.NotContains(t, result.Labels[model.AlertNameLabel], Rulename)
@@ -206,7 +220,7 @@ func Test_stateToPostableAlert(t *testing.T) {
t.Run("should copy labels as is", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Labels = randomMapOfStrings()
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, models.LabelSet(alertState.Labels), result.Labels)
})
}
@@ -222,10 +236,10 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) {
}
evalStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.Error, eval.NoData}
states := make([]state.StateTransition, 0, len(evalStates)*len(evalStates))
states := make([]StateTransition, 0, len(evalStates)*len(evalStates))
for _, to := range evalStates {
for _, from := range evalStates {
states = append(states, state.StateTransition{
states = append(states, StateTransition{
State: randomState(to),
PreviousState: from,
})
@@ -240,7 +254,7 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) {
if !(s.PreviousState == eval.Alerting || s.PreviousState == eval.Error || s.PreviousState == eval.NoData) {
continue
}
alert := stateToPostableAlert(s.State, appURL)
alert := StateToPostableAlert(s.State, appURL)
alert.EndsAt = strfmt.DateTime(clk.Now())
expected = append(expected, *alert)
}
@@ -271,8 +285,8 @@ func randomTimeInPast() time.Time {
return time.Now().Add(-randomDuration())
}
func randomState(evalState eval.State) *state.State {
return &state.State{
func randomState(evalState eval.State) *State {
return &State{
State: evalState,
AlertRuleUID: util.GenerateShortUID(),
StartsAt: time.Now(),
+16
View File
@@ -8,7 +8,9 @@ import (
"strings"
"time"
alertingModels "github.com/grafana/alerting/models"
"github.com/grafana/grafana-plugin-sdk-go/data"
prometheusModel "github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/expr"
"github.com/grafana/grafana/pkg/infra/log"
@@ -397,3 +399,17 @@ func FormatStateAndReason(state eval.State, reason string) string {
}
return s
}
// GetRuleExtraLabels returns a map of built-in labels that should be added to an alert before it is sent to the Alertmanager or its state is cached.
func GetRuleExtraLabels(rule *models.AlertRule, folderTitle string, includeFolder bool) map[string]string {
extraLabels := make(map[string]string, 4)
extraLabels[alertingModels.NamespaceUIDLabel] = rule.NamespaceUID
extraLabels[prometheusModel.AlertNameLabel] = rule.Title
extraLabels[alertingModels.RuleUIDLabel] = rule.UID
if includeFolder {
extraLabels[models.FolderTitleLabel] = folderTitle
}
return extraLabels
}
+78
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"strings"
"github.com/google/uuid"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
@@ -180,6 +182,11 @@ func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRu
// UpdateAlertRules is a handler for updating alert rules.
func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateRule) error {
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
err := st.preventIntermediateUniqueConstraintViolations(sess, rules)
if err != nil {
return fmt.Errorf("failed when preventing intermediate unique constraint violation: %w", err)
}
ruleVersions := make([]ngmodels.AlertRuleVersion, 0, len(rules))
for _, r := range rules {
var parentVersion int64
@@ -231,6 +238,77 @@ func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateR
})
}
// preventIntermediateUniqueConstraintViolations prevents unique constraint violations caused by an intermediate update.
// The uniqueness constraint for titles within an org+folder is enforced on every update within a transaction
// instead of on commit (deferred constraint). This means that there could be a set of updates that will throw
// a unique constraint violation in an intermediate step even though the final state is valid.
// For example, a chain of updates RuleA -> RuleB -> RuleC could fail if not executed in the correct order, or
// a swap of titles RuleA <-> RuleB cannot be executed in any order without violating the constraint.
func (st DBstore) preventIntermediateUniqueConstraintViolations(sess *db.Session, updates []ngmodels.UpdateRule) error {
// The exact solution to this is complex and requires determining directed paths and cycles in the update graph,
// adding in temporary updates to break cycles, and then executing the updates in reverse topological order.
// This is not implemented here. Instead, we choose a simpler solution that works in all cases but might perform
// more updates than necessary. This simpler solution makes a determination of whether an intermediate collision
// could occur and if so, adds a temporary title on all updated rules to break any cycles and remove the need for
// specific ordering.
titleUpdates := make([]ngmodels.UpdateRule, 0)
for _, update := range updates {
if update.Existing.Title != update.New.Title {
titleUpdates = append(titleUpdates, update)
}
}
// If there is no overlap then an intermediate unique constraint violation is not possible. If there is an overlap,
// then there is the possibility of intermediate unique constraint violation.
if !newTitlesOverlapExisting(titleUpdates) {
return nil
}
st.Logger.Debug("detected possible intermediate unique constraint violation, creating temporary title updates", "updates", len(titleUpdates))
for _, update := range titleUpdates {
r := update.Existing
u := uuid.New().String()
// Some defensive programming in case the temporary title is somehow persisted it will still be recognizable.
uniqueTempTitle := r.Title + u
if len(uniqueTempTitle) > AlertRuleMaxTitleLength {
uniqueTempTitle = r.Title[:AlertRuleMaxTitleLength-len(u)] + uuid.New().String()
}
if updated, err := sess.ID(r.ID).Cols("title").Update(&ngmodels.AlertRule{Title: uniqueTempTitle, Version: r.Version}); err != nil || updated == 0 {
if err != nil {
return fmt.Errorf("failed to set temporary rule title [%s] %s: %w", r.UID, r.Title, err)
}
return fmt.Errorf("%w: alert rule UID %s version %d", ErrOptimisticLock, r.UID, r.Version)
}
// Otherwise optimistic locking will conflict on the 2nd update.
r.Version++
// For consistency.
r.Title = uniqueTempTitle
}
return nil
}
// newTitlesOverlapExisting returns true if any new titles overlap with existing titles.
// It does so in a case-insensitive manner as some supported databases perform case-insensitive comparisons.
func newTitlesOverlapExisting(rules []ngmodels.UpdateRule) bool {
existingTitles := make(map[string]struct{}, len(rules))
for _, r := range rules {
existingTitles[strings.ToLower(r.Existing.Title)] = struct{}{}
}
// Check if there is any overlap between lower case existing and new titles.
for _, r := range rules {
if _, ok := existingTitles[strings.ToLower(r.New.Title)]; ok {
return true
}
}
return false
}
// CountInFolder is a handler for retrieving the number of alert rules of
// specific organisation associated with a given namespace (parent folder).
func (st DBstore) CountInFolder(ctx context.Context, orgID int64, folderUID string, u *user.SignedInUser) (int64, error) {
+235 -1
View File
@@ -4,11 +4,13 @@ import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/log/logtest"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
@@ -37,6 +39,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
SQLStore: sqlStore,
Cfg: cfg.UnifiedAlerting,
FolderService: setupFolderService(t, sqlStore, cfg),
Logger: &logtest.Fake{},
}
generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID())
@@ -79,6 +82,236 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
})
}
func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
cfg := setting.NewCfg()
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second}
sqlStore := db.InitTestDB(t)
store := &DBstore{
SQLStore: sqlStore,
Cfg: cfg.UnifiedAlerting,
FolderService: setupFolderService(t, sqlStore, cfg),
Logger: &logtest.Fake{},
}
idMutator := models.WithUniqueID()
createRuleInFolder := func(title string, orgID int64, namespaceUID string) *models.AlertRule {
generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), idMutator, models.WithNamespace(&folder.Folder{
UID: namespaceUID,
Title: namespaceUID,
}), withOrgID(orgID), models.WithTitle(title))
return createRule(t, store, generator)
}
t.Run("should handle update chains without unique constraint violation", func(t *testing.T) {
rule1 := createRuleInFolder("chain-rule1", 1, "my-namespace")
rule2 := createRuleInFolder("chain-rule2", 1, "my-namespace")
newRule1 := models.CopyRule(rule1)
newRule2 := models.CopyRule(rule2)
newRule1.Title = rule2.Title
newRule2.Title = util.GenerateShortUID()
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
Existing: rule1,
New: *newRule1,
}, {
Existing: rule2,
New: *newRule2,
},
})
require.NoError(t, err)
dbrule1 := &models.AlertRule{}
dbrule2 := &models.AlertRule{}
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
exist, err := sess.Table(models.AlertRule{}).ID(rule1.ID).Get(dbrule1)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
exist, err = sess.Table(models.AlertRule{}).ID(rule2.ID).Get(dbrule2)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
return nil
})
require.NoError(t, err)
require.Equal(t, newRule1.Title, dbrule1.Title)
require.Equal(t, newRule2.Title, dbrule2.Title)
})
t.Run("should handle update chains with cycle without unique constraint violation", func(t *testing.T) {
rule1 := createRuleInFolder("cycle-rule1", 1, "my-namespace")
rule2 := createRuleInFolder("cycle-rule2", 1, "my-namespace")
rule3 := createRuleInFolder("cycle-rule3", 1, "my-namespace")
newRule1 := models.CopyRule(rule1)
newRule2 := models.CopyRule(rule2)
newRule3 := models.CopyRule(rule3)
newRule1.Title = rule2.Title
newRule2.Title = rule3.Title
newRule3.Title = rule1.Title
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
Existing: rule1,
New: *newRule1,
}, {
Existing: rule2,
New: *newRule2,
}, {
Existing: rule3,
New: *newRule3,
},
})
require.NoError(t, err)
dbrule1 := &models.AlertRule{}
dbrule2 := &models.AlertRule{}
dbrule3 := &models.AlertRule{}
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
exist, err := sess.Table(models.AlertRule{}).ID(rule1.ID).Get(dbrule1)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
exist, err = sess.Table(models.AlertRule{}).ID(rule2.ID).Get(dbrule2)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
exist, err = sess.Table(models.AlertRule{}).ID(rule3.ID).Get(dbrule3)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule3.ID))
return nil
})
require.NoError(t, err)
require.Equal(t, newRule1.Title, dbrule1.Title)
require.Equal(t, newRule2.Title, dbrule2.Title)
require.Equal(t, newRule3.Title, dbrule3.Title)
})
t.Run("should handle case-insensitive intermediate collision without unique constraint violation", func(t *testing.T) {
rule1 := createRuleInFolder("case-cycle-rule1", 1, "my-namespace")
rule2 := createRuleInFolder("case-cycle-rule2", 1, "my-namespace")
newRule1 := models.CopyRule(rule1)
newRule2 := models.CopyRule(rule2)
newRule1.Title = strings.ToUpper(rule2.Title)
newRule2.Title = strings.ToUpper(rule1.Title)
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
Existing: rule1,
New: *newRule1,
}, {
Existing: rule2,
New: *newRule2,
},
})
require.NoError(t, err)
dbrule1 := &models.AlertRule{}
dbrule2 := &models.AlertRule{}
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
exist, err := sess.Table(models.AlertRule{}).ID(rule1.ID).Get(dbrule1)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
exist, err = sess.Table(models.AlertRule{}).ID(rule2.ID).Get(dbrule2)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
return nil
})
require.NoError(t, err)
require.Equal(t, newRule1.Title, dbrule1.Title)
require.Equal(t, newRule2.Title, dbrule2.Title)
})
t.Run("should handle update multiple chains in different folders without unique constraint violation", func(t *testing.T) {
rule1 := createRuleInFolder("multi-cycle-rule1", 1, "my-namespace")
rule2 := createRuleInFolder("multi-cycle-rule2", 1, "my-namespace")
rule3 := createRuleInFolder("multi-cycle-rule1", 1, "my-namespace2")
rule4 := createRuleInFolder("multi-cycle-rule2", 1, "my-namespace2")
newRule1 := models.CopyRule(rule1)
newRule2 := models.CopyRule(rule2)
newRule3 := models.CopyRule(rule3)
newRule4 := models.CopyRule(rule4)
newRule1.Title = rule2.Title
newRule2.Title = rule1.Title
newRule3.Title = rule4.Title
newRule4.Title = rule3.Title
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
Existing: rule1,
New: *newRule1,
}, {
Existing: rule2,
New: *newRule2,
}, {
Existing: rule3,
New: *newRule3,
}, {
Existing: rule4,
New: *newRule4,
},
})
require.NoError(t, err)
dbrule1 := &models.AlertRule{}
dbrule2 := &models.AlertRule{}
dbrule3 := &models.AlertRule{}
dbrule4 := &models.AlertRule{}
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
exist, err := sess.Table(models.AlertRule{}).ID(rule1.ID).Get(dbrule1)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
exist, err = sess.Table(models.AlertRule{}).ID(rule2.ID).Get(dbrule2)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
exist, err = sess.Table(models.AlertRule{}).ID(rule3.ID).Get(dbrule3)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule3.ID))
exist, err = sess.Table(models.AlertRule{}).ID(rule4.ID).Get(dbrule4)
if err != nil {
return err
}
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule4.ID))
return nil
})
require.NoError(t, err)
require.Equal(t, newRule1.Title, dbrule1.Title)
require.Equal(t, newRule2.Title, dbrule2.Title)
require.Equal(t, newRule3.Title, dbrule3.Title)
require.Equal(t, newRule4.Title, dbrule4.Title)
})
}
func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
@@ -99,6 +332,8 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID(), models.WithUniqueOrgID())
rule1 := createRule(t, store, generator)
rule2 := createRule(t, store, generator)
createFolder(t, store, rule1.NamespaceUID, rule1.Title, rule1.OrgID)
createFolder(t, store, rule2.NamespaceUID, rule2.Title, rule2.OrgID)
tc := []struct {
name string
@@ -251,7 +486,6 @@ func createRule(t *testing.T, store *DBstore, generate func() *models.AlertRule)
generate = models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID())
}
rule := generate()
createFolder(t, store, rule.NamespaceUID, rule.Title, rule.OrgID)
err := store.SQLStore.WithDbSession(context.Background(), func(sess *db.Session) error {
_, err := sess.Table(models.AlertRule{}).InsertOne(rule)
if err != nil {
@@ -39,20 +39,23 @@ type Provider struct {
// Get allows getting plugin context by its ID. If datasourceUID is not empty string
// then PluginContext.DataSourceInstanceSettings will be resolved and appended to
// returned context.
func (p *Provider) Get(ctx context.Context, pluginID string, user *user.SignedInUser) (backend.PluginContext, error) {
// Note: *user.SignedInUser can be nil.
func (p *Provider) Get(ctx context.Context, pluginID string, user *user.SignedInUser, orgID int64) (backend.PluginContext, error) {
plugin, exists := p.pluginStore.Plugin(ctx, pluginID)
if !exists {
return backend.PluginContext{}, ErrPluginNotFound
}
pCtx := backend.PluginContext{
OrgID: user.OrgID,
PluginID: pluginID,
User: adapters.BackendUserFromSignedInUser(user),
}
if user != nil {
pCtx.OrgID = user.OrgID
pCtx.User = adapters.BackendUserFromSignedInUser(user)
}
if plugin.IsApp() {
appSettings, err := p.appInstanceSettings(ctx, pluginID, user)
appSettings, err := p.appInstanceSettings(ctx, pluginID, orgID)
if err != nil {
return backend.PluginContext{}, err
}
@@ -64,10 +67,19 @@ func (p *Provider) Get(ctx context.Context, pluginID string, user *user.SignedIn
// GetWithDataSource allows getting plugin context by its ID and PluginContext.DataSourceInstanceSettings will be
// resolved and appended to the returned context.
// Note: *user.SignedInUser can be nil.
func (p *Provider) GetWithDataSource(ctx context.Context, pluginID string, user *user.SignedInUser, ds *datasources.DataSource) (backend.PluginContext, error) {
pCtx, err := p.Get(ctx, pluginID, user)
if err != nil {
return backend.PluginContext{}, err
_, exists := p.pluginStore.Plugin(ctx, pluginID)
if !exists {
return backend.PluginContext{}, ErrPluginNotFound
}
pCtx := backend.PluginContext{
PluginID: pluginID,
}
if user != nil {
pCtx.OrgID = user.OrgID
pCtx.User = adapters.BackendUserFromSignedInUser(user)
}
datasourceSettings, err := adapters.ModelToInstanceSettings(ds, p.decryptSecureJsonDataFn(ctx))
@@ -82,12 +94,12 @@ func (p *Provider) GetWithDataSource(ctx context.Context, pluginID string, user
const pluginSettingsCacheTTL = 5 * time.Second
const pluginSettingsCachePrefix = "plugin-setting-"
func (p *Provider) appInstanceSettings(ctx context.Context, pluginID string, user *user.SignedInUser) (*backend.AppInstanceSettings, error) {
func (p *Provider) appInstanceSettings(ctx context.Context, pluginID string, orgID int64) (*backend.AppInstanceSettings, error) {
jsonData := json.RawMessage{}
decryptedSecureJSONData := map[string]string{}
var updated time.Time
ps, err := p.getCachedPluginSettings(ctx, pluginID, user)
ps, err := p.getCachedPluginSettings(ctx, pluginID, orgID)
if err != nil {
// pluginsettings.ErrPluginSettingNotFound is expected if there's no row found for plugin setting in database (if non-app plugin).
// Otherwise, something is wrong with cache or database, and we return the error to the client.
@@ -114,19 +126,19 @@ func (p *Provider) InvalidateSettingsCache(_ context.Context, pluginID string) {
p.cacheService.Delete(getCacheKey(pluginID))
}
func (p *Provider) getCachedPluginSettings(ctx context.Context, pluginID string, user *user.SignedInUser) (*pluginsettings.DTO, error) {
func (p *Provider) getCachedPluginSettings(ctx context.Context, pluginID string, orgID int64) (*pluginsettings.DTO, error) {
cacheKey := getCacheKey(pluginID)
if cached, found := p.cacheService.Get(cacheKey); found {
ps := cached.(*pluginsettings.DTO)
if ps.OrgID == user.OrgID {
if ps.OrgID == orgID {
return ps, nil
}
}
ps, err := p.pluginSettingsService.GetPluginSettingByPluginID(ctx, &pluginsettings.GetByPluginIDArgs{
PluginID: pluginID,
OrgID: user.OrgID,
OrgID: orgID,
})
if err != nil {
return nil, err
@@ -2119,237 +2119,6 @@ func TestIntegrationEval(t *testing.T) {
expectedStatusCode func() int
expectedResponse func() string
expectedMessage func() string
}{
{
desc: "alerting condition",
payload: `
{
"grafana_condition": {
"condition": "A",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid":"__expr__",
"model": {
"type":"math",
"expression":"1 < 2"
}
}
],
"now": "2021-04-11T14:38:14Z"
}
}
`,
expectedMessage: func() string { return "" },
expectedStatusCode: func() int { return http.StatusOK },
expectedResponse: func() string {
return `{
"instances": [
{
"schema": {
"name": "evaluation results",
"fields": [
{
"name": "State",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "Info",
"type": "string",
"typeInfo": {
"frame": "string"
}
}
]
},
"data": {
"values": [
[
"Alerting"
],
[
"[ var='A' labels={} value=1 ]"
]
]
}
}
]
}`
},
},
{
desc: "normal condition",
payload: `
{
"grafana_condition": {
"condition": "A",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "__expr__",
"model": {
"type":"math",
"expression":"1 > 2"
}
}
],
"now": "2021-04-11T14:38:14Z"
}
}
`,
expectedMessage: func() string { return "" },
expectedStatusCode: func() int { return http.StatusOK },
expectedResponse: func() string {
return `{
"instances": [
{
"schema": {
"name": "evaluation results",
"fields": [
{
"name": "State",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "Info",
"type": "string",
"typeInfo": {
"frame": "string"
}
}
]
},
"data": {
"values": [
[
"Normal"
],
[
"[ var='A' labels={} value=0 ]"
]
]
}
}
]
}`
},
},
{
desc: "condition not found in any query or expression",
payload: `
{
"grafana_condition": {
"condition": "B",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "__expr__",
"model": {
"type":"math",
"expression":"1 > 2"
}
}
],
"now": "2021-04-11T14:38:14Z"
}
}
`,
expectedStatusCode: func() int { return http.StatusBadRequest },
expectedMessage: func() string {
return "invalid condition: condition B does not exist, must be one of [A]"
},
expectedResponse: func() string { return "" },
},
{
desc: "unknown query datasource",
payload: `
{
"grafana_condition": {
"condition": "A",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "unknown",
"model": {
}
}
],
"now": "2021-04-11T14:38:14Z"
}
}
`,
expectedStatusCode: func() int {
if setting.IsEnterprise {
return http.StatusUnauthorized
}
return http.StatusBadRequest
},
expectedMessage: func() string {
if setting.IsEnterprise {
return "user is not authorized to query one or many data sources used by the rule"
}
return "invalid condition: failed to build query 'A': data source not found"
},
expectedResponse: func() string { return "" },
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
u := fmt.Sprintf("http://grafana:password@%s/api/v1/rule/test/grafana", grafanaListedAddr)
r := strings.NewReader(tc.payload)
// nolint:gosec
resp, err := http.Post(u, "application/json", r)
require.NoError(t, err)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
res := Response{}
err = json.Unmarshal(b, &res)
require.NoError(t, err)
assert.Equal(t, tc.expectedStatusCode(), resp.StatusCode)
if tc.expectedResponse() != "" {
require.JSONEq(t, tc.expectedResponse(), string(b))
}
if tc.expectedMessage() != "" {
assert.Equal(t, tc.expectedMessage(), res.Message)
}
})
}
// test eval queries and expressions
testCases = []struct {
desc string
payload string
expectedStatusCode func() int
expectedResponse func() string
expectedMessage func() string
}{
{
desc: "alerting condition",
+28 -7
View File
@@ -358,9 +358,10 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) {
require.Len(t, createdRuleGroup.Rules, 2)
t.Run("trying to create alert with same title under same folder should fail", func(t *testing.T) {
rules := newTestingRuleConfig(t)
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup)
rulesWithUID.Rules = append(rulesWithUID.Rules, rules.Rules[0]) // Create new copy of first rule.
status, body := apiClient.PostRulesGroup(t, "folder1", &rules)
status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID)
assert.Equal(t, http.StatusInternalServerError, status)
var res map[string]interface{}
@@ -369,12 +370,10 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) {
})
t.Run("trying to update an alert to the title of an existing alert in the same folder should fail", func(t *testing.T) {
rules := newTestingRuleConfig(t)
rules.Rules[0].GrafanaManagedAlert.UID = createdRuleGroup.Rules[0].GrafanaManagedAlert.UID
rules.Rules[1].GrafanaManagedAlert.UID = createdRuleGroup.Rules[1].GrafanaManagedAlert.UID
rules.Rules[1].GrafanaManagedAlert.Title = "AlwaysFiring"
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup)
rulesWithUID.Rules[1].GrafanaManagedAlert.Title = "AlwaysFiring"
status, body := apiClient.PostRulesGroup(t, "folder1", &rules)
status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID)
assert.Equal(t, http.StatusInternalServerError, status)
var res map[string]interface{}
@@ -388,6 +387,28 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) {
assert.Equal(t, http.StatusAccepted, status)
require.JSONEq(t, `{"message":"rule group updated successfully"}`, body)
})
t.Run("trying to swap titles of existing alerts in the same folder should work", func(t *testing.T) {
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup)
title0 := rulesWithUID.Rules[0].GrafanaManagedAlert.Title
title1 := rulesWithUID.Rules[1].GrafanaManagedAlert.Title
rulesWithUID.Rules[0].GrafanaManagedAlert.Title = title1
rulesWithUID.Rules[1].GrafanaManagedAlert.Title = title0
status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID)
assert.Equal(t, http.StatusAccepted, status)
require.JSONEq(t, `{"message":"rule group updated successfully"}`, body)
})
t.Run("trying to update titles of existing alerts in a chain in the same folder should work", func(t *testing.T) {
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup)
rulesWithUID.Rules[0].GrafanaManagedAlert.Title = rulesWithUID.Rules[1].GrafanaManagedAlert.Title
rulesWithUID.Rules[1].GrafanaManagedAlert.Title = "something new"
status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID)
assert.Equal(t, http.StatusAccepted, status)
require.JSONEq(t, `{"message":"rule group updated successfully"}`, body)
})
}
func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
+410
View File
@@ -0,0 +1,410 @@
package alerting
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
alertingModels "github.com/grafana/alerting/models"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/expr"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/datasources"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/util"
)
const (
TESTDATA_UID = "testdata"
)
func TestGrafanaRuleConfig(t *testing.T) {
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
EnableFeatureToggles: []string{},
EnableLog: false,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
userId := createUser(t, env.SQLStore, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleAdmin),
Password: "admin",
Login: "admin",
})
apiCli := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
dsCmd := &datasources.AddDataSourceCommand{
Name: "TestDatasource",
Type: "testdata",
Access: datasources.DS_ACCESS_PROXY,
UID: TESTDATA_UID,
UserID: userId,
OrgID: 1,
}
_, err := env.Server.HTTPServer.DataSourcesService.AddDataSource(context.Background(), dsCmd)
require.NoError(t, err)
dynamicLabels := []string{"GA", "FL", "AL", "AZ"}
dynamicLabelsJson, _ := json.Marshal(&dynamicLabels)
testdataQueryModel := json.RawMessage(fmt.Sprintf(`{
"refId": "A",
"hide": false,
"scenarioId": "usa",
"usa": {
"mode": "timeseries",
"period": "1m",
"states": %s,
"fields": [
"baz"
]
}
}`, string(dynamicLabelsJson)))
genRule := func(ruleGen func() apimodels.PostableExtendedRuleNode) apimodels.PostableExtendedRuleNodeExtended {
return apimodels.PostableExtendedRuleNodeExtended{
Rule: ruleGen(),
NamespaceUID: "NamespaceUID",
NamespaceTitle: "NamespaceTitle",
}
}
t.Run("valid rule should accept request", func(t *testing.T) {
status, body := apiCli.SubmitRuleForTesting(t, genRule(alertRuleGen()))
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
})
t.Run("valid rule should return alerts in response", func(t *testing.T) {
status, body := apiCli.SubmitRuleForTesting(t, genRule(alertRuleGen()))
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 1)
})
t.Run("valid rule should return static annotations", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Annotations = map[string]string{
"foo": "bar",
"foo2": "bar2",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for _, alert := range result {
require.Equal(t, "bar", alert.Annotations["foo"])
require.Equal(t, "bar2", alert.Annotations["foo2"])
}
})
t.Run("valid rule should return static labels", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Labels = map[string]string{
"foo": "bar",
"foo2": "bar2",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for _, alert := range result {
require.Equal(t, "bar", alert.Labels["foo"])
require.Equal(t, "bar2", alert.Labels["foo2"])
}
})
t.Run("valid rule should return interpolated annotations", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Annotations = map[string]string{
"value": "{{ $value }}",
"values.B": "{{ $values.B }}",
"values.C": "{{ $values.C }}",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for i, alert := range result {
require.NotEmpty(t, alert.Annotations["values.B"])
require.NotEmpty(t, alert.Annotations["values.C"])
valueB := fmt.Sprintf("[ var='B' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Annotations["values.B"])
valueC := fmt.Sprintf("[ var='C' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Annotations["values.C"])
require.Contains(t, alert.Annotations["value"], valueB)
require.Contains(t, alert.Annotations["value"], valueC)
}
})
t.Run("valid rule should return interpolated labels", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Labels = map[string]string{
"value": "{{ $value }}",
"values.B": "{{ $values.B }}",
"values.C": "{{ $values.C }}",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for i, alert := range result {
require.NotEmpty(t, alert.Labels["values.B"])
require.NotEmpty(t, alert.Labels["values.C"])
valueB := fmt.Sprintf("[ var='B' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Labels["values.B"])
valueC := fmt.Sprintf("[ var='C' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Labels["values.C"])
require.Contains(t, alert.Labels["value"], valueB)
require.Contains(t, alert.Labels["value"], valueC)
}
})
t.Run("valid rule should use functions with annotations", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Annotations = map[string]string{
"externalURL": "{{ externalURL }}",
"humanize": "{{ humanize 1000.0 }}",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for _, alert := range result {
require.Equal(t, "http://localhost:3000/", alert.Annotations["externalURL"])
require.Equal(t, "1k", alert.Annotations["humanize"])
}
})
t.Run("valid rule should use functions with labels", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Labels = map[string]string{
"externalURL": "{{ externalURL }}",
"humanize": "{{ humanize 1000.0 }}",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for _, alert := range result {
require.Equal(t, "http://localhost:3000/", alert.Labels["externalURL"])
require.Equal(t, "1k", alert.Labels["humanize"])
}
})
t.Run("valid rule should return dynamic labels", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for i, alert := range result {
require.Equal(t, dynamicLabels[i], alert.Labels["state"])
}
})
t.Run("valid rule should return built-in labels", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for _, alert := range result {
require.Equal(t, rule.Rule.GrafanaManagedAlert.Title, alert.Labels[model.AlertNameLabel])
require.Equal(t, rule.NamespaceUID, alert.Labels[alertingModels.NamespaceUIDLabel])
require.Equal(t, rule.NamespaceTitle, alert.Labels[ngmodels.FolderTitleLabel])
}
})
t.Run("invalid rule should reject request", func(t *testing.T) {
req := genRule(alertRuleGen())
req.Rule = apimodels.PostableExtendedRuleNode{}
status, _ := apiCli.SubmitRuleForTesting(t, req)
require.Equal(t, http.StatusBadRequest, status)
})
t.Run("authentication permissions", func(t *testing.T) {
if !setting.IsEnterprise {
t.Skip("Enterprise-only test")
}
t.Skip("flakey tests - skipping") //TODO: Fix tests and remove skip.
testUserId := createUser(t, env.SQLStore, user.CreateUserCommand{
DefaultOrgRole: "DOESNOTEXIST", // Needed so that the SignedInUser has OrgId=1. Otherwise, datasource will not be found.
Password: "test",
Login: "test",
})
testUserApiCli := newAlertingApiClient(grafanaListedAddr, "test", "test")
t.Run("fail if can't read rules", func(t *testing.T) {
status, body := testUserApiCli.SubmitRuleForTesting(t, genRule(testdataRule(testdataQueryModel, nil, nil)))
require.Contains(t, body, accesscontrol.ActionAlertingRuleRead)
require.Equalf(t, http.StatusForbidden, status, "Response: %s", body)
})
// access control permissions store
permissionsStore := resourcepermissions.NewStore(env.SQLStore)
_, err := permissionsStore.SetUserResourcePermission(context.Background(),
accesscontrol.GlobalOrgID,
accesscontrol.User{ID: testUserId},
resourcepermissions.SetResourcePermissionCommand{
Actions: []string{
accesscontrol.ActionAlertingRuleRead,
},
Resource: "folders",
ResourceID: "*",
ResourceAttribute: "uid",
}, nil)
require.NoError(t, err)
testUserApiCli.ReloadCachedPermissions(t)
t.Run("fail if can't query data sources", func(t *testing.T) {
status, body := testUserApiCli.SubmitRuleForTesting(t, genRule(testdataRule(testdataQueryModel, nil, nil)))
require.Contains(t, body, "user is not authorized to query one or many data sources used by the rule")
require.Equalf(t, http.StatusUnauthorized, status, "Response: %s", body)
})
_, err = permissionsStore.SetUserResourcePermission(context.Background(),
accesscontrol.GlobalOrgID,
accesscontrol.User{ID: testUserId},
resourcepermissions.SetResourcePermissionCommand{
Actions: []string{
datasources.ActionQuery,
},
Resource: "datasources",
ResourceID: TESTDATA_UID,
ResourceAttribute: "uid",
}, nil)
require.NoError(t, err)
testUserApiCli.ReloadCachedPermissions(t)
t.Run("succeed if can query data sources", func(t *testing.T) {
status, body := testUserApiCli.SubmitRuleForTesting(t, genRule(testdataRule(testdataQueryModel, nil, nil)))
require.Equalf(t, http.StatusOK, status, "Response: %s", body)
})
})
}
func testdataRule(queryModel json.RawMessage, labels map[string]string, annotations map[string]string) func() apimodels.PostableExtendedRuleNode {
return func() apimodels.PostableExtendedRuleNode {
forDuration := model.Duration(10 * time.Second)
return apimodels.PostableExtendedRuleNode{
ApiRuleNode: &apimodels.ApiRuleNode{
For: &forDuration,
Labels: labels,
Annotations: annotations,
},
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
Title: fmt.Sprintf("rule-%s", util.GenerateShortUID()),
Condition: "C",
Data: []apimodels.AlertQuery{
{
RefID: "A",
RelativeTimeRange: apimodels.RelativeTimeRange{From: 600, To: 0},
DatasourceUID: TESTDATA_UID,
Model: queryModel,
},
{ // Simple reduce last A.
RefID: "B",
RelativeTimeRange: apimodels.RelativeTimeRange{From: 0, To: 0},
DatasourceUID: expr.DatasourceUID,
Model: json.RawMessage(`{
"refId": "B",
"hide": false,
"type": "reduce",
"datasource": {
"uid": "__expr__",
"type": "__expr__"
},
"conditions": [
{
"type": "query",
"evaluator": {
"params": [],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"B"
]
},
"reducer": {
"params": [],
"type": "last"
}
}
],
"reducer": "last",
"expression": "A"
}`),
},
{ // Threshold B > 0.
RefID: "C",
RelativeTimeRange: apimodels.RelativeTimeRange{From: 0, To: 0},
DatasourceUID: expr.DatasourceUID,
Model: json.RawMessage(`{
"refId": "C",
"hide": false,
"type": "threshold",
"datasource": {
"uid": "__expr__",
"type": "__expr__"
},
"conditions": [
{
"type": "query",
"evaluator": {
"params": [
0
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"C"
]
},
"reducer": {
"params": [],
"type": "last"
}
}
],
"expression": "B"
}`),
},
},
},
}
}
}
+19
View File
@@ -334,3 +334,22 @@ func (a apiClient) SubmitRuleForBacktesting(t *testing.T, config apimodels.Backt
require.NoError(t, err)
return resp.StatusCode, string(b)
}
func (a apiClient) SubmitRuleForTesting(t *testing.T, config apimodels.PostableExtendedRuleNodeExtended) (int, string) {
t.Helper()
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err := enc.Encode(config)
require.NoError(t, err)
u := fmt.Sprintf("%s/api/v1/rule/test/grafana", a.url)
// nolint:gosec
resp, err := http.Post(u, "application/json", &buf)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, string(b)
}
@@ -89,7 +89,7 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(ctx context.Context, logger l
resources := []string{}
var resourceOrWorkspace string
var queryString string
var resultFormat string
var resultFormat dataquery.ResultFormat
traceExploreQuery := ""
traceParentExploreQuery := ""
traceLogsExploreQuery := ""
@@ -103,7 +103,9 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(ctx context.Context, logger l
azureLogAnalyticsTarget := queryJSONModel.AzureLogAnalytics
logger.Debug("AzureLogAnalytics", "target", azureLogAnalyticsTarget)
resultFormat = azureLogAnalyticsTarget.ResultFormat
if azureLogAnalyticsTarget.ResultFormat != nil {
resultFormat = *azureLogAnalyticsTarget.ResultFormat
}
if resultFormat == "" {
resultFormat = types.TimeSeries
}
@@ -115,14 +117,16 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(ctx context.Context, logger l
if len(azureLogAnalyticsTarget.Resources) > 0 {
resources = azureLogAnalyticsTarget.Resources
resourceOrWorkspace = azureLogAnalyticsTarget.Resources[0]
} else if azureLogAnalyticsTarget.Resource != "" {
resources = []string{azureLogAnalyticsTarget.Resource}
resourceOrWorkspace = azureLogAnalyticsTarget.Resource
} else {
resourceOrWorkspace = azureLogAnalyticsTarget.Workspace
} else if azureLogAnalyticsTarget.Resource != nil && *azureLogAnalyticsTarget.Resource != "" {
resources = []string{*azureLogAnalyticsTarget.Resource}
resourceOrWorkspace = *azureLogAnalyticsTarget.Resource
} else if azureLogAnalyticsTarget.Workspace != nil {
resourceOrWorkspace = *azureLogAnalyticsTarget.Workspace
}
queryString = azureLogAnalyticsTarget.Query
if azureLogAnalyticsTarget.Query != nil {
queryString = *azureLogAnalyticsTarget.Query
}
}
if query.QueryType == string(dataquery.AzureQueryTypeAzureTraces) {
@@ -205,7 +209,7 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(ctx context.Context, logger l
azureLogAnalyticsQueries = append(azureLogAnalyticsQueries, &AzureLogAnalyticsQuery{
RefID: query.RefID,
ResultFormat: resultFormat,
ResultFormat: string(resultFormat),
URL: apiURL,
JSON: query.JSON,
TimeRange: query.TimeRange,
@@ -701,7 +705,7 @@ func encodeQuery(rawQuery string) (string, error) {
return base64.StdEncoding.EncodeToString(b.Bytes()), nil
}
func buildTracesQuery(operationId string, parentSpanID *string, traceTypes []string, filters []types.TracesFilters, resultFormat *string, resources []string) string {
func buildTracesQuery(operationId string, parentSpanID *string, traceTypes []string, filters []dataquery.AzureTracesFilter, resultFormat *dataquery.ResultFormat, resources []string) string {
types := traceTypes
if len(types) == 0 {
types = Tables
@@ -709,7 +713,7 @@ func buildTracesQuery(operationId string, parentSpanID *string, traceTypes []str
filteredTypes := make([]string, 0)
// If the result format is set to trace then we filter out all events that are of the type traces as they don't make sense when visualised as a span
if resultFormat != nil && dataquery.ResultFormat(*resultFormat) == dataquery.ResultFormatTrace {
if resultFormat != nil && *resultFormat == dataquery.ResultFormatTrace {
filteredTypes = slices.Filter(filteredTypes, types, func(s string) bool { return s != "traces" })
} else {
filteredTypes = types
@@ -20,6 +20,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/loganalytics"
azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
@@ -69,7 +70,7 @@ func (e *AzureMonitorDatasource) buildQueries(logger log.Logger, queries []backe
for _, query := range queries {
var target string
queryJSONModel := types.AzureMonitorJSONQuery{}
queryJSONModel := dataquery.AzureMonitorQuery{}
err := json.Unmarshal(query.JSON, &queryJSONModel)
if err != nil {
return nil, fmt.Errorf("failed to decode the Azure Monitor query object from JSON: %w", err)
@@ -77,49 +78,29 @@ func (e *AzureMonitorDatasource) buildQueries(logger log.Logger, queries []backe
azJSONModel := queryJSONModel.AzureMonitor
// Legacy: If only MetricDefinition is set, use it as namespace
if azJSONModel.MetricDefinition != "" && azJSONModel.MetricNamespace == "" {
if azJSONModel.MetricDefinition != nil && *azJSONModel.MetricDefinition != "" &&
azJSONModel.MetricNamespace != nil && *azJSONModel.MetricNamespace == "" {
azJSONModel.MetricNamespace = azJSONModel.MetricDefinition
}
azJSONModel.DimensionFilters = MigrateDimensionFilters(azJSONModel.DimensionFilters)
alias := azJSONModel.Alias
timeGrain := azJSONModel.TimeGrain
timeGrains := azJSONModel.AllowedTimeGrainsMs
if timeGrain == "auto" {
timeGrain, err = azTime.SetAutoTimeGrain(query.Interval.Milliseconds(), timeGrains)
if err != nil {
return nil, err
}
alias := ""
if azJSONModel.Alias != nil {
alias = *azJSONModel.Alias
}
params := url.Values{}
params.Add("api-version", AzureMonitorAPIVersion)
params.Add("timespan", fmt.Sprintf("%v/%v", query.TimeRange.From.UTC().Format(time.RFC3339), query.TimeRange.To.UTC().Format(time.RFC3339)))
params.Add("interval", timeGrain)
params.Add("aggregation", azJSONModel.Aggregation)
params.Add("metricnames", azJSONModel.MetricName)
if azJSONModel.CustomNamespace != "" {
params.Add("metricnamespace", azJSONModel.CustomNamespace)
} else {
params.Add("metricnamespace", azJSONModel.MetricNamespace)
azureURL := ""
if queryJSONModel.Subscription != nil {
azureURL = BuildSubscriptionMetricsURL(*queryJSONModel.Subscription)
}
azureURL := BuildSubscriptionMetricsURL(queryJSONModel.Subscription)
filterInBody := true
if azJSONModel.Region != "" {
params.Add("region", azJSONModel.Region)
}
resourceIDs := []string{}
resourceMap := map[string]types.AzureMonitorResource{}
resourceMap := map[string]dataquery.AzureMonitorResource{}
if hasOne, resourceGroup, resourceName := hasOneResource(queryJSONModel); hasOne {
ub := urlBuilder{
ResourceURI: azJSONModel.ResourceURI,
ResourceURI: azJSONModel.ResourceUri,
// Alternative, used to reconstruct resource URI if it's not present
DefaultSubscription: dsInfo.Settings.SubscriptionId,
DefaultSubscription: &dsInfo.Settings.SubscriptionId,
Subscription: queryJSONModel.Subscription,
ResourceGroup: resourceGroup,
MetricNamespace: azJSONModel.MetricNamespace,
@@ -128,25 +109,36 @@ func (e *AzureMonitorDatasource) buildQueries(logger log.Logger, queries []backe
azureURL = ub.BuildMetricsURL()
// POST requests are only supported at the subscription level
filterInBody = false
resourceMap[ub.buildResourceURI()] = types.AzureMonitorResource{ResourceGroup: resourceGroup, ResourceName: resourceName}
resourceUri := ub.buildResourceURI()
if resourceUri != nil {
resourceMap[*resourceUri] = dataquery.AzureMonitorResource{ResourceGroup: resourceGroup, ResourceName: resourceName}
}
} else {
for _, r := range azJSONModel.Resources {
ub := urlBuilder{
DefaultSubscription: dsInfo.Settings.SubscriptionId,
DefaultSubscription: &dsInfo.Settings.SubscriptionId,
Subscription: queryJSONModel.Subscription,
ResourceGroup: r.ResourceGroup,
MetricNamespace: azJSONModel.MetricNamespace,
ResourceName: r.ResourceName,
}
resourceUri := ub.buildResourceURI()
resourceMap[resourceUri] = r
resourceIDs = append(resourceIDs, fmt.Sprintf("Microsoft.ResourceId eq '%s'", resourceUri))
if resourceUri != nil {
resourceMap[*resourceUri] = r
}
resourceIDs = append(resourceIDs, fmt.Sprintf("Microsoft.ResourceId eq '%s'", *resourceUri))
}
}
// old model
dimension := strings.TrimSpace(azJSONModel.Dimension)
dimensionFilter := strings.TrimSpace(azJSONModel.DimensionFilter)
dimension := ""
if azJSONModel.Dimension != nil {
dimension = strings.TrimSpace(*azJSONModel.Dimension)
}
dimensionFilter := ""
if azJSONModel.DimensionFilter != nil {
dimensionFilter = strings.TrimSpace(*azJSONModel.DimensionFilter)
}
dimSB := strings.Builder{}
@@ -155,9 +147,9 @@ func (e *AzureMonitorDatasource) buildQueries(logger log.Logger, queries []backe
} else {
for i, filter := range azJSONModel.DimensionFilters {
if len(filter.Filters) == 0 {
dimSB.WriteString(fmt.Sprintf("%s eq '*'", filter.Dimension))
dimSB.WriteString(fmt.Sprintf("%s eq '*'", *filter.Dimension))
} else {
dimSB.WriteString(filter.ConstructFiltersString())
dimSB.WriteString(types.ConstructFiltersString(filter))
}
if i != len(azJSONModel.DimensionFilters)-1 {
dimSB.WriteString(" and ")
@@ -175,16 +167,21 @@ func (e *AzureMonitorDatasource) buildQueries(logger log.Logger, queries []backe
}
}
if azJSONModel.Top != "" {
params.Add("top", azJSONModel.Top)
params, err := getParams(azJSONModel, query)
if err != nil {
return nil, err
}
target = params.Encode()
if setting.Env == setting.Dev {
logger.Debug("Azuremonitor request", "params", params)
}
sub := ""
if queryJSONModel.Subscription != nil {
sub = *queryJSONModel.Subscription
}
query := &types.AzureMonitorQuery{
URL: azureURL,
Target: target,
@@ -194,7 +191,7 @@ func (e *AzureMonitorDatasource) buildQueries(logger log.Logger, queries []backe
TimeRange: query.TimeRange,
Dimensions: azJSONModel.DimensionFilters,
Resources: resourceMap,
Subscription: queryJSONModel.Subscription,
Subscription: sub,
}
if filterString != "" {
if filterInBody {
@@ -209,6 +206,45 @@ func (e *AzureMonitorDatasource) buildQueries(logger log.Logger, queries []backe
return azureMonitorQueries, nil
}
func getParams(azJSONModel *dataquery.AzureMetricQuery, query backend.DataQuery) (url.Values, error) {
params := url.Values{}
timeGrain := azJSONModel.TimeGrain
timeGrains := azJSONModel.AllowedTimeGrainsMs
if timeGrain != nil && *timeGrain == "auto" {
var err error
timeGrain, err = azTime.SetAutoTimeGrain(query.Interval.Milliseconds(), timeGrains)
if err != nil {
return nil, err
}
}
params.Add("api-version", AzureMonitorAPIVersion)
params.Add("timespan", fmt.Sprintf("%v/%v", query.TimeRange.From.UTC().Format(time.RFC3339), query.TimeRange.To.UTC().Format(time.RFC3339)))
if timeGrain != nil {
params.Add("interval", *timeGrain)
}
if azJSONModel.Aggregation != nil {
params.Add("aggregation", *azJSONModel.Aggregation)
}
if azJSONModel.MetricName != nil {
params.Add("metricnames", *azJSONModel.MetricName)
}
if azJSONModel.CustomNamespace != nil && *azJSONModel.CustomNamespace != "" {
params.Add("metricnamespace", *azJSONModel.CustomNamespace)
} else if azJSONModel.MetricNamespace != nil {
params.Add("metricnamespace", *azJSONModel.MetricNamespace)
}
if azJSONModel.Region != nil && *azJSONModel.Region != "" {
params.Add("region", *azJSONModel.Region)
}
if azJSONModel.Top != nil && *azJSONModel.Top != "" {
params.Add("top", *azJSONModel.Top)
}
return params, nil
}
func retrieveSubscriptionDetails(e *AzureMonitorDatasource, cli *http.Client, ctx context.Context, logger log.Logger, tracer tracing.Tracer, subscriptionId string, baseUrl string, dsId int64, orgId int64) {
req, err := e.createRequest(ctx, logger, fmt.Sprintf("%s/subscriptions/%s", baseUrl, subscriptionId))
if err != nil {
@@ -489,7 +525,20 @@ func getQueryUrl(query *types.AzureMonitorQuery, azurePortalUrl, resourceID, res
continue
}
switch dimension.Operator {
if dimension.Dimension == nil {
continue
}
if dimension.Operator == nil {
filter := types.AzureMonitorDimensionFilterBackend{
Key: *dimension.Dimension,
Operator: 0,
Values: dimensionFilters,
}
filters = append(filters, filter)
continue
}
switch *dimension.Operator {
case "eq":
dimensionInt = 0
case "ne":
@@ -499,7 +548,7 @@ func getQueryUrl(query *types.AzureMonitorQuery, azurePortalUrl, resourceID, res
}
filter := types.AzureMonitorDimensionFilterBackend{
Key: dimension.Dimension,
Key: *dimension.Dimension,
Operator: dimensionInt,
Values: dimensionFilters,
}
@@ -552,7 +601,7 @@ func getQueryUrl(query *types.AzureMonitorQuery, azurePortalUrl, resourceID, res
// formatAzureMonitorLegendKey builds the legend key or timeseries name
// Alias patterns like {{resourcename}} are replaced with the appropriate data values.
func formatAzureMonitorLegendKey(alias string, subscriptionId string, resource types.AzureMonitorResource, metricName string, metadataName string,
func formatAzureMonitorLegendKey(alias string, subscriptionId string, resource dataquery.AzureMonitorResource, metricName string, metadataName string,
metadataValue string, namespace string, seriesID string, labels data.Labels) string {
// Could be a collision problem if there were two keys that varied only in case, but I don't think that would happen in azure.
lowerLabels := data.Labels{}
@@ -582,16 +631,16 @@ func formatAzureMonitorLegendKey(alias string, subscriptionId string, resource t
}
}
if metaPartName == "resourcegroup" {
return []byte(resource.ResourceGroup)
if metaPartName == "resourcegroup" && resource.ResourceGroup != nil {
return []byte(*resource.ResourceGroup)
}
if metaPartName == "namespace" {
return []byte(namespace)
}
if metaPartName == "resourcename" {
return []byte(resource.ResourceName)
if metaPartName == "resourcename" && resource.ResourceName != nil {
return []byte(*resource.ResourceName)
}
if metaPartName == "metric" {
@@ -674,17 +723,17 @@ func extractResourceIDFromMetricsURL(url string) string {
return strings.Split(url, "/providers/microsoft.insights/metrics")[0]
}
func hasOneResource(query types.AzureMonitorJSONQuery) (bool, string, string) {
func hasOneResource(query dataquery.AzureMonitorQuery) (bool, *string, *string) {
azJSONModel := query.AzureMonitor
if len(azJSONModel.Resources) > 1 {
return false, "", ""
return false, nil, nil
}
if len(azJSONModel.Resources) == 1 {
return true, azJSONModel.Resources[0].ResourceGroup, azJSONModel.Resources[0].ResourceName
}
if azJSONModel.ResourceGroup != "" || azJSONModel.ResourceName != "" {
if (azJSONModel.ResourceGroup != nil && *azJSONModel.ResourceGroup != "") || (azJSONModel.ResourceName != nil && *azJSONModel.ResourceName != "") {
// Deprecated, Resources should be used instead
return true, azJSONModel.ResourceGroup, azJSONModel.ResourceName
}
return false, "", ""
return false, nil, nil
}
@@ -20,6 +20,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/testdata"
azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
@@ -48,7 +49,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
expectedBodyFilter string
expectedParamFilter string
expectedPortalURL *string
resources map[string]types.AzureMonitorResource
resources map[string]dataquery.AzureMonitorResource
}{
{
name: "Parse queries from frontend and build AzureMonitor API queries",
@@ -110,7 +111,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "legacy query without resourceURI and has dimensionFilter*s* property with one dimension",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "eq", Filter: &wildcardFilter}},
"dimensionFilters": []dataquery.AzureMetricDimension{{Dimension: strPtr("blob"), Operator: strPtr("eq"), Filter: &wildcardFilter}},
"top": "30",
},
queryInterval: duration,
@@ -123,7 +124,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "legacy query without resourceURI and has dimensionFilter*s* property with two dimensions",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "eq", Filter: &wildcardFilter}, {Dimension: "tier", Operator: "eq", Filter: &wildcardFilter}},
"dimensionFilters": []dataquery.AzureMetricDimension{{Dimension: strPtr("blob"), Operator: strPtr("eq"), Filter: &wildcardFilter}, {Dimension: strPtr("tier"), Operator: strPtr("eq"), Filter: &wildcardFilter}},
"top": "30",
},
queryInterval: duration,
@@ -148,7 +149,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "has dimensionFilter*s* property with not equals operator",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "ne", Filter: &wildcardFilter, Filters: []string{"test"}}},
"dimensionFilters": []dataquery.AzureMetricDimension{{Dimension: strPtr("blob"), Operator: strPtr("ne"), Filter: &wildcardFilter, Filters: []string{"test"}}},
"top": "30",
},
queryInterval: duration,
@@ -161,7 +162,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "has dimensionFilter*s* property with startsWith operator",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "sw", Filter: &testFilter}},
"dimensionFilters": []dataquery.AzureMetricDimension{{Dimension: strPtr("blob"), Operator: strPtr("sw"), Filter: &testFilter}},
"top": "30",
},
queryInterval: duration,
@@ -174,7 +175,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "correctly sets dimension operator to eq (irrespective of operator) when filter value is '*'",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "sw", Filter: &wildcardFilter}, {Dimension: "tier", Operator: "ne", Filter: &wildcardFilter}},
"dimensionFilters": []dataquery.AzureMetricDimension{{Dimension: strPtr("blob"), Operator: strPtr("sw"), Filter: &wildcardFilter}, {Dimension: strPtr("tier"), Operator: strPtr("ne"), Filter: &wildcardFilter}},
"top": "30",
},
queryInterval: duration,
@@ -187,7 +188,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "correctly constructs target when multiple filter values are provided for the 'eq' operator",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "eq", Filter: &wildcardFilter, Filters: []string{"test", "test2"}}},
"dimensionFilters": []dataquery.AzureMetricDimension{{Dimension: strPtr("blob"), Operator: strPtr("eq"), Filter: &wildcardFilter, Filters: []string{"test", "test2"}}},
"top": "30",
},
queryInterval: duration,
@@ -200,7 +201,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "correctly constructs target when multiple filter values are provided for ne 'eq' operator",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "ne", Filter: &wildcardFilter, Filters: []string{"test", "test2"}}},
"dimensionFilters": []dataquery.AzureMetricDimension{{Dimension: strPtr("blob"), Operator: strPtr("ne"), Filter: &wildcardFilter, Filters: []string{"test", "test2"}}},
"top": "30",
},
queryInterval: duration,
@@ -226,7 +227,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
"timeGrain": "PT1M",
"top": "10",
"region": "westus",
"resources": []types.AzureMonitorResource{{ResourceGroup: "rg", ResourceName: "vm"}, {ResourceGroup: "rg2", ResourceName: "vm2"}},
"resources": []dataquery.AzureMonitorResource{{ResourceGroup: strPtr("rg"), ResourceName: strPtr("vm")}, {ResourceGroup: strPtr("rg2"), ResourceName: strPtr("vm2")}},
},
expectedInterval: "PT1M",
azureMonitorQueryTarget: "aggregation=Average&api-version=2021-05-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute%2FvirtualMachines&region=westus&timespan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=10",
@@ -237,7 +238,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "includes a single resource as a parameter filter",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"resources": []types.AzureMonitorResource{{ResourceGroup: "rg", ResourceName: "vm"}},
"resources": []dataquery.AzureMonitorResource{{ResourceGroup: strPtr("rg"), ResourceName: strPtr("vm")}},
},
queryInterval: duration,
expectedInterval: "PT1M",
@@ -248,8 +249,8 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "includes a resource and a dimesion as filters",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"resources": []types.AzureMonitorResource{{ResourceGroup: "rg", ResourceName: "vm"}, {ResourceGroup: "rg2", ResourceName: "vm2"}},
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "ne", Filter: &wildcardFilter, Filters: []string{"test", "test2"}}},
"resources": []dataquery.AzureMonitorResource{{ResourceGroup: strPtr("rg"), ResourceName: strPtr("vm")}, {ResourceGroup: strPtr("rg2"), ResourceName: strPtr("vm2")}},
"dimensionFilters": []dataquery.AzureMetricDimension{{Dimension: strPtr("blob"), Operator: strPtr("ne"), Filter: &wildcardFilter, Filters: []string{"test", "test2"}}},
"top": "30",
},
queryInterval: duration,
@@ -296,14 +297,14 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
queries, err := datasource.buildQueries(log.New("test"), tsdbQuery, dsInfo)
require.NoError(t, err)
resources := map[string]types.AzureMonitorResource{}
resources := map[string]dataquery.AzureMonitorResource{}
if tt.azureMonitorVariedProperties["resources"] != nil {
resourceSlice := tt.azureMonitorVariedProperties["resources"].([]types.AzureMonitorResource)
resourceSlice := tt.azureMonitorVariedProperties["resources"].([]dataquery.AzureMonitorResource)
for _, resource := range resourceSlice {
resources[fmt.Sprintf("/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/%s/providers/Microsoft.Compute/virtualMachines/%s", resource.ResourceGroup, resource.ResourceName)] = resource
resources[fmt.Sprintf("/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/%s/providers/Microsoft.Compute/virtualMachines/%s", *resource.ResourceGroup, *resource.ResourceName)] = resource
}
} else {
resources["/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana"] = types.AzureMonitorResource{ResourceGroup: "grafanastaging", ResourceName: "grafana"}
resources["/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana"] = dataquery.AzureMonitorResource{ResourceGroup: strPtr("grafanastaging"), ResourceName: strPtr("grafana")}
}
azureMonitorQuery := &types.AzureMonitorQuery{
@@ -366,8 +367,9 @@ func TestCustomNamespace(t *testing.T) {
}
func TestAzureMonitorParseResponse(t *testing.T) {
resources := map[string]types.AzureMonitorResource{}
resources["/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana"] = types.AzureMonitorResource{ResourceGroup: "grafanastaging", ResourceName: "grafana"}
resources := map[string]dataquery.AzureMonitorResource{}
resources["/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana"] =
dataquery.AzureMonitorResource{ResourceGroup: strPtr("grafanastaging"), ResourceName: strPtr("grafana")}
subscription := "12345678-aaaa-bbbb-cccc-123456789abc"
tests := []struct {
@@ -484,7 +486,7 @@ func TestAzureMonitorParseResponse(t *testing.T) {
Params: url.Values{
"aggregation": {"Average"},
},
Resources: map[string]types.AzureMonitorResource{"/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanatest/providers/Microsoft.Storage/storageAccounts/testblobaccount/blobServices/default/providers/Microsoft.Insights/metrics": {ResourceGroup: "grafanatest", ResourceName: "testblobaccount"}},
Resources: map[string]dataquery.AzureMonitorResource{"/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanatest/providers/Microsoft.Storage/storageAccounts/testblobaccount/blobServices/default/providers/Microsoft.Insights/metrics": {ResourceGroup: strPtr("grafanatest"), ResourceName: strPtr("testblobaccount")}},
Subscription: subscription,
},
},
@@ -681,3 +683,7 @@ func TestExtractResourceNameFromMetricsURL(t *testing.T) {
require.Equal(t, expected, extractResourceNameFromMetricsURL((url)))
})
}
func strPtr(s string) *string {
return &s
}
+3 -5
View File
@@ -1,11 +1,9 @@
package metrics
import (
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
)
import "github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery"
func MigrateDimensionFilters(filters []types.AzureMonitorDimensionFilter) []types.AzureMonitorDimensionFilter {
var newFilters []types.AzureMonitorDimensionFilter
func MigrateDimensionFilters(filters []dataquery.AzureMetricDimension) []dataquery.AzureMetricDimension {
var newFilters []dataquery.AzureMetricDimension
for _, filter := range filters {
newFilter := filter
// Ignore the deprecation check as this is a migration
@@ -5,7 +5,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery"
)
func TestDimensionFiltersMigration(t *testing.T) {
@@ -14,38 +14,38 @@ func TestDimensionFiltersMigration(t *testing.T) {
additionalTestFilter := "testFilter2"
tests := []struct {
name string
dimensionFilters []types.AzureMonitorDimensionFilter
expectedDimensionFilters []types.AzureMonitorDimensionFilter
dimensionFilters []dataquery.AzureMetricDimension
expectedDimensionFilters []dataquery.AzureMetricDimension
}{
{
name: "will return new format unchanged",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{"testFilter"}}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{"testFilter"}}},
dimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filters: []string{"testFilter"}}},
expectedDimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filters: []string{"testFilter"}}},
},
{
name: "correctly updates old format with wildcard",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filter: &wildcard}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq"}},
dimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filter: &wildcard}},
expectedDimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq")}},
},
{
name: "correctly updates old format with a value",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filter: &testFilter}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{testFilter}}},
dimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filter: &testFilter}},
expectedDimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filters: []string{testFilter}}},
},
{
name: "correctly ignores wildcard if filters has a value",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filter: &wildcard, Filters: []string{testFilter}}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{testFilter}}},
dimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filter: &wildcard, Filters: []string{testFilter}}},
expectedDimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filters: []string{testFilter}}},
},
{
name: "correctly merges values if filters has a value (ignores duplicates)",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filter: &testFilter, Filters: []string{testFilter}}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{testFilter}}},
dimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filter: &testFilter, Filters: []string{testFilter}}},
expectedDimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filters: []string{testFilter}}},
},
{
name: "correctly merges values if filters has a value",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filter: &additionalTestFilter, Filters: []string{testFilter}}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{testFilter, additionalTestFilter}}},
dimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filter: &additionalTestFilter, Filters: []string{testFilter}}},
expectedDimensionFilters: []dataquery.AzureMetricDimension{{Dimension: strPtr("testDimension"), Operator: strPtr("eq"), Filters: []string{testFilter, additionalTestFilter}}},
},
}
+30 -18
View File
@@ -7,42 +7,54 @@ import (
// urlBuilder builds the URL for calling the Azure Monitor API
type urlBuilder struct {
ResourceURI string
ResourceURI *string
// Following fields will be used to generate a ResourceURI
DefaultSubscription string
Subscription string
ResourceGroup string
MetricNamespace string
ResourceName string
DefaultSubscription *string
Subscription *string
ResourceGroup *string
MetricNamespace *string
ResourceName *string
}
func (params *urlBuilder) buildResourceURI() string {
if params.ResourceURI != "" {
func (params *urlBuilder) buildResourceURI() *string {
if params.ResourceURI != nil && *params.ResourceURI != "" {
return params.ResourceURI
}
subscription := params.Subscription
if params.Subscription == "" {
if params.Subscription == nil || *params.Subscription == "" {
subscription = params.DefaultSubscription
}
metricNamespaceArray := strings.Split(params.MetricNamespace, "/")
resourceNameArray := strings.Split(params.ResourceName, "/")
if params.MetricNamespace == nil || *params.MetricNamespace == "" {
return nil
}
metricNamespaceArray := strings.Split(*params.MetricNamespace, "/")
var resourceNameArray []string
if params.ResourceName != nil && *params.ResourceName != "" {
resourceNameArray = strings.Split(*params.ResourceName, "/")
}
provider := metricNamespaceArray[0]
metricNamespaceArray = metricNamespaceArray[1:]
if strings.HasPrefix(strings.ToLower(params.MetricNamespace), "microsoft.storage/storageaccounts/") &&
!strings.HasSuffix(params.ResourceName, "default") {
if strings.HasPrefix(strings.ToLower(*params.MetricNamespace), "microsoft.storage/storageaccounts/") &&
params.ResourceName != nil &&
!strings.HasSuffix(*params.ResourceName, "default") {
resourceNameArray = append(resourceNameArray, "default")
}
resGroup := ""
if params.ResourceGroup != nil {
resGroup = *params.ResourceGroup
}
urlArray := []string{
"/subscriptions",
subscription,
*subscription,
"resourceGroups",
params.ResourceGroup,
resGroup,
"providers",
provider,
}
@@ -52,7 +64,7 @@ func (params *urlBuilder) buildResourceURI() string {
}
resourceURI := strings.Join(urlArray, "/")
return resourceURI
return &resourceURI
}
// BuildMetricsURL checks the metric properties to see which form of the url
@@ -61,11 +73,11 @@ func (params *urlBuilder) BuildMetricsURL() string {
resourceURI := params.ResourceURI
// Prior to Grafana 9, we had a legacy query object rather than a resourceURI, so we manually create the resource URI
if resourceURI == "" {
if resourceURI == nil || *resourceURI == "" {
resourceURI = params.buildResourceURI()
}
return fmt.Sprintf("%s/providers/microsoft.insights/metrics", resourceURI)
return fmt.Sprintf("%s/providers/microsoft.insights/metrics", *resourceURI)
}
// BuildSubscriptionMetricsURL returns a URL for querying metrics for all resources in a subscription
@@ -10,7 +10,7 @@ func TestURLBuilder(t *testing.T) {
t.Run("AzureMonitor URL Builder", func(t *testing.T) {
t.Run("when only resource uri is provided it returns resource/uri/providers/microsoft.insights/metrics", func(t *testing.T) {
ub := &urlBuilder{
ResourceURI: "/subscriptions/sub/resource/uri",
ResourceURI: strPtr("/subscriptions/sub/resource/uri"),
}
url := ub.BuildMetricsURL()
@@ -19,11 +19,11 @@ func TestURLBuilder(t *testing.T) {
t.Run("when resource uri and legacy fields are provided the legacy fields are ignored", func(t *testing.T) {
ub := &urlBuilder{
ResourceURI: "/subscriptions/sub/resource/uri",
DefaultSubscription: "default-sub",
ResourceGroup: "rg",
MetricNamespace: "Microsoft.NetApp/netAppAccounts/capacityPools/volumes",
ResourceName: "rn1/rn2/rn3",
ResourceURI: strPtr("/subscriptions/sub/resource/uri"),
DefaultSubscription: strPtr("default-sub"),
ResourceGroup: strPtr("rg"),
MetricNamespace: strPtr("Microsoft.NetApp/netAppAccounts/capacityPools/volumes"),
ResourceName: strPtr("rn1/rn2/rn3"),
}
url := ub.BuildMetricsURL()
@@ -33,10 +33,10 @@ func TestURLBuilder(t *testing.T) {
t.Run("Legacy URL Builder params", func(t *testing.T) {
t.Run("when metric definition is in the short form", func(t *testing.T) {
ub := &urlBuilder{
DefaultSubscription: "default-sub",
ResourceGroup: "rg",
MetricNamespace: "Microsoft.Compute/virtualMachines",
ResourceName: "rn",
DefaultSubscription: strPtr("default-sub"),
ResourceGroup: strPtr("rg"),
MetricNamespace: strPtr("Microsoft.Compute/virtualMachines"),
ResourceName: strPtr("rn"),
}
url := ub.BuildMetricsURL()
@@ -45,11 +45,11 @@ func TestURLBuilder(t *testing.T) {
t.Run("when metric definition is in the short form and a subscription is defined", func(t *testing.T) {
ub := &urlBuilder{
DefaultSubscription: "default-sub",
Subscription: "specified-sub",
ResourceGroup: "rg",
MetricNamespace: "Microsoft.Compute/virtualMachines",
ResourceName: "rn",
DefaultSubscription: strPtr("default-sub"),
Subscription: strPtr("specified-sub"),
ResourceGroup: strPtr("rg"),
MetricNamespace: strPtr("Microsoft.Compute/virtualMachines"),
ResourceName: strPtr("rn"),
}
url := ub.BuildMetricsURL()
@@ -58,10 +58,10 @@ func TestURLBuilder(t *testing.T) {
t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) {
ub := &urlBuilder{
DefaultSubscription: "default-sub",
ResourceGroup: "rg",
MetricNamespace: "Microsoft.Storage/storageAccounts/blobServices",
ResourceName: "rn1/default",
DefaultSubscription: strPtr("default-sub"),
ResourceGroup: strPtr("rg"),
MetricNamespace: strPtr("Microsoft.Storage/storageAccounts/blobServices"),
ResourceName: strPtr("rn1/default"),
}
url := ub.BuildMetricsURL()
@@ -70,10 +70,10 @@ func TestURLBuilder(t *testing.T) {
t.Run("when metric definition is Microsoft.Storage/storageAccounts/fileServices", func(t *testing.T) {
ub := &urlBuilder{
DefaultSubscription: "default-sub",
ResourceGroup: "rg",
MetricNamespace: "Microsoft.Storage/storageAccounts/fileServices",
ResourceName: "rn1/default",
DefaultSubscription: strPtr("default-sub"),
ResourceGroup: strPtr("rg"),
MetricNamespace: strPtr("Microsoft.Storage/storageAccounts/fileServices"),
ResourceName: strPtr("rn1/default"),
}
url := ub.BuildMetricsURL()
@@ -82,10 +82,10 @@ func TestURLBuilder(t *testing.T) {
t.Run("when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes", func(t *testing.T) {
ub := &urlBuilder{
DefaultSubscription: "default-sub",
ResourceGroup: "rg",
MetricNamespace: "Microsoft.NetApp/netAppAccounts/capacityPools/volumes",
ResourceName: "rn1/rn2/rn3",
DefaultSubscription: strPtr("default-sub"),
ResourceGroup: strPtr("rg"),
MetricNamespace: strPtr("Microsoft.NetApp/netAppAccounts/capacityPools/volumes"),
ResourceName: strPtr("rn1/rn2/rn3"),
}
url := ub.BuildMetricsURL()
@@ -94,13 +94,13 @@ func TestURLBuilder(t *testing.T) {
t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) {
ub := &urlBuilder{
DefaultSubscription: "default-sub",
ResourceGroup: "rg",
MetricNamespace: "Microsoft.Storage/storageAccounts/blobServices",
ResourceName: "rn1",
DefaultSubscription: strPtr("default-sub"),
ResourceGroup: strPtr("rg"),
MetricNamespace: strPtr("Microsoft.Storage/storageAccounts/blobServices"),
ResourceName: strPtr("rn1"),
}
url := ub.buildResourceURI()
url := *ub.buildResourceURI()
assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default", url)
})
})
@@ -8,14 +8,14 @@ var (
// SetAutoTimeGrain tries to find the closest interval to the query's intervalMs value
// if the metric has a limited set of possible intervals/time grains then use those
// instead of the default list of intervals
func SetAutoTimeGrain(intervalMs int64, timeGrains []int64) (string, error) {
func SetAutoTimeGrain(intervalMs int64, timeGrains []int64) (*string, error) {
autoInterval := FindClosestAllowedIntervalMS(intervalMs, timeGrains)
autoTimeGrain, err := CreateISO8601DurationFromIntervalMS(autoInterval)
if err != nil {
return "", err
return nil, err
}
return autoTimeGrain, nil
return &autoTimeGrain, nil
}
// FindClosestAllowedIntervalMS is used for the auto time grain setting.
+17 -88
View File
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana-azure-sdk-go/azcredentials"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery"
)
const (
@@ -83,8 +84,8 @@ type AzureMonitorQuery struct {
Alias string
TimeRange backend.TimeRange
BodyFilter string
Dimensions []AzureMonitorDimensionFilter
Resources map[string]AzureMonitorResource
Dimensions []dataquery.AzureMetricDimension
Resources map[string]dataquery.AzureMonitorResource
Subscription string
}
@@ -138,110 +139,38 @@ type AzureMonitorResource struct {
ResourceName string `json:"resourceName"`
}
// AzureMonitorJSONQuery is the frontend JSON query model for an Azure Monitor query.
type AzureMonitorJSONQuery struct {
AzureMonitor struct {
ResourceURI string `json:"resourceUri"`
// These are used to reconstruct a resource URI
MetricNamespace string `json:"metricNamespace"`
CustomNamespace string `json:"customNamespace"`
MetricName string `json:"metricName"`
Region string `json:"region"`
Resources []AzureMonitorResource `json:"resources"`
Aggregation string `json:"aggregation"`
Alias string `json:"alias"`
DimensionFilters []AzureMonitorDimensionFilter `json:"dimensionFilters"` // new model
TimeGrain string `json:"timeGrain"`
Top string `json:"top"`
AllowedTimeGrainsMs []int64 `json:"allowedTimeGrainsMs"`
Dimension string `json:"dimension"` // old model
DimensionFilter string `json:"dimensionFilter"` // old model
Format string `json:"format"`
// Deprecated, MetricNamespace should be used instead
MetricDefinition string `json:"metricDefinition"`
// Deprecated: Use Resources with a single element instead
AzureMonitorResource
} `json:"azureMonitor"`
Subscription string `json:"subscription"`
}
// AzureMonitorDimensionFilter is the model for the frontend sent for azureMonitor metric
// queries like "BlobType", "eq", "*"
type AzureMonitorDimensionFilter struct {
Dimension string `json:"dimension"`
Operator string `json:"operator"`
Filters []string `json:"filters,omitempty"`
// Deprecated: To support multiselection, filters are passed in a slice now. Also migrated in frontend.
Filter *string `json:"filter,omitempty"`
}
type AzureMonitorDimensionFilterBackend struct {
Key string `json:"key"`
Operator int `json:"operator"`
Values []string `json:"values"`
}
func (a AzureMonitorDimensionFilter) ConstructFiltersString() string {
func ConstructFiltersString(a dataquery.AzureMetricDimension) string {
var filterStrings []string
for _, filter := range a.Filters {
filterStrings = append(filterStrings, fmt.Sprintf("%v %v '%v'", a.Dimension, a.Operator, filter))
dimension := ""
operator := ""
if a.Dimension != nil {
dimension = *a.Dimension
}
if a.Operator != nil {
operator = *a.Operator
}
filterStrings = append(filterStrings, fmt.Sprintf("%v %v '%v'", dimension, operator, filter))
}
if a.Operator == "eq" {
if a.Operator != nil && *a.Operator == "eq" {
return strings.Join(filterStrings, " or ")
} else {
return strings.Join(filterStrings, " and ")
}
return strings.Join(filterStrings, " and ")
}
// LogJSONQuery is the frontend JSON query model for an Azure Log Analytics query.
type LogJSONQuery struct {
AzureLogAnalytics struct {
Query string `json:"query"`
ResultFormat string `json:"resultFormat"`
Resources []string `json:"resources"`
OperationId string `json:"operationId"`
// Deprecated: Queries should be migrated to use Resource instead
Workspace string `json:"workspace"`
// Deprecated: Use Resources instead
Resource string `json:"resource"`
} `json:"azureLogAnalytics"`
AzureLogAnalytics dataquery.AzureLogsQuery `json:"azureLogAnalytics"`
}
type TracesJSONQuery struct {
AzureTraces struct {
// Filters for property values.
Filters []TracesFilters `json:"filters"`
// Operation ID. Used only for Traces queries.
OperationId *string `json:"operationId"`
// KQL query to be executed.
Query *string `json:"query"`
// Array of resource URIs to be queried.
Resources []string `json:"resources"`
// Specifies the format results should be returned as.
ResultFormat *string `json:"resultFormat"`
// Types of events to filter by.
TraceTypes []string `json:"traceTypes"`
} `json:"azureTraces"`
}
type TracesFilters struct {
// Values to filter by.
Filters []string `json:"filters"`
// Comparison operator to use. Either equals or not equals.
Operation string `json:"operation"`
// Property name, auto-populated based on available traces.
Property string `json:"property"`
AzureTraces dataquery.AzureTracesQuery `json:"azureTraces"`
}
// MetricChartDefinition is the JSON model for a metrics chart definition
+1 -1
View File
@@ -333,7 +333,7 @@ var NamespaceMetricsMap = map[string][]string{
"AWS/ElasticMapReduce": {"AppsCompleted", "AppsFailed", "AppsKilled", "AppsPending", "AppsRunning", "AppsSubmitted", "BackupFailed", "CapacityRemainingGB", "Cluster Status", "ContainerAllocated", "ContainerPending", "ContainerPendingRatio", "ContainerReserved", "CoreNodesPending", "CoreNodesRunning", "CorruptBlocks", "DfsPendingReplicationBlocks", "HBase", "HDFSBytesRead", "HDFSBytesWritten", "HDFSUtilization", "HbaseBackupFailed", "IO", "IsIdle", "JobsFailed", "JobsRunning", "LiveDataNodes", "LiveTaskTrackers", "MRActiveNodes", "MRDecommissionedNodes", "MRLostNodes", "MRRebootedNodes", "MRTotalNodes", "MRUnhealthyNodes", "Map/Reduce", "MapSlotsOpen", "MapTasksRemaining", "MapTasksRunning", "MemoryAllocatedMB", "MemoryAvailableMB", "MemoryReservedMB", "MemoryTotalMB", "MissingBlocks", "MostRecentBackupDuration", "Node Status", "PendingDeletionBlocks", "ReduceSlotsOpen", "ReduceTasksRemaining", "ReduceTasksRunning", "RemainingMapTasksPerSlot", "S3BytesRead", "S3BytesWritten", "TaskNodesPending", "TaskNodesRunning", "TimeSinceLastSuccessfulBackup", "TotalLoad", "UnderReplicatedBlocks", "YARNMemoryAvailablePercentage"},
"AWS/ElasticTranscoder": {"Billed Audio Output", "Billed HD Output", "Billed SD Output", "Errors", "Jobs Completed", "Jobs Errored", "Outputs per Job", "Standby Time", "Throttles"},
"AWS/Events": {"DeadLetterInvocations", "Events", "FailedInvocations", "IngestionToInvocationStartLatency", "Invocations", "InvocationsFailedToBeSentToDlq", "InvocationsSentToDlq", "MatchedEvents", "ThrottledRules", "TriggeredRules"},
"AWS/FSx": {"DataReadBytes", "DataReadOperations", "DataWriteBytes", "DataWriteOperations", "FreeDataStorageCapacity", "FreeStorageCapacity", "MetadataOperations"},
"AWS/FSx": {"ClientConnections", "CPUUtilization", "DataReadBytes", "DataReadOperations", "DataWriteBytes", "DataWriteOperations", "DeduplicationSavedStorage", "DiskIopsUtilization", "DiskReadBytes", "DiskReadOperations", "DiskThroughputBalance", "DiskThroughputUtilization", "DiskWriteBytes", "DiskWriteOperations", "FileServerDiskIopsBalance", "FileServerDiskIopsUtilization", "FileServerDiskThroughputBalance", "FileServerDiskThroughputUtilization", "FreeDataStorageCapacity", "FreeStorageCapacity", "MemoryUtilization", "MetadataOperations", "NetworkThroughputUtilization", "StorageCapacityUtilization"},
"AWS/Firehose": {"BackupToS3.Bytes", "BackupToS3.DataFreshness", "BackupToS3.Records", "BackupToS3.Success", "DataReadFromKinesisStream.Bytes", "DataReadFromKinesisStream.Records", "DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.Records", "DeliveryToS3.Success", "DeliveryToSplunk.Bytes", "DeliveryToSplunk.DataFreshness", "DeliveryToSplunk.Records", "DeliveryToSplunk.Success", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ExecuteProcessing.Duration", "ExecuteProcessing.Success", "FailedConversion.Bytes", "FailedConversion.Records", "IncomingBytes", "IncomingRecords", "KinesisMillisBehindLatest", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "SucceedConversion.Bytes", "SucceedConversion.Records", "SucceedProcessing.Bytes", "SucceedProcessing.Records", "ThrottledDescribeStream", "ThrottledGetRecords", "ThrottledGetShardIterator", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"},
"AWS/FraudDetector": {"GetEventPrediction", "GetEventPrediction4xxError", "GetEventPrediction5xxError", "GetEventPredictionLatency", "ModelInvocation", "ModelInvocationError", "ModelInvocationLatency", "OutcomeReturned", "Prediction", "PredictionError", "PredictionLatency", "RuleEvaluateError", "RuleEvaluateFalse", "RuleEvaluateTrue", "RuleNotEvaluated", "VariableDefaultReturned", "VariableUsed"},
"AWS/GameLift": {"ActivatingGameSessions", "ActiveGameSessions", "ActiveInstances", "ActiveServerProcesses", "AvailableGameSessions", "AverageWaitTime", "CurrentPlayerSessions", "CurrentTickets", "DesiredInstances", "FirstChoiceNotViable", "FirstChoiceOutOfCapacity", "GameSessionInterruptions", "HealthyServerProcesses", "IdleInstances", "InstanceInterruptions", "LowestLatencyPlacement", "LowestPricePlacement", "MatchAcceptancesTimedOut", "MatchesAccepted", "MatchesCreated", "MatchesPlaced", "MatchesRejected", "MaxInstances", "MinInstances", "PercentAvailableGameSessions", "PercentHealthyServerProcesses", "PercentIdleInstances", "Placement", "PlacementsCanceled", "PlacementsFailed", "PlacementsStarted", "PlacementsSucceeded", "PlacementsTimedOut", "PlayerSessionActivations", "PlayersStarted", "QueueDepth", "RuleEvaluationsFailed", "RuleEvaluationsPassed", "ServerProcessAbnormalTerminations", "ServerProcessActivations", "ServerProcessTerminations", "TicketsFailed", "TicketsStarted", "TicketsTimedOut", "TimeToMatch", "TimeToTicketSuccess"},
@@ -0,0 +1,48 @@
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { OrgPicker } from './OrgPicker';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({
get: () =>
Promise.resolve([
{ name: 'Org 1', id: 0 },
{ name: 'Org 2', id: 1 },
]),
}),
}));
function setup(jsx: JSX.Element) {
return {
user: userEvent.setup(),
...render(jsx),
};
}
describe('OrgPicker', () => {
it('should render', async () => {
render(
<>
<label htmlFor={'picker'}>Org picker</label>
<OrgPicker onSelected={() => {}} inputId={'picker'} />
</>
);
expect(await screen.findByRole('combobox', { name: 'Org picker' })).toBeInTheDocument();
});
it('should have the options', async () => {
const { user } = setup(
<>
<label htmlFor={'picker'}>Org picker</label>
<OrgPicker onSelected={() => {}} inputId={'picker'} />
</>
);
await user.click(await screen.findByRole('combobox', { name: 'Org picker' }));
expect(screen.getByText('Org 1')).toBeInTheDocument();
expect(screen.getByText('Org 2')).toBeInTheDocument();
});
});
@@ -2,8 +2,8 @@ import React, { useEffect } from 'react';
import { useAsyncFn } from 'react-use';
import { SelectableValue } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { AsyncSelect } from '@grafana/ui';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { Organization, UserOrg } from 'app/types';
export type OrgSelectItem = SelectableValue<Organization>;
@@ -56,7 +56,11 @@ export const CloneRuleButton = React.forwardRef<HTMLAnchorElement, CloneRuleButt
</div>
}
confirmText="Copy"
onConfirm={() => provRuleCloneUrl && locationService.push(provRuleCloneUrl)}
onConfirm={() => {
if (provRuleCloneUrl) {
locationService.push(provRuleCloneUrl);
}
}}
onDismiss={() => setProvRuleCloneUrl(undefined)}
/>
</>
@@ -4,6 +4,7 @@ import React, { useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { StoreState } from 'app/types';
@@ -63,7 +64,13 @@ export const AuthConfigPageUnconnected = ({ providerStatuses, isLoading, loadSet
documentation.
</a>
);
const subTitle = <span>Manage your auth settings and configure single sign-on. Find out more in our {docsLink}</span>;
const onCTAClick = () => {
reportInteraction('authentication_ui_created', { provider: firstAvailableProvider?.type });
};
return (
<Page navId="authentication" subTitle={subTitle}>
<Page.Contents isLoading={isLoading}>
@@ -92,6 +99,7 @@ export const AuthConfigPageUnconnected = ({ providerStatuses, isLoading, loadSet
description={`Important: if you have ${firstAvailableProvider.type} configuration enabled via the .ini file Grafana is using it.
Configuring ${firstAvailableProvider.type} via UI will take precedence over any configuration in the .ini file.
No changes will be written into .ini file.`}
onClick={onCTAClick}
/>
)}
{!!configuresProviders?.length && (
@@ -12,6 +12,7 @@ export interface Props {
buttonTitle: string;
buttonDisabled?: boolean;
description?: string;
onClick?: () => void;
}
const ConfigureAuthCTA: React.FunctionComponent<Props> = ({
@@ -21,6 +22,7 @@ const ConfigureAuthCTA: React.FunctionComponent<Props> = ({
buttonTitle,
buttonDisabled,
description,
onClick,
}) => {
const styles = useStyles2(getStyles);
const footer = description ? <span key="proTipFooter">{description}</span> : '';
@@ -34,6 +36,7 @@ const ConfigureAuthCTA: React.FunctionComponent<Props> = ({
className={ctaElementClassName}
data-testid={selectors.components.CallToActionCard.buttonV2(buttonTitle)}
disabled={buttonDisabled}
onClick={() => onClick && onClick()}
>
{buttonTitle}
</LinkButton>
@@ -16,7 +16,7 @@ import { skipToken, useGetFolderQuery, useSaveFolderMutation } from './api/brows
import { BrowseActions } from './components/BrowseActions/BrowseActions';
import { BrowseFilters } from './components/BrowseFilters';
import { BrowseView } from './components/BrowseView';
import { CreateNewButton } from './components/CreateNewButton';
import CreateNewButton from './components/CreateNewButton';
import { FolderActionsButton } from './components/FolderActionsButton';
import { SearchView } from './components/SearchView';
import { getFolderPermissions } from './permissions';
@@ -104,7 +104,7 @@ const BrowseDashboardsPage = memo(({ match }: Props) => {
{folderDTO && <FolderActionsButton folder={folderDTO} />}
{(canCreateDashboards || canCreateFolder) && (
<CreateNewButton
inFolder={folderUID}
parentFolder={folderDTO}
canCreateDashboard={canCreateDashboards}
canCreateFolder={canCreateFolder}
/>
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { useSearchStateManager } from 'app/features/search/state/SearchStateManager';
@@ -73,6 +74,7 @@ export function BrowseActions() {
const dashboard = findItem(rootItems?.items ?? [], childrenByParentUID, dashboardUID);
parentsToRefresh.add(dashboard?.parentUID);
}
trackAction('delete', selectedDashboards, selectedFolders);
onActionComplete(parentsToRefresh);
};
@@ -97,6 +99,7 @@ export function BrowseActions() {
const dashboard = findItem(rootItems?.items ?? [], childrenByParentUID, dashboardUID);
parentsToRefresh.add(dashboard?.parentUID);
}
trackAction('move', selectedDashboards, selectedFolders);
onActionComplete(parentsToRefresh);
};
@@ -144,3 +147,19 @@ const getStyles = (theme: GrafanaTheme2) => ({
marginBottom: theme.spacing(2),
}),
});
type actionType = 'move' | 'delete';
const actionMap: Record<actionType, string> = {
move: 'grafana_manage_dashboards_item_moved',
delete: 'grafana_manage_dashboards_item_deleted',
};
function trackAction(action: actionType, selectedDashboards: string[], selectedFolders: string[]) {
reportInteraction(actionMap[action], {
item_counts: {
folder: selectedFolders.length,
dashboard: selectedDashboards.length,
},
source: 'tree_actions',
});
}
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Space } from '@grafana/experimental';
import { ConfirmModal } from '@grafana/ui';
@@ -10,15 +10,22 @@ import { DescendantCount } from './DescendantCount';
export interface Props {
isOpen: boolean;
onConfirm: () => void;
onConfirm: () => Promise<void>;
onDismiss: () => void;
selectedItems: DashboardTreeSelection;
}
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const onDelete = () => {
onConfirm();
onDismiss();
const [isDeleting, setIsDeleting] = useState(false);
const onDelete = async () => {
setIsDeleting(true);
try {
await onConfirm();
setIsDeleting(false);
onDismiss();
} catch {
setIsDeleting(false);
}
};
return (
@@ -31,7 +38,7 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P
</>
}
confirmationText="Delete"
confirmText="Delete"
confirmText={isDeleting ? 'Deleting...' : 'Delete'}
onDismiss={onDismiss}
onConfirm={onDelete}
title="Delete"
@@ -11,20 +11,27 @@ import { DescendantCount } from './DescendantCount';
export interface Props {
isOpen: boolean;
onConfirm: (targetFolderUid: string) => void;
onConfirm: (targetFolderUid: string) => Promise<void>;
onDismiss: () => void;
selectedItems: DashboardTreeSelection;
}
export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const [moveTarget, setMoveTarget] = useState<string>();
const [isMoving, setIsMoving] = useState(false);
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
const onMove = () => {
const onMove = async () => {
if (moveTarget !== undefined) {
onConfirm(moveTarget);
setIsMoving(true);
try {
await onConfirm(moveTarget);
setIsMoving(false);
onDismiss();
} catch {
setIsMoving(false);
}
}
onDismiss();
};
return (
@@ -45,8 +52,8 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
<Button onClick={onDismiss} variant="secondary" fill="outline">
Cancel
</Button>
<Button disabled={moveTarget === undefined} onClick={onMove} variant="primary">
Move
<Button disabled={moveTarget === undefined || isMoving} onClick={onMove} variant="primary">
{isMoving ? 'Moving...' : 'Move'}
</Button>
</Modal.ButtonRow>
</Modal>
@@ -1,40 +1,65 @@
import { render, screen } from '@testing-library/react';
import { render as rtlRender, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { CreateNewButton } from './CreateNewButton';
import { FolderDTO } from 'app/types';
async function renderAndOpen(folderUID?: string) {
render(<CreateNewButton canCreateDashboard canCreateFolder inFolder={folderUID} />);
import { mockFolderDTO } from '../fixtures/folder.fixture';
import CreateNewButton from './CreateNewButton';
const mockParentFolder = mockFolderDTO();
function render(...[ui, options]: Parameters<typeof rtlRender>) {
rtlRender(<TestProvider>{ui}</TestProvider>, options);
}
async function renderAndOpen(folder?: FolderDTO) {
render(<CreateNewButton canCreateDashboard canCreateFolder parentFolder={folder} />);
const newButton = screen.getByText('New');
await userEvent.click(newButton);
}
describe('NewActionsButton', () => {
it('should display the correct urls with a given folderUID', async () => {
await renderAndOpen('123');
it('should display the correct urls with a given parent folder', async () => {
await renderAndOpen(mockParentFolder);
expect(screen.getByText('New Dashboard')).toHaveAttribute('href', '/dashboard/new?folderUid=123');
expect(screen.getByText('New Folder')).toHaveAttribute('href', '/dashboards/folder/new?folderUid=123');
expect(screen.getByText('Import')).toHaveAttribute('href', '/dashboard/import?folderUid=123');
expect(screen.getByText('New dashboard')).toHaveAttribute(
'href',
`/dashboard/new?folderUid=${mockParentFolder.uid}`
);
expect(screen.getByText('Import')).toHaveAttribute('href', `/dashboard/import?folderUid=${mockParentFolder.uid}`);
});
it('should display urls without params when there is no folderUID', async () => {
it('should display urls without params when there is no parent folder', async () => {
await renderAndOpen();
expect(screen.getByText('New Dashboard')).toHaveAttribute('href', '/dashboard/new');
expect(screen.getByText('New Folder')).toHaveAttribute('href', '/dashboards/folder/new');
expect(screen.getByText('New dashboard')).toHaveAttribute('href', '/dashboard/new');
expect(screen.getByText('Import')).toHaveAttribute('href', '/dashboard/import');
});
it('clicking the "New folder" button opens the drawer', async () => {
render(<CreateNewButton canCreateDashboard canCreateFolder parentFolder={mockParentFolder} />);
const newButton = screen.getByText('New');
await userEvent.click(newButton);
await userEvent.click(screen.getByText('New folder'));
const drawer = screen.getByRole('dialog', { name: 'Drawer title New folder' });
expect(drawer).toBeInTheDocument();
expect(within(drawer).getByRole('heading', { name: 'New folder' })).toBeInTheDocument();
expect(within(drawer).getByText(`Location: ${mockParentFolder.title}`)).toBeInTheDocument();
});
it('should only render dashboard items when folder creation is disabled', async () => {
render(<CreateNewButton canCreateDashboard canCreateFolder={false} />);
const newButton = screen.getByText('New');
await userEvent.click(newButton);
expect(screen.getByText('New Dashboard')).toBeInTheDocument();
expect(screen.getByText('New dashboard')).toBeInTheDocument();
expect(screen.getByText('Import')).toBeInTheDocument();
expect(screen.queryByText('New Folder')).not.toBeInTheDocument();
expect(screen.queryByText('New folder')).not.toBeInTheDocument();
});
it('should only render folder item when dashboard creation is disabled', async () => {
@@ -42,8 +67,8 @@ describe('NewActionsButton', () => {
const newButton = screen.getByText('New');
await userEvent.click(newButton);
expect(screen.queryByText('New Dashboard')).not.toBeInTheDocument();
expect(screen.queryByText('New dashboard')).not.toBeInTheDocument();
expect(screen.queryByText('Import')).not.toBeInTheDocument();
expect(screen.getByText('New Folder')).toBeInTheDocument();
expect(screen.getByText('New folder')).toBeInTheDocument();
});
});
@@ -1,48 +1,104 @@
import React, { useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { Button, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
import { reportInteraction } from '@grafana/runtime';
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
import { createNewFolder } from 'app/features/folders/state/actions';
import {
getNewDashboardPhrase,
getNewFolderPhrase,
getImportPhrase,
getNewPhrase,
} from 'app/features/search/tempI18nPhrases';
import { FolderDTO } from 'app/types';
interface Props {
/**
* Pass a folder UID in which the dashboard or folder will be created
*/
inFolder?: string;
import { NewFolderForm } from './NewFolderForm';
const mapDispatchToProps = {
createNewFolder,
};
const connector = connect(null, mapDispatchToProps);
interface OwnProps {
parentFolder?: FolderDTO;
canCreateFolder: boolean;
canCreateDashboard: boolean;
}
export function CreateNewButton({ inFolder, canCreateDashboard, canCreateFolder }: Props) {
type Props = OwnProps & ConnectedProps<typeof connector>;
function CreateNewButton({ parentFolder, canCreateDashboard, canCreateFolder, createNewFolder }: Props) {
const [isOpen, setIsOpen] = useState(false);
const location = useLocation();
const [showNewFolderDrawer, setShowNewFolderDrawer] = useState(false);
const onCreateFolder = (folderName: string) => {
createNewFolder(folderName, parentFolder?.uid);
const depth = parentFolder?.parents ? parentFolder.parents.length + 1 : 0;
reportInteraction('grafana_manage_dashboards_folder_created', {
is_subfolder: Boolean(parentFolder?.uid),
folder_depth: depth,
});
setShowNewFolderDrawer(false);
};
const newMenu = (
<Menu>
{canCreateDashboard && (
<MenuItem url={addFolderUidToUrl('/dashboard/new', inFolder)} label={getNewDashboardPhrase()} />
)}
{canCreateFolder && (
<MenuItem url={addFolderUidToUrl('/dashboards/folder/new', inFolder)} label={getNewFolderPhrase()} />
<MenuItem
label={getNewDashboardPhrase()}
onClick={() =>
reportInteraction('grafana_menu_item_clicked', {
url: addFolderUidToUrl('/dashboard/new', parentFolder?.uid),
from: location.pathname,
})
}
url={addFolderUidToUrl('/dashboard/new', parentFolder?.uid)}
/>
)}
{canCreateFolder && <MenuItem onClick={() => setShowNewFolderDrawer(true)} label={getNewFolderPhrase()} />}
{canCreateDashboard && (
<MenuItem url={addFolderUidToUrl('/dashboard/import', inFolder)} label={getImportPhrase()} />
<MenuItem
label={getImportPhrase()}
onClick={() =>
reportInteraction('grafana_menu_item_clicked', {
url: addFolderUidToUrl('/dashboard/import', parentFolder?.uid),
from: location.pathname,
})
}
url={addFolderUidToUrl('/dashboard/import', parentFolder?.uid)}
/>
)}
</Menu>
);
return (
<Dropdown overlay={newMenu} onVisibleChange={setIsOpen}>
<Button>
{getNewPhrase()}
<Icon name={isOpen ? 'angle-up' : 'angle-down'} />
</Button>
</Dropdown>
<>
<Dropdown overlay={newMenu} onVisibleChange={setIsOpen}>
<Button>
{getNewPhrase()}
<Icon name={isOpen ? 'angle-up' : 'angle-down'} />
</Button>
</Dropdown>
{showNewFolderDrawer && (
<Drawer
title={getNewFolderPhrase()}
subtitle={parentFolder?.title ? `Location: ${parentFolder.title}` : undefined}
scrollableContent
onClose={() => setShowNewFolderDrawer(false)}
size="sm"
>
<NewFolderForm onConfirm={onCreateFolder} onCancel={() => setShowNewFolderDrawer(false)} />
</Drawer>
)}
</>
);
}
export default connector(CreateNewButton);
/**
*
* @param url without any parameters
@@ -4,9 +4,11 @@ import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { appEvents, contextSrv } from 'app/core/core';
import { AccessControlAction, FolderDTO } from 'app/types';
import { AccessControlAction } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events';
import { mockFolderDTO } from '../fixtures/folder.fixture';
import { DeleteModal } from './BrowseActions/DeleteModal';
import { MoveModal } from './BrowseActions/MoveModal';
import { FolderActionsButton } from './FolderActionsButton';
@@ -21,22 +23,7 @@ jest.mock('app/core/components/AccessControl', () => ({
}));
describe('browse-dashboards FolderActionsButton', () => {
const mockFolder: FolderDTO = {
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
created: '',
createdBy: '',
hasAcl: true,
id: 1,
title: 'myFolder',
uid: '12345',
updated: '',
updatedBy: '',
url: '',
version: 1,
};
const mockFolder = mockFolderDTO();
beforeEach(() => {
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { locationService } from '@grafana/runtime';
import { locationService, reportInteraction } from '@grafana/runtime';
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
import { Permissions } from 'app/core/components/AccessControl';
import { appEvents, contextSrv } from 'app/core/core';
@@ -30,6 +30,13 @@ export function FolderActionsButton({ folder }: Props) {
const onMove = async (destinationUID: string) => {
await moveFolder({ folderUID: folder.uid, destinationUID });
reportInteraction('grafana_manage_dashboards_item_moved', {
item_counts: {
folder: 1,
dashboard: 0,
},
source: 'folder_actions',
});
dispatch(refetchChildren({ parentUID: destinationUID, pageSize: destinationUID ? PAGE_SIZE : ROOT_PAGE_SIZE }));
if (folder.parentUid) {
@@ -41,6 +48,13 @@ export function FolderActionsButton({ folder }: Props) {
const onDelete = async () => {
await dispatch(deleteFolder(folder.uid));
reportInteraction('grafana_manage_dashboards_item_deleted', {
item_counts: {
folder: 1,
dashboard: 0,
},
source: 'folder_actions',
});
if (folder.parentUid) {
dispatch(
refetchChildren({ parentUID: folder.parentUid, pageSize: folder.parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE })
@@ -4,6 +4,7 @@ import Skeleton from 'react-loading-skeleton';
import { CellProps } from 'react-table';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { IconButton, Link, Spinner, useStyles2 } from '@grafana/ui';
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { Span } from '@grafana/ui/src/unstable';
@@ -82,7 +83,13 @@ export function NameCell({ row: { original: data }, onFolderClick }: NameCellPro
)}
<Span variant="body" truncate>
{item.url ? (
<Link href={item.url} className={styles.link}>
<Link
onClick={() => {
reportInteraction('manage_dashboards_result_clicked');
}}
href={item.url}
className={styles.link}
>
{item.title}
</Link>
) : (
@@ -0,0 +1,59 @@
import React from 'react';
import { Button, Input, Form, Field, HorizontalGroup } from '@grafana/ui';
import { validationSrv } from '../../manage-dashboards/services/ValidationSrv';
interface Props {
onConfirm: (folderName: string) => void;
onCancel: () => void;
}
interface FormModel {
folderName: string;
}
const initialFormModel: FormModel = { folderName: '' };
export function NewFolderForm({ onCancel, onConfirm }: Props) {
const validateFolderName = async (folderName: string) => {
try {
await validationSrv.validateNewFolderName(folderName);
return true;
} catch (e) {
if (e instanceof Error) {
return e.message;
} else {
throw e;
}
}
};
return (
<Form defaultValues={initialFormModel} onSubmit={(form: FormModel) => onConfirm(form.folderName)}>
{({ register, errors }) => (
<>
<Field
label="Folder name"
invalid={!!errors.folderName}
error={errors.folderName && errors.folderName.message}
>
<Input
id="folder-name-input"
{...register('folderName', {
required: 'Folder name is required.',
validate: async (v) => await validateFolderName(v),
})}
/>
</Field>
<HorizontalGroup>
<Button variant="secondary" fill="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Create</Button>
</HorizontalGroup>
</>
)}
</Form>
);
}
@@ -0,0 +1,25 @@
import { Chance } from 'chance';
import { FolderDTO } from 'app/types';
export function mockFolderDTO(seed = 1, partial?: Partial<FolderDTO>): FolderDTO {
const random = Chance(seed);
const uid = random.guid();
return {
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
created: new Date(random.timestamp()).toISOString(),
createdBy: '',
hasAcl: true,
id: 1,
title: random.sentence({ words: 3 }),
uid,
updated: new Date(random.timestamp()).toISOString(),
updatedBy: '',
url: `/dashboards/f/${uid}`,
version: 1,
...partial,
};
}
@@ -1,7 +1,6 @@
import debounce from 'debounce-promise';
import { useEffect, useRef, useState } from 'react';
import { locationUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
@@ -43,7 +42,7 @@ export async function getRecentDashboardActions(): Promise<CommandPaletteAction[
name: `${name}`,
section: t('command-palette.section.recent-dashboards', 'Recent dashboards'),
priority: RECENT_DASHBOARDS_PRORITY,
url: locationUtil.stripBaseFromUrl(url),
url,
};
});
@@ -72,7 +71,7 @@ export async function getSearchResultActions(searchQuery: string): Promise<Comma
? t('command-palette.section.dashboard-search-results', 'Dashboards')
: t('command-palette.section.folder-search-results', 'Folders'),
priority: SEARCH_RESULTS_PRORITY,
url: locationUtil.stripBaseFromUrl(url),
url,
subtitle: data.view.dataFrame.meta?.custom?.locationInfo[location]?.name,
};
});

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