Merge branch 'main' into ifrost/rich-history-migration/frontend-storage-refactor
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"__inputs": [],
|
||||
"__elements": [],
|
||||
"__requires": [
|
||||
{
|
||||
"type": "grafana",
|
||||
"id": "grafana",
|
||||
"name": "Grafana",
|
||||
"version": "8.4.0-pre"
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "text",
|
||||
"name": "Text",
|
||||
"version": ""
|
||||
}
|
||||
],
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"target": {
|
||||
"limit": 100,
|
||||
"matchAny": false,
|
||||
"tags": [],
|
||||
"type": "dashboard"
|
||||
},
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"mode": "markdown",
|
||||
"content": "VariableUnderTest: $VariableUnderTest"
|
||||
},
|
||||
"pluginVersion": "8.4.0-pre",
|
||||
"title": "Panel Title",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"schemaVersion": 35,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Test variable output",
|
||||
"uid": "kVi2Gex7z",
|
||||
"version": 2,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
+++
|
||||
title = "Database encryption (Enterprise)"
|
||||
description = "Grafana Enterprise database encryption"
|
||||
keywords = ["grafana", "enterprise", "database", "encryption", "documentation"]
|
||||
aliases = [""]
|
||||
weight = 440
|
||||
+++
|
||||
|
||||
# Grafana Enterprise database encryption
|
||||
|
||||
If you are using Grafana Enterprise, you can change Grafana’s cryptographic mode of operation from AES-CFB to AES-GCM, and integrate with a key management system (KMS) provider.
|
||||
|
||||
## Changing your encryption mode to AES-GCM
|
||||
|
||||
Grafana encrypts secrets using Advanced Encryption Standard in Cipher
|
||||
FeedBack mode (AES-CFB). You might prefer to use AES in Galois/Counter
|
||||
Mode (AES-GCM) instead, to meet your company’s security requirements or
|
||||
in order to maintain consistency with other services.
|
||||
|
||||
To change your encryption mode, update the `algorithm` value in the
|
||||
`[security.encryption]` section of your Grafana configuration file.
|
||||
For details, refer to Enterprise configuration.
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
title = "Database encryption"
|
||||
description = "Grafana database encryption"
|
||||
keywords = ["grafana", "database", "encryption", "documentation"]
|
||||
keywords = ["grafana", "database", "encryption", "envelope encryption", "documentation"]
|
||||
aliases = [""]
|
||||
weight = 450
|
||||
+++
|
||||
@@ -12,6 +12,24 @@ Grafana’s database contains secrets, which are used to query data sources, sen
|
||||
|
||||
Grafana encrypts these secrets before they are written to the database, by using a symmetric-key encryption algorithm called Advanced Encryption Standard (AES), and using a [secret key]({{< relref "../administration/configuration/#secret_key" >}}) that you can change when you configure a new Grafana instance.
|
||||
|
||||
You can choose to use [envelope encryption]({{< relref "./envelope-encryption.md" >}}), which complements a [KMS integration]({{< relref "../enterprise/kms-integration/_index.md" >}}) in Grafana Enterprise by adding a layer of indirection to the encryption process.
|
||||
You can choose to use [envelope encryption](#envelope-encryption), which adds a layer of indirection to the encryption process.
|
||||
|
||||
In Grafana Enterprise, you can also choose to [encrypt secrets in AES-GCM mode]({{< relref "../administration/database-encryption-enterprise.md" >}}) instead of AES-CFB.
|
||||
> **Note:** In Grafana Enterprise, you can also choose to [encrypt secrets in AES-GCM mode]({{< relref "../enterprise/enterprise-encryption/#changing-your-encryption-mode-to-aes-gcm" >}}) instead of AES-CFB.
|
||||
|
||||
# Envelope encryption
|
||||
|
||||
In Grafana, you can choose to use envelope encryption. Instead of
|
||||
encrypting all secrets with a single key, Grafana uses a set of keys
|
||||
called data encryption keys (DEKs) to encrypt them. These data
|
||||
encryption keys are themselves encrypted with a single key encryption
|
||||
key (KEK).
|
||||
|
||||
To turn on envelope encryption, add the term `envelopeEncryption` to the list of feature toggles in your [Grafana configuration]({{< relref "../administration/configuration/#feature_toggles" >}}).
|
||||
|
||||
> **Note:** Avoid turning off envelope encryption once you have turned it on, and back up your database before turning it on for the first time. If you turn envelope encryption on, create new secrets or update your existing secrets (for example, by creating a new data source or alert notification channel), and then turn envelope encryption off, then those data sources, alert notification channels, and other resources using envelope encryption will stop working and you will experience errors. This is because the secrets encrypted with envelope encryption cannot be decrypted or used by Grafana when envelope encryption is turned off.
|
||||
|
||||
# KMS integration
|
||||
|
||||
With KMS integrations, you can choose to encrypt secrets stored in the Grafana database using a key from a KMS, which is a secure central storage location that is designed to help you to create and manage cryptographic keys and control their use across many services.
|
||||
|
||||
> **Note:** KMS integration is available in Grafana Enterprise. For more information, refer to [Enterprise Encryption]({{< relref "../enterprise/enterprise-encryption/_index.md" >}}) in Grafana Enterprise.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
+++
|
||||
title = "Envelope encryption"
|
||||
description = "Envelope encryption"
|
||||
keywords = ["grafana", "envelope encryption", "documentation"]
|
||||
aliases = [""]
|
||||
weight = 430
|
||||
+++
|
||||
|
||||
# Envelope encryption
|
||||
|
||||
In Grafana, you can choose to use envelope encryption. Instead of
|
||||
encrypting all secrets with a single key, Grafana uses a set of keys
|
||||
called data encryption keys (DEKs) to encrypt them. These data
|
||||
encryption keys are themselves encrypted with a single key encryption
|
||||
key (KEK).
|
||||
|
||||
To turn on envelope encryption, add the term `envelopeEncryption` to the list of feature toggles in your [Grafana configuration]({{< relref "../administration/configuration/#feature_toggles" >}}).
|
||||
|
||||
> **Note:** Avoid turning off envelope encryption once you have turned it on, and back up your database before turning it on for the first time. If you turn envelope encryption on, create new secrets or update your existing secrets (for example, by creating a new data source or alert notification channel), and then turn envelope encryption off, then those data sources, alert notification channels, and other resources using envelope encryption will stop working and you will experience errors. This is because the secrets encrypted with envelope encryption cannot be decrypted or used by Grafana when envelope encryption is turned off.
|
||||
|
||||
Refer to [Database encryption]({{< relref "../administration/database-encryption.md" >}}) to learn more about how Grafana encrypts secrets in the database.
|
||||
@@ -0,0 +1,35 @@
|
||||
+++
|
||||
title = "Enterprise database encryption"
|
||||
description = "Grafana Enterprise database encryption"
|
||||
keywords = ["grafana", "enterprise", "database", "encryption", "documentation"]
|
||||
aliases = [""]
|
||||
weight = 130
|
||||
+++
|
||||
|
||||
# Grafana Enterprise database encryption
|
||||
|
||||
If you are using Grafana Enterprise, you can integrate with a key management system (KMS) provider, and change Grafana’s cryptographic mode of operation from AES-CFB to AES-GCM.
|
||||
|
||||
## Encrypting your database with a key from a Key Management System (KMS)
|
||||
|
||||
You can choose to encrypt secrets stored in the Grafana database using a key from a KMS, which is a secure central storage location that is designed to help you to create and manage cryptographic keys and control their use across many services. When you integrate with a KMS, Grafana does not directly store your encryption key. Instead, Grafana stores KMS credentials and the identifier of the key, which Grafana uses to encrypt the database.
|
||||
|
||||
Grafana integrates with the following key management systems:
|
||||
|
||||
- [AWS KMS]({{< relref "/using-aws-kms-to-encrypt-database-secrets.md" >}})
|
||||
- [Azure Key Vault]({{< relref "/using-azure-key-vault-to-encrypt-database-secrets.md" >}})
|
||||
- [Google Cloud KMS]({{< relref "/using-google-cloud-kms-to-encrypt-database-secrets.md" >}})
|
||||
- [Hashicorp Key Vault]({{< relref "/using-hashicorp-key-vault-to-encrypt-database-secrets.md" >}})
|
||||
|
||||
Refer to [Database encryption]({{< relref "../../administration/database-encryption.md" >}}) to learn more about how Grafana encrypts secrets in the database.
|
||||
|
||||
## Changing your encryption mode to AES-GCM
|
||||
|
||||
Grafana encrypts secrets using Advanced Encryption Standard in Cipher
|
||||
FeedBack mode (AES-CFB). You might prefer to use AES in Galois/Counter
|
||||
Mode (AES-GCM) instead, to meet your company’s security requirements or
|
||||
in order to maintain consistency with other services.
|
||||
|
||||
To change your encryption mode, update the `algorithm` value in the
|
||||
`[security.encryption]` section of your Grafana configuration file.
|
||||
For details, refer to [Enterprise configuration]({{< relref "../enterprise-configuration.md#securityencryption" >}}).
|
||||
@@ -1,19 +0,0 @@
|
||||
+++
|
||||
title = "KMS integration"
|
||||
description = ""
|
||||
keywords = ["grafana", "kms", "key management system integration"]
|
||||
weight = 1200
|
||||
+++
|
||||
|
||||
# Key management systems (KMSs)
|
||||
|
||||
You can choose to encrypt secrets stored in the Grafana database using a key from a KMS, which is a secure central storage location that is designed to help you to create and manage cryptographic keys and control their use across many services. When you integrate with a KMS, Grafana does not directly store your encryption key. Instead, Grafana stores KMS credentials and the identifier of the key, which Grafana uses to encrypt the database.
|
||||
|
||||
Grafana integrates with the following key management systems:
|
||||
|
||||
- [AWS KMS]({{< relref "/using-aws-kms-to-encrypt-database-secrets.md" >}})
|
||||
- [Azure Key Vault]({{< relref "/using-azure-key-vault-to-encrypt-database-secrets.md" >}})
|
||||
- [Google Cloud KMS]({{< relref "/using-google-cloud-kms-to-encrypt-database-secrets.md" >}})
|
||||
- [Hashicorp Key Vault]({{< relref "/using-hashicorp-key-vault-to-encrypt-database-secrets.md" >}})
|
||||
|
||||
Refer to [Database encryption]({{< relref "../../administration/database-encryption.md" >}}) to learn more about how Grafana encrypts secrets in the database.
|
||||
@@ -269,8 +269,8 @@ Creates a new library element.
|
||||
|
||||
JSON Body schema:
|
||||
|
||||
- **folderId** – ID of the folder where the library element is stored.
|
||||
- **name** – Name of the library element.
|
||||
- **folderId** – Optional, the ID of the folder where the library element is stored.
|
||||
- **name** – Optional, the name of the library element.
|
||||
- **model** – The JSON model for the library element.
|
||||
- **kind** – Kind of element to create, Use `1` for library panels or `2` for library variables.
|
||||
- **uid** – Optional, the [unique identifier](/http_api/library_element/#identifier-id-vs-unique-identifier-uid).
|
||||
|
||||
@@ -71,7 +71,7 @@ JSON Body schema:
|
||||
"deleteUrl":"myurl/api/snapshots-delete/XXXXXXX",
|
||||
"key":"YYYYYYY",
|
||||
"url":"myurl/dashboard/snapshot/YYYYYYY",
|
||||
"id": 1,
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+18
-5
@@ -148,31 +148,44 @@ Reduce takes one or more time series returned from a query or an expression and
|
||||
|
||||
- **Function -** The reduction function to use
|
||||
- **Input -** The variable (refID (such as `A`)) to resample
|
||||
- **Mode -** Allows control behavior of reduction function when a series contains non-numerical values (null, NaN, +\-Inf)
|
||||
|
||||
#### Reduction Functions
|
||||
|
||||
> **Note:** In the future we plan to add options to control empty, NaN, and null behavior for reduction functions.
|
||||
|
||||
##### Count
|
||||
|
||||
Count returns the number of points in each series.
|
||||
|
||||
##### Mean
|
||||
|
||||
Mean returns the total of all values in each series divided by the number of points in that series. If any values in the series are null or nan, or if the series is empty, NaN is returned.
|
||||
Mean returns the total of all values in each series divided by the number of points in that series. In `strict` mode if any values in the series are null or nan, or if the series is empty, NaN is returned.
|
||||
|
||||
##### Min and Max
|
||||
|
||||
Min and Max return the smallest or largest value in the series respectively. If any values in the series are null or nan, or if the series is empty, NaN is returned.
|
||||
Min and Max return the smallest or largest value in the series respectively. In `strict` mode if any values in the series are null or nan, or if the series is empty, NaN is returned.
|
||||
|
||||
##### Sum
|
||||
|
||||
Sum returns the total of all values in the series. If series is of zero length, the sum will be 0. If there are any NaN or Null values in the series, NaN is returned.
|
||||
Sum returns the total of all values in the series. If series is of zero length, the sum will be 0. In `strict` mode if there are any NaN or Null values in the series, NaN is returned.
|
||||
|
||||
#### Last
|
||||
|
||||
Last returns the last number in the series. If the series has no values then returns NaN.
|
||||
|
||||
#### Reduction Modes
|
||||
|
||||
##### Strict
|
||||
|
||||
In Strict mode the input series is processed as is. If any values in the series are non-numeric (null, NaN or +\-Inf), NaN is returned.
|
||||
|
||||
##### Drop Non-Numeric
|
||||
|
||||
In this mode all non-numeric values (null, NaN or +\-Inf) in the input series are filtered out before executing the reduction function.
|
||||
|
||||
##### Replace Non-Numeric
|
||||
|
||||
In this mode all non-numeric values are replaced by a pre-defined value.
|
||||
|
||||
### Resample
|
||||
|
||||
Resample changes the time stamps in each time series to have a consistent time interval. The main use case is so you can resample time series that do not share the same timestamps so math can be performed between them. This can be done by resample each of the two series, and then in a Math operation referencing the resampled variables.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { e2e } from '@grafana/e2e';
|
||||
|
||||
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
||||
|
||||
describe('Variables - Constant', () => {
|
||||
it('can add a new text box variable', () => {
|
||||
e2e.flows.login('admin', 'admin');
|
||||
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` });
|
||||
|
||||
// Create a new "Constant" variable
|
||||
e2e.components.CallToActionCard.buttonV2('Add variable').click();
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect().type('Constant{enter}');
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().clear().type('VariableUnderTest').blur();
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type('Variable under test').blur();
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.constantOptionsQueryInput().type('pesto').blur();
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().eq(0).should('have.text', 'pesto');
|
||||
|
||||
// Navigate back to the homepage and change the selected variable value
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||
e2e.components.BackButton.backArrow().should('be.visible').click({ force: true });
|
||||
e2e.components.RefreshPicker.runButtonV2().click();
|
||||
|
||||
// Assert it was rendered
|
||||
e2e().get('.markdown-html').should('include.text', 'VariableUnderTest: pesto');
|
||||
|
||||
// Assert the variable is not visible in the dashboard nav
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemLabels('Variable under test').should('not.exist');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { e2e } from '@grafana/e2e';
|
||||
|
||||
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
||||
|
||||
function fillInCustomVariable(name: string, label: string, value: string) {
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect().type('Custom{enter}');
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().clear().type(name).blur();
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type(label).blur();
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput().type(value).blur();
|
||||
}
|
||||
|
||||
function assertPreviewValues(expectedValues: string[]) {
|
||||
for (const expected of expectedValues) {
|
||||
const index = expectedValues.indexOf(expected);
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().eq(index).should('have.text', expected);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Variables - Custom', () => {
|
||||
it('can add a custom template variable', () => {
|
||||
e2e.flows.login('admin', 'admin');
|
||||
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` });
|
||||
|
||||
// Create a new "Custom" variable
|
||||
e2e.components.CallToActionCard.buttonV2('Add variable').click();
|
||||
fillInCustomVariable('VariableUnderTest', 'Variable under test', 'one,two,three');
|
||||
assertPreviewValues(['one', 'two', 'three']);
|
||||
|
||||
// Navigate back to the homepage and change the selected variable value
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||
e2e.components.BackButton.backArrow().should('be.visible').click({ force: true });
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('one').click();
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('two').click();
|
||||
|
||||
// Assert it was rendered
|
||||
e2e().get('.markdown-html').should('include.text', 'VariableUnderTest: two');
|
||||
});
|
||||
|
||||
it('can add a custom template variable with labels', () => {
|
||||
e2e.flows.login('admin', 'admin');
|
||||
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` });
|
||||
|
||||
// Create a new "Custom" variable
|
||||
e2e.components.CallToActionCard.buttonV2('Add variable').click();
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect().type('Custom{enter}');
|
||||
|
||||
// Set it's name, label, and content
|
||||
fillInCustomVariable('VariableUnderTest', 'Variable under test', 'One : 1,Two : 2, Three : 3');
|
||||
assertPreviewValues(['One', 'Two', 'Three']);
|
||||
|
||||
// Navigate back to the homepage and change the selected variable value
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||
e2e.components.BackButton.backArrow().should('be.visible').click({ force: true });
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('One').click();
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('Two').click();
|
||||
|
||||
// Assert it was rendered
|
||||
e2e().get('.markdown-html').should('include.text', 'VariableUnderTest: 2');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { e2e } from '@grafana/e2e';
|
||||
|
||||
const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
||||
|
||||
describe('Variables - Add variable', () => {
|
||||
describe('Variables - Query - Add variable', () => {
|
||||
it('query variable should be default and default fields should be correct', () => {
|
||||
e2e.flows.login('admin', 'admin');
|
||||
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` });
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { e2e } from '@grafana/e2e';
|
||||
|
||||
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
||||
|
||||
describe('Variables - Text box', () => {
|
||||
it('can add a new text box variable', () => {
|
||||
e2e.flows.login('admin', 'admin');
|
||||
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` });
|
||||
|
||||
// Create a new "Custom" variable
|
||||
e2e.components.CallToActionCard.buttonV2('Add variable').click();
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect().type('Text box{enter}');
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().clear().type('VariableUnderTest').blur();
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type('Variable under test').blur();
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInput().type('cat-dog').blur();
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().eq(0).should('have.text', 'cat-dog');
|
||||
|
||||
// Navigate back to the homepage and change the selected variable value
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||
e2e.components.BackButton.backArrow().should('be.visible').click({ force: true });
|
||||
e2e().get('#VariableUnderTest').clear().type('dog-cat').blur();
|
||||
|
||||
// Assert it was rendered
|
||||
e2e().get('.markdown-html').should('include.text', 'VariableUnderTest: dog-cat');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -53,7 +53,7 @@
|
||||
"postinstall": "husky install"
|
||||
},
|
||||
"grafana": {
|
||||
"whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v8-3/",
|
||||
"whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v8-4/",
|
||||
"releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
@@ -67,6 +67,12 @@ export interface FieldConfig<TOptions = any> {
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
|
||||
// Interval indicates the expected regular step between values in the series.
|
||||
// When an interval exists, consumers can identify "missing" values when the expected value is not present.
|
||||
// The grafana timeseries visualization will render disconnected values when missing values are found it the time field.
|
||||
// The interval uses the same units as the values. For time.Time, this is defined in milliseconds.
|
||||
interval?: number | null;
|
||||
|
||||
// Convert input values into a display string
|
||||
mappings?: ValueMapping[];
|
||||
|
||||
|
||||
@@ -135,6 +135,9 @@ export const Pages = {
|
||||
TextBoxVariable: {
|
||||
textBoxOptionsQueryInput: 'Variable editor Form TextBox Query field',
|
||||
},
|
||||
CustomVariable: {
|
||||
customValueInput: 'data-testid custom-variable-input',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -32,6 +32,10 @@ export const CollapsableSection: FC<Props> = ({
|
||||
const styles = useStyles2(collapsableSectionStyles);
|
||||
const tooltip = `Click to ${open ? 'collapse' : 'expand'}`;
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
if (e.target instanceof HTMLElement && e.target.tagName === 'A') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data';
|
||||
import { applyNullInsertThreshold } from './nullInsertThreshold';
|
||||
|
||||
function randInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
function genFrame() {
|
||||
let fieldCount = 10;
|
||||
let valueCount = 3000;
|
||||
let step = 1000;
|
||||
let skipProb = 0.5;
|
||||
let skipSteps = [1, 5]; // min, max
|
||||
|
||||
let allValues = Array(fieldCount);
|
||||
|
||||
allValues[0] = Array(valueCount);
|
||||
|
||||
for (let i = 0, curStep = Date.now(); i < valueCount; i++) {
|
||||
curStep = allValues[0][i] = curStep + step * (Math.random() < skipProb ? randInt(skipSteps[0], skipSteps[1]) : 1);
|
||||
}
|
||||
|
||||
for (let fi = 1; fi < fieldCount; fi++) {
|
||||
let values = Array(valueCount);
|
||||
|
||||
for (let i = 0; i < valueCount; i++) {
|
||||
values[i] = Math.random() * 100;
|
||||
}
|
||||
|
||||
allValues[fi] = values;
|
||||
}
|
||||
|
||||
return {
|
||||
length: valueCount,
|
||||
fields: allValues.map((values, i) => {
|
||||
return {
|
||||
name: 'A-' + i,
|
||||
type: i === 0 ? FieldType.time : FieldType.number,
|
||||
config: {
|
||||
interval: i === 0 ? step : null,
|
||||
},
|
||||
values: new ArrayVector(values),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('nullInsertThreshold Transformer', () => {
|
||||
test('should insert nulls at +threshold between adjacent > threshold: 1', () => {
|
||||
const df = new MutableDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [1, 3, 10] },
|
||||
{ name: 'One', type: FieldType.number, config: { custom: { insertNulls: 1 } }, values: [4, 6, 8] },
|
||||
{ name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 1 } }, values: ['a', 'b', 'c'] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyNullInsertThreshold(df);
|
||||
|
||||
expect(result.fields[0].values.toArray()).toStrictEqual([1, 2, 3, 4, 10]);
|
||||
expect(result.fields[1].values.toArray()).toStrictEqual([4, null, 6, null, 8]);
|
||||
expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']);
|
||||
});
|
||||
|
||||
test('should insert nulls at +threshold between adjacent > threshold: 2', () => {
|
||||
const df = new MutableDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [5, 7, 11] },
|
||||
{ name: 'One', type: FieldType.number, config: { custom: { insertNulls: 2 } }, values: [4, 6, 8] },
|
||||
{ name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 2 } }, values: ['a', 'b', 'c'] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyNullInsertThreshold(df);
|
||||
|
||||
expect(result.fields[0].values.toArray()).toStrictEqual([5, 7, 9, 11]);
|
||||
expect(result.fields[1].values.toArray()).toStrictEqual([4, 6, null, 8]);
|
||||
expect(result.fields[2].values.toArray()).toStrictEqual(['a', 'b', null, 'c']);
|
||||
});
|
||||
|
||||
test('should insert nulls at +interval between adjacent > interval: 1', () => {
|
||||
const df = new MutableDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 10] },
|
||||
{ name: 'One', type: FieldType.number, values: [4, 6, 8] },
|
||||
{ name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyNullInsertThreshold(df);
|
||||
|
||||
expect(result.fields[0].values.toArray()).toStrictEqual([1, 2, 3, 4, 10]);
|
||||
expect(result.fields[1].values.toArray()).toStrictEqual([4, null, 6, null, 8]);
|
||||
expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']);
|
||||
});
|
||||
|
||||
// TODO: make this work
|
||||
test.skip('should insert nulls at +threshold (when defined) instead of +interval', () => {
|
||||
const df = new MutableDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, config: { interval: 2 }, values: [5, 7, 11] },
|
||||
{ name: 'One', type: FieldType.number, config: { custom: { insertNulls: 1 } }, values: [4, 6, 8] },
|
||||
{ name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 1 } }, values: ['a', 'b', 'c'] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyNullInsertThreshold(df);
|
||||
|
||||
expect(result.fields[0].values.toArray()).toStrictEqual([5, 6, 7, 8, 11]);
|
||||
expect(result.fields[1].values.toArray()).toStrictEqual([4, null, 6, null, 8]);
|
||||
expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']);
|
||||
});
|
||||
|
||||
test('should insert nulls at midpoints between adjacent > interval: 2', () => {
|
||||
const df = new MutableDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, config: { interval: 2 }, values: [5, 7, 11] },
|
||||
{ name: 'One', type: FieldType.number, values: [4, 6, 8] },
|
||||
{ name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyNullInsertThreshold(df);
|
||||
|
||||
expect(result.fields[0].values.toArray()).toStrictEqual([5, 7, 9, 11]);
|
||||
expect(result.fields[1].values.toArray()).toStrictEqual([4, 6, null, 8]);
|
||||
expect(result.fields[2].values.toArray()).toStrictEqual(['a', 'b', null, 'c']);
|
||||
});
|
||||
|
||||
test('should noop on fewer than two values', () => {
|
||||
const df = new MutableDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1] },
|
||||
{ name: 'Value', type: FieldType.number, values: [1] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyNullInsertThreshold(df);
|
||||
|
||||
expect(result).toBe(df);
|
||||
});
|
||||
|
||||
test('should noop on invalid threshold', () => {
|
||||
const df = new MutableDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [1, 2, 4] },
|
||||
{ name: 'Value', type: FieldType.number, config: { custom: { insertNulls: -1 } }, values: [1, 1, 1] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyNullInsertThreshold(df);
|
||||
|
||||
expect(result).toBe(df);
|
||||
});
|
||||
|
||||
test('should noop on invalid interval', () => {
|
||||
const df = new MutableDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, config: { interval: -1 }, values: [1, 2, 4] },
|
||||
{ name: 'Value', type: FieldType.number, values: [1, 1, 1] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyNullInsertThreshold(df);
|
||||
|
||||
expect(result).toBe(df);
|
||||
});
|
||||
|
||||
test('should noop when no missing steps', () => {
|
||||
const df = new MutableDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 2, 3] },
|
||||
{ name: 'Value', type: FieldType.number, values: [1, 1, 1] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyNullInsertThreshold(df);
|
||||
|
||||
expect(result).toBe(df);
|
||||
});
|
||||
|
||||
test('should noop when refFieldName not found', () => {
|
||||
const df = new MutableDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 2, 5] },
|
||||
{ name: 'Value', type: FieldType.number, values: [1, 1, 1] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyNullInsertThreshold(df, 'Time2');
|
||||
|
||||
expect(result).toBe(df);
|
||||
});
|
||||
|
||||
test('perf stress test should be <= 10ms', () => {
|
||||
// 10 fields x 3,000 values with 50% skip (output = 10 fields x 6,000 values)
|
||||
let bigFrameA = genFrame();
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.time('insertValues-10x3k');
|
||||
applyNullInsertThreshold(bigFrameA);
|
||||
// eslint-disable-next-line no-console
|
||||
console.timeEnd('insertValues-10x3k');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ArrayVector, DataFrame, FieldType } from '@grafana/data';
|
||||
|
||||
type InsertMode = (prev: number, next: number, threshold: number) => number;
|
||||
|
||||
const INSERT_MODES = {
|
||||
threshold: (prev: number, next: number, threshold: number) => prev + threshold,
|
||||
midpoint: (prev: number, next: number, threshold: number) => (prev + next) / 2,
|
||||
// previous time + 1ms to prevent StateTimeline from forward-interpolating prior state
|
||||
plusone: (prev: number, next: number, threshold: number) => prev + 1,
|
||||
};
|
||||
|
||||
export function applyNullInsertThreshold(
|
||||
frame: DataFrame,
|
||||
refFieldName?: string | null,
|
||||
insertMode: InsertMode = INSERT_MODES.threshold
|
||||
): DataFrame {
|
||||
if (frame.length < 2) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
const refField = frame.fields.find((field) => {
|
||||
// note: getFieldDisplayName() would require full DF[]
|
||||
return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time;
|
||||
});
|
||||
|
||||
if (refField == null) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
const thresholds = frame.fields.map((field) => field.config.custom?.insertNulls ?? refField.config.interval ?? null);
|
||||
|
||||
const uniqueThresholds = new Set<number>(thresholds);
|
||||
|
||||
uniqueThresholds.delete(null as any);
|
||||
|
||||
if (uniqueThresholds.size === 0) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
if (uniqueThresholds.size === 1) {
|
||||
const threshold = uniqueThresholds.values().next().value;
|
||||
|
||||
if (threshold <= 0) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
const refValues = refField.values.toArray();
|
||||
|
||||
const frameValues = frame.fields.map((field) => field.values.toArray());
|
||||
|
||||
const filledFieldValues = nullInsertThreshold(refValues, frameValues, threshold, insertMode);
|
||||
|
||||
if (filledFieldValues === frameValues) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
return {
|
||||
...frame,
|
||||
length: filledFieldValues[0].length,
|
||||
fields: frame.fields.map((field, i) => ({
|
||||
...field,
|
||||
values: new ArrayVector(filledFieldValues[i]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: unique threshold-per-field (via overrides) is unimplemented
|
||||
// should be done by processing each (refField + thresholdA-field1 + thresholdA-field2...)
|
||||
// as a separate nullInsertThreshold() dataset, then re-join into single dataset via join()
|
||||
return frame;
|
||||
}
|
||||
|
||||
function nullInsertThreshold(refValues: number[], frameValues: any[][], threshold: number, getInsertValue: InsertMode) {
|
||||
const len = refValues.length;
|
||||
let prevValue: number = refValues[0];
|
||||
const refValuesNew: number[] = [prevValue];
|
||||
|
||||
for (let i = 1; i < len; i++) {
|
||||
const curValue = refValues[i];
|
||||
|
||||
if (curValue - prevValue > threshold) {
|
||||
refValuesNew.push(getInsertValue(prevValue, curValue, threshold));
|
||||
}
|
||||
|
||||
refValuesNew.push(curValue);
|
||||
|
||||
prevValue = curValue;
|
||||
}
|
||||
|
||||
const filledLen = refValuesNew.length;
|
||||
|
||||
if (filledLen === len) {
|
||||
return frameValues;
|
||||
}
|
||||
|
||||
const filledFieldValues: any[][] = [];
|
||||
|
||||
for (let fieldValues of frameValues) {
|
||||
let filledValues;
|
||||
|
||||
if (fieldValues !== refValues) {
|
||||
filledValues = Array(filledLen);
|
||||
|
||||
for (let i = 0, j = 0; i < filledLen; i++) {
|
||||
filledValues[i] = refValues[j] === refValuesNew[i] ? fieldValues[j++] : null;
|
||||
}
|
||||
} else {
|
||||
filledValues = refValuesNew;
|
||||
}
|
||||
|
||||
filledFieldValues.push(filledValues);
|
||||
}
|
||||
|
||||
return filledFieldValues;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { XYFieldMatchers } from './types';
|
||||
import { ArrayVector, DataFrame, FieldConfig, FieldType, outerJoinDataFrames } from '@grafana/data';
|
||||
import { nullToUndefThreshold } from './nullToUndefThreshold';
|
||||
import { applyNullInsertThreshold } from './nullInsertThreshold';
|
||||
import { AxisPlacement, GraphFieldConfig, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema';
|
||||
import { FIXED_UNIT } from './GraphNG';
|
||||
|
||||
@@ -32,7 +33,7 @@ function applySpanNullsThresholds(frame: DataFrame) {
|
||||
|
||||
export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers) {
|
||||
let alignedFrame = outerJoinDataFrames({
|
||||
frames: frames,
|
||||
frames: frames.map((frame) => applyNullInsertThreshold(frame)),
|
||||
joinBy: dimFields.x,
|
||||
keep: dimFields.y,
|
||||
keepOriginIndices: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataFrame, FieldConfig, FieldSparkline, IndexVector } from '@grafana/data';
|
||||
import { GraphFieldConfig } from '@grafana/schema';
|
||||
import { applyNullInsertThreshold } from '../GraphNG/nullInsertThreshold';
|
||||
|
||||
/** @internal
|
||||
* Given a sparkline config returns a DataFrame ready to be turned into Plot data set
|
||||
@@ -11,7 +12,7 @@ export function preparePlotFrame(sparkline: FieldSparkline, config?: FieldConfig
|
||||
...config,
|
||||
};
|
||||
|
||||
return {
|
||||
return applyNullInsertThreshold({
|
||||
refId: 'sparkline',
|
||||
fields: [
|
||||
sparkline.x ?? IndexVector.newField(length),
|
||||
@@ -21,5 +22,5 @@ export function preparePlotFrame(sparkline: FieldSparkline, config?: FieldConfig
|
||||
},
|
||||
],
|
||||
length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
@@ -112,7 +111,7 @@ func (hs *HTTPServer) AdminUpdateUserPermissions(c *models.ReqContext) response.
|
||||
return response.Error(http.StatusBadRequest, "id is invalid", err)
|
||||
}
|
||||
|
||||
err = updateUserPermissions(hs.SQLStore, userID, form.IsGrafanaAdmin)
|
||||
err = hs.SQLStore.UpdateUserPermissions(userID, form.IsGrafanaAdmin)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrLastGrafanaAdmin) {
|
||||
return response.Error(400, models.ErrLastGrafanaAdmin.Error(), nil)
|
||||
@@ -230,10 +229,3 @@ func (hs *HTTPServer) AdminRevokeUserAuthToken(c *models.ReqContext) response.Re
|
||||
}
|
||||
return hs.revokeUserAuthTokenInternal(c, userID, cmd)
|
||||
}
|
||||
|
||||
// updateUserPermissions updates the user's permissions.
|
||||
//
|
||||
// Stubbable by tests.
|
||||
var updateUserPermissions = func(sqlStore *sqlstore.SQLStore, userID int64, isAdmin bool) error {
|
||||
return sqlStore.UpdateUserPermissions(userID, isAdmin)
|
||||
}
|
||||
|
||||
+28
-43
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -34,25 +35,18 @@ func TestAdminAPIEndpoint(t *testing.T) {
|
||||
updateCmd := dtos.AdminUpdateUserPermissionsForm{
|
||||
IsGrafanaAdmin: false,
|
||||
}
|
||||
|
||||
mock := mockstore.SQLStoreMock{
|
||||
ExpectedError: models.ErrLastGrafanaAdmin,
|
||||
}
|
||||
putAdminScenario(t, "When calling PUT on", "/api/admin/users/1/permissions",
|
||||
"/api/admin/users/:id/permissions", role, updateCmd, func(sc *scenarioContext) {
|
||||
// TODO: Use a fake SQLStore when it's represented by an interface
|
||||
origUpdateUserPermissions := updateUserPermissions
|
||||
t.Cleanup(func() {
|
||||
updateUserPermissions = origUpdateUserPermissions
|
||||
})
|
||||
|
||||
updateUserPermissions = func(sqlStore *sqlstore.SQLStore, userID int64, isAdmin bool) error {
|
||||
return models.ErrLastGrafanaAdmin
|
||||
}
|
||||
|
||||
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 400, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When a server admin attempts to logout himself from all devices", func(t *testing.T) {
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
adminLogoutUserScenario(t, "Should not be allowed when calling POST on",
|
||||
"/api/admin/users/1/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
@@ -62,54 +56,41 @@ func TestAdminAPIEndpoint(t *testing.T) {
|
||||
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 400, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When a server admin attempts to logout a non-existing user from all devices", func(t *testing.T) {
|
||||
mock := mockstore.SQLStoreMock{
|
||||
ExpectedError: models.ErrUserNotFound,
|
||||
}
|
||||
adminLogoutUserScenario(t, "Should return not found when calling POST on", "/api/admin/users/200/logout",
|
||||
"/api/admin/users/:id/logout", func(sc *scenarioContext) {
|
||||
userID := int64(0)
|
||||
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
userID = cmd.Id
|
||||
return models.ErrUserNotFound
|
||||
})
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
assert.Equal(t, int64(200), userID)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When a server admin attempts to revoke an auth token for a non-existing user", func(t *testing.T) {
|
||||
cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2}
|
||||
|
||||
mock := mockstore.SQLStoreMock{
|
||||
ExpectedError: models.ErrUserNotFound,
|
||||
}
|
||||
adminRevokeUserAuthTokenScenario(t, "Should return not found when calling POST on",
|
||||
"/api/admin/users/200/revoke-auth-token", "/api/admin/users/:id/revoke-auth-token", cmd, func(sc *scenarioContext) {
|
||||
var userID int64
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
userID = cmd.Id
|
||||
return models.ErrUserNotFound
|
||||
})
|
||||
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
assert.Equal(t, int64(200), userID)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When a server admin gets auth tokens for a non-existing user", func(t *testing.T) {
|
||||
mock := mockstore.SQLStoreMock{
|
||||
ExpectedError: models.ErrUserNotFound,
|
||||
}
|
||||
adminGetUserAuthTokensScenario(t, "Should return not found when calling GET on",
|
||||
"/api/admin/users/200/auth-tokens", "/api/admin/users/:id/auth-tokens", func(sc *scenarioContext) {
|
||||
var userID int64
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
userID = cmd.Id
|
||||
return models.ErrUserNotFound
|
||||
})
|
||||
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
assert.Equal(t, int64(200), userID)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When a server admin attempts to enable/disable a nonexistent user", func(t *testing.T) {
|
||||
@@ -305,12 +286,13 @@ func TestAdminAPIEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
func putAdminScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType,
|
||||
cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) {
|
||||
cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
hs := &HTTPServer{
|
||||
Cfg: setting.NewCfg(),
|
||||
Cfg: setting.NewCfg(),
|
||||
SQLStore: sqlStore,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
@@ -330,13 +312,14 @@ func putAdminScenario(t *testing.T, desc string, url string, routePattern string
|
||||
})
|
||||
}
|
||||
|
||||
func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc) {
|
||||
func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: auth.NewFakeUserAuthTokenService(),
|
||||
SQLStore: sqlStore,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
@@ -357,7 +340,7 @@ func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern
|
||||
})
|
||||
}
|
||||
|
||||
func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, fn scenarioFunc) {
|
||||
func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
@@ -366,6 +349,7 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
SQLStore: sqlStore,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
@@ -386,7 +370,7 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou
|
||||
})
|
||||
}
|
||||
|
||||
func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc) {
|
||||
func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
@@ -395,6 +379,7 @@ func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, route
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
SQLStore: sqlStore,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/search"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -81,6 +82,7 @@ func TestAlertingAPIEndpoint(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/alerts?dashboardId=1", "/api/alerts",
|
||||
models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
setUp()
|
||||
@@ -102,7 +104,7 @@ func TestAlertingAPIEndpoint(t *testing.T) {
|
||||
|
||||
require.Nil(t, searchQuery)
|
||||
assert.NotNil(t, getAlertsQuery)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET",
|
||||
"/api/alerts?dashboardId=1&dashboardId=2&folderId=3&dashboardTag=abc&dashboardQuery=dbQuery&limit=5&query=alertQuery",
|
||||
@@ -140,7 +142,7 @@ func TestAlertingAPIEndpoint(t *testing.T) {
|
||||
assert.Equal(t, int64(2), getAlertsQuery.DashboardIDs[1])
|
||||
assert.Equal(t, int64(5), getAlertsQuery.Limit)
|
||||
assert.Equal(t, "alertQuery", getAlertsQuery.Query)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/alert-notifications/1",
|
||||
"/alert-notifications/:notificationId", models.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
@@ -149,7 +151,7 @@ func TestAlertingAPIEndpoint(t *testing.T) {
|
||||
sc.handlerFunc = GetAlertNotificationByID
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
}
|
||||
|
||||
func callPauseAlert(sc *scenarioContext) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -55,6 +56,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) {
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/annotations/1",
|
||||
"/api/annotations/:annotationId", role, func(sc *scenarioContext) {
|
||||
fakeAnnoRepo = &fakeAnnotationsRepo{}
|
||||
@@ -62,7 +64,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) {
|
||||
sc.handlerFunc = DeleteAnnotationByID
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,7 +86,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) {
|
||||
sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/annotations/1",
|
||||
"/api/annotations/:annotationId", role, func(sc *scenarioContext) {
|
||||
fakeAnnoRepo = &fakeAnnotationsRepo{}
|
||||
@@ -92,7 +94,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) {
|
||||
sc.handlerFunc = DeleteAnnotationByID
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -165,7 +167,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) {
|
||||
sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/annotations/1",
|
||||
"/api/annotations/:annotationId", role, func(sc *scenarioContext) {
|
||||
setUp()
|
||||
@@ -174,7 +176,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) {
|
||||
sc.handlerFunc = DeleteAnnotationByID
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -198,7 +200,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) {
|
||||
sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/annotations/1",
|
||||
"/api/annotations/:annotationId", role, func(sc *scenarioContext) {
|
||||
setUp()
|
||||
@@ -207,7 +209,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) {
|
||||
sc.handlerFunc = DeleteAnnotationByID
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+4
-4
@@ -154,7 +154,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
userRoute.Delete("/stars/dashboard/:id", routing.Wrap(hs.UnstarDashboard))
|
||||
|
||||
userRoute.Put("/password", routing.Wrap(hs.ChangeUserPassword))
|
||||
userRoute.Get("/quotas", routing.Wrap(GetUserQuotas))
|
||||
userRoute.Get("/quotas", routing.Wrap(hs.GetUserQuotas))
|
||||
userRoute.Put("/helpflags/:id", routing.Wrap(hs.SetHelpFlag))
|
||||
// For dev purpose
|
||||
userRoute.Get("/helpflags/clear", routing.Wrap(hs.ClearHelpFlags))
|
||||
@@ -264,7 +264,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
|
||||
// Preferences
|
||||
apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) {
|
||||
prefRoute.Post("/set-home-dash", routing.Wrap(SetHomeDashboard))
|
||||
prefRoute.Post("/set-home-dash", routing.Wrap(hs.SetHomeDashboard))
|
||||
})
|
||||
|
||||
// Data sources
|
||||
@@ -493,8 +493,8 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
adminUserRoute.Delete("/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersDelete, userIDScope)), routing.Wrap(AdminDeleteUser))
|
||||
adminUserRoute.Post("/:id/disable", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersDisable, userIDScope)), routing.Wrap(hs.AdminDisableUser))
|
||||
adminUserRoute.Post("/:id/enable", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersEnable, userIDScope)), routing.Wrap(AdminEnableUser))
|
||||
adminUserRoute.Get("/:id/quotas", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersQuotasList, userIDScope)), routing.Wrap(GetUserQuotas))
|
||||
adminUserRoute.Put("/:id/quotas/:target", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersQuotasUpdate, userIDScope)), routing.Wrap(UpdateUserQuota))
|
||||
adminUserRoute.Get("/:id/quotas", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersQuotasList, userIDScope)), routing.Wrap(hs.GetUserQuotas))
|
||||
adminUserRoute.Put("/:id/quotas/:target", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersQuotasUpdate, userIDScope)), routing.Wrap(hs.UpdateUserQuota))
|
||||
|
||||
adminUserRoute.Post("/:id/logout", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersLogout, userIDScope)), routing.Wrap(hs.AdminLogoutUser))
|
||||
adminUserRoute.Get("/:id/auth-tokens", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersAuthTokenList, userIDScope)), routing.Wrap(hs.AdminGetUserAuthTokens))
|
||||
|
||||
@@ -40,15 +40,16 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func loggedInUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc) {
|
||||
loggedInUserScenarioWithRole(t, desc, "GET", url, routePattern, models.ROLE_EDITOR, fn)
|
||||
func loggedInUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
loggedInUserScenarioWithRole(t, desc, "GET", url, routePattern, models.ROLE_EDITOR, fn, sqlStore)
|
||||
}
|
||||
|
||||
func loggedInUserScenarioWithRole(t *testing.T, desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) {
|
||||
func loggedInUserScenarioWithRole(t *testing.T, desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
sc.sqlStore = sqlStore
|
||||
sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response {
|
||||
sc.context = c
|
||||
sc.context.UserId = testUserID
|
||||
@@ -156,6 +157,7 @@ type scenarioContext struct {
|
||||
req *http.Request
|
||||
url string
|
||||
userAuthTokenService *auth.FakeUserAuthTokenService
|
||||
sqlStore sqlstore.Store
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) exec() {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/dashboards"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
@@ -30,13 +31,13 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) {
|
||||
return models.ErrDashboardNotFound
|
||||
})
|
||||
}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/1/permissions",
|
||||
"/api/dashboards/id/:dashboardId/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
setUp()
|
||||
callGetDashboardPermissions(sc, hs)
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
@@ -73,13 +74,13 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/1/permissions",
|
||||
"/api/dashboards/id/:dashboardId/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
setUp()
|
||||
callGetDashboardPermissions(sc, hs)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
@@ -125,6 +126,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/1/permissions",
|
||||
"/api/dashboards/id/:dashboardId/permissions", models.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
@@ -139,7 +141,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) {
|
||||
assert.Len(t, resp, 5)
|
||||
assert.Equal(t, int64(2), resp[0].UserId)
|
||||
assert.Equal(t, models.PERMISSION_VIEW, resp[0].Permission)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
@@ -341,7 +343,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
var resp []*models.DashboardAclInfoDTO
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/1/permissions",
|
||||
"/api/dashboards/id/:dashboardId/permissions", models.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
@@ -357,7 +359,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) {
|
||||
assert.Equal(t, models.PERMISSION_EDIT, resp[0].Permission)
|
||||
assert.Equal(t, int64(4), resp[1].UserId)
|
||||
assert.Equal(t, models.PERMISSION_ADMIN, resp[1].Permission)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -69,6 +70,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("When user has editor role and is not in the ACL", func(t *testing.T) {
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "Should not be able to delete snapshot when calling DELETE on",
|
||||
"DELETE", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
mockSnapshotResult := setUpSnapshotTest(t)
|
||||
@@ -84,7 +86,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
require.Nil(t, externalRequest)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When user is anonymous", func(t *testing.T) {
|
||||
@@ -120,7 +122,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
{Role: &viewerRole, Permission: models.PERMISSION_VIEW},
|
||||
{Role: &editorRole, Permission: models.PERMISSION_EDIT},
|
||||
}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "Should be able to delete a snapshot when calling DELETE on", "DELETE",
|
||||
"/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
mockSnapshotResult := setUpSnapshotTest(t)
|
||||
@@ -143,11 +145,12 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
assert.Equal(t, 1, respJSON.Get("id").MustInt())
|
||||
assert.Equal(t, ts.URL, fmt.Sprintf("http://%s", externalRequest.Host))
|
||||
assert.Equal(t, "/", externalRequest.URL.EscapedPath())
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When user is editor and creator of the snapshot", func(t *testing.T) {
|
||||
aclMockResp = []*models.DashboardAclInfoDTO{}
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "Should be able to delete a snapshot when calling DELETE on",
|
||||
"DELETE", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
mockSnapshotResult := setUpSnapshotTest(t)
|
||||
@@ -164,11 +167,12 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
|
||||
assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted"))
|
||||
assert.Equal(t, 1, respJSON.Get("id").MustInt())
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When deleting an external snapshot", func(t *testing.T) {
|
||||
aclMockResp = []*models.DashboardAclInfoDTO{}
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t,
|
||||
"Should gracefully delete local snapshot when remote snapshot has already been removed when calling DELETE on",
|
||||
"DELETE", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
@@ -192,7 +196,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
|
||||
assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted"))
|
||||
assert.Equal(t, 1, respJSON.Get("id").MustInt())
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
"Should fail to delete local snapshot when an unexpected 500 error occurs when calling DELETE on", "DELETE",
|
||||
@@ -213,7 +217,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
|
||||
require.NoError(t, writeErr)
|
||||
assert.Equal(t, 500, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
"Should fail to delete local snapshot when an unexpected remote error occurs when calling DELETE on",
|
||||
@@ -230,7 +234,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, 500, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "Should be able to read a snapshot's unencrypted data when calling GET on",
|
||||
"GET", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
@@ -247,6 +251,6 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
id := dashboard.Get("id")
|
||||
|
||||
assert.Equal(t, int64(100), id.MustInt64())
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
}
|
||||
|
||||
+49
-43
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/provisioning"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -159,7 +160,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
t.Run("When user is an Org Viewer", func(t *testing.T) {
|
||||
role := models.ROLE_VIEWER
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi",
|
||||
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUp()
|
||||
@@ -171,7 +172,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
assert.False(t, dash.Meta.CanEdit)
|
||||
assert.False(t, dash.Meta.CanSave)
|
||||
assert.False(t, dash.Meta.CanAdmin)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi",
|
||||
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
@@ -185,7 +186,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1",
|
||||
"/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@@ -193,7 +194,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
callGetDashboardVersion(sc)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions",
|
||||
"/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
|
||||
@@ -201,12 +202,12 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
callGetDashboardVersions(sc)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When user is an Org Editor", func(t *testing.T) {
|
||||
role := models.ROLE_EDITOR
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi",
|
||||
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUp()
|
||||
@@ -217,7 +218,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
assert.True(t, dash.Meta.CanEdit)
|
||||
assert.True(t, dash.Meta.CanSave)
|
||||
assert.False(t, dash.Meta.CanAdmin)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi",
|
||||
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
@@ -227,10 +228,11 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
Cfg: setting.NewCfg(),
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &mockLibraryElementService{},
|
||||
SQLStore: mock,
|
||||
})
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1",
|
||||
"/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@@ -238,7 +240,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
callGetDashboardVersion(sc)
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions",
|
||||
"/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
|
||||
@@ -246,7 +248,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
callGetDashboardVersions(sc)
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -256,6 +258,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
Live: newTestLive(t),
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &mockLibraryElementService{},
|
||||
SQLStore: mockstore.NewSQLStoreMock(),
|
||||
}
|
||||
|
||||
setUp := func() *testState {
|
||||
@@ -315,7 +318,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
t.Run("When user is an Org Viewer and has no permissions for this dashboard", func(t *testing.T) {
|
||||
role := models.ROLE_VIEWER
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi",
|
||||
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUp()
|
||||
@@ -324,7 +327,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi",
|
||||
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
@@ -333,7 +336,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
callDeleteDashboardByUID(sc, hs)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1",
|
||||
"/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@@ -341,7 +344,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
callGetDashboardVersion(sc)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions",
|
||||
"/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
|
||||
@@ -349,12 +352,12 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
callGetDashboardVersions(sc)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When user is an Org Editor and has no permissions for this dashboard", func(t *testing.T) {
|
||||
role := models.ROLE_EDITOR
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi",
|
||||
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUp()
|
||||
@@ -364,7 +367,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi",
|
||||
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
@@ -373,7 +376,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
callDeleteDashboardByUID(sc, hs)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1",
|
||||
"/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@@ -381,7 +384,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
callGetDashboardVersion(sc)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions",
|
||||
"/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
|
||||
@@ -389,7 +392,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
callGetDashboardVersions(sc)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When user is an Org Viewer but has an edit permission", func(t *testing.T) {
|
||||
@@ -407,7 +410,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi",
|
||||
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUpInner()
|
||||
@@ -418,29 +421,28 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
assert.True(t, dash.Meta.CanEdit)
|
||||
assert.True(t, dash.Meta.CanSave)
|
||||
assert.False(t, dash.Meta.CanAdmin)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUpInner()
|
||||
|
||||
callDeleteDashboardByUID(sc, hs)
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
setUpInner()
|
||||
|
||||
callGetDashboardVersion(sc)
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
|
||||
setUpInner()
|
||||
|
||||
callGetDashboardVersions(sc)
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When user is an Org Viewer and viewers can edit", func(t *testing.T) {
|
||||
@@ -466,7 +468,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUpInner()
|
||||
|
||||
@@ -477,7 +479,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
assert.True(t, dash.Meta.CanEdit)
|
||||
assert.False(t, dash.Meta.CanSave)
|
||||
assert.False(t, dash.Meta.CanAdmin)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUpInner()
|
||||
@@ -485,7 +487,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
callDeleteDashboardByUID(sc, hs)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When user is an Org Viewer but has an admin permission", func(t *testing.T) {
|
||||
@@ -503,7 +505,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUpInner()
|
||||
|
||||
@@ -513,7 +515,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
assert.True(t, dash.Meta.CanEdit)
|
||||
assert.True(t, dash.Meta.CanSave)
|
||||
assert.True(t, dash.Meta.CanAdmin)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUpInner()
|
||||
@@ -521,21 +523,21 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
callDeleteDashboardByUID(sc, hs)
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
setUpInner()
|
||||
|
||||
callGetDashboardVersion(sc)
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
|
||||
setUpInner()
|
||||
|
||||
callGetDashboardVersions(sc)
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When user is an Org Editor but has a view permission", func(t *testing.T) {
|
||||
@@ -553,7 +555,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUpInner()
|
||||
|
||||
@@ -561,7 +563,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
assert.False(t, dash.Meta.CanEdit)
|
||||
assert.False(t, dash.Meta.CanSave)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
state := setUpInner()
|
||||
@@ -569,21 +571,21 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
callDeleteDashboardByUID(sc, hs)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
setUpInner()
|
||||
|
||||
callGetDashboardVersion(sc)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
|
||||
setUpInner()
|
||||
|
||||
callGetDashboardVersions(sc)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -964,7 +966,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/db/abcdefghi", "/api/dashboards/db/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
setUp()
|
||||
|
||||
@@ -972,12 +974,13 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
Cfg: setting.NewCfg(),
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &mockLibraryElementService{},
|
||||
SQLStore: mock,
|
||||
})
|
||||
|
||||
assert.Equal(t, 400, sc.resp.Code)
|
||||
result := sc.ToJSON()
|
||||
assert.Equal(t, models.ErrDashboardCannotDeleteProvisionedDashboard.Error(), result.Get("error").MustString())
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
setUp()
|
||||
@@ -990,8 +993,9 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
dash := getDashboardShouldReturn200WithConfig(sc, mock)
|
||||
|
||||
assert.Equal(t, filepath.Join("test", "dashboard1.json"), dash.Meta.ProvisionedExternalId)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
mockSQLStore := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When allowUiUpdates is true and calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
setUp()
|
||||
|
||||
@@ -1008,6 +1012,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
ProvisioningService: mock,
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &mockLibraryElementService{},
|
||||
SQLStore: mockSQLStore,
|
||||
}
|
||||
callGetDashboard(sc, hs)
|
||||
|
||||
@@ -1018,7 +1023,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, false, dash.Meta.Provisioned)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1036,6 +1041,7 @@ func getDashboardShouldReturn200WithConfig(sc *scenarioContext, provisioningServ
|
||||
LibraryPanelService: &libraryPanelsService,
|
||||
LibraryElementService: &libraryElementsService,
|
||||
ProvisioningService: provisioningService,
|
||||
SQLStore: sc.sqlStore,
|
||||
}
|
||||
|
||||
callGetDashboard(sc, hs)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -27,6 +28,7 @@ const (
|
||||
)
|
||||
|
||||
func TestDataSourcesProxy_userLoggedIn(t *testing.T) {
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenario(t, "When calling GET on", "/api/datasources/", "/api/datasources/", func(sc *scenarioContext) {
|
||||
// Stubs the database query
|
||||
bus.AddHandler("test", func(ctx context.Context, query *models.GetDataSourcesQuery) error {
|
||||
@@ -45,6 +47,7 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) {
|
||||
Bus: bus.GetBus(),
|
||||
Cfg: setting.NewCfg(),
|
||||
pluginStore: &fakePluginStore{},
|
||||
SQLStore: mock,
|
||||
}
|
||||
sc.handlerFunc = hs.GetDataSources
|
||||
sc.fakeReq("GET", "/api/datasources").exec()
|
||||
@@ -57,7 +60,7 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) {
|
||||
assert.Equal(t, "BBB", respJSON[1]["name"])
|
||||
assert.Equal(t, "mmm", respJSON[2]["name"])
|
||||
assert.Equal(t, "ZZZ", respJSON[3]["name"])
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenario(t, "Should be able to save a data source when calling DELETE on non-existing",
|
||||
"/api/datasources/name/12345", "/api/datasources/name/:name", func(sc *scenarioContext) {
|
||||
@@ -70,7 +73,7 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) {
|
||||
sc.handlerFunc = hs.DeleteDataSourceByName
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
}
|
||||
|
||||
// Adding data sources with invalid URLs should lead to an error.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
@@ -34,11 +35,11 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
|
||||
dashboards.NewFolderService = origNewFolderService
|
||||
})
|
||||
mockFolderService(mock)
|
||||
|
||||
mockSQLStore := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
callGetFolderPermissions(sc, hs)
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
})
|
||||
}, mockSQLStore)
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
@@ -77,11 +78,11 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
mockFolderService(mock)
|
||||
|
||||
mockSQLStore := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
callGetFolderPermissions(sc, hs)
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
}, mockSQLStore)
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
@@ -130,7 +131,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
mockFolderService(mock)
|
||||
|
||||
mockSQLStore := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
callGetFolderPermissions(sc, hs)
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
@@ -142,7 +143,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
|
||||
assert.Len(t, resp, 5)
|
||||
assert.Equal(t, int64(2), resp[0].UserId)
|
||||
assert.Equal(t, models.PERMISSION_VIEW, resp[0].Permission)
|
||||
})
|
||||
}, mockSQLStore)
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
@@ -325,6 +326,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
|
||||
mockFolderService(mock)
|
||||
|
||||
var resp []*models.DashboardAclInfoDTO
|
||||
mockSQLStore := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
callGetFolderPermissions(sc, hs)
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
@@ -337,7 +339,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
|
||||
assert.Equal(t, models.PERMISSION_EDIT, resp[0].Permission)
|
||||
assert.Equal(t, int64(4), resp[1].UserId)
|
||||
assert.Equal(t, models.PERMISSION_ADMIN, resp[1].Permission)
|
||||
})
|
||||
}, mockSQLStore)
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
|
||||
@@ -107,7 +107,7 @@ type HTTPServer struct {
|
||||
LivePushGateway *pushhttp.Gateway
|
||||
ThumbService thumbs.Service
|
||||
ContextHandler *contexthandler.ContextHandler
|
||||
SQLStore *sqlstore.SQLStore
|
||||
SQLStore sqlstore.Store
|
||||
AlertEngine *alerting.AlertEngine
|
||||
LoadSchemaService *schemaloader.SchemaLoaderService
|
||||
AlertNG *ngalert.AlertNG
|
||||
@@ -432,6 +432,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
|
||||
m := hs.web
|
||||
|
||||
m.Use(middleware.RequestTracing(hs.tracer))
|
||||
m.Use(middleware.RequestMetrics(hs.Features))
|
||||
|
||||
m.Use(middleware.Logger(hs.Cfg))
|
||||
|
||||
|
||||
+2
-3
@@ -144,9 +144,8 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error)
|
||||
}
|
||||
|
||||
func enableServiceAccount(hs *HTTPServer, c *models.ReqContext) bool {
|
||||
return c.OrgRole == models.ROLE_ADMIN &&
|
||||
hs.Features.IsEnabled(featuremgmt.FlagServiceAccounts) &&
|
||||
hs.serviceAccountsService.Migrated(c.Req.Context(), c.OrgId)
|
||||
return (c.OrgRole == models.ROLE_ADMIN || (hs.Cfg.EditorsCanAdmin && c.OrgRole == models.ROLE_EDITOR)) &&
|
||||
hs.Features.IsEnabled(featuremgmt.FlagServiceAccounts)
|
||||
}
|
||||
|
||||
func enableTeams(hs *HTTPServer, c *models.ReqContext) bool {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
@@ -41,7 +42,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
sqlStore := sqlstore.InitTestDB(t)
|
||||
sqlStore.Cfg = settings
|
||||
hs.SQLStore = sqlStore
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenario(t, "When calling GET on", "api/org/users", "api/org/users", func(sc *scenarioContext) {
|
||||
setUpGetOrgUsersDB(t, sqlStore)
|
||||
|
||||
@@ -54,7 +55,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
err := json.Unmarshal(sc.resp.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, resp, 3)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenario(t, "When calling GET on", "api/org/users/search", "api/org/users/search", func(sc *scenarioContext) {
|
||||
setUpGetOrgUsersDB(t, sqlStore)
|
||||
@@ -72,7 +73,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
assert.Equal(t, int64(3), resp.TotalCount)
|
||||
assert.Equal(t, 1000, resp.PerPage)
|
||||
assert.Equal(t, 1, resp.Page)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenario(t, "When calling GET with page and limit query parameters on", "api/org/users/search", "api/org/users/search", func(sc *scenarioContext) {
|
||||
setUpGetOrgUsersDB(t, sqlStore)
|
||||
@@ -90,7 +91,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
assert.Equal(t, int64(3), resp.TotalCount)
|
||||
assert.Equal(t, 2, resp.PerPage)
|
||||
assert.Equal(t, 2, resp.Page)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
t.Run("Given there are two hidden users", func(t *testing.T) {
|
||||
settings.HiddenUsers = map[string]struct{}{
|
||||
@@ -113,7 +114,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
assert.Len(t, resp, 2)
|
||||
assert.Equal(t, testUserLogin, resp[0].Login)
|
||||
assert.Equal(t, "user2", resp[1].Login)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET as an admin on", "GET", "api/org/users/lookup",
|
||||
"api/org/users/lookup", models.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
@@ -130,7 +131,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
assert.Len(t, resp, 2)
|
||||
assert.Equal(t, testUserLogin, resp[0].Login)
|
||||
assert.Equal(t, "user2", resp[1].Login)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/infra/fs"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
@@ -143,7 +142,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Respon
|
||||
}
|
||||
|
||||
query := models.GetPluginSettingByIdQuery{PluginId: pluginID, OrgId: c.OrgId}
|
||||
if err := bus.Dispatch(c.Req.Context(), &query); err != nil {
|
||||
if err := hs.SQLStore.GetPluginSettingById(c.Req.Context(), &query); err != nil {
|
||||
if !errors.Is(err, models.ErrPluginSettingNotFound) {
|
||||
return response.Error(500, "Failed to get login settings", nil)
|
||||
}
|
||||
@@ -175,7 +174,7 @@ func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext) response.Respons
|
||||
|
||||
cmd.OrgId = c.OrgId
|
||||
cmd.PluginId = pluginID
|
||||
if err := bus.Dispatch(c.Req.Context(), &cmd); err != nil {
|
||||
if err := hs.SQLStore.UpdatePluginSetting(c.Req.Context(), &cmd); err != nil {
|
||||
return response.Error(500, "Failed to update plugin setting", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
@@ -18,7 +17,7 @@ const (
|
||||
)
|
||||
|
||||
// POST /api/preferences/set-home-dash
|
||||
func SetHomeDashboard(c *models.ReqContext) response.Response {
|
||||
func (hs *HTTPServer) SetHomeDashboard(c *models.ReqContext) response.Response {
|
||||
cmd := models.SavePreferencesCommand{}
|
||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
@@ -26,7 +25,7 @@ func SetHomeDashboard(c *models.ReqContext) response.Response {
|
||||
cmd.UserId = c.UserId
|
||||
cmd.OrgId = c.OrgId
|
||||
|
||||
if err := bus.Dispatch(c.Req.Context(), &cmd); err != nil {
|
||||
if err := hs.SQLStore.SavePreferences(c.Req.Context(), &cmd); err != nil {
|
||||
return response.Error(500, "Failed to set home dashboard", err)
|
||||
}
|
||||
|
||||
|
||||
+4
-5
@@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
@@ -61,7 +60,7 @@ func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response {
|
||||
return response.Success("Organization quota updated")
|
||||
}
|
||||
|
||||
func GetUserQuotas(c *models.ReqContext) response.Response {
|
||||
func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response {
|
||||
if !setting.Quota.Enabled {
|
||||
return response.Error(404, "Quotas not enabled", nil)
|
||||
}
|
||||
@@ -73,14 +72,14 @@ func GetUserQuotas(c *models.ReqContext) response.Response {
|
||||
|
||||
query := models.GetUserQuotasQuery{UserId: id}
|
||||
|
||||
if err := bus.Dispatch(c.Req.Context(), &query); err != nil {
|
||||
if err := hs.SQLStore.GetUserQuotas(c.Req.Context(), &query); err != nil {
|
||||
return response.Error(500, "Failed to get org quotas", err)
|
||||
}
|
||||
|
||||
return response.JSON(200, query.Result)
|
||||
}
|
||||
|
||||
func UpdateUserQuota(c *models.ReqContext) response.Response {
|
||||
func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response {
|
||||
cmd := models.UpdateUserQuotaCmd{}
|
||||
var err error
|
||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||
@@ -99,7 +98,7 @@ func UpdateUserQuota(c *models.ReqContext) response.Response {
|
||||
return response.Error(404, "Invalid quota target", nil)
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(c.Req.Context(), &cmd); err != nil {
|
||||
if err := hs.SQLStore.UpdateUserQuota(c.Req.Context(), &cmd); err != nil {
|
||||
return response.Error(500, "Failed to update org quotas", err)
|
||||
}
|
||||
return response.Success("Organization quota updated")
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
DisplayName: "Team writer",
|
||||
Description: "Create, read, write, or delete a team as well as controlling team memberships.",
|
||||
Group: "Teams",
|
||||
Version: 1,
|
||||
Version: 2,
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{Action: accesscontrol.ActionTeamsCreate},
|
||||
{Action: accesscontrol.ActionTeamsDelete, Scope: accesscontrol.ScopeTeamsAll},
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
@@ -52,8 +51,8 @@ type RouteRegister interface {
|
||||
|
||||
type RegisterNamedMiddleware func(name string) web.Handler
|
||||
|
||||
func ProvideRegister(features featuremgmt.FeatureToggles) *RouteRegisterImpl {
|
||||
return NewRouteRegister(middleware.ProvideRouteOperationName, middleware.RequestMetrics(features))
|
||||
func ProvideRegister() *RouteRegisterImpl {
|
||||
return NewRouteRegister(middleware.ProvideRouteOperationName)
|
||||
}
|
||||
|
||||
// NewRouteRegister creates a new RouteRegister with all middlewares sent as params
|
||||
|
||||
+1
-10
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
@@ -25,7 +24,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response {
|
||||
return response.Error(403, "Not allowed to create team.", nil)
|
||||
}
|
||||
|
||||
team, err := createTeam(hs.SQLStore, cmd.Name, cmd.Email, c.OrgId)
|
||||
team, err := hs.SQLStore.CreateTeam(cmd.Name, cmd.Email, c.OrgId)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrTeamNameTaken) {
|
||||
return response.Error(409, "Team name taken", err)
|
||||
@@ -45,7 +44,6 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response {
|
||||
c.Logger.Warn("Could not add creator to team because is not a real user")
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(200, &util.DynMap{
|
||||
"teamId": team.Id,
|
||||
"message": "Team created",
|
||||
@@ -211,10 +209,3 @@ func (hs *HTTPServer) UpdateTeamPreferences(c *models.ReqContext) response.Respo
|
||||
|
||||
return hs.updatePreferencesFor(c.Req.Context(), orgId, 0, teamId, &dtoCmd)
|
||||
}
|
||||
|
||||
// createTeam creates a team.
|
||||
//
|
||||
// Stubbable by tests.
|
||||
var createTeam = func(sqlStore *sqlstore.SQLStore, name, email string, orgID int64) (models.Team, error) {
|
||||
return sqlStore.CreateTeam(name, email, orgID)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/grafana/grafana/pkg/services/teamguardian/database"
|
||||
"github.com/grafana/grafana/pkg/services/teamguardian/manager"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -47,6 +48,7 @@ func TestTeamMembersAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
License: &licensing.OSSLicensingService{},
|
||||
SQLStore: sqlStore,
|
||||
}
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "api/teams/1/members",
|
||||
"api/teams/:teamId/members", models.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
@@ -61,7 +63,7 @@ func TestTeamMembersAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
err := json.Unmarshal(sc.resp.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, resp, 3)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
t.Run("Given there is two hidden users", func(t *testing.T) {
|
||||
settings.HiddenUsers = map[string]struct{}{
|
||||
@@ -86,7 +88,7 @@ func TestTeamMembersAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
assert.Equal(t, "loginuser0", resp[0].Login)
|
||||
assert.Equal(t, "loginuser1", resp[1].Login)
|
||||
assert.Equal(t, "loginuser2", resp[2].Login)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+10
-25
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -34,7 +35,7 @@ func TestTeamAPIEndpoint(t *testing.T) {
|
||||
t.Run("Given two teams", func(t *testing.T) {
|
||||
hs := setupSimpleHTTPServer(nil)
|
||||
hs.SQLStore = sqlstore.InitTestDB(t)
|
||||
|
||||
mock := mockstore.SQLStoreMock{}
|
||||
loggedInUserScenario(t, "When calling GET on", "/api/teams/search", "/api/teams/search", func(sc *scenarioContext) {
|
||||
_, err := hs.SQLStore.CreateTeam("team1", "", 1)
|
||||
require.NoError(t, err)
|
||||
@@ -50,7 +51,7 @@ func TestTeamAPIEndpoint(t *testing.T) {
|
||||
|
||||
assert.EqualValues(t, 2, resp.TotalCount)
|
||||
assert.Equal(t, 2, len(resp.Teams))
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenario(t, "When calling GET on", "/api/teams/search", "/api/teams/search", func(sc *scenarioContext) {
|
||||
_, err := hs.SQLStore.CreateTeam("team1", "", 1)
|
||||
@@ -67,29 +68,15 @@ func TestTeamAPIEndpoint(t *testing.T) {
|
||||
|
||||
assert.EqualValues(t, 2, resp.TotalCount)
|
||||
assert.Equal(t, 0, len(resp.Teams))
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When creating team with API key", func(t *testing.T) {
|
||||
hs := setupSimpleHTTPServer(nil)
|
||||
hs.Cfg.EditorsCanAdmin = true
|
||||
|
||||
hs.SQLStore = mockstore.NewSQLStoreMock()
|
||||
teamName := "team foo"
|
||||
|
||||
// TODO: Use a fake SQLStore when it's represented by an interface
|
||||
orgCreateTeam := createTeam
|
||||
orgAddTeamMember := addOrUpdateTeamMember
|
||||
t.Cleanup(func() {
|
||||
createTeam = orgCreateTeam
|
||||
addOrUpdateTeamMember = orgAddTeamMember
|
||||
})
|
||||
|
||||
createTeamCalled := 0
|
||||
createTeam = func(sqlStore *sqlstore.SQLStore, name, email string, orgID int64) (models.Team, error) {
|
||||
createTeamCalled++
|
||||
return models.Team{Name: teamName, Id: 42}, nil
|
||||
}
|
||||
|
||||
addTeamMemberCalled := 0
|
||||
addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService *resourcepermissions.Service, userID, orgID, teamID int64,
|
||||
permission string) error {
|
||||
@@ -109,9 +96,9 @@ func TestTeamAPIEndpoint(t *testing.T) {
|
||||
}
|
||||
c.OrgRole = models.ROLE_EDITOR
|
||||
c.Req.Body = mockRequestBody(models.CreateTeamCommand{Name: teamName})
|
||||
hs.CreateTeam(c)
|
||||
assert.Equal(t, createTeamCalled, 1)
|
||||
assert.Equal(t, addTeamMemberCalled, 0)
|
||||
r := hs.CreateTeam(c)
|
||||
|
||||
assert.Equal(t, 200, r.Status())
|
||||
assert.True(t, stub.warnCalled)
|
||||
assert.Equal(t, stub.warnMessage, "Could not add creator to team because is not a real user")
|
||||
})
|
||||
@@ -125,10 +112,8 @@ func TestTeamAPIEndpoint(t *testing.T) {
|
||||
}
|
||||
c.OrgRole = models.ROLE_EDITOR
|
||||
c.Req.Body = mockRequestBody(models.CreateTeamCommand{Name: teamName})
|
||||
createTeamCalled, addTeamMemberCalled = 0, 0
|
||||
hs.CreateTeam(c)
|
||||
assert.Equal(t, createTeamCalled, 1)
|
||||
assert.Equal(t, addTeamMemberCalled, 1)
|
||||
r := hs.CreateTeam(c)
|
||||
assert.Equal(t, 200, r.Status())
|
||||
assert.False(t, stub.warnCalled)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/secrets/database"
|
||||
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
@@ -41,7 +42,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
},
|
||||
TotalCount: 2,
|
||||
}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
loggedInUserScenario(t, "When calling GET on", "api/users/1", "api/users/:id", func(sc *scenarioContext) {
|
||||
fakeNow := time.Date(2019, 2, 11, 17, 30, 40, 0, time.UTC)
|
||||
secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
|
||||
@@ -100,7 +101,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
resp.UpdatedAt = fakeNow
|
||||
resp.AvatarUrl = avatarUrl
|
||||
require.EqualValues(t, expected, resp)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenario(t, "When calling GET on", "/api/users/lookup", "/api/users/lookup", func(sc *scenarioContext) {
|
||||
fakeNow := time.Date(2019, 2, 11, 17, 30, 40, 0, time.UTC)
|
||||
@@ -141,7 +142,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
require.Equal(t, "admin", resp.Login)
|
||||
require.Equal(t, "admin@test.com", resp.Email)
|
||||
require.True(t, resp.IsGrafanaAdmin)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenario(t, "When calling GET on", "/api/users", "/api/users", func(sc *scenarioContext) {
|
||||
var sentLimit int
|
||||
@@ -165,7 +166,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, len(respJSON.MustArray()))
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenario(t, "When calling GET with page and limit querystring parameters on", "/api/users", "/api/users", func(sc *scenarioContext) {
|
||||
var sentLimit int
|
||||
@@ -185,7 +186,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 10, sentLimit)
|
||||
assert.Equal(t, 2, sendPage)
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenario(t, "When calling GET on", "/api/users/search", "/api/users/search", func(sc *scenarioContext) {
|
||||
var sentLimit int
|
||||
@@ -211,7 +212,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 2, respJSON.Get("totalCount").MustInt())
|
||||
assert.Equal(t, 2, len(respJSON.Get("users").MustArray()))
|
||||
})
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenario(t, "When calling GET with page and perpage querystring parameters on", "/api/users/search", "/api/users/search", func(sc *scenarioContext) {
|
||||
var sentLimit int
|
||||
@@ -231,5 +232,5 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 10, sentLimit)
|
||||
assert.Equal(t, 2, sendPage)
|
||||
})
|
||||
}, mock)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
@@ -32,7 +31,7 @@ func (hs *HTTPServer) RevokeUserAuthToken(c *models.ReqContext) response.Respons
|
||||
func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, userID int64) response.Response {
|
||||
userQuery := models.GetUserByIdQuery{Id: userID}
|
||||
|
||||
if err := bus.Dispatch(ctx, &userQuery); err != nil {
|
||||
if err := hs.SQLStore.GetUserById(ctx, &userQuery); err != nil {
|
||||
if errors.Is(err, models.ErrUserNotFound) {
|
||||
return response.Error(404, "User not found", err)
|
||||
}
|
||||
@@ -52,7 +51,7 @@ func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, user
|
||||
func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) response.Response {
|
||||
userQuery := models.GetUserByIdQuery{Id: userID}
|
||||
|
||||
if err := bus.Dispatch(c.Req.Context(), &userQuery); err != nil {
|
||||
if err := hs.SQLStore.GetUserById(c.Req.Context(), &userQuery); err != nil {
|
||||
if errors.Is(err, models.ErrUserNotFound) {
|
||||
return response.Error(404, "User not found", err)
|
||||
}
|
||||
@@ -118,8 +117,7 @@ func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int
|
||||
|
||||
func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) response.Response {
|
||||
userQuery := models.GetUserByIdQuery{Id: userID}
|
||||
|
||||
if err := bus.Dispatch(c.Req.Context(), &userQuery); err != nil {
|
||||
if err := hs.SQLStore.GetUserById(c.Req.Context(), &userQuery); err != nil {
|
||||
if errors.Is(err, models.ErrUserNotFound) {
|
||||
return response.Error(404, "User not found", err)
|
||||
}
|
||||
|
||||
+34
-50
@@ -11,87 +11,73 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUserTokenAPIEndpoint(t *testing.T) {
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
t.Run("When current user attempts to revoke an auth token for a non-existing user", func(t *testing.T) {
|
||||
cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2}
|
||||
|
||||
mock.ExpectedError = models.ErrUserNotFound
|
||||
revokeUserAuthTokenScenario(t, "Should return not found when calling POST on", "/api/user/revoke-auth-token",
|
||||
"/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) {
|
||||
var userID int64
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
userID = cmd.Id
|
||||
return models.ErrUserNotFound
|
||||
})
|
||||
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
assert.Equal(t, int64(200), userID)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When current user gets auth tokens for a non-existing user", func(t *testing.T) {
|
||||
mock := mockstore.SQLStoreMock{
|
||||
ExpectedUser: &models.User{Id: 200},
|
||||
ExpectedError: models.ErrUserNotFound,
|
||||
}
|
||||
getUserAuthTokensScenario(t, "Should return not found when calling GET on", "/api/user/auth-tokens", "/api/user/auth-tokens", 200, func(sc *scenarioContext) {
|
||||
var userID int64
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
userID = cmd.Id
|
||||
return models.ErrUserNotFound
|
||||
})
|
||||
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
assert.Equal(t, int64(200), userID)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When logging out an existing user from all devices", func(t *testing.T) {
|
||||
mock := mockstore.SQLStoreMock{
|
||||
ExpectedUser: &models.User{Id: 200},
|
||||
}
|
||||
logoutUserFromAllDevicesInternalScenario(t, "Should be successful", 1, func(sc *scenarioContext) {
|
||||
const userID int64 = 200
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
cmd.Result = &models.User{Id: userID}
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When logout a non-existing user from all devices", func(t *testing.T) {
|
||||
logoutUserFromAllDevicesInternalScenario(t, "Should return not found", testUserID, func(sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
return models.ErrUserNotFound
|
||||
})
|
||||
mock.ExpectedError = models.ErrUserNotFound
|
||||
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When revoke an auth token for a user", func(t *testing.T) {
|
||||
cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2}
|
||||
token := &models.UserToken{Id: 1}
|
||||
mock := mockstore.SQLStoreMock{
|
||||
ExpectedUser: &models.User{Id: 200},
|
||||
}
|
||||
|
||||
revokeUserAuthTokenInternalScenario(t, "Should be successful", cmd, 200, token, func(sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
cmd.Result = &models.User{Id: 200}
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) {
|
||||
return &models.UserToken{Id: 2}, nil
|
||||
}
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When revoke the active auth token used by himself", func(t *testing.T) {
|
||||
cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2}
|
||||
token := &models.UserToken{Id: 2}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
revokeUserAuthTokenInternalScenario(t, "Should not be successful", cmd, testUserID, token, func(sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
cmd.Result = &models.User{Id: testUserID}
|
||||
@@ -103,18 +89,13 @@ func TestUserTokenAPIEndpoint(t *testing.T) {
|
||||
}
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
assert.Equal(t, 400, sc.resp.Code)
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
|
||||
t.Run("When gets auth tokens for a user", func(t *testing.T) {
|
||||
currentToken := &models.UserToken{Id: 1}
|
||||
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
getUserAuthTokensInternalScenario(t, "Should be successful", currentToken, func(sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
|
||||
cmd.Result = &models.User{Id: testUserID}
|
||||
return nil
|
||||
})
|
||||
|
||||
tokens := []*models.UserToken{
|
||||
{
|
||||
Id: 1,
|
||||
@@ -165,12 +146,12 @@ func TestUserTokenAPIEndpoint(t *testing.T) {
|
||||
assert.Equal(t, "11.0", resultTwo.Get("browserVersion").MustString())
|
||||
assert.Equal(t, "iOS", resultTwo.Get("os").MustString())
|
||||
assert.Equal(t, "11.0", resultTwo.Get("osVersion").MustString())
|
||||
})
|
||||
}, mock)
|
||||
})
|
||||
}
|
||||
|
||||
func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd,
|
||||
userId int64, fn scenarioFunc) {
|
||||
userId int64, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
@@ -179,6 +160,7 @@ func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePat
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
SQLStore: sqlStore,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
@@ -199,7 +181,7 @@ func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePat
|
||||
})
|
||||
}
|
||||
|
||||
func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, userId int64, fn scenarioFunc) {
|
||||
func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, userId int64, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
@@ -208,6 +190,7 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
SQLStore: sqlStore,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
@@ -227,13 +210,14 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte
|
||||
})
|
||||
}
|
||||
|
||||
func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId int64, fn scenarioFunc) {
|
||||
func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId int64, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: auth.NewFakeUserAuthTokenService(),
|
||||
SQLStore: sqlStore,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, "/")
|
||||
@@ -253,7 +237,7 @@ func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId
|
||||
}
|
||||
|
||||
func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.RevokeAuthTokenCmd, userId int64,
|
||||
token *models.UserToken, fn scenarioFunc) {
|
||||
token *models.UserToken, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
@@ -262,6 +246,7 @@ func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.R
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
SQLStore: sqlStore,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, "/")
|
||||
@@ -275,14 +260,12 @@ func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.R
|
||||
|
||||
return hs.revokeUserAuthTokenInternal(c, userId, cmd)
|
||||
})
|
||||
|
||||
sc.m.Post("/", sc.defaultHandler)
|
||||
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models.UserToken, fn scenarioFunc) {
|
||||
func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models.UserToken, fn scenarioFunc, sqlStore sqlstore.Store) {
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
@@ -291,6 +274,7 @@ func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models.
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
SQLStore: sqlStore,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, "/")
|
||||
|
||||
@@ -183,6 +183,16 @@ var adminCommands = []*cli.Command{
|
||||
Usage: "Re-encrypts secrets by decrypting and re-encrypting them with the currently configured encryption. Returns ok unless there is an error. Safe to execute multiple times.",
|
||||
Action: runRunnerCommand(secretsmigrations.ReEncryptSecrets),
|
||||
},
|
||||
{
|
||||
Name: "rollback",
|
||||
Usage: "Rolls back secrets to legacy encryption. Returns ok unless there is an error. Safe to execute multiple times.",
|
||||
Action: runRunnerCommand(secretsmigrations.RollBackSecrets),
|
||||
},
|
||||
{
|
||||
Name: "re-encrypt-data-keys",
|
||||
Usage: "Rotates persisted data encryption keys. Returns ok unless there is an error. Safe to execute multiple times.",
|
||||
Action: runRunnerCommand(secretsmigrations.ReEncryptDEKS),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package secretsmigrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/runner"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
func ReEncryptDEKS(_ utils.CommandLine, runner runner.Runner) error {
|
||||
if !runner.Features.IsEnabled(featuremgmt.FlagEnvelopeEncryption) {
|
||||
logger.Warn("Envelope encryption is not enabled, quitting...")
|
||||
return nil
|
||||
}
|
||||
|
||||
return runner.SecretsService.ReEncryptDataKeys(context.Background())
|
||||
}
|
||||
@@ -17,12 +17,6 @@ import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type simpleSecret struct {
|
||||
tableName string
|
||||
columnName string
|
||||
isBase64Encoded bool
|
||||
}
|
||||
|
||||
func (s simpleSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) error {
|
||||
var rows []struct {
|
||||
Id int
|
||||
@@ -76,10 +70,6 @@ func (s simpleSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.S
|
||||
return nil
|
||||
}
|
||||
|
||||
type jsonSecret struct {
|
||||
tableName string
|
||||
}
|
||||
|
||||
func (s jsonSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) error {
|
||||
var rows []struct {
|
||||
Id int
|
||||
@@ -119,8 +109,6 @@ func (s jsonSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Ses
|
||||
return nil
|
||||
}
|
||||
|
||||
type alertingSecret struct{}
|
||||
|
||||
func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) error {
|
||||
var results []struct {
|
||||
Id int
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package secretsmigrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/runner"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
||||
"github.com/grafana/grafana/pkg/services/encryption"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/manager"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func (s simpleSecret) rollback(
|
||||
secretsSrv *manager.SecretsService,
|
||||
encryptionSrv encryption.Internal,
|
||||
sess *xorm.Session,
|
||||
secretKey string,
|
||||
) error {
|
||||
var rows []struct {
|
||||
Id int
|
||||
Secret string
|
||||
}
|
||||
|
||||
if err := sess.Table(s.tableName).Select(fmt.Sprintf("id, %s as secret", s.columnName)).Find(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if len(row.Secret) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
err error
|
||||
decoded = []byte(row.Secret)
|
||||
)
|
||||
|
||||
if s.isBase64Encoded {
|
||||
decoded, err = base64.StdEncoding.DecodeString(row.Secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
decrypted, err := secretsSrv.Decrypt(context.Background(), decoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encrypted, err := encryptionSrv.Encrypt(context.Background(), decrypted, secretKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encoded := string(encrypted)
|
||||
if s.isBase64Encoded {
|
||||
encoded = base64.StdEncoding.EncodeToString(encrypted)
|
||||
}
|
||||
|
||||
updateSQL := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", s.tableName, s.columnName)
|
||||
if _, err := sess.Exec(updateSQL, encoded, row.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("Column %s from %s have been rolled back successfully\n", s.columnName, s.tableName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s jsonSecret) rollback(
|
||||
secretsSrv *manager.SecretsService,
|
||||
encryptionSrv encryption.Internal,
|
||||
sess *xorm.Session,
|
||||
secretKey string,
|
||||
) error {
|
||||
var rows []struct {
|
||||
Id int
|
||||
SecureJsonData map[string][]byte
|
||||
}
|
||||
|
||||
if err := sess.Table(s.tableName).Cols("id", "secure_json_data").Find(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if len(row.SecureJsonData) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
decrypted, err := secretsSrv.DecryptJsonData(context.Background(), row.SecureJsonData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var toUpdate struct {
|
||||
SecureJsonData map[string][]byte
|
||||
}
|
||||
|
||||
toUpdate.SecureJsonData, err = encryptionSrv.EncryptJsonData(context.Background(), decrypted, secretKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := sess.Table(s.tableName).Where("id = ?", row.Id).Update(toUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("Secure json data from %s have been rolled back successfully\n", s.tableName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s alertingSecret) rollback(
|
||||
secretsSrv *manager.SecretsService,
|
||||
encryptionSrv encryption.Internal,
|
||||
sess *xorm.Session,
|
||||
secretKey string,
|
||||
) error {
|
||||
var results []struct {
|
||||
Id int
|
||||
AlertmanagerConfiguration string
|
||||
}
|
||||
|
||||
selectSQL := "SELECT id, alertmanager_configuration FROM alert_configuration"
|
||||
if err := sess.SQL(selectSQL).Find(&results); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
result := result
|
||||
postableUserConfig, err := notifier.Load([]byte(result.AlertmanagerConfiguration))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, receiver := range postableUserConfig.AlertmanagerConfig.Receivers {
|
||||
for _, gmr := range receiver.GrafanaManagedReceivers {
|
||||
for k, v := range gmr.SecureSettings {
|
||||
decoded, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decrypted, err := secretsSrv.Decrypt(context.Background(), decoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reencrypted, err := encryptionSrv.Encrypt(context.Background(), decrypted, secretKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gmr.SecureSettings[k] = base64.StdEncoding.EncodeToString(reencrypted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
marshalled, err := json.Marshal(postableUserConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result.AlertmanagerConfiguration = string(marshalled)
|
||||
if _, err := sess.Table("alert_configuration").Where("id = ?", result.Id).Update(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Alerting secrets have rolled re-encrypted successfully\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RollBackSecrets(_ utils.CommandLine, runner runner.Runner) error {
|
||||
if !runner.Features.IsEnabled(featuremgmt.FlagEnvelopeEncryption) {
|
||||
logger.Warn("Envelope encryption is not enabled, quitting...")
|
||||
return nil
|
||||
}
|
||||
|
||||
toMigrate := []interface {
|
||||
rollback(*manager.SecretsService, encryption.Internal, *xorm.Session, string) error
|
||||
}{
|
||||
simpleSecret{tableName: "dashboard_snapshot", columnName: "dashboard_encrypted", isBase64Encoded: false},
|
||||
simpleSecret{tableName: "user_auth", columnName: "o_auth_access_token", isBase64Encoded: true},
|
||||
simpleSecret{tableName: "user_auth", columnName: "o_auth_refresh_token", isBase64Encoded: true},
|
||||
simpleSecret{tableName: "user_auth", columnName: "o_auth_token_type", isBase64Encoded: true},
|
||||
jsonSecret{tableName: "data_source"},
|
||||
jsonSecret{tableName: "plugin_setting"},
|
||||
alertingSecret{},
|
||||
}
|
||||
|
||||
return runner.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
for _, m := range toMigrate {
|
||||
if err := m.rollback(
|
||||
runner.SecretsService,
|
||||
runner.EncryptionService,
|
||||
sess.Session,
|
||||
runner.Cfg.SecretKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := sess.Exec("DELETE FROM data_keys"); err != nil {
|
||||
logger.Warn("Error while cleaning up data keys table...", "err", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package secretsmigrations
|
||||
|
||||
type simpleSecret struct {
|
||||
tableName string
|
||||
columnName string
|
||||
isBase64Encoded bool
|
||||
}
|
||||
|
||||
type jsonSecret struct {
|
||||
tableName string
|
||||
}
|
||||
|
||||
type alertingSecret struct{}
|
||||
@@ -402,10 +402,7 @@ func TestPercentDiffAbsReducer(t *testing.T) {
|
||||
func valBasedSeries(vals ...*float64) mathexp.Series {
|
||||
newSeries := mathexp.NewSeries("", nil, len(vals))
|
||||
for idx, f := range vals {
|
||||
err := newSeries.SetPoint(idx, time.Unix(int64(idx), 0), f)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
newSeries.SetPoint(idx, time.Unix(int64(idx), 0), f)
|
||||
}
|
||||
return newSeries
|
||||
}
|
||||
@@ -413,10 +410,7 @@ func valBasedSeries(vals ...*float64) mathexp.Series {
|
||||
func valBasedSeriesWithLabels(l data.Labels, vals ...*float64) mathexp.Series {
|
||||
newSeries := mathexp.NewSeries("", l, len(vals))
|
||||
for idx, f := range vals {
|
||||
err := newSeries.SetPoint(idx, time.Unix(int64(idx), 0), f)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
newSeries.SetPoint(idx, time.Unix(int64(idx), 0), f)
|
||||
}
|
||||
return newSeries
|
||||
}
|
||||
|
||||
+47
-11
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr/mathexp"
|
||||
)
|
||||
|
||||
@@ -69,19 +70,25 @@ func (gm *MathCommand) Execute(ctx context.Context, vars mathexp.Vars) (mathexp.
|
||||
|
||||
// ReduceCommand is an expression command for reduction of a timeseries such as a min, mean, or max.
|
||||
type ReduceCommand struct {
|
||||
Reducer string
|
||||
VarToReduce string
|
||||
refID string
|
||||
Reducer string
|
||||
VarToReduce string
|
||||
refID string
|
||||
seriesMapper mathexp.ReduceMapper
|
||||
}
|
||||
|
||||
// NewReduceCommand creates a new ReduceCMD.
|
||||
func NewReduceCommand(refID, reducer, varToReduce string) *ReduceCommand {
|
||||
// TODO: validate reducer here, before execution
|
||||
return &ReduceCommand{
|
||||
Reducer: reducer,
|
||||
VarToReduce: varToReduce,
|
||||
refID: refID,
|
||||
func NewReduceCommand(refID, reducer, varToReduce string, mapper mathexp.ReduceMapper) (*ReduceCommand, error) {
|
||||
_, err := mathexp.GetReduceFunc(reducer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ReduceCommand{
|
||||
Reducer: reducer,
|
||||
VarToReduce: varToReduce,
|
||||
refID: refID,
|
||||
seriesMapper: mapper,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UnmarshalReduceCommand creates a MathCMD from Grafana's frontend query.
|
||||
@@ -105,7 +112,36 @@ func UnmarshalReduceCommand(rn *rawNode) (*ReduceCommand, error) {
|
||||
return nil, fmt.Errorf("expected reducer to be a string, got %T for refId %v", rawReducer, rn.RefID)
|
||||
}
|
||||
|
||||
return NewReduceCommand(rn.RefID, redFunc, varToReduce), nil
|
||||
var mapper mathexp.ReduceMapper = nil
|
||||
settings, ok := rn.Query["settings"]
|
||||
if ok {
|
||||
switch s := settings.(type) {
|
||||
case map[string]interface{}:
|
||||
mode, ok := s["mode"]
|
||||
if ok && mode != "" {
|
||||
switch mode {
|
||||
case "dropNN":
|
||||
mapper = mathexp.DropNonNumber{}
|
||||
case "replaceNN":
|
||||
valueStr, ok := s["replaceWithValue"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected settings.replaceWithValue to be specified when mode is 'replaceNN' for refId %v", rn.RefID)
|
||||
}
|
||||
switch value := valueStr.(type) {
|
||||
case float64:
|
||||
mapper = mathexp.ReplaceNonNumberWithValue{Value: value}
|
||||
default:
|
||||
return nil, fmt.Errorf("expected settings.replaceWithValue to be a number, got %T for refId %v", value, rn.RefID)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("reducer mode %s is not supported for refId %v. Supported only: [dropNN,replaceNN]", mode, rn.RefID)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("expected settings to be an object, got %T for refId %v", s, rn.RefID)
|
||||
}
|
||||
}
|
||||
return NewReduceCommand(rn.RefID, redFunc, varToReduce, mapper)
|
||||
}
|
||||
|
||||
// NeedsVars returns the variable names (refIds) that are dependencies
|
||||
@@ -123,7 +159,7 @@ func (gr *ReduceCommand) Execute(ctx context.Context, vars mathexp.Vars) (mathex
|
||||
if !ok {
|
||||
return newRes, fmt.Errorf("can only reduce type series, got type %v", val.Type())
|
||||
}
|
||||
num, err := series.Reduce(gr.refID, gr.Reducer)
|
||||
num, err := series.Reduce(gr.refID, gr.Reducer, gr.seriesMapper)
|
||||
if err != nil {
|
||||
return newRes, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package expr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr/mathexp"
|
||||
)
|
||||
|
||||
func Test_UnmarshalReduceCommand_Settings(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
querySettings string
|
||||
isError bool
|
||||
expectedMapper mathexp.ReduceMapper
|
||||
}{
|
||||
{
|
||||
name: "no mapper function when settings is not specified",
|
||||
querySettings: ``,
|
||||
expectedMapper: nil,
|
||||
},
|
||||
{
|
||||
name: "no mapper function when mode is not specified",
|
||||
querySettings: `, "settings" : { }`,
|
||||
expectedMapper: nil,
|
||||
},
|
||||
{
|
||||
name: "error when settings is not object",
|
||||
querySettings: `, "settings" : "drop-nan"`,
|
||||
isError: true,
|
||||
},
|
||||
{
|
||||
name: "no mapper function when mode is empty",
|
||||
querySettings: `, "settings" : { "mode": "" }`,
|
||||
expectedMapper: nil,
|
||||
},
|
||||
{
|
||||
name: "error when mode is not known",
|
||||
querySettings: `, "settings" : { "mode": "test" }`,
|
||||
isError: true,
|
||||
},
|
||||
{
|
||||
name: "filterNonNumber function when mode is 'dropNN'",
|
||||
querySettings: `, "settings" : { "mode": "dropNN" }`,
|
||||
expectedMapper: mathexp.DropNonNumber{},
|
||||
},
|
||||
{
|
||||
name: "replaceNanWithValue function when mode is 'dropNN'",
|
||||
querySettings: `, "settings" : { "mode": "replaceNN" , "replaceWithValue": -12 }`,
|
||||
expectedMapper: mathexp.ReplaceNonNumberWithValue{Value: -12},
|
||||
},
|
||||
{
|
||||
name: "error if mode is 'replaceNN' but field replaceWithValue is not specified",
|
||||
querySettings: `, "settings" : { "mode": "replaceNN" }`,
|
||||
isError: true,
|
||||
},
|
||||
{
|
||||
name: "error if mode is 'replaceNN' but field replaceWithValue is not a number",
|
||||
querySettings: `, "settings" : { "mode": "replaceNN", "replaceWithValue" : "-12" }`,
|
||||
isError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
q := fmt.Sprintf(`{ "expression" : "$A", "reducer": "sum"%s }`, test.querySettings)
|
||||
var qmap = make(map[string]interface{})
|
||||
require.NoError(t, json.Unmarshal([]byte(q), &qmap))
|
||||
|
||||
cmd, err := UnmarshalReduceCommand(&rawNode{
|
||||
RefID: "A",
|
||||
Query: qmap,
|
||||
QueryType: "",
|
||||
TimeRange: TimeRange{},
|
||||
DataSource: nil,
|
||||
})
|
||||
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
require.Equal(t, test.expectedMapper, cmd.seriesMapper)
|
||||
})
|
||||
}
|
||||
}
|
||||
+6
-18
@@ -133,18 +133,14 @@ func (e *State) unarySeries(s Series, op string) (Series, error) {
|
||||
for i := 0; i < s.Len(); i++ {
|
||||
t, f := s.GetPoint(i)
|
||||
if f == nil {
|
||||
if err := newSeries.SetPoint(i, t, nil); err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
newSeries.SetPoint(i, t, nil)
|
||||
continue
|
||||
}
|
||||
newF, err := unaryOp(op, *f)
|
||||
if err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
if err := newSeries.SetPoint(i, t, &newF); err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
newSeries.SetPoint(i, t, &newF)
|
||||
}
|
||||
return newSeries, nil
|
||||
}
|
||||
@@ -437,9 +433,7 @@ func (e *State) biSeriesNumber(labels data.Labels, op string, s Series, scalarVa
|
||||
nF := math.NaN()
|
||||
t, f := s.GetPoint(i)
|
||||
if f == nil || scalarVal == nil {
|
||||
if err := newSeries.SetPoint(i, t, nil); err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
newSeries.SetPoint(i, t, nil)
|
||||
continue
|
||||
}
|
||||
if seriesFirst {
|
||||
@@ -450,9 +444,7 @@ func (e *State) biSeriesNumber(labels data.Labels, op string, s Series, scalarVa
|
||||
if err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
if err := newSeries.SetPoint(i, t, &nF); err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
newSeries.SetPoint(i, t, &nF)
|
||||
}
|
||||
return newSeries, nil
|
||||
}
|
||||
@@ -475,18 +467,14 @@ func (e *State) biSeriesSeries(labels data.Labels, op string, aSeries, bSeries S
|
||||
continue
|
||||
}
|
||||
if aF == nil || bF == nil {
|
||||
if err := newSeries.AppendPoint(aIdx, aTime, nil); err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
newSeries.AppendPoint(aTime, nil)
|
||||
continue
|
||||
}
|
||||
nF, err := binaryOp(op, *aF, *bF)
|
||||
if err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
if err := newSeries.AppendPoint(aIdx, aTime, &nF); err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
newSeries.AppendPoint(aTime, &nF)
|
||||
}
|
||||
return newSeries, nil
|
||||
}
|
||||
|
||||
@@ -116,28 +116,24 @@ func TestNumberExpr(t *testing.T) {
|
||||
results: Results{[]Value{makeNumber("", nil, float64Pointer(-2.0))}},
|
||||
},
|
||||
{
|
||||
name: "binary: Scalar Op Number (Number will nil val) - currently Panics",
|
||||
name: "binary: Scalar Op Number (Number will nil val) returns nil",
|
||||
expr: "1 + $A",
|
||||
newErrIs: assert.NoError,
|
||||
execErrIs: assert.NoError,
|
||||
resultIs: assert.Equal,
|
||||
vars: Vars{"A": Results{[]Value{makeNumber("", nil, nil)}}},
|
||||
willPanic: true,
|
||||
results: Results{[]Value{makeNumber("", nil, nil)}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testBlock := func() {
|
||||
e, err := New(tt.expr)
|
||||
tt.newErrIs(t, err)
|
||||
if e != nil {
|
||||
res, err := e.Execute("", tt.vars)
|
||||
tt.execErrIs(t, err)
|
||||
tt.resultIs(t, tt.results, res)
|
||||
}
|
||||
}
|
||||
if tt.willPanic {
|
||||
assert.Panics(t, testBlock)
|
||||
} else {
|
||||
assert.NotPanics(t, testBlock)
|
||||
e, err := New(tt.expr)
|
||||
tt.newErrIs(t, err)
|
||||
if e != nil {
|
||||
res, err := e.Execute("", tt.vars)
|
||||
tt.execErrIs(t, err)
|
||||
tt.resultIs(t, tt.results, res)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,10 +16,7 @@ type tp struct {
|
||||
func makeSeries(name string, labels data.Labels, points ...tp) Series {
|
||||
newSeries := NewSeries(name, labels, len(points))
|
||||
for idx, p := range points {
|
||||
err := newSeries.SetPoint(idx, p.t, p.f)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
newSeries.SetPoint(idx, p.t, p.f)
|
||||
}
|
||||
return newSeries
|
||||
}
|
||||
|
||||
@@ -226,9 +226,7 @@ func perFloat(e *State, val Value, floatF func(x float64) float64) (Value, error
|
||||
if f != nil {
|
||||
nF = floatF(*f)
|
||||
}
|
||||
if err := newSeries.SetPoint(i, t, &nF); err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
newSeries.SetPoint(i, t, &nF)
|
||||
}
|
||||
newVal = newSeries
|
||||
default:
|
||||
@@ -257,9 +255,7 @@ func perNullableFloat(e *State, val Value, floatF func(x *float64) *float64) (Va
|
||||
newSeries := NewSeries(e.RefID, resSeries.GetLabels(), resSeries.Len())
|
||||
for i := 0; i < resSeries.Len(); i++ {
|
||||
t, f := resSeries.GetPoint(i)
|
||||
if err := newSeries.SetPoint(i, t, floatF(f)); err != nil {
|
||||
return newSeries, err
|
||||
}
|
||||
newSeries.SetPoint(i, t, floatF(f))
|
||||
}
|
||||
newVal = newSeries
|
||||
default:
|
||||
|
||||
+100
-21
@@ -3,10 +3,13 @@ package mathexp
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
type ReducerFunc = func(fv *Float64Field) *float64
|
||||
|
||||
func Sum(fv *Float64Field) *float64 {
|
||||
var sum float64
|
||||
for i := 0; i < fv.Len(); i++ {
|
||||
@@ -75,38 +78,114 @@ func Last(fv *Float64Field) *float64 {
|
||||
f = math.NaN()
|
||||
return &f
|
||||
}
|
||||
v := fv.GetValue(fv.Len() - 1)
|
||||
f = *v
|
||||
return &f
|
||||
return fv.GetValue(fv.Len() - 1)
|
||||
}
|
||||
|
||||
func GetReduceFunc(rFunc string) (ReducerFunc, error) {
|
||||
switch strings.ToLower(rFunc) {
|
||||
case "sum":
|
||||
return Sum, nil
|
||||
case "mean":
|
||||
return Avg, nil
|
||||
case "min":
|
||||
return Min, nil
|
||||
case "max":
|
||||
return Max, nil
|
||||
case "count":
|
||||
return Count, nil
|
||||
case "last":
|
||||
return Last, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("reduction %v not implemented", rFunc)
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce turns the Series into a Number based on the given reduction function
|
||||
func (s Series) Reduce(refID, rFunc string) (Number, error) {
|
||||
// if ReduceMapper is defined it applies it to the provided series and performs reduction of the resulting series.
|
||||
// Otherwise, the reduction operation is done against the original series.
|
||||
func (s Series) Reduce(refID, rFunc string, mapper ReduceMapper) (Number, error) {
|
||||
var l data.Labels
|
||||
if s.GetLabels() != nil {
|
||||
l = s.GetLabels().Copy()
|
||||
}
|
||||
number := NewNumber(refID, l)
|
||||
var f *float64
|
||||
fVec := s.Frame.Fields[seriesTypeValIdx]
|
||||
series := s
|
||||
if mapper != nil {
|
||||
series = mapSeries(s, mapper)
|
||||
}
|
||||
fVec := series.Frame.Fields[seriesTypeValIdx]
|
||||
floatField := Float64Field(*fVec)
|
||||
switch rFunc {
|
||||
case "sum":
|
||||
f = Sum(&floatField)
|
||||
case "mean":
|
||||
f = Avg(&floatField)
|
||||
case "min":
|
||||
f = Min(&floatField)
|
||||
case "max":
|
||||
f = Max(&floatField)
|
||||
case "count":
|
||||
f = Count(&floatField)
|
||||
case "last":
|
||||
f = Last(&floatField)
|
||||
default:
|
||||
return number, fmt.Errorf("reduction %v not implemented", rFunc)
|
||||
reduceFunc, err := GetReduceFunc(rFunc)
|
||||
if err != nil {
|
||||
return number, err
|
||||
}
|
||||
f = reduceFunc(&floatField)
|
||||
if f != nil && mapper != nil {
|
||||
f = mapper.MapOutput(f)
|
||||
}
|
||||
number.SetValue(f)
|
||||
|
||||
return number, nil
|
||||
}
|
||||
|
||||
type ReduceMapper interface {
|
||||
MapInput(s *float64) *float64
|
||||
MapOutput(v *float64) *float64
|
||||
}
|
||||
|
||||
// mapSeries creates a series where all points are mapped using the provided map function ReduceMapper.MapInput
|
||||
func mapSeries(s Series, mapper ReduceMapper) Series {
|
||||
newSeries := NewSeries(s.Frame.RefID, s.GetLabels(), 0)
|
||||
for i := 0; i < s.Len(); i++ {
|
||||
f := s.GetValue(i)
|
||||
f = mapper.MapInput(f)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
newFloat := *f
|
||||
newSeries.AppendPoint(s.GetTime(i), &newFloat)
|
||||
}
|
||||
return newSeries
|
||||
}
|
||||
|
||||
type DropNonNumber struct {
|
||||
}
|
||||
|
||||
// MapInput returns nil if the input parameter is nil or point to either a NaN or a Inf
|
||||
func (d DropNonNumber) MapInput(s *float64) *float64 {
|
||||
if s == nil || math.IsNaN(*s) || math.IsInf(*s, 0) {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// MapOutput returns nil if the input parameter is nil or point to either a NaN or a Inf
|
||||
func (d DropNonNumber) MapOutput(s *float64) *float64 {
|
||||
if s != nil && math.IsNaN(*s) {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type ReplaceNonNumberWithValue struct {
|
||||
Value float64
|
||||
}
|
||||
|
||||
// MapInput returns a pointer to ReplaceNonNumberWithValue.Value if input parameter is nil or points to either a NaN or an Inf.
|
||||
// Otherwise, returns the input pointer as is.
|
||||
func (r ReplaceNonNumberWithValue) MapInput(v *float64) *float64 {
|
||||
if v == nil || math.IsNaN(*v) || math.IsInf(*v, 0) {
|
||||
return &r.Value
|
||||
} else {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// MapOutput returns a pointer to ReplaceNonNumberWithValue.Value if input parameter is nil or points to either a NaN or an Inf.
|
||||
// Otherwise, returns the input pointer as is.
|
||||
func (r ReplaceNonNumberWithValue) MapOutput(s *float64) *float64 {
|
||||
if s != nil && math.IsNaN(*s) {
|
||||
return &r.Value
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package mathexp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -227,6 +228,19 @@ func TestSeriesReduce(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "last null series",
|
||||
red: "last",
|
||||
varToReduce: "A",
|
||||
vars: seriesWithNil,
|
||||
errIs: require.NoError,
|
||||
resultsIs: require.Equal,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -234,7 +248,7 @@ func TestSeriesReduce(t *testing.T) {
|
||||
results := Results{}
|
||||
seriesSet := tt.vars[tt.varToReduce]
|
||||
for _, series := range seriesSet.Values {
|
||||
ns, err := series.Value().(*Series).Reduce("", tt.red)
|
||||
ns, err := series.Value().(*Series).Reduce("", tt.red, nil)
|
||||
tt.errIs(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -251,3 +265,252 @@ func TestSeriesReduce(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var seriesNonNumbers = Vars{
|
||||
"A": Results{
|
||||
[]Value{
|
||||
makeSeries("temp", nil,
|
||||
tp{time.Unix(5, 0), NaN},
|
||||
tp{time.Unix(10, 0), float64Pointer(math.Inf(-1))},
|
||||
tp{time.Unix(15, 0), float64Pointer(math.Inf(1))},
|
||||
tp{time.Unix(15, 0), nil}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestSeriesReduceDropNN(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
red string
|
||||
vars Vars
|
||||
varToReduce string
|
||||
results Results
|
||||
}{
|
||||
{
|
||||
name: "dropNN: sum series",
|
||||
red: "sum",
|
||||
varToReduce: "A",
|
||||
vars: aSeries,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(3)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dropNN: sum series with a nil value",
|
||||
red: "sum",
|
||||
varToReduce: "A",
|
||||
vars: seriesWithNil,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(2)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dropNN: sum empty series",
|
||||
red: "sum",
|
||||
varToReduce: "A",
|
||||
vars: seriesEmpty,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(0)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dropNN: mean series with a nil value and real value",
|
||||
red: "mean",
|
||||
varToReduce: "A",
|
||||
vars: seriesWithNil,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(2)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DropNN: mean empty series",
|
||||
red: "mean",
|
||||
varToReduce: "A",
|
||||
vars: seriesEmpty,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DropNN: mean series that becomes empty after filtering non-number",
|
||||
red: "mean",
|
||||
varToReduce: "A",
|
||||
vars: seriesNonNumbers,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DropNN: count empty series",
|
||||
red: "count",
|
||||
varToReduce: "A",
|
||||
vars: seriesEmpty,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(0)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DropNN: count series with nil and value should only count real numbers",
|
||||
red: "count",
|
||||
varToReduce: "A",
|
||||
vars: seriesWithNil,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(1)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := Results{}
|
||||
seriesSet := tt.vars[tt.varToReduce]
|
||||
for _, series := range seriesSet.Values {
|
||||
ns, err := series.Value().(*Series).Reduce("", tt.red, DropNonNumber{})
|
||||
require.NoError(t, err)
|
||||
results.Values = append(results.Values, ns)
|
||||
}
|
||||
opt := cmp.Comparer(func(x, y float64) bool {
|
||||
return (math.IsNaN(x) && math.IsNaN(y)) || x == y
|
||||
})
|
||||
options := append([]cmp.Option{opt}, data.FrameTestCompareOptions()...)
|
||||
if diff := cmp.Diff(tt.results, results, options...); diff != "" {
|
||||
t.Errorf("Result mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesReduceReplaceNN(t *testing.T) {
|
||||
replaceWith := rand.Float64()
|
||||
var tests = []struct {
|
||||
name string
|
||||
red string
|
||||
vars Vars
|
||||
varToReduce string
|
||||
results Results
|
||||
}{
|
||||
{
|
||||
name: "replaceNN: sum series",
|
||||
red: "sum",
|
||||
varToReduce: "A",
|
||||
vars: aSeries,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(3)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replaceNN: sum series with a nil value",
|
||||
red: "sum",
|
||||
varToReduce: "A",
|
||||
vars: seriesWithNil,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(replaceWith+2)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replaceNN: sum empty series",
|
||||
red: "sum",
|
||||
varToReduce: "A",
|
||||
vars: seriesEmpty,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(0)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replaceNN: mean series with a nil value and real value",
|
||||
red: "mean",
|
||||
varToReduce: "A",
|
||||
vars: seriesWithNil,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer((2+replaceWith)/2e0)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replaceNN: mean empty series",
|
||||
red: "mean",
|
||||
varToReduce: "A",
|
||||
vars: seriesEmpty,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(replaceWith)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replaceNN: mean series that becomes empty after filtering non-number",
|
||||
red: "mean",
|
||||
varToReduce: "A",
|
||||
vars: seriesNonNumbers,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(replaceWith)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replaceNN: count empty series",
|
||||
red: "count",
|
||||
varToReduce: "A",
|
||||
vars: seriesEmpty,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(0)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replaceNN: count series with nil and value should only count real numbers",
|
||||
red: "count",
|
||||
varToReduce: "A",
|
||||
vars: seriesWithNil,
|
||||
results: Results{
|
||||
[]Value{
|
||||
makeNumber("", nil, float64Pointer(2)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := Results{}
|
||||
seriesSet := tt.vars[tt.varToReduce]
|
||||
for _, series := range seriesSet.Values {
|
||||
ns, err := series.Value().(*Series).Reduce("", tt.red, ReplaceNonNumberWithValue{Value: replaceWith})
|
||||
require.NoError(t, err)
|
||||
results.Values = append(results.Values, ns)
|
||||
}
|
||||
opt := cmp.Comparer(func(x, y float64) bool {
|
||||
return (math.IsNaN(x) && math.IsNaN(y)) || x == y
|
||||
})
|
||||
options := append([]cmp.Option{opt}, data.FrameTestCompareOptions()...)
|
||||
if diff := cmp.Diff(tt.results, results, options...); diff != "" {
|
||||
t.Errorf("Result mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +72,7 @@ func (s Series) Resample(refID string, interval time.Duration, downsampler strin
|
||||
}
|
||||
value = tmp
|
||||
}
|
||||
if err := resampled.SetPoint(idx, t, value); err != nil {
|
||||
return resampled, err
|
||||
}
|
||||
resampled.SetPoint(idx, t, value)
|
||||
t = t.Add(interval)
|
||||
idx++
|
||||
}
|
||||
|
||||
@@ -166,17 +166,15 @@ func (s Series) GetPoint(pointIdx int) (time.Time, *float64) {
|
||||
}
|
||||
|
||||
// SetPoint sets the time and value on the corresponding vectors at the specified index.
|
||||
func (s Series) SetPoint(pointIdx int, t time.Time, f *float64) (err error) {
|
||||
func (s Series) SetPoint(pointIdx int, t time.Time, f *float64) {
|
||||
s.Frame.Fields[seriesTypeTimeIdx].Set(pointIdx, t)
|
||||
s.Frame.Fields[seriesTypeValIdx].Set(pointIdx, f)
|
||||
return
|
||||
}
|
||||
|
||||
// AppendPoint appends a point (time/value).
|
||||
func (s Series) AppendPoint(pointIdx int, t time.Time, f *float64) (err error) {
|
||||
func (s Series) AppendPoint(t time.Time, f *float64) {
|
||||
s.Frame.Fields[seriesTypeTimeIdx].Append(t)
|
||||
s.Frame.Fields[seriesTypeValIdx].Append(f)
|
||||
return
|
||||
}
|
||||
|
||||
// Len returns the length of the series.
|
||||
@@ -214,8 +212,8 @@ func (ss SortSeriesByTime) Len() int { return Series(ss).Len() }
|
||||
func (ss SortSeriesByTime) Swap(i, j int) {
|
||||
iTimeVal, iFVal := Series(ss).GetPoint(i)
|
||||
jTimeVal, jFVal := Series(ss).GetPoint(j)
|
||||
_ = Series(ss).SetPoint(j, iTimeVal, iFVal)
|
||||
_ = Series(ss).SetPoint(i, jTimeVal, jFVal)
|
||||
Series(ss).SetPoint(j, iTimeVal, iFVal)
|
||||
Series(ss).SetPoint(i, jTimeVal, jFVal)
|
||||
}
|
||||
|
||||
func (ss SortSeriesByTime) Less(i, j int) bool {
|
||||
|
||||
@@ -45,50 +45,60 @@ func init() {
|
||||
}
|
||||
|
||||
// RequestMetrics is a middleware handler that instruments the request.
|
||||
func RequestMetrics(features featuremgmt.FeatureToggles) func(handler string) web.Handler {
|
||||
return func(handler string) web.Handler {
|
||||
return func(res http.ResponseWriter, req *http.Request, c *web.Context) {
|
||||
rw := res.(web.ResponseWriter)
|
||||
now := time.Now()
|
||||
httpRequestsInFlight.Inc()
|
||||
defer httpRequestsInFlight.Dec()
|
||||
func RequestMetrics(features featuremgmt.FeatureToggles) web.Handler {
|
||||
return func(res http.ResponseWriter, req *http.Request, c *web.Context) {
|
||||
if strings.HasPrefix(c.Req.URL.Path, "/public/") || c.Req.URL.Path == "robots.txt" || c.Req.URL.Path == "/metrics" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
status := rw.Status()
|
||||
rw := res.(web.ResponseWriter)
|
||||
now := time.Now()
|
||||
httpRequestsInFlight.Inc()
|
||||
defer httpRequestsInFlight.Dec()
|
||||
c.Map(c.Req)
|
||||
c.Next()
|
||||
|
||||
code := sanitizeCode(status)
|
||||
method := sanitizeMethod(req.Method)
|
||||
handler := "unknown"
|
||||
|
||||
// enable histogram and disable summaries + counters for http requests.
|
||||
if features.IsEnabled(featuremgmt.FlagDisableHttpRequestHistogram) {
|
||||
duration := time.Since(now).Nanoseconds() / int64(time.Millisecond)
|
||||
metrics.MHttpRequestTotal.WithLabelValues(handler, code, method).Inc()
|
||||
metrics.MHttpRequestSummary.WithLabelValues(handler, code, method).Observe(float64(duration))
|
||||
} else {
|
||||
// avoiding the sanitize functions for in the new instrumentation
|
||||
// since they dont make much sense. We should remove them later.
|
||||
histogram := httpRequestDurationHistogram.
|
||||
WithLabelValues(handler, strconv.Itoa(rw.Status()), req.Method)
|
||||
if traceID, ok := cw.ExtractSampledTraceID(c.Req.Context()); ok {
|
||||
// Need to type-convert the Observer to an
|
||||
// ExemplarObserver. This will always work for a
|
||||
// HistogramVec.
|
||||
histogram.(prometheus.ExemplarObserver).ObserveWithExemplar(
|
||||
time.Since(now).Seconds(), prometheus.Labels{"traceID": traceID},
|
||||
)
|
||||
return
|
||||
}
|
||||
histogram.Observe(time.Since(now).Seconds())
|
||||
if routeOperation, exists := RouteOperationNameFromContext(c.Req.Context()); exists {
|
||||
handler = routeOperation
|
||||
}
|
||||
|
||||
status := rw.Status()
|
||||
|
||||
code := sanitizeCode(status)
|
||||
method := sanitizeMethod(req.Method)
|
||||
|
||||
// enable histogram and disable summaries + counters for http requests.
|
||||
if features.IsEnabled(featuremgmt.FlagDisableHttpRequestHistogram) {
|
||||
duration := time.Since(now).Nanoseconds() / int64(time.Millisecond)
|
||||
metrics.MHttpRequestTotal.WithLabelValues(handler, code, method).Inc()
|
||||
metrics.MHttpRequestSummary.WithLabelValues(handler, code, method).Observe(float64(duration))
|
||||
} else {
|
||||
// avoiding the sanitize functions for in the new instrumentation
|
||||
// since they dont make much sense. We should remove them later.
|
||||
histogram := httpRequestDurationHistogram.
|
||||
WithLabelValues(handler, code, req.Method)
|
||||
if traceID, ok := cw.ExtractSampledTraceID(c.Req.Context()); ok {
|
||||
// Need to type-convert the Observer to an
|
||||
// ExemplarObserver. This will always work for a
|
||||
// HistogramVec.
|
||||
histogram.(prometheus.ExemplarObserver).ObserveWithExemplar(
|
||||
time.Since(now).Seconds(), prometheus.Labels{"traceID": traceID},
|
||||
)
|
||||
return
|
||||
}
|
||||
histogram.Observe(time.Since(now).Seconds())
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(req.RequestURI, "/api/datasources/proxy"):
|
||||
countProxyRequests(status)
|
||||
case strings.HasPrefix(req.RequestURI, "/api/"):
|
||||
countApiRequests(status)
|
||||
default:
|
||||
countPageRequests(status)
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(req.RequestURI, "/api/datasources/proxy"):
|
||||
countProxyRequests(status)
|
||||
case strings.HasPrefix(req.RequestURI, "/api/"):
|
||||
countApiRequests(status)
|
||||
default:
|
||||
countPageRequests(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import (
|
||||
serviceaccountsmanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager"
|
||||
"github.com/grafana/grafana/pkg/services/shorturls"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
"github.com/grafana/grafana/pkg/services/teamguardian"
|
||||
teamguardianDatabase "github.com/grafana/grafana/pkg/services/teamguardian/database"
|
||||
teamguardianManager "github.com/grafana/grafana/pkg/services/teamguardian/manager"
|
||||
@@ -205,6 +206,7 @@ var wireSet = wire.NewSet(
|
||||
wire.Bind(new(notifications.Service), new(*notifications.NotificationService)),
|
||||
wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)),
|
||||
wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)),
|
||||
wire.Bind(new(sqlstore.Store), new(*sqlstore.SQLStore)),
|
||||
)
|
||||
|
||||
var wireTestSet = wire.NewSet(
|
||||
@@ -217,6 +219,8 @@ var wireTestSet = wire.NewSet(
|
||||
wire.Bind(new(notifications.Service), new(*notifications.NotificationServiceMock)),
|
||||
wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationServiceMock)),
|
||||
wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationServiceMock)),
|
||||
mockstore.NewSQLStoreMock,
|
||||
wire.Bind(new(sqlstore.Store), new(*mockstore.SQLStoreMock)),
|
||||
)
|
||||
|
||||
func Initialize(cla setting.CommandLineArgs, opts Options, apiOpts api.ServerOptions) (*Server, error) {
|
||||
|
||||
@@ -91,7 +91,7 @@ func buildScopeParams(c *models.ReqContext) accesscontrol.ScopeParams {
|
||||
|
||||
type OrgIDGetter func(c *models.ReqContext) (int64, error)
|
||||
|
||||
func AuthorizeInOrgMiddleware(ac accesscontrol.AccessControl, db *sqlstore.SQLStore) func(web.Handler, OrgIDGetter, accesscontrol.Evaluator) web.Handler {
|
||||
func AuthorizeInOrgMiddleware(ac accesscontrol.AccessControl, db sqlstore.Store) func(web.Handler, OrgIDGetter, accesscontrol.Evaluator) web.Handler {
|
||||
return func(fallback web.Handler, getTargetOrg OrgIDGetter, evaluator accesscontrol.Evaluator) web.Handler {
|
||||
if ac.IsDisabled() {
|
||||
return fallback
|
||||
|
||||
@@ -13,19 +13,16 @@ var (
|
||||
Description: "encrypt secrets",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
|
||||
{
|
||||
Name: "httpclientprovider_azure_auth",
|
||||
Description: "use http client for azure auth",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
{
|
||||
Name: "service-accounts",
|
||||
Description: "support service accounts",
|
||||
State: FeatureStateBeta,
|
||||
RequiresLicense: true,
|
||||
Name: "service-accounts",
|
||||
Description: "support service accounts",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
|
||||
{
|
||||
Name: "database_metrics",
|
||||
Description: "Add prometheus metrics for database tables",
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
apiv1 "github.com/prometheus/client_golang/api/prometheus/v1"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
|
||||
@@ -40,8 +40,8 @@ func (srv PrometheusSrv) RouteGetAlertStatuses(c *models.ReqContext) response.Re
|
||||
for _, alertState := range srv.manager.GetAll(c.OrgId) {
|
||||
startsAt := alertState.StartsAt
|
||||
valString := ""
|
||||
if len(alertState.Results) > 0 && alertState.State == eval.Alerting {
|
||||
valString = alertState.Results[0].EvaluationString
|
||||
if alertState.State == eval.Alerting {
|
||||
valString = alertState.LastEvaluationString
|
||||
}
|
||||
alertResponse.Data.Alerts = append(alertResponse.Data.Alerts, &apimodels.Alert{
|
||||
Labels: map[string]string(alertState.Labels),
|
||||
@@ -173,8 +173,8 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res
|
||||
for _, alertState := range srv.manager.GetStatesForRuleUID(c.OrgId, rule.UID) {
|
||||
activeAt := alertState.StartsAt
|
||||
valString := ""
|
||||
if len(alertState.Results) > 0 && alertState.State == eval.Alerting {
|
||||
valString = alertState.Results[0].EvaluationString
|
||||
if alertState.State == eval.Alerting {
|
||||
valString = alertState.LastEvaluationString
|
||||
}
|
||||
alert := &apimodels.Alert{
|
||||
Labels: map[string]string(alertState.Labels),
|
||||
|
||||
@@ -103,6 +103,7 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
|
||||
u := tmpl(d.WebhookURL)
|
||||
if tmplErr != nil {
|
||||
d.log.Warn("failed to template Discord message", "err", tmplErr.Error())
|
||||
return false, tmplErr
|
||||
}
|
||||
|
||||
body, err := json.Marshal(bodyJSON)
|
||||
|
||||
@@ -3,6 +3,7 @@ package channels
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
@@ -99,6 +100,14 @@ func TestDiscordNotifier(t *testing.T) {
|
||||
settings: `{}`,
|
||||
expInitError: `failed to validate receiver "discord_testing" of type "discord": could not find webhook url property in settings`,
|
||||
},
|
||||
{
|
||||
name: "Invalid template returns error",
|
||||
settings: `{
|
||||
"url": "http://localhost",
|
||||
"message": "{{ template \"invalid.template\" }}"
|
||||
}`,
|
||||
expMsgError: errors.New("template: :1:12: executing \"\" at <{{template \"invalid.template\"}}>: template \"invalid.template\" not defined"),
|
||||
},
|
||||
{
|
||||
name: "Default config with one alert, use default discord username",
|
||||
settings: `{
|
||||
|
||||
@@ -35,8 +35,8 @@ func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.Post
|
||||
nL := alertState.Labels.Copy()
|
||||
nA := data.Labels(alertState.Annotations).Copy()
|
||||
|
||||
if len(alertState.Results) > 0 {
|
||||
nA["__value_string__"] = alertState.Results[0].EvaluationString
|
||||
if alertState.LastEvaluationString != "" {
|
||||
nA["__value_string__"] = alertState.LastEvaluationString
|
||||
}
|
||||
|
||||
var urlStr string
|
||||
|
||||
@@ -100,11 +100,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
alertState := randomState(tc.state)
|
||||
alertState.Annotations = randomMapOfStrings()
|
||||
expectedValueString := util.GenerateShortUID()
|
||||
alertState.Results = []state.Evaluation{
|
||||
{
|
||||
EvaluationString: expectedValueString,
|
||||
},
|
||||
}
|
||||
alertState.LastEvaluationString = expectedValueString
|
||||
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
|
||||
|
||||
@@ -96,16 +96,16 @@ func (st *Manager) Warm() {
|
||||
st.log.Error("error getting cacheId for entry", "msg", err.Error())
|
||||
}
|
||||
stateForEntry := &State{
|
||||
AlertRuleUID: entry.RuleUID,
|
||||
OrgID: entry.RuleOrgID,
|
||||
CacheId: cacheId,
|
||||
Labels: lbs,
|
||||
State: translateInstanceState(entry.CurrentState),
|
||||
Results: []Evaluation{},
|
||||
StartsAt: entry.CurrentStateSince,
|
||||
EndsAt: entry.CurrentStateEnd,
|
||||
LastEvaluationTime: entry.LastEvalTime,
|
||||
Annotations: ruleForEntry.Annotations,
|
||||
AlertRuleUID: entry.RuleUID,
|
||||
OrgID: entry.RuleOrgID,
|
||||
CacheId: cacheId,
|
||||
Labels: lbs,
|
||||
State: translateInstanceState(entry.CurrentState),
|
||||
LastEvaluationString: "",
|
||||
StartsAt: entry.CurrentStateSince,
|
||||
EndsAt: entry.CurrentStateEnd,
|
||||
LastEvaluationTime: entry.LastEvalTime,
|
||||
Annotations: ruleForEntry.Annotations,
|
||||
}
|
||||
states = append(states, stateForEntry)
|
||||
}
|
||||
@@ -158,11 +158,11 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu
|
||||
currentState.LastEvaluationTime = result.EvaluatedAt
|
||||
currentState.EvaluationDuration = result.EvaluationDuration
|
||||
currentState.Results = append(currentState.Results, Evaluation{
|
||||
EvaluationTime: result.EvaluatedAt,
|
||||
EvaluationState: result.State,
|
||||
EvaluationString: result.EvaluationString,
|
||||
Values: NewEvaluationValues(result.Values),
|
||||
EvaluationTime: result.EvaluatedAt,
|
||||
EvaluationState: result.State,
|
||||
Values: NewEvaluationValues(result.Values),
|
||||
})
|
||||
currentState.LastEvaluationString = result.EvaluationString
|
||||
currentState.TrimResults(alertRule)
|
||||
oldState := currentState.State
|
||||
|
||||
|
||||
@@ -437,16 +437,6 @@ func TestProcessEvalResults(t *testing.T) {
|
||||
},
|
||||
State: eval.Pending,
|
||||
Results: []state.Evaluation{
|
||||
{
|
||||
EvaluationTime: evaluationTime.Add(10 * time.Second),
|
||||
EvaluationState: eval.Alerting,
|
||||
Values: make(map[string]*float64),
|
||||
},
|
||||
{
|
||||
EvaluationTime: evaluationTime.Add(20 * time.Second),
|
||||
EvaluationState: eval.NoData,
|
||||
Values: make(map[string]*float64),
|
||||
},
|
||||
{
|
||||
EvaluationTime: evaluationTime.Add(30 * time.Second),
|
||||
EvaluationState: eval.Alerting,
|
||||
@@ -528,16 +518,6 @@ func TestProcessEvalResults(t *testing.T) {
|
||||
},
|
||||
State: eval.NoData,
|
||||
Results: []state.Evaluation{
|
||||
{
|
||||
EvaluationTime: evaluationTime,
|
||||
EvaluationState: eval.Alerting,
|
||||
Values: make(map[string]*float64),
|
||||
},
|
||||
{
|
||||
EvaluationTime: evaluationTime.Add(10 * time.Second),
|
||||
EvaluationState: eval.Alerting,
|
||||
Values: make(map[string]*float64),
|
||||
},
|
||||
{
|
||||
EvaluationTime: evaluationTime.Add(20 * time.Second),
|
||||
EvaluationState: eval.Alerting,
|
||||
@@ -1336,11 +1316,6 @@ func TestProcessEvalResults(t *testing.T) {
|
||||
},
|
||||
State: eval.Alerting,
|
||||
Results: []state.Evaluation{
|
||||
{
|
||||
EvaluationTime: evaluationTime,
|
||||
EvaluationState: eval.Normal,
|
||||
Values: make(map[string]*float64),
|
||||
},
|
||||
{
|
||||
EvaluationTime: evaluationTime.Add(30 * time.Second),
|
||||
EvaluationState: eval.Alerting,
|
||||
@@ -1428,11 +1403,6 @@ func TestProcessEvalResults(t *testing.T) {
|
||||
},
|
||||
State: eval.NoData,
|
||||
Results: []state.Evaluation{
|
||||
{
|
||||
EvaluationTime: evaluationTime,
|
||||
EvaluationState: eval.Normal,
|
||||
Values: make(map[string]*float64),
|
||||
},
|
||||
{
|
||||
EvaluationTime: evaluationTime.Add(30 * time.Second),
|
||||
EvaluationState: eval.Alerting,
|
||||
|
||||
@@ -12,26 +12,26 @@ import (
|
||||
)
|
||||
|
||||
type State struct {
|
||||
AlertRuleUID string
|
||||
OrgID int64
|
||||
CacheId string
|
||||
State eval.State
|
||||
Resolved bool
|
||||
Results []Evaluation
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
LastEvaluationTime time.Time
|
||||
EvaluationDuration time.Duration
|
||||
LastSentAt time.Time
|
||||
Annotations map[string]string
|
||||
Labels data.Labels
|
||||
Error error
|
||||
AlertRuleUID string
|
||||
OrgID int64
|
||||
CacheId string
|
||||
State eval.State
|
||||
Resolved bool
|
||||
Results []Evaluation
|
||||
LastEvaluationString string
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
LastEvaluationTime time.Time
|
||||
EvaluationDuration time.Duration
|
||||
LastSentAt time.Time
|
||||
Annotations map[string]string
|
||||
Labels data.Labels
|
||||
Error error
|
||||
}
|
||||
|
||||
type Evaluation struct {
|
||||
EvaluationTime time.Time
|
||||
EvaluationState eval.State
|
||||
EvaluationString string
|
||||
EvaluationTime time.Time
|
||||
EvaluationState eval.State
|
||||
// Values contains the RefID and value of reduce and math expressions.
|
||||
// It does not contain values for classic conditions as the values
|
||||
// in classic conditions do not have a RefID.
|
||||
@@ -152,7 +152,7 @@ func (a *State) Equals(b *State) bool {
|
||||
}
|
||||
|
||||
func (a *State) TrimResults(alertRule *ngModels.AlertRule) {
|
||||
numBuckets := 2 * (int64(alertRule.For.Seconds()) / alertRule.IntervalSeconds)
|
||||
numBuckets := int64(alertRule.For.Seconds()) / alertRule.IntervalSeconds
|
||||
if numBuckets == 0 {
|
||||
numBuckets = 10 // keep at least 10 evaluations in the event For is set to 0
|
||||
}
|
||||
|
||||
@@ -87,3 +87,42 @@ func (ss *SecretsStoreImpl) DeleteDataKey(ctx context.Context, name string) erro
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (ss *SecretsStoreImpl) ReEncryptDataKeys(
|
||||
ctx context.Context,
|
||||
providers map[secrets.ProviderID]secrets.Provider,
|
||||
currProvider secrets.ProviderID,
|
||||
) error {
|
||||
return ss.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
||||
keys := make([]*secrets.DataKey, 0)
|
||||
if err := sess.Table(dataKeysTable).Find(&keys); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, k := range keys {
|
||||
provider, ok := providers[k.Provider]
|
||||
if !ok {
|
||||
return fmt.Errorf("could not find encryption provider '%s'", k.Provider)
|
||||
}
|
||||
|
||||
decrypted, err := provider.Decrypt(ctx, k.EncryptedData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Updating current data key by re-encrypting it with current provider.
|
||||
// Accessing the current provider within providers map should be safe.
|
||||
k.Provider = currProvider
|
||||
k.EncryptedData, err = providers[currProvider].Encrypt(ctx, decrypted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := sess.Table(dataKeysTable).Where("name = ?", k.Name).Update(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ func (f FakeSecretsService) GetDecryptedValue(_ context.Context, sjd map[string]
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (f FakeSecretsService) ReEncryptDataKeys(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FakeSecretsService) CurrentProviderID() string {
|
||||
return "fakeProvider"
|
||||
}
|
||||
|
||||
@@ -45,3 +45,7 @@ func (f FakeSecretsStore) DeleteDataKey(_ context.Context, name string) error {
|
||||
delete(f.store, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FakeSecretsStore) ReEncryptDataKeys(_ context.Context, _ map[secrets.ProviderID]secrets.Provider, _ secrets.ProviderID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -353,6 +353,17 @@ func (s *SecretsService) GetProviders() map[secrets.ProviderID]secrets.Provider
|
||||
return s.providers
|
||||
}
|
||||
|
||||
func (s *SecretsService) ReEncryptDataKeys(ctx context.Context) error {
|
||||
err := s.store.ReEncryptDataKeys(ctx, s.providers, s.currentProviderID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
s.dataKeyCache = make(map[string]dataKeyCacheItem)
|
||||
return err
|
||||
}
|
||||
|
||||
// These variables are used to test the code
|
||||
// responsible for periodically cleaning up
|
||||
// data encryption keys cache.
|
||||
|
||||
@@ -319,3 +319,42 @@ func TestSecretsService_Run(t *testing.T) {
|
||||
assert.True(t, svc.dataKeyCache[dataKeyID].expiry.After(time.Now().Add(dekTTL)))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecretsService_ReEncryptDataKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sql := sqlstore.InitTestDB(t)
|
||||
store := database.ProvideSecretsStore(sql)
|
||||
svc := SetupTestService(t, store)
|
||||
|
||||
// Encrypt to generate data encryption key
|
||||
withoutScope := secrets.WithoutScope()
|
||||
ciphertext, err := svc.Encrypt(ctx, []byte("grafana"), withoutScope)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("existing key should be re-encrypted", func(t *testing.T) {
|
||||
prevDataKeys, err := store.GetAllDataKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, prevDataKeys, 1)
|
||||
|
||||
err = svc.ReEncryptDataKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
reEncryptedDataKeys, err := store.GetAllDataKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reEncryptedDataKeys, 1)
|
||||
|
||||
assert.NotEqual(t, prevDataKeys[0].EncryptedData, reEncryptedDataKeys[0].EncryptedData)
|
||||
})
|
||||
|
||||
t.Run("data keys cache should be invalidated", func(t *testing.T) {
|
||||
// Decrypt to ensure data key is cached
|
||||
_, err := svc.Decrypt(ctx, ciphertext)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, svc.dataKeyCache)
|
||||
|
||||
err = svc.ReEncryptDataKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, svc.dataKeyCache)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ type Service interface {
|
||||
DecryptJsonData(ctx context.Context, sjd map[string][]byte) (map[string]string, error)
|
||||
|
||||
GetDecryptedValue(ctx context.Context, sjd map[string][]byte, key, fallback string) string
|
||||
|
||||
ReEncryptDataKeys(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Store defines methods to interact with secrets storage
|
||||
@@ -33,6 +35,7 @@ type Store interface {
|
||||
CreateDataKey(ctx context.Context, dataKey DataKey) error
|
||||
CreateDataKeyWithDBSession(ctx context.Context, dataKey DataKey, sess *xorm.Session) error
|
||||
DeleteDataKey(ctx context.Context, name string) error
|
||||
ReEncryptDataKeys(ctx context.Context, providers map[ProviderID]Provider, currProvider ProviderID) error
|
||||
}
|
||||
|
||||
// Provider is a key encryption key provider for envelope encryption
|
||||
|
||||
@@ -47,7 +47,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints(
|
||||
}
|
||||
|
||||
auth := acmiddleware.Middleware(api.accesscontrol)
|
||||
api.RouterRegister.Group("/api/org/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) {
|
||||
api.RouterRegister.Group("/api/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) {
|
||||
serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts))
|
||||
serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.RetrieveServiceAccount))
|
||||
serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount))
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
serviceaccountIDPath = "/api/org/serviceaccounts/%v"
|
||||
serviceaccountIDPath = "/api/serviceaccounts/%v"
|
||||
)
|
||||
|
||||
// test the accesscontrol endpoints
|
||||
|
||||
@@ -61,9 +61,3 @@ func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgI
|
||||
}
|
||||
return sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID)
|
||||
}
|
||||
|
||||
func (sa *ServiceAccountsService) Migrated(ctx context.Context, orgID int64) bool {
|
||||
// TODO: implement migration logic
|
||||
// change this to return true for development of service accounts page
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
type Service interface {
|
||||
CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*models.User, error)
|
||||
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
|
||||
Migrated(ctx context.Context, orgID int64) bool
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type SQLStoreMock struct {
|
||||
SQLStore *sqlstore.SQLStore
|
||||
ExpectedUser *models.User
|
||||
ExpectedError error
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ func (m SQLStoreMock) CreateUser(ctx context.Context, cmd models.CreateUserComma
|
||||
}
|
||||
|
||||
func (m SQLStoreMock) GetUserById(ctx context.Context, query *models.GetUserByIdQuery) error {
|
||||
query.Result = m.ExpectedUser
|
||||
return m.ExpectedError
|
||||
}
|
||||
|
||||
@@ -150,7 +151,7 @@ func (m SQLStoreMock) DeleteUser(ctx context.Context, cmd *models.DeleteUserComm
|
||||
}
|
||||
|
||||
func (m SQLStoreMock) UpdateUserPermissions(userID int64, isAdmin bool) error {
|
||||
return nil // TODO: Implement
|
||||
return m.ExpectedError
|
||||
}
|
||||
|
||||
func (m SQLStoreMock) SetUserHelpFlag(ctx context.Context, cmd *models.SetUserHelpFlagCommand) error {
|
||||
@@ -158,7 +159,11 @@ func (m SQLStoreMock) SetUserHelpFlag(ctx context.Context, cmd *models.SetUserHe
|
||||
}
|
||||
|
||||
func (m SQLStoreMock) CreateTeam(name string, email string, orgID int64) (models.Team, error) {
|
||||
return models.Team{}, nil // TODO: Implement
|
||||
return models.Team{
|
||||
Name: name,
|
||||
Email: email,
|
||||
OrgId: orgID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m SQLStoreMock) UpdateTeam(ctx context.Context, cmd *models.UpdateTeamCommand) error {
|
||||
|
||||
@@ -30,7 +30,7 @@ func (ss *SQLStore) addUserQueryAndCommandHandlers() {
|
||||
bus.AddHandler("sql", ss.GetUserProfile)
|
||||
bus.AddHandler("sql", SearchUsers)
|
||||
bus.AddHandler("sql", ss.GetUserOrgList)
|
||||
bus.AddHandler("sql", DisableUser)
|
||||
bus.AddHandler("sql", ss.DisableUser)
|
||||
bus.AddHandler("sql", ss.BatchDisableUsers)
|
||||
bus.AddHandler("sql", ss.DeleteUser)
|
||||
bus.AddHandler("sql", ss.SetUserHelpFlag)
|
||||
@@ -320,7 +320,7 @@ func (ss *SQLStore) CreateUser(ctx context.Context, cmd models.CreateUserCommand
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (ss *SQLStore) GetUserById(ctx context.Context, query *models.GetUserByIdQuery) error {
|
||||
func (ss SQLStore) GetUserById(ctx context.Context, query *models.GetUserByIdQuery) error {
|
||||
return withDbSession(ctx, x, func(sess *DBSession) error {
|
||||
user := new(models.User)
|
||||
has, err := sess.ID(query.Id).Get(user)
|
||||
@@ -721,7 +721,7 @@ func SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func DisableUser(ctx context.Context, cmd *models.DisableUserCommand) error {
|
||||
func (ss *SQLStore) DisableUser(ctx context.Context, cmd *models.DisableUserCommand) error {
|
||||
user := models.User{}
|
||||
sess := x.Table("user")
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { ShareEmbed } from './ShareEmbed';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import config from 'app/core/config';
|
||||
import { DashboardModel, PanelModel } from '../../state';
|
||||
|
||||
jest.mock('app/features/dashboard/services/TimeSrv', () => ({
|
||||
getTimeSrv: () => ({
|
||||
timeRange: () => {
|
||||
return { from: new Date(1000), to: new Date(2000) };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: {
|
||||
sidemenu: true,
|
||||
user: {},
|
||||
isSignedIn: false,
|
||||
isGrafanaAdmin: false,
|
||||
isEditor: false,
|
||||
hasEditPermissionFolders: false,
|
||||
},
|
||||
}));
|
||||
|
||||
function mockLocationHref(href: string) {
|
||||
const location = window.location;
|
||||
|
||||
let search = '';
|
||||
const searchPos = href.indexOf('?');
|
||||
if (searchPos >= 0) {
|
||||
search = href.substring(searchPos);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
delete window.location;
|
||||
(window as any).location = {
|
||||
...location,
|
||||
href,
|
||||
origin: new URL(href).origin,
|
||||
search,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ShareEmbed', () => {
|
||||
let originalBootData: any;
|
||||
|
||||
beforeAll(() => {
|
||||
originalBootData = config.bootData;
|
||||
config.appUrl = 'http://dashboards.grafana.com/';
|
||||
|
||||
config.bootData = {
|
||||
user: {
|
||||
orgId: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.bootData = originalBootData;
|
||||
});
|
||||
|
||||
it('generates the correct embed url for a dashboard', () => {
|
||||
const mockDashboard = new DashboardModel({
|
||||
uid: 'mockDashboardUid',
|
||||
});
|
||||
const mockPanel = new PanelModel({
|
||||
id: 'mockPanelId',
|
||||
});
|
||||
mockLocationHref(`http://dashboards.grafana.com/d/${mockDashboard.uid}?orgId=1`);
|
||||
render(<ShareEmbed dashboard={mockDashboard} panel={mockPanel} />);
|
||||
|
||||
const embedUrl = screen.getByTestId('share-embed-html');
|
||||
expect(embedUrl).toBeInTheDocument();
|
||||
expect(embedUrl).toHaveTextContent(
|
||||
`http://dashboards.grafana.com/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}`
|
||||
);
|
||||
});
|
||||
|
||||
it('generates the correct embed url for a dashboard set to the homepage in the grafana config', () => {
|
||||
mockLocationHref('http://dashboards.grafana.com/?orgId=1');
|
||||
const mockDashboard = new DashboardModel({
|
||||
uid: 'mockDashboardUid',
|
||||
});
|
||||
const mockPanel = new PanelModel({
|
||||
id: 'mockPanelId',
|
||||
});
|
||||
render(<ShareEmbed dashboard={mockDashboard} panel={mockPanel} />);
|
||||
|
||||
const embedUrl = screen.getByTestId('share-embed-html');
|
||||
expect(embedUrl).toBeInTheDocument();
|
||||
expect(embedUrl).toHaveTextContent(
|
||||
`http://dashboards.grafana.com/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}`
|
||||
);
|
||||
});
|
||||
|
||||
it('generates the correct embed url for a snapshot', () => {
|
||||
const mockSlug = 'mockSlug';
|
||||
mockLocationHref(`http://dashboards.grafana.com/dashboard/snapshot/${mockSlug}?orgId=1`);
|
||||
const mockDashboard = new DashboardModel({
|
||||
uid: 'mockDashboardUid',
|
||||
});
|
||||
const mockPanel = new PanelModel({
|
||||
id: 'mockPanelId',
|
||||
});
|
||||
render(<ShareEmbed dashboard={mockDashboard} panel={mockPanel} />);
|
||||
|
||||
const embedUrl = screen.getByTestId('share-embed-html');
|
||||
expect(embedUrl).toBeInTheDocument();
|
||||
expect(embedUrl).toHaveTextContent(
|
||||
`http://dashboards.grafana.com/dashboard-solo/snapshot/${mockSlug}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}`
|
||||
);
|
||||
});
|
||||
|
||||
it('generates the correct embed url for a scripted dashboard', () => {
|
||||
const mockSlug = 'scripted.js';
|
||||
mockLocationHref(`http://dashboards.grafana.com/dashboard/script/${mockSlug}?orgId=1`);
|
||||
const mockDashboard = new DashboardModel({
|
||||
uid: 'mockDashboardUid',
|
||||
});
|
||||
const mockPanel = new PanelModel({
|
||||
id: 'mockPanelId',
|
||||
});
|
||||
render(<ShareEmbed dashboard={mockDashboard} panel={mockPanel} />);
|
||||
|
||||
const embedUrl = screen.getByTestId('share-embed-html');
|
||||
expect(embedUrl).toBeInTheDocument();
|
||||
expect(embedUrl).toHaveTextContent(
|
||||
`http://dashboards.grafana.com/dashboard-solo/script/${mockSlug}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -34,10 +34,10 @@ export class ShareEmbed extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
buildIframeHtml = () => {
|
||||
const { panel } = this.props;
|
||||
const { panel, dashboard } = this.props;
|
||||
const { useCurrentTimeRange, selectedTheme } = this.state;
|
||||
|
||||
const iframeHtml = buildIframeHtml(useCurrentTimeRange, selectedTheme, panel);
|
||||
const iframeHtml = buildIframeHtml(useCurrentTimeRange, dashboard.uid, selectedTheme, panel);
|
||||
this.setState({ iframeHtml });
|
||||
};
|
||||
|
||||
@@ -92,6 +92,7 @@ export class ShareEmbed extends PureComponent<Props, State> {
|
||||
the user viewing that page need to be signed into Grafana for the graph to load."
|
||||
>
|
||||
<TextArea
|
||||
data-testid="share-embed-html"
|
||||
id="share-panel-embed-embed-html-textarea"
|
||||
rows={5}
|
||||
value={iframeHtml}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Props, ShareLink, State } from './ShareLink';
|
||||
import { initTemplateSrv } from '../../../../../test/helpers/initTemplateSrv';
|
||||
import { variableAdapters } from '../../../variables/adapters';
|
||||
import { createQueryVariableAdapter } from '../../../variables/query/adapter';
|
||||
import { PanelModel } from '../../state';
|
||||
import { DashboardModel, PanelModel } from '../../state';
|
||||
import { getDefaultTimeRange } from '@grafana/data';
|
||||
|
||||
jest.mock('app/features/dashboard/services/TimeSrv', () => ({
|
||||
@@ -31,6 +31,7 @@ function mockLocationHref(href: string) {
|
||||
(window as any).location = {
|
||||
...location,
|
||||
href,
|
||||
origin: new URL(href).origin,
|
||||
search,
|
||||
};
|
||||
}
|
||||
@@ -189,3 +190,39 @@ describe('ShareModal', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when default_home_dashboard_path is set in the grafana config', () => {
|
||||
let originalBootData: any;
|
||||
|
||||
beforeAll(() => {
|
||||
originalBootData = config.bootData;
|
||||
config.appUrl = 'http://dashboards.grafana.com/';
|
||||
|
||||
config.bootData = {
|
||||
user: {
|
||||
orgId: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.bootData = originalBootData;
|
||||
});
|
||||
|
||||
it('should render the correct link', async () => {
|
||||
const mockDashboard = new DashboardModel({
|
||||
uid: 'mockDashboardUid',
|
||||
});
|
||||
const mockPanel = new PanelModel({
|
||||
id: 'mockPanelId',
|
||||
});
|
||||
mockLocationHref('http://dashboards.grafana.com/?orgId=1');
|
||||
const test: ShallowWrapper<Props, State, ShareLink> = shallow(
|
||||
<ShareLink dashboard={mockDashboard} panel={mockPanel} />
|
||||
);
|
||||
await test.instance().buildUrl();
|
||||
expect(test.state().imageUrl).toBe(
|
||||
`http://dashboards.grafana.com/render/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}&width=1000&height=500&tz=UTC`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,11 +51,11 @@ export class ShareLink extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
buildUrl = async () => {
|
||||
const { panel } = this.props;
|
||||
const { panel, dashboard } = this.props;
|
||||
const { useCurrentTimeRange, useShortUrl, selectedTheme } = this.state;
|
||||
|
||||
const shareUrl = await buildShareUrl(useCurrentTimeRange, selectedTheme, panel, useShortUrl);
|
||||
const imageUrl = buildImageUrl(useCurrentTimeRange, selectedTheme, panel);
|
||||
const imageUrl = buildImageUrl(useCurrentTimeRange, dashboard.uid, selectedTheme, panel);
|
||||
|
||||
this.setState({ shareUrl, imageUrl });
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { createShortLink } from 'app/core/utils/shortLinks';
|
||||
import { dateTime, PanelModel, TimeRange, urlUtil } from '@grafana/data';
|
||||
import { dateTime, locationUtil, PanelModel, TimeRange, urlUtil } from '@grafana/data';
|
||||
|
||||
export interface BuildParamsArgs {
|
||||
useCurrentTimeRange: boolean;
|
||||
@@ -68,13 +68,24 @@ export async function buildShareUrl(
|
||||
return shareUrl;
|
||||
}
|
||||
|
||||
export function buildSoloUrl(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) {
|
||||
export function buildSoloUrl(
|
||||
useCurrentTimeRange: boolean,
|
||||
dashboardUid: string,
|
||||
selectedTheme?: string,
|
||||
panel?: PanelModel
|
||||
) {
|
||||
const baseUrl = buildBaseUrl();
|
||||
const params = buildParams({ useCurrentTimeRange, selectedTheme, panel });
|
||||
|
||||
let soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/');
|
||||
soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/');
|
||||
|
||||
// For handling the case when default_home_dashboard_path is set in the grafana config
|
||||
const strippedUrl = locationUtil.stripBaseFromUrl(baseUrl);
|
||||
if (strippedUrl === '/') {
|
||||
soloUrl = `${config.appUrl}d-solo/${dashboardUid}`;
|
||||
}
|
||||
|
||||
const panelId = params.get('editPanel') ?? params.get('viewPanel') ?? '';
|
||||
params.set('panelId', panelId);
|
||||
params.delete('editPanel');
|
||||
@@ -83,17 +94,26 @@ export function buildSoloUrl(useCurrentTimeRange: boolean, selectedTheme?: strin
|
||||
return urlUtil.appendQueryToUrl(soloUrl, params.toString());
|
||||
}
|
||||
|
||||
export function buildImageUrl(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) {
|
||||
let soloUrl = buildSoloUrl(useCurrentTimeRange, selectedTheme, panel);
|
||||
|
||||
export function buildImageUrl(
|
||||
useCurrentTimeRange: boolean,
|
||||
dashboardUid: string,
|
||||
selectedTheme?: string,
|
||||
panel?: PanelModel
|
||||
) {
|
||||
let soloUrl = buildSoloUrl(useCurrentTimeRange, dashboardUid, selectedTheme, panel);
|
||||
let imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/');
|
||||
imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
|
||||
imageUrl += '&width=1000&height=500' + getLocalTimeZone();
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
export function buildIframeHtml(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) {
|
||||
let soloUrl = buildSoloUrl(useCurrentTimeRange, selectedTheme, panel);
|
||||
export function buildIframeHtml(
|
||||
useCurrentTimeRange: boolean,
|
||||
dashboardUid: string,
|
||||
selectedTheme?: string,
|
||||
panel?: PanelModel
|
||||
) {
|
||||
let soloUrl = buildSoloUrl(useCurrentTimeRange, dashboardUid, selectedTheme, panel);
|
||||
return '<iframe src="' + soloUrl + '" width="450" height="200" frameborder="0"></iframe>';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { FC } from 'react';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { InlineField, InlineFieldRow, Select } from '@grafana/ui';
|
||||
import { ExpressionQuery, reducerTypes } from '../types';
|
||||
import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui';
|
||||
import { ExpressionQuery, ExpressionQuerySettings, ReducerMode, reducerMode, reducerTypes } from '../types';
|
||||
|
||||
interface Props {
|
||||
labelWidth: number;
|
||||
@@ -21,6 +21,49 @@ export const Reduce: FC<Props> = ({ labelWidth, onChange, refIds, query }) => {
|
||||
onChange({ ...query, reducer: value.value });
|
||||
};
|
||||
|
||||
const onSettingsChanged = (settings: ExpressionQuerySettings) => {
|
||||
onChange({ ...query, settings: settings });
|
||||
};
|
||||
|
||||
const onModeChanged = (value: SelectableValue<ReducerMode>) => {
|
||||
let newSettings: ExpressionQuerySettings;
|
||||
switch (value.value) {
|
||||
case ReducerMode.ReplaceNonNumbers:
|
||||
let replaceWithNumber = 0;
|
||||
if (query.settings?.mode === ReducerMode.ReplaceNonNumbers) {
|
||||
replaceWithNumber = query.settings?.replaceWithValue ?? 0;
|
||||
}
|
||||
newSettings = {
|
||||
mode: ReducerMode.ReplaceNonNumbers,
|
||||
replaceWithValue: replaceWithNumber,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
newSettings = {
|
||||
mode: value.value,
|
||||
};
|
||||
}
|
||||
onSettingsChanged(newSettings);
|
||||
};
|
||||
|
||||
const onReplaceWithChanged = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
const value = e.currentTarget.valueAsNumber;
|
||||
onSettingsChanged({ mode: ReducerMode.ReplaceNonNumbers, replaceWithValue: value ?? 0 });
|
||||
};
|
||||
|
||||
const mode = query.settings?.mode ?? ReducerMode.Strict;
|
||||
|
||||
const replaceWithNumber = () => {
|
||||
if (mode !== ReducerMode.ReplaceNonNumbers) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<InlineField label="Replace With" labelWidth={labelWidth}>
|
||||
<Input type="number" width={10} onChange={onReplaceWithChanged} value={query.settings?.replaceWithValue ?? 0} />
|
||||
</InlineField>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Function" labelWidth={labelWidth}>
|
||||
@@ -29,6 +72,10 @@ export const Reduce: FC<Props> = ({ labelWidth, onChange, refIds, query }) => {
|
||||
<InlineField label="Input" labelWidth={labelWidth}>
|
||||
<Select menuShouldPortal onChange={onRefIdChange} options={refIds} value={query.expression} width={20} />
|
||||
</InlineField>
|
||||
<InlineField label="Mode" labelWidth={labelWidth}>
|
||||
<Select menuShouldPortal onChange={onModeChanged} options={reducerMode} value={mode} width={25} />
|
||||
</InlineField>
|
||||
{replaceWithNumber()}
|
||||
</InlineFieldRow>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,30 @@ export const reducerTypes: Array<SelectableValue<string>> = [
|
||||
{ value: ReducerID.last, label: 'Last', description: 'Get the last value' },
|
||||
];
|
||||
|
||||
export enum ReducerMode {
|
||||
Strict = '', // backend API wants an empty string to support "strict" mode
|
||||
ReplaceNonNumbers = 'replaceNN',
|
||||
DropNonNumbers = 'dropNN',
|
||||
}
|
||||
|
||||
export const reducerMode: Array<SelectableValue<ReducerMode>> = [
|
||||
{
|
||||
value: ReducerMode.Strict,
|
||||
label: 'Strict',
|
||||
description: 'Result can be NaN if series contains non-numeric data',
|
||||
},
|
||||
{
|
||||
value: ReducerMode.DropNonNumbers,
|
||||
label: 'Drop Non-numeric Values',
|
||||
description: 'Drop NaN, +/-Inf and null from input series before reducing',
|
||||
},
|
||||
{
|
||||
value: ReducerMode.ReplaceNonNumbers,
|
||||
label: 'Replace Non-numeric Values',
|
||||
description: 'Replace NaN, +/-Inf and null with a constant value before reducing',
|
||||
},
|
||||
];
|
||||
|
||||
export const downsamplingTypes: Array<SelectableValue<string>> = [
|
||||
{ value: ReducerID.min, label: 'Min', description: 'Fill with the minimum value' },
|
||||
{ value: ReducerID.max, label: 'Max', description: 'Fill with the maximum value' },
|
||||
@@ -49,7 +73,14 @@ export interface ExpressionQuery extends DataQuery {
|
||||
downsampler?: string;
|
||||
upsampler?: string;
|
||||
conditions?: ClassicCondition[];
|
||||
settings?: ExpressionQuerySettings;
|
||||
}
|
||||
|
||||
export interface ExpressionQuerySettings {
|
||||
mode?: ReducerMode;
|
||||
replaceWithValue?: number;
|
||||
}
|
||||
|
||||
export interface ClassicCondition {
|
||||
evaluator: {
|
||||
params: number[];
|
||||
|
||||
@@ -32,7 +32,7 @@ const mapDispatchToProps = {
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
const ServiceAccountsListPage2: React.FC<Props> = ({ loadServiceAccounts, navModel, serviceAccounts, isLoading }) => {
|
||||
const ServiceAccountsListPage: React.FC<Props> = ({ loadServiceAccounts, navModel, serviceAccounts, isLoading }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -184,4 +184,4 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default connector(ServiceAccountsListPage2);
|
||||
export default connector(ServiceAccountsListPage);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getBackendSrv } from '@grafana/runtime';
|
||||
import { ServiceAccountDTO } from 'app/types';
|
||||
import { serviceAccountLoaded, serviceAccountsLoaded } from './reducers';
|
||||
|
||||
const BASE_URL = `/api/org/serviceaccounts`;
|
||||
const BASE_URL = `/api/serviceaccounts`;
|
||||
|
||||
export function loadServiceAccount(id: number): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { OnPropChangeArguments, VariableEditorProps } from '../editor/types';
|
||||
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
|
||||
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||
import { VerticalGroup } from '@grafana/ui';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { StoreState } from 'app/types';
|
||||
import { changeVariableMultiValue } from '../state/actions';
|
||||
import { VariableSectionHeader } from '../editor/VariableSectionHeader';
|
||||
@@ -55,6 +56,7 @@ class CustomVariableEditorUnconnected extends PureComponent<Props> {
|
||||
required
|
||||
width={50}
|
||||
labelWidth={27}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
|
||||
/>
|
||||
</VerticalGroup>
|
||||
<SelectionOptionsEditor
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user