Merge branch 'master' into hugoh/library-panel-api
This commit is contained in:
+4
-6
@@ -506,15 +506,13 @@ steps:
|
||||
- test-backend
|
||||
- test-frontend
|
||||
|
||||
- name: release-next-npm-packages
|
||||
- name: release-canary-npm-packages
|
||||
image: grafana/build-container:1.3.0
|
||||
commands:
|
||||
- ./node_modules/.bin/lerna bootstrap
|
||||
- echo "//registry.npmjs.org/:_authToken=$${NPM_TOKEN}" >> ~/.npmrc
|
||||
- ./scripts/circle-release-next-packages.sh
|
||||
- ./scripts/circle-release-canary-packages.sh
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: npm_token
|
||||
GITHUB_PACKAGE_TOKEN:
|
||||
from_secret: github_package_token
|
||||
depends_on:
|
||||
- end-to-end-tests
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ RUN export GF_GID_NAME=$(getent group $GF_GID | cut -d':' -f1) && \
|
||||
mkdir -p "$GF_PATHS_PROVISIONING/datasources" \
|
||||
"$GF_PATHS_PROVISIONING/dashboards" \
|
||||
"$GF_PATHS_PROVISIONING/notifiers" \
|
||||
"$GF_PATHS_PROVISIONING/plugins" \
|
||||
"$GF_PATHS_LOGS" \
|
||||
"$GF_PATHS_PLUGINS" \
|
||||
"$GF_PATHS_DATA" && \
|
||||
|
||||
@@ -58,6 +58,7 @@ RUN mkdir -p "$GF_PATHS_HOME/.aws" && \
|
||||
mkdir -p "$GF_PATHS_PROVISIONING/datasources" \
|
||||
"$GF_PATHS_PROVISIONING/dashboards" \
|
||||
"$GF_PATHS_PROVISIONING/notifiers" \
|
||||
"$GF_PATHS_PROVISIONING/plugins" \
|
||||
"$GF_PATHS_LOGS" \
|
||||
"$GF_PATHS_PLUGINS" \
|
||||
"$GF_PATHS_DATA" && \
|
||||
|
||||
@@ -115,7 +115,6 @@ If that is the case, then use the following format for headings:
|
||||
|
||||
- Grafana, Loki, and Prometheus are always capitalized unless part of a code block.
|
||||
- API names are always Title Case, followed by "API"—for example, "Dashboard Permissions API"
|
||||
- Git is always capitalized, unless part of a code block.
|
||||
- Abbreviations are always capitalized (such as API, HTTP, ID, JSON, SQL, or URL) unless they are part of a code block.
|
||||
- Menu and submenu titles always use sentence case: capitalize the first word, and lowercase the rest.
|
||||
- "Dashboards" when referring to the submenu title.
|
||||
@@ -125,6 +124,22 @@ If that is the case, then use the following format for headings:
|
||||
- Lowercase "dashboards" when referring to multiple dashboards.
|
||||
- **Exceptions:** If a term is lowercased in the Grafana UI, then match the UI.
|
||||
|
||||
#### Git, GitHub
|
||||
|
||||
Git is always capitalized, unless part of a code block. GitHub is the correct spelling and capitalization.
|
||||
|
||||
#### Integrations
|
||||
|
||||
In general, "integration" is not capitalized. Only capitalize it if it is capitalized in the UI or part of a proper noun, like the name of a specific integration.
|
||||
|
||||
The first letter of the name of an integration is always capitalized, even if the original named source is lowercase.
|
||||
|
||||
**Examples:**
|
||||
- MySQL Integration
|
||||
- CockroachDB Integration
|
||||
- Etcd Integration
|
||||
- I installed an integration on my local Grafana.
|
||||
|
||||
### Links and references
|
||||
|
||||
When referencing another document, use "Refer to" rather than alternatives such as "See" or "Check out."
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
environment:
|
||||
INFLUXDB_ADMIN_USER: grafana
|
||||
INFLUXDB_ADMIN_PASSWORD: grafana
|
||||
INFLUXDB_DB: site
|
||||
volumes:
|
||||
- ./docker/blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf
|
||||
|
||||
|
||||
@@ -147,19 +147,20 @@ Since not all datasources have the same configuration settings we only have the
|
||||
| tlsAuthWithCACert | boolean | _All_ | Enable TLS authentication using CA cert |
|
||||
| tlsSkipVerify | boolean | _All_ | Controls whether a client verifies the server's certificate chain and host name. |
|
||||
| graphiteVersion | string | Graphite | Graphite version |
|
||||
| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL and MSSQL | Lowest interval/step value that should be used for this data source |
|
||||
| httpMode | string | Influxdb | HTTP Method. 'GET', 'POST', defaults to GET |
|
||||
| httpMethod | string | Prometheus | HTTP Method. 'GET', 'POST', defaults to GET |
|
||||
| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL and MSSQL | Lowest interval/step value that should be used for this data source. |
|
||||
| httpMode | string | Influxdb | HTTP Method. 'GET', 'POST', defaults to GET |
|
||||
| httpMethod | string | Prometheus | HTTP Method. 'GET', 'POST', defaults to GET |
|
||||
| esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56/60/70) |
|
||||
| timeField | string | Elasticsearch | Which field that should be used as timestamp |
|
||||
| interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' |
|
||||
| logMessageField | string | Elasticsearch | Which field should be used as the log message |
|
||||
| logLevelField | string | Elasticsearch | Which field should be used to indicate the priority of the log message |
|
||||
| sigV4AuthType | string | Elasticsearch | SigV4 auth provider. default/credentials/keys |
|
||||
| sigV4ExternalId | string | Elasticsearch | Optional SigV4 External ID |
|
||||
| sigV4AssumeRoleArn | string | Elasticsearch | Optional SigV4 ARN role to assume |
|
||||
| sigV4Region | string | Elasticsearch | SigV4 AWS region |
|
||||
| sigV4Profile | string | Elasticsearch | Optional SigV4 credentials profile |
|
||||
| sigV4Auth | boolean | Elasticsearch | Enable usage of SigV4 |
|
||||
| sigV4AuthType | string | Elasticsearch | SigV4 auth provider. default/credentials/keys |
|
||||
| sigV4ExternalId | string | Elasticsearch | Optional SigV4 External ID |
|
||||
| sigV4AssumeRoleArn | string | Elasticsearch | Optional SigV4 ARN role to assume |
|
||||
| sigV4Region | string | Elasticsearch | SigV4 AWS region |
|
||||
| sigV4Profile | string | Elasticsearch | Optional SigV4 credentials profile |
|
||||
| authType | string | Cloudwatch | Auth provider. default/credentials/keys |
|
||||
| externalId | string | Cloudwatch | Optional External ID |
|
||||
| assumeRoleArn | string | Cloudwatch | Optional ARN role to assume |
|
||||
@@ -194,7 +195,7 @@ Secure json data is a map of settings that will be encrypted with [secret key]({
|
||||
| basicAuthPassword | string | _All_ | password for basic authentication |
|
||||
| accessKey | string | Cloudwatch | Access key for connecting to Cloudwatch |
|
||||
| secretKey | string | Cloudwatch | Secret key for connecting to Cloudwatch |
|
||||
| sigV4AccessKey | string | Elasticsearch | SigV4 access key. Required when using keys auth provider |
|
||||
| sigV4AccessKey | string | Elasticsearch | SigV4 access key. Required when using keys auth provider |
|
||||
| sigV4SecretKey | string | Elasticsearch | SigV4 secret key. Required when using keys auth provider |
|
||||
|
||||
#### Custom HTTP headers for datasources
|
||||
|
||||
+3
-3
@@ -185,9 +185,9 @@
|
||||
"style-loader": "1.1.3",
|
||||
"terser-webpack-plugin": "2.3.5",
|
||||
"ts-jest": "26.4.4",
|
||||
"ts-node": "8.8.1",
|
||||
"tslib": "2.0.1",
|
||||
"typescript": "4.0.2",
|
||||
"ts-node": "9.0.0",
|
||||
"tslib": "2.0.3",
|
||||
"typescript": "4.1.2",
|
||||
"webpack": "4.41.5",
|
||||
"webpack-bundle-analyzer": "3.6.0",
|
||||
"webpack-cleanup-plugin": "0.5.1",
|
||||
|
||||
+23
-1
@@ -27,7 +27,29 @@ Every commit to master that has changes within the `packages` directory is a sub
|
||||
<lerna.json version>-<COMMIT_SHA>
|
||||
```
|
||||
|
||||
Automatic prereleases are published under the `canary` dist tag.
|
||||
Automatic prereleases are published under the `canary` dist tag to the [github package registry](https://docs.github.com/en/free-pro-team@latest/packages/publishing-and-managing-packages/about-github-packages).
|
||||
|
||||
#### Consuming prereleases
|
||||
|
||||
As mentioned above the `canary` releases are published to the Github package registry rather than the NPM registry. If you wish to make use of these prereleases please follow these steps:
|
||||
|
||||
1. You must use a personal access token to install packages from Github. To create an access token [click here](https://github.com/settings/tokens) and create a token with the `read:packages` scope. Make a copy of the token.
|
||||
2. Create / modify your `~/.npmrc` file with the following:
|
||||
|
||||
```
|
||||
@grafana:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken={INSERT_GH_TOKEN_HERE}
|
||||
```
|
||||
|
||||
3. Update the package.json of your project to use either the `canary` channel or a version of the `canary` channel
|
||||
|
||||
```json
|
||||
// plugin's package.json
|
||||
{
|
||||
...
|
||||
"@grafana/data": "canary"
|
||||
}
|
||||
```
|
||||
|
||||
### Manual release
|
||||
|
||||
|
||||
@@ -53,6 +53,6 @@
|
||||
"rollup-plugin-typescript2": "0.26.0",
|
||||
"rollup-plugin-visualizer": "3.3.1",
|
||||
"sinon": "8.1.1",
|
||||
"typescript": "4.0.2"
|
||||
"typescript": "4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
|
||||
});
|
||||
|
||||
values.push({
|
||||
name,
|
||||
name: '',
|
||||
field: config,
|
||||
display: displayValue,
|
||||
view,
|
||||
|
||||
@@ -36,14 +36,14 @@
|
||||
"rollup-plugin-typescript2": "0.26.0",
|
||||
"rollup-plugin-visualizer": "3.3.1",
|
||||
"ts-loader": "6.2.1",
|
||||
"ts-node": "8.8.1"
|
||||
"ts-node": "9.0.0"
|
||||
},
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"commander": "5.0.0",
|
||||
"execa": "4.0.0",
|
||||
"typescript": "4.0.2",
|
||||
"typescript": "4.1.2",
|
||||
"yaml": "^1.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"execa": "4.0.0",
|
||||
"resolve-as-bin": "2.1.0",
|
||||
"ts-loader": "6.2.1",
|
||||
"typescript": "4.0.2",
|
||||
"typescript": "4.1.2",
|
||||
"yaml": "^1.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"rollup-plugin-terser": "5.3.0",
|
||||
"rollup-plugin-typescript2": "0.26.0",
|
||||
"rollup-plugin-visualizer": "3.3.1",
|
||||
"typescript": "4.0.2"
|
||||
"typescript": "4.1.2"
|
||||
},
|
||||
"types": "src/index.ts"
|
||||
}
|
||||
|
||||
@@ -103,9 +103,9 @@
|
||||
"terser-webpack-plugin": "2.3.5",
|
||||
"ts-jest": "26.4.4",
|
||||
"ts-loader": "6.2.1",
|
||||
"ts-node": "8.8.1",
|
||||
"tslib": "2.0.1",
|
||||
"typescript": "4.0.2",
|
||||
"ts-node": "9.0.0",
|
||||
"tslib": "2.0.3",
|
||||
"typescript": "4.1.2",
|
||||
"url-loader": "^2.0.1",
|
||||
"webpack": "4.41.5"
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ const compile = () =>
|
||||
|
||||
const savePackage = ({ path, pkg }: { path: string; pkg: {} }) =>
|
||||
useSpinner('Updating package.json', async () => {
|
||||
new Promise((resolve, reject) => {
|
||||
new Promise<void>((resolve, reject) => {
|
||||
fs.writeFile(path, JSON.stringify(pkg, null, 2), err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
@@ -61,7 +61,7 @@ const copyFiles = () => {
|
||||
|
||||
return useSpinner(`Moving ${files.join(', ')} files`, async () => {
|
||||
const promises = files.map(file => {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const basedir = path.dirname(`${distDir}/${file}`);
|
||||
if (!fs.existsSync(basedir)) {
|
||||
fs.mkdirSync(basedir, { recursive: true });
|
||||
@@ -85,7 +85,7 @@ const copySassFiles = () => {
|
||||
return useSpinner(`Copy scss files ${files.join(', ')} files`, async () => {
|
||||
const sassDir = path.resolve(cwd, '../../public/sass/');
|
||||
const promises = files.map(file => {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const name = file.replace('.generated', '');
|
||||
fs.copyFile(`${sassDir}/${file}`, `${distDir}/sass/${name}`, err => {
|
||||
if (err) {
|
||||
|
||||
@@ -26,7 +26,9 @@ module.exports = {
|
||||
check: true,
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
reactDocgenTypescriptOptions: {
|
||||
tsconfigPath: path.resolve(__dirname, 'tsconfig.json'),
|
||||
shouldExtractLiteralValuesFromEnum: true,
|
||||
shouldRemoveUndefinedFromOptional: true,
|
||||
propFilter: (prop: any) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
|
||||
},
|
||||
},
|
||||
@@ -44,21 +46,6 @@ module.exports = {
|
||||
configFile: path.resolve(__dirname, 'tsconfig.json'),
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: require.resolve('react-docgen-typescript-loader'),
|
||||
options: {
|
||||
tsconfigPath: path.resolve(__dirname, 'tsconfig.json'),
|
||||
// https://github.com/styleguidist/react-docgen-typescript#parseroptions
|
||||
// @ts-ignore
|
||||
propFilter: prop => {
|
||||
if (prop.parent) {
|
||||
return !prop.parent.fileName.includes('node_modules/@types/react/');
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -77,12 +77,12 @@
|
||||
"@rollup/plugin-commonjs": "16.0.0",
|
||||
"@rollup/plugin-image": "2.0.5",
|
||||
"@rollup/plugin-node-resolve": "10.0.0",
|
||||
"@storybook/addon-essentials": "6.1.2",
|
||||
"@storybook/addon-controls": "6.1.2",
|
||||
"@storybook/addon-knobs": "6.1.2",
|
||||
"@storybook/addon-storysource": "6.1.2",
|
||||
"@storybook/react": "6.1.2",
|
||||
"@storybook/theming": "6.1.2",
|
||||
"@storybook/addon-essentials": "6.1.9",
|
||||
"@storybook/addon-controls": "6.1.9",
|
||||
"@storybook/addon-knobs": "6.1.9",
|
||||
"@storybook/addon-storysource": "6.1.9",
|
||||
"@storybook/react": "6.1.9",
|
||||
"@storybook/theming": "6.1.9",
|
||||
"@types/classnames": "2.2.7",
|
||||
"@types/common-tags": "^1.8.0",
|
||||
"@types/d3": "5.7.2",
|
||||
@@ -111,7 +111,7 @@
|
||||
"rollup-plugin-visualizer": "4.2.0",
|
||||
"storybook-dark-mode": "1.0.3",
|
||||
"ts-loader": "8.0.11",
|
||||
"typescript": "4.0.2",
|
||||
"typescript": "4.1.2",
|
||||
"webpack-filter-warnings-plugin": "1.2.1"
|
||||
},
|
||||
"types": "src/index.ts"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getColorsFromSeverity } from '../../utils/colors';
|
||||
|
||||
export type AlertVariant = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
export interface Props extends HTMLAttributes<HTMLElement> {
|
||||
export interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
title: string;
|
||||
/** On click handler for alert button, mostly used for dismissing the alert */
|
||||
onRemove?: (event: React.MouseEvent) => void;
|
||||
@@ -39,13 +39,13 @@ function getIconFromSeverity(severity: AlertVariant): string {
|
||||
}
|
||||
}
|
||||
|
||||
export const Alert: FC<Props> = React.forwardRef<HTMLElement, Props>(
|
||||
({ title, buttonText, onButtonClick, onRemove, children, buttonContent, severity = 'error', ...restProps }) => {
|
||||
export const Alert: FC<Props> = React.forwardRef<HTMLDivElement, Props>(
|
||||
({ title, buttonText, onButtonClick, onRemove, children, buttonContent, severity = 'error', ...restProps }, ref) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme, severity, !!buttonContent);
|
||||
|
||||
return (
|
||||
<div className={styles.alert} aria-label={selectors.components.Alert.alert(severity)} {...restProps}>
|
||||
<div ref={ref} className={styles.alert} aria-label={selectors.components.Alert.alert(severity)} {...restProps}>
|
||||
<div className={styles.icon}>
|
||||
<Icon size="xl" name={getIconFromSeverity(severity) as IconName} />
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,11 @@ exports[`FormField should render component with custom inputEl 1`] = `
|
||||
<div
|
||||
className="form-field"
|
||||
>
|
||||
<Component
|
||||
<FormLabel
|
||||
width={11}
|
||||
>
|
||||
Test
|
||||
</Component>
|
||||
</FormLabel>
|
||||
<span>
|
||||
Input
|
||||
</span>
|
||||
@@ -22,11 +22,11 @@ exports[`FormField should render component with default inputEl 1`] = `
|
||||
<div
|
||||
className="form-field"
|
||||
>
|
||||
<Component
|
||||
<FormLabel
|
||||
width={11}
|
||||
>
|
||||
Test
|
||||
</Component>
|
||||
</FormLabel>
|
||||
<input
|
||||
className="gf-form-input width-12"
|
||||
onChange={[MockFunction]}
|
||||
|
||||
@@ -197,7 +197,7 @@ export const AsyncValidation = () => {
|
||||
|
||||
const validateAsync = (shouldPass: boolean) => async () => {
|
||||
try {
|
||||
await new Promise<ValidateResult>((resolve, reject) => {
|
||||
await new Promise<ValidateResult | void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (shouldPass) {
|
||||
resolve();
|
||||
|
||||
@@ -114,14 +114,15 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
}
|
||||
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
const scale = config.unit || '__fixed';
|
||||
const isNewScale = !builder.hasScale(scale);
|
||||
const scaleKey = config.unit || '__fixed';
|
||||
|
||||
if (isNewScale && customConfig.axisPlacement !== AxisPlacement.Hidden) {
|
||||
builder.addScale({ scaleKey: scale, min: field.config.min, max: field.config.max });
|
||||
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
|
||||
// The builder will manage unique scaleKeys and combine where appropriate
|
||||
builder.addScale({ scaleKey, min: field.config.min, max: field.config.max });
|
||||
builder.addAxis({
|
||||
scaleKey: scale,
|
||||
scaleKey,
|
||||
label: customConfig.axisLabel,
|
||||
size: customConfig.axisWidth,
|
||||
placement: customConfig.axisPlacement ?? AxisPlacement.Auto,
|
||||
formatValue: v => formattedValueToString(fmt(v)),
|
||||
theme,
|
||||
@@ -136,7 +137,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
const pointsMode = customConfig.mode === GraphMode.Points ? PointMode.Always : customConfig.points;
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: scale,
|
||||
scaleKey,
|
||||
mode: customConfig.mode!,
|
||||
lineColor: seriesColor,
|
||||
lineWidth: customConfig.lineWidth,
|
||||
@@ -149,7 +150,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
});
|
||||
|
||||
if (hasLegend.current) {
|
||||
const axisPlacement = builder.getAxisPlacement(scale);
|
||||
const axisPlacement = builder.getAxisPlacement(scaleKey);
|
||||
|
||||
legendItems.push({
|
||||
color: seriesColor,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { SelectOptions } from './types';
|
||||
import { SelectableOptGroup } from './types';
|
||||
|
||||
/**
|
||||
* Normalize the value format to SelectableValue[] | []. Only used for single select
|
||||
*/
|
||||
export const cleanValue = (value: any, options: SelectOptions): SelectableValue[] | [] => {
|
||||
export const cleanValue = (
|
||||
value: any,
|
||||
options: Array<SelectableValue | SelectableOptGroup | SelectableOptGroup[]>
|
||||
): SelectableValue[] | [] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(Boolean);
|
||||
}
|
||||
@@ -23,7 +26,10 @@ export const cleanValue = (value: any, options: SelectOptions): SelectableValue[
|
||||
/**
|
||||
* Find the label for a string|number value inside array of options or optgroups
|
||||
*/
|
||||
export const findSelectedValue = (value: string | number, options: SelectOptions): SelectableValue | null => {
|
||||
export const findSelectedValue = (
|
||||
value: string | number,
|
||||
options: Array<SelectableValue | SelectableOptGroup | SelectableOptGroup[]>
|
||||
): SelectableValue | null => {
|
||||
for (const option of options) {
|
||||
if ('options' in option) {
|
||||
let found = findSelectedValue(value, option.options);
|
||||
|
||||
@@ -13,7 +13,7 @@ interface DemoBoxProps {
|
||||
const DemoBox: FC<DemoBoxProps> = ({ bg, border, children }) => {
|
||||
const style = cx(
|
||||
css`
|
||||
padding: 16px 32px;
|
||||
padding: 32px 32px 16px 32px;
|
||||
background: ${bg};
|
||||
width: 100%;
|
||||
`,
|
||||
@@ -24,18 +24,7 @@ const DemoBox: FC<DemoBoxProps> = ({ bg, border, children }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={style}>
|
||||
<div
|
||||
className={css`
|
||||
padding-bottom: 16px;
|
||||
`}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return <div className={style}>{children}</div>;
|
||||
};
|
||||
|
||||
const DemoText: FC<{ color?: string; bold?: boolean; size?: number }> = ({ color, bold, size, children }) => {
|
||||
|
||||
+8
-8
@@ -57,7 +57,7 @@ exports[`TimePickerContent renders correctly in full screen 1`] = `
|
||||
}
|
||||
visible={false}
|
||||
/>
|
||||
<Component
|
||||
<TimeRangeList
|
||||
onSelect={[Function]}
|
||||
options={Array []}
|
||||
timeZone="utc"
|
||||
@@ -76,7 +76,7 @@ exports[`TimePickerContent renders correctly in full screen 1`] = `
|
||||
<div
|
||||
className="css-1ogeuxc"
|
||||
/>
|
||||
<Component
|
||||
<TimeRangeList
|
||||
onSelect={[Function]}
|
||||
options={Array []}
|
||||
timeZone="utc"
|
||||
@@ -94,7 +94,7 @@ exports[`TimePickerContent renders correctly in full screen 1`] = `
|
||||
/>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
<Component
|
||||
<TimePickerFooter
|
||||
onChangeTimeZone={[Function]}
|
||||
timeZone="utc"
|
||||
/>
|
||||
@@ -158,7 +158,7 @@ exports[`TimePickerContent renders correctly in narrow screen 1`] = `
|
||||
}
|
||||
visible={true}
|
||||
/>
|
||||
<Component
|
||||
<TimeRangeList
|
||||
onSelect={[Function]}
|
||||
options={Array []}
|
||||
timeZone="utc"
|
||||
@@ -177,7 +177,7 @@ exports[`TimePickerContent renders correctly in narrow screen 1`] = `
|
||||
<div
|
||||
className="css-1ogeuxc"
|
||||
/>
|
||||
<Component
|
||||
<TimeRangeList
|
||||
onSelect={[Function]}
|
||||
options={Array []}
|
||||
timeZone="utc"
|
||||
@@ -325,7 +325,7 @@ exports[`TimePickerContent renders recent absolute ranges correctly 1`] = `
|
||||
}
|
||||
visible={false}
|
||||
/>
|
||||
<Component
|
||||
<TimeRangeList
|
||||
onSelect={[Function]}
|
||||
options={Array []}
|
||||
timeZone="utc"
|
||||
@@ -344,7 +344,7 @@ exports[`TimePickerContent renders recent absolute ranges correctly 1`] = `
|
||||
<div
|
||||
className="css-1ogeuxc"
|
||||
/>
|
||||
<Component
|
||||
<TimeRangeList
|
||||
onSelect={[Function]}
|
||||
options={Array []}
|
||||
timeZone="utc"
|
||||
@@ -362,7 +362,7 @@ exports[`TimePickerContent renders recent absolute ranges correctly 1`] = `
|
||||
/>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
<Component
|
||||
<TimePickerFooter
|
||||
onChangeTimeZone={[Function]}
|
||||
timeZone="utc"
|
||||
/>
|
||||
|
||||
@@ -23,10 +23,6 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
props.config
|
||||
);
|
||||
const getPlotInstance = useCallback(() => {
|
||||
if (!plotInstance.current) {
|
||||
throw new Error("Plot hasn't initialised yet");
|
||||
}
|
||||
|
||||
return plotInstance.current;
|
||||
}, []);
|
||||
|
||||
@@ -72,13 +68,15 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
|
||||
// Memoize plot context
|
||||
const plotCtx = useMemo(() => {
|
||||
return buildPlotContext(Boolean(plotInstance.current), canvasRef, props.data, registerPlugin, getPlotInstance);
|
||||
return buildPlotContext(canvasRef, props.data, registerPlugin, getPlotInstance);
|
||||
}, [plotInstance, canvasRef, props.data, registerPlugin, getPlotInstance]);
|
||||
|
||||
return (
|
||||
<PlotContext.Provider value={plotCtx}>
|
||||
<div ref={plotCtx.canvasRef} data-testid="uplot-main-div" />
|
||||
{props.children}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div ref={plotCtx.canvasRef} data-testid="uplot-main-div" />
|
||||
{props.children}
|
||||
</div>
|
||||
</PlotContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,13 +3,14 @@ import uPlot, { Axis } from 'uplot';
|
||||
import { PlotConfigBuilder } from '../types';
|
||||
import { measureText } from '../../../utils/measureText';
|
||||
import { AxisPlacement } from '../config';
|
||||
import { optMinMax } from './UPlotScaleBuilder';
|
||||
|
||||
export interface AxisProps {
|
||||
scaleKey: string;
|
||||
theme: GrafanaTheme;
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
size?: number;
|
||||
size?: number | null;
|
||||
placement?: AxisPlacement;
|
||||
grid?: boolean;
|
||||
formatValue?: (v: any) => string;
|
||||
@@ -19,6 +20,16 @@ export interface AxisProps {
|
||||
}
|
||||
|
||||
export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
|
||||
merge(props: AxisProps) {
|
||||
this.props.size = optMinMax('max', this.props.size, props.size);
|
||||
if (!this.props.label) {
|
||||
this.props.label = props.label;
|
||||
}
|
||||
if (this.props.placement === AxisPlacement.Auto) {
|
||||
this.props.placement = props.placement;
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): Axis {
|
||||
const {
|
||||
scaleKey,
|
||||
@@ -42,7 +53,7 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
|
||||
side: getUPlotSideFromAxis(placement),
|
||||
font: `12px 'Roboto'`,
|
||||
labelFont: `12px 'Roboto'`,
|
||||
size: calculateAxisSize,
|
||||
size: this.props.size ?? calculateAxisSize,
|
||||
grid: {
|
||||
show: grid,
|
||||
stroke: gridColor,
|
||||
@@ -107,8 +118,7 @@ function calculateAxisSize(self: uPlot, values: string[], axisIdx: number) {
|
||||
}
|
||||
}
|
||||
|
||||
let axisWidth = measureText(maxLength, 12).width + 18;
|
||||
return axisWidth;
|
||||
return measureText(maxLength, 12).width + 18;
|
||||
}
|
||||
|
||||
/** Format time axis ticks */
|
||||
|
||||
@@ -61,7 +61,6 @@ describe('UPlotConfigBuilder', () => {
|
||||
formatValue: () => 'test value',
|
||||
grid: false,
|
||||
show: true,
|
||||
size: 1,
|
||||
theme: { isDark: true, palette: { gray25: '#ffffff' }, colors: { text: 'gray' } } as GrafanaTheme,
|
||||
values: [],
|
||||
});
|
||||
|
||||
@@ -8,13 +8,17 @@ export class UPlotConfigBuilder {
|
||||
private series: UPlotSeriesBuilder[] = [];
|
||||
private axes: Record<string, UPlotAxisBuilder> = {};
|
||||
private scales: UPlotScaleBuilder[] = [];
|
||||
private registeredScales: string[] = [];
|
||||
|
||||
hasLeftAxis = false;
|
||||
|
||||
addAxis(props: AxisProps) {
|
||||
props.placement = props.placement ?? AxisPlacement.Auto;
|
||||
|
||||
if (this.axes[props.scaleKey]) {
|
||||
this.axes[props.scaleKey].merge(props);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle auto placement logic
|
||||
if (props.placement === AxisPlacement.Auto) {
|
||||
props.placement = this.hasLeftAxis ? AxisPlacement.Right : AxisPlacement.Left;
|
||||
@@ -36,15 +40,16 @@ export class UPlotConfigBuilder {
|
||||
this.series.push(new UPlotSeriesBuilder(props));
|
||||
}
|
||||
|
||||
/** Add or update the scale with the scale key */
|
||||
addScale(props: ScaleProps) {
|
||||
this.registeredScales.push(props.scaleKey);
|
||||
const current = this.scales.find(v => v.props.scaleKey === props.scaleKey);
|
||||
if (current) {
|
||||
current.merge(props);
|
||||
return;
|
||||
}
|
||||
this.scales.push(new UPlotScaleBuilder(props));
|
||||
}
|
||||
|
||||
hasScale(scaleKey: string) {
|
||||
return this.registeredScales.indexOf(scaleKey) > -1;
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
const config: PlotSeriesConfig = { series: [{}] };
|
||||
config.axes = Object.values(this.axes).map(a => a.getConfig());
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { optMinMax } from './UPlotScaleBuilder';
|
||||
|
||||
describe('UPlotScaleBuilder', () => {
|
||||
it('opt min max', () => {
|
||||
expect(7).toEqual(optMinMax('min', null, 7));
|
||||
expect(7).toEqual(optMinMax('min', undefined, 7));
|
||||
expect(7).toEqual(optMinMax('min', 20, 7));
|
||||
|
||||
expect(7).toEqual(optMinMax('min', 7, null));
|
||||
expect(7).toEqual(optMinMax('min', 7, undefined));
|
||||
expect(7).toEqual(optMinMax('min', 7, 20));
|
||||
|
||||
expect(7).toEqual(optMinMax('max', null, 7));
|
||||
expect(7).toEqual(optMinMax('max', undefined, 7));
|
||||
expect(7).toEqual(optMinMax('max', 5, 7));
|
||||
|
||||
expect(7).toEqual(optMinMax('max', 7, null));
|
||||
expect(7).toEqual(optMinMax('max', 7, undefined));
|
||||
expect(7).toEqual(optMinMax('max', 7, 5));
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,11 @@ export interface ScaleProps {
|
||||
}
|
||||
|
||||
export class UPlotScaleBuilder extends PlotConfigBuilder<ScaleProps, Scale> {
|
||||
merge(props: ScaleProps) {
|
||||
this.props.min = optMinMax('min', this.props.min, props.min);
|
||||
this.props.max = optMinMax('max', this.props.max, props.max);
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
const { isTime, scaleKey } = this.props;
|
||||
if (isTime) {
|
||||
@@ -29,3 +34,18 @@ export class UPlotScaleBuilder extends PlotConfigBuilder<ScaleProps, Scale> {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function optMinMax(minmax: 'min' | 'max', a?: number | null, b?: number | null): undefined | number | null {
|
||||
const hasA = !(a === undefined || a === null);
|
||||
const hasB = !(b === undefined || b === null);
|
||||
if (hasA) {
|
||||
if (!hasB) {
|
||||
return a;
|
||||
}
|
||||
if (minmax === 'min') {
|
||||
return a! < b! ? a : b;
|
||||
}
|
||||
return a! > b! ? a : b;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ interface PlotPluginsContextType {
|
||||
}
|
||||
|
||||
interface PlotContextType extends PlotPluginsContextType {
|
||||
isPlotReady: boolean;
|
||||
getPlotInstance: () => uPlot;
|
||||
getPlotInstance: () => uPlot | undefined;
|
||||
getSeries: () => Series[];
|
||||
getCanvas: () => PlotCanvasContextType;
|
||||
canvasRef: any;
|
||||
@@ -127,28 +126,31 @@ export const usePlotData = (): PlotDataAPI => {
|
||||
};
|
||||
|
||||
export const buildPlotContext = (
|
||||
isPlotReady: boolean,
|
||||
canvasRef: any,
|
||||
data: AlignedFrameWithGapTest,
|
||||
registerPlugin: any,
|
||||
getPlotInstance: () => uPlot
|
||||
getPlotInstance: () => uPlot | undefined
|
||||
): PlotContextType => {
|
||||
return {
|
||||
isPlotReady,
|
||||
canvasRef,
|
||||
data,
|
||||
registerPlugin,
|
||||
getPlotInstance,
|
||||
getSeries: () => getPlotInstance().series,
|
||||
getCanvas: () => ({
|
||||
width: getPlotInstance().width,
|
||||
height: getPlotInstance().height,
|
||||
plot: {
|
||||
width: getPlotInstance().bbox.width / window.devicePixelRatio,
|
||||
height: getPlotInstance().bbox.height / window.devicePixelRatio,
|
||||
top: getPlotInstance().bbox.top / window.devicePixelRatio,
|
||||
left: getPlotInstance().bbox.left / window.devicePixelRatio,
|
||||
},
|
||||
}),
|
||||
getSeries: () => getPlotInstance()!.series,
|
||||
getCanvas: () => {
|
||||
const plotInstance = getPlotInstance()!;
|
||||
const bbox = plotInstance.bbox;
|
||||
const pxRatio = window.devicePixelRatio;
|
||||
return {
|
||||
width: plotInstance.width,
|
||||
height: plotInstance.height,
|
||||
plot: {
|
||||
width: bbox.width / pxRatio,
|
||||
height: bbox.height / pxRatio,
|
||||
top: bbox.top / pxRatio,
|
||||
left: bbox.left / pxRatio,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,8 +18,7 @@ export function EventsCanvas<T>({ id, events, renderEventMarker, mapEventToXYCoo
|
||||
|
||||
const eventMarkers = useMemo(() => {
|
||||
const markers: React.ReactNode[] = [];
|
||||
|
||||
if (!plotCtx.isPlotReady || events.length === 0) {
|
||||
if (!plotCtx.getPlotInstance() || events.length === 0) {
|
||||
return markers;
|
||||
}
|
||||
|
||||
@@ -41,9 +40,9 @@ export function EventsCanvas<T>({ id, events, renderEventMarker, mapEventToXYCoo
|
||||
}
|
||||
|
||||
return <>{markers}</>;
|
||||
}, [events, renderEventMarker, renderToken, plotCtx.isPlotReady]);
|
||||
}, [events, renderEventMarker, renderToken, plotCtx]);
|
||||
|
||||
if (!plotCtx.isPlotReady) {
|
||||
if (!plotCtx.getPlotInstance()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ interface XYCanvasProps {}
|
||||
*/
|
||||
export const XYCanvas: React.FC<XYCanvasProps> = ({ children }) => {
|
||||
const plotContext = usePlotContext();
|
||||
const plotInstance = plotContext.getPlotInstance();
|
||||
|
||||
if (!plotContext.isPlotReady) {
|
||||
if (!plotInstance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,8 +21,8 @@ export const XYCanvas: React.FC<XYCanvasProps> = ({ children }) => {
|
||||
className={css`
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
left: ${plotContext.getPlotInstance().bbox.left / window.devicePixelRatio}px;
|
||||
top: ${plotContext.getPlotInstance().bbox.top / window.devicePixelRatio}px;
|
||||
left: ${plotInstance.bbox.left / window.devicePixelRatio}px;
|
||||
top: ${plotInstance.bbox.top / window.devicePixelRatio}px;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDi
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!plotCtx.isPlotReady || !children || !selection) {
|
||||
if (!plotCtx.getPlotInstance() || !children || !selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
|
||||
return (
|
||||
<CursorPlugin id={pluginId}>
|
||||
{({ focusedSeriesIdx, focusedPointIdx, coords }) => {
|
||||
if (!plotContext.isPlotReady) {
|
||||
if (!plotContext.getPlotInstance()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export type IconName =
|
||||
| 'compass'
|
||||
| 'sliders-v-alt'
|
||||
| 'bell'
|
||||
| 'bell-slash'
|
||||
| 'database'
|
||||
| 'user'
|
||||
| 'camera'
|
||||
@@ -198,6 +199,7 @@ export const getAvailableIcons = (): IconName[] => [
|
||||
'compass',
|
||||
'sliders-v-alt',
|
||||
'bell',
|
||||
'bell-slash',
|
||||
'database',
|
||||
'user',
|
||||
'camera',
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"devDependencies": {
|
||||
"enzyme": "^3.8.0",
|
||||
"enzyme-adapter-react-16": "^1.2.0",
|
||||
"typescript": "4.0.2"
|
||||
"typescript": "4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/data": "7.4.0-pre.0",
|
||||
|
||||
@@ -132,6 +132,9 @@ func newNotFound() *Avatar {
|
||||
avatar := &Avatar{notFound: true}
|
||||
|
||||
// load user_profile png into buffer
|
||||
// It's safe to ignore gosec warning G304 since the variable part of the file path comes from a configuration
|
||||
// variable.
|
||||
// nolint:gosec
|
||||
path := filepath.Join(setting.StaticRootPath, "img", "user_profile.png")
|
||||
|
||||
if data, err := ioutil.ReadFile(path); err != nil {
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/dashdiffs"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
@@ -334,7 +333,7 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) Response {
|
||||
dashRedirect := dtos.DashboardRedirect{RedirectUri: url}
|
||||
return JSON(200, &dashRedirect)
|
||||
}
|
||||
log.Warnf("Failed to get slug from database, %s", err.Error())
|
||||
hs.log.Warn("Failed to get slug from database", "err", err)
|
||||
}
|
||||
|
||||
filePath := hs.Cfg.DefaultHomeDashboardPath
|
||||
@@ -342,11 +341,18 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) Response {
|
||||
filePath = filepath.Join(hs.Cfg.StaticRootPath, "dashboards/home.json")
|
||||
}
|
||||
|
||||
// It's safe to ignore gosec warning G304 since the variable part of the file path comes from a configuration
|
||||
// variable
|
||||
// nolint:gosec
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return Error(500, "Failed to load home dashboard", err)
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
hs.log.Warn("Failed to close dashboard file", "path", filePath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
dash := dtos.DashboardFullWithMeta{}
|
||||
dash.Meta.IsHome = true
|
||||
|
||||
@@ -291,6 +291,10 @@ func extractFile(file *zip.File, filePath string) (err error) {
|
||||
fileMode = os.FileMode(0755)
|
||||
}
|
||||
|
||||
// We can ignore the gosec G304 warning on this one, since the variable part of the file path stems
|
||||
// from command line flag "pluginsDir", and the only possible damage would be writing to the wrong directory.
|
||||
// If the user shouldn't be writing to this directory, they shouldn't have the permission in the file system.
|
||||
// nolint:gosec
|
||||
dst, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
|
||||
@@ -44,8 +44,11 @@ func (client *GrafanaComClient) GetPlugin(pluginId, repoUrl string) (models.Plug
|
||||
}
|
||||
|
||||
func (client *GrafanaComClient) DownloadFile(pluginName string, tmpFile *os.File, url string, checksum string) (err error) {
|
||||
// Try handling url like local file path first
|
||||
// Try handling URL as a local file path first
|
||||
if _, err := os.Stat(url); err == nil {
|
||||
// We can ignore this gosec G304 warning since `url` stems from command line flag "pluginUrl". If the
|
||||
// user shouldn't be able to read the file, it should be handled through filesystem permissions.
|
||||
// nolint:gosec
|
||||
f, err := os.Open(url)
|
||||
if err != nil {
|
||||
return errutil.Wrap("Failed to read plugin archive", err)
|
||||
|
||||
@@ -21,5 +21,9 @@ func (i IoUtilImp) ReadDir(path string) ([]os.FileInfo, error) {
|
||||
}
|
||||
|
||||
func (i IoUtilImp) ReadFile(filename string) ([]byte, error) {
|
||||
// We can ignore the gosec G304 warning on this one, since the variable part of the file path stems
|
||||
// from command line flag "pluginsDir". If the user shouldn't be reading from this directory, they shouldn't have
|
||||
// the permission in the file system.
|
||||
// nolint:gosec
|
||||
return ioutil.ReadFile(filename)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ func GetGrafanaPluginDir(currentOS string) string {
|
||||
return returnOsDefault(currentOS)
|
||||
}
|
||||
|
||||
// getGrafanaRoot tries to get root of directory when developing grafana ie repo root. It is not perfect it just
|
||||
// checks what is the binary path and tries to guess based on that but if it is not running in dev env you get a bogus
|
||||
// getGrafanaRoot tries to get root of directory when developing grafana, ie. repo root. It is not perfect, it just
|
||||
// checks what is the binary path and tries to guess based on that, but if it is not running in dev env you get a bogus
|
||||
// path back.
|
||||
func getGrafanaRoot() (string, error) {
|
||||
ex, err := os.Executable()
|
||||
|
||||
@@ -116,7 +116,11 @@ func executeServer(configFile, homePath, pidFile, packaging string, traceDiagnos
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
log.Error("Failed to write trace diagnostics", "path", traceDiagnostics.file, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := trace.Start(f); err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -45,11 +45,18 @@ func (az *AzureBlobUploader) Upload(ctx context.Context, imageDiskPath string) (
|
||||
// setup client
|
||||
blob := NewStorageClient(az.account_name, az.account_key)
|
||||
|
||||
// We can ignore the gosec G304 warning on this one because `imageDiskPath` comes
|
||||
// from alert notifiers and is only used to upload images generated by alerting.
|
||||
// nolint:gosec
|
||||
file, err := os.Open(imageDiskPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
az.log.Warn("Failed to close file", "path", imageDiskPath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
randomFileName, err := util.GetRandomString(30)
|
||||
if err != nil {
|
||||
|
||||
@@ -149,11 +149,19 @@ func (u *Uploader) uploadFile(
|
||||
key string,
|
||||
) error {
|
||||
u.log.Debug("Opening image file", "path", imageDiskPath)
|
||||
|
||||
// We can ignore the gosec G304 warning on this one because `imageDiskPath` comes
|
||||
// from alert notifiers and is only used to upload images generated by alerting.
|
||||
// nolint:gosec
|
||||
fileReader, err := os.Open(imageDiskPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fileReader.Close()
|
||||
defer func() {
|
||||
if err := fileReader.Close(); err != nil {
|
||||
u.log.Warn("Failed to close file", "err", err, "path", imageDiskPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set public access if not generating a signed URL
|
||||
pubAcc := !u.enableSignedURLs
|
||||
|
||||
@@ -76,16 +76,24 @@ func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string,
|
||||
key := u.path + rand + pngExt
|
||||
log.Debugf("Uploading image to s3. bucket = %s, path = %s", u.bucket, key)
|
||||
|
||||
// We can ignore the gosec G304 warning on this one because `imageDiskPath` comes
|
||||
// from alert notifiers and is only used to upload images generated by alerting.
|
||||
// nolint:gosec
|
||||
file, err := os.Open(imageDiskPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
u.log.Warn("Failed to close file", "path", imageDiskPath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sess, err = session.NewSession(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uploader := s3manager.NewUploader(sess)
|
||||
result, err := uploader.UploadWithContext(ctx, &s3manager.UploadInput{
|
||||
Bucket: aws.String(u.bucket),
|
||||
|
||||
@@ -45,7 +45,7 @@ func (u *WebdavUploader) PublicURL(filename string) string {
|
||||
return publicURL.String()
|
||||
}
|
||||
|
||||
func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) {
|
||||
func (u *WebdavUploader) Upload(ctx context.Context, imgToUpload string) (string, error) {
|
||||
url, _ := url.Parse(u.url)
|
||||
filename, err := util.GetRandomString(20)
|
||||
if err != nil {
|
||||
@@ -55,7 +55,10 @@ func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error)
|
||||
filename += pngExt
|
||||
url.Path = path.Join(url.Path, filename)
|
||||
|
||||
imgData, err := ioutil.ReadFile(pa)
|
||||
// We can ignore the gosec G304 warning on this one because `imgToUpload` comes
|
||||
// from alert notifiers and is only used to upload images generated by alerting.
|
||||
// nolint:gosec
|
||||
imgData, err := ioutil.ReadFile(imgToUpload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ func Warnf(format string, v ...interface{}) {
|
||||
Root.Warn(message)
|
||||
}
|
||||
|
||||
func Error(msg string, args ...interface{}) {
|
||||
Root.Error(msg, args...)
|
||||
}
|
||||
|
||||
func Errorf(skip int, format string, v ...interface{}) {
|
||||
Root.Error(fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
+78
-76
@@ -7,103 +7,105 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestMiddlewareAuth(t *testing.T) {
|
||||
Convey("Given the grafana middleware", t, func() {
|
||||
reqSignIn := Auth(&AuthOptions{ReqSignedIn: true})
|
||||
reqSignIn := Auth(&AuthOptions{ReqSignedIn: true})
|
||||
|
||||
middlewareScenario(t, "ReqSignIn true and unauthenticated request", func(sc *scenarioContext) {
|
||||
sc.m.Get("/secure", reqSignIn, sc.defaultHandler)
|
||||
middlewareScenario(t, "ReqSignIn true and unauthenticated request", func(t *testing.T, sc *scenarioContext) {
|
||||
sc.m.Get("/secure", reqSignIn, sc.defaultHandler)
|
||||
|
||||
sc.fakeReq("GET", "/secure").exec()
|
||||
sc.fakeReq("GET", "/secure").exec()
|
||||
|
||||
Convey("Should redirect to login", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 302)
|
||||
})
|
||||
assert.Equal(t, 302, sc.resp.Code)
|
||||
})
|
||||
|
||||
middlewareScenario(t, "ReqSignIn true and unauthenticated API request", func(t *testing.T, sc *scenarioContext) {
|
||||
sc.m.Get("/api/secure", reqSignIn, sc.defaultHandler)
|
||||
|
||||
sc.fakeReq("GET", "/api/secure").exec()
|
||||
|
||||
assert.Equal(t, 401, sc.resp.Code)
|
||||
})
|
||||
|
||||
t.Run("Anonymous auth enabled", func(t *testing.T) {
|
||||
const orgID int64 = 1
|
||||
|
||||
origEnabled := setting.AnonymousEnabled
|
||||
t.Cleanup(func() {
|
||||
setting.AnonymousEnabled = origEnabled
|
||||
})
|
||||
|
||||
middlewareScenario(t, "ReqSignIn true and unauthenticated API request", func(sc *scenarioContext) {
|
||||
sc.m.Get("/api/secure", reqSignIn, sc.defaultHandler)
|
||||
|
||||
sc.fakeReq("GET", "/api/secure").exec()
|
||||
|
||||
Convey("Should return 401", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 401)
|
||||
})
|
||||
origName := setting.AnonymousOrgName
|
||||
t.Cleanup(func() {
|
||||
setting.AnonymousOrgName = origName
|
||||
})
|
||||
setting.AnonymousEnabled = true
|
||||
setting.AnonymousOrgName = "test"
|
||||
|
||||
Convey("Anonymous auth enabled", func() {
|
||||
origEnabled := setting.AnonymousEnabled
|
||||
t.Cleanup(func() {
|
||||
setting.AnonymousEnabled = origEnabled
|
||||
})
|
||||
origName := setting.AnonymousOrgName
|
||||
t.Cleanup(func() {
|
||||
setting.AnonymousOrgName = origName
|
||||
})
|
||||
setting.AnonymousEnabled = true
|
||||
setting.AnonymousOrgName = "test"
|
||||
|
||||
middlewareScenario(t, "ReqSignIn true and request with forceLogin in query string", func(
|
||||
t *testing.T, sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error {
|
||||
query.Result = &models.Org{Id: 1, Name: "test"}
|
||||
query.Result = &models.Org{Id: orgID, Name: "test"}
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario(t, "ReqSignIn true and request with forceLogin in query string", func(sc *scenarioContext) {
|
||||
sc.m.Get("/secure", reqSignIn, sc.defaultHandler)
|
||||
sc.m.Get("/secure", reqSignIn, sc.defaultHandler)
|
||||
|
||||
sc.fakeReq("GET", "/secure?forceLogin=true").exec()
|
||||
sc.fakeReq("GET", "/secure?forceLogin=true").exec()
|
||||
|
||||
Convey("Should redirect to login", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 302)
|
||||
location, ok := sc.resp.Header()["Location"]
|
||||
So(ok, ShouldBeTrue)
|
||||
So(location[0], ShouldEqual, "/login")
|
||||
})
|
||||
})
|
||||
|
||||
middlewareScenario(t, "ReqSignIn true and request with same org provided in query string", func(sc *scenarioContext) {
|
||||
sc.m.Get("/secure", reqSignIn, sc.defaultHandler)
|
||||
|
||||
sc.fakeReq("GET", "/secure?orgId=1").exec()
|
||||
|
||||
Convey("Should not redirect to login", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
})
|
||||
|
||||
middlewareScenario(t, "ReqSignIn true and request with different org provided in query string", func(sc *scenarioContext) {
|
||||
sc.m.Get("/secure", reqSignIn, sc.defaultHandler)
|
||||
|
||||
sc.fakeReq("GET", "/secure?orgId=2").exec()
|
||||
|
||||
Convey("Should redirect to login", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 302)
|
||||
location, ok := sc.resp.Header()["Location"]
|
||||
So(ok, ShouldBeTrue)
|
||||
So(location[0], ShouldEqual, "/login")
|
||||
})
|
||||
})
|
||||
assert.Equal(t, 302, sc.resp.Code)
|
||||
location, ok := sc.resp.Header()["Location"]
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "/login", location[0])
|
||||
})
|
||||
|
||||
Convey("snapshot public mode or signed in", func() {
|
||||
middlewareScenario(t, "Snapshot public mode disabled and unauthenticated request should return 401", func(sc *scenarioContext) {
|
||||
sc.m.Get("/api/snapshot", SnapshotPublicModeOrSignedIn(), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/api/snapshot").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 401)
|
||||
middlewareScenario(t, "ReqSignIn true and request with same org provided in query string", func(
|
||||
t *testing.T, sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error {
|
||||
query.Result = &models.Org{Id: orgID, Name: "test"}
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario(t, "Snapshot public mode enabled and unauthenticated request should return 200", func(sc *scenarioContext) {
|
||||
setting.SnapshotPublicMode = true
|
||||
sc.m.Get("/api/snapshot", SnapshotPublicModeOrSignedIn(), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/api/snapshot").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
sc.m.Get("/secure", reqSignIn, sc.defaultHandler)
|
||||
|
||||
sc.fakeReq("GET", fmt.Sprintf("/secure?orgId=%d", orgID)).exec()
|
||||
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
|
||||
middlewareScenario(t, "ReqSignIn true and request with different org provided in query string", func(
|
||||
t *testing.T, sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error {
|
||||
query.Result = &models.Org{Id: orgID, Name: "test"}
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.m.Get("/secure", reqSignIn, sc.defaultHandler)
|
||||
|
||||
sc.fakeReq("GET", "/secure?orgId=2").exec()
|
||||
|
||||
assert.Equal(t, 302, sc.resp.Code)
|
||||
location, ok := sc.resp.Header()["Location"]
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "/login", location[0])
|
||||
})
|
||||
})
|
||||
|
||||
middlewareScenario(t, "Snapshot public mode disabled and unauthenticated request should return 401", func(
|
||||
t *testing.T, sc *scenarioContext) {
|
||||
sc.m.Get("/api/snapshot", SnapshotPublicModeOrSignedIn(), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/api/snapshot").exec()
|
||||
assert.Equal(t, 401, sc.resp.Code)
|
||||
})
|
||||
|
||||
middlewareScenario(t, "Snapshot public mode enabled and unauthenticated request should return 200", func(
|
||||
t *testing.T, sc *scenarioContext) {
|
||||
setting.SnapshotPublicMode = true
|
||||
sc.m.Get("/api/snapshot", SnapshotPublicModeOrSignedIn(), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/api/snapshot").exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMiddlewareDashboardRedirect(t *testing.T) {
|
||||
@@ -22,62 +24,57 @@ func TestMiddlewareDashboardRedirect(t *testing.T) {
|
||||
fakeDash.HasAcl = false
|
||||
fakeDash.Uid = util.GenerateShortUID()
|
||||
|
||||
bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
|
||||
query.Result = fakeDash
|
||||
return nil
|
||||
})
|
||||
middlewareScenario(t, "GET dashboard by legacy url", func(t *testing.T, sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
|
||||
query.Result = fakeDash
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario(t, "GET dashboard by legacy url", func(sc *scenarioContext) {
|
||||
sc.m.Get("/dashboard/db/:slug", redirectFromLegacyDashboardUrl, sc.defaultHandler)
|
||||
|
||||
sc.fakeReqWithParams("GET", "/dashboard/db/dash?orgId=1&panelId=2", map[string]string{}).exec()
|
||||
|
||||
Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 301)
|
||||
resp := sc.resp.Result()
|
||||
defer resp.Body.Close()
|
||||
redirectURL, err := resp.Location()
|
||||
So(err, ShouldBeNil)
|
||||
So(redirectURL.Path, ShouldEqual, models.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug))
|
||||
So(len(redirectURL.Query()), ShouldEqual, 2)
|
||||
})
|
||||
assert.Equal(t, 301, sc.resp.Code)
|
||||
resp := sc.resp.Result()
|
||||
resp.Body.Close()
|
||||
redirectURL, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug), redirectURL.Path)
|
||||
assert.Equal(t, 2, len(redirectURL.Query()))
|
||||
})
|
||||
|
||||
middlewareScenario(t, "GET dashboard solo by legacy url", func(sc *scenarioContext) {
|
||||
middlewareScenario(t, "GET dashboard solo by legacy url", func(t *testing.T, sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
|
||||
query.Result = fakeDash
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.m.Get("/dashboard-solo/db/:slug", redirectFromLegacyDashboardSoloUrl, sc.defaultHandler)
|
||||
|
||||
sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash?orgId=1&panelId=2", map[string]string{}).exec()
|
||||
|
||||
Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 301)
|
||||
resp := sc.resp.Result()
|
||||
defer resp.Body.Close()
|
||||
redirectURL, err := resp.Location()
|
||||
So(err, ShouldBeNil)
|
||||
expectedURL := models.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)
|
||||
expectedURL = strings.Replace(expectedURL, "/d/", "/d-solo/", 1)
|
||||
So(redirectURL.Path, ShouldEqual, expectedURL)
|
||||
So(len(redirectURL.Query()), ShouldEqual, 2)
|
||||
})
|
||||
assert.Equal(t, 301, sc.resp.Code)
|
||||
resp := sc.resp.Result()
|
||||
resp.Body.Close()
|
||||
redirectURL, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
expectedURL := models.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)
|
||||
expectedURL = strings.Replace(expectedURL, "/d/", "/d-solo/", 1)
|
||||
assert.Equal(t, expectedURL, redirectURL.Path)
|
||||
assert.Equal(t, 2, len(redirectURL.Query()))
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given the dashboard legacy edit panel middleware", t, func() {
|
||||
bus.ClearBusHandlers()
|
||||
middlewareScenario(t, "GET dashboard by legacy edit url", func(t *testing.T, sc *scenarioContext) {
|
||||
sc.m.Get("/d/:uid/:slug", RedirectFromLegacyPanelEditURL(), sc.defaultHandler)
|
||||
|
||||
middlewareScenario(t, "GET dashboard by legacy edit url", func(sc *scenarioContext) {
|
||||
sc.m.Get("/d/:uid/:slug", RedirectFromLegacyPanelEditURL(), sc.defaultHandler)
|
||||
sc.fakeReqWithParams("GET", "/d/asd/dash?orgId=1&panelId=12&fullscreen&edit", map[string]string{}).exec()
|
||||
|
||||
sc.fakeReqWithParams("GET", "/d/asd/dash?orgId=1&panelId=12&fullscreen&edit", map[string]string{}).exec()
|
||||
|
||||
Convey("Should redirect to new dashboard edit url with a 301 Moved Permanently", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 301)
|
||||
resp := sc.resp.Result()
|
||||
defer resp.Body.Close()
|
||||
redirectURL, err := resp.Location()
|
||||
So(err, ShouldBeNil)
|
||||
So(redirectURL.String(), ShouldEqual, "/d/asd/d/asd/dash?editPanel=12&orgId=1")
|
||||
})
|
||||
})
|
||||
assert.Equal(t, 301, sc.resp.Code)
|
||||
resp := sc.resp.Result()
|
||||
resp.Body.Close()
|
||||
redirectURL, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/d/asd/d/asd/dash?editPanel=12&orgId=1", redirectURL.String())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,10 +54,13 @@ func GetContextHandler(
|
||||
Logger: log.New("context"),
|
||||
}
|
||||
|
||||
orgId := int64(0)
|
||||
orgIdHeader := ctx.Req.Header.Get("X-Grafana-Org-Id")
|
||||
if orgIdHeader != "" {
|
||||
orgId, _ = strconv.ParseInt(orgIdHeader, 10, 64)
|
||||
orgID := int64(0)
|
||||
orgIDHeader := ctx.Req.Header.Get("X-Grafana-Org-Id")
|
||||
if orgIDHeader != "" {
|
||||
orgIDParsed, err := strconv.ParseInt(orgIDHeader, 10, 64)
|
||||
if err == nil {
|
||||
orgID = orgIDParsed
|
||||
}
|
||||
}
|
||||
|
||||
// the order in which these are tested are important
|
||||
@@ -68,9 +71,9 @@ func GetContextHandler(
|
||||
switch {
|
||||
case initContextWithRenderAuth(ctx, renderService):
|
||||
case initContextWithApiKey(ctx):
|
||||
case initContextWithBasicAuth(ctx, orgId):
|
||||
case initContextWithAuthProxy(remoteCache, ctx, orgId):
|
||||
case initContextWithToken(ats, ctx, orgId):
|
||||
case initContextWithBasicAuth(ctx, orgID):
|
||||
case initContextWithAuthProxy(remoteCache, ctx, orgID):
|
||||
case initContextWithToken(ats, ctx, orgID):
|
||||
case initContextWithAnonymousUser(ctx):
|
||||
}
|
||||
|
||||
|
||||
@@ -4,149 +4,136 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
authLogin "github.com/grafana/grafana/pkg/login"
|
||||
"github.com/grafana/grafana/pkg/login"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMiddlewareBasicAuth(t *testing.T) {
|
||||
Convey("Given the basic auth", t, func() {
|
||||
var oldBasicAuthEnabled = setting.BasicAuthEnabled
|
||||
var oldDisableBruteForceLoginProtection = setting.DisableBruteForceLoginProtection
|
||||
var id int64 = 12
|
||||
var origBasicAuthEnabled = setting.BasicAuthEnabled
|
||||
var origDisableBruteForceLoginProtection = setting.DisableBruteForceLoginProtection
|
||||
t.Cleanup(func() {
|
||||
setting.BasicAuthEnabled = origBasicAuthEnabled
|
||||
setting.DisableBruteForceLoginProtection = origDisableBruteForceLoginProtection
|
||||
})
|
||||
setting.BasicAuthEnabled = true
|
||||
setting.DisableBruteForceLoginProtection = true
|
||||
|
||||
Convey("Setup", func() {
|
||||
setting.BasicAuthEnabled = true
|
||||
setting.DisableBruteForceLoginProtection = true
|
||||
bus.ClearBusHandlers()
|
||||
bus.ClearBusHandlers()
|
||||
|
||||
const id int64 = 12
|
||||
|
||||
middlewareScenario(t, "Valid API key", func(t *testing.T, sc *scenarioContext) {
|
||||
const orgID int64 = 2
|
||||
keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd")
|
||||
require.NoError(t, err)
|
||||
|
||||
bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error {
|
||||
query.Result = &models.ApiKey{OrgId: orgID, Role: models.ROLE_EDITOR, Key: keyhash}
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario(t, "Valid API key", func(sc *scenarioContext) {
|
||||
var orgID int64 = 2
|
||||
keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd")
|
||||
So(err, ShouldBeNil)
|
||||
authHeader := util.GetBasicAuthHeader("api_key", "eyJrIjoidjVuQXdwTWFmRlA2em5hUzR1cmhkV0RMUzU1MTFNNDIiLCJuIjoiYXNkIiwiaWQiOjF9")
|
||||
sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec()
|
||||
|
||||
bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error {
|
||||
query.Result = &models.ApiKey{OrgId: orgID, Role: models.ROLE_EDITOR, Key: keyhash}
|
||||
return nil
|
||||
})
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
assert.True(t, sc.context.IsSignedIn)
|
||||
assert.Equal(t, orgID, sc.context.OrgId)
|
||||
assert.Equal(t, models.ROLE_EDITOR, sc.context.OrgRole)
|
||||
})
|
||||
|
||||
authHeader := util.GetBasicAuthHeader("api_key", "eyJrIjoidjVuQXdwTWFmRlA2em5hUzR1cmhkV0RMUzU1MTFNNDIiLCJuIjoiYXNkIiwiaWQiOjF9")
|
||||
sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec()
|
||||
middlewareScenario(t, "Handle auth", func(t *testing.T, sc *scenarioContext) {
|
||||
const password = "MyPass"
|
||||
const salt = "Salt"
|
||||
const orgID int64 = 2
|
||||
|
||||
Convey("Should return 200", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
t.Cleanup(bus.ClearBusHandlers)
|
||||
|
||||
Convey("Should init middleware context", func() {
|
||||
So(sc.context.IsSignedIn, ShouldEqual, true)
|
||||
So(sc.context.OrgId, ShouldEqual, orgID)
|
||||
So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR)
|
||||
})
|
||||
bus.AddHandler("grafana-auth", func(query *models.LoginUserQuery) error {
|
||||
encoded, err := util.EncodePassword(password, salt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.User = &models.User{
|
||||
Password: encoded,
|
||||
Salt: salt,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario(t, "Handle auth", func(sc *scenarioContext) {
|
||||
var password = "MyPass"
|
||||
var salt = "Salt"
|
||||
var orgID int64 = 2
|
||||
|
||||
bus.AddHandler("grafana-auth", func(query *models.LoginUserQuery) error {
|
||||
encoded, err := util.EncodePassword(password, salt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.User = &models.User{
|
||||
Password: encoded,
|
||||
Salt: salt,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("get-sign-user", func(query *models.GetSignedInUserQuery) error {
|
||||
query.Result = &models.SignedInUser{OrgId: orgID, UserId: id}
|
||||
return nil
|
||||
})
|
||||
|
||||
authHeader := util.GetBasicAuthHeader("myUser", password)
|
||||
sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec()
|
||||
|
||||
Convey("Should init middleware context with users", func() {
|
||||
So(sc.context.IsSignedIn, ShouldEqual, true)
|
||||
So(sc.context.OrgId, ShouldEqual, orgID)
|
||||
So(sc.context.UserId, ShouldEqual, id)
|
||||
})
|
||||
|
||||
bus.ClearBusHandlers()
|
||||
bus.AddHandler("get-sign-user", func(query *models.GetSignedInUserQuery) error {
|
||||
query.Result = &models.SignedInUser{OrgId: orgID, UserId: id}
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario(t, "Auth sequence", func(sc *scenarioContext) {
|
||||
var password = "MyPass"
|
||||
var salt = "Salt"
|
||||
authHeader := util.GetBasicAuthHeader("myUser", password)
|
||||
sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec()
|
||||
|
||||
authLogin.Init()
|
||||
assert.True(t, sc.context.IsSignedIn)
|
||||
assert.Equal(t, orgID, sc.context.OrgId)
|
||||
assert.Equal(t, id, sc.context.UserId)
|
||||
})
|
||||
|
||||
bus.AddHandler("user-query", func(query *models.GetUserByLoginQuery) error {
|
||||
encoded, err := util.EncodePassword(password, salt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.Result = &models.User{
|
||||
Password: encoded,
|
||||
Id: id,
|
||||
Salt: salt,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
middlewareScenario(t, "Auth sequence", func(t *testing.T, sc *scenarioContext) {
|
||||
const password = "MyPass"
|
||||
const salt = "Salt"
|
||||
|
||||
bus.AddHandler("get-sign-user", func(query *models.GetSignedInUserQuery) error {
|
||||
query.Result = &models.SignedInUser{UserId: query.UserId}
|
||||
return nil
|
||||
})
|
||||
login.Init()
|
||||
|
||||
authHeader := util.GetBasicAuthHeader("myUser", password)
|
||||
sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec()
|
||||
|
||||
Convey("Should init middleware context with user", func() {
|
||||
So(sc.context.IsSignedIn, ShouldEqual, true)
|
||||
So(sc.context.UserId, ShouldEqual, id)
|
||||
})
|
||||
bus.AddHandler("user-query", func(query *models.GetUserByLoginQuery) error {
|
||||
encoded, err := util.EncodePassword(password, salt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.Result = &models.User{
|
||||
Password: encoded,
|
||||
Id: id,
|
||||
Salt: salt,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario(t, "Should return error if user is not found", func(sc *scenarioContext) {
|
||||
sc.fakeReq("GET", "/")
|
||||
sc.req.SetBasicAuth("user", "password")
|
||||
sc.exec()
|
||||
|
||||
err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 401)
|
||||
So(sc.respJson["message"], ShouldEqual, errStringInvalidUsernamePassword)
|
||||
bus.AddHandler("get-sign-user", func(query *models.GetSignedInUserQuery) error {
|
||||
query.Result = &models.SignedInUser{UserId: query.UserId}
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario(t, "Should return error if user & password do not match", func(sc *scenarioContext) {
|
||||
bus.AddHandler("user-query", func(loginUserQuery *models.GetUserByLoginQuery) error {
|
||||
return nil
|
||||
})
|
||||
authHeader := util.GetBasicAuthHeader("myUser", password)
|
||||
sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec()
|
||||
|
||||
sc.fakeReq("GET", "/")
|
||||
sc.req.SetBasicAuth("killa", "gorilla")
|
||||
sc.exec()
|
||||
assert.True(t, sc.context.IsSignedIn)
|
||||
assert.Equal(t, id, sc.context.UserId)
|
||||
})
|
||||
|
||||
err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson)
|
||||
So(err, ShouldNotBeNil)
|
||||
middlewareScenario(t, "Should return error if user is not found", func(t *testing.T, sc *scenarioContext) {
|
||||
sc.fakeReq("GET", "/")
|
||||
sc.req.SetBasicAuth("user", "password")
|
||||
sc.exec()
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 401)
|
||||
So(sc.respJson["message"], ShouldEqual, errStringInvalidUsernamePassword)
|
||||
err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson)
|
||||
require.Error(t, err)
|
||||
|
||||
assert.Equal(t, 401, sc.resp.Code)
|
||||
assert.Equal(t, errStringInvalidUsernamePassword, sc.respJson["message"])
|
||||
})
|
||||
|
||||
middlewareScenario(t, "Should return error if user & password do not match", func(t *testing.T, sc *scenarioContext) {
|
||||
bus.AddHandler("user-query", func(loginUserQuery *models.GetUserByLoginQuery) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("Destroy", func() {
|
||||
setting.BasicAuthEnabled = oldBasicAuthEnabled
|
||||
setting.DisableBruteForceLoginProtection = oldDisableBruteForceLoginProtection
|
||||
})
|
||||
sc.fakeReq("GET", "/")
|
||||
sc.req.SetBasicAuth("killa", "gorilla")
|
||||
sc.exec()
|
||||
|
||||
err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson)
|
||||
require.Error(t, err)
|
||||
|
||||
assert.Equal(t, 401, sc.resp.Code)
|
||||
assert.Equal(t, errStringInvalidUsernamePassword, sc.respJson["message"])
|
||||
})
|
||||
}
|
||||
|
||||
+496
-414
File diff suppressed because it is too large
Load Diff
@@ -7,61 +7,55 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestOrgRedirectMiddleware(t *testing.T) {
|
||||
Convey("Can redirect to correct org", t, func() {
|
||||
middlewareScenario(t, "when setting a correct org for the user", func(sc *scenarioContext) {
|
||||
sc.withTokenSessionCookie("token")
|
||||
bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
|
||||
query.Result = &models.SignedInUser{OrgId: 1, UserId: 12}
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
|
||||
return &models.UserToken{
|
||||
UserId: 0,
|
||||
UnhashedToken: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
sc.m.Get("/", sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/?orgId=3").exec()
|
||||
|
||||
Convey("change org and redirect", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 302)
|
||||
})
|
||||
middlewareScenario(t, "when setting a correct org for the user", func(t *testing.T, sc *scenarioContext) {
|
||||
sc.withTokenSessionCookie("token")
|
||||
bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario(t, "when setting an invalid org for user", func(sc *scenarioContext) {
|
||||
sc.withTokenSessionCookie("token")
|
||||
bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error {
|
||||
return fmt.Errorf("")
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
|
||||
query.Result = &models.SignedInUser{OrgId: 1, UserId: 12}
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
|
||||
return &models.UserToken{
|
||||
UserId: 12,
|
||||
UnhashedToken: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
sc.m.Get("/", sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/?orgId=3").exec()
|
||||
|
||||
Convey("not allowed to change org", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 404)
|
||||
})
|
||||
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
|
||||
query.Result = &models.SignedInUser{OrgId: 1, UserId: 12}
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
|
||||
return &models.UserToken{
|
||||
UserId: 0,
|
||||
UnhashedToken: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
sc.m.Get("/", sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/?orgId=3").exec()
|
||||
|
||||
assert.Equal(t, 302, sc.resp.Code)
|
||||
})
|
||||
|
||||
middlewareScenario(t, "when setting an invalid org for user", func(t *testing.T, sc *scenarioContext) {
|
||||
sc.withTokenSessionCookie("token")
|
||||
bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error {
|
||||
return fmt.Errorf("")
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
|
||||
query.Result = &models.SignedInUser{OrgId: 1, UserId: 12}
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
|
||||
return &models.UserToken{
|
||||
UserId: 12,
|
||||
UnhashedToken: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
sc.m.Get("/", sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/?orgId=3").exec()
|
||||
|
||||
assert.Equal(t, 404, sc.resp.Code)
|
||||
})
|
||||
}
|
||||
|
||||
+145
-114
@@ -9,40 +9,40 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMiddlewareQuota(t *testing.T) {
|
||||
Convey("Given the grafana quota middleware", t, func() {
|
||||
setting.AnonymousEnabled = false
|
||||
setting.Quota = setting.QuotaSettings{
|
||||
Enabled: true,
|
||||
Org: &setting.OrgQuota{
|
||||
User: 5,
|
||||
Dashboard: 5,
|
||||
DataSource: 5,
|
||||
ApiKey: 5,
|
||||
},
|
||||
User: &setting.UserQuota{
|
||||
Org: 5,
|
||||
},
|
||||
Global: &setting.GlobalQuota{
|
||||
Org: 5,
|
||||
User: 5,
|
||||
Dashboard: 5,
|
||||
DataSource: 5,
|
||||
ApiKey: 5,
|
||||
Session: 5,
|
||||
},
|
||||
}
|
||||
setting.AnonymousEnabled = false
|
||||
setting.Quota = setting.QuotaSettings{
|
||||
Enabled: true,
|
||||
Org: &setting.OrgQuota{
|
||||
User: 5,
|
||||
Dashboard: 5,
|
||||
DataSource: 5,
|
||||
ApiKey: 5,
|
||||
},
|
||||
User: &setting.UserQuota{
|
||||
Org: 5,
|
||||
},
|
||||
Global: &setting.GlobalQuota{
|
||||
Org: 5,
|
||||
User: 5,
|
||||
Dashboard: 5,
|
||||
DataSource: 5,
|
||||
ApiKey: 5,
|
||||
Session: 5,
|
||||
},
|
||||
}
|
||||
|
||||
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
|
||||
qs := "a.QuotaService{
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
}
|
||||
QuotaFn := Quota(qs)
|
||||
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
|
||||
qs := "a.QuotaService{
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
}
|
||||
quotaFn := Quota(qs)
|
||||
|
||||
middlewareScenario(t, "with user not logged in", func(sc *scenarioContext) {
|
||||
t.Run("With user not logged in", func(t *testing.T) {
|
||||
middlewareScenario(t, "and global quota not reached", func(t *testing.T, sc *scenarioContext) {
|
||||
bus.AddHandler("globalQuota", func(query *models.GetGlobalQuotaByTargetQuery) error {
|
||||
query.Result = &models.GlobalQuotaDTO{
|
||||
Target: query.Target,
|
||||
@@ -52,48 +52,12 @@ func TestMiddlewareQuota(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("global quota not reached", func() {
|
||||
sc.m.Get("/user", QuotaFn("user"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/user").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
|
||||
Convey("global quota reached", func() {
|
||||
setting.Quota.Global.User = 4
|
||||
sc.m.Get("/user", QuotaFn("user"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/user").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
|
||||
Convey("global session quota not reached", func() {
|
||||
setting.Quota.Global.Session = 10
|
||||
sc.m.Get("/user", QuotaFn("session"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/user").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
|
||||
Convey("global session quota reached", func() {
|
||||
setting.Quota.Global.Session = 1
|
||||
sc.m.Get("/user", QuotaFn("session"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/user").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
sc.m.Get("/user", quotaFn("user"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/user").exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
|
||||
middlewareScenario(t, "with user logged in", func(sc *scenarioContext) {
|
||||
sc.withTokenSessionCookie("token")
|
||||
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
|
||||
query.Result = &models.SignedInUser{OrgId: 2, UserId: 12}
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
|
||||
return &models.UserToken{
|
||||
UserId: 12,
|
||||
UnhashedToken: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
middlewareScenario(t, "and global quota reached", func(t *testing.T, sc *scenarioContext) {
|
||||
bus.AddHandler("globalQuota", func(query *models.GetGlobalQuotaByTargetQuery) error {
|
||||
query.Result = &models.GlobalQuotaDTO{
|
||||
Target: query.Target,
|
||||
@@ -103,8 +67,20 @@ func TestMiddlewareQuota(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("userQuota", func(query *models.GetUserQuotaByTargetQuery) error {
|
||||
query.Result = &models.UserQuotaDTO{
|
||||
origUser := setting.Quota.Global.User
|
||||
t.Cleanup(func() {
|
||||
setting.Quota.Global.User = origUser
|
||||
})
|
||||
setting.Quota.Global.User = 4
|
||||
|
||||
sc.m.Get("/user", quotaFn("user"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/user").exec()
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
|
||||
middlewareScenario(t, "and global session quota not reached", func(t *testing.T, sc *scenarioContext) {
|
||||
bus.AddHandler("globalQuota", func(query *models.GetGlobalQuotaByTargetQuery) error {
|
||||
query.Result = &models.GlobalQuotaDTO{
|
||||
Target: query.Target,
|
||||
Limit: query.Default,
|
||||
Used: 4,
|
||||
@@ -112,57 +88,112 @@ func TestMiddlewareQuota(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("orgQuota", func(query *models.GetOrgQuotaByTargetQuery) error {
|
||||
query.Result = &models.OrgQuotaDTO{
|
||||
Target: query.Target,
|
||||
Limit: query.Default,
|
||||
Used: 4,
|
||||
}
|
||||
return nil
|
||||
origSession := setting.Quota.Global.Session
|
||||
t.Cleanup(func() {
|
||||
setting.Quota.Global.Session = origSession
|
||||
})
|
||||
setting.Quota.Global.Session = 10
|
||||
|
||||
Convey("global datasource quota reached", func() {
|
||||
setting.Quota.Global.DataSource = 4
|
||||
sc.m.Get("/ds", QuotaFn("data_source"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/ds").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
sc.m.Get("/user", quotaFn("session"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/user").exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
|
||||
Convey("user Org quota not reached", func() {
|
||||
setting.Quota.User.Org = 5
|
||||
sc.m.Get("/org", QuotaFn("org"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/org").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
middlewareScenario(t, "and global session quota reached", func(t *testing.T, sc *scenarioContext) {
|
||||
origSession := setting.Quota.Global.Session
|
||||
t.Cleanup(func() {
|
||||
setting.Quota.Global.Session = origSession
|
||||
})
|
||||
setting.Quota.Global.Session = 1
|
||||
|
||||
Convey("user Org quota reached", func() {
|
||||
setting.Quota.User.Org = 4
|
||||
sc.m.Get("/org", QuotaFn("org"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/org").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
sc.m.Get("/user", quotaFn("session"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/user").exec()
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("org dashboard quota not reached", func() {
|
||||
setting.Quota.Org.Dashboard = 10
|
||||
sc.m.Get("/dashboard", QuotaFn("dashboard"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/dashboard").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
middlewareScenario(t, "with user logged in", func(t *testing.T, sc *scenarioContext) {
|
||||
sc.withTokenSessionCookie("token")
|
||||
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
|
||||
query.Result = &models.SignedInUser{OrgId: 2, UserId: 12}
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("org dashboard quota reached", func() {
|
||||
setting.Quota.Org.Dashboard = 4
|
||||
sc.m.Get("/dashboard", QuotaFn("dashboard"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/dashboard").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
|
||||
return &models.UserToken{
|
||||
UserId: 12,
|
||||
UnhashedToken: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
Convey("org dashboard quota reached but quotas disabled", func() {
|
||||
setting.Quota.Org.Dashboard = 4
|
||||
setting.Quota.Enabled = false
|
||||
sc.m.Get("/dashboard", QuotaFn("dashboard"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/dashboard").exec()
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
bus.AddHandler("globalQuota", func(query *models.GetGlobalQuotaByTargetQuery) error {
|
||||
query.Result = &models.GlobalQuotaDTO{
|
||||
Target: query.Target,
|
||||
Limit: query.Default,
|
||||
Used: 4,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("userQuota", func(query *models.GetUserQuotaByTargetQuery) error {
|
||||
query.Result = &models.UserQuotaDTO{
|
||||
Target: query.Target,
|
||||
Limit: query.Default,
|
||||
Used: 4,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("orgQuota", func(query *models.GetOrgQuotaByTargetQuery) error {
|
||||
query.Result = &models.OrgQuotaDTO{
|
||||
Target: query.Target,
|
||||
Limit: query.Default,
|
||||
Used: 4,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
t.Run("global datasource quota reached", func(t *testing.T) {
|
||||
setting.Quota.Global.DataSource = 4
|
||||
sc.m.Get("/ds", quotaFn("data_source"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/ds").exec()
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
|
||||
t.Run("user Org quota not reached", func(t *testing.T) {
|
||||
setting.Quota.User.Org = 5
|
||||
sc.m.Get("/org", quotaFn("org"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/org").exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
|
||||
t.Run("user Org quota reached", func(t *testing.T) {
|
||||
setting.Quota.User.Org = 4
|
||||
sc.m.Get("/org", quotaFn("org"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/org").exec()
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
|
||||
t.Run("org dashboard quota not reached", func(t *testing.T) {
|
||||
setting.Quota.Org.Dashboard = 10
|
||||
sc.m.Get("/dashboard", quotaFn("dashboard"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/dashboard").exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
|
||||
t.Run("org dashboard quota reached", func(t *testing.T) {
|
||||
setting.Quota.Org.Dashboard = 4
|
||||
sc.m.Get("/dashboard", quotaFn("dashboard"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/dashboard").exec()
|
||||
assert.Equal(t, 403, sc.resp.Code)
|
||||
})
|
||||
|
||||
t.Run("org dashboard quota reached but quotas disabled", func(t *testing.T) {
|
||||
setting.Quota.Org.Dashboard = 4
|
||||
setting.Quota.Enabled = false
|
||||
sc.m.Get("/dashboard", quotaFn("dashboard"), sc.defaultHandler)
|
||||
sc.fakeReq("GET", "/dashboard").exec()
|
||||
assert.Equal(t, 200, sc.resp.Code)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ func stack(skip int) []byte {
|
||||
// Print this much at least. If we can't find the source, it won't show.
|
||||
fmt.Fprintf(buf, "%s:%d (0x%x)\n", file, line, pc)
|
||||
if file != lastFile {
|
||||
// We can ignore the gosec G304 warning on this one because `file`
|
||||
// comes from the runtime.Caller() function.
|
||||
// nolint:gosec
|
||||
data, err := ioutil.ReadFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
@@ -9,52 +10,55 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
macaron "gopkg.in/macaron.v1"
|
||||
)
|
||||
|
||||
func TestRecoveryMiddleware(t *testing.T) {
|
||||
setting.ErrTemplateName = "error-template"
|
||||
|
||||
Convey("Given an api route that panics", t, func() {
|
||||
t.Run("Given an API route that panics", func(t *testing.T) {
|
||||
apiURL := "/api/whatever"
|
||||
recoveryScenario(t, "recovery middleware should return json", apiURL, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = PanicHandler
|
||||
recoveryScenario(t, "recovery middleware should return json", apiURL, func(t *testing.T, sc *scenarioContext) {
|
||||
sc.handlerFunc = panicHandler
|
||||
sc.fakeReq("GET", apiURL).exec()
|
||||
sc.req.Header.Add("content-type", "application/json")
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 500)
|
||||
So(sc.respJson["message"], ShouldStartWith, "Internal Server Error - Check the Grafana server logs for the detailed error message.")
|
||||
So(sc.respJson["error"], ShouldStartWith, "Server Error")
|
||||
assert.Equal(t, 500, sc.resp.Code)
|
||||
assert.Equal(t, "Internal Server Error - Check the Grafana server logs for the detailed error message.", sc.respJson["message"])
|
||||
assert.True(t, strings.HasPrefix(sc.respJson["error"].(string), "Server Error"))
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given a non-api route that panics", t, func() {
|
||||
t.Run("Given a non-API route that panics", func(t *testing.T) {
|
||||
apiURL := "/whatever"
|
||||
recoveryScenario(t, "recovery middleware should return html", apiURL, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = PanicHandler
|
||||
recoveryScenario(t, "recovery middleware should return html", apiURL, func(t *testing.T, sc *scenarioContext) {
|
||||
sc.handlerFunc = panicHandler
|
||||
sc.fakeReq("GET", apiURL).exec()
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 500)
|
||||
So(sc.resp.Header().Get("content-type"), ShouldEqual, "text/html; charset=UTF-8")
|
||||
So(sc.resp.Body.String(), ShouldContainSubstring, "<title>Grafana - Error</title>")
|
||||
assert.Equal(t, 500, sc.resp.Code)
|
||||
assert.Equal(t, "text/html; charset=UTF-8", sc.resp.Header().Get("content-type"))
|
||||
assert.True(t, strings.Contains(sc.resp.Body.String(), "<title>Grafana - Error</title>"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func PanicHandler(c *models.ReqContext) {
|
||||
func panicHandler(c *models.ReqContext) {
|
||||
panic("Handler has panicked")
|
||||
}
|
||||
|
||||
func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) {
|
||||
Convey(desc, func() {
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
sc := &scenarioContext{
|
||||
t: t,
|
||||
url: url,
|
||||
}
|
||||
|
||||
viewsPath, _ := filepath.Abs("../../public/views")
|
||||
viewsPath, err := filepath.Abs("../../public/views")
|
||||
require.NoError(t, err)
|
||||
|
||||
sc.m = macaron.New()
|
||||
sc.m.Use(Recovery())
|
||||
@@ -81,6 +85,6 @@ func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) {
|
||||
|
||||
sc.m.Get(url, sc.defaultHandler)
|
||||
|
||||
fn(sc)
|
||||
fn(t, sc)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/macaron.v1"
|
||||
|
||||
@@ -11,10 +12,11 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type scenarioContext struct {
|
||||
t *testing.T
|
||||
m *macaron.Macaron
|
||||
context *models.ReqContext
|
||||
resp *httptest.ResponseRecorder
|
||||
@@ -47,15 +49,19 @@ func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioC
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
|
||||
sc.t.Helper()
|
||||
|
||||
sc.resp = httptest.NewRecorder()
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
convey.So(err, convey.ShouldBeNil)
|
||||
require.NoError(sc.t, err)
|
||||
sc.req = req
|
||||
|
||||
return sc
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
|
||||
sc.t.Helper()
|
||||
|
||||
sc.resp = httptest.NewRecorder()
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
q := req.URL.Query()
|
||||
@@ -63,7 +69,7 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
|
||||
q.Add(k, v)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
convey.So(err, convey.ShouldBeNil)
|
||||
require.NoError(sc.t, err)
|
||||
sc.req = req
|
||||
|
||||
return sc
|
||||
@@ -75,15 +81,20 @@ func (sc *scenarioContext) handler(fn handlerFunc) *scenarioContext {
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) exec() {
|
||||
sc.t.Helper()
|
||||
|
||||
if sc.apiKey != "" {
|
||||
sc.t.Logf(`Adding header "Authorization: Bearer %s"`, sc.apiKey)
|
||||
sc.req.Header.Add("Authorization", "Bearer "+sc.apiKey)
|
||||
}
|
||||
|
||||
if sc.authHeader != "" {
|
||||
sc.t.Logf(`Adding header "Authorization: %s"`, sc.authHeader)
|
||||
sc.req.Header.Add("Authorization", sc.authHeader)
|
||||
}
|
||||
|
||||
if sc.tokenSessionCookie != "" {
|
||||
sc.t.Log(`Adding cookie`, "name", setting.LoginCookieName, "value", sc.tokenSessionCookie)
|
||||
sc.req.AddCookie(&http.Cookie{
|
||||
Name: setting.LoginCookieName,
|
||||
Value: sc.tokenSessionCookie,
|
||||
@@ -94,9 +105,9 @@ func (sc *scenarioContext) exec() {
|
||||
|
||||
if sc.resp.Header().Get("Content-Type") == "application/json; charset=UTF-8" {
|
||||
err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson)
|
||||
convey.So(err, convey.ShouldBeNil)
|
||||
require.NoError(sc.t, err)
|
||||
}
|
||||
}
|
||||
|
||||
type scenarioFunc func(c *scenarioContext)
|
||||
type scenarioFunc func(t *testing.T, c *scenarioContext)
|
||||
type handlerFunc func(c *models.ReqContext)
|
||||
|
||||
@@ -91,18 +91,25 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT
|
||||
|
||||
func loadPluginDashboard(pluginId, path string) (*models.Dashboard, error) {
|
||||
plugin, exists := Plugins[pluginId]
|
||||
|
||||
if !exists {
|
||||
return nil, PluginNotFoundError{pluginId}
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `plugin.PluginDir` is based
|
||||
// on plugin folder structure on disk and not user input. `path` comes from the
|
||||
// `plugin.json` configuration file for the loaded plugin
|
||||
dashboardFilePath := filepath.Join(plugin.PluginDir, path)
|
||||
reader, err := os.Open(dashboardFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer reader.Close()
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
plog.Warn("Failed to close file", "path", dashboardFilePath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
data, err := simplejson.NewFromReader(reader)
|
||||
if err != nil {
|
||||
|
||||
+12
-1
@@ -87,6 +87,9 @@ func getPluginSignatureState(log log.Logger, plugin *PluginBase) PluginSignature
|
||||
log.Debug("Getting signature state of plugin", "plugin", plugin.Id, "isBackend", plugin.Backend)
|
||||
manifestPath := filepath.Join(plugin.PluginDir, "MANIFEST.txt")
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `manifestPath` is based
|
||||
// on plugin the folder structure on disk and not user input.
|
||||
byteValue, err := ioutil.ReadFile(manifestPath)
|
||||
if err != nil || len(byteValue) < 10 {
|
||||
log.Debug("Plugin is unsigned", "id", plugin.Id)
|
||||
@@ -109,11 +112,19 @@ func getPluginSignatureState(log log.Logger, plugin *PluginBase) PluginSignature
|
||||
for p, hash := range manifest.Files {
|
||||
// Open the file
|
||||
fp := filepath.Join(plugin.PluginDir, p)
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `fp` is based
|
||||
// on the manifest file for a plugin and not user input.
|
||||
f, err := os.Open(fp)
|
||||
if err != nil {
|
||||
return PluginSignatureModified
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
log.Warn("Failed to close plugin file", "path", fp, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
|
||||
+22
-2
@@ -269,11 +269,18 @@ func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `jsonFPath` is based
|
||||
// on plugin the folder structure on disk and not user input.
|
||||
reader, err := os.Open(jsonFPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
scanner.log.Warn("Failed to close JSON file", "path", jsonFPath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
jsonParser := json.NewDecoder(reader)
|
||||
|
||||
@@ -328,6 +335,9 @@ func (s *PluginScanner) walker(currentPath string, f os.FileInfo, err error) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `currentPath` is based
|
||||
// on plugin the folder structure on disk and not user input.
|
||||
if err := s.loadPlugin(currentPath); err != nil {
|
||||
s.log.Error("Failed to load plugin", "error", err, "pluginPath", filepath.Dir(currentPath))
|
||||
s.errors = append(s.errors, err)
|
||||
@@ -343,7 +353,11 @@ func (s *PluginScanner) loadPlugin(pluginJSONFilePath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
s.log.Warn("Failed to close JSON file", "path", pluginJSONFilePath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
jsonParser := json.NewDecoder(reader)
|
||||
pluginCommon := PluginBase{}
|
||||
@@ -463,6 +477,9 @@ func GetPluginMarkdown(pluginId string, name string) ([]byte, error) {
|
||||
return nil, PluginNotFoundError{pluginId}
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `plug.PluginDir` is based
|
||||
// on plugin the folder structure on disk and not user input.
|
||||
path := filepath.Join(plug.PluginDir, fmt.Sprintf("%s.md", strings.ToUpper(name)))
|
||||
exists, err := fs.Exists(path)
|
||||
if err != nil {
|
||||
@@ -480,6 +497,9 @@ func GetPluginMarkdown(pluginId string, name string) ([]byte, error) {
|
||||
return make([]byte, 0), nil
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `plug.PluginDir` is based
|
||||
// on plugin the folder structure on disk and not user input.
|
||||
data, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -30,94 +30,102 @@ func TestNotificationService(t *testing.T) {
|
||||
}
|
||||
evalCtx := NewEvalContext(context.Background(), testRule)
|
||||
|
||||
notificationServiceScenario(t, "Given alert rule with upload image enabled should render and upload image and send notification", evalCtx, true, func(scenarioCtx *scenarioContext) {
|
||||
err := scenarioCtx.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(t, err)
|
||||
notificationServiceScenario(t, "Given alert rule with upload image enabled should render and upload image and send notification",
|
||||
evalCtx, true, func(sc *scenarioContext) {
|
||||
err := sc.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(sc.t, err)
|
||||
|
||||
require.Equalf(t, 1, scenarioCtx.renderCount, "expected render to be called, but wasn't")
|
||||
require.Equalf(t, 1, scenarioCtx.imageUploadCount, "expected image to be uploaded, but wasn't")
|
||||
require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't")
|
||||
require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but wasn't")
|
||||
require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
|
||||
notificationServiceScenario(t, "Given alert rule with upload image enabled but no renderer available should render and upload unavailable image and send notification", evalCtx, true, func(scenarioCtx *scenarioContext) {
|
||||
scenarioCtx.rendererAvailable = false
|
||||
err := scenarioCtx.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(t, err)
|
||||
notificationServiceScenario(t,
|
||||
"Given alert rule with upload image enabled but no renderer available should render and upload unavailable image and send notification",
|
||||
evalCtx, true, func(sc *scenarioContext) {
|
||||
sc.rendererAvailable = false
|
||||
err := sc.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(sc.t, err)
|
||||
|
||||
require.Equalf(t, 1, scenarioCtx.renderCount, "expected render to be called, but it wasn't")
|
||||
require.Equalf(t, 1, scenarioCtx.imageUploadCount, "expected image to be uploaded, but it wasn't")
|
||||
require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but it wasn't")
|
||||
require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but it wasn't")
|
||||
require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
|
||||
notificationServiceScenario(t, "Given alert rule with upload image disabled should not render and upload image, but send notification", evalCtx, false, func(scenarioCtx *scenarioContext) {
|
||||
err := scenarioCtx.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(t, err)
|
||||
notificationServiceScenario(
|
||||
t, "Given alert rule with upload image disabled should not render and upload image, but send notification",
|
||||
evalCtx, false, func(sc *scenarioContext) {
|
||||
err := sc.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equalf(t, 0, scenarioCtx.renderCount, "expected render not to be called, but it was")
|
||||
require.Equalf(t, 0, scenarioCtx.imageUploadCount, "expected image not to be uploaded, but it was")
|
||||
require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
require.Equalf(sc.t, 0, sc.renderCount, "expected render not to be called, but it was")
|
||||
require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was")
|
||||
require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
|
||||
notificationServiceScenario(t, "Given alert rule with upload image enabled and render times out should send notification", evalCtx, true, func(scenarioCtx *scenarioContext) {
|
||||
setting.AlertingNotificationTimeout = 200 * time.Millisecond
|
||||
scenarioCtx.renderProvider = func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) {
|
||||
wait := make(chan bool)
|
||||
notificationServiceScenario(t, "Given alert rule with upload image enabled and render times out should send notification",
|
||||
evalCtx, true, func(sc *scenarioContext) {
|
||||
setting.AlertingNotificationTimeout = 200 * time.Millisecond
|
||||
sc.renderProvider = func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) {
|
||||
wait := make(chan bool)
|
||||
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
wait <- true
|
||||
}()
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
wait <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
case <-wait:
|
||||
}
|
||||
break
|
||||
case <-wait:
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
err := sc.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(sc.t, err)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
err := scenarioCtx.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(t, err)
|
||||
require.Equalf(sc.t, 0, sc.renderCount, "expected render not to be called, but it was")
|
||||
require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was")
|
||||
require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
|
||||
require.Equalf(t, 0, scenarioCtx.renderCount, "expected render not to be called, but it was")
|
||||
require.Equalf(t, 0, scenarioCtx.imageUploadCount, "expected image not to be uploaded, but it was")
|
||||
require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
notificationServiceScenario(t, "Given alert rule with upload image enabled and upload times out should send notification",
|
||||
evalCtx, true, func(sc *scenarioContext) {
|
||||
setting.AlertingNotificationTimeout = 200 * time.Millisecond
|
||||
sc.uploadProvider = func(ctx context.Context, path string) (string, error) {
|
||||
wait := make(chan bool)
|
||||
|
||||
notificationServiceScenario(t, "Given alert rule with upload image enabled and upload times out should send notification", evalCtx, true, func(scenarioCtx *scenarioContext) {
|
||||
setting.AlertingNotificationTimeout = 200 * time.Millisecond
|
||||
scenarioCtx.uploadProvider = func(ctx context.Context, path string) (string, error) {
|
||||
wait := make(chan bool)
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
wait <- true
|
||||
}()
|
||||
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
wait <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
break
|
||||
case <-wait:
|
||||
}
|
||||
break
|
||||
case <-wait:
|
||||
|
||||
return "", nil
|
||||
}
|
||||
err := sc.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(sc.t, err)
|
||||
|
||||
return "", nil
|
||||
}
|
||||
err := scenarioCtx.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equalf(t, 1, scenarioCtx.renderCount, "expected render to be called, but wasn't")
|
||||
require.Equalf(t, 0, scenarioCtx.imageUploadCount, "expected image not to be uploaded, but it was")
|
||||
require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't")
|
||||
require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was")
|
||||
require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
}
|
||||
|
||||
type scenarioContext struct {
|
||||
t *testing.T
|
||||
evalCtx *EvalContext
|
||||
notificationService *notificationService
|
||||
imageUploadCount int
|
||||
@@ -175,6 +183,7 @@ func notificationServiceScenario(t *testing.T, name string, evalCtx *EvalContext
|
||||
setting.AlertingNotificationTimeout = 30 * time.Second
|
||||
|
||||
scenarioCtx := &scenarioContext{
|
||||
t: t,
|
||||
evalCtx: evalCtx,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package notifiers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
@@ -158,6 +159,9 @@ func (dn *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
}
|
||||
|
||||
func (dn *DiscordNotifier) embedImage(cmd *models.SendWebhookSync, imagePath string, existingJSONBody []byte) error {
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `imagePath` comes
|
||||
// from the alert `evalContext` that generates the images.
|
||||
f, err := os.Open(imagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -168,12 +172,20 @@ func (dn *DiscordNotifier) embedImage(cmd *models.SendWebhookSync, imagePath str
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
dn.log.Warn("Failed to close file", "path", imagePath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
|
||||
defer func() {
|
||||
if err := w.Close(); err != nil {
|
||||
// Should be OK since we already close it on non-error path
|
||||
dn.log.Warn("Failed to close multipart writer", "err", err)
|
||||
}
|
||||
}()
|
||||
fw, err := w.CreateFormField("payload_json")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -192,7 +204,9 @@ func (dn *DiscordNotifier) embedImage(cmd *models.SendWebhookSync, imagePath str
|
||||
return err
|
||||
}
|
||||
|
||||
w.Close()
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
cmd.Body = b.String()
|
||||
cmd.ContentType = w.FormDataContentType()
|
||||
|
||||
@@ -292,7 +292,11 @@ func (pn *PushoverNotifier) genPushoverBody(evalContext *alerting.EvalContext, m
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
pn.log.Warn("Failed to close file", "path", evalContext.ImageOnDiskPath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
fw, err := w.CreateFormFile("attachment", evalContext.ImageOnDiskPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -321,7 +321,7 @@ func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
return err
|
||||
}
|
||||
if sn.Token != "" && sn.UploadImage {
|
||||
err = slackFileUpload(evalContext, sn.log, "https://slack.com/api/files.upload", sn.Recipient, sn.Token)
|
||||
err = sn.slackFileUpload(evalContext, sn.log, "https://slack.com/api/files.upload", sn.Recipient, sn.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -329,12 +329,14 @@ func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func slackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error {
|
||||
func (sn *SlackNotifier) slackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error {
|
||||
if evalContext.ImageOnDiskPath == "" {
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `setting.HomePath` comes from Grafana's configuration file.
|
||||
evalContext.ImageOnDiskPath = filepath.Join(setting.HomePath, "public/img/mixed_styles.png")
|
||||
}
|
||||
log.Info("Uploading to slack via file.upload API")
|
||||
headers, uploadBody, err := generateSlackBody(evalContext.ImageOnDiskPath, token, recipient)
|
||||
headers, uploadBody, err := sn.generateSlackBody(evalContext.ImageOnDiskPath, token, recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -346,37 +348,51 @@ func slackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url stri
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateSlackBody(file string, token string, recipient string) (map[string]string, bytes.Buffer, error) {
|
||||
func (sn *SlackNotifier) generateSlackBody(path string, token string, recipient string) (map[string]string, bytes.Buffer, error) {
|
||||
// Slack requires all POSTs to files.upload to present
|
||||
// an "application/x-www-form-urlencoded" encoded querystring
|
||||
// See https://api.slack.com/methods/files.upload
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
defer func() {
|
||||
if err := w.Close(); err != nil {
|
||||
// Shouldn't matter since we already close w explicitly on the non-error path
|
||||
sn.log.Warn("Failed to close multipart writer", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Add the generated image file
|
||||
f, err := os.Open(file)
|
||||
// We can ignore the gosec G304 warning on this one because `imagePath` comes
|
||||
// from the alert `evalContext` that generates the images. `evalContext` in turn derives the root of the file
|
||||
// path from configuration variables.
|
||||
// nolint:gosec
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
defer f.Close()
|
||||
fw, err := w.CreateFormFile("file", file)
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
sn.log.Warn("Failed to close file", "path", path, "err", err)
|
||||
}
|
||||
}()
|
||||
fw, err := w.CreateFormFile("file", path)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
_, err = io.Copy(fw, f)
|
||||
if err != nil {
|
||||
if _, err := io.Copy(fw, f); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
// Add the authorization token
|
||||
err = w.WriteField("token", token)
|
||||
if err != nil {
|
||||
if err := w.WriteField("token", token); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
// Add the channel(s) to POST to
|
||||
err = w.WriteField("channels", recipient)
|
||||
if err != nil {
|
||||
if err := w.WriteField("channels", recipient); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
w.Close()
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, b, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
headers := map[string]string{
|
||||
"Content-Type": w.FormDataContentType(),
|
||||
"Authorization": "auth_token=\"" + token + "\"",
|
||||
|
||||
@@ -95,6 +95,8 @@ func (server *Server) Dial() error {
|
||||
if server.Config.RootCACert != "" {
|
||||
certPool = x509.NewCertPool()
|
||||
for _, caCertFile := range strings.Split(server.Config.RootCACert, " ") {
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `caCertFile` comes from ldap config.
|
||||
pem, err := ioutil.ReadFile(caCertFile)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -115,6 +115,8 @@ func readConfig(configFile string) (*Config, error) {
|
||||
|
||||
logger.Info("LDAP enabled, reading config file", "file", configFile)
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `filename` comes from grafana configuration file
|
||||
fileBytes, err := ioutil.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return nil, errutil.Wrap("Failed to load LDAP config file", err)
|
||||
|
||||
@@ -19,6 +19,9 @@ type configReader struct {
|
||||
|
||||
func (cr *configReader) parseConfigs(file os.FileInfo) ([]*config, error) {
|
||||
filename, _ := filepath.Abs(filepath.Join(cr.path, file.Name()))
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath
|
||||
yamlFile, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -343,11 +343,17 @@ type dashboardJSONFile struct {
|
||||
}
|
||||
|
||||
func (fr *FileReader) readDashboardFromFile(path string, lastModified time.Time, folderID int64) (*dashboardJSONFile, error) {
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `path` comes from the provisioning configuration file.
|
||||
reader, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
fr.log.Warn("Failed to close file", "path", path, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
all, err := ioutil.ReadAll(reader)
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,9 @@ func (cr *configReader) readConfig(path string) ([]*configs, error) {
|
||||
|
||||
func (cr *configReader) parseDatasourceConfig(path string, file os.FileInfo) (*configs, error) {
|
||||
filename, _ := filepath.Abs(filepath.Join(path, file.Name()))
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath
|
||||
yamlFile, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -61,6 +61,9 @@ func (cr *configReader) readConfig(path string) ([]*notificationsAsConfig, error
|
||||
|
||||
func (cr *configReader) parseNotificationConfig(path string, file os.FileInfo) (*notificationsAsConfig, error) {
|
||||
filename, _ := filepath.Abs(filepath.Join(path, file.Name()))
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath
|
||||
yamlFile, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -69,6 +69,8 @@ func (cr *configReaderImpl) parsePluginConfig(path string, file os.FileInfo) (*p
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath
|
||||
yamlFile, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -96,7 +96,12 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, renderKey string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer out.Close()
|
||||
defer func() {
|
||||
if err := out.Close(); err != nil {
|
||||
// We already close the file explicitly in the non-error path, so shouldn't be a problem
|
||||
rs.log.Warn("Failed to close file", "path", filePath, "err", err)
|
||||
}
|
||||
}()
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
// check that we didn't timeout while receiving the response.
|
||||
@@ -107,6 +112,9 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, renderKey string,
|
||||
rs.log.Error("Remote rendering request failed", "error", err)
|
||||
return nil, fmt.Errorf("remote rendering request failed: %w", err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to write to %q: %w", filePath, err)
|
||||
}
|
||||
|
||||
return &RenderResult{FilePath: filePath}, err
|
||||
}
|
||||
|
||||
@@ -5,56 +5,45 @@ import (
|
||||
|
||||
. "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/xorm"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestMigrations(t *testing.T) {
|
||||
testDBs := []sqlutil.TestDB{
|
||||
sqlutil.SQLite3TestDB(),
|
||||
}
|
||||
testDB := sqlutil.SQLite3TestDB()
|
||||
const query = `select count(*) as count from migration_log`
|
||||
result := struct{ Count int }{}
|
||||
|
||||
for _, testDB := range testDBs {
|
||||
sql := `select count(*) as count from migration_log`
|
||||
r := struct {
|
||||
Count int64
|
||||
}{}
|
||||
x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
Convey("Initial "+testDB.DriverName+" migration", t, func() {
|
||||
x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr)
|
||||
So(err, ShouldBeNil)
|
||||
err = NewDialect(x).CleanDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = NewDialect(x).CleanDB()
|
||||
So(err, ShouldBeNil)
|
||||
_, err = x.SQL(query).Get(&result)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = x.SQL(sql).Get(&r)
|
||||
So(err, ShouldNotBeNil)
|
||||
mg := NewMigrator(x)
|
||||
AddMigrations(mg)
|
||||
expectedMigrations := mg.MigrationsCount()
|
||||
|
||||
mg := NewMigrator(x)
|
||||
AddMigrations(mg)
|
||||
err = mg.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = mg.Start()
|
||||
So(err, ShouldBeNil)
|
||||
has, err := x.SQL(query).Get(&result)
|
||||
require.NoError(t, err)
|
||||
require.True(t, has)
|
||||
|
||||
has, err := x.SQL(sql).Get(&r)
|
||||
So(err, ShouldBeNil)
|
||||
So(has, ShouldBeTrue)
|
||||
// we currently skip to migrations. We should rewrite skipped migrations to write in the log as well.
|
||||
// until then we have to keep this
|
||||
expectedMigrations := mg.MigrationsCount()
|
||||
So(r.Count, ShouldEqual, expectedMigrations)
|
||||
require.Equal(t, expectedMigrations, result.Count)
|
||||
|
||||
mg = NewMigrator(x)
|
||||
AddMigrations(mg)
|
||||
mg = NewMigrator(x)
|
||||
AddMigrations(mg)
|
||||
|
||||
err = mg.Start()
|
||||
So(err, ShouldBeNil)
|
||||
err = mg.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
has, err = x.SQL(sql).Get(&r)
|
||||
So(err, ShouldBeNil)
|
||||
So(has, ShouldBeTrue)
|
||||
So(r.Count, ShouldEqual, expectedMigrations)
|
||||
})
|
||||
}
|
||||
has, err = x.SQL(query).Get(&result)
|
||||
require.NoError(t, err)
|
||||
require.True(t, has)
|
||||
require.Equal(t, expectedMigrations, result.Count)
|
||||
}
|
||||
|
||||
@@ -138,6 +138,8 @@ func (e fileExpander) Expand(s string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `s` comes from configuration section keys
|
||||
f, err := ioutil.ReadFile(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -4,156 +4,125 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/tsdb"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
p "github.com/prometheus/common/model"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPrometheus(t *testing.T) {
|
||||
Convey("Prometheus", t, func() {
|
||||
dsInfo := &models.DataSource{
|
||||
JsonData: simplejson.New(),
|
||||
dsInfo := &models.DataSource{
|
||||
JsonData: simplejson.New(),
|
||||
}
|
||||
|
||||
t.Run("converting metric name", func(t *testing.T) {
|
||||
metric := map[p.LabelName]p.LabelValue{
|
||||
p.LabelName("app"): p.LabelValue("backend"),
|
||||
p.LabelName("device"): p.LabelValue("mobile"),
|
||||
}
|
||||
|
||||
Convey("converting metric name", func() {
|
||||
metric := map[p.LabelName]p.LabelValue{
|
||||
p.LabelName("app"): p.LabelValue("backend"),
|
||||
p.LabelName("device"): p.LabelValue("mobile"),
|
||||
}
|
||||
query := &PrometheusQuery{
|
||||
LegendFormat: "legend {{app}} {{ device }} {{broken}}",
|
||||
}
|
||||
|
||||
query := &PrometheusQuery{
|
||||
LegendFormat: "legend {{app}} {{ device }} {{broken}}",
|
||||
}
|
||||
require.Equal(t, "legend backend mobile ", formatLegend(metric, query))
|
||||
})
|
||||
|
||||
So(formatLegend(metric, query), ShouldEqual, "legend backend mobile ")
|
||||
})
|
||||
t.Run("build full series name", func(t *testing.T) {
|
||||
metric := map[p.LabelName]p.LabelValue{
|
||||
p.LabelName(p.MetricNameLabel): p.LabelValue("http_request_total"),
|
||||
p.LabelName("app"): p.LabelValue("backend"),
|
||||
p.LabelName("device"): p.LabelValue("mobile"),
|
||||
}
|
||||
|
||||
Convey("build full series name", func() {
|
||||
metric := map[p.LabelName]p.LabelValue{
|
||||
p.LabelName(p.MetricNameLabel): p.LabelValue("http_request_total"),
|
||||
p.LabelName("app"): p.LabelValue("backend"),
|
||||
p.LabelName("device"): p.LabelValue("mobile"),
|
||||
}
|
||||
query := &PrometheusQuery{
|
||||
LegendFormat: "",
|
||||
}
|
||||
|
||||
query := &PrometheusQuery{
|
||||
LegendFormat: "",
|
||||
}
|
||||
require.Equal(t, `http_request_total{app="backend", device="mobile"}`, formatLegend(metric, query))
|
||||
})
|
||||
|
||||
So(formatLegend(metric, query), ShouldEqual, `http_request_total{app="backend", device="mobile"}`)
|
||||
})
|
||||
|
||||
Convey("parsing query model with step", func() {
|
||||
json := `{
|
||||
t.Run("parsing query model with step", func(t *testing.T) {
|
||||
json := `{
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"refId": "A"
|
||||
}`
|
||||
jsonModel, _ := simplejson.NewJson([]byte(json))
|
||||
queryContext := &tsdb.TsdbQuery{}
|
||||
queryModels := []*tsdb.Query{
|
||||
{Model: jsonModel},
|
||||
}
|
||||
jsonModel, _ := simplejson.NewJson([]byte(json))
|
||||
queryContext := &tsdb.TsdbQuery{}
|
||||
queryModels := []*tsdb.Query{
|
||||
{Model: jsonModel},
|
||||
}
|
||||
|
||||
Convey("with 48h time range", func() {
|
||||
queryContext.TimeRange = tsdb.NewTimeRange("12h", "now")
|
||||
queryContext.TimeRange = tsdb.NewTimeRange("12h", "now")
|
||||
|
||||
models, err := parseQuery(dsInfo, queryModels, queryContext)
|
||||
So(err, ShouldBeNil)
|
||||
models, err := parseQuery(dsInfo, queryModels, queryContext)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Second*30, models[0].Step)
|
||||
})
|
||||
|
||||
model := models[0]
|
||||
So(model.Step, ShouldEqual, time.Second*30)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("parsing query model without step parameter", func() {
|
||||
json := `{
|
||||
t.Run("parsing query model without step parameter", func(t *testing.T) {
|
||||
json := `{
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}`
|
||||
jsonModel, _ := simplejson.NewJson([]byte(json))
|
||||
queryContext := &tsdb.TsdbQuery{}
|
||||
queryModels := []*tsdb.Query{
|
||||
{Model: jsonModel},
|
||||
}
|
||||
jsonModel, _ := simplejson.NewJson([]byte(json))
|
||||
queryContext := &tsdb.TsdbQuery{}
|
||||
queryModels := []*tsdb.Query{
|
||||
{Model: jsonModel},
|
||||
}
|
||||
|
||||
Convey("with 48h time range", func() {
|
||||
queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
|
||||
queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
|
||||
models, err := parseQuery(dsInfo, queryModels, queryContext)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Minute*2, models[0].Step)
|
||||
|
||||
models, err := parseQuery(dsInfo, queryModels, queryContext)
|
||||
queryContext.TimeRange = tsdb.NewTimeRange("1h", "now")
|
||||
models, err = parseQuery(dsInfo, queryModels, queryContext)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Second*15, models[0].Step)
|
||||
})
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
model := models[0]
|
||||
So(model.Step, ShouldEqual, time.Minute*2)
|
||||
})
|
||||
|
||||
Convey("with 1h time range", func() {
|
||||
queryContext.TimeRange = tsdb.NewTimeRange("1h", "now")
|
||||
|
||||
models, err := parseQuery(dsInfo, queryModels, queryContext)
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
model := models[0]
|
||||
So(model.Step, ShouldEqual, time.Second*15)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("parsing query model with intervalFactor", func() {
|
||||
Convey("high intervalFactor", func() {
|
||||
json := `{
|
||||
t.Run("parsing query model with high intervalFactor", func(t *testing.T) {
|
||||
json := `{
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 10,
|
||||
"refId": "A"
|
||||
}`
|
||||
jsonModel, _ := simplejson.NewJson([]byte(json))
|
||||
queryContext := &tsdb.TsdbQuery{}
|
||||
queryModels := []*tsdb.Query{
|
||||
{Model: jsonModel},
|
||||
}
|
||||
jsonModel, _ := simplejson.NewJson([]byte(json))
|
||||
queryContext := &tsdb.TsdbQuery{}
|
||||
queryModels := []*tsdb.Query{
|
||||
{Model: jsonModel},
|
||||
}
|
||||
|
||||
Convey("with 48h time range", func() {
|
||||
queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
|
||||
queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
|
||||
|
||||
models, err := parseQuery(dsInfo, queryModels, queryContext)
|
||||
models, err := parseQuery(dsInfo, queryModels, queryContext)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Minute*20, models[0].Step)
|
||||
})
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
model := models[0]
|
||||
So(model.Step, ShouldEqual, time.Minute*20)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("low intervalFactor", func() {
|
||||
json := `{
|
||||
t.Run("parsing query model with low intervalFactor", func(t *testing.T) {
|
||||
json := `{
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}`
|
||||
jsonModel, _ := simplejson.NewJson([]byte(json))
|
||||
queryContext := &tsdb.TsdbQuery{}
|
||||
queryModels := []*tsdb.Query{
|
||||
{Model: jsonModel},
|
||||
}
|
||||
jsonModel, _ := simplejson.NewJson([]byte(json))
|
||||
queryContext := &tsdb.TsdbQuery{}
|
||||
queryModels := []*tsdb.Query{
|
||||
{Model: jsonModel},
|
||||
}
|
||||
|
||||
Convey("with 48h time range", func() {
|
||||
queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
|
||||
queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
|
||||
|
||||
models, err := parseQuery(dsInfo, queryModels, queryContext)
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
model := models[0]
|
||||
So(model.Step, ShouldEqual, time.Minute*2)
|
||||
})
|
||||
})
|
||||
})
|
||||
models, err := parseQuery(dsInfo, queryModels, queryContext)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Minute*2, models[0].Step)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/tsdb"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTestdataScenarios(t *testing.T) {
|
||||
Convey("random walk ", t, func() {
|
||||
t.Run("random walk ", func(t *testing.T) {
|
||||
scenario := ScenarioRegistry["random_walk"]
|
||||
|
||||
Convey("Should start at the requested value", func() {
|
||||
t.Run("Should start at the requested value", func(t *testing.T) {
|
||||
req := &tsdb.TsdbQuery{
|
||||
TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()),
|
||||
Queries: []*tsdb.Query{
|
||||
@@ -24,17 +24,17 @@ func TestTestdataScenarios(t *testing.T) {
|
||||
query.Model.Set("startValue", 1.234)
|
||||
|
||||
result := scenario.Handler(req.Queries[0], req)
|
||||
points := result.Series[0].Points
|
||||
require.NotNil(t, result.Series)
|
||||
|
||||
So(result.Series, ShouldNotBeNil)
|
||||
So(points[0][0].Float64, ShouldEqual, 1.234)
|
||||
points := result.Series[0].Points
|
||||
require.Equal(t, 1.234, points[0][0].Float64)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("random walk table", t, func() {
|
||||
t.Run("random walk table", func(t *testing.T) {
|
||||
scenario := ScenarioRegistry["random_walk_table"]
|
||||
|
||||
Convey("Should return a table that looks like value/min/max", func() {
|
||||
t.Run("Should return a table that looks like value/min/max", func(t *testing.T) {
|
||||
req := &tsdb.TsdbQuery{
|
||||
TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()),
|
||||
Queries: []*tsdb.Query{
|
||||
@@ -45,18 +45,18 @@ func TestTestdataScenarios(t *testing.T) {
|
||||
result := scenario.Handler(req.Queries[0], req)
|
||||
table := result.Tables[0]
|
||||
|
||||
So(len(table.Rows), ShouldBeGreaterThan, 50)
|
||||
require.Greater(t, len(table.Rows), 50)
|
||||
for _, row := range table.Rows {
|
||||
value := row[1]
|
||||
min := row[2]
|
||||
max := row[3]
|
||||
|
||||
So(min, ShouldBeLessThan, value)
|
||||
So(max, ShouldBeGreaterThan, value)
|
||||
require.Less(t, min, value)
|
||||
require.Greater(t, max, value)
|
||||
}
|
||||
})
|
||||
|
||||
Convey("Should return a table with some nil values", func() {
|
||||
t.Run("Should return a table with some nil values", func(t *testing.T) {
|
||||
req := &tsdb.TsdbQuery{
|
||||
TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()),
|
||||
Queries: []*tsdb.Query{
|
||||
@@ -73,7 +73,7 @@ func TestTestdataScenarios(t *testing.T) {
|
||||
nil2 := false
|
||||
nil3 := false
|
||||
|
||||
So(len(table.Rows), ShouldBeGreaterThan, 50)
|
||||
require.Greater(t, len(table.Rows), 50)
|
||||
for _, row := range table.Rows {
|
||||
if row[1] == nil {
|
||||
nil1 = true
|
||||
@@ -86,41 +86,37 @@ func TestTestdataScenarios(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
So(nil1, ShouldBeTrue)
|
||||
So(nil2, ShouldBeTrue)
|
||||
So(nil3, ShouldBeTrue)
|
||||
require.True(t, nil1)
|
||||
require.True(t, nil2)
|
||||
require.True(t, nil3)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestToLabels(t *testing.T) {
|
||||
Convey("read labels", t, func() {
|
||||
tags := make(map[string]string)
|
||||
tags["job"] = "foo"
|
||||
tags["instance"] = "bar"
|
||||
func TestParseLabels(t *testing.T) {
|
||||
expectedTags := map[string]string{
|
||||
"job": "foo",
|
||||
"instance": "bar",
|
||||
}
|
||||
|
||||
query1 := tsdb.Query{
|
||||
Model: simplejson.NewFromAny(map[string]interface{}{
|
||||
"labels": `{job="foo", instance="bar"}`,
|
||||
}),
|
||||
}
|
||||
query1 := tsdb.Query{
|
||||
Model: simplejson.NewFromAny(map[string]interface{}{
|
||||
"labels": `{job="foo", instance="bar"}`,
|
||||
}),
|
||||
}
|
||||
require.Equal(t, expectedTags, parseLabels(&query1))
|
||||
|
||||
So(parseLabels(&query1), ShouldResemble, tags)
|
||||
query2 := tsdb.Query{
|
||||
Model: simplejson.NewFromAny(map[string]interface{}{
|
||||
"labels": `job=foo, instance=bar`,
|
||||
}),
|
||||
}
|
||||
require.Equal(t, expectedTags, parseLabels(&query2))
|
||||
|
||||
query2 := tsdb.Query{
|
||||
Model: simplejson.NewFromAny(map[string]interface{}{
|
||||
"labels": `job=foo, instance=bar`,
|
||||
}),
|
||||
}
|
||||
|
||||
So(parseLabels(&query2), ShouldResemble, tags)
|
||||
|
||||
query3 := tsdb.Query{
|
||||
Model: simplejson.NewFromAny(map[string]interface{}{
|
||||
"labels": `job = foo,instance = bar`,
|
||||
}),
|
||||
}
|
||||
|
||||
So(parseLabels(&query3), ShouldResemble, tags)
|
||||
})
|
||||
query3 := tsdb.Query{
|
||||
Model: simplejson.NewFromAny(map[string]interface{}{
|
||||
"labels": `job = foo,instance = bar`,
|
||||
}),
|
||||
}
|
||||
require.Equal(t, expectedTags, parseLabels(&query3))
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ChangePassword: FC<Props> = ({ onSubmit, onSkip }) => {
|
||||
|
||||
{onSkip && (
|
||||
<Tooltip
|
||||
content="If you skip you will be prompted to change password next time you login."
|
||||
content="If you skip you will be prompted to change password next time you log in."
|
||||
placement="bottom"
|
||||
>
|
||||
<LinkButton variant="link" onClick={onSkip} aria-label={selectors.pages.Login.skip}>
|
||||
|
||||
@@ -7,7 +7,7 @@ exports[`Render should render component 1`] = `
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<Component
|
||||
<FilterInput
|
||||
inputClassName="gf-form-input width-20"
|
||||
labelClassName="gf-form--has-input-icon"
|
||||
onChange={[MockFunction]}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ describe('CustomEndpointTransport', () => {
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// wait out the retry-after and call again - great success
|
||||
await new Promise(resolve => setTimeout(() => resolve(), 1001));
|
||||
await new Promise(resolve => setTimeout(() => resolve(null), 1001));
|
||||
await expect(transport.sendEvent(event)).resolves.toBeTruthy();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ exports[`Render should render alert rules 1`] = `
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<Component
|
||||
<FilterInput
|
||||
inputClassName="gf-form-input"
|
||||
labelClassName="gf-form--has-input-icon gf-form--grow"
|
||||
onChange={[Function]}
|
||||
@@ -145,7 +145,7 @@ exports[`Render should render component 1`] = `
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<Component
|
||||
<FilterInput
|
||||
inputClassName="gf-form-input"
|
||||
labelClassName="gf-form--has-input-icon gf-form--grow"
|
||||
onChange={[Function]}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<Component
|
||||
<LoadingPlaceholder
|
||||
text="Evaluating rule"
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -43,7 +43,7 @@ exports[`Render should render CTA if there are no API keys 1`] = `
|
||||
proTip="Remember you can provide view-only API access to other applications."
|
||||
title="You haven't added any API Keys yet."
|
||||
/>
|
||||
<Component
|
||||
<SlideDown
|
||||
in={false}
|
||||
>
|
||||
<div
|
||||
@@ -126,11 +126,11 @@ exports[`Render should render CTA if there are no API keys 1`] = `
|
||||
<div
|
||||
className="gf-form max-width-21"
|
||||
>
|
||||
<Component
|
||||
<FormLabel
|
||||
tooltip="The api key life duration. For example 1d if your key is going to last for one day. All the supported units are: s,m,h,d,w,M,y"
|
||||
>
|
||||
Time to live
|
||||
</Component>
|
||||
</FormLabel>
|
||||
<Input
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
@@ -161,7 +161,7 @@ exports[`Render should render CTA if there are no API keys 1`] = `
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Component>
|
||||
</SlideDown>
|
||||
</PageContents>
|
||||
</Page>
|
||||
`;
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ exports[`Render should render component 1`] = `
|
||||
onPasteCopiedPanel={[Function]}
|
||||
/>
|
||||
<div>
|
||||
<Component
|
||||
<HorizontalGroup
|
||||
justify="center"
|
||||
>
|
||||
<Button
|
||||
@@ -25,7 +25,7 @@ exports[`Render should render component 1`] = `
|
||||
>
|
||||
Convert to row
|
||||
</Button>
|
||||
</Component>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
|
||||
// eslint-disable-next-line react/display-name
|
||||
const renderLabel = (includeDescription = true, includeCounter = false) => (isExpanded = false) => (
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Label description={includeDescription ? item.description : undefined}>
|
||||
<Label category={item.category?.splice(1)} description={includeDescription ? item.description : undefined}>
|
||||
{item.name}
|
||||
{!isExpanded && includeCounter && item.getItemsCount && <Counter value={item.getItemsCount(property.value)} />}
|
||||
</Label>
|
||||
|
||||
@@ -98,8 +98,12 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
|
||||
);
|
||||
|
||||
let configPropertiesOptions = registry.list().map(item => {
|
||||
let label = item.name;
|
||||
if (item.category && item.category.length > 1) {
|
||||
label = [...item.category!.slice(1), item.name].join(' > ');
|
||||
}
|
||||
return {
|
||||
label: item.name,
|
||||
label,
|
||||
value: item.id,
|
||||
description: item.description,
|
||||
};
|
||||
|
||||
@@ -337,22 +337,22 @@ exports[`DashboardPage Dashboard is fetching slowly Should render slow init stat
|
||||
<div
|
||||
className="dashboard-loading__text"
|
||||
>
|
||||
<Component
|
||||
<VerticalGroup
|
||||
spacing="md"
|
||||
>
|
||||
<Component
|
||||
<HorizontalGroup
|
||||
align="center"
|
||||
justify="center"
|
||||
spacing="xs"
|
||||
>
|
||||
<Component
|
||||
<Spinner
|
||||
inline={true}
|
||||
/>
|
||||
|
||||
Fetching
|
||||
</Component>
|
||||
</HorizontalGroup>
|
||||
|
||||
<Component
|
||||
<HorizontalGroup
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
@@ -364,8 +364,8 @@ exports[`DashboardPage Dashboard is fetching slowly Should render slow init stat
|
||||
>
|
||||
Cancel loading dashboard
|
||||
</Button>
|
||||
</Component>
|
||||
</Component>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { isEqual } from 'lodash';
|
||||
import { DataLink, LoadingState, PanelData, PanelMenuItem, QueryResultMetaNotice, ScopedVars } from '@grafana/data';
|
||||
import { AngularComponent, config, getTemplateSrv } from '@grafana/runtime';
|
||||
import { ClickOutsideWrapper, Icon, IconName, Tooltip, stylesFactory } from '@grafana/ui';
|
||||
@@ -52,8 +51,8 @@ export class PanelHeader extends PureComponent<Props, State> {
|
||||
|
||||
eventToClickCoordinates = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
return {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
x: Math.floor(event.clientX),
|
||||
y: Math.floor(event.clientY),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,7 +61,7 @@ export class PanelHeader extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
isClick = (clickCoordinates: ClickCoordinates) => {
|
||||
return isEqual(clickCoordinates, this.clickCoordinates);
|
||||
return clickCoordinates.x === this.clickCoordinates.x && clickCoordinates.y === this.clickCoordinates.y;
|
||||
};
|
||||
|
||||
onMenuToggle = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -16,11 +16,11 @@ exports[`Render should render component 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<Component
|
||||
<FormLabel
|
||||
tooltip="The name is used when you select the data source in panels. The Default data source is preselected in new panels."
|
||||
>
|
||||
Name
|
||||
</Component>
|
||||
</FormLabel>
|
||||
<Input
|
||||
aria-label="Data source settings page name input field"
|
||||
className="gf-form-input max-width-23"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ErrorContainer should render component 1`] = `
|
||||
<Component
|
||||
<FadeIn
|
||||
duration={100}
|
||||
in={true}
|
||||
>
|
||||
@@ -29,5 +29,5 @@ exports[`ErrorContainer should render component 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
</FadeIn>
|
||||
`;
|
||||
|
||||
@@ -37,7 +37,7 @@ exports[`Explore should render component 1`] = `
|
||||
richHistoryButtonActive={false}
|
||||
/>
|
||||
</div>
|
||||
<Component
|
||||
<ErrorContainer
|
||||
queryError={Object {}}
|
||||
/>
|
||||
<AutoSizer
|
||||
|
||||
@@ -9,12 +9,12 @@ export abstract class GrafanaLiveScope {
|
||||
/**
|
||||
* Load the real namespaces
|
||||
*/
|
||||
abstract async getChannelSupport(namespace: string): Promise<LiveChannelSupport | undefined>;
|
||||
abstract getChannelSupport(namespace: string): Promise<LiveChannelSupport | undefined>;
|
||||
|
||||
/**
|
||||
* List the possible values within this scope
|
||||
*/
|
||||
abstract async listNamespaces(): Promise<Array<SelectableValue<string>>>;
|
||||
abstract listNamespaces(): Promise<Array<SelectableValue<string>>>;
|
||||
}
|
||||
|
||||
export interface CoreGrafanaLiveFeature {
|
||||
|
||||
@@ -14,7 +14,7 @@ export const getDashboardNavModel = (state: StoreState): NavModel => {
|
||||
}
|
||||
|
||||
// This needs to be copied to avoid mutating the store in a selector
|
||||
nav.main.children = [...navModel.main.children];
|
||||
nav.main.children = [...(navModel.main.children ?? [])];
|
||||
|
||||
for (const item of nav.main.children) {
|
||||
item.active = false;
|
||||
|
||||
@@ -35,7 +35,7 @@ exports[`Render should render organization and preferences 1`] = `
|
||||
<PageContents
|
||||
isLoading={false}
|
||||
>
|
||||
<Component>
|
||||
<VerticalGroup>
|
||||
<OrgProfile
|
||||
onSubmit={[Function]}
|
||||
orgName="Cool org"
|
||||
@@ -43,7 +43,7 @@ exports[`Render should render organization and preferences 1`] = `
|
||||
<SharedPreferences
|
||||
resourceUri="org"
|
||||
/>
|
||||
</Component>
|
||||
</VerticalGroup>
|
||||
</PageContents>
|
||||
</Page>
|
||||
`;
|
||||
|
||||
@@ -44,7 +44,7 @@ export class PluginDashboards extends PureComponent<Props, State> {
|
||||
const { dashboards } = this.state;
|
||||
return this.import(dashboards[index], true).then(() => {
|
||||
if (index + 1 < dashboards.length) {
|
||||
return new Promise(resolve => {
|
||||
return new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
this.importNext(index + 1).then(() => {
|
||||
resolve();
|
||||
|
||||
@@ -171,7 +171,6 @@ export class QueryGroupOptionsEditor extends PureComponent<Props, State> {
|
||||
type="text"
|
||||
className="width-6"
|
||||
placeholder="60"
|
||||
name={name}
|
||||
spellCheck={false}
|
||||
onBlur={this.onCacheTimeoutBlur}
|
||||
defaultValue={options.cacheTimeout ?? ''}
|
||||
@@ -205,7 +204,6 @@ export class QueryGroupOptionsEditor extends PureComponent<Props, State> {
|
||||
type="number"
|
||||
className="width-6"
|
||||
placeholder={`${realMd}`}
|
||||
name={name}
|
||||
spellCheck={false}
|
||||
onBlur={this.onMaxDataPointsBlur}
|
||||
defaultValue={value}
|
||||
@@ -246,7 +244,6 @@ export class QueryGroupOptionsEditor extends PureComponent<Props, State> {
|
||||
type="text"
|
||||
className="width-6"
|
||||
placeholder={`${minIntervalOnDs}`}
|
||||
name={name}
|
||||
spellCheck={false}
|
||||
onBlur={this.onMinIntervalBlur}
|
||||
defaultValue={options.minInterval ?? ''}
|
||||
|
||||
@@ -23,7 +23,7 @@ exports[`Render should render component 1`] = `
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
</div>
|
||||
<Component
|
||||
<SlideDown
|
||||
in={false}
|
||||
>
|
||||
<div
|
||||
@@ -68,7 +68,7 @@ exports[`Render should render component 1`] = `
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Component>
|
||||
</SlideDown>
|
||||
<EmptyListCTA
|
||||
buttonIcon="users-alt"
|
||||
buttonTitle="Add Group"
|
||||
@@ -114,7 +114,7 @@ exports[`Render should render groups table 1`] = `
|
||||
Add group
|
||||
</button>
|
||||
</div>
|
||||
<Component
|
||||
<SlideDown
|
||||
in={false}
|
||||
>
|
||||
<div
|
||||
@@ -159,7 +159,7 @@ exports[`Render should render groups table 1`] = `
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Component>
|
||||
</SlideDown>
|
||||
<div
|
||||
className="admin-list-table"
|
||||
>
|
||||
|
||||
@@ -41,7 +41,7 @@ exports[`Render should render teams table 1`] = `
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<Component
|
||||
<FilterInput
|
||||
inputClassName="gf-form-input"
|
||||
labelClassName="gf-form--has-input-icon gf-form--grow"
|
||||
onChange={[Function]}
|
||||
@@ -132,7 +132,7 @@ exports[`Render should render teams table 1`] = `
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={false}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
@@ -184,7 +184,7 @@ exports[`Render should render teams table 1`] = `
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={false}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
@@ -236,7 +236,7 @@ exports[`Render should render teams table 1`] = `
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={false}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
@@ -288,7 +288,7 @@ exports[`Render should render teams table 1`] = `
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={false}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
@@ -340,7 +340,7 @@ exports[`Render should render teams table 1`] = `
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={false}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
@@ -376,7 +376,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<Component
|
||||
<FilterInput
|
||||
inputClassName="gf-form-input"
|
||||
labelClassName="gf-form--has-input-icon gf-form--grow"
|
||||
onChange={[Function]}
|
||||
@@ -467,7 +467,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={true}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
@@ -503,7 +503,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<Component
|
||||
<FilterInput
|
||||
inputClassName="gf-form-input"
|
||||
labelClassName="gf-form--has-input-icon gf-form--grow"
|
||||
onChange={[Function]}
|
||||
@@ -594,7 +594,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={true}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
|
||||
@@ -21,7 +21,7 @@ exports[`Render should render team members when sync enabled 1`] = `
|
||||
<td>
|
||||
testName
|
||||
</td>
|
||||
<Component
|
||||
<WithFeatureToggle
|
||||
featureToggle={false}
|
||||
>
|
||||
<td
|
||||
@@ -35,7 +35,7 @@ exports[`Render should render team members when sync enabled 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</Component>
|
||||
</WithFeatureToggle>
|
||||
<td>
|
||||
<TagBadge
|
||||
count={0}
|
||||
@@ -48,7 +48,7 @@ exports[`Render should render team members when sync enabled 1`] = `
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={true}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
@@ -78,7 +78,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned off should not ren
|
||||
<td>
|
||||
testName
|
||||
</td>
|
||||
<Component
|
||||
<WithFeatureToggle
|
||||
featureToggle={false}
|
||||
>
|
||||
<td
|
||||
@@ -134,11 +134,11 @@ exports[`Render when feature toggle editorsCanAdmin is turned off should not ren
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</Component>
|
||||
</WithFeatureToggle>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={false}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
@@ -168,7 +168,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p
|
||||
<td>
|
||||
testName
|
||||
</td>
|
||||
<Component
|
||||
<WithFeatureToggle
|
||||
featureToggle={true}
|
||||
>
|
||||
<td
|
||||
@@ -224,11 +224,11 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</Component>
|
||||
</WithFeatureToggle>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={false}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
@@ -258,7 +258,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render s
|
||||
<td>
|
||||
testName
|
||||
</td>
|
||||
<Component
|
||||
<WithFeatureToggle
|
||||
featureToggle={true}
|
||||
>
|
||||
<td
|
||||
@@ -272,11 +272,11 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render s
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</Component>
|
||||
</WithFeatureToggle>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<Component
|
||||
<DeleteButton
|
||||
disabled={true}
|
||||
onConfirm={[Function]}
|
||||
size="sm"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user