merge from 14293-metric-display-names

This commit is contained in:
Erik Sundell
2019-01-02 15:07:38 +01:00
237 changed files with 3781 additions and 4660 deletions
+5 -5
View File
@@ -19,7 +19,7 @@ version: 2
jobs:
mysql-integration-test:
docker:
- image: circleci/golang:1.11
- image: circleci/golang:1.11.4
- image: circleci/mysql:5.6-ram
environment:
MYSQL_ROOT_PASSWORD: rootpass
@@ -39,7 +39,7 @@ jobs:
postgres-integration-test:
docker:
- image: circleci/golang:1.11
- image: circleci/golang:1.11.4
- image: circleci/postgres:9.3-ram
environment:
POSTGRES_USER: grafanatest
@@ -74,7 +74,7 @@ jobs:
gometalinter:
docker:
- image: circleci/golang:1.11
- image: circleci/golang:1.11.4
environment:
# we need CGO because of go-sqlite3
CGO_ENABLED: 1
@@ -117,7 +117,7 @@ jobs:
test-backend:
docker:
- image: circleci/golang:1.11
- image: circleci/golang:1.11.4
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -175,7 +175,7 @@ jobs:
build:
docker:
- image: grafana/build-container:1.2.1
- image: grafana/build-container:1.2.2
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
+8 -1
View File
@@ -2,6 +2,7 @@
### New Features
* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
* **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109)
### Minor
@@ -13,12 +14,18 @@
* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
### Bug fixes
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
# 5.4.2 (2018-12-13)
* **Datasource admin**: Fix for issue creating new data source when same name exists [#14467](https://github.com/grafana/grafana/issues/14467)
* **OAuth**: Fix for oauth auto login setting, can now be set using env variable [#14435](https://github.com/grafana/grafana/issues/14435)
* **Dashboard search**: Fix for searching tags in tags filter dropdown.
* **Dashboard search**: Fix for searching tags in tags filter dropdown.
# 5.4.1 (2018-12-10)
+1 -1
View File
@@ -1,5 +1,5 @@
# Golang build container
FROM golang:1.11
FROM golang:1.11.4
WORKDIR $GOPATH/src/github.com/grafana/grafana
+1 -1
View File
@@ -90,7 +90,7 @@ Choose this option to build on platforms other than linux/amd64 and/or not have
The resulting image will be tagged as `grafana/grafana:dev`
Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Perferences -> Advanced), otherwize you may faild at `grunt build`
Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Preferences -> Advanced), otherwize you may faild at `grunt build`
### Dev config
+1 -1
View File
@@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
environment:
nodejs_version: "8"
GOPATH: C:\gopath
GOVERSION: 1.11
GOVERSION: 1.11.4
install:
- rmdir c:\go /s /q
+4
View File
@@ -103,6 +103,9 @@ server_cert_name =
# For "sqlite3" only, path relative to data_path setting
path = grafana.db
# For "sqlite3" only. cache mode setting used for connecting to the database
cache_mode = private
#################################### Session #############################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -335,6 +338,7 @@ tls_skip_verify_insecure = false
tls_client_cert =
tls_client_key =
tls_client_ca =
send_client_credentials_via_post = false
#################################### Basic Auth ##########################
[auth.basic]
+7
View File
@@ -99,6 +99,9 @@
# Set to true to log the sql calls and execution times.
log_queries =
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
;cache_mode = private
#################################### Session ####################################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -284,6 +287,10 @@ log_queries =
;tls_client_key =
;tls_client_ca =
; Set to true to enable sending client_id and client_secret via POST body instead of Basic authentication HTTP header
; This might be required if the OAuth provider is not RFC6749 compliant, only supporting credentials passed via POST payload
;send_client_credentials_via_post = false
#################################### Grafana.com Auth ####################
[auth.grafana_com]
;enabled = false
@@ -0,0 +1,7 @@
FROM golang:latest
ADD main.go /
WORKDIR /
RUN go build -o main .
EXPOSE 3010
ENTRYPOINT ["/main"]
@@ -0,0 +1,5 @@
alert_webhook_listener:
build: docker/blocks/alert_webhook_listener
network_mode: host
ports:
- "3010:3010"
@@ -0,0 +1,24 @@
package main
import (
"fmt"
"io"
"io/ioutil"
"net/http"
)
func hello(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return
}
line := fmt.Sprintf("webbhook: -> %s", string(body))
fmt.Println(line)
io.WriteString(w, line)
}
func main() {
http.HandleFunc("/", hello)
http.ListenAndServe(":3010", nil)
}
+2 -1
View File
@@ -31,9 +31,10 @@ auto_sign_up = true
ldap_sync_ttl = 60
# Limit where auth proxy requests come from by configuring a list of IP addresses.
# This can be used to prevent users spoofing the X-WEBAUTH-USER header.
# Example `whitelist = 192.168.1.1, 192.168.1.0/24, 2001::23, 2001::0/120`
whitelist =
# Optionally define more headers to sync other user attributes
# Example `headers = Name:X-WEBAUTH-NAME Email:X-WEBAUTH-EMAIL``
# Example `headers = Name:X-WEBAUTH-NAME Email:X-WEBAUTH-EMAIL`
headers =
```
+12 -1
View File
@@ -17,7 +17,7 @@ can find examples using Okta, BitBucket, OneLogin and Azure.
This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the prefix path of `/login/generic_oauth`.
You may have to set the `root_url` option of `[server]` for the callback URL to be
You may have to set the `root_url` option of `[server]` for the callback URL to be
correct. For example in case you are serving Grafana behind a proxy.
Example config:
@@ -209,6 +209,17 @@ allowed_organizations =
token_url = https://<your domain>.my.centrify.com/OAuth2/Token/<Application ID>
```
## Set up OAuth2 with non-compliant providers
Some OAuth2 providers might not support `client_id` and `client_secret` passed via Basic Authentication HTTP header, which
results in `invalid_client` error. To allow Grafana to authenticate via these type of providers, the client identifiers must be
send via POST body, which can be enabled via the following settings:
```bash
[auth.generic_oauth]
send_client_credentials_via_post = true
```
<hr>
@@ -250,6 +250,12 @@ Sets the maximum amount of time a connection may be reused. The default is 14400
Set to `true` to log the sql calls and execution times.
### cache_mode
For "sqlite3" only. [Shared cache](https://www.sqlite.org/sharedcache.html) setting used for connecting to the database. (private, shared)
Defaults to private.
<hr />
## [security]
+3 -1
View File
@@ -6,7 +6,9 @@ module.exports = {
},
"moduleDirectories": ["node_modules", "public"],
"roots": [
"<rootDir>/public"
"<rootDir>/public/app",
"<rootDir>/public/test",
"<rootDir>/packages"
],
"testRegex": "(\\.|/)(test)\\.(jsx?|tsx?)$",
"moduleFileExtensions": [
+41 -16
View File
@@ -1,4 +1,5 @@
{
"private": true,
"author": {
"name": "Torkel Ödegaard",
"company": "Grafana Labs"
@@ -11,14 +12,16 @@
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1",
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/preset-env": "^7.1.0",
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.1.0",
"@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1",
"@types/classnames": "^2.2.6",
"@types/d3": "^4.10.1",
"@types/enzyme": "^3.1.13",
"@types/jest": "^23.3.2",
"@types/jquery": "^1.10.35",
"@types/node": "^8.0.31",
"@types/react": "^16.7.6",
"@types/react-custom-scrollbars": "^4.0.5",
@@ -49,15 +52,12 @@
"grunt-cli": "~1.2.0",
"grunt-contrib-clean": "~1.0.0",
"grunt-contrib-compress": "^1.3.0",
"grunt-contrib-concat": "^1.0.1",
"grunt-contrib-copy": "~1.0.0",
"grunt-contrib-cssmin": "~1.0.2",
"grunt-exec": "^1.0.1",
"grunt-newer": "^1.3.0",
"grunt-notify": "^0.4.5",
"grunt-postcss": "^0.8.0",
"grunt-sass": "^2.0.0",
"grunt-sass-lint": "^0.2.2",
"grunt-sass-lint": "^0.2.4",
"grunt-usemin": "3.1.1",
"grunt-webpack": "^3.0.2",
"html-loader": "^0.5.1",
@@ -73,6 +73,7 @@
"ng-annotate-webpack-plugin": "^0.3.0",
"ngtemplate-loader": "^2.0.1",
"npm": "^5.4.2",
"node-sass": "^4.11.0",
"optimize-css-assets-webpack-plugin": "^4.0.2",
"phantomjs-prebuilt": "^2.1.15",
"postcss-browser-reporter": "^0.5.0",
@@ -92,6 +93,7 @@
"tslib": "^1.9.3",
"tslint": "^5.8.0",
"tslint-loader": "^3.5.3",
"tslint-react": "^3.6.0",
"typescript": "^3.0.3",
"uglifyjs-webpack-plugin": "^1.2.7",
"webpack": "4.19.1",
@@ -108,15 +110,30 @@
"watch": "webpack --progress --colors --watch --mode development --config scripts/webpack/webpack.dev.js",
"build": "grunt build",
"test": "grunt test",
"lint": "tslint -c tslint.json --project tsconfig.json",
"tslint": "tslint -c tslint.json --project tsconfig.json",
"typecheck": "tsc --noEmit",
"jest": "jest --notify --watch",
"api-tests": "jest --notify --watch --config=tests/api/jest.js",
"precommit": "lint-staged && grunt precommit"
"precommit": "grunt precommit"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged && grunt precommit"
}
},
"lint-staged": {
"*.{ts,tsx}": ["prettier --write", "git add"],
"*.scss": ["prettier --write", "git add"],
"*pkg/**/*.go": ["gofmt -w -s", "git add"]
"*.{ts,tsx}": [
"prettier --write",
"git add"
],
"*.scss": [
"prettier --write",
"git add"
],
"*pkg/**/*.go": [
"gofmt -w -s",
"git add"
]
},
"prettier": {
"trailingComma": "es5",
@@ -126,6 +143,7 @@
"license": "Apache-2.0",
"dependencies": {
"@babel/polyfill": "^7.0.0",
"@torkelo/react-select": "2.1.1",
"angular": "1.6.6",
"angular-bindonce": "0.3.1",
"angular-native-dragdrop": "1.2.2",
@@ -133,7 +151,7 @@
"angular-sanitize": "1.6.6",
"baron": "^3.0.3",
"brace": "^0.10.0",
"classnames": "^2.2.5",
"classnames": "^2.2.6",
"clipboard": "^1.7.1",
"d3": "^4.11.0",
"d3-scale-chromatic": "^1.3.0",
@@ -152,10 +170,9 @@
"react-custom-scrollbars": "^4.2.1",
"react-dom": "^16.6.3",
"react-grid-layout": "0.16.6",
"react-popper": "^1.3.0",
"react-highlight-words": "0.11.0",
"react-popper": "^1.3.0",
"react-redux": "^5.0.7",
"@torkelo/react-select": "2.1.1",
"react-sizeme": "^2.3.6",
"react-table": "^6.8.6",
"react-transition-group": "^2.2.1",
@@ -165,18 +182,26 @@
"redux-thunk": "^2.3.0",
"remarkable": "^1.7.1",
"rst2html": "github:thoward/rst2html#990cb89",
"rxjs": "^5.4.3",
"rxjs": "^6.3.3",
"slate": "^0.33.4",
"slate-plain-serializer": "^0.5.10",
"slate-prism": "^0.5.0",
"slate-react": "^0.12.4",
"tether": "^1.4.0",
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
"tinycolor2": "^1.4.1",
"tslint-react": "^3.6.0"
"tinycolor2": "^1.4.1"
},
"resolutions": {
"caniuse-db": "1.0.30000772",
"**/@types/react": "16.7.6"
},
"workspaces": {
"packages": [
"packages/*"
],
"nohoist": [
"**/@types/*",
"**/@types/*/**"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
# Shared build scripts
Shared build scripts for plugins & internal packages.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@grafana/build",
"private": true,
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"tslint": "echo \"Nothing to do\"",
"typecheck": "echo \"Nothing to do\""
},
"author": "",
"license": "ISC"
}
+3
View File
@@ -0,0 +1,3 @@
# Grafana (WIP) shared component library
Used by internal & external plugins.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@grafana/ui",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"tslint": "tslint -c tslint.json --project tsconfig.json",
"typecheck": "tsc --noEmit"
},
"author": "",
"license": "ISC",
"dependencies": {
"@torkelo/react-select": "2.1.1",
"classnames": "^2.2.5",
"jquery": "^3.2.1",
"lodash": "^4.17.10",
"moment": "^2.22.2",
"react": "^16.6.3",
"react-dom": "^16.6.3",
"react-highlight-words": "0.11.0",
"react-popper": "^1.3.0",
"react-transition-group": "^2.2.1",
"react-virtualized": "^9.21.0"
},
"devDependencies": {
"@types/jest": "^23.3.2",
"@types/lodash": "^4.14.119",
"@types/react": "^16.7.6",
"@types/classnames": "^2.2.6",
"@types/jquery": "^1.10.35",
"typescript": "^3.2.2"
}
}
@@ -1,10 +1,10 @@
import React from 'react';
import DeleteButton from './DeleteButton';
import { DeleteButton } from './DeleteButton';
import { shallow } from 'enzyme';
describe('DeleteButton', () => {
let wrapper;
let deleted;
let wrapper: any;
let deleted: any;
beforeAll(() => {
deleted = false;
@@ -12,7 +12,8 @@ describe('DeleteButton', () => {
function deleteItem() {
deleted = true;
}
wrapper = shallow(<DeleteButton onConfirmDelete={() => deleteItem()} />);
wrapper = shallow(<DeleteButton onConfirm={() => deleteItem()} />);
});
it('should show confirm delete when clicked', () => {
@@ -1,19 +1,19 @@
import React, { PureComponent } from 'react';
import React, { PureComponent, SyntheticEvent } from 'react';
export interface DeleteButtonProps {
onConfirmDelete();
interface Props {
onConfirm(): void;
}
export interface DeleteButtonStates {
interface State {
showConfirm: boolean;
}
export default class DeleteButton extends PureComponent<DeleteButtonProps, DeleteButtonStates> {
state: DeleteButtonStates = {
export class DeleteButton extends PureComponent<Props, State> {
state: State = {
showConfirm: false,
};
onClickDelete = event => {
onClickDelete = (event: SyntheticEvent) => {
if (event) {
event.preventDefault();
}
@@ -23,7 +23,7 @@ export default class DeleteButton extends PureComponent<DeleteButtonProps, Delet
});
};
onClickCancel = event => {
onClickCancel = (event: SyntheticEvent) => {
if (event) {
event.preventDefault();
}
@@ -33,7 +33,7 @@ export default class DeleteButton extends PureComponent<DeleteButtonProps, Delet
};
render() {
const onClickConfirm = this.props.onConfirmDelete;
const { onConfirm } = this.props;
let showConfirm;
let showDeleteButton;
@@ -55,7 +55,7 @@ export default class DeleteButton extends PureComponent<DeleteButtonProps, Delet
<a className="btn btn-small" onClick={this.onClickCancel}>
Cancel
</a>
<a className="btn btn-danger btn-small" onClick={onClickConfirm}>
<a className="btn btn-danger btn-small" onClick={onConfirm}>
Confirm Delete
</a>
</span>
@@ -0,0 +1 @@
@import 'DeleteButton/DeleteButton';
@@ -0,0 +1 @@
export { DeleteButton } from './DeleteButton/DeleteButton';
@@ -0,0 +1,23 @@
import React, { SFC, ReactNode } from 'react';
import classNames from 'classnames';
interface Props {
children: ReactNode;
htmlFor?: string;
className?: string;
isFocused?: boolean;
isInvalid?: boolean;
}
export const GfFormLabel: SFC<Props> = ({ children, isFocused, isInvalid, className, htmlFor, ...rest }) => {
const classes = classNames('gf-form-label', className, {
'gf-form-label--is-focused': isFocused,
'gf-form-label--is-invalid': isInvalid,
});
return (
<label className={classes} {...rest} htmlFor={htmlFor}>
{children}
</label>
);
};
+1
View File
@@ -0,0 +1 @@
export { GfFormLabel } from './GfFormLabel/GfFormLabel';
+1
View File
@@ -0,0 +1 @@
@import 'components/index';
+5
View File
@@ -0,0 +1,5 @@
export * from './components';
export * from './visualizations';
export * from './types';
export * from './utils';
export * from './forms';
+3
View File
@@ -0,0 +1,3 @@
export * from './series';
export * from './time';
export * from './panel';
+17
View File
@@ -0,0 +1,17 @@
interface JQueryPlot {
(element: HTMLElement | JQuery, data: any, options: any): void;
plugins: any[];
}
interface JQueryStatic {
plot: JQueryPlot;
}
interface JQuery {
place_tt: any;
modal: any;
tagsinput: any;
typeahead: any;
accessKey: any;
tooltip: any;
}
+31
View File
@@ -0,0 +1,31 @@
import { TimeSeries, LoadingState } from './series';
import { TimeRange } from './time';
export interface PanelProps<T = any> {
timeSeries: TimeSeries[];
timeRange: TimeRange;
loading: LoadingState;
options: T;
renderCounter: number;
width: number;
height: number;
}
export interface PanelOptionsProps<T = any> {
options: T;
onChange: (options: T) => void;
}
export interface PanelSize {
width: number;
height: number;
}
export interface PanelMenuItem {
type?: 'submenu' | 'divider';
text?: string;
iconClassName?: string;
onClick?: () => void;
shortcut?: string;
subMenu?: PanelMenuItem[];
}
+53
View File
@@ -0,0 +1,53 @@
export enum LoadingState {
NotStarted = 'NotStarted',
Loading = 'Loading',
Done = 'Done',
Error = 'Error',
}
export type TimeSeriesValue = number | null;
export type TimeSeriesPoints = TimeSeriesValue[][];
export interface TimeSeries {
target: string;
datapoints: TimeSeriesPoints;
unit?: string;
}
/** View model projection of a time series */
export interface TimeSeriesVM {
label: string;
color: string;
data: TimeSeriesValue[][];
stats: TimeSeriesStats;
}
export interface TimeSeriesStats {
total: number | null;
max: number | null;
min: number | null;
logmin: number;
avg: number | null;
current: number | null;
first: number | null;
delta: number;
diff: number | null;
range: number | null;
timeStep: number;
count: number;
allIsNull: boolean;
allIsZero: boolean;
}
export enum NullValueMode {
Null = 'null',
Ignore = 'connected',
AsZero = 'null as zero',
}
/** View model projection of many time series */
export interface TimeSeriesVMs {
[index: number]: TimeSeriesVM;
length: number;
}
+17
View File
@@ -0,0 +1,17 @@
import { Moment } from 'moment';
export interface RawTimeRange {
from: Moment | string;
to: Moment | string;
}
export interface TimeRange {
from: Moment;
to: Moment;
raw: RawTimeRange;
}
export interface IntervalValues {
interval: string; // 10s,5m
intervalMs: number;
}
+1
View File
@@ -0,0 +1 @@
export * from './processTimeSeries';
@@ -0,0 +1,174 @@
// Libraries
import _ from 'lodash';
// Types
import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
interface Options {
timeSeries: TimeSeries[];
nullValueMode: NullValueMode;
colorPalette: string[];
}
export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: Options): TimeSeriesVMs {
const vmSeries = timeSeries.map((item, index) => {
const colorIndex = index % colorPalette.length;
const label = item.target;
const result = [];
// stat defaults
let total = 0;
let max: TimeSeriesValue = -Number.MAX_VALUE;
let min: TimeSeriesValue = Number.MAX_VALUE;
let logmin = Number.MAX_VALUE;
let avg: TimeSeriesValue = null;
let current: TimeSeriesValue = null;
let first: TimeSeriesValue = null;
let delta: TimeSeriesValue = 0;
let diff: TimeSeriesValue = null;
let range: TimeSeriesValue = null;
let timeStep = Number.MAX_VALUE;
let allIsNull = true;
let allIsZero = true;
const ignoreNulls = nullValueMode === NullValueMode.Ignore;
const nullAsZero = nullValueMode === NullValueMode.AsZero;
let currentTime: TimeSeriesValue = null;
let currentValue: TimeSeriesValue = null;
let nonNulls = 0;
let previousTime: TimeSeriesValue = null;
let previousValue = 0;
let previousDeltaUp = true;
for (let i = 0; i < item.datapoints.length; i++) {
currentValue = item.datapoints[i][0];
currentTime = item.datapoints[i][1];
if (typeof currentTime !== 'number') {
continue;
}
if (typeof currentValue !== 'number') {
continue;
}
// Due to missing values we could have different timeStep all along the series
// so we have to find the minimum one (could occur with aggregators such as ZimSum)
if (previousTime !== null && currentTime !== null) {
const currentStep = currentTime - previousTime;
if (currentStep < timeStep) {
timeStep = currentStep;
}
}
previousTime = currentTime;
if (currentValue === null) {
if (ignoreNulls) {
continue;
}
if (nullAsZero) {
currentValue = 0;
}
}
if (currentValue !== null) {
if (_.isNumber(currentValue)) {
total += currentValue;
allIsNull = false;
nonNulls++;
}
if (currentValue > max) {
max = currentValue;
}
if (currentValue < min) {
min = currentValue;
}
if (first === null) {
first = currentValue;
} else {
if (previousValue > currentValue) {
// counter reset
previousDeltaUp = false;
if (i === item.datapoints.length - 1) {
// reset on last
delta += currentValue;
}
} else {
if (previousDeltaUp) {
delta += currentValue - previousValue; // normal increment
} else {
delta += currentValue; // account for counter reset
}
previousDeltaUp = true;
}
}
previousValue = currentValue;
if (currentValue < logmin && currentValue > 0) {
logmin = currentValue;
}
if (currentValue !== 0) {
allIsZero = false;
}
}
result.push([currentTime, currentValue]);
}
if (max === -Number.MAX_VALUE) {
max = null;
}
if (min === Number.MAX_VALUE) {
min = null;
}
if (result.length && !allIsNull) {
avg = total / nonNulls;
current = result[result.length - 1][1];
if (current === null && result.length > 1) {
current = result[result.length - 2][1];
}
}
if (max !== null && min !== null) {
range = max - min;
}
if (current !== null && first !== null) {
diff = current - first;
}
const count = result.length;
return {
data: result,
label: label,
color: colorPalette[colorIndex],
stats: {
total,
min,
max,
current,
logmin,
avg,
diff,
delta,
timeStep,
range,
count,
first,
allIsZero,
allIsNull,
},
};
});
return vmSeries;
}
@@ -1,11 +1,9 @@
// Libraries
import $ from 'jquery';
import React, { PureComponent } from 'react';
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.time';
// Types
import { TimeRange, TimeSeriesVMs } from 'app/types';
import { TimeRange, TimeSeriesVMs } from '../../types';
interface GraphProps {
timeSeries: TimeSeriesVMs;
@@ -24,7 +22,7 @@ export class Graph extends PureComponent<GraphProps> {
showBars: false,
};
element: HTMLElement;
element: HTMLElement | null;
componentDidUpdate() {
this.draw();
@@ -35,6 +33,10 @@ export class Graph extends PureComponent<GraphProps> {
}
draw() {
if (this.element === null) {
return;
}
const { width, timeSeries, timeRange, showLines, showBars, showPoints } = this.props;
if (!width) {
@@ -76,7 +78,7 @@ export class Graph extends PureComponent<GraphProps> {
max: max,
label: 'Datetime',
ticks: ticks,
timeformat: time_format(ticks, min, max),
timeformat: timeFormat(ticks, min, max),
},
grid: {
minBorderMargin: 0,
@@ -109,7 +111,7 @@ export class Graph extends PureComponent<GraphProps> {
}
// Copied from graph.ts
function time_format(ticks, min, max) {
function timeFormat(ticks: number, min: number, max: number): string {
if (min && max && ticks) {
const range = max - min;
const secPerTick = range / ticks / 1000;
@@ -0,0 +1 @@
export { Graph } from './Graph/Graph';
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"dist"
],
"compilerOptions": {
"rootDir": ".",
"module": "esnext",
"outDir": "dist",
"declaration": true,
"noImplicitAny": true,
"strictNullChecks": true
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tslint.json"
}
+112 -8
View File
@@ -1,10 +1,15 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/guardian"
@@ -12,6 +17,11 @@ import (
"github.com/grafana/grafana/pkg/util"
)
var client = &http.Client{
Timeout: time.Second * 5,
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
}
func GetSharingOptions(c *m.ReqContext) {
c.JSON(200, util.DynMap{
"externalSnapshotURL": setting.ExternalSnapshotUrl,
@@ -20,26 +30,79 @@ func GetSharingOptions(c *m.ReqContext) {
})
}
type CreateExternalSnapshotResponse struct {
Key string `json:"key"`
DeleteKey string `json:"deleteKey"`
Url string `json:"url"`
DeleteUrl string `json:"deleteUrl"`
}
func createExternalDashboardSnapshot(cmd m.CreateDashboardSnapshotCommand) (*CreateExternalSnapshotResponse, error) {
var createSnapshotResponse CreateExternalSnapshotResponse
message := map[string]interface{}{
"name": cmd.Name,
"expires": cmd.Expires,
"dashboard": cmd.Dashboard,
}
messageBytes, err := simplejson.NewFromAny(message).Encode()
if err != nil {
return nil, err
}
response, err := client.Post(setting.ExternalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes))
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return nil, fmt.Errorf("Create external snapshot response status code %d", response.StatusCode)
}
if err := json.NewDecoder(response.Body).Decode(&createSnapshotResponse); err != nil {
return nil, err
}
return &createSnapshotResponse, nil
}
// POST /api/snapshots
func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotCommand) {
if cmd.Name == "" {
cmd.Name = "Unnamed snapshot"
}
var url string
cmd.ExternalUrl = ""
cmd.OrgId = c.OrgId
cmd.UserId = c.UserId
if cmd.External {
// external snapshot ref requires key and delete key
if cmd.Key == "" || cmd.DeleteKey == "" {
c.JsonApiErr(400, "Missing key and delete key for external snapshot", nil)
if !setting.ExternalEnabled {
c.JsonApiErr(403, "External dashboard creation is disabled", nil)
return
}
cmd.OrgId = -1
cmd.UserId = -1
response, err := createExternalDashboardSnapshot(cmd)
if err != nil {
c.JsonApiErr(500, "Failed to create external snaphost", err)
return
}
url = response.Url
cmd.Key = response.Key
cmd.DeleteKey = response.DeleteKey
cmd.ExternalUrl = response.Url
cmd.ExternalDeleteUrl = response.DeleteUrl
cmd.Dashboard = simplejson.New()
metrics.M_Api_Dashboard_Snapshot_External.Inc()
} else {
cmd.Key = util.GetRandomString(32)
cmd.DeleteKey = util.GetRandomString(32)
cmd.OrgId = c.OrgId
cmd.UserId = c.UserId
url = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
metrics.M_Api_Dashboard_Snapshot_Create.Inc()
}
@@ -51,7 +114,7 @@ func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotComma
c.JSON(200, util.DynMap{
"key": cmd.Key,
"deleteKey": cmd.DeleteKey,
"url": setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key),
"url": url,
"deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey),
})
}
@@ -91,6 +154,33 @@ func GetDashboardSnapshot(c *m.ReqContext) {
c.JSON(200, dto)
}
func deleteExternalDashboardSnapshot(externalUrl string) error {
response, err := client.Get(externalUrl)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == 200 {
return nil
}
// Gracefully ignore "snapshot not found" errors as they could have already
// been removed either via the cleanup script or by request.
if response.StatusCode == 500 {
var respJson map[string]interface{}
if err := json.NewDecoder(response.Body).Decode(&respJson); err != nil {
return err
}
if respJson["message"] == "Failed to get dashboard snapshot" {
return nil
}
}
return fmt.Errorf("Unexpected response when deleting external snapshot. Status code: %d", response.StatusCode)
}
// GET /api/snapshots-delete/:deleteKey
func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response {
key := c.Params(":deleteKey")
@@ -102,6 +192,13 @@ func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response {
return Error(500, "Failed to get dashboard snapshot", err)
}
if query.Result.External {
err := deleteExternalDashboardSnapshot(query.Result.ExternalDeleteUrl)
if err != nil {
return Error(500, "Failed to delete external dashboard", err)
}
}
cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
if err := bus.Dispatch(cmd); err != nil {
@@ -138,6 +235,13 @@ func DeleteDashboardSnapshot(c *m.ReqContext) Response {
return Error(403, "Access denied to this snapshot", nil)
}
if query.Result.External {
err := deleteExternalDashboardSnapshot(query.Result.ExternalDeleteUrl)
if err != nil {
return Error(500, "Failed to delete external dashboard", err)
}
}
cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
if err := bus.Dispatch(cmd); err != nil {
+87
View File
@@ -1,6 +1,9 @@
package api
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
@@ -13,13 +16,17 @@ import (
func TestDashboardSnapshotApiEndpoint(t *testing.T) {
Convey("Given a single snapshot", t, func() {
var externalRequest *http.Request
jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`))
mockSnapshotResult := &m.DashboardSnapshot{
Id: 1,
Key: "12345",
DeleteKey: "54321",
Dashboard: jsonModel,
Expires: time.Now().Add(time.Duration(1000) * time.Second),
UserId: 999999,
External: true,
}
bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error {
@@ -45,13 +52,25 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
return nil
})
setupRemoteServer := func(fn func(http.ResponseWriter, *http.Request)) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
fn(rw, r)
}))
}
Convey("When user has editor role and is not in the ACL", func() {
Convey("Should not be able to delete snapshot", func() {
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
externalRequest = req
})
mockSnapshotResult.ExternalDeleteUrl = ts.URL
sc.handlerFunc = DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
So(sc.resp.Code, ShouldEqual, 403)
So(externalRequest, ShouldBeNil)
})
})
})
@@ -59,6 +78,12 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
Convey("When user is anonymous", func() {
Convey("Should be able to delete snapshot by deleteKey", func() {
anonymousUserScenario("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", func(sc *scenarioContext) {
ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(200)
externalRequest = req
})
mockSnapshotResult.ExternalDeleteUrl = ts.URL
sc.handlerFunc = DeleteDashboardSnapshotByDeleteKey
sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec()
@@ -67,6 +92,10 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
So(err, ShouldBeNil)
So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
So(externalRequest.Method, ShouldEqual, http.MethodGet)
So(fmt.Sprintf("http://%s", externalRequest.Host), ShouldEqual, ts.URL)
So(externalRequest.URL.EscapedPath(), ShouldEqual, "/")
})
})
})
@@ -79,6 +108,12 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
Convey("Should be able to delete a snapshot", func() {
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(200)
externalRequest = req
})
mockSnapshotResult.ExternalDeleteUrl = ts.URL
sc.handlerFunc = DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
@@ -87,6 +122,8 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
So(err, ShouldBeNil)
So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
So(fmt.Sprintf("http://%s", externalRequest.Host), ShouldEqual, ts.URL)
So(externalRequest.URL.EscapedPath(), ShouldEqual, "/")
})
})
})
@@ -94,6 +131,7 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
Convey("When user is editor and is the creator of the snapshot", func() {
aclMockResp = []*m.DashboardAclInfoDTO{}
mockSnapshotResult.UserId = TestUserID
mockSnapshotResult.External = false
Convey("Should be able to delete a snapshot", func() {
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
@@ -108,5 +146,54 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
})
})
})
Convey("When deleting an external snapshot", func() {
aclMockResp = []*m.DashboardAclInfoDTO{}
mockSnapshotResult.UserId = TestUserID
Convey("Should gracefully delete local snapshot when remote snapshot has already been removed", func() {
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
rw.Write([]byte(`{"message":"Failed to get dashboard snapshot"}`))
rw.WriteHeader(500)
})
mockSnapshotResult.ExternalDeleteUrl = ts.URL
sc.handlerFunc = DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
})
Convey("Should fail to delete local snapshot when an unexpected 500 error occurs", func() {
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(500)
rw.Write([]byte(`{"message":"Unexpected"}`))
})
mockSnapshotResult.ExternalDeleteUrl = ts.URL
sc.handlerFunc = DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
So(sc.resp.Code, ShouldEqual, 500)
})
})
Convey("Should fail to delete local snapshot when an unexpected remote error occurs", func() {
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(404)
})
mockSnapshotResult.ExternalDeleteUrl = ts.URL
sc.handlerFunc = DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
So(sc.resp.Code, ShouldEqual, 500)
})
})
})
})
}
+7 -5
View File
@@ -186,16 +186,18 @@ func getPanelSort(id string) int {
sort = 1
case "singlestat":
sort = 2
case "table":
case "gauge":
sort = 3
case "text":
case "table":
sort = 4
case "heatmap":
case "text":
sort = 5
case "alertlist":
case "heatmap":
sort = 6
case "dashlist":
case "alertlist":
sort = 7
case "dashlist":
sort = 8
}
return sort
}
+8
View File
@@ -164,6 +164,14 @@ func GetPluginMarkdown(c *m.ReqContext) Response {
return Error(500, "Could not get markdown file", err)
}
// fallback try readme
if len(content) == 0 {
content, err = plugins.GetPluginMarkdown(pluginID, "readme")
if err != nil {
return Error(501, "Could not get markdown file", err)
}
}
resp := Respond(200, content)
resp.Header("Content-Type", "text/plain; charset=utf-8")
return resp
+2
View File
@@ -292,6 +292,8 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) {
Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1),
}
a.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq))
searchResult, err = a.conn.Search(&searchReq)
if err != nil {
return nil, err
+21 -7
View File
@@ -198,17 +198,31 @@ func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error
}
proxies := strings.Split(setting.AuthProxyWhitelist, ",")
sourceIP, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return err
var proxyObjs []*net.IPNet
for _, proxy := range proxies {
proxyObjs = append(proxyObjs, coerceProxyAddress(proxy))
}
// Compare allowed IP addresses to actual address
for _, proxyIP := range proxies {
if sourceIP == strings.TrimSpace(proxyIP) {
sourceIP, _, _ := net.SplitHostPort(remoteAddr)
sourceObj := net.ParseIP(sourceIP)
for _, proxyObj := range proxyObjs {
if proxyObj.Contains(sourceObj) {
return nil
}
}
return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, sourceIP)
}
func coerceProxyAddress(proxyAddr string) *net.IPNet {
proxyAddr = strings.TrimSpace(proxyAddr)
if !strings.Contains(proxyAddr, "/") {
proxyAddr = strings.Join([]string{proxyAddr, "32"}, "/")
}
_, network, err := net.ParseCIDR(proxyAddr)
if err != nil {
fmt.Println(err)
}
return network
}
+90
View File
@@ -271,6 +271,23 @@ func TestMiddlewareContext(t *testing.T) {
})
})
middlewareScenario("When auth_proxy is enabled and IPv4 request RemoteAddr is not within trusted CIDR block", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
setting.AuthProxyHeaderProperty = "username"
setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120"
sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.req.RemoteAddr = "192.168.3.1:12345"
sc.exec()
Convey("should return 407 status code", func() {
So(sc.resp.Code, ShouldEqual, 407)
So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 192.168.3.1 is not from the authentication proxy")
})
})
middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is not trusted", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
@@ -288,6 +305,23 @@ func TestMiddlewareContext(t *testing.T) {
})
})
middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is not within trusted CIDR block", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
setting.AuthProxyHeaderProperty = "username"
setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120"
sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.req.RemoteAddr = "[2001:23]:12345"
sc.exec()
Convey("should return 407 status code", func() {
So(sc.resp.Code, ShouldEqual, 407)
So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 2001:23 is not from the authentication proxy")
})
})
middlewareScenario("When auth_proxy is enabled and request RemoteAddr is trusted", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
@@ -316,6 +350,62 @@ func TestMiddlewareContext(t *testing.T) {
})
})
middlewareScenario("When auth_proxy is enabled and IPv4 request RemoteAddr is within trusted CIDR block", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
setting.AuthProxyHeaderProperty = "username"
setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120"
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
return nil
})
bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
cmd.Result = &m.User{Id: 33}
return nil
})
sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.req.RemoteAddr = "192.168.1.10:12345"
sc.exec()
Convey("Should init context with user info", func() {
So(sc.context.IsSignedIn, ShouldBeTrue)
So(sc.context.UserId, ShouldEqual, 33)
So(sc.context.OrgId, ShouldEqual, 4)
})
})
middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is within trusted CIDR block", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
setting.AuthProxyHeaderProperty = "username"
setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120"
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
return nil
})
bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
cmd.Result = &m.User{Id: 33}
return nil
})
sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.req.RemoteAddr = "[2001::23]:12345"
sc.exec()
Convey("Should init context with user info", func() {
So(sc.context.IsSignedIn, ShouldBeTrue)
So(sc.context.UserId, ShouldEqual, 33)
So(sc.context.OrgId, ShouldEqual, 4)
})
})
middlewareScenario("When session exists for previous user, create a new session", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
+13 -9
View File
@@ -8,14 +8,15 @@ import (
// DashboardSnapshot model
type DashboardSnapshot struct {
Id int64
Name string
Key string
DeleteKey string
OrgId int64
UserId int64
External bool
ExternalUrl string
Id int64
Name string
Key string
DeleteKey string
OrgId int64
UserId int64
External bool
ExternalUrl string
ExternalDeleteUrl string
Expires time.Time
Created time.Time
@@ -48,7 +49,10 @@ type CreateDashboardSnapshotCommand struct {
Expires int64 `json:"expires"`
// these are passed when storing an external snapshot ref
External bool `json:"external"`
External bool `json:"external"`
ExternalUrl string `json:"-"`
ExternalDeleteUrl string `json:"-"`
Key string `json:"key"`
DeleteKey string `json:"deleteKey"`
-12
View File
@@ -3,10 +3,8 @@ package plugins
import (
"context"
"encoding/json"
"os"
"os/exec"
"path"
"path/filepath"
"time"
"github.com/grafana/grafana-plugin-model/go/datasource"
@@ -29,7 +27,6 @@ type DataSourcePlugin struct {
QueryOptions map[string]bool `json:"queryOptions,omitempty"`
BuiltIn bool `json:"builtIn,omitempty"`
Mixed bool `json:"mixed,omitempty"`
HasQueryHelp bool `json:"hasQueryHelp,omitempty"`
Routes []*AppPluginRoute `json:"routes"`
Backend bool `json:"backend,omitempty"`
@@ -48,15 +45,6 @@ func (p *DataSourcePlugin) Load(decoder *json.Decoder, pluginDir string) error {
return err
}
// look for help markdown
helpPath := filepath.Join(p.PluginDir, "QUERY_HELP.md")
if _, err := os.Stat(helpPath); os.IsNotExist(err) {
helpPath = filepath.Join(p.PluginDir, "query_help.md")
}
if _, err := os.Stat(helpPath); err == nil {
p.HasQueryHelp = true
}
DataSources[p.Id] = p
return nil
}
+4 -2
View File
@@ -166,7 +166,7 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
var result notifierStateSlice
for _, notification := range query.Result {
not, err := n.createNotifierFor(notification)
not, err := InitNotifier(notification)
if err != nil {
n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err)
continue
@@ -195,7 +195,8 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
return result, nil
}
func (n *notificationService) createNotifierFor(model *m.AlertNotification) (Notifier, error) {
// InitNotifier instantiate a new notifier based on the model
func InitNotifier(model *m.AlertNotification) (Notifier, error) {
notifierPlugin, found := notifierFactories[model.Type]
if !found {
return nil, errors.New("Unsupported notification type")
@@ -208,6 +209,7 @@ type NotifierFactory func(notification *m.AlertNotification) (Notifier, error)
var notifierFactories = make(map[string]*NotifierPlugin)
// RegisterNotifier register an notifier
func RegisterNotifier(plugin *NotifierPlugin) {
notifierFactories[plugin.Type] = plugin
}
+1 -1
View File
@@ -32,7 +32,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
Settings: cmd.Settings,
}
notifiers, err := notifier.createNotifierFor(model)
notifiers, err := InitNotifier(model)
if err != nil {
log.Error2("Failed to create notifier", "error", err.Error())
@@ -175,7 +175,9 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO,
dto.User = &models.SignedInUser{
UserId: 0,
OrgRole: models.ROLE_ADMIN,
OrgId: dto.OrgId,
}
cmd, err := dr.buildSaveDashboardCommand(dto, true, false)
if err != nil {
return nil, err
+5 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
@@ -69,11 +70,14 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook *
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 == 2 {
// flushing the body enables the transport to reuse the same connection
io.Copy(ioutil.Discard, resp.Body)
return nil
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
+27 -12
View File
@@ -29,18 +29,22 @@ import (
// MysqlStore represents a mysql session store implementation.
type MysqlStore struct {
c *sql.DB
sid string
lock sync.RWMutex
data map[interface{}]interface{}
c *sql.DB
sid string
lock sync.RWMutex
data map[interface{}]interface{}
expiry int64
dirty bool
}
// NewMysqlStore creates and returns a mysql session store.
func NewMysqlStore(c *sql.DB, sid string, kv map[interface{}]interface{}) *MysqlStore {
func NewMysqlStore(c *sql.DB, sid string, kv map[interface{}]interface{}, expiry int64) *MysqlStore {
return &MysqlStore{
c: c,
sid: sid,
data: kv,
c: c,
sid: sid,
data: kv,
expiry: expiry,
dirty: false,
}
}
@@ -50,6 +54,7 @@ func (s *MysqlStore) Set(key, val interface{}) error {
defer s.lock.Unlock()
s.data[key] = val
s.dirty = true
return nil
}
@@ -67,6 +72,7 @@ func (s *MysqlStore) Delete(key interface{}) error {
defer s.lock.Unlock()
delete(s.data, key)
s.dirty = true
return nil
}
@@ -77,13 +83,20 @@ func (s *MysqlStore) ID() string {
// Release releases resource and save data to provider.
func (s *MysqlStore) Release() error {
newExpiry := time.Now().Unix()
if !s.dirty && (s.expiry+60) >= newExpiry {
return nil
}
data, err := session.EncodeGob(s.data)
if err != nil {
return err
}
_, err = s.c.Exec("UPDATE session SET data=?, expiry=? WHERE `key`=?",
data, time.Now().Unix(), s.sid)
data, newExpiry, s.sid)
s.dirty = false
s.expiry = newExpiry
return err
}
@@ -93,6 +106,7 @@ func (s *MysqlStore) Flush() error {
defer s.lock.Unlock()
s.data = make(map[interface{}]interface{})
s.dirty = true
return nil
}
@@ -117,11 +131,12 @@ func (p *MysqlProvider) Init(expire int64, connStr string) (err error) {
// Read returns raw session store by session ID.
func (p *MysqlProvider) Read(sid string) (session.RawStore, error) {
expiry := time.Now().Unix()
var data []byte
err := p.c.QueryRow("SELECT data FROM session WHERE `key`=?", sid).Scan(&data)
err := p.c.QueryRow("SELECT data,expiry FROM session WHERE `key`=?", sid).Scan(&data, &expiry)
if err == sql.ErrNoRows {
_, err = p.c.Exec("INSERT INTO session(`key`,data,expiry) VALUES(?,?,?)",
sid, "", time.Now().Unix())
sid, "", expiry)
}
if err != nil {
return nil, err
@@ -137,7 +152,7 @@ func (p *MysqlProvider) Read(sid string) (session.RawStore, error) {
}
}
return NewMysqlStore(p.c, sid, kv), nil
return NewMysqlStore(p.c, sid, kv, expiry), nil
}
// Exist returns true if session with given ID exists.
+12 -10
View File
@@ -47,16 +47,18 @@ func CreateDashboardSnapshot(cmd *m.CreateDashboardSnapshotCommand) error {
}
snapshot := &m.DashboardSnapshot{
Name: cmd.Name,
Key: cmd.Key,
DeleteKey: cmd.DeleteKey,
OrgId: cmd.OrgId,
UserId: cmd.UserId,
External: cmd.External,
Dashboard: cmd.Dashboard,
Expires: expires,
Created: time.Now(),
Updated: time.Now(),
Name: cmd.Name,
Key: cmd.Key,
DeleteKey: cmd.DeleteKey,
OrgId: cmd.OrgId,
UserId: cmd.UserId,
External: cmd.External,
ExternalUrl: cmd.ExternalUrl,
ExternalDeleteUrl: cmd.ExternalDeleteUrl,
Dashboard: cmd.Dashboard,
Expires: expires,
Created: time.Now(),
Updated: time.Now(),
}
_, err := sess.Insert(snapshot)
+2 -2
View File
@@ -53,14 +53,14 @@ func GetDataSourceByName(query *m.GetDataSourceByNameQuery) error {
}
func GetDataSources(query *m.GetDataSourcesQuery) error {
sess := x.Limit(1000, 0).Where("org_id=?", query.OrgId).Asc("name")
sess := x.Limit(5000, 0).Where("org_id=?", query.OrgId).Asc("name")
query.Result = make([]*m.DataSource, 0)
return sess.Find(&query.Result)
}
func GetAllDataSources(query *m.GetAllDataSourcesQuery) error {
sess := x.Limit(1000, 0).Asc("name")
sess := x.Limit(5000, 0).Asc("name")
query.Result = make([]*m.DataSource, 0)
return sess.Find(&query.Result)
@@ -60,4 +60,8 @@ func addDashboardSnapshotMigrations(mg *Migrator) {
{Name: "external_url", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "dashboard", Type: DB_MediumText, Nullable: false},
}))
mg.AddMigration("Add column external_delete_url to dashboard_snapshots table", NewAddColumnMigration(snapshotV5, &Column{
Name: "external_delete_url", Type: DB_NVarchar, Length: 255, Nullable: true,
}))
}
+38 -3
View File
@@ -2,12 +2,47 @@ package migrator
type MigrationCondition interface {
Sql(dialect Dialect) (string, []interface{})
IsFulfilled(results []map[string][]byte) bool
}
type IfTableExistsCondition struct {
type ExistsMigrationCondition struct{}
func (c *ExistsMigrationCondition) IsFulfilled(results []map[string][]byte) bool {
return len(results) >= 1
}
type NotExistsMigrationCondition struct{}
func (c *NotExistsMigrationCondition) IsFulfilled(results []map[string][]byte) bool {
return len(results) == 0
}
type IfIndexExistsCondition struct {
ExistsMigrationCondition
TableName string
IndexName string
}
func (c *IfTableExistsCondition) Sql(dialect Dialect) (string, []interface{}) {
return dialect.TableCheckSql(c.TableName)
func (c *IfIndexExistsCondition) Sql(dialect Dialect) (string, []interface{}) {
return dialect.IndexCheckSql(c.TableName, c.IndexName)
}
type IfIndexNotExistsCondition struct {
NotExistsMigrationCondition
TableName string
IndexName string
}
func (c *IfIndexNotExistsCondition) Sql(dialect Dialect) (string, []interface{}) {
return dialect.IndexCheckSql(c.TableName, c.IndexName)
}
type IfColumnNotExistsCondition struct {
NotExistsMigrationCondition
TableName string
ColumnName string
}
func (c *IfColumnNotExistsCondition) Sql(dialect Dialect) (string, []interface{}) {
return dialect.ColumnCheckSql(c.TableName, c.ColumnName)
}
+7 -1
View File
@@ -29,10 +29,12 @@ type Dialect interface {
DropTable(tableName string) string
DropIndexSql(tableName string, index *Index) string
TableCheckSql(tableName string) (string, []interface{})
RenameTable(oldName string, newName string) string
UpdateTableSql(tableName string, columns []*Column) string
IndexCheckSql(tableName, indexName string) (string, []interface{})
ColumnCheckSql(tableName, columnName string) (string, []interface{})
ColString(*Column) string
ColStringNoPk(*Column) string
@@ -182,6 +184,10 @@ func (db *BaseDialect) RenameTable(oldName string, newName string) string {
return fmt.Sprintf("ALTER TABLE %s RENAME TO %s", quote(oldName), quote(newName))
}
func (db *BaseDialect) ColumnCheckSql(tableName, columnName string) (string, []interface{}) {
return "", nil
}
func (db *BaseDialect) DropIndexSql(tableName string, index *Index) string {
quote := db.dialect.Quote
name := index.XName(tableName)
+9 -13
View File
@@ -85,7 +85,9 @@ type AddColumnMigration struct {
}
func NewAddColumnMigration(table Table, col *Column) *AddColumnMigration {
return &AddColumnMigration{tableName: table.Name, column: col}
m := &AddColumnMigration{tableName: table.Name, column: col}
m.Condition = &IfColumnNotExistsCondition{TableName: table.Name, ColumnName: col.Name}
return m
}
func (m *AddColumnMigration) Table(tableName string) *AddColumnMigration {
@@ -109,7 +111,9 @@ type AddIndexMigration struct {
}
func NewAddIndexMigration(table Table, index *Index) *AddIndexMigration {
return &AddIndexMigration{tableName: table.Name, index: index}
m := &AddIndexMigration{tableName: table.Name, index: index}
m.Condition = &IfIndexNotExistsCondition{TableName: table.Name, IndexName: index.XName(table.Name)}
return m
}
func (m *AddIndexMigration) Table(tableName string) *AddIndexMigration {
@@ -128,7 +132,9 @@ type DropIndexMigration struct {
}
func NewDropIndexMigration(table Table, index *Index) *DropIndexMigration {
return &DropIndexMigration{tableName: table.Name, index: index}
m := &DropIndexMigration{tableName: table.Name, index: index}
m.Condition = &IfIndexExistsCondition{TableName: table.Name, IndexName: index.XName(table.Name)}
return m
}
func (m *DropIndexMigration) Sql(dialect Dialect) string {
@@ -179,11 +185,6 @@ func NewRenameTableMigration(oldName string, newName string) *RenameTableMigrati
return &RenameTableMigration{oldName: oldName, newName: newName}
}
func (m *RenameTableMigration) IfTableExists(tableName string) *RenameTableMigration {
m.Condition = &IfTableExistsCondition{TableName: tableName}
return m
}
func (m *RenameTableMigration) Rename(oldName string, newName string) *RenameTableMigration {
m.oldName = oldName
m.newName = newName
@@ -212,11 +213,6 @@ func NewCopyTableDataMigration(targetTable string, sourceTable string, colMap ma
return m
}
func (m *CopyTableDataMigration) IfTableExists(tableName string) *CopyTableDataMigration {
m.Condition = &IfTableExistsCondition{TableName: tableName}
return m
}
func (m *CopyTableDataMigration) Sql(d Dialect) string {
return d.CopyTableData(m.sourceTable, m.targetTable, m.sourceCols, m.targetCols)
}
+17 -7
View File
@@ -94,8 +94,6 @@ func (mg *Migrator) Start() error {
Timestamp: time.Now(),
}
mg.Logger.Debug("Executing", "sql", sql)
err := mg.inTransaction(func(sess *xorm.Session) error {
err := mg.exec(m, sess)
if err != nil {
@@ -123,18 +121,30 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error {
condition := m.GetCondition()
if condition != nil {
sql, args := condition.Sql(mg.Dialect)
results, err := sess.SQL(sql).Query(args...)
if err != nil || len(results) == 0 {
mg.Logger.Debug("Skipping migration condition not fulfilled", "id", m.Id())
return sess.Rollback()
if sql != "" {
mg.Logger.Debug("Executing migration condition sql", "id", m.Id(), "sql", sql, "args", args)
results, err := sess.SQL(sql, args...).Query()
if err != nil {
mg.Logger.Error("Executing migration condition failed", "id", m.Id(), "error", err)
return err
}
if !condition.IsFulfilled(results) {
mg.Logger.Warn("Skipping migration: Already executed, but not recorded in migration log", "id", m.Id())
return nil
}
}
}
var err error
if codeMigration, ok := m.(CodeMigration); ok {
mg.Logger.Debug("Executing code migration", "id", m.Id())
err = codeMigration.Exec(sess, mg)
} else {
_, err = sess.Exec(m.Sql(mg.Dialect))
sql := m.Sql(mg.Dialect)
mg.Logger.Debug("Executing sql migration", "id", m.Id(), "sql", sql)
_, err = sess.Exec(sql)
}
if err != nil {
@@ -90,12 +90,6 @@ func (db *Mysql) SqlType(c *Column) string {
return res
}
func (db *Mysql) TableCheckSql(tableName string) (string, []interface{}) {
args := []interface{}{"grafana", tableName}
sql := "SELECT `TABLE_NAME` from `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_SCHEMA`=? and `TABLE_NAME`=?"
return sql, args
}
func (db *Mysql) UpdateTableSql(tableName string, columns []*Column) string {
var statements = []string{}
@@ -108,6 +102,18 @@ func (db *Mysql) UpdateTableSql(tableName string, columns []*Column) string {
return "ALTER TABLE " + db.Quote(tableName) + " " + strings.Join(statements, ", ") + ";"
}
func (db *Mysql) IndexCheckSql(tableName, indexName string) (string, []interface{}) {
args := []interface{}{tableName, indexName}
sql := "SELECT 1 FROM " + db.Quote("INFORMATION_SCHEMA") + "." + db.Quote("STATISTICS") + " WHERE " + db.Quote("TABLE_SCHEMA") + " = DATABASE() AND " + db.Quote("TABLE_NAME") + "=? AND " + db.Quote("INDEX_NAME") + "=?"
return sql, args
}
func (db *Mysql) ColumnCheckSql(tableName, columnName string) (string, []interface{}) {
args := []interface{}{tableName, columnName}
sql := "SELECT 1 FROM " + db.Quote("INFORMATION_SCHEMA") + "." + db.Quote("COLUMNS") + " WHERE " + db.Quote("TABLE_SCHEMA") + " = DATABASE() AND " + db.Quote("TABLE_NAME") + "=? AND " + db.Quote("COLUMN_NAME") + "=?"
return sql, args
}
func (db *Mysql) CleanDB() error {
tables, _ := db.engine.DBMetas()
sess := db.engine.NewSession()
@@ -101,9 +101,9 @@ func (db *Postgres) SqlType(c *Column) string {
return res
}
func (db *Postgres) TableCheckSql(tableName string) (string, []interface{}) {
args := []interface{}{"grafana", tableName}
sql := "SELECT table_name FROM information_schema.tables WHERE table_schema=? and table_name=?"
func (db *Postgres) IndexCheckSql(tableName, indexName string) (string, []interface{}) {
args := []interface{}{tableName, indexName}
sql := "SELECT 1 FROM " + db.Quote("pg_indexes") + " WHERE" + db.Quote("tablename") + "=? AND " + db.Quote("indexname") + "=?"
return sql, args
}
@@ -68,9 +68,10 @@ func (db *Sqlite3) SqlType(c *Column) string {
}
}
func (db *Sqlite3) TableCheckSql(tableName string) (string, []interface{}) {
args := []interface{}{tableName}
return "SELECT name FROM sqlite_master WHERE type='table' and name = ?", args
func (db *Sqlite3) IndexCheckSql(tableName, indexName string) (string, []interface{}) {
args := []interface{}{tableName, indexName}
sql := "SELECT 1 FROM " + db.Quote("sqlite_master") + " WHERE " + db.Quote("type") + "='index' AND " + db.Quote("tbl_name") + "=? AND " + db.Quote("name") + "=?"
return sql, args
}
func (db *Sqlite3) DropIndexSql(tableName string, index *Index) string {
+19 -10
View File
@@ -243,7 +243,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
ss.dbCfg.Path = filepath.Join(ss.Cfg.DataPath, ss.dbCfg.Path)
}
os.MkdirAll(path.Dir(ss.dbCfg.Path), os.ModePerm)
cnnstr = "file:" + ss.dbCfg.Path + "?cache=shared&mode=rwc"
cnnstr = fmt.Sprintf("file:%s?cache=%s&mode=rwc", ss.dbCfg.Path, ss.dbCfg.CacheMode)
default:
return "", fmt.Errorf("Unknown database type: %s", ss.dbCfg.Type)
}
@@ -319,6 +319,8 @@ func (ss *SqlStore) readConfig() {
ss.dbCfg.ClientCertPath = sec.Key("client_cert_path").String()
ss.dbCfg.ServerCertName = sec.Key("server_cert_name").String()
ss.dbCfg.Path = sec.Key("path").MustString("data/grafana.db")
ss.dbCfg.CacheMode = sec.Key("cache_mode").MustString("private")
}
func InitTestDB(t *testing.T) *SqlStore {
@@ -391,13 +393,20 @@ func IsTestDbPostgres() bool {
}
type DatabaseConfig struct {
Type, Host, Name, User, Pwd, Path, SslMode string
CaCertPath string
ClientKeyPath string
ClientCertPath string
ServerCertName string
ConnectionString string
MaxOpenConn int
MaxIdleConn int
ConnMaxLifetime int
Type string
Host string
Name string
User string
Pwd string
Path string
SslMode string
CaCertPath string
ClientKeyPath string
ClientCertPath string
ServerCertName string
ConnectionString string
MaxOpenConn int
MaxIdleConn int
ConnMaxLifetime int
CacheMode string
}
+6 -1
View File
@@ -345,8 +345,12 @@ func GetUserOrgList(query *m.GetUserOrgListQuery) error {
return err
}
func newSignedInUserCacheKey(orgID, userID int64) string {
return fmt.Sprintf("signed-in-user-%d-%d", userID, orgID)
}
func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) error {
cacheKey := fmt.Sprintf("signed-in-user-%d-%d", query.UserId, query.OrgId)
cacheKey := newSignedInUserCacheKey(query.OrgId, query.UserId)
if cached, found := ss.CacheService.Get(cacheKey); found {
query.Result = cached.(*m.SignedInUser)
return nil
@@ -357,6 +361,7 @@ func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) erro
return err
}
cacheKey = newSignedInUserCacheKey(query.Result.OrgId, query.UserId)
ss.CacheService.Set(cacheKey, query.Result, time.Second*5)
return nil
}
+22 -1
View File
@@ -13,7 +13,7 @@ import (
func TestUserDataAccess(t *testing.T) {
Convey("Testing DB", t, func() {
InitTestDB(t)
ss := InitTestDB(t)
Convey("Creating a user", func() {
cmd := &m.CreateUserCommand{
@@ -153,6 +153,27 @@ func TestUserDataAccess(t *testing.T) {
So(prefsQuery.Result.UserId, ShouldEqual, 0)
})
})
Convey("when retreiving signed in user for orgId=0 result should return active org id", func() {
ss.CacheService.Flush()
query := &m.GetSignedInUserQuery{OrgId: users[1].OrgId, UserId: users[1].Id}
err := ss.GetSignedInUserWithCache(query)
So(err, ShouldBeNil)
So(query.Result, ShouldNotBeNil)
So(query.OrgId, ShouldEqual, users[1].OrgId)
err = SetUsingOrg(&m.SetUsingOrgCommand{UserId: users[1].Id, OrgId: users[0].OrgId})
So(err, ShouldBeNil)
query = &m.GetSignedInUserQuery{OrgId: 0, UserId: users[1].Id}
err = ss.GetSignedInUserWithCache(query)
So(err, ShouldBeNil)
So(query.Result, ShouldNotBeNil)
So(query.Result.OrgId, ShouldEqual, users[0].OrgId)
cacheKey := newSignedInUserCacheKey(query.Result.OrgId, query.UserId)
_, found := ss.CacheService.Get(cacheKey)
So(found, ShouldBeTrue)
})
})
})
+15 -14
View File
@@ -1,20 +1,21 @@
package setting
type OAuthInfo struct {
ClientId, ClientSecret string
Scopes []string
AuthUrl, TokenUrl string
Enabled bool
EmailAttributeName string
AllowedDomains []string
HostedDomain string
ApiUrl string
AllowSignup bool
Name string
TlsClientCert string
TlsClientKey string
TlsClientCa string
TlsSkipVerify bool
ClientId, ClientSecret string
Scopes []string
AuthUrl, TokenUrl string
Enabled bool
EmailAttributeName string
AllowedDomains []string
HostedDomain string
ApiUrl string
AllowSignup bool
Name string
TlsClientCert string
TlsClientKey string
TlsClientCa string
TlsSkipVerify bool
SendClientCredentialsViaPost bool
}
type OAuther struct {
+22 -16
View File
@@ -63,28 +63,34 @@ func NewOAuthService() {
for _, name := range allOauthes {
sec := setting.Raw.Section("auth." + name)
info := &setting.OAuthInfo{
ClientId: sec.Key("client_id").String(),
ClientSecret: sec.Key("client_secret").String(),
Scopes: util.SplitString(sec.Key("scopes").String()),
AuthUrl: sec.Key("auth_url").String(),
TokenUrl: sec.Key("token_url").String(),
ApiUrl: sec.Key("api_url").String(),
Enabled: sec.Key("enabled").MustBool(),
EmailAttributeName: sec.Key("email_attribute_name").String(),
AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()),
HostedDomain: sec.Key("hosted_domain").String(),
AllowSignup: sec.Key("allow_sign_up").MustBool(),
Name: sec.Key("name").MustString(name),
TlsClientCert: sec.Key("tls_client_cert").String(),
TlsClientKey: sec.Key("tls_client_key").String(),
TlsClientCa: sec.Key("tls_client_ca").String(),
TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(),
ClientId: sec.Key("client_id").String(),
ClientSecret: sec.Key("client_secret").String(),
Scopes: util.SplitString(sec.Key("scopes").String()),
AuthUrl: sec.Key("auth_url").String(),
TokenUrl: sec.Key("token_url").String(),
ApiUrl: sec.Key("api_url").String(),
Enabled: sec.Key("enabled").MustBool(),
EmailAttributeName: sec.Key("email_attribute_name").String(),
AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()),
HostedDomain: sec.Key("hosted_domain").String(),
AllowSignup: sec.Key("allow_sign_up").MustBool(),
Name: sec.Key("name").MustString(name),
TlsClientCert: sec.Key("tls_client_cert").String(),
TlsClientKey: sec.Key("tls_client_key").String(),
TlsClientCa: sec.Key("tls_client_ca").String(),
TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(),
SendClientCredentialsViaPost: sec.Key("send_client_credentials_via_post").MustBool(),
}
if !info.Enabled {
continue
}
// handle the clients that do not properly support Basic auth headers and require passing client_id/client_secret via POST payload
if info.SendClientCredentialsViaPost {
oauth2.RegisterBrokenAuthHeaderProvider(info.TokenUrl)
}
if name == "grafananet" {
name = grafanaCom
}
+2
View File
@@ -16,6 +16,7 @@ func (qp *InfluxdbQueryParser) Parse(model *simplejson.Json, dsInfo *models.Data
rawQuery := model.Get("query").MustString("")
useRawQuery := model.Get("rawQuery").MustBool(false)
alias := model.Get("alias").MustString("")
tz := model.Get("tz").MustString("")
measurement := model.Get("measurement").MustString("")
@@ -55,6 +56,7 @@ func (qp *InfluxdbQueryParser) Parse(model *simplejson.Json, dsInfo *models.Data
Interval: parsedInterval,
Alias: alias,
UseRawQuery: useRawQuery,
Tz: tz,
}, nil
}
+2
View File
@@ -41,6 +41,7 @@ func TestInfluxdbQueryParser(t *testing.T) {
}
],
"measurement": "logins.count",
"tz": "Europe/Paris",
"policy": "default",
"refId": "B",
"resultFormat": "time_series",
@@ -115,6 +116,7 @@ func TestInfluxdbQueryParser(t *testing.T) {
So(len(res.GroupBy), ShouldEqual, 3)
So(len(res.Selects), ShouldEqual, 3)
So(len(res.Tags), ShouldEqual, 2)
So(res.Tz, ShouldEqual, "Europe/Paris")
So(res.Interval, ShouldEqual, time.Second*20)
So(res.Alias, ShouldEqual, "serie alias")
})
+1
View File
@@ -13,6 +13,7 @@ type Query struct {
UseRawQuery bool
Alias string
Interval time.Duration
Tz string
}
type Tag struct {
+10
View File
@@ -26,6 +26,7 @@ func (query *Query) Build(queryContext *tsdb.TsdbQuery) (string, error) {
res += query.renderWhereClause()
res += query.renderTimeFilter(queryContext)
res += query.renderGroupBy(queryContext)
res += query.renderTz()
}
calculator := tsdb.NewIntervalCalculator(&tsdb.IntervalOptions{})
@@ -154,3 +155,12 @@ func (query *Query) renderGroupBy(queryContext *tsdb.TsdbQuery) string {
return groupBy
}
func (query *Query) renderTz() string {
tz := query.Tz
if tz == "" {
return ""
} else {
return fmt.Sprintf(" tz('%s')", tz)
}
}
+14
View File
@@ -47,6 +47,20 @@ func TestInfluxdbQueryBuilder(t *testing.T) {
So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "policy"."cpu" WHERE time > now() - 5m GROUP BY time(10s) fill(null)`)
})
Convey("can build query with tz", func() {
query := &Query{
Selects: []*Select{{*qp1, *qp2}},
Measurement: "cpu",
GroupBy: []*QueryPart{groupBy1},
Tz: "Europe/Paris",
Interval: time.Second * 5,
}
rawQuery, err := query.Build(queryContext)
So(err, ShouldBeNil)
So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "cpu" WHERE time > now() - 5m GROUP BY time(5s) tz('Europe/Paris')`)
})
Convey("can build query with group bys", func() {
query := &Query{
Selects: []*Select{{*qp1, *qp2}},
+10
View File
@@ -5,6 +5,7 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import { SearchResult } from './components/search/SearchResult';
import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
export function registerAngularDirectives() {
@@ -19,4 +20,13 @@ export function registerAngularDirectives() {
['onChange', { watchDepth: 'reference' }],
['tagOptions', { watchDepth: 'reference' }],
]);
react2AngularDirective('metricSelect', MetricSelect, [
'options',
'onChange',
'value',
'isSearchable',
'className',
'placeholder',
['variables', { watchDepth: 'reference' }],
]);
}
@@ -1,43 +0,0 @@
import React, { PureComponent, ReactNode, ReactElement } from 'react';
import { Label } from './Label';
import { uniqueId } from 'lodash';
interface Props {
label?: ReactNode;
labelClassName?: string;
id?: string;
children: ReactElement<any>;
}
export class Element extends PureComponent<Props> {
elementId: string = this.props.id || uniqueId('form-element-');
get elementLabel() {
const { label, labelClassName } = this.props;
if (label) {
return (
<Label htmlFor={this.elementId} className={labelClassName}>
{label}
</Label>
);
}
return null;
}
get children() {
const { children } = this.props;
return React.cloneElement(children, { id: this.elementId });
}
render() {
return (
<div className="our-custom-wrapper-class">
{this.elementLabel}
{this.children}
</div>
);
}
}
-19
View File
@@ -1,19 +0,0 @@
import React, { PureComponent, ReactNode } from 'react';
interface Props {
children: ReactNode;
htmlFor?: string;
className?: string;
}
export class Label extends PureComponent<Props> {
render() {
const { children, htmlFor, className } = this.props;
return (
<label className={`custom-label-class ${className || ''}`} htmlFor={htmlFor}>
{children}
</label>
);
}
}
-2
View File
@@ -1,3 +1 @@
export { Element } from './Element';
export { Input } from './Input';
export { Label } from './Label';
@@ -0,0 +1,83 @@
import React, { PureComponent } from 'react';
import Remarkable from 'remarkable';
import { getBackendSrv } from '../../services/backend_srv';
interface Props {
plugin: {
name: string;
id: string;
};
type: string;
}
interface State {
isError: boolean;
isLoading: boolean;
help: string;
}
export class PluginHelp extends PureComponent<Props, State> {
state = {
isError: false,
isLoading: false,
help: '',
};
componentDidMount(): void {
this.loadHelp();
}
constructPlaceholderInfo() {
return 'No plugin help or readme markdown file was found';
}
loadHelp = () => {
const { plugin, type } = this.props;
this.setState({ isLoading: true });
getBackendSrv()
.get(`/api/plugins/${plugin.id}/markdown/${type}`)
.then(response => {
const markdown = new Remarkable();
const helpHtml = markdown.render(response);
if (response === '' && type === 'help') {
this.setState({
isError: false,
isLoading: false,
help: this.constructPlaceholderInfo(),
});
} else {
this.setState({
isError: false,
isLoading: false,
help: helpHtml,
});
}
})
.catch(() => {
this.setState({
isError: true,
isLoading: false,
});
});
};
render() {
const { type } = this.props;
const { isError, isLoading, help } = this.state;
if (isLoading) {
return <h2>Loading help...</h2>;
}
if (isError) {
return <h3>'Error occurred when loading help'</h3>;
}
if (type === 'panel_help' && help === '') {
}
return <div className="markdown-html" dangerouslySetInnerHTML={{ __html: help }} />;
}
}
@@ -0,0 +1,90 @@
import React from 'react';
import _ from 'lodash';
import Select from './Select';
import { Variable } from 'app/types/templates';
import { SelectOptionItem } from './Select';
export interface Props {
onChange: (value: string) => void;
options: SelectOptionItem[];
isSearchable: boolean;
value: string;
placeholder?: string;
className?: string;
variables?: Variable[];
}
interface State {
options: any[];
}
export class MetricSelect extends React.Component<Props, State> {
static defaultProps = {
variables: [],
options: [],
isSearchable: true,
};
constructor(props) {
super(props);
this.state = { options: [] };
}
componentDidMount() {
this.setState({ options: this.buildOptions(this.props) });
}
componentWillReceiveProps(nextProps: Props) {
if (nextProps.options.length > 0 || nextProps.variables.length) {
this.setState({ options: this.buildOptions(nextProps) });
}
}
shouldComponentUpdate(nextProps: Props) {
const nextOptions = this.buildOptions(nextProps);
return nextProps.value !== this.props.value || !_.isEqual(nextOptions, this.state.options);
}
buildOptions({ variables = [], options }) {
return variables.length > 0 ? [this.getVariablesGroup(), ...options] : options;
}
getVariablesGroup() {
return {
label: 'Template Variables',
options: this.props.variables.map(v => ({
label: `$${v.name}`,
value: `$${v.name}`,
})),
};
}
getSelectedOption() {
const { options } = this.state;
const allOptions = options.every(o => o.options) ? _.flatten(options.map(o => o.options)) : options;
return allOptions.find(option => option.value === this.props.value);
}
render() {
const { placeholder, className, isSearchable, onChange } = this.props;
const { options } = this.state;
const selectedOption = this.getSelectedOption();
return (
<Select
className={className}
isMulti={false}
isClearable={false}
backspaceRemovesValue={false}
onChange={item => onChange(item.value)}
options={options}
isSearchable={isSearchable}
maxMenuHeight={500}
placeholder={placeholder}
noOptionsMessage={() => 'No options found'}
value={selectedOption}
/>
);
}
}
@@ -3,7 +3,7 @@ import Select from './Select';
import kbn from 'app/core/utils/kbn';
interface Props {
onChange: (item: any) => {} | void;
onChange: (item: any) => void;
defaultValue?: string;
width?: number;
}
@@ -52,7 +52,11 @@ export const ToggleButton: SFC<ToggleButtonProps> = ({
);
if (tooltip) {
return <Tooltip content={tooltip}>{button}</Tooltip>;
return (
<Tooltip content={tooltip} placement="bottom">
{button}
</Tooltip>
);
} else {
return button;
}
@@ -50,7 +50,7 @@ const DEFAULT_THEME_LIGHT = 'ace/theme/textmate';
const DEFAULT_MODE = 'text';
const DEFAULT_MAX_LINES = 10;
const DEFAULT_TAB_SIZE = 2;
const DEFAULT_BEHAVIOURS = true;
const DEFAULT_BEHAVIORS = true;
const DEFAULT_SNIPPETS = true;
const editorTemplate = `<div></div>`;
@@ -61,7 +61,7 @@ function link(scope, elem, attrs) {
const maxLines = attrs.maxLines || DEFAULT_MAX_LINES;
const showGutter = attrs.showGutter !== undefined;
const tabSize = attrs.tabSize || DEFAULT_TAB_SIZE;
const behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIOURS;
const behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIORS;
const snippetsEnabled = attrs.snippetsEnabled ? attrs.snippetsEnabled === 'true' : DEFAULT_SNIPPETS;
// Initialize editor
@@ -1,5 +1,5 @@
// Based on work https://github.com/mohsen1/json-formatter-js
// Licence MIT, Copyright (c) 2015 Mohsen Azimi
// License MIT, Copyright (c) 2015 Mohsen Azimi
/*
* Escapes `"` characters from string
@@ -1,5 +1,5 @@
// Based on work https://github.com/mohsen1/json-formatter-js
// Licence MIT, Copyright (c) 2015 Mohsen Azimi
// License MIT, Copyright (c) 2015 Mohsen Azimi
import { isObject, getObjectName, getType, getValuePreview, cssClass, createElement } from './helpers';
@@ -36,7 +36,7 @@ describe('Render', () => {
expect(wrapper).toMatchSnapshot();
});
it('should render organisation switcher', () => {
it('should render organization switcher', () => {
const wrapper = setup({
link: {
showOrgSwitcher: true,
@@ -73,7 +73,7 @@ exports[`Render should render component 1`] = `
</div>
`;
exports[`Render should render organisation switcher 1`] = `
exports[`Render should render organization switcher 1`] = `
<div
className="sidemenu-item dropdown dropup"
>
+2 -2
View File
@@ -69,7 +69,7 @@ function bootstrapTagsinput() {
},
});
select.on('itemAdded', event => {
select.on('itemAdded', (event: any) => {
if (scope.model.indexOf(event.item) === -1) {
scope.model.push(event.item);
if (scope.onTagsUpdated) {
@@ -85,7 +85,7 @@ function bootstrapTagsinput() {
setColor(event.item, tagElement);
});
select.on('itemRemoved', event => {
select.on('itemRemoved', (event: any) => {
const idx = scope.model.indexOf(event.item);
if (idx !== -1) {
scope.model.splice(idx, 1);
+1 -1
View File
@@ -1,7 +1,7 @@
import _ from 'lodash';
import config from 'app/core/config';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
export class LiveSrv {
conn: any;
+12 -1
View File
@@ -2,14 +2,23 @@ import _ from 'lodash';
import { TimeSeries } from 'app/core/core';
import colors, { getThemeColor } from 'app/core/utils/colors';
/**
* Mapping of log level abbreviation to canonical log level.
* Supported levels are reduce to limit color variation.
*/
export enum LogLevel {
emerg = 'critical',
alert = 'critical',
crit = 'critical',
critical = 'critical',
warn = 'warning',
warning = 'warning',
err = 'error',
eror = 'error',
error = 'error',
info = 'info',
notice = 'info',
dbug = 'debug',
debug = 'debug',
trace = 'trace',
unkown = 'unkown',
@@ -81,7 +90,9 @@ export interface LogsStream {
export interface LogsStreamEntry {
line: string;
timestamp: string;
ts: string;
// Legacy, was renamed to ts
timestamp?: string;
}
export interface LogsStreamLabels {
+2 -2
View File
@@ -5,7 +5,7 @@ import { DashboardModel } from 'app/features/dashboard/dashboard_model';
export class BackendSrv {
private inFlightRequests = {};
private HTTP_REQUEST_CANCELLED = -1;
private HTTP_REQUEST_CANCELED = -1;
private noBackendCache: boolean;
/** @ngInject */
@@ -178,7 +178,7 @@ export class BackendSrv {
return response;
})
.catch(err => {
if (err.status === this.HTTP_REQUEST_CANCELLED) {
if (err.status === this.HTTP_REQUEST_CANCELED) {
throw { err, cancelled: true };
}
+2 -2
View File
@@ -31,7 +31,7 @@ export class SearchSrv {
}
private queryForRecentDashboards() {
const dashIds = _.take(impressionSrv.getDashboardOpened(), 5);
const dashIds = _.take(impressionSrv.getDashboardOpened(), 30);
if (dashIds.length === 0) {
return Promise.resolve([]);
}
@@ -70,7 +70,7 @@ export class SearchSrv {
return Promise.resolve();
}
return this.backendSrv.search({ starred: true, limit: 5 }).then(result => {
return this.backendSrv.search({ starred: true, limit: 30 }).then(result => {
if (result.length > 0) {
sections['starred'] = {
title: 'Starred',
+1 -1
View File
@@ -2,7 +2,7 @@ import _ from 'lodash';
import coreModule from 'app/core/core_module';
// This service really just tracks a list of $timeout promises to give us a
// method for cancelling them all when we need to
// method for canceling them all when we need to
export class Timer {
timers = [];
@@ -73,6 +73,7 @@ describe('file_export', () => {
],
rows: [
[123, 'some_string', 1.234, true],
[1000, 'some_string', 1.234567891, true],
[0o765, 'some string with " in the middle', 1e-2, false],
[0o765, 'some string with "" in the middle', 1e-2, false],
[0o765, 'some string with """ in the middle', 1e-2, false],
@@ -89,6 +90,7 @@ describe('file_export', () => {
const expectedText =
'"integer_value";"string_value";"float_value";"boolean_value"\r\n' +
'123;"some_string";1.234;true\r\n' +
'1000;"some_string";1.234567891;true\r\n' +
'501;"some string with "" in the middle";0.01;false\r\n' +
'501;"some string with """" in the middle";0.01;false\r\n' +
'501;"some string with """""" in the middle";0.01;false\r\n' +
+1 -1
View File
@@ -40,7 +40,7 @@ export default class TableModel {
this.rows.sort((a, b) => {
a = a[options.col];
b = b[options.col];
// Sort null or undefined seperately from comparable values
// Sort null or undefined separately from comparable values
return +(a == null) - +(b == null) || +(a > b) || -(a < b);
});
+17 -7
View File
@@ -9,7 +9,8 @@ import { parse as parseDate } from 'app/core/utils/datemath';
import TimeSeries from 'app/core/time_series2';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore';
import { DataQuery, RawTimeRange, IntervalValues, DataSourceApi } from 'app/types/series';
import { DataQuery, DataSourceApi } from 'app/types/series';
import { RawTimeRange, IntervalValues } from '@grafana/ui';
export const DEFAULT_RANGE = {
from: 'now-6h',
@@ -57,12 +58,19 @@ export async function getExploreUrl(
}
}
if (exploreDatasource && exploreDatasource.meta.explore) {
if (panelDatasource) {
const range = timeSrv.timeRangeForUrl();
const state = {
...exploreDatasource.getExploreState(exploreTargets),
range,
};
let state: Partial<ExploreUrlState> = { range };
if (exploreDatasource.getExploreState) {
state = { ...state, ...exploreDatasource.getExploreState(exploreTargets) };
} else {
state = {
...state,
datasource: panelDatasource.name,
queries: exploreTargets.map(t => ({ ...t, datasource: panelDatasource.name })),
};
}
const exploreState = JSON.stringify(state);
url = renderUrl('/explore', { state: exploreState });
}
@@ -151,7 +159,9 @@ export function calculateResultsFromQueryTransactions(
);
const tableResult = mergeTablesIntoModel(
new TableModel(),
...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result)
...queryTransactions
.filter(qt => qt.resultType === 'Table' && qt.done && qt.result && qt.result.columns && qt.result.rows)
.map(qt => qt.result)
);
const logsResult =
datasource && datasource.mergeStreams
+1 -3
View File
@@ -41,10 +41,8 @@ function formatSpecialHeader(useExcelHeader) {
function formatRow(row, addEndRowDelimiter = true) {
let text = '';
for (let i = 0; i < row.length; i += 1) {
if (isBoolean(row[i]) || isNullOrUndefined(row[i])) {
if (isBoolean(row[i]) || isNumber(row[i]) || isNullOrUndefined(row[i])) {
text += row[i];
} else if (isNumber(row[i])) {
text += row[i].toLocaleString();
} else {
text += `${QUOTE}${csvEscaped(htmlUnescaped(htmlDecoded(row[i])))}${QUOTE}`;
}
+32 -9
View File
@@ -484,6 +484,14 @@ kbn.valueFormats.Mbits = kbn.formatBuilders.decimalSIPrefix('bps', 2);
kbn.valueFormats.GBs = kbn.formatBuilders.decimalSIPrefix('Bs', 3);
kbn.valueFormats.Gbits = kbn.formatBuilders.decimalSIPrefix('bps', 3);
// Floating Point Operations per Second
kbn.valueFormats.flops = kbn.formatBuilders.decimalSIPrefix('FLOP/s');
kbn.valueFormats.mflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 2);
kbn.valueFormats.gflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 3);
kbn.valueFormats.tflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 4);
kbn.valueFormats.pflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 5);
kbn.valueFormats.eflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 6);
// Hash Rate
kbn.valueFormats.Hs = kbn.formatBuilders.decimalSIPrefix('H/s');
kbn.valueFormats.KHs = kbn.formatBuilders.decimalSIPrefix('H/s', 1);
@@ -621,6 +629,8 @@ kbn.valueFormats.conmgm3 = kbn.formatBuilders.fixedUnit('mg/m³');
kbn.valueFormats.conmgNm3 = kbn.formatBuilders.fixedUnit('mg/Nm³');
kbn.valueFormats.congm3 = kbn.formatBuilders.fixedUnit('g/m³');
kbn.valueFormats.congNm3 = kbn.formatBuilders.fixedUnit('g/Nm³');
kbn.valueFormats.conmgdL = kbn.formatBuilders.fixedUnit('mg/dL');
kbn.valueFormats.conmmolL = kbn.formatBuilders.fixedUnit('mmol/L');
// Time
kbn.valueFormats.hertz = kbn.formatBuilders.decimalSIPrefix('Hz');
@@ -1019,6 +1029,17 @@ kbn.getUnitFormats = () => {
{ text: 'exahashes/sec', value: 'EHs' },
],
},
{
text: 'computation throughput',
submenu: [
{ text: 'FLOP/s', value: 'flops' },
{ text: 'MFLOP/s', value: 'mflops' },
{ text: 'GFLOP/s', value: 'gflops' },
{ text: 'TFLOP/s', value: 'tflops' },
{ text: 'PFLOP/s', value: 'pflops' },
{ text: 'EFLOP/s', value: 'eflops' },
],
},
{
text: 'throughput',
submenu: [
@@ -1085,7 +1106,7 @@ kbn.getUnitFormats = () => {
{ text: 'Watt (W)', value: 'watt' },
{ text: 'Kilowatt (kW)', value: 'kwatt' },
{ text: 'Milliwatt (mW)', value: 'mwatt' },
{ text: 'Watt per square metre (W/m²)', value: 'Wm2' },
{ text: 'Watt per square meter (W/m²)', value: 'Wm2' },
{ text: 'Volt-ampere (VA)', value: 'voltamp' },
{ text: 'Kilovolt-ampere (kVA)', value: 'kvoltamp' },
{ text: 'Volt-ampere reactive (var)', value: 'voltampreact' },
@@ -1182,14 +1203,16 @@ kbn.getUnitFormats = () => {
submenu: [
{ text: 'parts-per-million (ppm)', value: 'ppm' },
{ text: 'parts-per-billion (ppb)', value: 'conppb' },
{ text: 'nanogram per cubic metre (ng/m³)', value: 'conngm3' },
{ text: 'nanogram per normal cubic metre (ng/Nm³)', value: 'conngNm3' },
{ text: 'microgram per cubic metre (μg/m³)', value: 'conμgm3' },
{ text: 'microgram per normal cubic metre (μg/Nm³)', value: 'conμgNm3' },
{ text: 'milligram per cubic metre (mg/m³)', value: 'conmgm3' },
{ text: 'milligram per normal cubic metre (mg/Nm³)', value: 'conmgNm3' },
{ text: 'gram per cubic metre (g/m³)', value: 'congm3' },
{ text: 'gram per normal cubic metre (g/Nm³)', value: 'congNm3' },
{ text: 'nanogram per cubic meter (ng/m³)', value: 'conngm3' },
{ text: 'nanogram per normal cubic meter (ng/Nm³)', value: 'conngNm3' },
{ text: 'microgram per cubic meter (μg/m³)', value: 'conμgm3' },
{ text: 'microgram per normal cubic meter (μg/Nm³)', value: 'conμgNm3' },
{ text: 'milligram per cubic meter (mg/m³)', value: 'conmgm3' },
{ text: 'milligram per normal cubic meter (mg/Nm³)', value: 'conmgNm3' },
{ text: 'gram per cubic meter (g/m³)', value: 'congm3' },
{ text: 'gram per normal cubic meter (g/Nm³)', value: 'congNm3' },
{ text: 'milligrams per decilitre (mg/dL)', value: 'conmgdL' },
{ text: 'millimoles per litre (mmol/L)', value: 'conmmolL' },
],
},
];
+1 -1
View File
@@ -1,7 +1,7 @@
import _ from 'lodash';
import moment from 'moment';
import { RawTimeRange } from 'app/types/series';
import { RawTimeRange } from '@grafana/ui';
import * as dateMath from './datemath';
@@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
import Highlighter from 'react-highlight-words';
import classNames from 'classnames/bind';
import classNames from 'classnames';
import { AlertRule } from '../../types';
export interface Props {
@@ -23,7 +23,7 @@ class AlertRuleItem extends PureComponent<Props> {
render() {
const { rule, onTogglePause } = this.props;
const stateClass = classNames({
const iconClassName = classNames({
fa: true,
'fa-play': rule.state === 'paused',
'fa-pause': rule.state !== 'paused',
@@ -55,7 +55,7 @@ class AlertRuleItem extends PureComponent<Props> {
title="Pausing an alert rule prevents it from executing"
onClick={onTogglePause}
>
<i className={stateClass} />
<i className={iconClassName} />
</button>
<a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
<i className="icon-gf icon-gf-settings" />
+2 -2
View File
@@ -13,7 +13,7 @@ import ApiKeysAddedModal from './ApiKeysAddedModal';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
import { DeleteButton } from '@grafana/ui';
export interface Props {
navModel: NavModel;
@@ -224,7 +224,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
<td>{key.name}</td>
<td>{key.role}</td>
<td>
<DeleteButton onConfirmDelete={() => this.onDeleteApiKey(key)} />
<DeleteButton onConfirm={() => this.onDeleteApiKey(key)} />
</td>
</tr>
);

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