Merge branch 'main' into ifrost/rich-history-migration/frontend-storage-refactor

This commit is contained in:
Piotr Jamróz
2022-02-03 14:28:29 +01:00
107 changed files with 2182 additions and 621 deletions
@@ -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.
+2 -2
View File
@@ -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).
+1 -1
View File
@@ -71,7 +71,7 @@ JSON Body schema:
"deleteUrl":"myurl/api/snapshots-delete/XXXXXXX",
"key":"YYYYYYY",
"url":"myurl/dashboard/snapshot/YYYYYYY",
"id": 1,
"id": 1
}
```
@@ -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
View File
@@ -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,
};
});
}
+1 -9
View File
@@ -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
View File
@@ -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)
+5 -3
View File
@@ -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) {
+9 -7
View File
@@ -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
View File
@@ -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))
+5 -3
View File
@@ -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() {
+9 -7
View File
@@ -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 -8
View File
@@ -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
View File
@@ -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)
+5 -2
View File
@@ -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.
+9 -7
View File
@@ -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{
+2 -1
View File
@@ -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
View File
@@ -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 {
+7 -6
View File
@@ -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
View File
@@ -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)
}
+2 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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},
+2 -3
View File
@@ -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
View File
@@ -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)
}
+4 -2
View File
@@ -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
View File
@@ -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)
})
})
+8 -7
View File
@@ -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)
}
+3 -5
View File
@@ -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
View File
@@ -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, "/")
+10
View File
@@ -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{}
+2 -8
View File
@@ -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
View File
@@ -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
}
+91
View File
@@ -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
View File
@@ -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
}
+11 -15
View File
@@ -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)
}
})
}
+1 -4
View File
@@ -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
}
+2 -6
View File
@@ -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
View File
@@ -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
}
+264 -1
View File
@@ -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)
}
})
}
}
+1 -3
View File
@@ -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++
}
+4 -6
View File
@@ -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 {
+48 -38
View File
@@ -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)
}
}
}
+4
View File
@@ -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
+3 -6
View File
@@ -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",
+5 -5
View File
@@ -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: `{
+2 -2
View File
@@ -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
+1 -5
View File
@@ -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)
+14 -14
View File
@@ -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,
+18 -18
View File
@@ -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
}
+39
View File
@@ -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"
}
+4
View File
@@ -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
}
+11
View File
@@ -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)
})
}
+3
View File
@@ -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
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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 {
+8 -3
View File
@@ -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 {
+3 -3
View File
@@ -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>
);
};
+31
View File
@@ -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